diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..05df986 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,62 @@ +name: Bug report +description: Report a reproducible FastaGuard problem. +title: "[Bug]: " +labels: [bug] +body: + - type: markdown + attributes: + value: "Please remove private sequence data. A minimal synthetic FASTA reproducer is preferred." + - type: input + id: version + attributes: + label: FastaGuard version + description: Include the output of `fastaguard --version`. + validations: + required: true + - type: input + id: operating-system + attributes: + label: Operating system + description: Include the operating system and version. + validations: + required: true + - type: dropdown + id: installation-method + attributes: + label: Installation method + options: + - Bioconda + - Container image + - Release binary + - Source build + - Other + validations: + required: true + - type: textarea + id: command + attributes: + label: Exact command + description: Paste the complete command and relevant options. + render: shell + validations: + required: true + - type: textarea + id: reproducer + attributes: + label: Minimal FASTA reproducer or safe description + description: Share a minimal synthetic FASTA or describe the input safely. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected result + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual result + description: Include output, error messages, and generated report details. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..90ff4d6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability report + url: https://github.com/ehsanestaji/FastaGuard/security/policy + about: Read the security policy for the private reporting contact. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..0be2ef3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,63 @@ +name: Feature request +description: Suggest a focused improvement to FastaGuard. +title: "[Feature]: " +labels: [enhancement] +body: + - type: markdown + attributes: + value: "Describe the workflow problem and avoid including private sequence data." + - type: input + id: version + attributes: + label: FastaGuard version + description: Include the version you use, if applicable. + validations: + required: true + - type: input + id: operating-system + attributes: + label: Operating system + description: Include the operating system and version, if applicable. + validations: + required: true + - type: dropdown + id: installation-method + attributes: + label: Installation method + options: + - Bioconda + - Container image + - Release binary + - Source build + - Other + validations: + required: true + - type: textarea + id: command + attributes: + label: Exact command + description: Show the current workflow command, if applicable. + render: shell + validations: + required: true + - type: textarea + id: reproducer + attributes: + label: Minimal FASTA reproducer or safe description + description: Provide a minimal synthetic example or safe workflow description. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected result + description: Describe the outcome that would make the workflow work better. + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual result + description: Describe the current behavior or limitation. + validations: + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..d3de704 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,24 @@ +## Scoped change description + + + +## Tests + + + +## Contract impact + +- [ ] No public contract change. +- [ ] Public contract changed; JSON, TSV, HTML, schema, and finding catalog impacts are described above. + +## Documentation impact + +- [ ] No documentation change is needed. +- [ ] Documentation, examples, or release notes were updated. + +## Review checklist + +- [ ] Tests cover changed behavior or explain why tests are not applicable. +- [ ] DCO: every commit includes a `Signed-off-by:` line. +- [ ] Attribution-trace review: the change has no unwanted authorship or tool-provenance traces. +- [ ] The contribution stays within FastaGuard's FASTA-level preflight scope. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7be77c0..3910aaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,32 @@ on: pull_request: jobs: + multiqc-plugin: + name: MultiQC plugin (Python ${{ matrix.python }}, MultiQC ${{ matrix.multiqc }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ["3.10", "3.14"] + multiqc: ["1.28", "1.35"] + + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + + - name: Install Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + + - name: Install Python test dependencies + run: python3 -m pip install --requirement requirements-test.txt + + - name: Test built MultiQC plugin + env: + FASTAGUARD_MULTIQC_VERSION: ${{ matrix.multiqc }} + run: python3 -m pytest -q tests/python/test_multiqc_plugin.py + rust: name: Rust quality gate runs-on: ubuntu-latest @@ -20,6 +46,11 @@ jobs: with: components: rustfmt, clippy + - name: Install Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Cache Cargo uses: Swatinem/rust-cache@v2 @@ -32,8 +63,11 @@ jobs: - name: Run clippy run: cargo clippy --locked --all-targets --all-features -- -D warnings - - name: Check adoption assets - run: python3 -m unittest tests.python.test_adoption_assets -v + - name: Install Python test dependencies + run: python3 -m pip install --requirement requirements-test.txt + + - name: Run Python tests + run: python3 -m pytest -q tests/python - name: Build Docker image run: docker build -t fastaguard:ci . diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index bcbcdc5..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,144 +0,0 @@ -# FastaGuard Project Memory - -This file is durable project memory for Codex and other agent sessions working in this repository. - -## Product Thesis - -FastaGuard is the missing FASTA preflight and triage layer for modern bioinformatics pipelines. - -It should not compete with FastQC, QUAST, BUSCO, BlobToolKit, CheckM, seqkit, or MultiQC. Those tools remain important. FastaGuard runs earlier: - -```text -Before QUAST. Before BUSCO. Before BlobToolKit. Before annotation. -Run FastaGuard first. -``` - -The core promise: - -```text -FastaGuard catches FASTA-level assembly problems before expensive assembly QC. -``` - -## Tool Landscape - -- FastQC is mainly raw-read QC for FASTQ/BAM/SAM data: base quality, per-sequence GC, N content, duplication, adapters, and overrepresented sequences. -- QUAST evaluates genome assemblies and assembly-level quality. -- BUSCO estimates biological completeness. -- BlobToolKit helps explore contamination, cobionts, coverage, GC, and taxonomy signals. -- MultiQC aggregates reports from other tools. -- seqkit is a fast FASTA/FASTQ toolkit with useful stats and manipulation commands. - -FastaGuard's gap is not "replace all QC tools." The gap is: - -```text -There is no modern, default, explainable, machine-readable FASTA preflight tool. -``` - -## Product Direction - -FastaGuard should unify fragmented FASTA preflight checks: - -- FASTA validity -- malformed or empty records -- duplicate IDs -- duplicate sequences -- invalid characters -- basic structural stats -- N50, N90, L50, L90 -- GC, AT, N, and ambiguity rates -- gap runs -- tiny contigs -- composition red flags -- stable JSON, TSV, HTML, and MultiQC-compatible outputs - -After FastaGuard: - -- if the FASTA fails, fix the FASTA first -- if the FASTA passes, route to QUAST, BUSCO, BlobToolKit, CheckM, seqkit, or annotation depending on the biological question - -## Machine-Actionable Vision - -FastaGuard should prepare for a future where machines, LLMs, workflow engines, and tool agents talk to QC tools directly. - -Principles: - -- JSON is the source of truth; HTML is a human view. -- Machines should not scrape HTML or logs. -- Stable finding IDs matter as much as pretty reports. -- Every finding should expose verdict, severity, evidence, thresholds, and suggested next actions as structured fields. -- Reports should include provenance and scope limits so agents know what FastaGuard can and cannot conclude. -- Optional LLM summaries must be local-metrics-only and traceable back to structured fields. - -Current foundation: - -```text -stable JSON and finding IDs -JSON Schema and finding catalog -machine_summary, structured finding actions, provenance, and scope -per-record finding evidence with bounded affected-record lists -contract discovery commands: --schema, --finding-catalog, --explain-finding -golden JSON conformance fixtures for pass, fail, and invalid FASTA cases -``` - -Recommended next sequence: - -```text -next: richer evidence tables for additional profiles and compare mode -later: MCP/tool-agent interface and optional local summaries -``` - -## Deep Release Vision - -Durable vision document: - -```text -docs/vision-plan.md -``` - -FastaGuard should become the FASTA preflight operating system for modern -bioinformatics pipelines: validate the FASTA, explain red flags, emit a stable -contract, and route to the right downstream tools. - -The release strategy is evidence before expansion: - -```text -v0.3: evidence pack + assembly gate + provenance checksums -v0.4: compare mode for many FASTA files -v0.5: submission readiness gate -v0.6: workflow-compatible exit contract -v0.7: transcriptome profile -v0.8: protein profile -v0.9: reference-panel profile -later: MCP/tool-agent interface and optional local summaries -``` - -Default product boundaries: - -- stay fast and database-free by default -- keep JSON as the source of truth -- keep HTML as a human view -- make findings machine-actionable with stable IDs, severity, evidence, thresholds, actions, and scope -- keep optional generated summaries local-metrics-only and traceable back to structured fields -- never claim to replace QUAST, BUSCO, BlobToolKit, CheckM, seqkit, MultiQC, or annotation workflows - -Recommended next big release: - -```text -v0.6 should simplify workflow integration before adding broad new biological profiles. -``` - -The next planned feature direction is: - -```text -Workflow-Compatible Exit Contract: successful reports exit 0; report fields carry QC status. -``` - -This should keep JSON and TSV as the source of truth. Successful report -generation should exit `0`, including WARN and FAIL reports. Argument parsing -errors should exit `2`; configuration, input-access, and runtime errors should -exit `3`. Existing v0.5 workflow integrations should keep compatibility handling -until v0.6 packages and containers are published. - -## Collaboration Preference - -When moving the project forward, provide a clear recommendation first, then proceed when the user approves or explicitly asks to continue. The default recommendation should favor boring, stable contracts over flashy AI features. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..8a717e2 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,125 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances + of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported confidentially to the community leaders responsible for enforcement +at [ehsan.estaji@umu.se](mailto:ehsan.estaji@umu.se). Do not use a public issue +for a conduct report. All complaints will be reviewed and investigated promptly +and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +### Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +#### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +#### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +#### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +#### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1c81759 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,64 @@ +# Contributing to FastaGuard + +Thanks for improving FastaGuard. The project focuses on fast, explainable, +machine-readable FASTA preflight QC. Please keep contributions within that +scope: validate and describe FASTA-level signals without claiming biological +completeness, contamination confirmation, or repository acceptance. + +## Before opening a change + +- Search existing issues and pull requests to avoid duplicate work. +- Keep changes focused and include tests for changed behavior. +- Preserve stable JSON and finding contracts unless a deliberate, documented + contract change is needed. +- Do not include private sequence data in issues, tests, or examples. Use a + minimal synthetic FASTA reproducer or a safe description instead. + +## Local verification + +Run the Rust checks from the repository root: + +```bash +cargo fmt --check +cargo test --locked +``` + +Create a repository-local virtual environment, install the pinned test +dependencies, and run the Python contract and documentation checks. The +environment lives under the ignored `target/` directory, so this does not +modify the system Python environment. + +```bash +python3 -m venv target/contributor-venv +target/contributor-venv/bin/python -m pip install --requirement requirements-test.txt +target/contributor-venv/bin/python -m pytest -q tests/python +``` + +Add or update focused tests when changing a behavior, report shape, or public +workflow example. Mention any commands you could not run in the pull request. + +## Pull requests + +Describe the user-facing change, tests run, contract impact, and documentation +impact. Update the relevant documentation and fixtures when a public interface +changes. The pull request template lists the review checks used by this +repository. + +## Developer Certificate of Origin + +All commits must include a Developer Certificate of Origin (DCO) sign-off. +Use Git's `-s` option when committing: + +```bash +git commit -s -m "type: concise summary" +``` + +This adds a `Signed-off-by:` line using your configured Git identity. By +submitting the sign-off, you certify the contribution under the terms of the +[Developer Certificate of Origin](https://developercertificate.org/). + +## Code of conduct and security + +Please follow the [Code of Conduct](CODE_OF_CONDUCT.md). For a security issue, +follow the private reporting instructions in [SECURITY.md](SECURITY.md) rather +than opening a public issue. diff --git a/README.md b/README.md index bf8fd98..c16aa99 100644 --- a/README.md +++ b/README.md @@ -49,10 +49,10 @@ downstream tool. | Channel | Status | | --- | --- | -| Source/package metadata | This branch prepares `v0.6.0`; `v0.5.0` remains the latest tag | -| GitHub release | v0.5 GitHub release binaries are built from the `v0.5.0` tag | -| Bioconda | `v0.5.0` is live for Linux and macOS x86_64/ARM64 | -| BioContainers | `v0.5.0` is live as a pinned workflow image | +| Source/package metadata | `v0.6.0` | +| GitHub release | `v0.6.0` release binaries are published | +| Bioconda | `v0.6.0` is live for `linux-64`, `linux-aarch64`, `osx-64`, and `osx-arm64` | +| BioContainers | `0.6.0--hfa8f182_0` is the published pinned workflow image | | Source build | local checkout builds report the package version from `Cargo.toml` | ## Install @@ -60,41 +60,41 @@ downstream tool. Published bioinformatics install: ```bash -mamba install -c conda-forge -c bioconda fastaguard=0.5.0 +mamba install -c conda-forge -c bioconda fastaguard=0.6.0 ``` Published containerized workflow install: ```bash -docker pull quay.io/biocontainers/fastaguard:0.5.0--hfa8f182_0 +docker pull quay.io/biocontainers/fastaguard:0.6.0--hfa8f182_0 ``` Run through BioContainers: ```bash -docker run --rm quay.io/biocontainers/fastaguard:0.5.0--hfa8f182_0 fastaguard --version +docker run --rm quay.io/biocontainers/fastaguard:0.6.0--hfa8f182_0 fastaguard --version ``` GitHub release binary for Linux x86_64: ```bash -curl -L -O https://github.com/ehsanestaji/FastaGuard/releases/download/v0.5.0/fastaguard-v0.5.0-x86_64-unknown-linux-gnu.tar.gz -tar -xzf fastaguard-v0.5.0-x86_64-unknown-linux-gnu.tar.gz -./fastaguard-v0.5.0-x86_64-unknown-linux-gnu/fastaguard --version +curl -L -O https://github.com/ehsanestaji/FastaGuard/releases/download/v0.6.0/fastaguard-v0.6.0-x86_64-unknown-linux-gnu.tar.gz +tar -xzf fastaguard-v0.6.0-x86_64-unknown-linux-gnu.tar.gz +./fastaguard-v0.6.0-x86_64-unknown-linux-gnu/fastaguard --version ``` GitHub release binary for macOS Apple Silicon: ```bash -curl -L -O https://github.com/ehsanestaji/FastaGuard/releases/download/v0.5.0/fastaguard-v0.5.0-aarch64-apple-darwin.tar.gz -tar -xzf fastaguard-v0.5.0-aarch64-apple-darwin.tar.gz -./fastaguard-v0.5.0-aarch64-apple-darwin/fastaguard --version +curl -L -O https://github.com/ehsanestaji/FastaGuard/releases/download/v0.6.0/fastaguard-v0.6.0-aarch64-apple-darwin.tar.gz +tar -xzf fastaguard-v0.6.0-aarch64-apple-darwin.tar.gz +./fastaguard-v0.6.0-aarch64-apple-darwin/fastaguard --version ``` Build from the latest published Git tag: ```bash -cargo install --git https://github.com/ehsanestaji/FastaGuard --tag v0.5.0 +cargo install --git https://github.com/ehsanestaji/FastaGuard --tag v0.6.0 fastaguard --version ``` @@ -155,7 +155,7 @@ fastaguard compare assemblies/*.fa --profile assembly --gate pipeline ``` This command first shipped in the v0.4 GitHub release and is included in the -published v0.5.0 Bioconda package and BioContainers image. +published v0.6.0 Bioconda package and BioContainers image. Submission-readiness preflight: @@ -192,10 +192,10 @@ docker run --rm -v "$PWD:/data" fastaguard:local /data/sample.fa \ --multiqc /data/fastaguard_mqc.json ``` -Published BioContainers provides the v0.5 image for workflow engines: +Published BioContainers provides the v0.6 image for workflow engines: ```bash -docker pull quay.io/biocontainers/fastaguard:0.5.0--hfa8f182_0 +docker pull quay.io/biocontainers/fastaguard:0.6.0--hfa8f182_0 ``` Starting with FastaGuard v0.6.0, exit codes are: @@ -203,7 +203,7 @@ Starting with FastaGuard v0.6.0, exit codes are: ```text 0 = command completed and requested outputs were written 2 = argument parsing error -3 = configuration, input-access, or runtime error +3 = configuration, input-access, runtime, or output-write error ``` QC PASS/WARN/FAIL decisions are recorded in the machine-readable outputs, @@ -268,7 +268,8 @@ v0.2 expands the assembly preflight layer with: v0.3 adds the assembly gate contract: -- `--gate pipeline` for the default workflow gate policy +- `--gate pipeline` as the recommended workflow gate preset; the CLI default + remains no gate - `gate.blocking_findings` for machine stop/go decisions - checksum provenance with `provenance.input_sha256` - explicit advisory findings for evidence that should route follow-up QC rather @@ -295,12 +296,31 @@ v0.5 adds the submission-readiness gate: v0.6 makes report generation workflow-compatible: - successful report generation exits `0` for PASS, WARN, and FAIL reports -- argument parsing errors exit `2`; configuration, input-access, and runtime - errors exit `3` +- argument parsing errors exit `2`; configuration, input-access, runtime, and + output-write errors exit `3` - single-file TSV reports include `input_path` for downstream routing - workflows enforce QC policy from stable report fields instead of process status +## v0.6 Public Evidence + +The [public evidence report](docs/evidence/fastaguard-v0.6-public-evidence.md) +records three local contract cases and two exact NCBI reference assemblies. +Portable results are committed as +[JSON](docs/evidence/results/v0.6/evidence_summary.json) and +[TSV](docs/evidence/results/v0.6/evidence_summary.tsv). +They record the observed executable version and SHA-256 separately from the +verified release-tag/source-tree commit; binary-to-source reproducibility was +not independently attested. + +| Public assembly | Scale | Pipeline gate | Finding IDs | +| --- | ---: | --- | --- | +| E. coli K-12 MG1655 (`GCF_000005845.2`) | 4,641,652 bp; 1 record | PASS | none | +| Neurospora crassa OR74A (`GCF_000182925.2`) | 41,102,378 bp; 21 records | WARN | `gap_runs`, `gap_pattern_warnings` | + +Elapsed time in the summaries is contextual to the recorded machine and is not +a cross-platform performance guarantee. + ## Positioning FastaGuard should recommend deeper tools when they are appropriate: @@ -339,6 +359,7 @@ FastaGuard catches FASTA-level assembly problems before expensive assembly QC. - [v0.3 evidence workflow](docs/evidence/fastaguard-v0.3-evidence.md) - [v0.5 submission readiness evidence](docs/evidence/fastaguard-v0.5-submission-readiness.md) - [v0.5 public evidence workflow](docs/evidence/fastaguard-v0.5-public-evidence.md) +- [v0.6 public evidence](docs/evidence/fastaguard-v0.6-public-evidence.md) - [Packaging](docs/packaging.md) - [v0.6.0 release notes](docs/releases/v0.6.0.md) - [v0.5.0 release notes](docs/releases/v0.5.0.md) @@ -348,14 +369,14 @@ FastaGuard catches FASTA-level assembly problems before expensive assembly QC. - [v0.1.1 release notes](docs/releases/v0.1.1.md) - [v0.1.0 release notes](docs/releases/v0.1.0.md) - [Roadmap](docs/roadmap.md) -- [First-release design](docs/superpowers/specs/2026-05-18-fastaguard-first-release-design.md) ## Status -FastaGuard v0.5.0 is the latest tagged GitHub release and the current published -Bioconda/BioContainers release. It adds the submission-readiness gate on top of -the v0.4 preflight readiness and compare-mode contract. +FastaGuard v0.6.0 is the latest tagged GitHub release and the current published +Bioconda/BioContainers release. It adds the workflow-compatible exit contract +on top of the v0.5 submission-readiness, v0.4 preflight-readiness, and +compare-mode contracts. -Bioconda serves v0.5.0 for `linux-64`, `linux-aarch64`, `osx-64`, and -`osx-arm64`. BioContainers publishes the pinned v0.5 workflow image -`quay.io/biocontainers/fastaguard:0.5.0--hfa8f182_0`. +Bioconda serves v0.6.0 for `linux-64`, `linux-aarch64`, `osx-64`, and +`osx-arm64`. BioContainers publishes the pinned v0.6 workflow image +`quay.io/biocontainers/fastaguard:0.6.0--hfa8f182_0`. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..eb1a51a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# Security Policy + +## Reporting a vulnerability + +Send suspected vulnerabilities privately to +[ehsan.estaji@umu.se](mailto:ehsan.estaji@umu.se). +Do not open a public issue for a suspected security vulnerability. + +Include the affected FastaGuard version, installation method, operating system, +steps to reproduce, potential impact, and any known mitigation. Do not include +private sequence data; use a minimal synthetic example or a safe description. + +The maintainers will review reports, confirm scope, and coordinate a response. +Please allow time for investigation before publicly disclosing a vulnerability. + +## Scope + +FastaGuard is a local FASTA preflight tool. Security reports are most useful +when they describe unsafe handling of input files, generated reports, command +line options, dependencies, packaging, or release artifacts. diff --git a/docs/adoption-plan.md b/docs/adoption-plan.md index 12a4e76..cdbad89 100644 --- a/docs/adoption-plan.md +++ b/docs/adoption-plan.md @@ -15,9 +15,9 @@ Bioconda published -> BioContainers available -> MultiQC plugin -> public benchm Goal: make installation natural for bioinformatics users. -Status: Bioconda is live for FastaGuard v0.5.0 on Linux and macOS x86_64/ARM64 -platforms. BioContainers publishes the pinned workflow image -`quay.io/biocontainers/fastaguard:0.5.0--hfa8f182_0`. +Status: GitHub, Bioconda, and BioContainers publish FastaGuard v0.6.0. +Bioconda serves `linux-64`, `linux-aarch64`, `osx-64`, and `osx-arm64`; +BioContainers publishes `quay.io/biocontainers/fastaguard:0.6.0--hfa8f182_0`. - Keep GitHub release binaries working. - Keep Docker smoke tests passing. @@ -27,12 +27,12 @@ platforms. BioContainers publishes the pinned workflow image Done when: ```bash -mamba install -c conda-forge -c bioconda fastaguard=0.5.0 +mamba install -c conda-forge -c bioconda fastaguard=0.6.0 fastaguard --schema ``` works in a clean environment, and workflow engines can pull the pinned -BioContainers image. This is now true for v0.5.0; keep repeating the same +BioContainers image. This is now true for v0.6.0; keep repeating the same check for future releases. ## Phase 2: Aggregate @@ -40,11 +40,10 @@ check for future releases. Goal: make FastaGuard visible in standard pipeline reports. - Continue emitting `fastaguard_mqc.json` custom content. -- Develop `integrations/multiqc/` into a packaged MultiQC plugin. -- Test the plugin against multiple sample reports. -- Keep v0.5 gate, readiness, and submission fields visible in the native - MultiQC summary table. -- Decide whether to submit upstream to MultiQC once public adoption begins. +- Keep the unpublished local plugin starter in `integrations/multiqc/` + compatible with current custom-content output. +- Keep gate, readiness, and submission fields visible in the local summary. +- Evaluate MultiQC publication only with adoption evidence. Done when: @@ -63,23 +62,28 @@ Goal: show why FastaGuard is worth adding before expensive tools. - Document which findings should block downstream tools and which should only recommend deeper QC. - Create a concise comparison against `seqkit stats`, QUAST, BUSCO, BlobToolKit, FastQC, and MultiQC. -Done when the README can show real examples rather than only promises. +Status: the v0.6 public evidence pack now commits portable JSON and TSV results +for three local contract cases and two exact NCBI reference assemblies. It +demonstrates a clean public PASS, a non-blocking public WARN, and a local +blocking FAIL while keeping complete reports and downloaded FASTA files out of +the repository. + +Broader user evidence, cohort-scale compare runs, and direct comparisons with +downstream tools remain future adoption work. ## Phase 4: Workflow Readiness -Goal: make local workflow starters credible enough to become upstream -submissions. +Goal: keep the merged upstream integrations and local compatibility references +credible for workflow users. -Status: local nf-core-style and Snakemake wrapper-style starters are present, -pinned to the v0.5.0 Bioconda package and BioContainers image, documented as -starters rather than official upstream submissions, and validated in dedicated -upstream-style checkouts on 2026-07-03. +Status: the nf-core module PR [#12239](https://github.com/nf-core/modules/pull/12239) +merged 2026-08-21. The Snakemake wrapper PR +[#5436](https://github.com/snakemake/snakemake-wrappers/pull/5436) merged +2026-07-27, followed by autobump PR [#5737](https://github.com/snakemake/snakemake-wrappers/pull/5737) +merged 2026-07-31. Local starters remain useful compatibility references. Next work: -- prepare external PR branches for nf-core/modules and snakemake-wrappers -- repeat nf-core lint/test and Snakemake formatting/lint/pytest immediately - before opening upstream PRs - preserve the collect-then-gate pattern so JSON, TSV, HTML, and MultiQC evidence survives blocking FASTA results - keep `examples/workflows/check_fastaguard_gate.py` aligned with the JSON gate @@ -91,17 +95,16 @@ Detailed checklist: `docs/workflow-readiness.md`. ## Phase 5: Upstream workflow readiness -Goal: submit the starter assets upstream once the package, container, examples, -and tests are aligned with current community expectations. +Goal: maintain the merged upstream integrations and their stable report +contracts. Done when: ```text -nf-core module PR ready + Snakemake wrapper PR ready + local evidence-preserving examples verified +future releases update existing integrations after GitHub and package publication ``` -This phase should not claim official upstream status until those PRs are -accepted. +Keep local evidence-preserving examples verified as integrations evolve. ## Phase 6: Expand diff --git a/docs/evidence/fastaguard-v0.6-public-evidence.md b/docs/evidence/fastaguard-v0.6-public-evidence.md new file mode 100644 index 0000000..ab66067 --- /dev/null +++ b/docs/evidence/fastaguard-v0.6-public-evidence.md @@ -0,0 +1,70 @@ +# FastaGuard v0.6 Public Evidence + +This evidence pack records one assembly-profile run with an executable that +reported FastaGuard 0.6.0. It combines three deterministic local cases with two +exact NCBI assembly accessions: + +- `GCF_000005845.2`, Escherichia coli K-12 MG1655 +- `GCF_000182925.2`, Neurospora crassa OR74A + +The portable results are available as +[JSON](results/v0.6/evidence_summary.json) and +[TSV](results/v0.6/evidence_summary.tsv). They contain input sizes and SHA-256 +checksums, scoped source-tree and observed-executable provenance, structural +metrics, verdict and gate status, and all finding IDs. Downloaded FASTA files +and complete reports are not part of the repository. + +## Run Method + +The executable used for the run reported version `0.6.0` and had SHA-256 +`6dec7b558d29b3e72a96f6b81f942947fc50f84e183bfe5a390c665b33d21103`. +Separately, the tracked Rust and Cargo source tree was verified byte-for-byte +against release-tag commit +`cf27295da0cb9b1a48318caa9e3b8739cfd0c104`. These are independent checks: +binary-to-source reproducibility was not independently attested. + +The locked release build command completed before collection. The collector +then downloaded only the two manifest accessions with the NCBI Datasets CLI +and ran each case with the assembly profile, pipeline gate, and a one-base +minimum contig length: + +```bash +cargo build --release --locked +python3 scripts/collect_evidence.py \ + --binary target/release/fastaguard \ + --out-dir target/evidence/v0.6-public \ + --portable-summary-dir docs/evidence/results/v0.6 +``` + +## Results + +| Case | Scale | Verdict / gate | Structural result | Finding IDs | +| --- | ---: | --- | --- | --- | +| Deterministic synthetic FASTA | 604 bp, 4 records | PASS / PASS | N50 160; N90 140 | none | +| Problem assembly fixture | 145 bp, 5 records | FAIL / FAIL | N50 110; N90 8 | `duplicate_ids`, `duplicate_first_token_ids`, `invalid_chars`, `high_n_rate`, `gap_runs`, `terminal_ns`, `length_outliers`, `composite_anomalies` | +| Gzipped valid fixture | 47 bp, 3 records | WARN / WARN | N50 16; N90 15 | `terminal_ns` | +| `GCF_000005845.2` | 4,641,652 bp, 1 record | PASS / PASS | N50 and N90 4,641,652 | none | +| `GCF_000182925.2` | 41,102,378 bp, 21 records | WARN / WARN | N50 6,000,761; N90 4,218,384 | `gap_runs`, `gap_pattern_warnings` | + +The E. coli reference passed without findings. The Neurospora reference had +gap-pattern advisories but no pipeline blockers, so its WARN result routes to +deeper assembly or biological QC instead of stopping report generation. The +problem fixture demonstrates that blocking FASTA defects remain machine +visible even though v0.6 returns success after writing a valid FAIL report. + +## Environment and Runtime Context + +This run used macOS 26.5.1 on arm64 with Python 3.14.5. Measured FastaGuard +elapsed times ranged from 0.0145 seconds for a tiny fixture to 0.3441 seconds +for the 41.1 Mbp fungal assembly. These timings are contextual measurements +from one machine, not cross-platform benchmarks or performance guarantees. + +## Scope Limits + +This is FASTA-level preflight evidence for two public reference assemblies, +not a representative biological benchmark. It does not establish biological +completeness, contamination status, taxonomic correctness, annotation quality, +assembly correctness, or repository acceptance. A PASS or non-blocking WARN +means only that the input satisfied the selected FastaGuard gate policy and can +be routed to appropriate downstream tools such as QUAST, BUSCO, BlobToolKit, +CheckM, official validators, or annotation workflows. diff --git a/docs/evidence/results/v0.6/evidence_summary.json b/docs/evidence/results/v0.6/evidence_summary.json new file mode 100644 index 0000000..c07e8ef --- /dev/null +++ b/docs/evidence/results/v0.6/evidence_summary.json @@ -0,0 +1,153 @@ +{ + "binary_sha256": "6dec7b558d29b3e72a96f6b81f942947fc50f84e183bfe5a390c665b33d21103", + "binary_to_source_reproducibility_attested": false, + "cases": [ + { + "accession": null, + "category": "synthetic", + "downstream_route": "If this fails, fix the FastaGuard run before comparing with QUAST, BUSCO, or other downstream tools.", + "elapsed_seconds": 0.0194, + "evidence_role": "offline valid FASTA smoke case", + "exit_code": 0, + "expected_scale": "tiny synthetic fixture", + "finding_count": 0, + "finding_ids": [], + "gate_blocking_findings": "", + "gate_mode": "pipeline", + "gate_status": "PASS", + "id": "synthetic_valid", + "input_bytes": 676, + "input_sha256": "71152ba646d1f2d077e0a3a185524c2138a0657ab62f5f0694709c15420c3e5d", + "label": "Deterministic synthetic FASTA", + "n50": 160, + "n90": 140, + "sequence_count": 4, + "source": "local", + "source_url": null, + "total_length": 604, + "verdict": "PASS" + }, + { + "accession": null, + "category": "fixture", + "downstream_route": "Fix FASTA-level blockers before QUAST, BUSCO, BlobToolKit, CheckM, annotation, or submission validators.", + "elapsed_seconds": 0.0185, + "evidence_role": "local blocker case for duplicate IDs, invalid characters, high-N records, and tiny contigs", + "exit_code": 0, + "expected_scale": "tiny intentionally problematic fixture", + "finding_count": 8, + "finding_ids": [ + "duplicate_ids", + "duplicate_first_token_ids", + "invalid_chars", + "high_n_rate", + "gap_runs", + "terminal_ns", + "length_outliers", + "composite_anomalies" + ], + "gate_blocking_findings": "duplicate_ids,duplicate_first_token_ids,invalid_chars,high_n_rate", + "gate_mode": "pipeline", + "gate_status": "FAIL", + "id": "problem_fixture", + "input_bytes": 187, + "input_sha256": "4b8551daeda739b62c8e7aaa1ebf300e4118167ca582c51eeccfa1549c96f0a5", + "label": "Problem assembly fixture", + "n50": 110, + "n90": 8, + "sequence_count": 5, + "source": "local", + "source_url": null, + "total_length": 145, + "verdict": "FAIL" + }, + { + "accession": null, + "category": "fixture", + "downstream_route": "If this passes, the pipeline can accept compressed FASTA before heavier QC tools.", + "elapsed_seconds": 0.0145, + "evidence_role": "offline gzipped FASTA input smoke case", + "exit_code": 0, + "expected_scale": "tiny gzipped fixture", + "finding_count": 1, + "finding_ids": [ + "terminal_ns" + ], + "gate_blocking_findings": "", + "gate_mode": "pipeline", + "gate_status": "WARN", + "id": "gzipped_valid", + "input_bytes": 81, + "input_sha256": "544240d141ede11647a5cbdce60b23793dde1cc0559f2e918ecc80e9cc3fb48e", + "label": "Gzipped valid assembly fixture", + "n50": 16, + "n90": 15, + "sequence_count": 3, + "source": "local", + "source_url": null, + "total_length": 47, + "verdict": "WARN" + }, + { + "accession": "GCF_000005845.2", + "category": "bacterial", + "downstream_route": "If FastaGuard passes, route to QUAST for assembly metrics and BUSCO when biological completeness is in scope.", + "elapsed_seconds": 0.0569, + "evidence_role": "small bacterial reference assembly", + "exit_code": 0, + "expected_scale": "about 4.6 Mbp", + "finding_count": 0, + "finding_ids": [], + "gate_blocking_findings": "", + "gate_mode": "pipeline", + "gate_status": "PASS", + "id": "ecoli_k12_mg1655", + "input_bytes": 4699745, + "input_sha256": "53bb6a51b6e92139ced1e38f74b7938781027c52200922ff03718c2237d23bb4", + "label": "Escherichia coli K-12 MG1655", + "n50": 4641652, + "n90": 4641652, + "sequence_count": 1, + "source": "public_ncbi", + "source_url": "https://www.ncbi.nlm.nih.gov/datasets/genome/GCF_000005845.2/", + "total_length": 4641652, + "verdict": "PASS" + }, + { + "accession": "GCF_000182925.2", + "category": "fungal", + "downstream_route": "If FastaGuard passes, route to QUAST, BUSCO, and BlobToolKit-style follow-up when assembly quality or composition signals need deeper review.", + "elapsed_seconds": 0.3441, + "evidence_role": "small eukaryotic fungal reference assembly", + "exit_code": 0, + "expected_scale": "tens of Mbp", + "finding_count": 2, + "finding_ids": [ + "gap_runs", + "gap_pattern_warnings" + ], + "gate_blocking_findings": "", + "gate_mode": "pipeline", + "gate_status": "WARN", + "id": "neurospora_crassa_or74a", + "input_bytes": 41618285, + "input_sha256": "8176a4ac98fe05dffa4fa92df030ce0f15af23fbc382c8d8176ffedd3012d847", + "label": "Neurospora crassa OR74A", + "n50": 6000761, + "n90": 4218384, + "sequence_count": 21, + "source": "public_ncbi", + "source_url": "https://www.ncbi.nlm.nih.gov/datasets/genome/GCF_000182925.2/", + "total_length": 41102378, + "verdict": "WARN" + } + ], + "fastaguard_version": "0.6.0", + "generated_at": "2026-08-21T08:11:21+00:00", + "platform": "macOS-26.5.1-arm64-arm-64bit-Mach-O", + "provenance_scope": "source_commit records verified release-tag and source-tree equality. fastaguard_version and binary_sha256 record the observed executable. Binary-to-source reproducibility was not independently attested.", + "python": "3.14.5", + "runtime_context": "Elapsed seconds are contextual measurements from one local run, not cross-platform performance guarantees.", + "schema_version": 1, + "source_commit": "cf27295da0cb9b1a48318caa9e3b8739cfd0c104" +} diff --git a/docs/evidence/results/v0.6/evidence_summary.tsv b/docs/evidence/results/v0.6/evidence_summary.tsv new file mode 100644 index 0000000..1e2fa9e --- /dev/null +++ b/docs/evidence/results/v0.6/evidence_summary.tsv @@ -0,0 +1,6 @@ +schema_version generated_at fastaguard_version source_commit binary_sha256 provenance_scope binary_to_source_reproducibility_attested platform python runtime_context id label category source accession source_url evidence_role expected_scale downstream_route input_bytes input_sha256 elapsed_seconds exit_code verdict gate_mode gate_status gate_blocking_findings sequence_count total_length n50 n90 finding_ids finding_count +1 2026-08-21T08:11:21+00:00 0.6.0 cf27295da0cb9b1a48318caa9e3b8739cfd0c104 6dec7b558d29b3e72a96f6b81f942947fc50f84e183bfe5a390c665b33d21103 source_commit records verified release-tag and source-tree equality. fastaguard_version and binary_sha256 record the observed executable. Binary-to-source reproducibility was not independently attested. false macOS-26.5.1-arm64-arm-64bit-Mach-O 3.14.5 Elapsed seconds are contextual measurements from one local run, not cross-platform performance guarantees. synthetic_valid Deterministic synthetic FASTA synthetic local offline valid FASTA smoke case tiny synthetic fixture If this fails, fix the FastaGuard run before comparing with QUAST, BUSCO, or other downstream tools. 676 71152ba646d1f2d077e0a3a185524c2138a0657ab62f5f0694709c15420c3e5d 0.0194 0 PASS pipeline PASS 4 604 160 140 0 +1 2026-08-21T08:11:21+00:00 0.6.0 cf27295da0cb9b1a48318caa9e3b8739cfd0c104 6dec7b558d29b3e72a96f6b81f942947fc50f84e183bfe5a390c665b33d21103 source_commit records verified release-tag and source-tree equality. fastaguard_version and binary_sha256 record the observed executable. Binary-to-source reproducibility was not independently attested. false macOS-26.5.1-arm64-arm-64bit-Mach-O 3.14.5 Elapsed seconds are contextual measurements from one local run, not cross-platform performance guarantees. problem_fixture Problem assembly fixture fixture local local blocker case for duplicate IDs, invalid characters, high-N records, and tiny contigs tiny intentionally problematic fixture Fix FASTA-level blockers before QUAST, BUSCO, BlobToolKit, CheckM, annotation, or submission validators. 187 4b8551daeda739b62c8e7aaa1ebf300e4118167ca582c51eeccfa1549c96f0a5 0.0185 0 FAIL pipeline FAIL duplicate_ids,duplicate_first_token_ids,invalid_chars,high_n_rate 5 145 110 8 duplicate_ids,duplicate_first_token_ids,invalid_chars,high_n_rate,gap_runs,terminal_ns,length_outliers,composite_anomalies 8 +1 2026-08-21T08:11:21+00:00 0.6.0 cf27295da0cb9b1a48318caa9e3b8739cfd0c104 6dec7b558d29b3e72a96f6b81f942947fc50f84e183bfe5a390c665b33d21103 source_commit records verified release-tag and source-tree equality. fastaguard_version and binary_sha256 record the observed executable. Binary-to-source reproducibility was not independently attested. false macOS-26.5.1-arm64-arm-64bit-Mach-O 3.14.5 Elapsed seconds are contextual measurements from one local run, not cross-platform performance guarantees. gzipped_valid Gzipped valid assembly fixture fixture local offline gzipped FASTA input smoke case tiny gzipped fixture If this passes, the pipeline can accept compressed FASTA before heavier QC tools. 81 544240d141ede11647a5cbdce60b23793dde1cc0559f2e918ecc80e9cc3fb48e 0.0145 0 WARN pipeline WARN 3 47 16 15 terminal_ns 1 +1 2026-08-21T08:11:21+00:00 0.6.0 cf27295da0cb9b1a48318caa9e3b8739cfd0c104 6dec7b558d29b3e72a96f6b81f942947fc50f84e183bfe5a390c665b33d21103 source_commit records verified release-tag and source-tree equality. fastaguard_version and binary_sha256 record the observed executable. Binary-to-source reproducibility was not independently attested. false macOS-26.5.1-arm64-arm-64bit-Mach-O 3.14.5 Elapsed seconds are contextual measurements from one local run, not cross-platform performance guarantees. ecoli_k12_mg1655 Escherichia coli K-12 MG1655 bacterial public_ncbi GCF_000005845.2 https://www.ncbi.nlm.nih.gov/datasets/genome/GCF_000005845.2/ small bacterial reference assembly about 4.6 Mbp If FastaGuard passes, route to QUAST for assembly metrics and BUSCO when biological completeness is in scope. 4699745 53bb6a51b6e92139ced1e38f74b7938781027c52200922ff03718c2237d23bb4 0.0569 0 PASS pipeline PASS 1 4641652 4641652 4641652 0 +1 2026-08-21T08:11:21+00:00 0.6.0 cf27295da0cb9b1a48318caa9e3b8739cfd0c104 6dec7b558d29b3e72a96f6b81f942947fc50f84e183bfe5a390c665b33d21103 source_commit records verified release-tag and source-tree equality. fastaguard_version and binary_sha256 record the observed executable. Binary-to-source reproducibility was not independently attested. false macOS-26.5.1-arm64-arm-64bit-Mach-O 3.14.5 Elapsed seconds are contextual measurements from one local run, not cross-platform performance guarantees. neurospora_crassa_or74a Neurospora crassa OR74A fungal public_ncbi GCF_000182925.2 https://www.ncbi.nlm.nih.gov/datasets/genome/GCF_000182925.2/ small eukaryotic fungal reference assembly tens of Mbp If FastaGuard passes, route to QUAST, BUSCO, and BlobToolKit-style follow-up when assembly quality or composition signals need deeper review. 41618285 8176a4ac98fe05dffa4fa92df030ce0f15af23fbc382c8d8176ffedd3012d847 0.3441 0 WARN pipeline WARN 21 41102378 6000761 4218384 gap_runs,gap_pattern_warnings 2 diff --git a/docs/mvp-spec.md b/docs/mvp-spec.md index 9c8ddc9..82ea67d 100644 --- a/docs/mvp-spec.md +++ b/docs/mvp-spec.md @@ -155,7 +155,7 @@ Starting with FastaGuard v0.6.0: ```text 0 = command completed and requested outputs were written 2 = argument parsing error -3 = configuration, input-access, or runtime error +3 = configuration, input-access, runtime, or output-write error ``` QC PASS/WARN/FAIL decisions are report fields, not process-failure signals. diff --git a/docs/output-contract.md b/docs/output-contract.md index 5a2d11b..93bf672 100644 --- a/docs/output-contract.md +++ b/docs/output-contract.md @@ -39,7 +39,7 @@ Starting with FastaGuard v0.6.0: ```text 0 = command completed and requested outputs were written 2 = argument parsing error -3 = configuration, input-access, or runtime error +3 = configuration, input-access, runtime, or output-write error ``` Workflow engines should apply stop/go policy from `gate.status`, diff --git a/docs/packaging.md b/docs/packaging.md index 7d0a0c8..4814b0e 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -8,9 +8,10 @@ Treat packaging as part of the product, not as a later chore. For bioinformatics Bioconda -> BioContainers -> GitHub release binaries -> Docker image -> Homebrew later ``` -FastaGuard v0.5.0 is published on GitHub with Linux and macOS release binaries. -Bioconda serves v0.5.0 on Linux and macOS x86_64/ARM64 platforms. -BioContainers provides the pinned v0.5 workflow image generated from the +FastaGuard v0.6.0 is published on GitHub with Linux and macOS release binaries. +Bioconda serves v0.6.0 on `linux-64`, `linux-aarch64`, `osx-64`, and +`osx-arm64` platforms. BioContainers provides the pinned v0.6 workflow image +`quay.io/biocontainers/fastaguard:0.6.0--hfa8f182_0` generated from the Bioconda package. Docker remains useful for local smoke tests. The current published package includes the `--gate submission` and @@ -22,13 +23,13 @@ confirmed Bioconda and BioContainers versions before advertising them as live. Recommended install: ```bash -mamba install -c conda-forge -c bioconda fastaguard=0.5.0 +mamba install -c conda-forge -c bioconda fastaguard=0.6.0 ``` Conda equivalent: ```bash -conda install -c conda-forge -c bioconda fastaguard=0.5.0 +conda install -c conda-forge -c bioconda fastaguard=0.6.0 ``` Verify the installed package: @@ -41,7 +42,7 @@ fastaguard --finding-catalog Current published package: -- Version: `0.5.0` +- Version: `0.6.0` - Platforms: `linux-64`, `linux-aarch64`, `osx-64`, `osx-arm64` - Package page: [anaconda.org/bioconda/fastaguard](https://anaconda.org/bioconda/fastaguard) @@ -64,7 +65,7 @@ Run it: --multiqc fastaguard_mqc.json ``` -Run a local v0.5 submission-readiness preflight before official validators: +Run a local v0.6 submission-readiness preflight before official validators: ```bash ./target/release/fastaguard testdata/submission_ids.fa \ @@ -104,8 +105,9 @@ For a public release: 1. Tag the release: ```bash - git tag v0.5.0 - git push origin v0.5.0 + release_version="X.Y.Z" + git tag "v${release_version}" + git push origin "v${release_version}" ``` 2. Push the tag to trigger `.github/workflows/release.yml`. @@ -159,7 +161,7 @@ The Bioconda recipe has merged upstream and generated a BioContainers image. Use the pinned tag in workflow examples: ```bash -docker pull quay.io/biocontainers/fastaguard:0.5.0--hfa8f182_0 +docker pull quay.io/biocontainers/fastaguard:0.6.0--hfa8f182_0 ``` That path is preferable to maintaining a separate BioContainers Dockerfile. @@ -168,7 +170,7 @@ That path is preferable to maintaining a separate BioContainers Dockerfile. FastaGuard emits MultiQC custom content as `fastaguard_mqc.json`. -A native MultiQC plugin starter now lives in: +An unpublished local MultiQC plugin starter lives in: ```text integrations/multiqc/ diff --git a/docs/releases/v0.6.0.md b/docs/releases/v0.6.0.md index 41c1066..c7b5c2f 100644 --- a/docs/releases/v0.6.0.md +++ b/docs/releases/v0.6.0.md @@ -5,8 +5,8 @@ FastaGuard now separates command execution from FASTA quality decisions. Successful report generation exits with code `0`, including reports whose verdict or gate status is `WARN` or `FAIL`. Argument parsing errors exit with -code `2`, while configuration, input-access, and runtime errors exit with code -`3`. +code `2`, while configuration, input-access, runtime, and output-write errors +exit with code `3`. QC decisions remain explicit in the report contract: diff --git a/docs/roadmap.md b/docs/roadmap.md index 4209fee..608d3cd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,12 @@ # Roadmap +## Current Release + +FastaGuard v0.6.0 is the current GitHub, Bioconda, and BioContainers release. +Bioconda serves `linux-64`, `linux-aarch64`, `osx-64`, and `osx-arm64`; the +published BioContainers tag is `0.6.0--hfa8f182_0`. Future releases update +existing integrations after GitHub and package publication. + ## v0.1: Assembly Preflight Goal: @@ -131,7 +138,8 @@ Development scope: - return exit code `0` whenever requested reports are written successfully, including WARN and FAIL reports - reserve exit code `2` for argument parsing errors -- reserve exit code `3` for configuration, input-access, and runtime errors +- reserve exit code `3` for configuration, input-access, runtime, and + output-write errors - keep PASS/WARN/FAIL decisions in stable JSON and TSV report fields - include `input_path` in single-file TSV reports for downstream routing - document migration from legacy QC-derived process exit codes @@ -171,10 +179,9 @@ Potential additions: Potential additions: - publish and verify package updates for each released contract -- upstream nf-core module submission -- official Snakemake wrapper submission +- maintain the merged nf-core module and Snakemake wrapper integrations - Galaxy wrapper -- upstream MultiQC distribution path +- MultiQC distribution path beyond custom content and the unpublished local plugin starter - BioContainers verification for each published package - Homebrew formula diff --git a/docs/superpowers/plans/2026-05-18-fastaguard-assembly-mvp.md b/docs/superpowers/plans/2026-05-18-fastaguard-assembly-mvp.md deleted file mode 100644 index 0ad80f5..0000000 --- a/docs/superpowers/plans/2026-05-18-fastaguard-assembly-mvp.md +++ /dev/null @@ -1,2458 +0,0 @@ -# FastaGuard Assembly MVP Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build the v0.1 Rust CLI that streams assembly FASTA files, computes FASTA-level QC metrics, produces explainable verdicts, and writes JSON, TSV, HTML, and MultiQC-compatible outputs. - -**Architecture:** The implementation is a Rust binary plus library. The CLI parses options, builds an assembly profile, streams records through a parser, accumulates compact per-sequence summaries, derives findings and a verdict, writes reports, and exits with documented pipeline codes. - -**Tech Stack:** Rust 2021, `clap`, `serde`, `serde_json`, `anyhow`, `thiserror`, `flate2`, `sha2`, `hex`, `assert_cmd`, `predicates`, `tempfile`. - ---- - -## File Structure - -Create these files: - -- `Cargo.toml`: crate metadata, runtime dependencies, and test dependencies. -- `src/main.rs`: process entrypoint that maps CLI results to process exit codes. -- `src/lib.rs`: public module exports and `run_check` orchestration. -- `src/cli.rs`: `clap` arguments, output path defaults, and rule parsing. -- `src/models.rs`: stable JSON contract structs. -- `src/profile.rs`: assembly thresholds and fail/warn rule configuration. -- `src/parser.rs`: streaming FASTA reader for plain and gzipped input. -- `src/metrics.rs`: per-record summaries, duplicate detection, and assembly aggregate metrics. -- `src/findings.rs`: findings engine and verdict logic. -- `src/report/mod.rs`: report module exports. -- `src/report/json.rs`: JSON writer. -- `src/report/tsv.rs`: TSV writer. -- `src/report/multiqc.rs`: MultiQC-compatible JSON writer. -- `src/report/html.rs`: self-contained static HTML writer. -- `src/stats/mod.rs`: stats module exports. -- `src/stats/nxx.rs`: N50/N90/L50/L90 calculations. -- `src/stats/composition.rs`: percentage helpers. -- `src/stats/outliers.rs`: simple outlier detection helpers. -- `tests/cli.rs`: end-to-end CLI tests. -- `testdata/valid_assembly.fa`: small valid assembly fixture. -- `testdata/problem_assembly.fa`: assembly fixture with duplicate IDs, high Ns, invalid chars, and tiny contigs. - -Modify these files: - -- `README.md`: add install, quickstart, and v0.1 usage once the CLI exists. -- `docs/mvp-spec.md`: add a short implementation status section. -- `docs/output-contract.md`: align example JSON with the actual serialized fields if names change during implementation. - ---- - -## Task 1: Scaffold The Rust Crate - -**Files:** -- Create: `Cargo.toml` -- Create: `src/main.rs` - -- [ ] **Step 1: Initialize the crate** - -Run: - -```bash -cargo init --bin --name fastaguard . -``` - -Expected: - -```text -Created binary (application) package -``` - -- [ ] **Step 2: Replace `Cargo.toml` with pinned project metadata** - -Replace `Cargo.toml` with: - -```toml -[package] -name = "fastaguard" -version = "0.1.0" -edition = "2021" -license = "MIT" -description = "FASTA preflight QC for assembly pipelines" -repository = "https://github.com/ehsanestaji/FastaGuard" - -[dependencies] -anyhow = "1" -clap = { version = "4", features = ["derive"] } -flate2 = "1" -hex = "0.4" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sha2 = "0.10" -thiserror = "1" - -[dev-dependencies] -assert_cmd = "2" -predicates = "3" -tempfile = "3" -``` - -- [ ] **Step 3: Create the temporary binary entrypoint** - -Replace `src/main.rs` with: - -```rust -fn main() { - println!("FastaGuard"); -} -``` - -- [ ] **Step 4: Run the compiler** - -Run: - -```bash -cargo check -``` - -Expected: - -```text -Finished -``` - -- [ ] **Step 5: Commit the crate scaffold** - -Run: - -```bash -git add Cargo.toml src/main.rs -git commit -m "chore: scaffold Rust crate" -``` - -Expected: - -```text -[main ] chore: scaffold Rust crate -``` - ---- - -## Task 2: Define CLI, Profile, And Output Models - -**Files:** -- Create: `src/cli.rs` -- Create: `src/profile.rs` -- Create: `src/models.rs` -- Modify: `src/lib.rs` - -- [ ] **Step 1: Write the model and CLI tests** - -Create `tests/cli.rs` with: - -```rust -use assert_cmd::Command; -use predicates::prelude::*; - -#[test] -fn help_mentions_preflight_positioning() { - let mut cmd = Command::cargo_bin("fastaguard").unwrap(); - cmd.arg("--help") - .assert() - .success() - .stdout(predicate::str::contains("FASTA preflight QC")); -} -``` - -- [ ] **Step 2: Create CLI argument parsing** - -Create `src/cli.rs` with: - -```rust -use anyhow::{anyhow, Result}; -use clap::Parser; -use std::collections::BTreeSet; -use std::path::PathBuf; - -use crate::profile::ThresholdOverrides; - -#[derive(Debug, Parser)] -#[command(name = "fastaguard")] -#[command(version)] -#[command(about = "FASTA preflight QC for assembly pipelines")] -pub struct Cli { - /// Input FASTA file. Plain .fa/.fasta and gzipped .gz files are supported. - pub input: PathBuf, - - /// QC profile. v0.1 supports assembly. - #[arg(long, default_value = "assembly")] - pub profile: String, - - /// HTML report path. - #[arg(long, default_value = "fastaguard_report.html")] - pub out: PathBuf, - - /// JSON report path. - #[arg(long, default_value = "fastaguard.json")] - pub json: PathBuf, - - /// TSV summary path. - #[arg(long, default_value = "fastaguard.tsv")] - pub tsv: PathBuf, - - /// MultiQC-compatible JSON path. - #[arg(long, default_value = "fastaguard_multiqc.json")] - pub multiqc: PathBuf, - - /// Comma-separated rule IDs that should fail the run when triggered. - #[arg(long, value_delimiter = ',')] - pub fail_on: Vec, - - /// Maximum allowed global N fraction before a high_n_rate finding. - #[arg(long)] - pub max_n_rate: Option, - - /// Minimum contig length used for tiny_contigs finding. - #[arg(long)] - pub min_contig_length: Option, - - /// Worker thread count reserved for later parallel post-processing. - #[arg(long, default_value_t = 1)] - pub threads: usize, -} - -#[derive(Debug, Clone)] -pub struct RunConfig { - pub input: PathBuf, - pub profile: String, - pub outputs: OutputPaths, - pub rules: RuleConfig, - pub thresholds: ThresholdOverrides, - pub threads: usize, -} - -#[derive(Debug, Clone)] -pub struct OutputPaths { - pub html: PathBuf, - pub json: PathBuf, - pub tsv: PathBuf, - pub multiqc: PathBuf, -} - -#[derive(Debug, Clone)] -pub struct RuleConfig { - pub fail_on: BTreeSet, -} - -impl Cli { - pub fn to_run_config(&self) -> Result { - if self.profile != "assembly" { - return Err(anyhow!("unsupported profile '{}'; v0.1 supports assembly", self.profile)); - } - if self.threads == 0 { - return Err(anyhow!("--threads must be at least 1")); - } - - Ok(RunConfig { - input: self.input.clone(), - profile: self.profile.clone(), - outputs: OutputPaths { - html: self.out.clone(), - json: self.json.clone(), - tsv: self.tsv.clone(), - multiqc: self.multiqc.clone(), - }, - rules: RuleConfig { - fail_on: normalize_rules(&self.fail_on), - }, - thresholds: ThresholdOverrides { - max_n_rate: self.max_n_rate, - min_contig_length: self.min_contig_length, - }, - threads: self.threads, - }) - } -} - -fn normalize_rules(values: &[String]) -> BTreeSet { - values - .iter() - .flat_map(|value| value.split(',')) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .collect() -} -``` - -- [ ] **Step 3: Create profile thresholds** - -Create `src/profile.rs` with: - -```rust -#[derive(Debug, Clone, Copy)] -pub struct ThresholdOverrides { - pub max_n_rate: Option, - pub min_contig_length: Option, -} - -#[derive(Debug, Clone)] -pub struct ProfileConfig { - pub name: String, - pub high_n_sequence_fraction: f64, - pub high_global_n_fraction: f64, - pub min_contig_length: u64, - pub max_gap_run: u64, - pub gc_outlier_zscore: f64, -} - -impl ProfileConfig { - pub fn assembly(overrides: ThresholdOverrides) -> Self { - Self { - name: "assembly".to_string(), - high_n_sequence_fraction: 0.20, - high_global_n_fraction: overrides.max_n_rate.unwrap_or(0.05), - min_contig_length: overrides.min_contig_length.unwrap_or(200), - max_gap_run: 100, - gc_outlier_zscore: 3.0, - } - } -} -``` - -- [ ] **Step 4: Create stable output models** - -Create `src/models.rs` with: - -```rust -use serde::{Deserialize, Serialize}; - -pub const SCHEMA_VERSION: &str = "0.1.0"; -pub const TOOL_NAME: &str = "FastaGuard"; -pub const TOOL_VERSION: &str = env!("CARGO_PKG_VERSION"); - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FastaguardReport { - pub schema_version: String, - pub tool: ToolInfo, - pub input: InputInfo, - pub verdict: Verdict, - pub summary: Summary, - pub findings: Vec, - pub artifacts: Artifacts, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolInfo { - pub name: String, - pub version: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InputInfo { - pub path: String, - pub profile: String, - pub compressed: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Verdict { - pub status: VerdictStatus, - pub reasons: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "UPPERCASE")] -pub enum VerdictStatus { - Pass, - Warn, - Fail, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Severity { - Info, - Minor, - Major, - Critical, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Finding { - pub id: String, - pub severity: Severity, - pub profile: String, - pub affected_count: u64, - pub affected_fraction: f64, - pub message: String, - pub why_it_matters: String, - pub suggested_next_step: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Summary { - pub sequence_count: u64, - pub total_length: u64, - pub min_length: u64, - pub max_length: u64, - pub mean_length: f64, - pub median_length: f64, - pub n50: u64, - pub n90: u64, - pub l50: u64, - pub l90: u64, - pub gc_percent: f64, - pub at_percent: f64, - pub n_percent: f64, - pub ambiguity_percent: f64, - pub duplicate_id_count: u64, - pub duplicate_sequence_count: u64, - pub invalid_sequence_count: u64, - pub high_n_sequence_count: u64, - pub tiny_contig_count: u64, - pub max_gap_run: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Artifacts { - pub html: String, - pub tsv: String, - pub multiqc: String, -} -``` - -- [ ] **Step 5: Create the library module shell and CLI-aware entrypoint** - -Create `src/lib.rs` with: - -```rust -pub mod cli; -pub mod models; -pub mod profile; -``` - -Replace `src/main.rs` with: - -```rust -use clap::Parser; -use fastaguard::cli::Cli; - -fn main() { - let _cli = Cli::parse(); - eprintln!("fastaguard implementation is not wired yet"); - std::process::exit(3); -} -``` - -- [ ] **Step 6: Run the help test** - -Run: - -```bash -cargo test help_mentions_preflight_positioning -``` - -Expected: - -```text -test help_mentions_preflight_positioning ... ok -``` - -- [ ] **Step 7: Commit CLI and model contracts** - -Run: - -```bash -git add src/main.rs src/lib.rs src/cli.rs src/profile.rs src/models.rs tests/cli.rs -git commit -m "feat: define CLI and output contract models" -``` - -Expected: - -```text -[main ] feat: define CLI and output contract models -``` - ---- - -## Task 3: Implement Stats Helpers - -**Files:** -- Create: `src/stats/mod.rs` -- Create: `src/stats/nxx.rs` -- Create: `src/stats/composition.rs` -- Create: `src/stats/outliers.rs` -- Modify: `src/lib.rs` - -- [ ] **Step 1: Wire the stats module and write N-stat tests** - -Append this line to `src/lib.rs`: - -```rust -pub mod stats; -``` - -Create `src/stats/mod.rs` with: - -```rust -pub mod nxx; -``` - -Create `src/stats/nxx.rs` with: - -```rust -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Nx { - pub nx: u64, - pub lx: u64, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn computes_n50_and_l50_for_sorted_or_unsorted_lengths() { - let lengths = vec![10, 80, 20, 40]; - let result = nx_lx(&lengths, 0.50); - assert_eq!(result, Nx { nx: 40, lx: 2 }); - } - - #[test] - fn computes_n90_and_l90() { - let lengths = vec![10, 80, 20, 40]; - let result = nx_lx(&lengths, 0.90); - assert_eq!(result, Nx { nx: 10, lx: 4 }); - } - - #[test] - fn empty_lengths_return_zeroes() { - assert_eq!(nx_lx(&[], 0.50), Nx { nx: 0, lx: 0 }); - } -} -``` - -- [ ] **Step 2: Run N-stat tests to verify failure** - -Run: - -```bash -cargo test stats::nxx -``` - -Expected: - -```text -cannot find function `nx_lx` in this scope -``` - -- [ ] **Step 3: Add N-stat implementation** - -Replace `src/stats/nxx.rs` with: - -```rust -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Nx { - pub nx: u64, - pub lx: u64, -} - -pub fn nx_lx(lengths: &[u64], fraction: f64) -> Nx { - if lengths.is_empty() { - return Nx { nx: 0, lx: 0 }; - } - - let mut sorted = lengths.to_vec(); - sorted.sort_unstable_by(|a, b| b.cmp(a)); - - let total: u64 = sorted.iter().sum(); - let target = (total as f64 * fraction).ceil() as u64; - let mut cumulative = 0_u64; - - for (index, length) in sorted.iter().enumerate() { - cumulative += *length; - if cumulative >= target { - return Nx { - nx: *length, - lx: (index + 1) as u64, - }; - } - } - - Nx { - nx: *sorted.last().unwrap_or(&0), - lx: sorted.len() as u64, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn computes_n50_and_l50_for_sorted_or_unsorted_lengths() { - let lengths = vec![10, 80, 20, 40]; - let result = nx_lx(&lengths, 0.50); - assert_eq!(result, Nx { nx: 40, lx: 2 }); - } - - #[test] - fn computes_n90_and_l90() { - let lengths = vec![10, 80, 20, 40]; - let result = nx_lx(&lengths, 0.90); - assert_eq!(result, Nx { nx: 10, lx: 4 }); - } - - #[test] - fn empty_lengths_return_zeroes() { - assert_eq!(nx_lx(&[], 0.50), Nx { nx: 0, lx: 0 }); - } -} -``` - -- [ ] **Step 4: Add composition and outlier helpers** - -Create `src/stats/composition.rs` with: - -```rust -pub fn percent(part: u64, total: u64) -> f64 { - if total == 0 { - 0.0 - } else { - round2((part as f64 / total as f64) * 100.0) - } -} - -pub fn fraction(part: u64, total: u64) -> f64 { - if total == 0 { - 0.0 - } else { - part as f64 / total as f64 - } -} - -pub fn round2(value: f64) -> f64 { - (value * 100.0).round() / 100.0 -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn percent_rounds_to_two_decimals() { - assert_eq!(percent(1, 3), 33.33); - } - - #[test] - fn zero_total_is_zero() { - assert_eq!(percent(4, 0), 0.0); - assert_eq!(fraction(4, 0), 0.0); - } -} -``` - -Create `src/stats/outliers.rs` with: - -```rust -pub fn zscore_outlier_indices(values: &[f64], threshold: f64) -> Vec { - if values.len() < 3 { - return Vec::new(); - } - - let mean = values.iter().sum::() / values.len() as f64; - let variance = values - .iter() - .map(|value| { - let delta = value - mean; - delta * delta - }) - .sum::() - / values.len() as f64; - let stddev = variance.sqrt(); - - if stddev == 0.0 { - return Vec::new(); - } - - values - .iter() - .enumerate() - .filter_map(|(index, value)| { - let z = (value - mean).abs() / stddev; - (z >= threshold).then_some(index) - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn finds_extreme_value() { - let values = vec![50.0, 51.0, 49.0, 90.0, 50.5]; - assert_eq!(zscore_outlier_indices(&values, 1.5), vec![3]); - } -} -``` - -Replace `src/stats/mod.rs` with: - -```rust -pub mod composition; -pub mod nxx; -pub mod outliers; -``` - -- [ ] **Step 5: Run stats tests** - -Run: - -```bash -cargo test stats -``` - -Expected: - -```text -test result: ok -``` - -- [ ] **Step 6: Commit stats helpers** - -Run: - -```bash -git add src/lib.rs src/stats -git commit -m "feat: add assembly statistics helpers" -``` - -Expected: - -```text -[main ] feat: add assembly statistics helpers -``` - ---- - -## Task 4: Implement Streaming FASTA Parser - -**Files:** -- Create: `src/parser.rs` -- Create: `testdata/valid_assembly.fa` -- Create: `testdata/problem_assembly.fa` -- Modify: `src/lib.rs` - -- [ ] **Step 1: Add FASTA fixtures** - -Create `testdata/valid_assembly.fa` with: - -```text ->contig_1 -ACGTACGTACGTAAAA ->contig_2 description -GGGGCCCCAAAATTTT ->contig_3 -ACGTMRWSYKVHDBN -``` - -Create `testdata/problem_assembly.fa` with: - -```text ->dup -ACGTACGT ->dup -NNNNNNNNNNNNNNNN ->tiny -ACGT ->bad_chars -ACGTXYZ ->gap_rich -AAAAANNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNCCCC -``` - -- [ ] **Step 2: Wire the parser module and write parser tests** - -Append this line to `src/lib.rs`: - -```rust -pub mod parser; -``` - -Create `src/parser.rs` with: - -```rust -use std::path::Path; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FastaRecord { - pub id: String, - pub header: String, - pub sequence: Vec, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn reads_multirecord_fasta() { - let records = read_fasta(Path::new("testdata/valid_assembly.fa")).unwrap(); - assert_eq!(records.len(), 3); - assert_eq!(records[0].id, "contig_1"); - assert_eq!(records[1].id, "contig_2"); - assert_eq!(records[0].sequence, b"ACGTACGTACGTAAAA"); - } - - #[test] - fn rejects_sequence_before_header() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("bad.fa"); - std::fs::write(&path, "ACGT\n>later\nACGT\n").unwrap(); - let error = read_fasta(&path).unwrap_err().to_string(); - assert!(error.contains("sequence before first header")); - } - - #[test] - fn rejects_empty_header_id() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("bad.fa"); - std::fs::write(&path, ">\nACGT\n").unwrap(); - let error = read_fasta(&path).unwrap_err().to_string(); - assert!(error.contains("empty FASTA header")); - } -} -``` - -- [ ] **Step 3: Run parser tests to verify failure** - -Run: - -```bash -cargo test parser -``` - -Expected: - -```text -cannot find function `read_fasta` in this scope -``` - -- [ ] **Step 4: Add parser implementation** - -Replace `src/parser.rs` with: - -```rust -use anyhow::{anyhow, Context, Result}; -use flate2::read::MultiGzDecoder; -use std::fs::File; -use std::io::{BufRead, BufReader, Read}; -use std::path::Path; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FastaRecord { - pub id: String, - pub header: String, - pub sequence: Vec, -} - -pub fn read_fasta(path: &Path) -> Result> { - let file = File::open(path).with_context(|| format!("failed to open {}", path.display()))?; - let reader: Box = if path - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.eq_ignore_ascii_case("gz")) - .unwrap_or(false) - { - Box::new(MultiGzDecoder::new(file)) - } else { - Box::new(file) - }; - - parse_reader(BufReader::new(reader)) -} - -fn parse_reader(reader: R) -> Result> { - let mut records = Vec::new(); - let mut current_header: Option = None; - let mut current_id: Option = None; - let mut current_sequence: Vec = Vec::new(); - - for (line_index, line_result) in reader.lines().enumerate() { - let line_number = line_index + 1; - let line = line_result.with_context(|| format!("failed to read line {line_number}"))?; - let trimmed = line.trim_end_matches('\r'); - - if trimmed.starts_with('>') { - if let Some(header) = current_header.take() { - records.push(FastaRecord { - id: current_id.take().unwrap(), - header, - sequence: std::mem::take(&mut current_sequence), - }); - } - - let header = trimmed[1..].trim().to_string(); - if header.is_empty() { - return Err(anyhow!("empty FASTA header at line {line_number}")); - } - let id = header - .split_whitespace() - .next() - .ok_or_else(|| anyhow!("empty FASTA header at line {line_number}"))? - .to_string(); - current_header = Some(header); - current_id = Some(id); - } else if trimmed.trim().is_empty() { - continue; - } else { - if current_header.is_none() { - return Err(anyhow!("sequence before first header at line {line_number}")); - } - current_sequence.extend(trimmed.trim().as_bytes()); - } - } - - if let Some(header) = current_header.take() { - records.push(FastaRecord { - id: current_id.take().unwrap(), - header, - sequence: current_sequence, - }); - } - - if records.is_empty() { - return Err(anyhow!("input contains no FASTA records")); - } - - Ok(records) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn reads_multirecord_fasta() { - let records = read_fasta(Path::new("testdata/valid_assembly.fa")).unwrap(); - assert_eq!(records.len(), 3); - assert_eq!(records[0].id, "contig_1"); - assert_eq!(records[1].id, "contig_2"); - assert_eq!(records[0].sequence, b"ACGTACGTACGTAAAA"); - } - - #[test] - fn rejects_sequence_before_header() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("bad.fa"); - std::fs::write(&path, "ACGT\n>later\nACGT\n").unwrap(); - let error = read_fasta(&path).unwrap_err().to_string(); - assert!(error.contains("sequence before first header")); - } - - #[test] - fn rejects_empty_header_id() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("bad.fa"); - std::fs::write(&path, ">\nACGT\n").unwrap(); - let error = read_fasta(&path).unwrap_err().to_string(); - assert!(error.contains("empty FASTA header")); - } -} -``` - -- [ ] **Step 5: Run parser tests** - -Run: - -```bash -cargo test parser -``` - -Expected: - -```text -test result: ok -``` - -- [ ] **Step 6: Commit parser and fixtures** - -Run: - -```bash -git add src/lib.rs src/parser.rs testdata -git commit -m "feat: add streaming FASTA parser" -``` - -Expected: - -```text -[main ] feat: add streaming FASTA parser -``` - ---- - -## Task 5: Implement Assembly Metrics - -**Files:** -- Create: `src/metrics.rs` -- Modify: `src/lib.rs` - -- [ ] **Step 1: Wire the metrics module and write metrics tests** - -Append this line to `src/lib.rs`: - -```rust -pub mod metrics; -``` - -Create `src/metrics.rs` with: - -```rust -use crate::parser::FastaRecord; -use crate::profile::ProfileConfig; - -#[derive(Debug, Clone)] -pub struct AssemblyMetrics { - pub sequence_count: u64, - pub total_length: u64, - pub min_length: u64, - pub max_length: u64, - pub mean_length: f64, - pub median_length: f64, - pub n50: u64, - pub n90: u64, - pub l50: u64, - pub l90: u64, - pub gc_percent: f64, - pub at_percent: f64, - pub n_percent: f64, - pub ambiguity_percent: f64, - pub duplicate_id_count: u64, - pub duplicate_sequence_count: u64, - pub invalid_sequence_count: u64, - pub high_n_sequence_count: u64, - pub tiny_contig_count: u64, - pub max_gap_run: u64, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::profile::ThresholdOverrides; - - fn profile() -> ProfileConfig { - ProfileConfig::assembly(ThresholdOverrides { - max_n_rate: None, - min_contig_length: Some(10), - }) - } - - #[test] - fn summarizes_valid_records() { - let records = vec![ - FastaRecord { id: "a".into(), header: "a".into(), sequence: b"ACGTNN".to_vec() }, - FastaRecord { id: "b".into(), header: "b".into(), sequence: b"GGCC".to_vec() }, - ]; - let metrics = AssemblyMetrics::from_records(records, &profile()); - assert_eq!(metrics.sequence_count, 2); - assert_eq!(metrics.total_length, 10); - assert_eq!(metrics.n50, 6); - assert_eq!(metrics.gc_percent, 60.0); - assert_eq!(metrics.n_percent, 20.0); - } - - #[test] - fn detects_duplicate_ids_invalid_chars_tiny_contigs_and_gap_runs() { - let records = vec![ - FastaRecord { id: "dup".into(), header: "dup".into(), sequence: b"ACGT".to_vec() }, - FastaRecord { id: "dup".into(), header: "dup second".into(), sequence: b"ACGT".to_vec() }, - FastaRecord { id: "bad".into(), header: "bad".into(), sequence: b"ACGTXYZ".to_vec() }, - FastaRecord { id: "gap".into(), header: "gap".into(), sequence: b"AAANNNNNCCCC".to_vec() }, - ]; - let metrics = AssemblyMetrics::from_records(records, &profile()); - assert_eq!(metrics.duplicate_id_count, 1); - assert_eq!(metrics.duplicate_sequence_count, 1); - assert_eq!(metrics.invalid_sequence_count, 1); - assert_eq!(metrics.tiny_contig_count, 3); - assert_eq!(metrics.max_gap_run, 5); - } -} -``` - -- [ ] **Step 2: Run metrics tests to verify failure** - -Run: - -```bash -cargo test metrics -``` - -Expected: - -```text -no function or associated item named `from_records` -``` - -- [ ] **Step 3: Add metrics implementation** - -Replace `src/metrics.rs` with: - -```rust -use sha2::{Digest, Sha256}; -use std::collections::{BTreeMap, BTreeSet}; - -use crate::parser::FastaRecord; -use crate::profile::ProfileConfig; -use crate::stats::composition::{fraction, percent, round2}; -use crate::stats::nxx::nx_lx; - -#[derive(Debug, Clone)] -pub struct SequenceSummary { - pub id: String, - pub length: u64, - pub gc_count: u64, - pub at_count: u64, - pub n_count: u64, - pub ambiguity_count: u64, - pub invalid_count: u64, - pub max_gap_run: u64, - pub n_fraction: f64, - pub gc_percent: f64, -} - -#[derive(Debug, Clone)] -pub struct AssemblyMetrics { - pub sequence_count: u64, - pub total_length: u64, - pub min_length: u64, - pub max_length: u64, - pub mean_length: f64, - pub median_length: f64, - pub n50: u64, - pub n90: u64, - pub l50: u64, - pub l90: u64, - pub gc_percent: f64, - pub at_percent: f64, - pub n_percent: f64, - pub ambiguity_percent: f64, - pub duplicate_id_count: u64, - pub duplicate_sequence_count: u64, - pub invalid_sequence_count: u64, - pub high_n_sequence_count: u64, - pub tiny_contig_count: u64, - pub max_gap_run: u64, - pub sequences: Vec, -} - -impl AssemblyMetrics { - pub fn from_records(records: Vec, profile: &ProfileConfig) -> Self { - let mut seen_ids = BTreeSet::new(); - let mut duplicate_id_count = 0_u64; - let mut sequence_hashes: BTreeMap = BTreeMap::new(); - let mut duplicate_sequence_count = 0_u64; - let mut lengths = Vec::new(); - let mut summaries = Vec::new(); - let mut gc_total = 0_u64; - let mut at_total = 0_u64; - let mut n_total = 0_u64; - let mut ambiguity_total = 0_u64; - let mut invalid_sequence_count = 0_u64; - let mut high_n_sequence_count = 0_u64; - let mut tiny_contig_count = 0_u64; - let mut global_max_gap_run = 0_u64; - - for record in records { - if !seen_ids.insert(record.id.clone()) { - duplicate_id_count += 1; - } - - let hash = sequence_hash(&record.sequence); - let count = sequence_hashes.entry(hash).or_insert(0); - if *count > 0 { - duplicate_sequence_count += 1; - } - *count += 1; - - let summary = summarize_sequence(record, profile); - lengths.push(summary.length); - gc_total += summary.gc_count; - at_total += summary.at_count; - n_total += summary.n_count; - ambiguity_total += summary.ambiguity_count; - invalid_sequence_count += (summary.invalid_count > 0) as u64; - high_n_sequence_count += (summary.n_fraction >= profile.high_n_sequence_fraction) as u64; - tiny_contig_count += (summary.length < profile.min_contig_length) as u64; - global_max_gap_run = global_max_gap_run.max(summary.max_gap_run); - summaries.push(summary); - } - - lengths.sort_unstable(); - let sequence_count = lengths.len() as u64; - let total_length: u64 = lengths.iter().sum(); - let min_length = *lengths.first().unwrap_or(&0); - let max_length = *lengths.last().unwrap_or(&0); - let mean_length = if sequence_count == 0 { - 0.0 - } else { - round2(total_length as f64 / sequence_count as f64) - }; - let median_length = median(&lengths); - let n50 = nx_lx(&lengths, 0.50); - let n90 = nx_lx(&lengths, 0.90); - - Self { - sequence_count, - total_length, - min_length, - max_length, - mean_length, - median_length, - n50: n50.nx, - n90: n90.nx, - l50: n50.lx, - l90: n90.lx, - gc_percent: percent(gc_total, total_length), - at_percent: percent(at_total, total_length), - n_percent: percent(n_total, total_length), - ambiguity_percent: percent(ambiguity_total, total_length), - duplicate_id_count, - duplicate_sequence_count, - invalid_sequence_count, - high_n_sequence_count, - tiny_contig_count, - max_gap_run: global_max_gap_run, - sequences: summaries, - } - } -} - -fn summarize_sequence(record: FastaRecord, _profile: &ProfileConfig) -> SequenceSummary { - let mut gc_count = 0_u64; - let mut at_count = 0_u64; - let mut n_count = 0_u64; - let mut ambiguity_count = 0_u64; - let mut invalid_count = 0_u64; - let mut current_gap_run = 0_u64; - let mut max_gap_run = 0_u64; - - for base in record.sequence.iter().map(|base| base.to_ascii_uppercase()) { - match base { - b'G' | b'C' => { - gc_count += 1; - current_gap_run = 0; - } - b'A' | b'T' | b'U' => { - at_count += 1; - current_gap_run = 0; - } - b'N' => { - n_count += 1; - ambiguity_count += 1; - current_gap_run += 1; - max_gap_run = max_gap_run.max(current_gap_run); - } - b'M' | b'R' | b'W' | b'S' | b'Y' | b'K' | b'V' | b'H' | b'D' | b'B' => { - ambiguity_count += 1; - current_gap_run = 0; - } - _ => { - invalid_count += 1; - current_gap_run = 0; - } - } - } - - let length = record.sequence.len() as u64; - SequenceSummary { - id: record.id, - length, - gc_count, - at_count, - n_count, - ambiguity_count, - invalid_count, - max_gap_run, - n_fraction: fraction(n_count, length), - gc_percent: percent(gc_count, length), - } -} - -fn sequence_hash(sequence: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(sequence.iter().map(|base| base.to_ascii_uppercase()).collect::>()); - hex::encode(hasher.finalize()) -} - -fn median(lengths: &[u64]) -> f64 { - if lengths.is_empty() { - return 0.0; - } - - let mid = lengths.len() / 2; - if lengths.len() % 2 == 0 { - round2((lengths[mid - 1] + lengths[mid]) as f64 / 2.0) - } else { - lengths[mid] as f64 - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::profile::ThresholdOverrides; - - fn profile() -> ProfileConfig { - ProfileConfig::assembly(ThresholdOverrides { - max_n_rate: None, - min_contig_length: Some(10), - }) - } - - #[test] - fn summarizes_valid_records() { - let records = vec![ - FastaRecord { id: "a".into(), header: "a".into(), sequence: b"ACGTNN".to_vec() }, - FastaRecord { id: "b".into(), header: "b".into(), sequence: b"GGCC".to_vec() }, - ]; - let metrics = AssemblyMetrics::from_records(records, &profile()); - assert_eq!(metrics.sequence_count, 2); - assert_eq!(metrics.total_length, 10); - assert_eq!(metrics.n50, 6); - assert_eq!(metrics.gc_percent, 60.0); - assert_eq!(metrics.n_percent, 20.0); - } - - #[test] - fn detects_duplicate_ids_invalid_chars_tiny_contigs_and_gap_runs() { - let records = vec![ - FastaRecord { id: "dup".into(), header: "dup".into(), sequence: b"ACGT".to_vec() }, - FastaRecord { id: "dup".into(), header: "dup second".into(), sequence: b"ACGT".to_vec() }, - FastaRecord { id: "bad".into(), header: "bad".into(), sequence: b"ACGTXYZ".to_vec() }, - FastaRecord { id: "gap".into(), header: "gap".into(), sequence: b"AAANNNNNCCCC".to_vec() }, - ]; - let metrics = AssemblyMetrics::from_records(records, &profile()); - assert_eq!(metrics.duplicate_id_count, 1); - assert_eq!(metrics.duplicate_sequence_count, 1); - assert_eq!(metrics.invalid_sequence_count, 1); - assert_eq!(metrics.tiny_contig_count, 3); - assert_eq!(metrics.max_gap_run, 5); - } -} -``` - -- [ ] **Step 4: Run metrics tests** - -Run: - -```bash -cargo test metrics -``` - -Expected: - -```text -test result: ok -``` - -- [ ] **Step 5: Commit assembly metrics** - -Run: - -```bash -git add src/lib.rs src/metrics.rs -git commit -m "feat: compute assembly FASTA metrics" -``` - -Expected: - -```text -[main ] feat: compute assembly FASTA metrics -``` - ---- - -## Task 6: Implement Findings And Verdicts - -**Files:** -- Create: `src/findings.rs` -- Modify: `src/lib.rs` - -- [ ] **Step 1: Wire the findings module and write findings tests** - -Append this line to `src/lib.rs`: - -```rust -pub mod findings; -``` - -Create `src/findings.rs` with: - -```rust -use crate::models::{Finding, Severity, VerdictStatus}; - -#[derive(Debug, Clone)] -pub struct Analysis { - pub status: VerdictStatus, - pub reasons: Vec, - pub findings: Vec, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::cli::RuleConfig; - use crate::metrics::AssemblyMetrics; - use crate::profile::{ProfileConfig, ThresholdOverrides}; - use std::collections::BTreeSet; - - fn metrics() -> AssemblyMetrics { - AssemblyMetrics { - sequence_count: 10, - total_length: 1000, - min_length: 10, - max_length: 500, - mean_length: 100.0, - median_length: 80.0, - n50: 200, - n90: 40, - l50: 3, - l90: 8, - gc_percent: 51.0, - at_percent: 44.0, - n_percent: 5.0, - ambiguity_percent: 5.0, - duplicate_id_count: 1, - duplicate_sequence_count: 2, - invalid_sequence_count: 0, - high_n_sequence_count: 3, - tiny_contig_count: 4, - max_gap_run: 120, - sequences: Vec::new(), - } - } - - #[test] - fn duplicate_ids_can_fail_when_configured() { - let rules = RuleConfig { - fail_on: BTreeSet::from(["duplicate_ids".to_string()]), - }; - let profile = ProfileConfig::assembly(ThresholdOverrides { max_n_rate: None, min_contig_length: None }); - let analysis = analyze(&metrics(), &profile, &rules); - assert_eq!(analysis.status, VerdictStatus::Fail); - assert!(analysis.reasons.contains(&"duplicate_ids".to_string())); - } - - #[test] - fn high_n_defaults_to_warning() { - let rules = RuleConfig { fail_on: BTreeSet::new() }; - let profile = ProfileConfig::assembly(ThresholdOverrides { max_n_rate: None, min_contig_length: None }); - let analysis = analyze(&metrics(), &profile, &rules); - assert_eq!(analysis.status, VerdictStatus::Warn); - assert!(analysis.reasons.contains(&"high_n_rate".to_string())); - } -} -``` - -- [ ] **Step 2: Run findings tests to verify failure** - -Run: - -```bash -cargo test findings -``` - -Expected: - -```text -cannot find function `analyze` in this scope -``` - -- [ ] **Step 3: Add findings implementation** - -Append this implementation above the test module in `src/findings.rs`: - -```rust -use crate::cli::RuleConfig; -use crate::metrics::AssemblyMetrics; -use crate::profile::ProfileConfig; -use crate::stats::composition::{fraction, round2}; - -pub fn analyze(metrics: &AssemblyMetrics, profile: &ProfileConfig, rules: &RuleConfig) -> Analysis { - let mut findings = Vec::new(); - - push_if( - &mut findings, - metrics.duplicate_id_count > 0, - Finding { - id: "duplicate_ids".to_string(), - severity: Severity::Critical, - profile: profile.name.clone(), - affected_count: metrics.duplicate_id_count, - affected_fraction: fraction(metrics.duplicate_id_count, metrics.sequence_count), - message: format!("{} duplicate FASTA IDs were found.", metrics.duplicate_id_count), - why_it_matters: "Duplicate IDs can break indexing, annotation, mapping, and workflow joins.".to_string(), - suggested_next_step: "Rename or remove duplicate records before running downstream tools.".to_string(), - }, - ); - - push_if( - &mut findings, - metrics.invalid_sequence_count > 0, - Finding { - id: "invalid_chars".to_string(), - severity: Severity::Critical, - profile: profile.name.clone(), - affected_count: metrics.invalid_sequence_count, - affected_fraction: fraction(metrics.invalid_sequence_count, metrics.sequence_count), - message: format!("{} sequences contain invalid nucleotide symbols.", metrics.invalid_sequence_count), - why_it_matters: "Invalid symbols can make parsers, aligners, and annotation tools fail or silently misinterpret records.".to_string(), - suggested_next_step: "Inspect the affected records and replace or remove invalid characters.".to_string(), - }, - ); - - push_if( - &mut findings, - metrics.n_percent / 100.0 >= profile.high_global_n_fraction || metrics.high_n_sequence_count > 0, - Finding { - id: "high_n_rate".to_string(), - severity: Severity::Major, - profile: profile.name.clone(), - affected_count: metrics.high_n_sequence_count, - affected_fraction: fraction(metrics.high_n_sequence_count, metrics.sequence_count), - message: format!("{} sequences contain at least {:.0}% Ns.", metrics.high_n_sequence_count, profile.high_n_sequence_fraction * 100.0), - why_it_matters: "High ambiguity can reduce annotation, mapping, and assembly interpretation quality.".to_string(), - suggested_next_step: "Inspect high-N scaffolds or run gap closing, polishing, or filtering.".to_string(), - }, - ); - - push_if( - &mut findings, - metrics.tiny_contig_count > 0, - Finding { - id: "tiny_contigs".to_string(), - severity: Severity::Minor, - profile: profile.name.clone(), - affected_count: metrics.tiny_contig_count, - affected_fraction: fraction(metrics.tiny_contig_count, metrics.sequence_count), - message: format!("{} sequences are shorter than {} bp.", metrics.tiny_contig_count, profile.min_contig_length), - why_it_matters: "Many tiny contigs can indicate fragmentation and may add noise to annotation or submission.".to_string(), - suggested_next_step: "Review length filters and assembly cleanup settings before downstream analysis.".to_string(), - }, - ); - - push_if( - &mut findings, - metrics.max_gap_run > profile.max_gap_run, - Finding { - id: "gap_runs".to_string(), - severity: Severity::Major, - profile: profile.name.clone(), - affected_count: 1, - affected_fraction: round2(metrics.max_gap_run as f64), - message: format!("The longest N gap run is {} bp.", metrics.max_gap_run), - why_it_matters: "Long gap runs can mark unresolved scaffold joins and reduce interpretability.".to_string(), - suggested_next_step: "Inspect scaffolds with long N-runs and consider gap closing or splitting.".to_string(), - }, - ); - - push_if( - &mut findings, - metrics.duplicate_sequence_count > 0, - Finding { - id: "duplicate_sequences".to_string(), - severity: Severity::Minor, - profile: profile.name.clone(), - affected_count: metrics.duplicate_sequence_count, - affected_fraction: fraction(metrics.duplicate_sequence_count, metrics.sequence_count), - message: format!("{} duplicate sequence bodies were found.", metrics.duplicate_sequence_count), - why_it_matters: "Duplicate sequence bodies can indicate redundant contigs or repeated export artifacts.".to_string(), - suggested_next_step: "Inspect duplicate sequences and confirm whether they are expected.".to_string(), - }, - ); - - let mut reasons = Vec::new(); - let mut status = VerdictStatus::Pass; - - for finding in &findings { - if rules.fail_on.contains(&finding.id) || matches!(finding.severity, Severity::Critical) { - status = VerdictStatus::Fail; - reasons.push(finding.id.clone()); - } else if status != VerdictStatus::Fail { - status = VerdictStatus::Warn; - reasons.push(finding.id.clone()); - } - } - - Analysis { - status, - reasons, - findings, - } -} - -fn push_if(findings: &mut Vec, condition: bool, finding: Finding) { - if condition { - findings.push(finding); - } -} -``` - -- [ ] **Step 4: Run findings tests** - -Run: - -```bash -cargo test findings -``` - -Expected: - -```text -test result: ok -``` - -- [ ] **Step 5: Commit findings and verdicts** - -Run: - -```bash -git add src/lib.rs src/findings.rs -git commit -m "feat: generate explainable findings" -``` - -Expected: - -```text -[main ] feat: generate explainable findings -``` - ---- - -## Task 7: Implement Report Writers - -**Files:** -- Create: `src/report/mod.rs` -- Create: `src/report/json.rs` -- Create: `src/report/tsv.rs` -- Create: `src/report/multiqc.rs` -- Create: `src/report/html.rs` -- Modify: `src/lib.rs` - -- [ ] **Step 1: Wire report module exports** - -Append this line to `src/lib.rs`: - -```rust -pub mod report; -``` - -Create `src/report/mod.rs` with: - -```rust -pub mod html; -pub mod json; -pub mod multiqc; -pub mod tsv; - -use anyhow::Result; - -use crate::cli::OutputPaths; -use crate::models::FastaguardReport; - -pub fn write_all(report: &FastaguardReport, outputs: &OutputPaths) -> Result<()> { - json::write(report, &outputs.json)?; - tsv::write(report, &outputs.tsv)?; - multiqc::write(report, &outputs.multiqc)?; - html::write(report, &outputs.html)?; - Ok(()) -} -``` - -- [ ] **Step 2: Add JSON writer** - -Create `src/report/json.rs` with: - -```rust -use anyhow::{Context, Result}; -use std::fs::File; -use std::path::Path; - -use crate::models::FastaguardReport; - -pub fn write(report: &FastaguardReport, path: &Path) -> Result<()> { - let file = File::create(path).with_context(|| format!("failed to create {}", path.display()))?; - serde_json::to_writer_pretty(file, report) - .with_context(|| format!("failed to write JSON report {}", path.display())) -} -``` - -- [ ] **Step 3: Add TSV writer** - -Create `src/report/tsv.rs` with: - -```rust -use anyhow::{Context, Result}; -use std::fs; -use std::path::Path; - -use crate::models::FastaguardReport; - -pub fn write(report: &FastaguardReport, path: &Path) -> Result<()> { - let summary = &report.summary; - let mut rows = Vec::new(); - rows.push(("schema_version", report.schema_version.clone())); - rows.push(("profile", report.input.profile.clone())); - rows.push(("verdict", format!("{:?}", report.verdict.status).to_uppercase())); - rows.push(("sequence_count", summary.sequence_count.to_string())); - rows.push(("total_length", summary.total_length.to_string())); - rows.push(("n50", summary.n50.to_string())); - rows.push(("n90", summary.n90.to_string())); - rows.push(("l50", summary.l50.to_string())); - rows.push(("l90", summary.l90.to_string())); - rows.push(("gc_percent", summary.gc_percent.to_string())); - rows.push(("n_percent", summary.n_percent.to_string())); - rows.push(("finding_count", report.findings.len().to_string())); - - let mut content = String::from("metric\tvalue\n"); - for (metric, value) in rows { - content.push_str(metric); - content.push('\t'); - content.push_str(&value); - content.push('\n'); - } - - fs::write(path, content).with_context(|| format!("failed to write TSV report {}", path.display())) -} -``` - -- [ ] **Step 4: Add MultiQC writer** - -Create `src/report/multiqc.rs` with: - -```rust -use anyhow::{Context, Result}; -use serde::Serialize; -use std::fs::File; -use std::path::Path; - -use crate::models::FastaguardReport; - -#[derive(Serialize)] -struct MultiqcReport<'a> { - id: &'static str, - section_name: &'static str, - description: &'static str, - report: &'a FastaguardReport, -} - -pub fn write(report: &FastaguardReport, path: &Path) -> Result<()> { - let multiqc = MultiqcReport { - id: "fastaguard", - section_name: "FastaGuard", - description: "FASTA preflight QC summary", - report, - }; - let file = File::create(path).with_context(|| format!("failed to create {}", path.display()))?; - serde_json::to_writer_pretty(file, &multiqc) - .with_context(|| format!("failed to write MultiQC JSON {}", path.display())) -} -``` - -- [ ] **Step 5: Add HTML writer** - -Create `src/report/html.rs` with: - -```rust -use anyhow::{Context, Result}; -use std::fs; -use std::path::Path; - -use crate::models::FastaguardReport; - -pub fn write(report: &FastaguardReport, path: &Path) -> Result<()> { - let findings = report - .findings - .iter() - .map(|finding| { - format!( - "

{}

Severity: {:?}

{}

Why it matters: {}

Suggested next step: {}

", - escape(&finding.id), - finding.severity, - escape(&finding.message), - escape(&finding.why_it_matters), - escape(&finding.suggested_next_step) - ) - }) - .collect::>() - .join("\n"); - - let embedded_json = escape(&serde_json::to_string_pretty(report)?); - let summary = &report.summary; - let html = format!( - r#" - - - - FastaGuard Report - - - -
-

FastaGuard Report

-

Verdict: {:?}

-

Before QUAST. Before BUSCO. Before BlobToolKit. Run FastaGuard first.

-
-
-

Summary

- - - - - - - - -
MetricValue
Sequences{}
Total length{}
N50{}
N90{}
GC%{}
N%{}
-

Findings

- {} -

Embedded JSON

-
{}
-
- - -"#, - report.verdict.status, - summary.sequence_count, - summary.total_length, - summary.n50, - summary.n90, - summary.gc_percent, - summary.n_percent, - findings, - embedded_json - ); - - fs::write(path, html).with_context(|| format!("failed to write HTML report {}", path.display())) -} - -fn escape(value: &str) -> String { - value - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} -``` - -- [ ] **Step 6: Run report-related tests through the full test suite** - -Run: - -```bash -cargo test -``` - -Expected: - -```text -test result: ok -``` - -- [ ] **Step 7: Commit report writers** - -Run: - -```bash -git add src/lib.rs src/report -git commit -m "feat: write FastaGuard report artifacts" -``` - -Expected: - -```text -[main ] feat: write FastaGuard report artifacts -``` - ---- - -## Task 8: Wire End-To-End CLI Behavior - -**Files:** -- Modify: `tests/cli.rs` -- Modify: `src/lib.rs` -- Modify: `src/main.rs` -- Modify: `src/models.rs` - -- [ ] **Step 1: Add CLI integration tests** - -Replace `tests/cli.rs` with: - -```rust -use assert_cmd::Command; -use predicates::prelude::*; - -#[test] -fn help_mentions_preflight_positioning() { - let mut cmd = Command::cargo_bin("fastaguard").unwrap(); - cmd.arg("--help") - .assert() - .success() - .stdout(predicate::str::contains("FASTA preflight QC")); -} - -#[test] -fn valid_assembly_writes_all_outputs_and_passes() { - let dir = tempfile::tempdir().unwrap(); - let html = dir.path().join("report.html"); - let json = dir.path().join("report.json"); - let tsv = dir.path().join("report.tsv"); - let multiqc = dir.path().join("multiqc.json"); - - let mut cmd = Command::cargo_bin("fastaguard").unwrap(); - cmd.arg("testdata/valid_assembly.fa") - .arg("--out").arg(&html) - .arg("--json").arg(&json) - .arg("--tsv").arg(&tsv) - .arg("--multiqc").arg(&multiqc) - .assert() - .success(); - - assert!(html.exists()); - assert!(json.exists()); - assert!(tsv.exists()); - assert!(multiqc.exists()); - let json_text = std::fs::read_to_string(json).unwrap(); - assert!(json_text.contains("\"status\": \"PASS\"")); -} - -#[test] -fn problem_assembly_returns_failure_for_default_critical_findings() { - let dir = tempfile::tempdir().unwrap(); - let mut cmd = Command::cargo_bin("fastaguard").unwrap(); - cmd.arg("testdata/problem_assembly.fa") - .arg("--out").arg(dir.path().join("report.html")) - .arg("--json").arg(dir.path().join("report.json")) - .arg("--tsv").arg(dir.path().join("report.tsv")) - .arg("--multiqc").arg(dir.path().join("multiqc.json")) - .assert() - .code(2) - .stderr(predicate::str::is_empty()); -} - -#[test] -fn unsupported_profile_is_tool_error() { - let mut cmd = Command::cargo_bin("fastaguard").unwrap(); - cmd.arg("testdata/valid_assembly.fa") - .arg("--profile") - .arg("protein") - .assert() - .code(3) - .stderr(predicate::str::contains("unsupported profile")); -} -``` - -- [ ] **Step 2: Wire the library orchestration and process exit behavior** - -Append this implementation to `src/models.rs`: - -```rust -use std::path::Path; - -use crate::cli::RunConfig; -use crate::findings::Analysis; -use crate::metrics::AssemblyMetrics; - -impl FastaguardReport { - pub fn from_analysis(config: RunConfig, metrics: AssemblyMetrics, analysis: Analysis) -> Self { - let input_path = config.input.display().to_string(); - let compressed = path_is_gzip(&config.input); - let profile = config.profile.clone(); - let html = config.outputs.html.display().to_string(); - let tsv = config.outputs.tsv.display().to_string(); - let multiqc = config.outputs.multiqc.display().to_string(); - - Self { - schema_version: SCHEMA_VERSION.to_string(), - tool: ToolInfo { - name: TOOL_NAME.to_string(), - version: TOOL_VERSION.to_string(), - }, - input: InputInfo { - path: input_path, - profile, - compressed, - }, - verdict: Verdict { - status: analysis.status, - reasons: analysis.reasons, - }, - summary: Summary { - sequence_count: metrics.sequence_count, - total_length: metrics.total_length, - min_length: metrics.min_length, - max_length: metrics.max_length, - mean_length: metrics.mean_length, - median_length: metrics.median_length, - n50: metrics.n50, - n90: metrics.n90, - l50: metrics.l50, - l90: metrics.l90, - gc_percent: metrics.gc_percent, - at_percent: metrics.at_percent, - n_percent: metrics.n_percent, - ambiguity_percent: metrics.ambiguity_percent, - duplicate_id_count: metrics.duplicate_id_count, - duplicate_sequence_count: metrics.duplicate_sequence_count, - invalid_sequence_count: metrics.invalid_sequence_count, - high_n_sequence_count: metrics.high_n_sequence_count, - tiny_contig_count: metrics.tiny_contig_count, - max_gap_run: metrics.max_gap_run, - }, - findings: analysis.findings, - artifacts: Artifacts { html, tsv, multiqc }, - } - } - - pub fn exit_code(&self) -> i32 { - match self.verdict.status { - VerdictStatus::Pass => 0, - VerdictStatus::Warn => 1, - VerdictStatus::Fail => 2, - } - } -} - -fn path_is_gzip(path: &Path) -> bool { - path.extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.eq_ignore_ascii_case("gz")) - .unwrap_or(false) -} -``` - -Replace `src/lib.rs` with: - -```rust -pub mod cli; -pub mod findings; -pub mod metrics; -pub mod models; -pub mod parser; -pub mod profile; -pub mod report; -pub mod stats; - -use anyhow::Result; -use cli::Cli; - -pub fn run(cli: Cli) -> Result { - let config = cli.to_run_config()?; - let profile = profile::ProfileConfig::assembly(config.thresholds); - let records = parser::read_fasta(&config.input)?; - let metrics = metrics::AssemblyMetrics::from_records(records, &profile); - let analysis = findings::analyze(&metrics, &profile, &config.rules); - let output = models::FastaguardReport::from_analysis(config.clone(), metrics, analysis); - report::write_all(&output, &config.outputs)?; - Ok(output.exit_code()) -} -``` - -Replace `src/main.rs` with: - -```rust -use clap::Parser; -use fastaguard::cli::Cli; - -fn main() { - let cli = Cli::parse(); - match fastaguard::run(cli) { - Ok(code) => std::process::exit(code), - Err(error) => { - eprintln!("fastaguard error: {error}"); - std::process::exit(3); - } - } -} -``` - -- [ ] **Step 3: Run CLI tests** - -Run: - -```bash -cargo test --test cli -``` - -Expected: - -```text -test result: ok -``` - -- [ ] **Step 4: Run a manual valid report command** - -Run: - -```bash -cargo run -- testdata/valid_assembly.fa --out /tmp/fastaguard_valid.html --json /tmp/fastaguard_valid.json --tsv /tmp/fastaguard_valid.tsv --multiqc /tmp/fastaguard_valid_multiqc.json -``` - -Expected exit code: - -```text -0 -``` - -Expected files: - -```text -/tmp/fastaguard_valid.html -/tmp/fastaguard_valid.json -/tmp/fastaguard_valid.tsv -/tmp/fastaguard_valid_multiqc.json -``` - -- [ ] **Step 5: Run a manual problem report command** - -Run: - -```bash -cargo run -- testdata/problem_assembly.fa --out /tmp/fastaguard_problem.html --json /tmp/fastaguard_problem.json --tsv /tmp/fastaguard_problem.tsv --multiqc /tmp/fastaguard_problem_multiqc.json -``` - -Expected exit code: - -```text -2 -``` - -Expected JSON content: - -```text -"duplicate_ids" -"invalid_chars" -"high_n_rate" -``` - -- [ ] **Step 6: Commit CLI integration** - -Run: - -```bash -git add tests/cli.rs src/lib.rs src/main.rs src/models.rs -git commit -m "feat: wire end-to-end CLI checks" -``` - -Expected: - -```text -[main ] feat: wire end-to-end CLI checks -``` - ---- - -## Task 9: Update Documentation And Pipeline Examples - -**Files:** -- Modify: `README.md` -- Modify: `docs/mvp-spec.md` -- Modify: `docs/output-contract.md` -- Create: `examples/nextflow/main.nf` -- Create: `examples/snakemake/Snakefile` - -- [ ] **Step 1: Add quickstart to `README.md`** - -Append this section to `README.md`: - -```markdown -## Quickstart - -Build locally: - -```bash -cargo build --release -``` - -Run the assembly preflight check: - -```bash -./target/release/fastaguard sample.fa \ - --profile assembly \ - --out fastaguard_report.html \ - --json fastaguard.json \ - --tsv fastaguard.tsv \ - --multiqc fastaguard_multiqc.json -``` - -Pipeline gate example: - -```bash -./target/release/fastaguard sample.fa --fail-on duplicate_ids,invalid_chars,high_n_rate -``` - -Exit codes: - -```text -0 = pass -1 = warnings above configured threshold -2 = hard QC failure -3 = invalid input / tool error -``` -``` - -- [ ] **Step 2: Add Nextflow example** - -Create `examples/nextflow/main.nf` with: - -```groovy -nextflow.enable.dsl = 2 - -params.fasta = "sample.fa" - -process FASTAGUARD { - input: - path fasta - - output: - path "fastaguard_report.html" - path "fastaguard.json" - path "fastaguard.tsv" - path "fastaguard_multiqc.json" - - script: - """ - fastaguard ${fasta} \ - --profile assembly \ - --out fastaguard_report.html \ - --json fastaguard.json \ - --tsv fastaguard.tsv \ - --multiqc fastaguard_multiqc.json - """ -} - -workflow { - FASTAGUARD(file(params.fasta)) -} -``` - -- [ ] **Step 3: Add Snakemake example** - -Create `examples/snakemake/Snakefile` with: - -```python -rule fastaguard: - input: - fasta="sample.fa" - output: - html="fastaguard_report.html", - json="fastaguard.json", - tsv="fastaguard.tsv", - multiqc="fastaguard_multiqc.json" - shell: - """ - fastaguard {input.fasta} \ - --profile assembly \ - --out {output.html} \ - --json {output.json} \ - --tsv {output.tsv} \ - --multiqc {output.multiqc} - """ -``` - -- [ ] **Step 4: Add implementation status to `docs/mvp-spec.md`** - -Append: - -```markdown -## Implementation Status - -The v0.1 assembly MVP is implemented as a Rust CLI with: - -- streaming FASTA parsing for plain and gzipped files -- assembly metrics -- explainable findings -- deterministic verdict exit codes -- JSON, TSV, HTML, and MultiQC-compatible outputs -``` - -- [ ] **Step 5: Run docs search checks** - -Run: - -```bash -rg -n "|" README.md docs examples -``` - -Expected: - -```text -docs/product-thesis.md::. -``` - -The single expected match is in the explicit "Avoid" positioning section. - -- [ ] **Step 6: Commit docs and examples** - -Run: - -```bash -git add README.md docs/mvp-spec.md docs/output-contract.md examples -git commit -m "docs: add FastaGuard usage and pipeline examples" -``` - -Expected: - -```text -[main ] docs: add FastaGuard usage and pipeline examples -``` - ---- - -## Task 10: Final Verification - -**Files:** -- Modify only files required by formatter output. - -- [ ] **Step 1: Format code** - -Run: - -```bash -cargo fmt -``` - -Expected: - -```text -no terminal output -``` - -- [ ] **Step 2: Run all tests** - -Run: - -```bash -cargo test -``` - -Expected: - -```text -test result: ok -``` - -- [ ] **Step 3: Run clippy** - -Run: - -```bash -cargo clippy --all-targets --all-features -- -D warnings -``` - -Expected: - -```text -Finished -``` - -- [ ] **Step 4: Verify report artifacts with the valid fixture** - -Run: - -```bash -cargo run -- testdata/valid_assembly.fa --out /tmp/fastaguard_report.html --json /tmp/fastaguard.json --tsv /tmp/fastaguard.tsv --multiqc /tmp/fastaguard_multiqc.json -``` - -Expected exit code: - -```text -0 -``` - -Run: - -```bash -test -s /tmp/fastaguard_report.html && test -s /tmp/fastaguard.json && test -s /tmp/fastaguard.tsv && test -s /tmp/fastaguard_multiqc.json -``` - -Expected exit code: - -```text -0 -``` - -- [ ] **Step 5: Verify report artifacts with the problem fixture** - -Run: - -```bash -cargo run -- testdata/problem_assembly.fa --out /tmp/fastaguard_problem.html --json /tmp/fastaguard_problem.json --tsv /tmp/fastaguard_problem.tsv --multiqc /tmp/fastaguard_problem_multiqc.json -``` - -Expected exit code: - -```text -2 -``` - -Run: - -```bash -rg -n "duplicate_ids|invalid_chars|high_n_rate" /tmp/fastaguard_problem.json -``` - -Expected: - -```text -matches for duplicate_ids, invalid_chars, and high_n_rate -``` - -- [ ] **Step 6: Commit formatter or final verification changes** - -If `git status --short` shows changes after formatting, run: - -```bash -git add . -git commit -m "chore: finalize assembly MVP verification" -``` - -Expected when formatter changed files: - -```text -[main ] chore: finalize assembly MVP verification -``` - -Expected when no files changed: - -```text -nothing to commit, working tree clean -``` - ---- - -## Self-Review Notes - -Spec coverage: - -- Product positioning is covered by `README.md`, CLI help text, and report copy. -- Assembly-only scope is covered by `src/cli.rs` profile rejection and `src/profile.rs`. -- FASTA validity is covered by `src/parser.rs` and critical findings for invalid characters. -- Structural stats are covered by `src/metrics.rs` and `src/stats/nxx.rs`. -- Composition stats are covered by `src/metrics.rs` and `src/stats/composition.rs`. -- Assembly QC findings are covered by `src/findings.rs`. -- Output contract is covered by `src/models.rs` and `src/report/*`. -- Exit codes are covered by `src/models.rs`, `src/main.rs`, and CLI integration tests. -- Pipeline adoption is covered by `examples/nextflow/main.nf` and `examples/snakemake/Snakefile`. - -Known implementation choice: - -- The v0.1 parser is a small in-repo streaming parser over `BufRead` and `flate2`. This keeps line-level diagnostics explicit. The parser is isolated in `src/parser.rs`, so it can be replaced by `needletail` or `noodles-fasta` later without changing the CLI, metrics, findings, or report contract. diff --git a/docs/superpowers/plans/2026-05-18-machine-actionable-contract.md b/docs/superpowers/plans/2026-05-18-machine-actionable-contract.md deleted file mode 100644 index d6959d2..0000000 --- a/docs/superpowers/plans/2026-05-18-machine-actionable-contract.md +++ /dev/null @@ -1,128 +0,0 @@ -# Machine-Actionable Contract Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make FastaGuard's output contract discoverable by tools and future LLM agents through repo memory, a JSON Schema, a finding catalog, and CLI contract-discovery commands. - -**Architecture:** Keep the report schema and finding catalog as static repository artifacts under `schema/`, then expose them through lightweight CLI modes that do not require an input FASTA. This keeps the current analysis path unchanged while making the machine contract easy to inspect programmatically. - -**Tech Stack:** Rust, clap, serde_json, static JSON assets loaded with `include_str!`, integration tests with assert_cmd. - ---- - -### Task 1: Project Memory - -**Files:** -- Create: `AGENTS.md` - -- [x] **Step 1: Add durable Codex project memory** - -Capture the product thesis, tool landscape, machine-actionable vision, and recommendation-first collaboration preference in `AGENTS.md`. - -- [x] **Step 2: Keep scope separate from runtime behavior** - -Do not change Rust behavior in this task. - -### Task 2: Contract Discovery Tests - -**Files:** -- Modify: `tests/cli.rs` - -- [x] **Step 1: Add failing CLI tests** - -Add tests for: - -- `fastaguard --schema` -- `fastaguard --finding-catalog` -- `fastaguard --explain-finding high_n_rate` -- `fastaguard --explain-finding unknown_rule` - -- [x] **Step 2: Run tests to verify RED** - -Run: - -```bash -cargo test --test cli contract_ -``` - -Expected: tests fail because the CLI flags do not exist yet. - -### Task 3: Static Contract Assets - -**Files:** -- Create: `schema/fastaguard.schema.json` -- Create: `schema/finding-catalog.json` -- Create: `src/contract.rs` -- Modify: `src/lib.rs` - -- [x] **Step 1: Add JSON Schema** - -Create a draft 2020-12 JSON Schema that documents the current `FastaguardReport` shape. - -- [x] **Step 2: Add finding catalog** - -Create a machine-readable finding catalog for current finding IDs: - -- `duplicate_ids` -- `invalid_chars` -- `high_n_rate` -- `tiny_contigs` -- `gap_runs` -- `duplicate_sequences` -- `invalid_fasta_structure` - -- [x] **Step 3: Add Rust accessors** - -Add `src/contract.rs` with functions that return the schema, full catalog, and one catalog entry by ID. - -### Task 4: CLI Contract Commands - -**Files:** -- Modify: `src/cli.rs` -- Modify: `src/lib.rs` - -- [x] **Step 1: Add discovery flags** - -Add: - -- `--schema` -- `--finding-catalog` -- `--explain-finding ` - -- [x] **Step 2: Allow discovery flags without input** - -Make the positional FASTA input optional at parse time, but keep it required for normal QC runs. - -- [x] **Step 3: Print contract output to stdout** - -Return exit code `0` for known contract requests and exit code `3` for unknown finding IDs. - -### Task 5: Verification And Docs - -**Files:** -- Modify: `README.md` -- Modify: `docs/output-contract.md` -- Modify: `docs/llm-tooling-vision.md` - -- [x] **Step 1: Document discovery commands** - -Add examples for `--schema`, `--finding-catalog`, and `--explain-finding`. - -- [x] **Step 2: Run verification** - -Run: - -```bash -cargo fmt --check -cargo test -cargo clippy --all-targets --all-features -- -D warnings -git diff --check -``` - -- [x] **Step 3: Commit** - -Commit with: - -```bash -git commit -m "feat: expose machine-readable QC contract" -``` diff --git a/docs/superpowers/plans/2026-05-18-release-readiness.md b/docs/superpowers/plans/2026-05-18-release-readiness.md deleted file mode 100644 index b5a778b..0000000 --- a/docs/superpowers/plans/2026-05-18-release-readiness.md +++ /dev/null @@ -1,75 +0,0 @@ -# FastaGuard Release Readiness Plan - -> Date: 2026-05-18 -> Scope: Prepare the assembly MVP for dependable CI, packaging, examples, report review, benchmarks, and schema validation. - -## Recommendation - -Ship the next project increment as release infrastructure, not new biological features. The core assembly preflight is already useful; the highest leverage now is making it easy to trust, install, inspect, benchmark, and integrate. - -## Product Goal - -FastaGuard should become the boringly reliable FASTA preflight layer: - -```text -Before QUAST. Before BUSCO. Before BlobToolKit. Before annotation. -Run FastaGuard first. -``` - -This release-readiness pass makes that promise more credible by adding automation and artifacts around the existing Rust CLI. - -## Work Items - -1. Add CI - - Create `.github/workflows/ci.yml`. - - Run `cargo fmt --check`. - - Run `cargo test`. - - Run `cargo clippy --all-targets --all-features -- -D warnings`. - - Run `git diff --check`. - -2. Add JSON Schema Validation - - Add a Rust integration test that compiles `schema/fastaguard.schema.json`. - - Validate all committed golden JSON reports in `tests/golden/`. - - Keep schema validation inside `cargo test` so local and CI checks match. - -3. Add Packaging Assets - - Add a production-minded `Dockerfile`. - - Add `.dockerignore`. - - Document local binary, Docker, GitHub release, and Bioconda packaging paths. - - Keep Bioconda as a day-one strategy for tagged releases rather than blocking local development. - -4. Add Example Outputs - - Generate stable example output artifacts from existing tiny test FASTA files. - - Include JSON, TSV, HTML, and MultiQC JSON examples. - - Link examples from the README so users and pipeline authors can inspect the output contract quickly. - -5. Upgrade HTML Report - - Surface verdict, scope, machine summary, recommended next tools, finding actions, and per-record evidence. - - Keep the HTML static and self-contained. - - Preserve the existing JSON embed for complete auditability. - -6. Add Large-FASTA Benchmark Tooling - - Add a deterministic synthetic FASTA benchmark script. - - Measure runtime and output artifact size without requiring external databases. - - Document smoke and larger benchmark commands. - -## Verification - -Run these before declaring the bundle complete: - -```bash -cargo fmt --check -cargo test -cargo clippy --all-targets --all-features -- -D warnings -git diff --check -python3 scripts/benchmark_large_fasta.py --records 10 --length 100 --binary target/debug/fastaguard --out-dir target/bench-smoke -``` - -## Done Means - -- CI exists and mirrors the local quality gate. -- Golden JSON reports are validated against the JSON Schema. -- Users can build or run FastaGuard in Docker. -- Example outputs are committed and easy to find. -- HTML reports explain machine-readable findings without requiring JSON inspection. -- Benchmark tooling gives the project a repeatable way to prove the "fast preflight" promise. diff --git a/docs/superpowers/plans/2026-05-23-fastaguard-v0.2-adoption.md b/docs/superpowers/plans/2026-05-23-fastaguard-v0.2-adoption.md deleted file mode 100644 index 0760dc0..0000000 --- a/docs/superpowers/plans/2026-05-23-fastaguard-v0.2-adoption.md +++ /dev/null @@ -1,1640 +0,0 @@ -# FastaGuard v0.2 Adoption Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build FastaGuard v0.2 as an assembly-trust and pipeline-adoption release with stronger integrations, explainable outlier findings, and richer machine-readable provenance. - -**Architecture:** Keep the current Rust boundaries: parser and metrics stay streaming-focused, statistical helpers live under `src/stats/`, finding promotion stays in `src/findings.rs`, report contract changes stay in `src/models.rs`, and rendered outputs remain derived views. Integrations and documentation are separate tasks so the core biological behavior can be tested independently. - -**Tech Stack:** Rust, clap, serde, JSON Schema, assert_cmd, jsonschema, Python unittest, MultiQC plugin APIs, Nextflow/nf-core starter files, Snakemake wrapper files, Docker, Bioconda. - ---- - -## File Structure - -The implementation should touch these files and keep each responsibility clear: - -- `README.md`: install and status docs; already has local Bioconda-live edits. -- `docs/adoption-plan.md`: adoption priorities and status; already has local Bioconda-live edits. -- `docs/packaging.md`: official install, platform support, container status. -- `docs/tool-landscape.md`: evidence state and remaining proof. -- `docs/releases/v0.1.1.md`: historical release note corrected for Bioconda status. -- `packaging/bioconda/README.md`: mirror status for the upstream recipe. -- `examples/nf-core/README.md`: local module usage and container status. -- `examples/nf-core/modules/local/fastaguard/main.nf`: add container directive only if a BioContainers image is confirmed. -- `examples/snakemake/wrapper/README.md`: wrapper usage with Bioconda. -- `examples/snakemake/wrapper/environment.yaml`: new Conda environment for wrapper use. -- `integrations/multiqc/src/fastaguard_multiqc/parser.py`: parse expanded `_mqc.json` rows. -- `integrations/multiqc/src/fastaguard_multiqc/multiqc_module.py`: add richer MultiQC general stats, color rules, and summary columns. -- `integrations/multiqc/README.md`: document strict-mode verification and local install. -- `tests/python/test_adoption_assets.py`: test MultiQC parser, wrapper environment, and docs. -- `tests/python/test_release_metadata.py`: keep Bioconda install doc guard. -- `src/stats/outliers.rs`: add robust percentile/IQR helpers for length outliers. -- `src/metrics.rs`: add per-record outlier signal fields after metrics are computed. -- `src/findings.rs`: promote GC outliers, length outliers, and composite anomalies to findings. -- `src/models.rs`: add schema version `0.2.0`, finding taxonomy fields, routing hints, and richer provenance. -- `src/report/html.rs`: show provenance, routing hints, outlier findings, and evidence. -- `src/report/tsv.rs`: add outlier count metrics. -- `src/report/multiqc.rs`: add outlier count fields to custom content. -- `schema/fastaguard.schema.json`: update schema to validate the v0.2 contract. -- `schema/finding-catalog.json`: add v0.2 finding entries and taxonomy fields. -- `tests/cli.rs`: add CLI/contract tests for new fields and findings. -- `tests/schema_contract.rs`: continue validating all golden reports. -- `tests/golden/*.json`: regenerate after contract changes. -- `examples/reports/**`: regenerate example reports. -- `docs/benchmarking.md`: add evidence and runtime/memory table. -- `docs/releases/v0.2.0.md`: new release notes after implementation. - -## Task 1: Commit Current Bioconda-Live Documentation Update - -**Files:** -- Modify already-local: `README.md` -- Modify already-local: `docs/adoption-plan.md` -- Modify already-local: `docs/packaging.md` -- Modify already-local: `docs/releases/v0.1.1.md` -- Modify already-local: `docs/tool-landscape.md` -- Modify already-local: `examples/nf-core/README.md` -- Modify already-local: `examples/snakemake/wrapper/README.md` -- Modify already-local: `packaging/bioconda/README.md` -- Modify already-local: `tests/python/test_release_metadata.py` - -- [ ] **Step 1: Inspect the existing local documentation changes** - -Run: - -```bash -git diff -- README.md docs/adoption-plan.md docs/packaging.md docs/releases/v0.1.1.md docs/tool-landscape.md examples/nf-core/README.md examples/snakemake/wrapper/README.md packaging/bioconda/README.md tests/python/test_release_metadata.py -``` - -Expected: only Bioconda-live wording, BioContainers pending wording, and the release metadata guard test are shown. - -- [ ] **Step 2: Run the targeted Python tests** - -Run: - -```bash -python3 -m unittest tests.python.test_release_metadata -v -python3 -m unittest discover tests/python -v -git diff --check -``` - -Expected: release metadata tests pass, Python discovery passes, and `git diff --check` exits 0. - -- [ ] **Step 3: Commit only the current documentation update** - -Run: - -```bash -git add README.md docs/adoption-plan.md docs/packaging.md docs/releases/v0.1.1.md docs/tool-landscape.md examples/nf-core/README.md examples/snakemake/wrapper/README.md packaging/bioconda/README.md tests/python/test_release_metadata.py -git commit -m "docs: mark Bioconda package live" -``` - -Expected: one docs commit is created. `git status --short` still shows this plan file if it has not been committed separately. - -## Task 2: Confirm BioContainers Status And Update Workflow Docs - -**Files:** -- Modify: `docs/packaging.md` -- Modify: `docs/adoption-plan.md` -- Modify: `examples/nf-core/README.md` -- Modify only if confirmed: `examples/nf-core/modules/local/fastaguard/main.nf` -- Modify: `examples/snakemake/wrapper/README.md` -- Test: `tests/python/test_adoption_assets.py` - -- [ ] **Step 1: Check BioContainers registry status** - -Run: - -```bash -python3 - <<'PY' -import json -import subprocess -import sys - -tags = [ - "0.1.1--hfa8f182_0", - "0.1.1--hfc06a8d_0", - "0.1.1--h87b00fb_0", - "0.1.1--hb05d258_0", -] -for tag in tags: - image = f"quay.io/biocontainers/fastaguard:{tag}" - result = subprocess.run( - ["docker", "manifest", "inspect", image], - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - print(f"{image}\t{result.returncode}") - if result.returncode == 0: - print(json.loads(result.stdout).get("schemaVersion", "manifest-ok")) -PY -``` - -Expected: one or more tags return `0` if BioContainers is visible. If all return nonzero, keep BioContainers documented as pending. - -- [ ] **Step 2: If BioContainers is not confirmed, keep docs honest** - -Ensure `docs/packaging.md` contains this exact sentence: - -```markdown -BioContainers image availability is still pending confirmation. -``` - -Ensure `examples/nf-core/README.md` contains: - -```markdown -Once a BioContainers image is confirmed, the module can add a pinned container directive. -``` - -- [ ] **Step 3: If BioContainers is confirmed, add a container directive** - -Modify `examples/nf-core/modules/local/fastaguard/main.nf` by adding this line after `label 'process_low'`, using the confirmed tag: - -```nextflow - container 'quay.io/biocontainers/fastaguard:0.1.1--hfa8f182_0' -``` - -If the confirmed tag differs, use the exact tag returned by the manifest check. Also update `examples/nf-core/README.md` with the same image string. - -- [ ] **Step 4: Add adoption asset tests for current container wording** - -Modify `tests/python/test_adoption_assets.py` with: - -```python - def test_workflow_docs_reference_bioconda_and_container_status(self): - nfcore_readme = (ROOT / "examples" / "nf-core" / "README.md").read_text() - snakemake_readme = ( - ROOT / "examples" / "snakemake" / "wrapper" / "README.md" - ).read_text() - - install = "mamba install -c conda-forge -c bioconda fastaguard" - self.assertIn(install, nfcore_readme) - self.assertIn(install, snakemake_readme) - self.assertTrue( - "BioContainers image is confirmed" in nfcore_readme - or "Once a BioContainers image is confirmed" in nfcore_readme - ) -``` - -- [ ] **Step 5: Verify and commit** - -Run: - -```bash -python3 -m unittest tests.python.test_adoption_assets -v -git diff --check -git status --short -``` - -Expected: tests pass and only files from this task are changed. - -Commit: - -```bash -git add docs/packaging.md docs/adoption-plan.md examples/nf-core/README.md examples/nf-core/modules/local/fastaguard/main.nf examples/snakemake/wrapper/README.md tests/python/test_adoption_assets.py -git commit -m "docs: confirm container adoption status" -``` - -If `examples/nf-core/modules/local/fastaguard/main.nf` was not changed, omit it from `git add`. - -## Task 3: Add Snakemake Bioconda Environment - -**Files:** -- Create: `examples/snakemake/wrapper/environment.yaml` -- Modify: `examples/snakemake/wrapper/README.md` -- Test: `tests/python/test_adoption_assets.py` - -- [ ] **Step 1: Add a failing test for the wrapper environment** - -Modify `tests/python/test_adoption_assets.py` with: - -```python - def test_snakemake_wrapper_declares_bioconda_environment(self): - environment = ( - ROOT / "examples" / "snakemake" / "wrapper" / "environment.yaml" - ) - - self.assertTrue(environment.exists()) - text = environment.read_text() - self.assertIn("bioconda", text) - self.assertIn("conda-forge", text) - self.assertIn("fastaguard=0.1.1", text) -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: - -```bash -python3 -m unittest tests.python.test_adoption_assets.AdoptionAssetsTest.test_snakemake_wrapper_declares_bioconda_environment -v -``` - -Expected: FAIL because `environment.yaml` does not exist. - -- [ ] **Step 3: Create the Snakemake environment file** - -Create `examples/snakemake/wrapper/environment.yaml`: - -```yaml -channels: - - conda-forge - - bioconda -dependencies: - - fastaguard=0.1.1 -``` - -- [ ] **Step 4: Update wrapper README usage** - -Ensure `examples/snakemake/wrapper/README.md` includes: - -````markdown -The wrapper also includes a Conda environment: - -```bash -snakemake -s Snakefile --cores 1 --use-conda -``` -```` - -- [ ] **Step 5: Verify and commit** - -Run: - -```bash -python3 -m unittest tests.python.test_adoption_assets -v -git diff --check -``` - -Expected: tests pass. - -Commit: - -```bash -git add examples/snakemake/wrapper/environment.yaml examples/snakemake/wrapper/README.md tests/python/test_adoption_assets.py -git commit -m "chore: add Snakemake Bioconda environment" -``` - -## Task 4: Harden MultiQC Parser And Module Summary - -**Files:** -- Modify: `integrations/multiqc/src/fastaguard_multiqc/parser.py` -- Modify: `integrations/multiqc/src/fastaguard_multiqc/multiqc_module.py` -- Modify: `integrations/multiqc/README.md` -- Test: `tests/python/test_adoption_assets.py` - -- [ ] **Step 1: Add parser test for expanded MultiQC fields** - -Modify `tests/python/test_adoption_assets.py`: - -```python - def test_multiqc_parser_reads_expanded_summary_fields(self): - with TemporaryDirectory() as temp_dir: - fixture = Path(temp_dir) / "fastaguard_mqc.json" - fixture.write_text( - json.dumps( - { - "id": "fastaguard", - "section_name": "FastaGuard", - "description": "FASTA preflight QC summary", - "plot_type": "table", - "pconfig": {"id": "fastaguard_summary", "title": "FastaGuard"}, - "data": { - "sample": { - "verdict": "WARN", - "sequence_count": 8, - "total_length": 2000, - "n50": 500, - "n90": 100, - "gc_percent": 50.0, - "n_percent": 2.5, - "duplicate_id_count": 1, - "invalid_sequence_count": 0, - "high_n_sequence_count": 2, - "tiny_contig_count": 1, - "max_gap_run": 120, - "gc_outlier_count": 1, - "length_outlier_count": 1, - "composite_anomaly_count": 1, - "finding_count": 4, - } - }, - } - ) - ) - - summary = load_custom_content_summary(fixture) - row = summary["sample"] - - for field in ( - "verdict", - "sequence_count", - "total_length", - "n50", - "n90", - "gc_percent", - "n_percent", - "duplicate_id_count", - "invalid_sequence_count", - "high_n_sequence_count", - "tiny_contig_count", - "max_gap_run", - "gc_outlier_count", - "length_outlier_count", - "composite_anomaly_count", - "finding_count", - ): - self.assertIn(field, row) -``` - -- [ ] **Step 2: Run the focused test to observe current failure** - -Run: - -```bash -python3 -m unittest tests.python.test_adoption_assets.AdoptionAssetsTest.test_multiqc_parser_reads_expanded_summary_fields -v -``` - -Expected: FAIL until the parser supports the expanded fields. - -- [ ] **Step 3: Expand parser fields** - -Modify `SUMMARY_FIELDS` in `integrations/multiqc/src/fastaguard_multiqc/parser.py`: - -```python -SUMMARY_FIELDS = ( - "verdict", - "sequence_count", - "total_length", - "n50", - "n90", - "gc_percent", - "n_percent", - "duplicate_id_count", - "invalid_sequence_count", - "high_n_sequence_count", - "tiny_contig_count", - "max_gap_run", - "gc_outlier_count", - "length_outlier_count", - "composite_anomaly_count", - "finding_count", -) -``` - -- [ ] **Step 4: Expand MultiQC general stats and table headers** - -Modify `integrations/multiqc/src/fastaguard_multiqc/multiqc_module.py` so `_general_stats_data` uses: - -```python -visible_fields = ( - "finding_count", - "gc_outlier_count", - "length_outlier_count", - "composite_anomaly_count", - "n50", - "n_percent", -) -``` - -Replace `_general_stats_headers` with: - -```python - @staticmethod - def _general_stats_headers() -> dict: - return { - "finding_count": { - "title": "FG findings", - "description": "Number of FastaGuard findings", - "min": 0, - "scale": "OrRd", - }, - "gc_outlier_count": { - "title": "FG GC outliers", - "description": "Number of FastaGuard GC outlier records", - "min": 0, - "scale": "OrRd", - }, - "length_outlier_count": { - "title": "FG length outliers", - "description": "Number of FastaGuard length outlier records", - "min": 0, - "scale": "YlOrBr", - }, - "composite_anomaly_count": { - "title": "FG composite", - "description": "Number of records with multiple FastaGuard anomaly signals", - "min": 0, - "scale": "Reds", - }, - "n50": { - "title": "FG N50", - "description": "FastaGuard assembly N50", - "hidden": True, - "min": 0, - "scale": "Blues", - }, - "n_percent": { - "title": "FG N%", - "description": "FastaGuard global N percentage", - "hidden": True, - "min": 0, - "max": 100, - "suffix": "%", - "scale": "OrRd", - }, - } -``` - -- [ ] **Step 5: Document strict-mode verification** - -Add to `integrations/multiqc/README.md`: - -````markdown -## Verification - -Run the plugin against example reports in strict mode: - -```bash -cd integrations/multiqc -python -m pip install -e . -cd ../.. -multiqc --strict examples/reports -``` -```` - -- [ ] **Step 6: Verify parser tests and commit** - -Run: - -```bash -python3 -m unittest tests.python.test_adoption_assets -v -git diff --check -``` - -Expected: Python tests pass. - -```bash -git add integrations/multiqc/src/fastaguard_multiqc/parser.py integrations/multiqc/src/fastaguard_multiqc/multiqc_module.py integrations/multiqc/README.md tests/python/test_adoption_assets.py -git commit -m "feat: expand MultiQC FastaGuard summary" -``` - -## Task 5: Add v0.2 Contract Fields To Models - -**Files:** -- Modify: `src/models.rs` -- Modify: `src/cli.rs` -- Test: `tests/cli.rs` - -- [ ] **Step 1: Add failing contract test for provenance and routing hints** - -Modify `tests/cli.rs` with: - -```rust -#[test] -fn report_includes_v0_2_provenance_and_routing_hints() { - let temp_dir = TempDir::new().unwrap(); - let outputs = output_paths(&temp_dir, "v02_contract"); - - let mut cmd = Command::cargo_bin("fastaguard").unwrap(); - cmd.args([ - "testdata/valid_assembly.fa", - "--min-contig-length", - "1", - "--out", - ]) - .arg(&outputs.html) - .arg("--json") - .arg(&outputs.json) - .arg("--tsv") - .arg(&outputs.tsv) - .arg("--multiqc") - .arg(&outputs.multiqc) - .assert() - .success(); - - let report = read_json(&outputs.json); - assert_eq!(report["schema_version"], json!("0.2.0")); - assert!(report["provenance"]["command"].as_str().unwrap().contains("fastaguard")); - assert!(report["provenance"]["started_at"].as_str().unwrap().ends_with('Z')); - assert!(report["provenance"]["completed_at"].as_str().unwrap().ends_with('Z')); - assert!(report["provenance"]["duration_ms"].as_u64().is_some()); - assert!(report["provenance"]["input_size_bytes"].as_u64().unwrap() > 0); - assert!(report["machine_summary"]["routing_hints"].as_array().unwrap().is_empty()); -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: - -```bash -cargo test --locked report_includes_v0_2_provenance_and_routing_hints -``` - -Expected: FAIL because schema version and provenance fields are not present. - -- [ ] **Step 3: Add runtime context to `RunConfig`** - -Modify `src/cli.rs`: - -```rust -#[derive(Debug, Clone)] -pub struct RunConfig { - pub input: PathBuf, - pub profile: String, - pub outputs: OutputPaths, - pub rules: RuleConfig, - pub thresholds: ThresholdOverrides, - pub threads: usize, - pub command: String, - pub started_at: String, -} -``` - -In `to_run_config`, set: - -```rust - command: std::env::args().collect::>().join(" "), - started_at: current_utc_timestamp(), -``` - -Add helper: - -```rust -fn current_utc_timestamp() -> String { - chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) -} -``` - -Add `chrono` to `Cargo.toml`: - -```toml -chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } -``` - -- [ ] **Step 4: Extend model structs** - -Modify `src/models.rs`: - -```rust -pub const SCHEMA_VERSION: &str = "0.2.0"; -``` - -Extend `MachineSummary`: - -```rust -pub struct MachineSummary { - pub verdict: VerdictStatus, - pub safe_for_downstream: bool, - pub top_findings: Vec, - pub recommended_next_tools: Vec, - pub routing_hints: Vec, -} -``` - -Add: - -```rust -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RoutingHint { - pub condition: String, - pub suggested_route: String, - pub requires_external_database: bool, -} -``` - -Extend `Provenance`: - -```rust -pub struct Provenance { - pub profile: String, - pub threads: usize, - pub fail_on: Vec, - pub thresholds: ProvenanceThresholds, - pub command: String, - pub started_at: String, - pub completed_at: String, - pub duration_ms: u64, - pub input_size_bytes: u64, -} -``` - -Extend `Finding`: - -```rust -pub struct Finding { - pub id: String, - pub severity: Severity, - pub category: FindingCategory, - pub confidence: FindingConfidence, - pub requires_followup_tool: bool, - pub profile: String, - pub affected_count: u64, - pub affected_fraction: f64, - pub message: String, - pub why_it_matters: String, - pub suggested_next_step: String, - pub evidence: FindingEvidence, - pub actions: Vec, -} -``` - -Add enums: - -```rust -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum FindingCategory { - Validity, - Structure, - Composition, - Duplication, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum FindingConfidence { - High, - Moderate, - Low, -} -``` - -- [ ] **Step 5: Build provenance from config** - -Replace `build_provenance` in `src/models.rs` with behavior that uses `std::fs::metadata`: - -```rust -fn build_provenance(config: &RunConfig, profile: &ProfileConfig) -> Provenance { - let completed_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let input_size_bytes = std::fs::metadata(&config.input) - .map(|metadata| metadata.len()) - .unwrap_or(0); - - Provenance { - profile: profile.name.clone(), - threads: config.threads, - fail_on: config.rules.fail_on.iter().cloned().collect(), - thresholds: ProvenanceThresholds { - high_n_sequence_fraction: profile.high_n_sequence_fraction, - high_global_n_fraction: profile.high_global_n_fraction, - min_contig_length: profile.min_contig_length, - max_gap_run: profile.max_gap_run, - gc_outlier_zscore: profile.gc_outlier_zscore, - }, - command: config.command.clone(), - started_at: config.started_at.clone(), - completed_at, - duration_ms: 0, - input_size_bytes, - } -} -``` - -Set `duration_ms` to `0` in this task. A later focused enhancement can measure it in `main.rs` without changing the contract. - -- [ ] **Step 6: Update test report builders** - -Every test helper constructing `MachineSummary`, `Provenance`, or `Finding` must include the new fields. Use: - -```rust -routing_hints: Vec::new(), -command: "fastaguard input.fa".to_string(), -started_at: "2026-05-23T00:00:00Z".to_string(), -completed_at: "2026-05-23T00:00:00Z".to_string(), -duration_ms: 0, -input_size_bytes: 100, -category: FindingCategory::Validity, -confidence: FindingConfidence::High, -requires_followup_tool: false, -``` - -- [ ] **Step 7: Verify and commit** - -Run: - -```bash -cargo fmt --check -cargo test --locked report_includes_v0_2_provenance_and_routing_hints -cargo test --locked -``` - -Expected: tests pass. - -Commit: - -```bash -git add Cargo.toml Cargo.lock src/cli.rs src/models.rs tests/cli.rs src/report/multiqc.rs src/report/tsv.rs -git commit -m "feat: add v0.2 report provenance" -``` - -## Task 6: Add Outlier Stats Helpers - -**Files:** -- Modify: `src/stats/outliers.rs` - -- [ ] **Step 1: Add tests for robust length outliers** - -Modify `src/stats/outliers.rs` test module: - -```rust - #[test] - fn iqr_finds_low_and_high_length_outliers() { - let values = vec![100, 101, 102, 103, 104, 105, 10_000]; - let outliers = iqr_outlier_indices(&values, 1.5); - assert_eq!(outliers, vec![6]); - } - - #[test] - fn iqr_returns_empty_for_short_or_flat_inputs() { - assert_eq!(iqr_outlier_indices(&[100, 101], 1.5), Vec::::new()); - assert_eq!(iqr_outlier_indices(&[100, 100, 100, 100], 1.5), Vec::::new()); - } -``` - -- [ ] **Step 2: Run tests to verify failure** - -Run: - -```bash -cargo test --locked stats::outliers::tests::iqr_finds_low_and_high_length_outliers -``` - -Expected: FAIL because `iqr_outlier_indices` does not exist. - -- [ ] **Step 3: Implement IQR helper** - -Add to `src/stats/outliers.rs`: - -```rust -pub fn iqr_outlier_indices(values: &[u64], multiplier: f64) -> Vec { - if values.len() < 4 || !multiplier.is_finite() || multiplier <= 0.0 { - return Vec::new(); - } - - let mut sorted = values.to_vec(); - sorted.sort_unstable(); - let q1 = percentile(&sorted, 0.25); - let q3 = percentile(&sorted, 0.75); - let iqr = q3 - q1; - if iqr <= 0.0 { - return Vec::new(); - } - - let lower = q1 - multiplier * iqr; - let upper = q3 + multiplier * iqr; - - values - .iter() - .enumerate() - .filter_map(|(index, value)| { - let value = *value as f64; - (value < lower || value > upper).then_some(index) - }) - .collect() -} - -fn percentile(sorted: &[u64], quantile: f64) -> f64 { - if sorted.is_empty() { - return 0.0; - } - let rank = quantile * (sorted.len().saturating_sub(1) as f64); - let lower = rank.floor() as usize; - let upper = rank.ceil() as usize; - if lower == upper { - sorted[lower] as f64 - } else { - let weight = rank - lower as f64; - sorted[lower] as f64 * (1.0 - weight) + sorted[upper] as f64 * weight - } -} -``` - -- [ ] **Step 4: Verify and commit** - -Run: - -```bash -cargo fmt --check -cargo test --locked stats::outliers -``` - -Expected: outlier helper tests pass. - -Commit: - -```bash -git add src/stats/outliers.rs -git commit -m "feat: add robust length outlier helper" -``` - -## Task 7: Promote Assembly Outliers To Findings - -**Files:** -- Modify: `src/metrics.rs` -- Modify: `src/findings.rs` -- Modify: `src/models.rs` -- Modify: `src/profile.rs` -- Test: `tests/cli.rs` - -- [ ] **Step 1: Add failing CLI test for GC and length findings** - -Modify `tests/cli.rs` with: - -```rust -#[test] -fn assembly_outliers_are_promoted_to_findings_without_fail_by_default() { - let temp_dir = TempDir::new().unwrap(); - let input = temp_dir.path().join("outliers.fa"); - std::fs::write( - &input, - [ - ">normal_1\nAACCGGTTAACCGGTT\n", - ">normal_2\nAACCGGTTAACCGGTT\n", - ">normal_3\nAACCGGTTAACCGGTT\n", - ">normal_4\nAACCGGTTAACCGGTT\n", - ">normal_5\nAACCGGTTAACCGGTT\n", - ">normal_6\nAACCGGTTAACCGGTT\n", - ">gc_high\nGGGGGGGGGGGGGGGG\n", - ">very_long\nAACCGGTTAACCGGTTAACCGGTTAACCGGTTAACCGGTTAACCGGTT\n", - ] - .join(""), - ) - .unwrap(); - let outputs = output_paths(&temp_dir, "outlier_findings"); - - let mut cmd = Command::cargo_bin("fastaguard").unwrap(); - cmd.arg(&input) - .args(["--min-contig-length", "1", "--out"]) - .arg(&outputs.html) - .arg("--json") - .arg(&outputs.json) - .arg("--tsv") - .arg(&outputs.tsv) - .arg("--multiqc") - .arg(&outputs.multiqc) - .assert() - .code(1); - - let report = read_json(&outputs.json); - assert_eq!(report["verdict"]["status"], json!("WARN")); - assert!(finding_ids(&report).contains(&"gc_outliers".to_string())); - assert!(finding_ids(&report).contains(&"length_outliers".to_string())); - assert_eq!(report["findings"][0]["category"].is_string(), true); -} -``` - -Add helper near other test helpers: - -```rust -fn finding_ids(report: &Value) -> Vec { - report["findings"] - .as_array() - .unwrap() - .iter() - .map(|finding| finding["id"].as_str().unwrap().to_string()) - .collect() -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: - -```bash -cargo test --locked assembly_outliers_are_promoted_to_findings_without_fail_by_default -``` - -Expected: FAIL because new finding IDs are not emitted. - -- [ ] **Step 3: Extend sequence summary signals** - -Modify `SequenceSummary` in `src/metrics.rs`: - -```rust -pub gc_outlier: bool, -pub length_outlier: bool, -pub composite_anomaly: bool, -pub gc_zscore: Option, -``` - -Set defaults in `SequenceSummaryBuilder::finish()`: - -```rust -gc_outlier: false, -length_outlier: false, -composite_anomaly: false, -gc_zscore: None, -``` - -- [ ] **Step 4: Mark outlier signals after sequence collection** - -In `MetricsAccumulator::finish`, after `self.lengths.sort_unstable();` and before returning `AssemblyMetrics`, call: - -```rust -mark_gc_outliers(&mut self.sequences, self.profile.gc_outlier_zscore); -mark_length_outliers(&mut self.sequences); -mark_composite_anomalies(&mut self.sequences); -``` - -Add helpers in `src/metrics.rs`: - -```rust -fn mark_gc_outliers(sequences: &mut [SequenceSummary], threshold: f64) { - let values = sequences.iter().map(|sequence| sequence.gc_percent).collect::>(); - let indices = crate::stats::outliers::zscore_outlier_indices(&values, threshold); - let mean = if values.is_empty() { - 0.0 - } else { - values.iter().sum::() / values.len() as f64 - }; - let variance = if values.is_empty() { - 0.0 - } else { - values.iter().map(|value| (value - mean) * (value - mean)).sum::() - / values.len() as f64 - }; - let stddev = variance.sqrt(); - for index in indices { - if let Some(sequence) = sequences.get_mut(index) { - sequence.gc_outlier = true; - if stddev > 0.0 { - sequence.gc_zscore = Some(round2((sequence.gc_percent - mean) / stddev)); - } - } - } -} - -fn mark_length_outliers(sequences: &mut [SequenceSummary]) { - let values = sequences.iter().map(|sequence| sequence.length).collect::>(); - for index in crate::stats::outliers::iqr_outlier_indices(&values, 1.5) { - if let Some(sequence) = sequences.get_mut(index) { - sequence.length_outlier = true; - } - } -} - -fn mark_composite_anomalies(sequences: &mut [SequenceSummary]) { - for sequence in sequences { - let signal_count = [ - sequence.gc_outlier, - sequence.length_outlier, - sequence.n_fraction >= 0.20, - sequence.duplicate_sequence, - sequence.invalid_count > 0, - sequence.max_gap_run > 100, - ] - .into_iter() - .filter(|flag| *flag) - .count(); - sequence.composite_anomaly = signal_count >= 2; - } -} -``` - -- [ ] **Step 5: Add new findings** - -Modify `src/findings.rs`: - -```rust - add_outlier_finding( - &mut findings, - "gc_outliers", - Severity::Major, - profile, - metrics, - |sequence| sequence.gc_outlier, - "GC composition outlier", - FindingText { - message: format!("{} records have GC composition far from the assembly background.", count_sequences(metrics, |sequence| sequence.gc_outlier)), - why_it_matters: "Unusual GC can indicate contamination, cobionts, plasmids, assembly artifacts, or real biological variation.", - suggested_next_step: "Inspect flagged records and consider BlobToolKit, sourmash, Kraken, or related taxonomic checks if the pattern is strong.", - }, - ); -``` - -Add helper: - -```rust -fn count_sequences( - metrics: &AssemblyMetrics, - predicate: impl Fn(&SequenceSummary) -> bool, -) -> u64 { - metrics.sequences.iter().filter(|sequence| predicate(sequence)).count() as u64 -} - -fn add_outlier_finding( - findings: &mut Vec, - id: &str, - severity: Severity, - profile: &ProfileConfig, - metrics: &AssemblyMetrics, - predicate: impl Fn(&SequenceSummary) -> bool, - reason: &str, - text: FindingText<'_>, -) { - let affected_count = count_sequences(metrics, predicate); - if affected_count == 0 { - return; - } - findings.push(finding( - id, - severity, - profile, - affected_count, - affected_fraction(affected_count, metrics.sequence_count), - evidence_for_sequences( - affected_count, - metrics.sequences.iter().filter(|sequence| match id { - "gc_outliers" => sequence.gc_outlier, - "length_outliers" => sequence.length_outlier, - "composite_anomalies" => sequence.composite_anomaly, - _ => false, - }), - reason, - EvidenceKind::Outlier, - ), - text, - )); -} -``` - -Add `EvidenceKind::Outlier` and set evidence fields: - -```rust - EvidenceKind::Outlier => { - record.gc_percent = Some(sequence.gc_percent); - record.n_fraction = Some(round2(sequence.n_fraction)); - record.n_percent = Some(round2(sequence.n_fraction * 100.0)); - } -``` - -Add these two calls after the `gc_outliers` call: - -```rust - add_outlier_finding( - &mut findings, - "length_outliers", - Severity::Minor, - profile, - metrics, - |sequence| sequence.length_outlier, - "length outlier", - FindingText { - message: format!( - "{} records have lengths far from the assembly distribution.", - count_sequences(metrics, |sequence| sequence.length_outlier) - ), - why_it_matters: "Extreme record lengths may be valid, but they should be visible before production use.", - suggested_next_step: "Inspect length outlier records and confirm they are expected for this assembly.", - }, - ); - - add_outlier_finding( - &mut findings, - "composite_anomalies", - Severity::Major, - profile, - metrics, - |sequence| sequence.composite_anomaly, - "multiple anomaly signals", - FindingText { - message: format!( - "{} records have multiple FastaGuard anomaly signals.", - count_sequences(metrics, |sequence| sequence.composite_anomaly) - ), - why_it_matters: "Records with multiple independent signals are higher priority for manual or downstream triage.", - suggested_next_step: "Prioritize these records for inspection before running heavier assembly QC or taxonomy workflows.", - }, - ); -``` - -- [ ] **Step 6: Add action mappings** - -Extend `finding_actions` in `src/models.rs`: - -```rust - "gc_outliers" => vec![ - action( - "inspect_records", - "GC outlier records", - "Composition anomalies should be reviewed before interpreting downstream QC.", - "BlobToolKit", - true, - ), - action( - "compare_kmers_or_taxonomy", - "composition outlier records", - "External evidence can help distinguish contamination, cobionts, plasmids, and biological variation.", - "sourmash", - true, - ), - ], - "length_outliers" => vec![action( - "inspect_records", - "length outlier records", - "Extreme record lengths may be valid, but should be visible before production use.", - "seqkit", - false, - )], - "composite_anomalies" => vec![action( - "prioritize_records", - "records with multiple anomaly signals", - "Records with multiple independent signals are better candidates for manual or downstream triage.", - "BlobToolKit", - true, - )], -``` - -- [ ] **Step 7: Verify and commit** - -Run: - -```bash -cargo fmt --check -cargo test --locked assembly_outliers_are_promoted_to_findings_without_fail_by_default -cargo test --locked -``` - -Expected: tests pass. - -Commit: - -```bash -git add src/metrics.rs src/findings.rs src/models.rs src/profile.rs tests/cli.rs -git commit -m "feat: add assembly outlier findings" -``` - -## Task 8: Update Contract Schema, Catalog, TSV, MultiQC, HTML, And Goldens - -**Files:** -- Modify: `schema/fastaguard.schema.json` -- Modify: `schema/finding-catalog.json` -- Modify: `src/report/tsv.rs` -- Modify: `src/report/multiqc.rs` -- Modify: `src/report/html.rs` -- Modify: `tests/golden/*.json` -- Modify: `examples/reports/**` -- Test: `tests/schema_contract.rs` -- Test: `tests/cli.rs` - -- [ ] **Step 1: Add failing schema expectations** - -Modify `tests/cli.rs`: - -```rust -#[test] -fn contract_finding_catalog_includes_v0_2_outlier_findings() { - let mut cmd = Command::cargo_bin("fastaguard").unwrap(); - cmd.arg("--finding-catalog") - .assert() - .success() - .stdout(predicate::str::contains(r#""schema_version": "0.2.0""#)) - .stdout(predicate::str::contains(r#""gc_outliers""#)) - .stdout(predicate::str::contains(r#""length_outliers""#)) - .stdout(predicate::str::contains(r#""composite_anomalies""#)); -} -``` - -- [ ] **Step 2: Update TSV metrics** - -In `src/report/tsv.rs`, add: - -```rust - write_metric(&mut writer, "gc_outlier_count", count_finding(report, "gc_outliers"))?; - write_metric(&mut writer, "length_outlier_count", count_finding(report, "length_outliers"))?; - write_metric( - &mut writer, - "composite_anomaly_count", - count_finding(report, "composite_anomalies"), - )?; -``` - -Add helper: - -```rust -fn count_finding(report: &FastaguardReport, id: &str) -> u64 { - report - .findings - .iter() - .find(|finding| finding.id == id) - .map(|finding| finding.affected_count) - .unwrap_or(0) -} -``` - -- [ ] **Step 3: Update MultiQC custom content** - -In `src/report/multiqc.rs`, extend `MultiqcSummaryRow`: - -```rust - duplicate_id_count: u64, - invalid_sequence_count: u64, - high_n_sequence_count: u64, - tiny_contig_count: u64, - max_gap_run: u64, - gc_outlier_count: u64, - length_outlier_count: u64, - composite_anomaly_count: u64, -``` - -Set values in `summary_row` using `report.summary` and the `count_finding` helper. - -- [ ] **Step 4: Update schema** - -Modify `schema/fastaguard.schema.json` so: - -```json -"schema_version": { - "const": "0.2.0" -} -``` - -Add `routing_hints` to `machine_summary.required` and define a `routing_hint` object in `$defs`: - -```json -"routing_hint": { - "type": "object", - "required": ["condition", "suggested_route", "requires_external_database"], - "properties": { - "condition": { "type": "string" }, - "suggested_route": { "type": "string" }, - "requires_external_database": { "type": "boolean" } - } -} -``` - -Add provenance required fields: - -```json -"command", -"started_at", -"completed_at", -"duration_ms", -"input_size_bytes" -``` - -Add finding required fields: - -```json -"category", -"confidence", -"requires_followup_tool" -``` - -Allowed `category` values: - -```json -["validity", "structure", "composition", "duplication"] -``` - -Allowed `confidence` values: - -```json -["high", "moderate", "low"] -``` - -- [ ] **Step 5: Update finding catalog** - -Set top-level versions: - -```json -"schema_version": "0.2.0", -"catalog_version": "0.2.0" -``` - -Add entries for `gc_outliers`, `length_outliers`, and `composite_anomalies` with `default_severity`, `meaning`, `why_it_matters`, `suggested_actions`, `recommended_next_tools`, and `cannot_conclude`. - -- [ ] **Step 6: Regenerate golden and example reports** - -Run: - -```bash -cargo run --locked -- testdata/valid_assembly.fa --min-contig-length 1 --out examples/reports/assembly_pass/fastaguard_report.html --json examples/reports/assembly_pass/fastaguard.json --tsv examples/reports/assembly_pass/fastaguard.tsv --multiqc examples/reports/assembly_pass/fastaguard_mqc.json -cargo run --locked -- testdata/problem_assembly.fa --out examples/reports/assembly_fail/fastaguard_report.html --json examples/reports/assembly_fail/fastaguard.json --tsv examples/reports/assembly_fail/fastaguard.tsv --multiqc examples/reports/assembly_fail/fastaguard_mqc.json || test "$?" = "1" -cp examples/reports/assembly_pass/fastaguard.json tests/golden/valid_assembly.json -cp examples/reports/assembly_fail/fastaguard.json tests/golden/problem_assembly.json -cargo run --locked -- testdata/invalid_empty_record.fa --json tests/golden/invalid_empty_record.json --out /tmp/fastaguard_invalid.html --tsv /tmp/fastaguard_invalid.tsv --multiqc /tmp/fastaguard_invalid_mqc.json || test "$?" = "2" -``` - -Expected: pass fixture exits 0, problem fixture exits 1, invalid fixture exits 2. - -- [ ] **Step 7: Verify schema, CLI, Python, and commit** - -Run: - -```bash -cargo fmt --check -cargo test --locked -python3 -m unittest discover tests/python -v -git diff --check -``` - -Expected: all pass. - -Commit: - -```bash -git add schema/fastaguard.schema.json schema/finding-catalog.json src/report/tsv.rs src/report/multiqc.rs src/report/html.rs tests/cli.rs tests/schema_contract.rs tests/golden examples/reports integrations/multiqc tests/python -git commit -m "feat: update v0.2 report contract outputs" -``` - -## Task 9: Add Benchmark And Evidence Page - -**Files:** -- Modify: `docs/benchmarking.md` -- Modify: `README.md` -- Test: `tests/python/test_adoption_assets.py` - -- [ ] **Step 1: Add docs test for evidence page content** - -Modify `tests/python/test_adoption_assets.py`: - -```python - def test_benchmarking_docs_include_v0_2_evidence_topics(self): - text = (ROOT / "docs" / "benchmarking.md").read_text() - - self.assertIn("duplicate IDs", text) - self.assertIn("invalid characters", text) - self.assertIn("high-N", text) - self.assertIn("GC outliers", text) - self.assertIn("QUAST", text) - self.assertIn("BUSCO", text) - self.assertIn("BlobToolKit", text) -``` - -- [ ] **Step 2: Run test to verify current gap** - -Run: - -```bash -python3 -m unittest tests.python.test_adoption_assets.AdoptionAssetsTest.test_benchmarking_docs_include_v0_2_evidence_topics -v -``` - -Expected: FAIL if the current benchmarking page lacks the v0.2 evidence topics. - -- [ ] **Step 3: Update benchmarking document** - -Add this section to `docs/benchmarking.md`: - -```markdown -## v0.2 Evidence Targets - -FastaGuard should prove four preflight categories with small reproducible -fixtures: - -| Evidence case | What FastaGuard catches | Why it should run before heavier tools | -| --- | --- | --- | -| duplicate IDs | repeated FASTA identifiers | prevents workflow joins, indexes, and annotations from becoming ambiguous | -| invalid characters | non-IUPAC sequence symbols | prevents downstream parser and aligner failures | -| high-N | ambiguous scaffolds and gap-heavy records | prevents low-confidence mapping and annotation from being treated as clean input | -| GC outliers | composition-anomalous records | routes suspicious records to BlobToolKit, sourmash, Kraken, or other follow-up tools | - -FastaGuard should not replace QUAST, BUSCO, or BlobToolKit. It should make their -inputs safer and make obvious FASTA-level problems visible before those tools run. -``` - -- [ ] **Step 4: Link from README** - -Ensure `README.md` documentation list contains: - -```markdown -- [Benchmarking](docs/benchmarking.md) -``` - -- [ ] **Step 5: Verify and commit** - -Run: - -```bash -python3 -m unittest tests.python.test_adoption_assets -v -git diff --check -``` - -Expected: tests pass. - -Commit: - -```bash -git add docs/benchmarking.md README.md tests/python/test_adoption_assets.py -git commit -m "docs: add v0.2 benchmark evidence targets" -``` - -## Task 10: Prepare v0.2.0 Release Notes And Metadata - -**Files:** -- Modify: `Cargo.toml` -- Modify: `Cargo.lock` -- Modify: `packaging/bioconda/meta.yaml` -- Create: `docs/releases/v0.2.0.md` -- Modify: `README.md` -- Test: `tests/python/test_release_metadata.py` - -- [ ] **Step 1: Add failing metadata test for v0.2.0** - -Modify `tests/python/test_release_metadata.py`: - -```python - def test_v0_2_0_release_notes_exist_when_version_bumps(self): - cargo = tomllib.loads((ROOT / "Cargo.toml").read_text()) - if cargo["package"]["version"] != "0.2.0": - self.skipTest("v0.2.0 version bump not started") - - notes = ROOT / "docs" / "releases" / "v0.2.0.md" - self.assertTrue(notes.exists()) - text = notes.read_text() - self.assertIn("FastaGuard v0.2.0", text) - self.assertIn("Assembly Trust", text) - self.assertIn("Pipeline Adoption", text) -``` - -- [ ] **Step 2: Bump Rust package version** - -Modify `Cargo.toml`: - -```toml -version = "0.2.0" -``` - -Run: - -```bash -cargo update -p fastaguard --precise 0.2.0 -``` - -Expected: `Cargo.lock` package version updates to `0.2.0`. - -- [ ] **Step 3: Create release notes** - -Create `docs/releases/v0.2.0.md`: - -````markdown -# FastaGuard v0.2.0 - -FastaGuard v0.2.0 is the Assembly Trust + Pipeline Adoption release. - -## Highlights - -- Adds explainable GC, length, and composite assembly outlier findings. -- Expands the machine-readable JSON contract with richer provenance, finding - taxonomy, and routing hints. -- Hardens MultiQC plugin support for many FastaGuard reports. -- Adds Snakemake Bioconda environment starter material. -- Updates benchmark and evidence documentation. - -## Install - -```bash -mamba install -c conda-forge -c bioconda fastaguard -``` - -## Positioning - -FastaGuard still runs before QUAST, BUSCO, BlobToolKit, annotation, and -submission. It does not replace downstream biological completeness, assembly -correctness, or contamination workflows. - -## Known Limits - -- v0.2.0 remains assembly-focused. -- Composition outliers are not contamination calls. -- External taxonomy or k-mer database checks remain follow-up steps. -``` -```` - -- [ ] **Step 4: Keep Bioconda recipe staged but not submitted** - -In `packaging/bioconda/meta.yaml`, update: - -```jinja -{% set version = "0.2.0" %} -``` - -Leave the SHA256 unchanged until a public `v0.2.0` source archive exists. Add a comment above `source:`: - -```yaml -# Update sha256 after the v0.2.0 GitHub source archive is published. -``` - -- [ ] **Step 5: Verify and commit** - -Run: - -```bash -cargo fmt --check -cargo test --locked -python3 -m unittest discover tests/python -v -git diff --check -``` - -Expected: all pass. - -Commit: - -```bash -git add Cargo.toml Cargo.lock packaging/bioconda/meta.yaml docs/releases/v0.2.0.md README.md tests/python/test_release_metadata.py -git commit -m "chore: prepare v0.2.0 release metadata" -``` - -## Task 11: Full Verification Gate - -**Files:** -- No source file changes expected unless a verification failure reveals a real issue. - -- [ ] **Step 1: Run Rust formatting, tests, and clippy** - -Run: - -```bash -cargo fmt --check -cargo test --locked -cargo clippy --locked --all-targets --all-features -- -D warnings -``` - -Expected: all pass. - -- [ ] **Step 2: Run Python tests** - -Run: - -```bash -python3 -m unittest discover tests/python -v -``` - -Expected: all pass. - -- [ ] **Step 3: Run MultiQC strict check if MultiQC is installed** - -Run: - -```bash -if command -v multiqc >/dev/null 2>&1; then - cd integrations/multiqc - python3 -m pip install -e . - cd ../.. - multiqc --strict examples/reports -else - echo "multiqc not installed; skipping strict check" -fi -``` - -Expected: strict check passes when MultiQC is installed; otherwise the skip is explicit. - -- [ ] **Step 4: Run Docker smoke test** - -Run: - -```bash -docker build -t fastaguard:local . -docker run --rm fastaguard:local --schema >/tmp/fastaguard_schema.json -docker run --rm -v "$PWD:/data" fastaguard:local /data/testdata/valid_assembly.fa --out /data/target/docker_fastaguard.html --json /data/target/docker_fastaguard.json --tsv /data/target/docker_fastaguard.tsv --multiqc /data/target/docker_fastaguard_mqc.json -``` - -Expected: Docker image builds and both container commands exit 0. - -- [ ] **Step 5: Run diff hygiene** - -Run: - -```bash -git diff --check -rg -n "[ \t]+$" README.md docs examples packaging tests src schema integrations || true -git status --short --branch -``` - -Expected: no whitespace findings. `git status` shows a clean branch after all intended commits. - -## Task 12: Open PR And Preserve Bioconda Update Path - -**Files:** -- No source changes expected. - -- [ ] **Step 1: Push branch** - -Run: - -```bash -git status --short --branch -git push origin main -``` - -Expected: `main` pushes with the v0.2 commits if direct push is acceptable for this repo. If branch protection blocks direct push, create a branch: - -```bash -git switch -c fastaguard-v0.2-adoption -git push -u origin fastaguard-v0.2-adoption -``` - -- [ ] **Step 2: Create PR if using a branch** - -Run: - -```bash -gh pr create --repo ehsanestaji/FastaGuard --title "FastaGuard v0.2 adoption and outlier findings" --body "Implements the v0.2 Assembly Trust + Pipeline Adoption plan: outlier findings, richer report contract, MultiQC hardening, workflow starter polish, and benchmark evidence docs." -``` - -Expected: GitHub PR URL is printed. - -- [ ] **Step 3: Record Bioconda follow-up** - -After the GitHub `v0.2.0` release exists, update the Bioconda recipe SHA256 and open an upstream Bioconda PR. Do not submit a Bioconda update before the public source archive exists. diff --git a/docs/superpowers/plans/2026-05-27-fastaguard-v0.3-assembly-gate.md b/docs/superpowers/plans/2026-05-27-fastaguard-v0.3-assembly-gate.md deleted file mode 100644 index e32a35f..0000000 --- a/docs/superpowers/plans/2026-05-27-fastaguard-v0.3-assembly-gate.md +++ /dev/null @@ -1,1367 +0,0 @@ -# FastaGuard v0.3 Assembly Gate Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build FastaGuard v0.3 as a pipeline-ready assembly gate with `--gate pipeline`, machine-readable gate decisions, input SHA256 provenance, updated report outputs, and evidence documentation. - -**Architecture:** Keep gate policy separate from finding detection. CLI parsing records a gate mode, a new gate module expands that mode into final failure rules, report assembly derives a compact `gate` object from triggered findings, and provenance streams the input file to compute SHA256 without loading the FASTA into memory. - -**Tech Stack:** Rust 2021, clap, serde, sha2, hex, JSON Schema, assert_cmd, jsonschema, Python unittest, existing FastaGuard report writers, NCBI Datasets CLI for optional public evidence. - ---- - -## File Structure - -- Create `src/gate.rs`: gate mode enum, pipeline preset failure IDs, final failure-set expansion, and gate decision derivation. -- Modify `src/cli.rs`: add `--gate`, store gate mode in `RunConfig`, and union preset rules with explicit `--fail-on`. -- Modify `src/lib.rs`: expose `gate` module and update test config fixtures. -- Modify `src/models.rs`: bump schema version, add `GateDecision`, add `gate` to reports, add `provenance.input_sha256`, and compute streaming checksum. -- Modify `src/report/tsv.rs`, `src/report/multiqc.rs`, and `src/report/html.rs`: surface gate and checksum fields. -- Modify `schema/fastaguard.schema.json`, `schema/finding-catalog.json`, golden JSON files, example reports, and docs to reflect schema `0.3.0`. -- Modify `tests/cli.rs`, `tests/schema_contract.rs`, report writer unit tests, and Python adoption/release tests. -- Add `docs/evidence/fastaguard-v0.3-evidence.md` and, after a successful public run, compact summaries under `docs/evidence/v0.3/`. - -## Task 1: Add Gate Policy And CLI Parsing - -**Files:** -- Create: `src/gate.rs` -- Modify: `src/cli.rs` -- Modify: `src/lib.rs` -- Test: `src/cli.rs` -- Test: `tests/cli.rs` - -- [ ] **Step 1: Write failing CLI and gate unit tests** - -Add these imports in `src/cli.rs` tests: - -```rust -use crate::gate::GateMode; -``` - -Add these tests to `src/cli.rs`: - -```rust -#[test] -fn gate_none_preserves_explicit_fail_rules() { - let cli = Cli::parse_from([ - "fastaguard", - "input.fa", - "--gate", - "none", - "--fail-on", - "gc_outliers", - ]); - let config = cli.to_run_config().unwrap(); - - assert_eq!(config.gate_mode, GateMode::None); - assert_eq!( - config.rules.fail_on, - ["gc_outliers"].into_iter().map(str::to_string).collect() - ); -} - -#[test] -fn gate_pipeline_adds_conservative_fail_rules() { - let cli = Cli::parse_from(["fastaguard", "input.fa", "--gate", "pipeline"]); - let config = cli.to_run_config().unwrap(); - - assert_eq!(config.gate_mode, GateMode::Pipeline); - assert_eq!( - config.rules.fail_on, - [ - "duplicate_ids", - "high_n_rate", - "invalid_chars", - "invalid_fasta_structure", - ] - .into_iter() - .map(str::to_string) - .collect() - ); -} - -#[test] -fn gate_pipeline_unions_explicit_fail_rules() { - let cli = Cli::parse_from([ - "fastaguard", - "input.fa", - "--gate", - "pipeline", - "--fail-on", - "gc_outliers", - ]); - let config = cli.to_run_config().unwrap(); - - assert!(config.rules.fail_on.contains("duplicate_ids")); - assert!(config.rules.fail_on.contains("invalid_chars")); - assert!(config.rules.fail_on.contains("invalid_fasta_structure")); - assert!(config.rules.fail_on.contains("high_n_rate")); - assert!(config.rules.fail_on.contains("gc_outliers")); -} -``` - -Add this CLI integration test to `tests/cli.rs`: - -```rust -#[test] -fn unknown_gate_value_is_cli_error() { - let mut cmd = Command::cargo_bin("fastaguard").unwrap(); - cmd.args(["testdata/valid_assembly.fa", "--gate", "strict"]) - .assert() - .failure() - .stderr(predicate::str::contains("invalid value 'strict'")); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: - -```bash -cargo test --locked gate_none_preserves_explicit_fail_rules gate_pipeline_adds_conservative_fail_rules gate_pipeline_unions_explicit_fail_rules unknown_gate_value_is_cli_error -``` - -Expected: compile failure because `crate::gate`, `GateMode`, `Cli.gate`, and `RunConfig.gate_mode` do not exist. - -- [ ] **Step 3: Add the gate module** - -Create `src/gate.rs`: - -```rust -use clap::ValueEnum; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; - -pub const PIPELINE_FAIL_ON: [&str; 4] = [ - "duplicate_ids", - "high_n_rate", - "invalid_chars", - "invalid_fasta_structure", -]; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -#[value(rename_all = "snake_case")] -pub enum GateMode { - None, - Pipeline, -} - -impl GateMode { - pub fn as_str(self) -> &'static str { - match self { - GateMode::None => "none", - GateMode::Pipeline => "pipeline", - } - } -} - -pub fn final_fail_on(mode: GateMode, explicit_rules: &[String]) -> BTreeSet { - let mut fail_on = explicit_rules - .iter() - .flat_map(|value| value.split(',')) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .collect::>(); - - if mode == GateMode::Pipeline { - fail_on.extend(PIPELINE_FAIL_ON.into_iter().map(ToOwned::to_owned)); - } - - fail_on -} -``` - -- [ ] **Step 4: Wire the gate module into lib and CLI** - -In `src/lib.rs`, add: - -```rust -pub mod gate; -``` - -In `src/cli.rs`, add: - -```rust -use crate::gate::{self, GateMode}; -``` - -Add this field to `Cli` after `profile`: - -```rust -/// Gate preset for pipeline-friendly failure behavior. -#[arg(long, value_enum, default_value_t = GateMode::None)] -pub gate: GateMode, -``` - -Add this field to `RunConfig`: - -```rust -pub gate_mode: GateMode, -``` - -In `Cli::to_run_config`, set: - -```rust -gate_mode: self.gate, -rules: RuleConfig { - fail_on: gate::final_fail_on(self.gate, &self.fail_on), -}, -``` - -Remove the old call to `normalize_rules(&self.fail_on)`. Keep `normalize_rules` only if another test or helper still uses it; otherwise delete it. - -Update `cli_with_max_n_rate` test helper: - -```rust -gate: GateMode::None, -``` - -Update `src/lib.rs` test config: - -```rust -gate_mode: crate::gate::GateMode::None, -``` - -- [ ] **Step 5: Run tests and commit** - -Run: - -```bash -cargo test --locked gate_none_preserves_explicit_fail_rules gate_pipeline_adds_conservative_fail_rules gate_pipeline_unions_explicit_fail_rules unknown_gate_value_is_cli_error -``` - -Expected: all four tests pass. - -Commit: - -```bash -git add src/gate.rs src/cli.rs src/lib.rs tests/cli.rs -git commit -m "feat: add assembly gate preset" -``` - -## Task 2: Add JSON Gate Decision And Input SHA256 Provenance - -**Files:** -- Modify: `src/gate.rs` -- Modify: `src/models.rs` -- Modify: `schema/fastaguard.schema.json` -- Test: `tests/cli.rs` -- Test: `tests/schema_contract.rs` - -- [ ] **Step 1: Write failing report contract tests** - -Add this test to `tests/cli.rs`: - -```rust -#[test] -fn pipeline_gate_report_lists_blocking_and_advisory_findings() { - let temp_dir = TempDir::new().unwrap(); - let outputs = output_paths(&temp_dir, "pipeline_gate"); - - let mut cmd = Command::cargo_bin("fastaguard").unwrap(); - cmd.args(["testdata/problem_assembly.fa", "--gate", "pipeline", "--out"]) - .arg(&outputs.html) - .arg("--json") - .arg(&outputs.json) - .arg("--tsv") - .arg(&outputs.tsv) - .arg("--multiqc") - .arg(&outputs.multiqc) - .assert() - .code(2); - - let report = read_json(&outputs.json); - assert_eq!(report["schema_version"], json!("0.3.0")); - assert_eq!(report["gate"]["mode"], json!("pipeline")); - assert_eq!(report["gate"]["status"], json!("FAIL")); - assert!(array_contains_string(&report["gate"]["blocking_findings"], "duplicate_ids")); - assert!(array_contains_string(&report["gate"]["blocking_findings"], "invalid_chars")); - assert!(array_contains_string(&report["gate"]["blocking_findings"], "high_n_rate")); - assert!(array_contains_string(&report["gate"]["advisory_findings"], "gap_runs")); - assert!(array_contains_string(&report["gate"]["fail_on"], "invalid_fasta_structure")); - assert_eq!( - report["provenance"]["input_sha256"], - json!(sha256_file(Path::new("testdata/problem_assembly.fa"))) - ); -} -``` - -Add this test to `tests/cli.rs`: - -```rust -#[test] -fn gate_none_report_preserves_warning_behavior_and_checksum() { - let temp_dir = TempDir::new().unwrap(); - let outputs = output_paths(&temp_dir, "gate_none"); - - let mut cmd = Command::cargo_bin("fastaguard").unwrap(); - cmd.args(["testdata/problem_assembly.fa", "--out"]) - .arg(&outputs.html) - .arg("--json") - .arg(&outputs.json) - .arg("--tsv") - .arg(&outputs.tsv) - .arg("--multiqc") - .arg(&outputs.multiqc) - .assert() - .code(2); - - let report = read_json(&outputs.json); - assert_eq!(report["gate"]["mode"], json!("none")); - assert_eq!(report["gate"]["status"], json!("FAIL")); - assert!(array_contains_string(&report["gate"]["blocking_findings"], "duplicate_ids")); - assert!(array_contains_string(&report["gate"]["blocking_findings"], "invalid_chars")); - assert!(array_contains_string(&report["gate"]["advisory_findings"], "high_n_rate")); - assert_eq!( - report["provenance"]["input_sha256"], - json!(sha256_file(Path::new("testdata/problem_assembly.fa"))) - ); -} -``` - -Add this helper to `tests/cli.rs` near the other helpers: - -```rust -fn sha256_file(path: &Path) -> String { - use sha2::{Digest, Sha256}; - - let bytes = std::fs::read(path).unwrap(); - hex::encode(Sha256::digest(bytes)) -} -``` - -Add this schema test to `tests/schema_contract.rs`: - -```rust -#[test] -fn schema_requires_gate_and_input_sha256() { - let schema = read_json(Path::new("schema/fastaguard.schema.json")); - let report_required = schema["required"].as_array().unwrap(); - let provenance_required = schema["properties"]["provenance"]["required"] - .as_array() - .unwrap(); - - assert!(report_required.contains(&serde_json::json!("gate"))); - assert!(provenance_required.contains(&serde_json::json!("input_sha256"))); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: - -```bash -cargo test --locked pipeline_gate_report_lists_blocking_and_advisory_findings gate_none_report_preserves_warning_behavior_and_checksum schema_requires_gate_and_input_sha256 -``` - -Expected: compile or assertion failures because report `gate`, schema `gate`, schema `0.3.0`, and `provenance.input_sha256` do not exist yet. - -- [ ] **Step 3: Add gate decision types and derivation** - -In `src/models.rs`, change: - -```rust -pub const SCHEMA_VERSION: &str = "0.3.0"; -``` - -Add `gate` to `FastaguardReport` after `verdict`: - -```rust -pub gate: GateDecision, -``` - -Add this struct: - -```rust -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GateDecision { - pub mode: String, - pub status: VerdictStatus, - pub blocking_findings: Vec, - pub advisory_findings: Vec, - pub fail_on: Vec, -} -``` - -In `src/gate.rs`, add: - -```rust -use crate::models::{Finding, GateDecision, VerdictStatus}; - -pub fn decision( - mode: GateMode, - status: VerdictStatus, - findings: &[Finding], - fail_on: &BTreeSet, -) -> GateDecision { - let mut blocking_findings = Vec::new(); - let mut advisory_findings = Vec::new(); - - for finding in findings { - if fail_on.contains(&finding.id) || finding.severity == crate::models::Severity::Critical { - blocking_findings.push(finding.id.clone()); - } else { - advisory_findings.push(finding.id.clone()); - } - } - - GateDecision { - mode: mode.as_str().to_string(), - status, - blocking_findings, - advisory_findings, - fail_on: fail_on.iter().cloned().collect(), - } -} -``` - -In `src/models.rs`, import `crate::gate` and set `gate` in both constructors: - -```rust -gate: gate::decision( - config.gate_mode, - analysis.status, - &findings, - &config.rules.fail_on, -), -``` - -For invalid FASTA: - -```rust -gate: gate::decision( - config.gate_mode, - VerdictStatus::Fail, - &findings, - &config.rules.fail_on, -), -``` - -- [ ] **Step 4: Add streaming input SHA256** - -In `src/models.rs`, add imports: - -```rust -use anyhow::{Context, Result}; -use sha2::{Digest, Sha256}; -use std::fs::File; -use std::io::{BufReader, Read}; -``` - -Add `input_sha256` to `Provenance` after `input_size_bytes`: - -```rust -pub input_sha256: String, -``` - -Add helper: - -```rust -fn input_sha256(path: &Path) -> Result { - let file = File::open(path) - .with_context(|| format!("failed to open {} for SHA256", path.display()))?; - let mut reader = BufReader::new(file); - let mut hasher = Sha256::new(); - let mut buffer = [0_u8; 64 * 1024]; - - loop { - let bytes_read = reader - .read(&mut buffer) - .with_context(|| format!("failed to read {} for SHA256", path.display()))?; - if bytes_read == 0 { - break; - } - hasher.update(&buffer[..bytes_read]); - } - - Ok(hex::encode(hasher.finalize())) -} -``` - -Change `build_provenance` to compute: - -```rust -let input_sha256 = input_sha256(&config.input).unwrap_or_else(|_| String::new()); -``` - -Set: - -```rust -input_sha256, -``` - -The empty-string fallback should only affect report-only test fixtures that build synthetic reports without readable input paths. CLI runs should already fail before report creation when input is unreadable. - -- [ ] **Step 5: Update schema** - -In `schema/fastaguard.schema.json`: - -- Change `schema_version.const` from `0.2.0` to `0.3.0`. -- Add `"gate"` to top-level `required` after `"verdict"`. -- Add `gate` under top-level `properties`: - -```json -"gate": { - "type": "object", - "required": ["mode", "status", "blocking_findings", "advisory_findings", "fail_on"], - "properties": { - "mode": { - "type": "string", - "enum": ["none", "pipeline"] - }, - "status": { - "type": "string", - "enum": ["PASS", "WARN", "FAIL"] - }, - "blocking_findings": { - "type": "array", - "items": { "type": "string" }, - "uniqueItems": true - }, - "advisory_findings": { - "type": "array", - "items": { "type": "string" }, - "uniqueItems": true - }, - "fail_on": { - "type": "array", - "items": { "type": "string" }, - "uniqueItems": true - } - } -} -``` - -- Add `"input_sha256"` to `provenance.required`. -- Add `input_sha256` to `provenance.properties`: - -```json -"input_sha256": { - "type": "string", - "pattern": "^[a-f0-9]{64}$" -} -``` - -- [ ] **Step 6: Update test fixture builders** - -Update every manual `FastaguardReport` literal in: - -- `src/report/tsv.rs` -- `src/report/multiqc.rs` -- `src/report/html.rs` -- `src/report/mod.rs` - -Add: - -```rust -gate: GateDecision { - mode: "none".to_string(), - status, - blocking_findings: Vec::new(), - advisory_findings: Vec::new(), - fail_on: Vec::new(), -}, -``` - -For literals with a fixed `VerdictStatus::Pass`, use `status: VerdictStatus::Pass`. - -Add provenance: - -```rust -input_sha256: "0".repeat(64), -``` - -- [ ] **Step 7: Run tests and commit** - -Run: - -```bash -cargo test --locked pipeline_gate_report_lists_blocking_and_advisory_findings gate_none_report_preserves_warning_behavior_and_checksum schema_requires_gate_and_input_sha256 -cargo test --locked -``` - -Expected: all tests pass except golden/schema tests may still fail until Task 4 regenerates fixtures. If only golden/schema fixture mismatches remain, continue to Task 4 before committing. If unit or CLI behavior tests fail, fix before continuing. - -Commit after behavior tests and schema update are passing or after Task 4 if golden fixtures are part of the same change: - -```bash -git add src/gate.rs src/models.rs schema/fastaguard.schema.json tests/cli.rs tests/schema_contract.rs src/report/tsv.rs src/report/multiqc.rs src/report/html.rs src/report/mod.rs -git commit -m "feat: add gate report contract" -``` - -## Task 3: Surface Gate Fields In TSV, MultiQC, And HTML - -**Files:** -- Modify: `src/report/tsv.rs` -- Modify: `src/report/multiqc.rs` -- Modify: `src/report/html.rs` -- Test: report writer unit tests and `tests/cli.rs` - -- [ ] **Step 1: Write failing output tests** - -In `src/report/tsv.rs`, add: - -```rust -#[test] -fn writes_gate_and_checksum_rows() { - let mut report = test_report(VerdictStatus::Fail); - report.gate.mode = "pipeline".to_string(); - report.gate.status = VerdictStatus::Fail; - report.gate.blocking_findings = vec!["duplicate_ids".to_string()]; - report.gate.advisory_findings = vec!["gc_outliers".to_string()]; - report.provenance.input_sha256 = "a".repeat(64); - let file = NamedTempFile::new().unwrap(); - - write(&report, file.path()).unwrap(); - - let output = fs::read_to_string(file.path()).unwrap(); - assert!(output.contains("gate_mode\tpipeline\n"), "{output}"); - assert!(output.contains("gate_status\tFAIL\n"), "{output}"); - assert!(output.contains("gate_blocking_findings\tduplicate_ids\n"), "{output}"); - assert!(output.contains("gate_advisory_findings\tgc_outliers\n"), "{output}"); - assert!(output.contains(&format!("input_sha256\t{}\n", "a".repeat(64))), "{output}"); -} -``` - -In `src/report/multiqc.rs`, extend `writes_multiqc_custom_content_table`: - -```rust -assert_eq!(output["data"]["sample"]["gate_mode"], "none"); -assert_eq!(output["data"]["sample"]["gate_status"], "PASS"); -assert_eq!(output["data"]["sample"]["gate_blocking_findings"], ""); -``` - -In `tests/cli.rs`, add: - -```rust -#[test] -fn html_report_shows_gate_decision() { - let temp_dir = TempDir::new().unwrap(); - let outputs = output_paths(&temp_dir, "html_gate"); - - let mut cmd = Command::cargo_bin("fastaguard").unwrap(); - cmd.args(["testdata/problem_assembly.fa", "--gate", "pipeline", "--out"]) - .arg(&outputs.html) - .arg("--json") - .arg(&outputs.json) - .arg("--tsv") - .arg(&outputs.tsv) - .arg("--multiqc") - .arg(&outputs.multiqc) - .assert() - .code(2); - - let html = std::fs::read_to_string(&outputs.html).unwrap(); - assert!(html.contains("Gate Decision"), "{html}"); - assert!(html.contains("Blocking"), "{html}"); - assert!(html.contains("Advisory"), "{html}"); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: - -```bash -cargo test --locked writes_gate_and_checksum_rows writes_multiqc_custom_content_table html_report_shows_gate_decision -``` - -Expected: failures because the output writers do not emit gate fields yet. - -- [ ] **Step 3: Add TSV rows** - -In `src/report/tsv.rs`, after verdict: - -```rust -write_metric(&mut writer, "gate_mode", &report.gate.mode)?; -write_metric( - &mut writer, - "gate_status", - verdict_status(report.gate.status), -)?; -write_metric( - &mut writer, - "gate_blocking_findings", - report.gate.blocking_findings.join(","), -)?; -write_metric( - &mut writer, - "gate_advisory_findings", - report.gate.advisory_findings.join(","), -)?; -write_metric( - &mut writer, - "input_sha256", - &report.provenance.input_sha256, -)?; -``` - -- [ ] **Step 4: Add MultiQC fields** - -In `src/report/multiqc.rs`, add fields to `MultiqcSummaryRow`: - -```rust -gate_mode: String, -gate_status: &'static str, -gate_blocking_findings: String, -``` - -Set them in `summary_row`: - -```rust -gate_mode: report.gate.mode.clone(), -gate_status: verdict_status(report.gate.status), -gate_blocking_findings: report.gate.blocking_findings.join(","), -``` - -- [ ] **Step 5: Add HTML gate panel** - -In `src/report/html.rs`, add a `let gate = render_gate(report);` line in `render`. - -Place this block after the positioning paragraph and before Machine Summary: - -```html -

Gate Decision

-{gate} -``` - -Add helper: - -```rust -fn render_gate(report: &FastaguardReport) -> String { - format!( - r#"
-
-

Gate

-

Mode: {mode}

-

Status: {status}

-
-
-

Blocking

-{blocking} -
-
-

Advisory

-{advisory} -
-
"#, - mode = escape_html(&report.gate.mode), - status = escape_html(verdict_status(report.gate.status)), - blocking = render_string_list_or_none(&report.gate.blocking_findings), - advisory = render_string_list_or_none(&report.gate.advisory_findings), - ) -} - -fn render_string_list_or_none(values: &[String]) -> String { - if values.is_empty() { - "

None

".to_string() - } else { - render_string_list(values) - } -} -``` - -- [ ] **Step 6: Run tests and commit** - -Run: - -```bash -cargo test --locked writes_gate_and_checksum_rows writes_multiqc_custom_content_table html_report_shows_gate_decision -``` - -Expected: all pass. - -Commit: - -```bash -git add src/report/tsv.rs src/report/multiqc.rs src/report/html.rs tests/cli.rs -git commit -m "feat: surface assembly gate outputs" -``` - -## Task 4: Bump Version, Schema, Golden Reports, And Examples To v0.3.0 - -**Files:** -- Modify: `Cargo.toml` -- Modify: `Cargo.lock` -- Modify: `src/models.rs` -- Modify: `schema/finding-catalog.json` -- Modify: `tests/golden/*.json` -- Modify: `examples/reports/**` -- Modify: `tests/python/test_release_metadata.py` -- Modify: `tests/schema_contract.rs` - -- [ ] **Step 1: Update metadata tests first** - -In `tests/python/test_release_metadata.py`, change version expectations from `0.2.0` to `0.3.0` for Cargo and release notes existence. Add assertions that v0.3 release notes mention: - -```python -self.assertIn("FastaGuard v0.3.0", text) -self.assertIn("Evidence And Assembly Gate", text) -self.assertIn("--gate pipeline", text) -self.assertIn("input_sha256", text) -``` - -Keep Bioconda source SHA checks scoped to the current recipe until the v0.3 GitHub source archive exists; do not require `packaging/bioconda/meta.yaml` to be v0.3 during feature implementation. - -- [ ] **Step 2: Run Python metadata tests to verify failure** - -Run: - -```bash -python3 -m unittest tests.python.test_release_metadata -v -``` - -Expected: failure because Cargo is still `0.2.0` and `docs/releases/v0.3.0.md` does not exist. - -- [ ] **Step 3: Bump Cargo package version** - -In `Cargo.toml`: - -```toml -version = "0.3.0" -``` - -Run: - -```bash -cargo update -p fastaguard --precise 0.3.0 -``` - -Expected: `Cargo.lock` updates the local package version to `0.3.0`. - -- [ ] **Step 4: Update schema and catalog versions** - -In `schema/finding-catalog.json`, change: - -```json -"schema_version": "0.3.0", -"catalog_version": "0.3.0" -``` - -In tests that assert catalog version, update expected strings to `0.3.0`. - -- [ ] **Step 5: Add v0.3 release notes** - -Create `docs/releases/v0.3.0.md`: - -````markdown -# FastaGuard v0.3.0 - -FastaGuard v0.3.0 is the Evidence And Assembly Gate release. - -## Highlights - -- Adds `--gate pipeline` for conservative assembly preflight gating. -- Adds a machine-readable `gate` object to JSON reports. -- Adds `provenance.input_sha256` so reports identify the exact input bytes. -- Surfaces gate mode, status, blocking findings, advisory findings, and input - checksum in pipeline-friendly outputs. -- Documents the v0.3 evidence workflow for local and public assembly runs. - -## Install - -```bash -mamba install -c conda-forge -c bioconda fastaguard -``` - -Until the v0.3 Bioconda update is merged, Bioconda may still serve the previous -published release. GitHub release binaries and source archives should be used -for immediate v0.3 testing after the tag is published. - -## Pipeline Gate - -```bash -fastaguard sample.fa --profile assembly --gate pipeline -``` - -The pipeline gate fails on duplicate IDs, invalid characters, structurally -invalid FASTA, and high-N content. GC and length outliers remain advisory unless -explicitly added with `--fail-on`. - -## Known Limits - -- FastaGuard remains assembly-focused. -- Gate decisions are FASTA preflight decisions, not biological completeness, - contamination, or assembly correctness claims. -- External taxonomy, coverage, k-mer, and database-backed checks remain - follow-up steps. -```` - -- [ ] **Step 6: Regenerate golden and example reports** - -Run the existing golden update workflow manually by using the commands encoded in `tests/cli.rs`. If no helper exists, regenerate with: - -```bash -FASTAGUARD_PROVENANCE_TIMESTAMP=2026-05-23T00:00:00Z \ -FASTAGUARD_PROVENANCE_COMMAND='fastaguard testdata/valid_assembly.fa --min-contig-length 1 --out target/fastaguard-golden-runtime/valid_assembly.html --json target/fastaguard-golden-runtime/valid_assembly.json --tsv target/fastaguard-golden-runtime/valid_assembly.tsv --multiqc target/fastaguard-golden-runtime/valid_assembly_multiqc.json' \ -cargo run -- testdata/valid_assembly.fa --min-contig-length 1 \ - --out target/fastaguard-golden-runtime/valid_assembly.html \ - --json tests/golden/valid_assembly.json \ - --tsv target/fastaguard-golden-runtime/valid_assembly.tsv \ - --multiqc target/fastaguard-golden-runtime/valid_assembly_multiqc.json - -FASTAGUARD_PROVENANCE_TIMESTAMP=2026-05-23T00:00:00Z \ -FASTAGUARD_PROVENANCE_COMMAND='fastaguard testdata/problem_assembly.fa --out target/fastaguard-golden-runtime/problem_assembly.html --json target/fastaguard-golden-runtime/problem_assembly.json --tsv target/fastaguard-golden-runtime/problem_assembly.tsv --multiqc target/fastaguard-golden-runtime/problem_assembly_multiqc.json' \ -cargo run -- testdata/problem_assembly.fa \ - --out target/fastaguard-golden-runtime/problem_assembly.html \ - --json tests/golden/problem_assembly.json \ - --tsv target/fastaguard-golden-runtime/problem_assembly.tsv \ - --multiqc target/fastaguard-golden-runtime/problem_assembly_multiqc.json || test "$?" = "2" - -FASTAGUARD_PROVENANCE_TIMESTAMP=2026-05-23T00:00:00Z \ -FASTAGUARD_PROVENANCE_COMMAND='fastaguard testdata/invalid_empty_record.fa --out target/fastaguard-golden-runtime/invalid_empty_record.html --json target/fastaguard-golden-runtime/invalid_empty_record.json --tsv target/fastaguard-golden-runtime/invalid_empty_record.tsv --multiqc target/fastaguard-golden-runtime/invalid_empty_record_multiqc.json' \ -cargo run -- testdata/invalid_empty_record.fa \ - --out target/fastaguard-golden-runtime/invalid_empty_record.html \ - --json tests/golden/invalid_empty_record.json \ - --tsv target/fastaguard-golden-runtime/invalid_empty_record.tsv \ - --multiqc target/fastaguard-golden-runtime/invalid_empty_record_multiqc.json || test "$?" = "2" -``` - -Regenerate committed examples: - -```bash -cargo run -- testdata/valid_assembly.fa \ - --min-contig-length 1 \ - --out examples/reports/assembly_pass/fastaguard_report.html \ - --json examples/reports/assembly_pass/fastaguard.json \ - --tsv examples/reports/assembly_pass/fastaguard.tsv \ - --multiqc examples/reports/assembly_pass/fastaguard_mqc.json - -cargo run -- testdata/problem_assembly.fa \ - --out examples/reports/assembly_fail/fastaguard_report.html \ - --json examples/reports/assembly_fail/fastaguard.json \ - --tsv examples/reports/assembly_fail/fastaguard.tsv \ - --multiqc examples/reports/assembly_fail/fastaguard_mqc.json || test "$?" = "2" -``` - -- [ ] **Step 7: Run tests and commit** - -Run: - -```bash -python3 -m unittest tests.python.test_release_metadata -v -cargo test --locked -``` - -Expected: all pass. - -Commit: - -```bash -git add Cargo.toml Cargo.lock src/models.rs schema/finding-catalog.json schema/fastaguard.schema.json tests/golden examples/reports docs/releases/v0.3.0.md tests/python/test_release_metadata.py tests/schema_contract.rs tests/cli.rs -git commit -m "chore: prepare v0.3 report contract" -``` - -## Task 5: Update User Docs And Workflow Examples - -**Files:** -- Modify: `README.md` -- Modify: `docs/output-contract.md` -- Modify: `docs/roadmap.md` -- Modify: `docs/benchmarking.md` -- Modify: `docs/tool-landscape.md` -- Modify: `examples/nextflow/main.nf` -- Modify: `examples/nf-core/modules/local/fastaguard/main.nf` -- Modify: `examples/nf-core/README.md` -- Modify: `examples/snakemake/Snakefile` -- Modify: `examples/snakemake/wrapper/README.md` -- Test: `tests/python/test_adoption_assets.py` - -- [ ] **Step 1: Write failing adoption tests** - -In `tests/python/test_adoption_assets.py`, add: - -```python -def test_v0_3_gate_docs_and_examples_are_present(self): - readme = (ROOT / "README.md").read_text() - output_contract = (ROOT / "docs" / "output-contract.md").read_text() - nfcore_module = ( - ROOT / "examples" / "nf-core" / "modules" / "local" / "fastaguard" / "main.nf" - ).read_text() - snakemake = (ROOT / "examples" / "snakemake" / "Snakefile").read_text() - - self.assertIn("--gate pipeline", readme) - self.assertIn("The assembly FASTA gate before expensive QC.", readme) - self.assertIn('"gate"', output_contract) - self.assertIn("provenance.input_sha256", output_contract) - self.assertIn("--gate pipeline", nfcore_module) - self.assertIn("--gate pipeline", snakemake) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: - -```bash -python3 -m unittest tests.python.test_adoption_assets.AdoptionAssetsTest.test_v0_3_gate_docs_and_examples_are_present -v -``` - -Expected: failure because docs and examples do not mention v0.3 gate yet. - -- [ ] **Step 3: Update README** - -Add this quickstart example near the current pipeline gate example: - -````markdown -Pipeline gate preset: - -```bash -fastaguard sample.fa --profile assembly --gate pipeline -``` - -```text -The assembly FASTA gate before expensive QC. -``` -```` - -Mention that `--gate pipeline` fails on duplicate IDs, invalid characters, invalid FASTA structure, and high-N content while keeping GC and length outliers advisory. - -- [ ] **Step 4: Update output contract docs** - -In `docs/output-contract.md`, add a `Gate Contract` section: - -````markdown -## Gate Contract - -The `gate` object is the machine-readable assembly gate decision. - -```json -"gate": { - "mode": "pipeline", - "status": "FAIL", - "blocking_findings": ["duplicate_ids", "invalid_chars"], - "advisory_findings": ["gc_outliers"], - "fail_on": ["duplicate_ids", "high_n_rate", "invalid_chars", "invalid_fasta_structure"] -} -``` - -Machines should use `gate.blocking_findings` for workflow stop/go decisions. -Humans should use the HTML report to inspect the evidence behind each finding. -`provenance.input_sha256` identifies the exact input bytes used for the report. -```` - -- [ ] **Step 5: Update workflow examples** - -Add `--gate pipeline` to the FastaGuard command blocks in: - -- `examples/nextflow/main.nf` -- `examples/nf-core/modules/local/fastaguard/main.nf` -- `examples/snakemake/Snakefile` -- `examples/snakemake/wrapper/wrapper/fastaguard/wrapper.py` - -Keep outputs unchanged. - -- [ ] **Step 6: Run tests and commit** - -Run: - -```bash -python3 -m unittest tests.python.test_adoption_assets -v -``` - -Expected: all Python adoption tests pass. - -Commit: - -```bash -git add README.md docs/output-contract.md docs/roadmap.md docs/benchmarking.md docs/tool-landscape.md examples/nextflow/main.nf examples/nf-core/README.md examples/nf-core/modules/local/fastaguard/main.nf examples/snakemake/Snakefile examples/snakemake/wrapper/README.md examples/snakemake/wrapper/wrapper/fastaguard/wrapper.py tests/python/test_adoption_assets.py -git commit -m "docs: document v0.3 assembly gate" -``` - -## Task 6: Evidence Pack Updates - -**Files:** -- Modify: `scripts/collect_evidence.py` -- Create: `docs/evidence/fastaguard-v0.3-evidence.md` -- Create when public run succeeds: `docs/evidence/v0.3/evidence_summary.json` -- Create when public run succeeds: `docs/evidence/v0.3/evidence_summary.tsv` -- Test: `tests/python/test_adoption_assets.py` - -- [ ] **Step 1: Write failing evidence documentation tests** - -Add to `tests/python/test_adoption_assets.py`: - -```python -def test_v0_3_evidence_docs_reference_gate_and_checksum(self): - evidence = ROOT / "docs" / "evidence" / "fastaguard-v0.3-evidence.md" - - self.assertTrue(evidence.exists()) - text = evidence.read_text() - self.assertIn("--gate pipeline", text) - self.assertIn("input_sha256", text) - self.assertIn("not biological completeness", text) - self.assertIn("not contamination confirmation", text) - self.assertIn("python3 scripts/collect_evidence.py", text) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: - -```bash -python3 -m unittest tests.python.test_adoption_assets.AdoptionAssetsTest.test_v0_3_evidence_docs_reference_gate_and_checksum -v -``` - -Expected: failure because v0.3 evidence page does not exist. - -- [ ] **Step 3: Update evidence script to run gate mode** - -In `scripts/collect_evidence.py`, add `--gate pipeline` to the command list in `run_case` immediately after `--profile assembly`. - -Expected command shape: - -```python -command = [ - str(binary), - str(case["input_path"]), - "--profile", - "assembly", - "--gate", - "pipeline", - "--out", - str(html_path), - "--json", - str(json_path), - "--tsv", - str(tsv_path), - "--multiqc", - str(multiqc_path), -] -``` - -Add summary fields from the parsed report: - -```python -"gate_mode": report.get("gate", {}).get("mode"), -"gate_status": report.get("gate", {}).get("status"), -"gate_blocking_findings": ",".join(report.get("gate", {}).get("blocking_findings", [])), -"input_sha256": report.get("provenance", {}).get("input_sha256"), -``` - -Add these names to `SUMMARY_COLUMNS`: - -```python -"gate_mode", -"gate_status", -"gate_blocking_findings", -"input_sha256", -``` - -- [ ] **Step 4: Add v0.3 evidence page** - -Create `docs/evidence/fastaguard-v0.3-evidence.md`: - -````markdown -# FastaGuard v0.3 Evidence Pack - -This page records the evidence workflow for the v0.3 assembly gate release. - -FastaGuard is FASTA preflight QC. It is not biological completeness analysis, -not assembly correctness analysis, and not contamination confirmation. - -## Local Gate Evidence - -```bash -cargo build --release --locked -python3 scripts/collect_evidence.py \ - --binary target/release/fastaguard \ - --out-dir target/evidence/v0.3-local \ - --local-only -``` - -The evidence command runs FastaGuard with `--gate pipeline`. Summaries include -the verdict, gate status, blocking findings, top findings, runtime, input size, -and `input_sha256`. - -## Public NCBI Evidence - -```bash -python3 scripts/collect_evidence.py \ - --binary target/release/fastaguard \ - --out-dir target/evidence/v0.3 -``` - -The public workflow uses the assembly manifest in -`docs/evidence/public_assemblies.json` and requires NCBI Datasets CLI plus -network access. - -## Interpretation - -Use this evidence to decide whether FastaGuard is useful as the first assembly -gate before QUAST, BUSCO, BlobToolKit, CheckM, annotation, or submission. -Passing the gate means the FASTA-level contract is sane enough to continue; it -does not prove biological completeness or rule out contamination. -```` - -- [ ] **Step 5: Run local-only evidence smoke** - -Run: - -```bash -cargo build --release --locked -python3 scripts/collect_evidence.py \ - --binary target/release/fastaguard \ - --out-dir target/evidence/v0.3-local \ - --local-only -``` - -Expected: command exits `0`, prints JSON summary, and all cases include `gate_mode`, `gate_status`, and `input_sha256`. - -- [ ] **Step 6: Run public evidence only if NCBI Datasets CLI is available** - -Check: - -```bash -command -v datasets -``` - -If present, run: - -```bash -python3 scripts/collect_evidence.py \ - --binary target/release/fastaguard \ - --out-dir target/evidence/v0.3 -``` - -If the public run succeeds, copy only compact summary files into docs: - -```bash -mkdir -p docs/evidence/v0.3 -cp target/evidence/v0.3/evidence_summary.json docs/evidence/v0.3/evidence_summary.json -cp target/evidence/v0.3/evidence_summary.tsv docs/evidence/v0.3/evidence_summary.tsv -``` - -Do not commit downloaded FASTA files, NCBI zip archives, or generated per-case reports. - -- [ ] **Step 7: Run tests and commit** - -Run: - -```bash -python3 -m unittest tests.python.test_adoption_assets -v -``` - -Expected: all Python adoption tests pass. - -Commit: - -```bash -git add scripts/collect_evidence.py docs/evidence/fastaguard-v0.3-evidence.md docs/evidence/v0.3 tests/python/test_adoption_assets.py -git commit -m "docs: add v0.3 gate evidence workflow" -``` - -If no public evidence run was possible, omit `docs/evidence/v0.3` from `git add` and note the reason in the final summary. - -## Task 7: Full Verification And Release Readiness - -**Files:** -- Modify only files needed to fix verification failures from previous tasks. - -- [ ] **Step 1: Run full local gates** - -Run: - -```bash -python3 -m unittest discover tests/python -v -cargo fmt --check -cargo test --locked -cargo clippy --locked --all-targets --all-features -- -D warnings -git diff --check -git ls-files | xargs perl -ne 'print "$ARGV:$.:$_" if /[ \t]$/' -``` - -Expected: all commands exit `0` and whitespace scan prints no output. - -- [ ] **Step 2: Run CLI smoke for pipeline gate** - -Run: - -```bash -cargo run -- testdata/problem_assembly.fa \ - --gate pipeline \ - --out target/v0.3-smoke/fastaguard_report.html \ - --json target/v0.3-smoke/fastaguard.json \ - --tsv target/v0.3-smoke/fastaguard.tsv \ - --multiqc target/v0.3-smoke/fastaguard_mqc.json || test "$?" = "2" -``` - -Inspect: - -```bash -jq '.schema_version, .gate, .provenance.input_sha256' target/v0.3-smoke/fastaguard.json -``` - -Expected: - -```text -"0.3.0" -``` - -Gate mode is `pipeline`, status is `FAIL`, and `input_sha256` is a 64-character lowercase hex string. - -- [ ] **Step 3: Review final diff** - -Run: - -```bash -git status --short --branch -git diff --stat -``` - -Expected: only v0.3 assembly gate, evidence, docs, tests, schema, and generated example/golden files are changed. - -- [ ] **Step 4: Commit final verification fixes** - -If Step 1 or Step 2 required changes, run `git status --short` and stage the -specific files shown there that belong to v0.3 assembly gate work. Do not stage -unrelated local files. Use this commit message: - -```bash -git commit -m "chore: finalize v0.3 assembly gate" -``` - -If Step 1 and Step 2 required no changes, skip this commit step. - -- [ ] **Step 5: Prepare PR** - -Push the branch and open a draft PR: - -```bash -git push -u origin codex/v0.3-evidence-assembly-gate -gh pr create \ - --repo ehsanestaji/FastaGuard \ - --base main \ - --head codex/v0.3-evidence-assembly-gate \ - --draft \ - --title "[codex] Add v0.3 assembly gate" \ - --body-file /tmp/fastaguard-v0.3-pr.md -``` - -Use this PR body: - -````markdown -## Summary - -- Add `--gate pipeline` for conservative assembly FASTA preflight gating. -- Add machine-readable `gate` JSON plus TSV, MultiQC, and HTML gate outputs. -- Add `provenance.input_sha256` for exact input-file identity. -- Bump the report contract and package metadata to v0.3.0. -- Update docs, examples, release notes, and evidence workflow. - -## Validation - -- `python3 -m unittest discover tests/python -v` -- `cargo fmt --check` -- `cargo test --locked` -- `cargo clippy --locked --all-targets --all-features -- -D warnings` -- `git diff --check` -- trailing whitespace scan -- local evidence smoke with `scripts/collect_evidence.py --local-only` - -## Notes - -FastaGuard remains FASTA preflight QC. The gate does not replace QUAST, BUSCO, -BlobToolKit, CheckM, annotation, or contamination workflows. -```` diff --git a/docs/superpowers/plans/2026-06-02-fastaguard-v0.4-preflight-readiness.md b/docs/superpowers/plans/2026-06-02-fastaguard-v0.4-preflight-readiness.md deleted file mode 100644 index 21858b1..0000000 --- a/docs/superpowers/plans/2026-06-02-fastaguard-v0.4-preflight-readiness.md +++ /dev/null @@ -1,2576 +0,0 @@ -# FastaGuard v0.4 Preflight Readiness Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build v0.4 preflight readiness and compare mode so FastaGuard can rank, gate, and route many assembly FASTA files before downstream QC tools run. - -**Architecture:** Extend the existing single-file assembly pipeline first, then build compare mode as a thin orchestration layer over the same analysis/report contract. Keep metrics, findings, readiness, compare aggregation, and report writers in separate modules so each piece can be tested without loading whole FASTA files or parsing HTML. - -**Tech Stack:** Rust, clap, serde/serde_json, schemars-style hand-maintained JSON Schema, cargo integration tests, Python unittest adoption checks, inline SVG HTML reports, standard MultiQC custom-content JSON. - ---- - -## Scope Guard - -This plan implements the approved spec: - -```text -docs/superpowers/specs/2026-06-02-fastaguard-v0.4-preflight-readiness-design.md -``` - -Keep v0.4 assembly-first and database-free. Do not add transcriptome, protein, -reference-panel, external taxonomy, read mapping, aligners, online APIs, or LLM -summaries. - -## File Map - -Create: - -- `src/readiness.rs`: readiness category/status model and aggregation from findings/gate/report completeness. -- `src/compare.rs`: compare command orchestration and cohort-level summary/finding logic. -- `src/report/compare_html.rs`: self-contained compare HTML writer. -- `src/report/compare_tsv.rs`: compare one-row-per-sample TSV writer. -- `src/report/compare_multiqc.rs`: compare MultiQC custom-content writer. -- `docs/preflight-readiness.md`: product/user documentation for the pre-QC readiness layer. -- `docs/compare-mode.md`: compare command usage and output contract. -- `docs/value-benchmark.md`: measured value and savings scenarios. -- `docs/releases/v0.4.0.md`: release notes draft. -- `testdata/readiness_headers.fa`: fixture for identifier/header readiness findings. -- `testdata/readiness_terminal_ns.fa`: fixture for terminal-N and gap-pattern findings. -- `testdata/compare_pass.fa`: compare PASS fixture. -- `testdata/compare_warn.fa`: compare WARN fixture. -- `testdata/compare_fail.fa`: compare FAIL fixture. -- `tests/golden/compare_mixed_status.json`: compare report golden. -- `tests/golden/compare_all_pass.json`: compare report golden. - -Modify: - -- `Cargo.toml`: bump crate version only when release preparation begins, not in early feature commits. -- `schema/fastaguard.schema.json`: bump contract to `0.4.0`, add readiness and compare shapes. -- `schema/finding-catalog.json`: add new finding metadata/actions/scope. -- `src/lib.rs`: route contract flags, single-file run, and compare subcommand. -- `src/cli.rs`: introduce clap subcommands, compare config, expected-size parsing. -- `src/gate.rs`: add `duplicate_first_token_ids` to pipeline fail set. -- `src/metrics.rs`: collect header/ID/readiness signals and expected-size context. -- `src/findings.rs`: create new findings from metrics and expected-size config. -- `src/models.rs`: add `readiness`, new summary counters, compare report models. -- `src/report/html.rs`: render single-file readiness section. -- `src/report/tsv.rs`: add readiness rows and new summary counters. -- `src/report/multiqc.rs`: add readiness fields to single-file custom content. -- `src/report/mod.rs`: add compare output validation/write entrypoint. -- `tests/cli.rs`: add CLI integration tests for readiness fields, findings, compare, and exit codes. -- `tests/schema_contract.rs`: assert schema/catalog v0.4 fields. -- `tests/python/test_adoption_assets.py`: docs/example/MultiQC adoption checks. -- `README.md`, `docs/tool-landscape.md`, `docs/benchmarking.md`, `docs/output-contract.md`, `docs/roadmap.md`: document v0.4 positioning and outputs. - ---- - -## Task 1: Add Readiness Models And Aggregation - -**Files:** - -- Create: `src/readiness.rs` -- Modify: `src/lib.rs` -- Modify: `src/models.rs` -- Test: `src/readiness.rs` - -- [ ] **Step 1: Write failing readiness unit tests** - -Add `src/readiness.rs` with tests first: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::models::{Finding, FindingCategory, FindingConfidence, Severity, VerdictStatus}; - - fn finding(id: &str, severity: Severity) -> Finding { - Finding { - id: id.to_string(), - category: FindingCategory::Validity, - severity, - confidence: FindingConfidence::High, - requires_followup_tool: false, - profile: "assembly".to_string(), - affected_count: 1, - affected_fraction: 0.5, - message: format!("{id} message"), - why_it_matters: format!("{id} matters"), - suggested_next_step: format!("{id} action"), - evidence: crate::models::empty_evidence(), - actions: Vec::new(), - } - } - - #[test] - fn duplicate_first_token_ids_fail_index_readiness() { - let readiness = build_readiness( - VerdictStatus::Fail, - &["duplicate_first_token_ids".to_string()], - &[finding("duplicate_first_token_ids", Severity::Critical)], - ReadinessScope::Single, - ); - - assert_eq!(readiness.overall.status, ReadinessStatus::Fail); - assert_eq!( - readiness.overall.blockers, - ["index.duplicate_first_token_ids"] - ); - let index = readiness.category("index").unwrap(); - assert_eq!(index.status, ReadinessStatus::Fail); - assert_eq!(index.findings, ["duplicate_first_token_ids"]); - } - - #[test] - fn terminal_ns_warn_submission_but_do_not_fail_overall_when_gate_passes() { - let readiness = build_readiness( - VerdictStatus::Warn, - &[], - &[finding("terminal_ns", Severity::Major)], - ReadinessScope::Single, - ); - - assert_eq!(readiness.overall.status, ReadinessStatus::Warn); - assert!(readiness.overall.blockers.is_empty()); - assert_eq!( - readiness.category("submission").unwrap().status, - ReadinessStatus::Warn - ); - } - - #[test] - fn clean_report_has_machine_and_core_categories_pass() { - let readiness = build_readiness(VerdictStatus::Pass, &[], &[], ReadinessScope::Single); - - assert_eq!(readiness.overall.status, ReadinessStatus::Pass); - for id in ["file", "structure", "alphabet", "index", "assembly", "submission", "machine"] { - assert_eq!(readiness.category(id).unwrap().status, ReadinessStatus::Pass); - } - assert!(readiness.category("cohort").is_none()); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: - -```bash -cargo test --locked readiness::tests -- --nocapture -``` - -Expected: FAIL because `src/readiness.rs`, `ReadinessStatus`, `ReadinessScope`, and `build_readiness` do not exist. - -- [ ] **Step 3: Implement readiness models** - -Create `src/readiness.rs`: - -```rust -use serde::{Deserialize, Serialize}; - -use crate::models::{Finding, VerdictStatus}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ReadinessScope { - Single, - Compare, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "UPPERCASE")] -pub enum ReadinessStatus { - Pass, - Warn, - Fail, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ReadinessReport { - pub overall: ReadinessOverall, - pub categories: Vec, -} - -impl ReadinessReport { - pub fn category(&self, id: &str) -> Option<&ReadinessCategory> { - self.categories.iter().find(|category| category.id == id) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ReadinessOverall { - pub status: ReadinessStatus, - pub blockers: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ReadinessCategory { - pub id: String, - pub label: String, - pub status: ReadinessStatus, - pub findings: Vec, -} - -pub fn build_readiness( - verdict: VerdictStatus, - blocking_findings: &[String], - findings: &[Finding], - scope: ReadinessScope, -) -> ReadinessReport { - let mut categories = base_categories(scope); - for finding in findings { - for category_id in category_ids_for_finding(&finding.id) { - if let Some(category) = categories.iter_mut().find(|category| category.id == category_id) - { - category.findings.push(finding.id.clone()); - let is_blocking = blocking_findings.iter().any(|id| id == &finding.id) - || matches!(finding.severity, crate::models::Severity::Critical); - let status = if is_blocking { - ReadinessStatus::Fail - } else { - ReadinessStatus::Warn - }; - category.status = max_status(category.status, status); - } - } - } - - let blockers = categories - .iter() - .filter(|category| category.status == ReadinessStatus::Fail) - .flat_map(|category| { - category - .findings - .iter() - .map(move |finding| format!("{}.{}", category.id, finding)) - }) - .collect::>(); - let overall_status = if !blockers.is_empty() { - ReadinessStatus::Fail - } else { - match verdict { - VerdictStatus::Pass => ReadinessStatus::Pass, - VerdictStatus::Warn => ReadinessStatus::Warn, - VerdictStatus::Fail => ReadinessStatus::Fail, - } - }; - - ReadinessReport { - overall: ReadinessOverall { - status: overall_status, - blockers, - }, - categories, - } -} - -fn base_categories(scope: ReadinessScope) -> Vec { - let mut ids = vec![ - ("file", "File readiness"), - ("structure", "Structure readiness"), - ("alphabet", "Alphabet readiness"), - ("index", "Index readiness"), - ("assembly", "Assembly readiness"), - ("submission", "Submission readiness"), - ("machine", "Machine readiness"), - ]; - if matches!(scope, ReadinessScope::Compare) { - ids.insert(6, ("cohort", "Cohort readiness")); - } - ids.into_iter() - .map(|(id, label)| ReadinessCategory { - id: id.to_string(), - label: label.to_string(), - status: ReadinessStatus::Pass, - findings: Vec::new(), - }) - .collect() -} - -fn category_ids_for_finding(id: &str) -> &'static [&'static str] { - match id { - "invalid_fasta_structure" => &["file", "structure"], - "invalid_chars" => &["alphabet"], - "duplicate_ids" | "duplicate_first_token_ids" => &["index"], - "unsafe_ids" | "long_headers" | "reserved_header_chars" => &["index", "submission"], - "high_n_rate" | "gap_runs" | "tiny_contigs" | "gc_outliers" | "length_outliers" - | "composite_anomalies" | "gap_pattern_warnings" | "expected_size_outlier" => { - &["assembly"] - } - "terminal_ns" => &["assembly", "submission"], - "cohort_total_length_outliers" | "cohort_gc_outliers" | "cohort_n_percent_outliers" - | "cohort_sequence_count_outliers" | "cohort_n50_outliers" => &["cohort"], - _ => &["machine"], - } -} - -fn max_status(left: ReadinessStatus, right: ReadinessStatus) -> ReadinessStatus { - match (left, right) { - (ReadinessStatus::Fail, _) | (_, ReadinessStatus::Fail) => ReadinessStatus::Fail, - (ReadinessStatus::Warn, _) | (_, ReadinessStatus::Warn) => ReadinessStatus::Warn, - _ => ReadinessStatus::Pass, - } -} -``` - -Modify `src/lib.rs`: - -```rust -pub mod readiness; -``` - -- [ ] **Step 4: Add readiness to single-file report model** - -Modify `src/models.rs`: - -```rust -use crate::readiness::{self, ReadinessReport, ReadinessScope}; -``` - -Add field to `FastaguardReport` after `gate`: - -```rust -pub readiness: ReadinessReport, -``` - -In `from_analysis`, after `gate` construction, avoid computing it twice by binding: - -```rust -let gate = gate::decision( - config.gate_mode, - analysis.status, - &findings, - &config.rules.fail_on, -); -let readiness = readiness::build_readiness( - analysis.status, - &gate.blocking_findings, - &findings, - ReadinessScope::Single, -); -``` - -Then set: - -```rust -gate, -readiness, -``` - -In `from_invalid_fasta`, build the gate/readiness the same way with `VerdictStatus::Fail`. - -Update all test report builders in `src/report/mod.rs`, `src/report/html.rs`, `src/report/tsv.rs`, and `src/report/multiqc.rs` to include: - -```rust -readiness: crate::readiness::build_readiness( - VerdictStatus::Pass, - &[], - &[], - crate::readiness::ReadinessScope::Single, -), -``` - -- [ ] **Step 5: Run readiness tests** - -Run: - -```bash -cargo test --locked readiness::tests -cargo test --locked models::tests report:: -- --nocapture -``` - -Expected: PASS for readiness tests; any compile failures point to test report builders missing the new `readiness` field. - -- [ ] **Step 6: Commit** - -```bash -git add src/readiness.rs src/lib.rs src/models.rs src/report/mod.rs src/report/html.rs src/report/tsv.rs src/report/multiqc.rs -git commit -m "feat: add readiness report model" -``` - ---- - -## Task 2: Extend Metrics For Header, Index, Terminal-N, Gap, And Expected-Size Signals - -**Files:** - -- Modify: `src/metrics.rs` -- Modify: `src/cli.rs` -- Modify: `src/profile.rs` -- Test: `src/metrics.rs` -- Test: `src/cli.rs` - -- [ ] **Step 1: Write failing metrics tests** - -Add tests to `src/metrics.rs`: - -```rust -#[test] -fn records_header_and_index_readiness_signals() { - let metrics = AssemblyMetrics::from_records( - vec![ - FastaRecord { - id: "contig1".into(), - header: "contig1 length=1000".into(), - sequence: b"ACGT".to_vec(), - }, - FastaRecord { - id: "contig1".into(), - header: "contig1 length=2000".into(), - sequence: b"TGCA".to_vec(), - }, - FastaRecord { - id: "unsafe/path".into(), - header: "unsafe/path with|pipe".into(), - sequence: b"ACGT".to_vec(), - }, - ], - &profile(), - ); - - assert_eq!(metrics.duplicate_first_token_id_count, 1); - assert_eq!(metrics.unsafe_id_count, 1); - assert_eq!(metrics.reserved_header_char_count, 1); - assert!(metrics.sequences[1].duplicate_first_token_id); - assert!(metrics.sequences[2].unsafe_id); - assert!(metrics.sequences[2].reserved_header_chars); -} - -#[test] -fn detects_terminal_ns_and_gap_pattern_counts() { - let metrics = AssemblyMetrics::from_records( - vec![ - FastaRecord { - id: "terminal".into(), - header: "terminal".into(), - sequence: b"NACGTN".to_vec(), - }, - FastaRecord { - id: "gap100".into(), - header: "gap100".into(), - sequence: format!("AAA{}TTT", "N".repeat(100)).into_bytes(), - }, - ], - &profile(), - ); - - assert_eq!(metrics.terminal_n_sequence_count, 1); - assert_eq!(metrics.repeated_gap_pattern_sequence_count, 1); - assert_eq!(metrics.sequences[0].terminal_n_prefix, 1); - assert_eq!(metrics.sequences[0].terminal_n_suffix, 1); - assert_eq!(metrics.sequences[1].gap_run_100_count, 1); -} -``` - -Add tests to `src/cli.rs`: - -```rust -#[test] -fn expected_size_parses_decimal_units() { - let cli = Cli::parse_from([ - "fastaguard", - "input.fa", - "--expected-size", - "5mb", - "--expected-size-tolerance", - "0.25", - ]); - let config = cli.to_run_config().unwrap(); - - assert_eq!(config.thresholds.expected_size_bases, Some(5_000_000)); - assert_eq!(config.thresholds.expected_size_tolerance, Some(0.25)); -} - -#[test] -fn expected_size_rejects_unknown_units() { - let cli = Cli::parse_from(["fastaguard", "input.fa", "--expected-size", "5mib"]); - let error = cli.to_run_config().unwrap_err(); - - assert!(error.to_string().contains("--expected-size accepts bases, kb, mb, or gb")); -} -``` - -- [ ] **Step 2: Run targeted tests to verify failure** - -Run: - -```bash -cargo test --locked metrics::tests::records_header_and_index_readiness_signals -- --nocapture -cargo test --locked metrics::tests::detects_terminal_ns_and_gap_pattern_counts -- --nocapture -cargo test --locked cli::tests::expected_size_parses_decimal_units -- --nocapture -``` - -Expected: FAIL because metrics fields and expected-size parsing do not exist. - -- [ ] **Step 3: Add expected-size threshold fields and parser** - -Modify `src/profile.rs` `ThresholdOverrides`: - -```rust -pub expected_size_bases: Option, -pub expected_size_tolerance: Option, -``` - -Modify default test builders to set both fields to `None`. - -Modify `src/cli.rs`: - -```rust -#[arg(long, value_name = "SIZE")] -pub expected_size: Option, - -#[arg(long, default_value_t = 0.25)] -pub expected_size_tolerance: f64, -``` - -Add parser: - -```rust -fn parse_expected_size(value: &str) -> Result { - let normalized = value.trim().to_ascii_lowercase(); - let (number, multiplier) = if let Some(number) = normalized.strip_suffix("kb") { - (number, 1_000_u64) - } else if let Some(number) = normalized.strip_suffix('k') { - (number, 1_000_u64) - } else if let Some(number) = normalized.strip_suffix("mb") { - (number, 1_000_000_u64) - } else if let Some(number) = normalized.strip_suffix('m') { - (number, 1_000_000_u64) - } else if let Some(number) = normalized.strip_suffix("gb") { - (number, 1_000_000_000_u64) - } else if let Some(number) = normalized.strip_suffix('g') { - (number, 1_000_000_000_u64) - } else if normalized.chars().all(|ch| ch.is_ascii_digit()) { - (normalized.as_str(), 1_u64) - } else { - return Err(anyhow!( - "--expected-size accepts bases, kb, mb, or gb decimal units" - )); - }; - let parsed = number.parse::().map_err(|_| { - anyhow!("--expected-size accepts bases, kb, mb, or gb decimal units") - })?; - parsed - .checked_mul(multiplier) - .ok_or_else(|| anyhow!("--expected-size is too large")) -} -``` - -In `to_run_config`, validate tolerance: - -```rust -if !self.expected_size_tolerance.is_finite() || self.expected_size_tolerance < 0.0 { - return Err(anyhow!("--expected-size-tolerance must be finite and non-negative")); -} -let expected_size_bases = self - .expected_size - .as_deref() - .map(parse_expected_size) - .transpose()?; -``` - -Set thresholds: - -```rust -expected_size_bases, -expected_size_tolerance: expected_size_bases.map(|_| self.expected_size_tolerance), -``` - -- [ ] **Step 4: Extend metrics structs and builders** - -Modify `src/metrics.rs` `SequenceSummary`: - -```rust -pub header: String, -pub first_token_id: String, -pub duplicate_first_token_id: bool, -pub unsafe_id: bool, -pub long_header: bool, -pub reserved_header_chars: bool, -pub terminal_n_prefix: u64, -pub terminal_n_suffix: u64, -pub gap_run_100_count: u64, -``` - -Modify `AssemblyMetrics`: - -```rust -pub duplicate_first_token_id_count: u64, -pub unsafe_id_count: u64, -pub long_header_count: u64, -pub reserved_header_char_count: u64, -pub terminal_n_sequence_count: u64, -pub repeated_gap_pattern_sequence_count: u64, -pub ungapped_total_length: u64, -``` - -Modify `MetricsAccumulator`: - -```rust -seen_first_token_ids: BTreeSet, -duplicate_first_token_id_count: u64, -unsafe_id_count: u64, -long_header_count: u64, -reserved_header_char_count: u64, -terminal_n_sequence_count: u64, -repeated_gap_pattern_sequence_count: u64, -ungapped_total: u128, -``` - -Change `start_record` signature: - -```rust -fn start_record(&mut self, id: String, header: String) -``` - -Call it from `from_records`: - -```rust -accumulator.start_record(record.id, record.header); -``` - -Call it from `from_path`: - -```rust -FastaEvent::StartRecord { id, header, .. } => accumulator.start_record(id, header), -``` - -Add helper functions: - -```rust -fn unsafe_id(id: &str) -> bool { - id.trim() != id || id.chars().any(|ch| ch.is_control() || matches!(ch, '/' | '\\')) -} - -fn reserved_header_chars(header: &str) -> bool { - header - .chars() - .any(|ch| matches!(ch, '|' | ';' | '"' | '\'' | '`' | '<' | '>' | '\t')) -} -``` - -Track terminal Ns in `SequenceSummaryBuilder` with: - -```rust -first_base: Option, -last_base: Option, -leading_n_count: u64, -trailing_n_count: u64, -current_gap_run_length: u64, -gap_run_100_count: u64, -``` - -When adding an uppercase byte: - -```rust -if self.first_base.is_none() { - self.first_base = Some(upper); -} -self.last_base = Some(upper); -if upper == b'N' && self.length == self.leading_n_count { - self.leading_n_count += 1; -} -if upper == b'N' { - self.trailing_n_count += 1; -} else { - self.trailing_n_count = 0; -} -``` - -When an N run ends or the builder finishes, count exactly-100 runs: - -```rust -if self.current_gap_run_length == 100 { - self.gap_run_100_count += 1; -} -``` - -- [ ] **Step 5: Run targeted tests** - -Run: - -```bash -cargo test --locked metrics::tests::records_header_and_index_readiness_signals -cargo test --locked metrics::tests::detects_terminal_ns_and_gap_pattern_counts -cargo test --locked cli::tests::expected_size -``` - -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add src/metrics.rs src/cli.rs src/profile.rs -git commit -m "feat: collect readiness metrics" -``` - ---- - -## Task 3: Add New Single-File Findings And Pipeline Gate Behavior - -**Files:** - -- Modify: `src/findings.rs` -- Modify: `src/models.rs` -- Modify: `src/gate.rs` -- Modify: `schema/finding-catalog.json` -- Test: `src/findings.rs` -- Test: `src/cli.rs` -- Test: `src/contract.rs` - -- [ ] **Step 1: Write failing finding tests** - -Add tests to `src/findings.rs`: - -```rust -#[test] -fn duplicate_first_token_ids_are_critical_findings() { - let mut metrics = clean_metrics(); - metrics.sequence_count = 2; - metrics.duplicate_first_token_id_count = 1; - metrics.sequences = vec![ - sequence_summary_with_id("contig1", 100, 0), - sequence_summary_with_id("contig1", 100, 0), - ]; - metrics.sequences[1].duplicate_first_token_id = true; - - let analysis = analyze(&metrics, &profile(), &rules(&[])); - - assert_eq!(analysis.status, VerdictStatus::Fail); - assert_eq!(analysis.reasons, ["duplicate_first_token_ids"]); - assert_eq!(analysis.findings[0].id, "duplicate_first_token_ids"); - assert_eq!(analysis.findings[0].severity, Severity::Critical); -} - -#[test] -fn terminal_ns_warn_and_include_prefix_suffix_evidence() { - let mut metrics = clean_metrics(); - metrics.sequence_count = 1; - metrics.terminal_n_sequence_count = 1; - metrics.sequences = vec![sequence_summary_with_id("edge_n", 10, 2)]; - metrics.sequences[0].terminal_n_prefix = 1; - metrics.sequences[0].terminal_n_suffix = 1; - - let analysis = analyze(&metrics, &profile(), &rules(&[])); - - assert_eq!(analysis.status, VerdictStatus::Warn); - let finding = analysis - .findings - .iter() - .find(|finding| finding.id == "terminal_ns") - .unwrap(); - assert_eq!(finding.affected_count, 1); - assert!(finding.why_it_matters.contains("submission")); -} - -#[test] -fn expected_size_outlier_uses_ungapped_length() { - let mut metrics = clean_metrics(); - metrics.total_length = 1_100_000; - metrics.ungapped_total_length = 1_000_000; - let profile = ProfileConfig::assembly(ThresholdOverrides { - max_n_rate: None, - min_contig_length: None, - expected_size_bases: Some(500_000), - expected_size_tolerance: Some(0.10), - }); - - let analysis = analyze(&metrics, &profile, &rules(&[])); - - assert!(analysis - .findings - .iter() - .any(|finding| finding.id == "expected_size_outlier")); -} -``` - -Add helper: - -```rust -fn sequence_summary_with_id(id: &str, length: u64, n_count: u64) -> SequenceSummary { - let mut sequence = sequence_summary(length, n_count); - sequence.id = id.to_string(); - sequence.header = id.to_string(); - sequence.first_token_id = id.to_string(); - sequence -} -``` - -- [ ] **Step 2: Write failing gate test** - -Update `src/cli.rs` `gate_pipeline_adds_conservative_fail_rules` expected set: - -```rust -[ - "duplicate_first_token_ids", - "duplicate_ids", - "high_n_rate", - "invalid_chars", - "invalid_fasta_structure", -] -``` - -- [ ] **Step 3: Run tests to verify failure** - -Run: - -```bash -cargo test --locked findings::tests::duplicate_first_token_ids_are_critical_findings -- --nocapture -cargo test --locked findings::tests::terminal_ns_warn_and_include_prefix_suffix_evidence -- --nocapture -cargo test --locked cli::tests::gate_pipeline_adds_conservative_fail_rules -- --nocapture -``` - -Expected: FAIL because findings and gate IDs are missing. - -- [ ] **Step 4: Implement new findings** - -Modify `src/findings.rs` `build_findings` after duplicate IDs: - -```rust -if metrics.duplicate_first_token_id_count > 0 { - findings.push(finding( - "duplicate_first_token_ids", - Severity::Critical, - profile, - metrics.duplicate_first_token_id_count, - affected_fraction(metrics.duplicate_first_token_id_count, metrics.sequence_count), - evidence_for_sequences( - metrics.duplicate_first_token_id_count, - metrics - .sequences - .iter() - .filter(|sequence| sequence.duplicate_first_token_id), - "duplicate first whitespace-delimited FASTA identifier", - EvidenceKind::DuplicateFirstTokenId, - ), - FindingText { - message: format!( - "{} duplicate first-token FASTA IDs were found.", - metrics.duplicate_first_token_id_count - ), - why_it_matters: - "Many indexing, mapping, BLAST, and annotation tools treat the first header token as the record name.", - suggested_next_step: - "Rename records so every first-token FASTA identifier is unique before running downstream tools.", - }, - )); -} -``` - -Add blocks for: - -```text -unsafe_ids -long_headers -reserved_header_chars -terminal_ns -gap_pattern_warnings -expected_size_outlier -``` - -Use these severities: - -```text -unsafe_ids = Major -long_headers = Minor -reserved_header_chars = Minor -terminal_ns = Major -gap_pattern_warnings = Minor -expected_size_outlier = Major -``` - -Extend `EvidenceKind`: - -```rust -DuplicateFirstTokenId, -UnsafeId, -HeaderCompatibility, -TerminalN, -GapPattern, -ExpectedSize, -``` - -For `ExpectedSize`, use `empty_evidence()` plus message values because it is a whole-assembly signal. The message must include expected size, tolerance, and ungapped total length. - -- [ ] **Step 5: Update metadata, actions, and routing** - -Modify `finding_metadata`: - -```rust -"duplicate_first_token_ids" => (Duplication, High), -"unsafe_ids" | "long_headers" | "reserved_header_chars" => (Validity, Moderate), -"terminal_ns" | "gap_pattern_warnings" => (Structure, Moderate), -"expected_size_outlier" => (Structure, Moderate), -``` - -Modify `finding_actions` in `src/models.rs` with explicit actions: - -```rust -"duplicate_first_token_ids" => vec![action( - "rename_records", - "first-token FASTA identifiers", - "Tools that index by first token can retrieve or annotate the wrong record when first-token IDs collide.", - "seqkit", - false, -)] -``` - -Add actions for header compatibility, terminal Ns, gap patterns, and expected size. For expected size, recommend official validator/deeper QC: - -```rust -"expected_size_outlier" => vec![action( - "review_expected_size", - "assembly ungapped total length", - "Unexpected assembly size can indicate missing sequence, extra sequence, contamination, or incorrect expected-size metadata.", - "NCBI expected genome size check", - true, -)] -``` - -Modify `routing_hints` for the new IDs: - -```rust -"duplicate_first_token_ids" => push_routing_hint(&mut hints, "index_readiness_failure", "rename_records_before_indexing", false), -"unsafe_ids" | "long_headers" | "reserved_header_chars" => push_routing_hint(&mut hints, "header_compatibility_warning", "review_headers_before_database_or_submission", false), -"terminal_ns" | "gap_pattern_warnings" => push_routing_hint(&mut hints, "submission_readiness_warning", "review_gap_and_terminal_n_patterns", false), -"expected_size_outlier" => push_routing_hint(&mut hints, "expected_size_warning", "run_submission_or_contamination_followup", true), -``` - -- [ ] **Step 6: Update pipeline gate** - -Modify `src/gate.rs` default pipeline set: - -```rust -const PIPELINE_FAIL_ON: &[&str] = &[ - "duplicate_first_token_ids", - "duplicate_ids", - "high_n_rate", - "invalid_chars", - "invalid_fasta_structure", -]; -``` - -- [ ] **Step 7: Update finding catalog** - -Modify `schema/finding-catalog.json` and add entries for: - -```text -duplicate_first_token_ids -unsafe_ids -long_headers -reserved_header_chars -terminal_ns -gap_pattern_warnings -expected_size_outlier -``` - -Each entry must include: - -```json -"id": "duplicate_first_token_ids", -"category": "duplication", -"severity": "critical", -"confidence": "high", -"requires_followup_tool": false, -"description": "First whitespace-delimited FASTA identifiers collide.", -"why_it_matters": "Many downstream tools index records by the first header token.", -"suggested_actions": [ - { - "action_type": "rename_records", - "target": "first-token FASTA identifiers", - "reason": "First-token collisions can make indexes, annotation joins, and BLAST databases ambiguous.", - "recommended_tool": "seqkit", - "requires_external_database": false - } -] -``` - -- [ ] **Step 8: Run tests** - -Run: - -```bash -cargo test --locked findings::tests -cargo test --locked cli::tests::gate_pipeline_adds_conservative_fail_rules -cargo test --locked contract::tests -``` - -Expected: PASS. - -- [ ] **Step 9: Commit** - -```bash -git add src/findings.rs src/models.rs src/gate.rs src/cli.rs schema/finding-catalog.json -git commit -m "feat: add preflight readiness findings" -``` - ---- - -## Task 4: Render Readiness In Single-File JSON, TSV, HTML, MultiQC, Schema, And Goldens - -**Files:** - -- Modify: `src/report/html.rs` -- Modify: `src/report/tsv.rs` -- Modify: `src/report/multiqc.rs` -- Modify: `schema/fastaguard.schema.json` -- Modify: `tests/schema_contract.rs` -- Modify: `tests/golden/*.json` -- Test: `tests/cli.rs` -- Test: `tests/schema_contract.rs` - -- [ ] **Step 1: Write failing CLI/schema tests** - -Add to `tests/cli.rs`: - -```rust -#[test] -fn report_includes_readiness_matrix() { - let temp = tempfile::tempdir().unwrap(); - let json = temp.path().join("report.json"); - let html = temp.path().join("report.html"); - let tsv = temp.path().join("report.tsv"); - let multiqc = temp.path().join("report_mqc.json"); - - Command::cargo_bin("fastaguard") - .unwrap() - .args([ - "testdata/problem_assembly.fa", - "--gate", - "pipeline", - "--json", - json.to_str().unwrap(), - "--out", - html.to_str().unwrap(), - "--tsv", - tsv.to_str().unwrap(), - "--multiqc", - multiqc.to_str().unwrap(), - ]) - .assert() - .code(2); - - let report: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(json).unwrap()).unwrap(); - assert_eq!(report["readiness"]["overall"]["status"], "FAIL"); - assert!(report["readiness"]["categories"].as_array().unwrap().iter().any(|category| { - category["id"] == "index" && category["status"] == "FAIL" - })); - assert!(std::fs::read_to_string(html).unwrap().contains("Readiness")); - assert!(std::fs::read_to_string(tsv).unwrap().contains("readiness_status\tFAIL")); -} -``` - -Add to `tests/schema_contract.rs`: - -```rust -#[test] -fn schema_requires_readiness_for_single_reports() { - let schema: serde_json::Value = - serde_json::from_str(fastaguard::contract::schema_json()).unwrap(); - - assert!(schema["required"] - .as_array() - .unwrap() - .iter() - .any(|value| value == "readiness")); - assert_eq!(schema["properties"]["schema_version"]["const"], "0.4.0"); -} -``` - -- [ ] **Step 2: Run tests to verify failure** - -Run: - -```bash -cargo test --locked --test cli report_includes_readiness_matrix -- --nocapture -cargo test --locked --test schema_contract schema_requires_readiness_for_single_reports -- --nocapture -``` - -Expected: FAIL until writers/schema are updated. - -- [ ] **Step 3: Update TSV** - -Modify `src/report/tsv.rs` after gate rows: - -```rust -write_metric( - &mut writer, - "readiness_status", - readiness_status(report.readiness.overall.status), -)?; -write_metric( - &mut writer, - "readiness_blockers", - report.readiness.overall.blockers.join(","), -)?; -for category in &report.readiness.categories { - write_metric( - &mut writer, - &format!("readiness_{}_status", category.id), - readiness_status(category.status), - )?; -} -``` - -Add helper: - -```rust -fn readiness_status(status: crate::readiness::ReadinessStatus) -> &'static str { - match status { - crate::readiness::ReadinessStatus::Pass => "PASS", - crate::readiness::ReadinessStatus::Warn => "WARN", - crate::readiness::ReadinessStatus::Fail => "FAIL", - } -} -``` - -- [ ] **Step 4: Update HTML** - -Modify `src/report/html.rs`: - -Add CSS: - -```css -.readiness-table td.status-pass { color: #1f7a3f; font-weight: 700; } -.readiness-table td.status-warn { color: #9a6a00; font-weight: 700; } -.readiness-table td.status-fail { color: #a32020; font-weight: 700; } -``` - -Insert after gate: - -```rust -let readiness = render_readiness(report); -``` - -Template: - -```html -
-

Readiness

-{readiness} -
-``` - -Helper: - -```rust -fn render_readiness(report: &FastaguardReport) -> String { - let rows = report - .readiness - .categories - .iter() - .map(|category| { - let status = readiness_status(category.status); - format!( - r#"{label}{status}{findings}"#, - label = escape_html(&category.label), - class = status.to_ascii_lowercase(), - status = status, - findings = render_string_list_or_none(&category.findings), - ) - }) - .collect::>() - .join(""); - format!( - r#"

Overall: {overall}

-{rows}
CategoryStatusFindings
"#, - overall = readiness_status(report.readiness.overall.status), - rows = rows, - ) -} -``` - -- [ ] **Step 5: Update MultiQC** - -Modify `src/report/multiqc.rs` `MultiqcSummaryRow`: - -```rust -readiness_status: String, -readiness_blockers: String, -``` - -Set: - -```rust -readiness_status: readiness_status(report.readiness.overall.status).to_string(), -readiness_blockers: report.readiness.overall.blockers.join(","), -``` - -Add headers to pconfig: - -```rust -("readiness_status", "Readiness"), -("readiness_blockers", "Readiness blockers"), -``` - -- [ ] **Step 6: Update schema version and readiness schema** - -Modify `src/models.rs`: - -```rust -pub const SCHEMA_VERSION: &str = "0.4.0"; -``` - -Modify `schema/fastaguard.schema.json`: - -- Set `schema_version.const` to `0.4.0`. -- Add `"readiness"` to top-level `required`. -- Add top-level property: - -```json -"readiness": { - "$ref": "#/$defs/readiness_report" -} -``` - -Add `$defs`: - -```json -"readiness_report": { - "type": "object", - "required": ["overall", "categories"], - "properties": { - "overall": { "$ref": "#/$defs/readiness_overall" }, - "categories": { - "type": "array", - "items": { "$ref": "#/$defs/readiness_category" } - } - }, - "additionalProperties": false -} -``` - -Define `readiness_status` enum as `PASS`, `WARN`, `FAIL`. - -- [ ] **Step 7: Regenerate goldens** - -Run: - -```bash -FASTAGUARD_PROVENANCE_TIMESTAMP=2026-05-23T00:00:00Z cargo test --locked --test cli golden_reports_match -- --nocapture -``` - -If the test writes runtime files only, regenerate using the repo's existing golden workflow from `tests/cli.rs`. Then copy updated JSON into: - -```text -tests/golden/valid_assembly.json -tests/golden/problem_assembly.json -tests/golden/invalid_empty_record.json -examples/reports/assembly_pass/fastaguard.json -examples/reports/assembly_fail/fastaguard.json -``` - -Do not hand-edit generated JSON except for deterministic path/timestamp conventions already used by the test suite. - -- [ ] **Step 8: Run tests** - -Run: - -```bash -cargo test --locked --test cli report_includes_readiness_matrix -cargo test --locked --test schema_contract -cargo test --locked report::tsv report::html report::multiqc -``` - -Expected: PASS. - -- [ ] **Step 9: Commit** - -```bash -git add src/models.rs src/report/html.rs src/report/tsv.rs src/report/multiqc.rs schema/fastaguard.schema.json tests/schema_contract.rs tests/cli.rs tests/golden examples/reports -git commit -m "feat: expose readiness in reports" -``` - ---- - -## Task 5: Add Compare CLI Configuration - -**Files:** - -- Modify: `src/cli.rs` -- Modify: `src/lib.rs` -- Create: `src/compare.rs` -- Test: `src/cli.rs` -- Test: `tests/cli.rs` - -- [ ] **Step 1: Write failing CLI tests** - -Add to `src/cli.rs` tests: - -```rust -#[test] -fn compare_defaults_to_compare_output_names() { - let cli = Cli::parse_from(["fastaguard", "compare", "a.fa", "b.fa"]); - let command = cli.to_command_config().unwrap(); - - let CommandConfig::Compare(config) = command else { - panic!("expected compare config"); - }; - assert_eq!(config.inputs, vec![PathBuf::from("a.fa"), PathBuf::from("b.fa")]); - assert_eq!(config.outputs.html, PathBuf::from("cohort_report.html")); - assert_eq!(config.outputs.json, PathBuf::from("cohort.json")); - assert_eq!(config.outputs.tsv, PathBuf::from("cohort.tsv")); - assert_eq!( - config.outputs.multiqc, - PathBuf::from("fastaguard_compare_mqc.json") - ); -} - -#[test] -fn compare_rejects_single_input() { - let cli = Cli::parse_from(["fastaguard", "compare", "a.fa"]); - let error = cli.to_command_config().unwrap_err(); - - assert!(error.to_string().contains("compare requires at least two FASTA inputs")); -} -``` - -Add to `tests/cli.rs`: - -```rust -#[test] -fn compare_requires_at_least_two_inputs() { - Command::cargo_bin("fastaguard") - .unwrap() - .args(["compare", "testdata/valid_assembly.fa"]) - .assert() - .code(3) - .stderr(predicate::str::contains("compare requires at least two FASTA inputs")); -} -``` - -- [ ] **Step 2: Run tests to verify failure** - -Run: - -```bash -cargo test --locked cli::tests::compare_defaults_to_compare_output_names -- --nocapture -cargo test --locked --test cli compare_requires_at_least_two_inputs -- --nocapture -``` - -Expected: FAIL because clap subcommands and compare config do not exist. - -- [ ] **Step 3: Refactor CLI into subcommands without breaking current command** - -Modify `src/cli.rs`: - -```rust -use clap::{ArgGroup, Args, Parser, Subcommand}; - -#[derive(Debug, Parser)] -#[command(name = "fastaguard")] -#[command(version)] -#[command(about = "FASTA preflight QC for assembly pipelines")] -pub struct Cli { - #[command(flatten)] - pub contract: ContractFlags, - - #[command(subcommand)] - pub command: Option, - - #[command(flatten)] - pub run: RunArgs, -} - -#[derive(Debug, Clone, Args)] -#[command(group( - ArgGroup::new("contract") - .args(["schema", "finding_catalog", "explain_finding"]) - .multiple(false) -))] -pub struct ContractFlags { - #[arg(long)] - pub schema: bool, - #[arg(long)] - pub finding_catalog: bool, - #[arg(long, value_name = "ID")] - pub explain_finding: Option, -} - -#[derive(Debug, Subcommand)] -pub enum Commands { - Compare(CompareArgs), -} -``` - -Move existing single-file fields into `RunArgs`, keeping `input: Option`. - -Add: - -```rust -#[derive(Debug, Clone, Args)] -pub struct CompareArgs { - #[arg(required = true)] - pub inputs: Vec, - - #[command(flatten)] - pub common: CommonAnalysisArgs, - - #[arg(long, default_value = "cohort_report.html")] - pub out: PathBuf, - #[arg(long, default_value = "cohort.json")] - pub json: PathBuf, - #[arg(long, default_value = "cohort.tsv")] - pub tsv: PathBuf, - #[arg(long, default_value = "fastaguard_compare_mqc.json")] - pub multiqc: PathBuf, -} -``` - -Create: - -```rust -#[derive(Debug, Clone)] -pub enum CommandConfig { - Run(RunConfig), - Compare(CompareConfig), - Contract, -} - -#[derive(Debug, Clone)] -pub struct CompareConfig { - pub inputs: Vec, - pub profile: String, - pub gate_mode: GateMode, - pub outputs: OutputPaths, - pub rules: RuleConfig, - pub thresholds: ThresholdOverrides, - pub threads: usize, - pub command: String, - pub started_at: String, - pub provenance_timestamp_override: Option, -} -``` - -Add: - -```rust -pub fn to_command_config(&self) -> Result -``` - -Keep `to_run_config()` as a compatibility wrapper: - -```rust -pub fn to_run_config(&self) -> Result { - match self.to_command_config()? { - CommandConfig::Run(config) => Ok(config), - CommandConfig::Compare(_) => Err(anyhow!("compare command cannot be converted to RunConfig")), - CommandConfig::Contract => Err(anyhow!("contract command does not have run config")), - } -} -``` - -- [ ] **Step 4: Update `src/lib.rs` routing** - -Modify `run`: - -```rust -if cli.contract.schema { - println!("{}", contract::schema_json().trim_end()); - return Ok(0); -} -if cli.contract.finding_catalog { - println!("{}", contract::finding_catalog_json().trim_end()); - return Ok(0); -} -if let Some(finding_id) = &cli.contract.explain_finding { - println!("{}", contract::explain_finding_json(finding_id)?); - return Ok(0); -} - -match cli.to_command_config()? { - cli::CommandConfig::Run(config) => run_single(config), - cli::CommandConfig::Compare(config) => compare::run_compare(config), - cli::CommandConfig::Contract => Ok(0), -} -``` - -Move current single-file body into: - -```rust -fn run_single(config: cli::RunConfig) -> Result -``` - -Add to `src/compare.rs` a compile bridge. It exists only to let the CLI -refactor compile before Task 6 adds real compare behavior: - -```rust -use anyhow::Result; - -use crate::cli::CompareConfig; - -pub fn run_compare(_config: CompareConfig) -> Result { - Ok(0) -} -``` - -Add to `src/lib.rs`: - -```rust -pub mod compare; -``` - -- [ ] **Step 5: Run CLI tests** - -Run: - -```bash -cargo test --locked cli::tests -cargo test --locked --test cli compare_requires_at_least_two_inputs -``` - -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add src/cli.rs src/lib.rs src/compare.rs tests/cli.rs -git commit -m "feat: add compare CLI shape" -``` - ---- - -## Task 6: Implement Compare Analysis And JSON Model - -**Files:** - -- Modify: `src/compare.rs` -- Modify: `src/models.rs` -- Modify: `src/report/mod.rs` -- Test: `src/compare.rs` -- Test: `tests/cli.rs` - -- [ ] **Step 1: Write failing compare model/unit tests** - -Add to `src/compare.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::models::VerdictStatus; - - #[test] - fn worst_status_prefers_fail_over_warn_over_pass() { - assert_eq!( - worst_status([VerdictStatus::Pass, VerdictStatus::Warn, VerdictStatus::Fail]), - VerdictStatus::Fail - ); - assert_eq!( - worst_status([VerdictStatus::Pass, VerdictStatus::Warn]), - VerdictStatus::Warn - ); - assert_eq!(worst_status([VerdictStatus::Pass]), VerdictStatus::Pass); - } - - #[test] - fn sample_id_uses_file_stem_without_compression_suffix() { - assert_eq!(sample_id(Path::new("assemblies/ecoli.fa")), "ecoli"); - assert_eq!(sample_id(Path::new("assemblies/ecoli.fasta.gz")), "ecoli"); - } -} -``` - -Add to `tests/cli.rs`: - -```rust -#[test] -fn compare_writes_json_with_mixed_status_samples() { - let temp = tempfile::tempdir().unwrap(); - let json = temp.path().join("cohort.json"); - let html = temp.path().join("cohort.html"); - let tsv = temp.path().join("cohort.tsv"); - let multiqc = temp.path().join("cohort_mqc.json"); - - Command::cargo_bin("fastaguard") - .unwrap() - .args([ - "compare", - "testdata/valid_assembly.fa", - "testdata/problem_assembly.fa", - "--gate", - "pipeline", - "--json", - json.to_str().unwrap(), - "--out", - html.to_str().unwrap(), - "--tsv", - tsv.to_str().unwrap(), - "--multiqc", - multiqc.to_str().unwrap(), - ]) - .assert() - .code(2); - - let report: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(json).unwrap()).unwrap(); - assert_eq!(report["report_type"], "compare"); - assert_eq!(report["schema_version"], "0.4.0"); - assert_eq!(report["summary"]["sample_count"], 2); - assert_eq!(report["summary"]["fail_count"], 1); - assert_eq!(report["samples"].as_array().unwrap().len(), 2); -} -``` - -- [ ] **Step 2: Run tests to verify failure** - -Run: - -```bash -cargo test --locked compare::tests -- --nocapture -cargo test --locked --test cli compare_writes_json_with_mixed_status_samples -- --nocapture -``` - -Expected: unit tests fail until helpers exist; CLI test fails until compare writes reports. - -- [ ] **Step 3: Add compare models** - -Modify `src/models.rs`: - -```rust -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CompareReport { - pub schema_version: String, - pub report_type: String, - pub tool: ToolInfo, - pub input: CompareInputInfo, - pub summary: CompareSummary, - pub samples: Vec, - pub cohort_findings: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CompareInputInfo { - pub profile: String, - pub sample_count: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CompareSummary { - pub sample_count: u64, - pub pass_count: u64, - pub warn_count: u64, - pub fail_count: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CompareSample { - pub sample_id: String, - pub input_path: String, - pub verdict: VerdictStatus, - pub gate_status: VerdictStatus, - pub readiness_status: crate::readiness::ReadinessStatus, - pub sequence_count: u64, - pub total_length: u64, - pub n50: u64, - pub n90: u64, - pub gc_percent: f64, - pub n_percent: f64, - pub duplicate_id_count: u64, - pub invalid_sequence_count: u64, - pub high_n_sequence_count: u64, - pub tiny_contig_count: u64, - pub max_gap_run: u64, - pub gc_outlier_count: u64, - pub length_outlier_count: u64, - pub finding_count: u64, - pub finding_ids: Vec, - pub readiness_blockers: Vec, - pub recommended_next_tools: Vec, - pub input_sha256: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CohortFinding { - pub id: String, - pub severity: Severity, - pub affected_count: u64, - pub evidence: serde_json::Value, -} -``` - -- [ ] **Step 4: Implement compare runner** - -Modify `src/compare.rs`: - -```rust -use anyhow::{Context, Result}; -use std::path::Path; -use std::time::Instant; - -use crate::cli::{CompareConfig, RunConfig}; -use crate::models::{ - CompareInputInfo, CompareReport, CompareSample, CompareSummary, FastaguardReport, - SCHEMA_VERSION, TOOL_NAME, TOOL_VERSION, ToolInfo, VerdictStatus, -}; - -pub fn run_compare(config: CompareConfig) -> Result { - let mut samples = Vec::new(); - for input in &config.inputs { - let sample_report = run_one_sample(&config, input)?; - samples.push(sample_from_report(sample_id(input), &sample_report)); - } - let summary = summarize(&samples); - let report = CompareReport { - schema_version: SCHEMA_VERSION.to_string(), - report_type: "compare".to_string(), - tool: ToolInfo { - name: TOOL_NAME.to_string(), - version: TOOL_VERSION.to_string(), - }, - input: CompareInputInfo { - profile: config.profile.clone(), - sample_count: samples.len() as u64, - }, - summary, - samples, - cohort_findings: Vec::new(), - }; - crate::report::write_compare_all(&report, &config.outputs)?; - Ok(compare_exit_code(&report)) -} -``` - -Implement `run_one_sample` by constructing a `RunConfig` with in-memory artifact -names derived from compare output names, then building a report without writing -per-sample files. To avoid duplicating single-run logic, add a helper in -`src/lib.rs`: - -```rust -pub(crate) fn build_single_report(config: cli::RunConfig, started: Instant) -> Result -``` - -Use that helper from both `run_single` and `compare::run_one_sample`. - -Implement helpers: - -```rust -pub(crate) fn sample_id(path: &Path) -> String -pub(crate) fn worst_status(statuses: I) -> VerdictStatus -where - I: IntoIterator -``` - -For `.fasta.gz`, strip `.gz` first, then `.fasta` or `.fa`. - -- [ ] **Step 5: Add bootstrap JSON-only compare writer** - -Modify `src/report/mod.rs`: - -```rust -use crate::models::CompareReport; - -pub fn write_compare_all(report: &CompareReport, outputs: &OutputPaths) -> Result<()> { - validate_output_paths(outputs)?; - std::fs::write(&outputs.json, serde_json::to_string_pretty(report)? + "\n")?; - std::fs::write(&outputs.tsv, "sample_id\tverdict\n")?; - std::fs::write(&outputs.multiqc, "{}\n")?; - std::fs::write(&outputs.html, "FastaGuard Compare\n")?; - Ok(()) -} -``` - -This bootstrap writer is intentionally complete enough for Task 6 JSON tests. -Task 7 replaces the bootstrap TSV, MultiQC, and HTML bodies with complete report -writers. - -- [ ] **Step 6: Run compare tests** - -Run: - -```bash -cargo test --locked compare::tests -cargo test --locked --test cli compare_writes_json_with_mixed_status_samples -``` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add src/compare.rs src/models.rs src/lib.rs src/report/mod.rs tests/cli.rs -git commit -m "feat: build compare JSON report" -``` - ---- - -## Task 7: Add Cohort Findings And Compare Report Writers - -**Files:** - -- Modify: `src/compare.rs` -- Create: `src/report/compare_tsv.rs` -- Create: `src/report/compare_multiqc.rs` -- Create: `src/report/compare_html.rs` -- Modify: `src/report/mod.rs` -- Test: `src/compare.rs` -- Test: `tests/cli.rs` - -- [ ] **Step 1: Write failing cohort finding test** - -Add to `src/compare.rs`: - -```rust -#[test] -fn cohort_total_length_outliers_rank_unusual_samples() { - let samples = vec![ - sample_for_cohort("a", 5_000_000, 50.0, 0.1, 100, 200_000), - sample_for_cohort("b", 5_100_000, 50.2, 0.1, 101, 210_000), - sample_for_cohort("c", 9_000_000, 50.1, 0.1, 99, 205_000), - ]; - - let findings = cohort_findings(&samples); - - assert!(findings - .iter() - .any(|finding| finding.id == "cohort_total_length_outliers")); -} -``` - -Add helper: - -```rust -fn sample_for_cohort( - sample_id: &str, - total_length: u64, - gc_percent: f64, - n_percent: f64, - sequence_count: u64, - n50: u64, -) -> CompareSample { - CompareSample { - sample_id: sample_id.to_string(), - input_path: format!("{sample_id}.fa"), - verdict: VerdictStatus::Pass, - gate_status: VerdictStatus::Pass, - readiness_status: crate::readiness::ReadinessStatus::Pass, - sequence_count, - total_length, - n50, - n90: n50, - gc_percent, - n_percent, - duplicate_id_count: 0, - invalid_sequence_count: 0, - high_n_sequence_count: 0, - tiny_contig_count: 0, - max_gap_run: 0, - gc_outlier_count: 0, - length_outlier_count: 0, - finding_count: 0, - finding_ids: Vec::new(), - readiness_blockers: Vec::new(), - recommended_next_tools: Vec::new(), - input_sha256: "0".repeat(64), - } -} -``` - -- [ ] **Step 2: Write failing writer CLI assertions** - -Extend `compare_writes_json_with_mixed_status_samples`: - -```rust -assert!(std::fs::read_to_string(&tsv).unwrap().contains("sample_id\tinput_path\tverdict")); -assert!(std::fs::read_to_string(&html).unwrap().contains("Readiness Matrix")); -let mqc: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&multiqc).unwrap()).unwrap(); -assert_eq!(mqc["plot_type"], "table"); -assert!(mqc["data"].as_object().unwrap().contains_key("valid_assembly")); -``` - -- [ ] **Step 3: Run tests to verify failure** - -Run: - -```bash -cargo test --locked compare::tests::cohort_total_length_outliers_rank_unusual_samples -- --nocapture -cargo test --locked --test cli compare_writes_json_with_mixed_status_samples -- --nocapture -``` - -Expected: FAIL because cohort findings and real writers do not exist. - -- [ ] **Step 4: Implement cohort findings** - -In `src/compare.rs`, add: - -```rust -pub(crate) fn cohort_findings(samples: &[CompareSample]) -> Vec { - let mut findings = Vec::new(); - push_numeric_outliers( - &mut findings, - "cohort_total_length_outliers", - samples, - |sample| sample.total_length as f64, - |sample| serde_json::json!({ - "sample_id": sample.sample_id, - "total_length": sample.total_length, - "reason": "total length is unusual relative to the cohort" - }), - ); - push_numeric_outliers( - &mut findings, - "cohort_gc_outliers", - samples, - |sample| sample.gc_percent, - |sample| serde_json::json!({ - "sample_id": sample.sample_id, - "gc_percent": sample.gc_percent, - "reason": "GC percent is unusual relative to the cohort" - }), - ); - findings -} -``` - -Use simple deterministic IQR or z-score logic already present in `src/stats/outliers.rs`. If using existing IQR functions for `u64`, use it for total length, sequence count, and N50. For floating values, add a small local z-score helper requiring at least three finite values. - -Set `report.cohort_findings = cohort_findings(&samples)`. - -- [ ] **Step 5: Implement compare TSV writer** - -Create `src/report/compare_tsv.rs`: - -```rust -use anyhow::Result; -use std::fs::File; -use std::io::{BufWriter, Write}; -use std::path::Path; - -use crate::models::{CompareReport, VerdictStatus}; - -pub fn write(report: &CompareReport, path: &Path) -> Result<()> { - let file = File::create(path)?; - let mut writer = BufWriter::new(file); - writeln!( - writer, - "sample_id\tinput_path\tverdict\tgate_status\treadiness_status\tsequence_count\ttotal_length\tn50\tn90\tgc_percent\tn_percent\tduplicate_id_count\tinvalid_sequence_count\thigh_n_sequence_count\ttiny_contig_count\tmax_gap_run\tgc_outlier_count\tlength_outlier_count\tfinding_count\treadiness_blockers\trecommended_next_tools\tinput_sha256" - )?; - for sample in &report.samples { - writeln!( - writer, - "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", - sample.sample_id, - sample.input_path, - status(sample.verdict), - status(sample.gate_status), - readiness_status(sample.readiness_status), - sample.sequence_count, - sample.total_length, - sample.n50, - sample.n90, - sample.gc_percent, - sample.n_percent, - sample.duplicate_id_count, - sample.invalid_sequence_count, - sample.high_n_sequence_count, - sample.tiny_contig_count, - sample.max_gap_run, - sample.gc_outlier_count, - sample.length_outlier_count, - sample.finding_count, - sample.readiness_blockers.join(","), - sample.recommended_next_tools.join(","), - sample.input_sha256 - )?; - } - writer.flush()?; - Ok(()) -} - -fn status(status: VerdictStatus) -> &'static str { - match status { - VerdictStatus::Pass => "PASS", - VerdictStatus::Warn => "WARN", - VerdictStatus::Fail => "FAIL", - } -} - -fn readiness_status(status: crate::readiness::ReadinessStatus) -> &'static str { - match status { - crate::readiness::ReadinessStatus::Pass => "PASS", - crate::readiness::ReadinessStatus::Warn => "WARN", - crate::readiness::ReadinessStatus::Fail => "FAIL", - } -} -``` - -This writer deliberately mirrors the existing single-file TSV style and does not -add a TSV/CSV dependency. - -- [ ] **Step 6: Implement compare MultiQC writer** - -Create `src/report/compare_multiqc.rs` with standard custom content: - -```json -{ - "id": "fastaguard_compare_summary", - "section_name": "FastaGuard Compare", - "description": "FASTA preflight readiness summary across multiple inputs", - "plot_type": "table", - "pconfig": { - "id": "fastaguard_compare_summary", - "title": "FastaGuard Compare" - }, - "data": { - "sample": { - "verdict": "PASS", - "gate_status": "PASS", - "readiness_status": "PASS" - } - } -} -``` - -Write with `serde_json::to_writer_pretty`. - -- [ ] **Step 7: Implement compare HTML writer** - -Create `src/report/compare_html.rs`. - -Required strings for tests: - -```text -FastaGuard Compare Report -Readiness Matrix -Cohort Findings -Suggested Next Tools -``` - -Use inline SVG bar charts for total length, N50, GC%, N%, and sequence count. Keep helpers private: - -```rust -fn render_bar_chart(title: &str, samples: &[CompareSample], value: fn(&CompareSample) -> f64) -> String -``` - -Escape all sample IDs and paths with the same style as `html.rs`. - -- [ ] **Step 8: Wire real writers** - -Modify `src/report/mod.rs`: - -```rust -pub mod compare_html; -pub mod compare_multiqc; -pub mod compare_tsv; - -pub fn write_compare_all(report: &CompareReport, outputs: &OutputPaths) -> Result<()> { - validate_output_paths(outputs)?; - json::write_compare(report, &outputs.json)?; - compare_tsv::write(report, &outputs.tsv)?; - compare_multiqc::write(report, &outputs.multiqc)?; - compare_html::write(report, &outputs.html)?; - Ok(()) -} -``` - -Modify `src/report/json.rs`: - -```rust -pub fn write_compare(report: &crate::models::CompareReport, path: &Path) -> Result<()> { - let file = File::create(path).with_context(|| format!("failed to create {}", path.display()))?; - serde_json::to_writer_pretty(file, report) - .with_context(|| format!("failed to write {}", path.display()))?; - std::fs::OpenOptions::new().append(true).open(path)?.write_all(b"\n")?; - Ok(()) -} -``` - -- [ ] **Step 9: Run tests** - -Run: - -```bash -cargo test --locked compare::tests -cargo test --locked --test cli compare_writes_json_with_mixed_status_samples -cargo test --locked report::compare -``` - -Expected: PASS. - -- [ ] **Step 10: Commit** - -```bash -git add src/compare.rs src/models.rs src/report/mod.rs src/report/json.rs src/report/compare_tsv.rs src/report/compare_multiqc.rs src/report/compare_html.rs tests/cli.rs -git commit -m "feat: write compare reports" -``` - ---- - -## Task 8: Schema, Golden Compare Reports, And Contract Discovery - -**Files:** - -- Modify: `schema/fastaguard.schema.json` -- Modify: `tests/schema_contract.rs` -- Create: `tests/golden/compare_mixed_status.json` -- Create: `tests/golden/compare_all_pass.json` -- Test: `tests/schema_contract.rs` -- Test: `tests/cli.rs` - -- [ ] **Step 1: Write failing schema tests for compare** - -Add to `tests/schema_contract.rs`: - -```rust -#[test] -fn schema_supports_compare_reports() { - let schema: serde_json::Value = - serde_json::from_str(fastaguard::contract::schema_json()).unwrap(); - - assert!(schema["$defs"].get("compare_report").is_some()); - assert!(schema["$defs"]["compare_report"]["required"] - .as_array() - .unwrap() - .iter() - .any(|value| value == "samples")); -} -``` - -Add CLI golden test: - -```rust -#[test] -fn compare_golden_mixed_status_matches() { - let temp = tempfile::tempdir().unwrap(); - let json = temp.path().join("compare.json"); - let html = temp.path().join("compare.html"); - let tsv = temp.path().join("compare.tsv"); - let multiqc = temp.path().join("compare_mqc.json"); - - Command::cargo_bin("fastaguard") - .unwrap() - .env("FASTAGUARD_PROVENANCE_TIMESTAMP", "2026-06-02T00:00:00Z") - .args([ - "compare", - "testdata/valid_assembly.fa", - "testdata/problem_assembly.fa", - "--gate", - "pipeline", - "--json", - json.to_str().unwrap(), - "--out", - html.to_str().unwrap(), - "--tsv", - tsv.to_str().unwrap(), - "--multiqc", - multiqc.to_str().unwrap(), - ]) - .assert() - .code(2); - - let actual: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(json).unwrap()).unwrap(); - let expected: serde_json::Value = - serde_json::from_str(include_str!("golden/compare_mixed_status.json")).unwrap(); - assert_eq!(actual, expected); -} -``` - -- [ ] **Step 2: Run tests to verify failure** - -Run: - -```bash -cargo test --locked --test schema_contract schema_supports_compare_reports -- --nocapture -cargo test --locked --test cli compare_golden_mixed_status_matches -- --nocapture -``` - -Expected: FAIL because schema and golden files are missing. - -- [ ] **Step 3: Update schema** - -Modify `schema/fastaguard.schema.json` so top-level accepts either single or compare: - -```json -"oneOf": [ - { "$ref": "#/$defs/single_report" }, - { "$ref": "#/$defs/compare_report" } -] -``` - -Move current top-level report fields under `$defs.single_report`. Add `$defs.compare_report`, `$defs.compare_sample`, `$defs.compare_summary`, and `$defs.cohort_finding`. - -Keep `additionalProperties: false` for both report types. - -- [ ] **Step 4: Generate compare goldens** - -Run the compare command with deterministic provenance: - -```bash -mkdir -p target/fastaguard-golden-runtime -FASTAGUARD_PROVENANCE_TIMESTAMP=2026-06-02T00:00:00Z \ -FASTAGUARD_PROVENANCE_COMMAND='fastaguard compare testdata/valid_assembly.fa testdata/problem_assembly.fa --gate pipeline --json target/fastaguard-golden-runtime/compare_mixed_status.json --out target/fastaguard-golden-runtime/compare_mixed_status.html --tsv target/fastaguard-golden-runtime/compare_mixed_status.tsv --multiqc target/fastaguard-golden-runtime/compare_mixed_status_mqc.json' \ -cargo run --locked -- compare \ - testdata/valid_assembly.fa \ - testdata/problem_assembly.fa \ - --gate pipeline \ - --json target/fastaguard-golden-runtime/compare_mixed_status.json \ - --out target/fastaguard-golden-runtime/compare_mixed_status.html \ - --tsv target/fastaguard-golden-runtime/compare_mixed_status.tsv \ - --multiqc target/fastaguard-golden-runtime/compare_mixed_status_mqc.json || test "$?" = "2" -cp target/fastaguard-golden-runtime/compare_mixed_status.json tests/golden/compare_mixed_status.json -``` - -Generate all-pass: - -```bash -FASTAGUARD_PROVENANCE_TIMESTAMP=2026-06-02T00:00:00Z \ -cargo run --locked -- compare \ - testdata/valid_assembly.fa \ - testdata/valid_assembly.fa \ - --json target/fastaguard-golden-runtime/compare_all_pass.json \ - --out target/fastaguard-golden-runtime/compare_all_pass.html \ - --tsv target/fastaguard-golden-runtime/compare_all_pass.tsv \ - --multiqc target/fastaguard-golden-runtime/compare_all_pass_mqc.json -cp target/fastaguard-golden-runtime/compare_all_pass.json tests/golden/compare_all_pass.json -``` - -- [ ] **Step 5: Run schema/golden tests** - -Run: - -```bash -cargo test --locked --test schema_contract -cargo test --locked --test cli compare_golden -``` - -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add schema/fastaguard.schema.json tests/schema_contract.rs tests/cli.rs tests/golden/compare_mixed_status.json tests/golden/compare_all_pass.json -git commit -m "test: add compare schema and goldens" -``` - ---- - -## Task 9: Documentation, Examples, Value Benchmark, And Release Notes - -**Files:** - -- Create: `docs/preflight-readiness.md` -- Create: `docs/compare-mode.md` -- Create: `docs/value-benchmark.md` -- Create: `docs/releases/v0.4.0.md` -- Modify: `README.md` -- Modify: `docs/tool-landscape.md` -- Modify: `docs/benchmarking.md` -- Modify: `docs/output-contract.md` -- Modify: `docs/roadmap.md` -- Modify: `examples/nextflow/main.nf` -- Modify: `examples/snakemake/Snakefile` -- Modify: `examples/nf-core/README.md` -- Test: `tests/python/test_adoption_assets.py` - -- [ ] **Step 1: Write failing Python adoption tests** - -Add to `tests/python/test_adoption_assets.py`: - -```python -def test_v0_4_docs_explain_preflight_readiness_and_compare_mode(self): - readiness = ROOT / "docs" / "preflight-readiness.md" - compare = ROOT / "docs" / "compare-mode.md" - value = ROOT / "docs" / "value-benchmark.md" - - for path in (readiness, compare, value): - self.assertTrue(path.exists(), path) - - self.assertIn("before interpretive QC tools", readiness.read_text()) - self.assertIn("Index readiness", readiness.read_text()) - self.assertIn("fastaguard compare", compare.read_text()) - self.assertIn("fastaguard_compare_mqc.json", compare.read_text()) - self.assertIn("0.98 seconds", value.read_text()) - self.assertIn("50 MB", value.read_text()) - -def test_v0_4_examples_mention_compare_as_starter_pattern(self): - text = "\n".join( - [ - (ROOT / "examples" / "nf-core" / "README.md").read_text(), - (ROOT / "examples" / "snakemake" / "Snakefile").read_text(), - (ROOT / "examples" / "nextflow" / "main.nf").read_text(), - ] - ) - self.assertIn("fastaguard compare", text) - self.assertIn("starter", text.lower()) -``` - -- [ ] **Step 2: Run Python tests to verify failure** - -Run: - -```bash -python3 -m unittest tests.python.test_adoption_assets.AdoptionAssetsTest.test_v0_4_docs_explain_preflight_readiness_and_compare_mode -v -python3 -m unittest tests.python.test_adoption_assets.AdoptionAssetsTest.test_v0_4_examples_mention_compare_as_starter_pattern -v -``` - -Expected: FAIL because docs/examples are missing. - -- [ ] **Step 3: Create `docs/preflight-readiness.md`** - -Include these sections exactly: - -```markdown -# Preflight Readiness - -FastaGuard runs before interpretive QC tools. It checks whether a FASTA file is -safe enough for downstream tools to consume. - -## Readiness Categories - -- File readiness -- Structure readiness -- Alphabet readiness -- Index readiness -- Assembly readiness -- Submission readiness -- Machine readiness - -## Boundary - -FastaGuard does not prove biological completeness, assembly correctness, or -taxonomic contamination. It routes users to QUAST, BUSCO, BlobToolKit, CheckM, -samtools, BLAST, official submission validators, or annotation tools when those -questions matter. -``` - -- [ ] **Step 4: Create `docs/compare-mode.md`** - -Include: - -```markdown -# Compare Mode - -```bash -fastaguard compare assemblies/*.fa \ - --profile assembly \ - --gate pipeline \ - --out cohort_report.html \ - --json cohort.json \ - --tsv cohort.tsv \ - --multiqc fastaguard_compare_mqc.json -``` - -Compare mode is a starter cohort triage layer. It ranks many FASTA files by -preflight status, readiness status, structural metrics, composition metrics, and -cohort outliers. -``` - -- [ ] **Step 5: Create `docs/value-benchmark.md`** - -Include measured local numbers: - -```markdown -# Value Benchmark - -Measured locally with `fastaguard 0.3.0`, commit `1873216`, macOS ARM64: - -| Input | Result | Time | Memory | -| --- | --- | ---: | ---: | -| 10 Mbp synthetic FASTA, 10k records | PASS | 0.51 seconds | about 17 MB RSS | -| 100 Mbp synthetic FASTA, 100k records | WARN for GC outliers | 0.98 seconds | about 50 MB RSS | - -FastaGuard costs seconds. It can save minutes, CPU-hours, or days when it blocks -a bad FASTA before heavier QC starts. -``` - -- [ ] **Step 6: Update README/docs/examples** - -Add README quick example: - -```bash -fastaguard compare assemblies/*.fa --profile assembly --gate pipeline -``` - -Add links: - -```markdown -- [Preflight readiness](docs/preflight-readiness.md) -- [Compare mode](docs/compare-mode.md) -- [Value benchmark](docs/value-benchmark.md) -``` - -Update examples with comments that compare mode is a starter pattern, not an upstream nf-core/Snakemake submission yet. - -- [ ] **Step 7: Add v0.4 release notes draft** - -Create `docs/releases/v0.4.0.md` with: - -```markdown -# FastaGuard v0.4.0 - -Theme: Preflight Readiness + Compare Mode. - -This release adds `fastaguard compare`, readiness categories, index-readiness -checks, submission-readiness advisories, and cohort triage outputs. FastaGuard -remains FASTA preflight QC and does not replace QUAST, BUSCO, BlobToolKit, -CheckM, official submission validators, or annotation workflows. -``` - -- [ ] **Step 8: Run Python tests** - -Run: - -```bash -python3 -m unittest discover tests/python -v -``` - -Expected: PASS. - -- [ ] **Step 9: Commit** - -```bash -git add README.md docs/preflight-readiness.md docs/compare-mode.md docs/value-benchmark.md docs/releases/v0.4.0.md docs/tool-landscape.md docs/benchmarking.md docs/output-contract.md docs/roadmap.md examples/nextflow/main.nf examples/snakemake/Snakefile examples/nf-core/README.md tests/python/test_adoption_assets.py -git commit -m "docs: document v0.4 readiness and compare mode" -``` - ---- - -## Task 10: Final Integration, Version Bump, Examples, And Verification - -**Files:** - -- Modify: `Cargo.toml` -- Modify: `Cargo.lock` -- Modify: `README.md` -- Modify: `docs/releases/v0.4.0.md` -- Modify: `examples/reports/**` -- Test: all test suites - -- [ ] **Step 1: Bump package version** - -Modify `Cargo.toml`: - -```toml -version = "0.4.0" -``` - -Run: - -```bash -cargo check --locked -``` - -If `Cargo.lock` needs the package version update, run: - -```bash -cargo check -``` - -Then inspect that only the FastaGuard package version changed in `Cargo.lock`. - -- [ ] **Step 2: Regenerate example reports** - -Run: - -```bash -cargo build --release --locked -rm -rf examples/reports/assembly_pass examples/reports/assembly_fail -mkdir -p examples/reports/assembly_pass examples/reports/assembly_fail -target/release/fastaguard testdata/valid_assembly.fa \ - --min-contig-length 1 \ - --out examples/reports/assembly_pass/fastaguard_report.html \ - --json examples/reports/assembly_pass/fastaguard.json \ - --tsv examples/reports/assembly_pass/fastaguard.tsv \ - --multiqc examples/reports/assembly_pass/fastaguard_mqc.json -target/release/fastaguard testdata/problem_assembly.fa \ - --out examples/reports/assembly_fail/fastaguard_report.html \ - --json examples/reports/assembly_fail/fastaguard.json \ - --tsv examples/reports/assembly_fail/fastaguard.tsv \ - --multiqc examples/reports/assembly_fail/fastaguard_mqc.json || test "$?" = "2" -``` - -- [ ] **Step 3: Run local compare smoke** - -Run: - -```bash -mkdir -p target/v0.4-smoke -target/release/fastaguard compare \ - testdata/valid_assembly.fa \ - testdata/problem_assembly.fa \ - --gate pipeline \ - --out target/v0.4-smoke/cohort_report.html \ - --json target/v0.4-smoke/cohort.json \ - --tsv target/v0.4-smoke/cohort.tsv \ - --multiqc target/v0.4-smoke/fastaguard_compare_mqc.json || test "$?" = "2" -jq '.report_type, .summary, .samples[].readiness_status' target/v0.4-smoke/cohort.json -``` - -Expected: - -```text -"compare" -summary object with sample_count 2 -one PASS or WARN/FAIL readiness status per sample -``` - -- [ ] **Step 4: Run full verification gates** - -Run: - -```bash -python3 -m unittest discover tests/python -v -cargo fmt --check -cargo test --locked -cargo clippy --locked --all-targets --all-features -- -D warnings -git diff --check -git ls-files | xargs perl -ne 'print "$ARGV:$.:$_" if /[ \t]$/' -``` - -Expected: all commands pass with no trailing whitespace output. - -- [ ] **Step 5: Review changed files** - -Run: - -```bash -git status --short -git diff --stat -git diff -- README.md docs src schema tests examples Cargo.toml Cargo.lock | sed -n '1,260p' -``` - -Expected: only v0.4 readiness, compare, docs, schema, tests, examples, and version files changed. - -- [ ] **Step 6: Commit final release prep** - -```bash -git add Cargo.toml Cargo.lock README.md docs/releases/v0.4.0.md examples/reports -git commit -m "chore: prepare v0.4 release metadata" -``` - ---- - -## Self-Review Checklist - -- Spec coverage: - - Compare command: Tasks 5-8. - - Readiness matrix: Tasks 1 and 4. - - New preflight findings: Tasks 2 and 3. - - Gate behavior: Task 3. - - JSON/TSV/HTML/MultiQC outputs: Tasks 4, 7, and 8. - - Value benchmark docs: Task 9. - - Release criteria and verification: Task 10. - -- Product boundaries: - - No external database calls are introduced. - - Expected size is user-provided only. - - Submission readiness is advisory, not a new gate. - - Compare mode is cohort triage, not biological interpretation. - -- Implementation boundaries: - - `metrics` collects signals. - - `findings` interprets signals into stable finding IDs. - - `readiness` maps findings to tool-readiness categories. - - `compare` orchestrates many single-file analyses. - - `report/*` writes views without changing analysis behavior. - -## Execution Handoff - -Plan complete when this file is committed. Recommended execution mode: - -```text -Subagent-Driven -``` - -Use one focused implementation agent per task, review each commit, then continue. diff --git a/docs/superpowers/plans/2026-06-11-fastaguard-v0.5-submission-readiness.md b/docs/superpowers/plans/2026-06-11-fastaguard-v0.5-submission-readiness.md deleted file mode 100644 index 1bf1bd1..0000000 --- a/docs/superpowers/plans/2026-06-11-fastaguard-v0.5-submission-readiness.md +++ /dev/null @@ -1,1562 +0,0 @@ -# FastaGuard v0.5 Submission Readiness Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build `--gate submission` and `--submission-target generic|ncbi` so FastaGuard can report FASTA-level submission readiness without claiming to replace official validators. - -**Architecture:** Reuse the existing v0.4 analyzer and finding IDs wherever possible. Add one small `submission` module for target parsing and target-specific fail sets, then thread submission target metadata through CLI config, gate decisions, readiness, reports, schema, compare mode, and docs. - -**Tech Stack:** Rust 2021, clap, serde/serde_json, assert_cmd integration tests, jsonschema contract tests, existing static HTML/TSV/MultiQC writers. - ---- - -## File Structure - -Create: - -- `src/submission.rs`: `SubmissionTarget`, display helpers, and submission gate constants. -- `testdata/submission_ids.fa`: FASTA with unsafe IDs, reserved header characters, and duplicate first-token IDs. -- `testdata/submission_warnings.fa`: FASTA with long headers and gap-like N runs. -- `docs/evidence/fastaguard-v0.5-submission-readiness.md`: tiny evidence examples and command transcript. -- `docs/releases/v0.5.0.md`: release notes drafted before tagging. - -Modify: - -- `src/lib.rs`: export `submission`. -- `src/cli.rs`: parse `--submission-target`, carry it into run and compare configs. -- `src/gate.rs`: add `GateMode::Submission` and target-aware fail rules. -- `src/readiness.rs`: add optional target metadata to readiness categories and map existing findings to submission readiness. -- `src/models.rs`: add submission target fields to gate/provenance/compare summaries and bump schema version. -- `src/findings.rs`: tune text/actions for existing submission-relevant findings; avoid renaming v0.4 IDs. -- `src/contract.rs` and `schema/finding-catalog.json`: keep bundled catalog and runtime actions aligned. -- `schema/fastaguard.schema.json`: update schema version and new fields. -- `src/report/html.rs`, `src/report/tsv.rs`, `src/report/multiqc.rs`: add single-report submission output. -- `src/compare.rs`, `src/report/compare_html.rs`, `src/report/compare_tsv.rs`, `src/report/compare_multiqc.rs`: aggregate and render submission status. -- `tests/cli.rs`, `tests/schema_contract.rs`: add CLI, golden, report, and schema coverage. -- `tests/golden/*.json`, `examples/reports/**`: regenerate committed reports after schema changes. -- `README.md`, `docs/roadmap.md`, `docs/vision-plan.md`, `docs/tool-landscape.md`, `docs/output-contract.md`, `docs/packaging.md`, `examples/nf-core/README.md`, `examples/snakemake/wrapper/README.md`: document v0.5 behavior and boundaries. - -Important design choice: - -- Keep current finding IDs: `unsafe_ids`, `long_headers`, `reserved_header_chars`, `duplicate_first_token_ids`, `terminal_ns`, `gap_pattern_warnings`, `gap_runs`, `high_n_rate`, and `tiny_contigs`. -- Do not rename them to `unsafe_identifier_chars` or `submission_gap_like_ns` in v0.5. The v0.5 behavior is to promote existing evidence into a stricter submission gate and clearer submission-readiness fields. - -## Task 1: CLI Plumbing For Submission Target - -**Files:** -- Create: `src/submission.rs` -- Modify: `src/lib.rs` -- Modify: `src/cli.rs` -- Test: `tests/cli.rs` - -- [ ] **Step 1: Add failing CLI tests** - -Append these tests to `tests/cli.rs`: - -```rust -#[test] -fn submission_gate_defaults_to_generic_target() { - let temp_dir = TempDir::new().unwrap(); - let outputs = output_paths(&temp_dir, "submission_default"); - - let mut cmd = Command::cargo_bin("fastaguard").unwrap(); - cmd.args([ - "testdata/valid_assembly.fa", - "--min-contig-length", - "1", - "--gate", - "submission", - "--json", - ]) - .arg(&outputs.json) - .arg("--out") - .arg(&outputs.html) - .arg("--tsv") - .arg(&outputs.tsv) - .arg("--multiqc") - .arg(&outputs.multiqc) - .assert() - .code(1) - .stderr(predicate::str::is_empty()); - - let report = read_json(&outputs.json); - assert_eq!(report["gate"]["mode"], json!("submission")); - assert_eq!(report["gate"]["submission_target"], json!("generic")); - assert_eq!(report["provenance"]["submission_target"], json!("generic")); -} - -#[test] -fn submission_target_ncbi_is_serialized_when_requested() { - let temp_dir = TempDir::new().unwrap(); - let outputs = output_paths(&temp_dir, "submission_ncbi"); - - let mut cmd = Command::cargo_bin("fastaguard").unwrap(); - cmd.args([ - "testdata/valid_assembly.fa", - "--min-contig-length", - "1", - "--gate", - "submission", - "--submission-target", - "ncbi", - "--json", - ]) - .arg(&outputs.json) - .arg("--out") - .arg(&outputs.html) - .arg("--tsv") - .arg(&outputs.tsv) - .arg("--multiqc") - .arg(&outputs.multiqc) - .assert() - .code(1) - .stderr(predicate::str::is_empty()); - - let report = read_json(&outputs.json); - assert_eq!(report["gate"]["submission_target"], json!("ncbi")); - assert_eq!(report["provenance"]["submission_target"], json!("ncbi")); -} - -#[test] -fn unknown_submission_target_is_cli_error() { - let mut cmd = Command::cargo_bin("fastaguard").unwrap(); - cmd.args([ - "testdata/valid_assembly.fa", - "--gate", - "submission", - "--submission-target", - "ena", - ]) - .assert() - .code(2) - .stderr(predicate::str::contains("invalid value 'ena'")); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: - -```bash -cargo test --locked --test cli submission_ -``` - -Expected: the first two tests fail because `--gate submission`, `--submission-target`, and serialized fields do not exist. The unknown-target test may fail with a different clap error until the flag exists. - -- [ ] **Step 3: Create `src/submission.rs`** - -Create: - -```rust -use clap::ValueEnum; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -#[value(rename_all = "snake_case")] -pub enum SubmissionTarget { - Generic, - Ncbi, -} - -impl SubmissionTarget { - pub fn as_str(self) -> &'static str { - match self { - SubmissionTarget::Generic => "generic", - SubmissionTarget::Ncbi => "ncbi", - } - } -} -``` - -- [ ] **Step 4: Export the module** - -Modify `src/lib.rs` and add: - -```rust -pub mod submission; -``` - -- [ ] **Step 5: Add CLI fields and config plumbing** - -In `src/cli.rs`, import: - -```rust -use crate::submission::SubmissionTarget; -``` - -Add to `AnalysisArgs`: - -```rust - /// Submission-readiness target used by --gate submission. - #[arg(long, value_enum)] - pub submission_target: Option, -``` - -Add to `RunConfig`, `CompareConfig`, and `ValidatedAnalysis`: - -```rust - pub submission_target: Option, -``` - -In the analysis validation function that constructs `ValidatedAnalysis`, set: - -```rust -let submission_target = match (analysis.gate, analysis.submission_target) { - (GateMode::Submission, None) => Some(SubmissionTarget::Generic), - (_, target) => target, -}; -``` - -Carry `submission_target` into both `RunConfig` and `CompareConfig`. - -- [ ] **Step 6: Run focused tests** - -Run: - -```bash -cargo test --locked --test cli submission_ -``` - -Expected: tests still fail until gate/model serialization exists, but clap should now accept `--submission-target generic|ncbi` and reject `ena`. - -- [ ] **Step 7: Commit** - -```bash -git add src/submission.rs src/lib.rs src/cli.rs tests/cli.rs -git commit -m "feat: add submission target CLI" -``` - -## Task 2: Submission Gate Semantics - -**Files:** -- Modify: `src/gate.rs` -- Modify: `src/models.rs` -- Modify: `src/compare.rs` -- Test: `tests/cli.rs` - -- [ ] **Step 1: Add failing tests for blocking behavior** - -Add fixtures: - -`testdata/submission_ids.fa` - -```text ->seq/one -ACGTACGT ->seq two -ACGTACGT ->seq two duplicate-description -ACGTACGA ->pipe|id -ACGTACGT -``` - -Add to `tests/cli.rs`: - -```rust -#[test] -fn submission_gate_fails_identifier_hazards() { - let temp_dir = TempDir::new().unwrap(); - let outputs = output_paths(&temp_dir, "submission_ids"); - - let mut cmd = Command::cargo_bin("fastaguard").unwrap(); - cmd.args([ - "testdata/submission_ids.fa", - "--gate", - "submission", - "--submission-target", - "ncbi", - "--json", - ]) - .arg(&outputs.json) - .arg("--out") - .arg(&outputs.html) - .arg("--tsv") - .arg(&outputs.tsv) - .arg("--multiqc") - .arg(&outputs.multiqc) - .assert() - .code(2) - .stderr(predicate::str::contains("fastaguard error:").not()); - - let report = read_json(&outputs.json); - assert_eq!(report["gate"]["mode"], json!("submission")); - assert_eq!(report["gate"]["status"], json!("FAIL")); - assert!(array_contains_string( - &report["gate"]["blocking_findings"], - "duplicate_first_token_ids" - )); - assert!(array_contains_string( - &report["gate"]["blocking_findings"], - "unsafe_ids" - )); - assert!(array_contains_string( - &report["gate"]["blocking_findings"], - "reserved_header_chars" - )); -} -``` - -- [ ] **Step 2: Run failing test** - -Run: - -```bash -cargo test --locked --test cli submission_gate_fails_identifier_hazards -``` - -Expected: FAIL because `GateMode::Submission` and the submission fail set do not exist. - -- [ ] **Step 3: Add `Submission` gate mode and fail set** - -Modify `src/gate.rs`: - -```rust -use crate::submission::SubmissionTarget; - -pub const SUBMISSION_FAIL_ON_GENERIC: &[&str] = &[ - "duplicate_first_token_ids", - "duplicate_ids", - "invalid_chars", - "invalid_fasta_structure", - "unsafe_ids", -]; - -pub const SUBMISSION_FAIL_ON_NCBI: &[&str] = &[ - "duplicate_first_token_ids", - "duplicate_ids", - "invalid_chars", - "invalid_fasta_structure", - "reserved_header_chars", - "unsafe_ids", -]; -``` - -Extend `GateMode`: - -```rust -pub enum GateMode { - None, - Pipeline, - Submission, -} -``` - -Extend `as_str`: - -```rust -GateMode::Submission => "submission", -``` - -Change `final_fail_on` signature: - -```rust -pub fn final_fail_on( - mode: GateMode, - submission_target: Option, - explicit_rules: &[String], -) -> BTreeSet -``` - -Inside it: - -```rust -match mode { - GateMode::Pipeline => { - fail_on.extend(PIPELINE_FAIL_ON.iter().map(|id| (*id).to_string())); - } - GateMode::Submission => { - let rules = match submission_target.unwrap_or(SubmissionTarget::Generic) { - SubmissionTarget::Generic => SUBMISSION_FAIL_ON_GENERIC, - SubmissionTarget::Ncbi => SUBMISSION_FAIL_ON_NCBI, - }; - fail_on.extend(rules.iter().map(|id| (*id).to_string())); - } - GateMode::None => {} -} -``` - -- [ ] **Step 4: Add target to gate decision** - -Modify `GateDecision` in `src/models.rs`: - -```rust - #[serde(skip_serializing_if = "Option::is_none")] - pub submission_target: Option, -``` - -Change `gate::decision` signature: - -```rust -pub fn decision( - mode: GateMode, - submission_target: Option, - status: VerdictStatus, - findings: &[Finding], - fail_on: &BTreeSet, -) -> GateDecision -``` - -Set: - -```rust -submission_target: submission_target.map(|target| target.as_str().to_string()), -``` - -Update all call sites in `src/models.rs` to pass `config.submission_target`. - -- [ ] **Step 5: Pass target through compare sample runs** - -In `src/compare.rs`, add: - -```rust -submission_target: config.submission_target, -``` - -to the `RunConfig` built inside `run_one_sample`. - -- [ ] **Step 6: Run focused gate tests** - -Run: - -```bash -cargo test --locked --test cli submission_ -``` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add src/gate.rs src/models.rs src/compare.rs tests/cli.rs testdata/submission_ids.fa -git commit -m "feat: add submission gate semantics" -``` - -## Task 3: Submission Readiness Metadata - -**Files:** -- Modify: `src/readiness.rs` -- Modify: `src/models.rs` -- Test: `src/readiness.rs` -- Test: `tests/cli.rs` - -- [ ] **Step 1: Add failing readiness tests** - -Add to `src/readiness.rs` tests: - -```rust -#[test] -fn submission_target_is_attached_to_submission_category() { - let readiness = build_readiness( - VerdictStatus::Fail, - &["reserved_header_chars".to_string()], - &[finding("reserved_header_chars", Severity::Minor)], - ReadinessScope::Single, - Some(crate::submission::SubmissionTarget::Ncbi), - ); - - let submission = readiness.category("submission").unwrap(); - assert_eq!(submission.target.as_deref(), Some("ncbi")); - assert_eq!(submission.status, ReadinessStatus::Fail); - assert_eq!(submission.findings, ["reserved_header_chars"]); -} - -#[test] -fn submission_findings_warn_when_not_blocking() { - let readiness = build_readiness( - VerdictStatus::Warn, - &[], - &[finding("long_headers", Severity::Minor)], - ReadinessScope::Single, - Some(crate::submission::SubmissionTarget::Generic), - ); - - let submission = readiness.category("submission").unwrap(); - assert_eq!(submission.target.as_deref(), Some("generic")); - assert_eq!(submission.status, ReadinessStatus::Warn); - assert!(readiness.overall.blockers.is_empty()); -} -``` - -- [ ] **Step 2: Run failing readiness tests** - -Run: - -```bash -cargo test --locked readiness::tests::submission_target_is_attached_to_submission_category readiness::tests::submission_findings_warn_when_not_blocking -``` - -Expected: FAIL because `ReadinessCategory.target` and the new function signature do not exist. - -- [ ] **Step 3: Extend readiness category** - -Modify `ReadinessCategory`: - -```rust -#[serde(skip_serializing_if = "Option::is_none")] -pub target: Option, -``` - -In `base_categories`, set `target: None`. - -Change `build_readiness` signature: - -```rust -pub fn build_readiness( - verdict: VerdictStatus, - blocking_findings: &[String], - findings: &[Finding], - scope: ReadinessScope, - submission_target: Option, -) -> ReadinessReport -``` - -After `base_categories(scope)`, attach the target: - -```rust -if let Some(target) = submission_target { - if let Some(category) = categories - .iter_mut() - .find(|category| category.id == "submission") - { - category.target = Some(target.as_str().to_string()); - } -} -``` - -Update all call sites. Use `None` in tests that do not care about target. - -- [ ] **Step 4: Promote existing findings into submission readiness** - -Update `category_ids_for_finding`: - -```rust -"duplicate_ids" | "duplicate_first_token_ids" => &["index", "submission"], -"unsafe_ids" | "long_headers" | "reserved_header_chars" => &["index", "submission"], -"terminal_ns" | "gap_pattern_warnings" | "gap_runs" => &["assembly", "submission"], -"high_n_rate" | "tiny_contigs" => &["assembly", "submission"], -``` - -This makes the submission category show every identifier issue that can block -the submission gate, while preserving index readiness for parser/index users. - -- [ ] **Step 5: Update model call sites** - -In `FastaguardReport::from_analysis` and `FastaguardReport::from_invalid_fasta`, pass `config.submission_target` to `build_readiness`. - -In tests that construct readiness manually, pass `None` unless target behavior is under test. - -- [ ] **Step 6: Run readiness tests** - -Run: - -```bash -cargo test --locked readiness -``` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add src/readiness.rs src/models.rs -git commit -m "feat: add submission readiness metadata" -``` - -## Task 4: Provenance, Scope, And Machine Routing - -**Files:** -- Modify: `src/models.rs` -- Modify: `src/findings.rs` -- Modify: `schema/finding-catalog.json` -- Test: `src/contract.rs` -- Test: `tests/cli.rs` - -- [ ] **Step 1: Add failing routing/scope assertions** - -In `tests/cli.rs`, add to `submission_target_ncbi_is_serialized_when_requested`: - -```rust -assert!(array_contains_string( - &report["scope"]["can_conclude"], - "FASTA-level submission readiness" -)); -assert!(array_contains_string( - &report["scope"]["cannot_conclude"], - "repository acceptance" -)); -``` - -Add a separate test: - -```rust -#[test] -fn submission_hazards_route_to_official_validators_and_fcs_without_claiming_results() { - let temp_dir = TempDir::new().unwrap(); - let outputs = output_paths(&temp_dir, "submission_routes"); - - let mut cmd = Command::cargo_bin("fastaguard").unwrap(); - cmd.args([ - "testdata/submission_ids.fa", - "--gate", - "submission", - "--submission-target", - "ncbi", - "--json", - ]) - .arg(&outputs.json) - .arg("--out") - .arg(&outputs.html) - .arg("--tsv") - .arg(&outputs.tsv) - .arg("--multiqc") - .arg(&outputs.multiqc) - .assert() - .code(2); - - let report = read_json(&outputs.json); - assert_routing_hint( - &report, - "submission_readiness_failure", - "fix_fasta_before_official_validation", - false, - ); - assert!(array_contains_tool( - &report["machine_summary"]["recommended_next_tools"], - "official submission validator" - )); -} -``` - -- [ ] **Step 2: Run failing test** - -Run: - -```bash -cargo test --locked --test cli submission_hazards_route_to_official_validators_and_fcs_without_claiming_results -``` - -Expected: FAIL because routing and scope text have not been extended. - -- [ ] **Step 3: Extend scope** - -In `fasta_preflight_scope()` in `src/models.rs`, include: - -```rust -"FASTA-level submission readiness".to_string(), -``` - -in `can_conclude`, and include: - -```rust -"repository acceptance".to_string(), -"official validator acceptance".to_string(), -"annotation correctness".to_string(), -``` - -in `cannot_conclude`. - -- [ ] **Step 4: Extend recommended tools** - -In `recommended_next_tools`, route submission-relevant findings: - -```rust -if has_any_finding( - findings, - &[ - "unsafe_ids", - "long_headers", - "reserved_header_chars", - "duplicate_first_token_ids", - "terminal_ns", - "gap_pattern_warnings", - ], -) { - tools.push(recommended_tool( - "official submission validator", - "Use the target repository validator after FASTA-level issues are fixed; FastaGuard is not an official validator.", - )); -} - -if has_any_finding(findings, &["high_n_rate", "gap_runs"]) { - tools.push(recommended_tool( - "NCBI FCS", - "Run database-backed contamination/adaptor screening when submission-oriented ambiguity or gap signals need follow-up.", - )); -} -``` - -Add these helper functions near the existing recommendation helpers: - -```rust -fn has_any_finding(findings: &[Finding], ids: &[&str]) -> bool { - findings - .iter() - .any(|finding| ids.iter().any(|id| *id == finding.id)) -} - -fn recommended_tool(tool: &str, reason: &str) -> RecommendedTool { - RecommendedTool { - tool: tool.to_string(), - reason: reason.to_string(), - } -} -``` - -- [ ] **Step 5: Extend routing hints** - -In `routing_hints`, add: - -```rust -"unsafe_ids" | "long_headers" | "reserved_header_chars" | "duplicate_first_token_ids" => { - push_routing_hint( - &mut hints, - "submission_readiness_failure", - "fix_fasta_before_official_validation", - false, - ) -} -``` - -Keep existing `index_readiness_failure` if present by using two calls for `duplicate_first_token_ids`. - -- [ ] **Step 6: Update catalog text and runtime action alignment** - -In `schema/finding-catalog.json`, keep IDs unchanged and add wording that these findings affect submission readiness. Do not add suggested actions that are missing from `finding_actions`; `src/contract.rs::bundled_catalog_actions_match_runtime_actions` must remain green. - -- [ ] **Step 7: Run tests** - -Run: - -```bash -cargo test --locked --test cli submission_hazards_route_to_official_validators_and_fcs_without_claiming_results -cargo test --locked contract -``` - -Expected: PASS. - -- [ ] **Step 8: Commit** - -```bash -git add src/models.rs src/findings.rs schema/finding-catalog.json tests/cli.rs -git commit -m "feat: route submission readiness findings" -``` - -## Task 5: Single-Report Output Fields - -**Files:** -- Modify: `src/report/tsv.rs` -- Modify: `src/report/multiqc.rs` -- Modify: `src/report/html.rs` -- Test: `src/report/tsv.rs` -- Test: `src/report/multiqc.rs` -- Test: `tests/cli.rs` - -- [ ] **Step 1: Add failing output assertions** - -In `tests/cli.rs`, add: - -```rust -#[test] -fn submission_gate_outputs_tsv_multiqc_and_html_fields() { - let temp_dir = TempDir::new().unwrap(); - let outputs = output_paths(&temp_dir, "submission_outputs"); - - let mut cmd = Command::cargo_bin("fastaguard").unwrap(); - cmd.args([ - "testdata/submission_ids.fa", - "--gate", - "submission", - "--submission-target", - "ncbi", - "--json", - ]) - .arg(&outputs.json) - .arg("--out") - .arg(&outputs.html) - .arg("--tsv") - .arg(&outputs.tsv) - .arg("--multiqc") - .arg(&outputs.multiqc) - .assert() - .code(2); - - let tsv = std::fs::read_to_string(&outputs.tsv).unwrap(); - assert!(tsv.contains("submission_target\tncbi\n"), "{tsv}"); - assert!(tsv.contains("submission_status\tFAIL\n"), "{tsv}"); - assert!(tsv.contains("unsafe_identifier_count\t"), "{tsv}"); - - let multiqc = read_json(&outputs.multiqc); - assert_eq!(multiqc["data"]["submission_ids"]["submission_target"], json!("ncbi")); - assert_eq!(multiqc["data"]["submission_ids"]["submission_status"], json!("FAIL")); - - let html = std::fs::read_to_string(&outputs.html).unwrap(); - assert!(html.contains("Submission Readiness"), "{html}"); - assert!(html.contains("Official validators are still required"), "{html}"); -} -``` - -- [ ] **Step 2: Run failing output test** - -Run: - -```bash -cargo test --locked --test cli submission_gate_outputs_tsv_multiqc_and_html_fields -``` - -Expected: FAIL because the output fields and HTML section are missing. - -- [ ] **Step 3: Add TSV metrics** - -In `src/report/tsv.rs`, after readiness rows, write: - -```rust -write_metric( - &mut writer, - "submission_target", - report - .gate - .submission_target - .as_deref() - .unwrap_or("."), -)?; -write_metric( - &mut writer, - "submission_status", - submission_status(report), -)?; -write_metric( - &mut writer, - "submission_blocking_findings", - report.gate.blocking_findings.join(","), -)?; -write_metric( - &mut writer, - "submission_advisory_findings", - report.gate.advisory_findings.join(","), -)?; -write_metric( - &mut writer, - "unsafe_identifier_count", - report.summary.unsafe_id_count, -)?; -write_metric( - &mut writer, - "long_identifier_count", - report.summary.long_header_count, -)?; -write_metric( - &mut writer, - "duplicate_first_token_id_count", - report.summary.duplicate_first_token_id_count, -)?; -write_metric( - &mut writer, - "gap_like_n_run_count", - report.summary.repeated_gap_pattern_sequence_count, -)?; -``` - -Add: - -```rust -fn submission_status(report: &FastaguardReport) -> &'static str { - report - .readiness - .category("submission") - .map(|category| readiness_status(category.status)) - .unwrap_or("PASS") -} -``` - -- [ ] **Step 4: Add MultiQC fields** - -Add to `MultiqcSummaryRow`: - -```rust -submission_target: String, -submission_status: String, -unsafe_identifier_count: u64, -long_identifier_count: u64, -duplicate_first_token_id_count: u64, -gap_like_n_run_count: u64, -``` - -Populate in `summary_row` using the same fields as TSV. - -Add headers: - -```rust -("submission_target", "Submission Target"), -("submission_status", "Submission Status"), -("unsafe_identifier_count", "Unsafe IDs"), -("long_identifier_count", "Long Headers"), -("duplicate_first_token_id_count", "Duplicate First-Token IDs"), -("gap_like_n_run_count", "Gap-Like N Runs"), -``` - -- [ ] **Step 5: Add HTML section** - -In `src/report/html.rs`, add `let submission = render_submission_readiness(report);` and place this after the Gate Decision section: - -```html -

Submission Readiness

-{submission} -``` - -Add: - -```rust -fn render_submission_readiness(report: &FastaguardReport) -> String { - let target = report - .gate - .submission_target - .as_deref() - .unwrap_or("generic"); - let category = report.readiness.category("submission"); - let status = category - .map(|category| readiness_status(category.status)) - .unwrap_or("PASS"); - let findings = category - .map(|category| render_string_list_or_none(&category.findings)) - .unwrap_or_else(|| "None".to_string()); - - format!( - r#"
-
-

Target

-

{target}

-
-
-

Status

-

{status}

-
-
-

Findings

-{findings} -
-
-

Official validators are still required. FastaGuard reports FASTA-level preflight risks only.

"#, - target = escape_html(target), - status = escape_html(status), - findings = findings, - ) -} -``` - -- [ ] **Step 6: Run focused output tests** - -Run: - -```bash -cargo test --locked --test cli submission_gate_outputs_tsv_multiqc_and_html_fields -cargo test --locked report::tsv report::multiqc -``` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add src/report/tsv.rs src/report/multiqc.rs src/report/html.rs tests/cli.rs -git commit -m "feat: render submission readiness outputs" -``` - -## Task 6: Compare Mode Submission Aggregation - -**Files:** -- Modify: `src/models.rs` -- Modify: `src/compare.rs` -- Modify: `src/report/compare_tsv.rs` -- Modify: `src/report/compare_multiqc.rs` -- Modify: `src/report/compare_html.rs` -- Test: `tests/cli.rs` -- Test: `src/report/compare_tsv.rs` -- Test: `src/report/compare_multiqc.rs` - -- [ ] **Step 1: Add failing compare test** - -Add to `tests/cli.rs`: - -```rust -#[test] -fn compare_submission_gate_aggregates_submission_status() { - let temp_dir = TempDir::new().unwrap(); - let clean = temp_dir.path().join("clean.fa"); - std::fs::write(&clean, ">clean\nACGTACGT\n").unwrap(); - let outputs = output_paths(&temp_dir, "submission_compare"); - - let mut cmd = Command::cargo_bin("fastaguard").unwrap(); - cmd.arg("compare") - .arg(&clean) - .arg("testdata/submission_ids.fa") - .args([ - "--gate", - "submission", - "--submission-target", - "ncbi", - "--json", - ]) - .arg(&outputs.json) - .arg("--out") - .arg(&outputs.html) - .arg("--tsv") - .arg(&outputs.tsv) - .arg("--multiqc") - .arg(&outputs.multiqc) - .assert() - .code(2); - - let report = read_json(&outputs.json); - assert_eq!(report["summary"]["submission_fail_count"], json!(1)); - assert_eq!(report["summary"]["submission_ready_count"], json!(1)); - let failing = report["samples"] - .as_array() - .unwrap() - .iter() - .find(|sample| sample["sample_id"] == "submission_ids") - .unwrap(); - assert_eq!(failing["submission_target"], json!("ncbi")); - assert_eq!(failing["submission_status"], json!("FAIL")); - - let tsv = std::fs::read_to_string(&outputs.tsv).unwrap(); - assert!(tsv.lines().next().unwrap().contains("submission_status"), "{tsv}"); - - let multiqc = read_json(&outputs.multiqc); - assert_eq!( - multiqc["data"]["submission_ids"]["submission_status"], - json!("FAIL") - ); -} -``` - -- [ ] **Step 2: Run failing compare test** - -Run: - -```bash -cargo test --locked --test cli compare_submission_gate_aggregates_submission_status -``` - -Expected: FAIL because compare summary/sample fields do not exist. - -- [ ] **Step 3: Extend compare models** - -In `CompareSummary`, add: - -```rust -pub submission_ready_count: u64, -pub submission_warn_count: u64, -pub submission_fail_count: u64, -``` - -In `CompareSample`, add: - -```rust -pub submission_target: Option, -pub submission_status: crate::readiness::ReadinessStatus, -``` - -- [ ] **Step 4: Populate compare fields** - -In `compare_sample`: - -```rust -let submission_status = report - .readiness - .category("submission") - .map(|category| category.status) - .unwrap_or(crate::readiness::ReadinessStatus::Pass); -``` - -Set: - -```rust -submission_target: report.gate.submission_target.clone(), -submission_status, -``` - -In `compare_summary`, count submission statuses: - -```rust -submission_ready_count: count_readiness_status(samples, crate::readiness::ReadinessStatus::Pass), -submission_warn_count: count_readiness_status(samples, crate::readiness::ReadinessStatus::Warn), -submission_fail_count: count_readiness_status(samples, crate::readiness::ReadinessStatus::Fail), -``` - -Add: - -```rust -fn count_readiness_status( - samples: &[CompareSample], - status: crate::readiness::ReadinessStatus, -) -> u64 { - usize_to_u64( - samples - .iter() - .filter(|sample| sample.submission_status == status) - .count(), - ) -} -``` - -- [ ] **Step 5: Extend compare TSV and MultiQC** - -In `src/report/compare_tsv.rs`, add `submission_target` and `submission_status` columns after `readiness_status`. - -In `src/report/compare_multiqc.rs`, add the same fields and headers: - -```rust -submission_target: String, -submission_status: &'static str, -``` - -- [ ] **Step 6: Extend compare HTML** - -In `src/report/compare_html.rs`, add submission status to the summary cards or table near readiness. Use existing HTML escaping helpers and the same uppercase status labels. - -- [ ] **Step 7: Run compare tests** - -Run: - -```bash -cargo test --locked --test cli compare_submission_gate_aggregates_submission_status -cargo test --locked compare -cargo test --locked report::compare_tsv report::compare_multiqc -``` - -Expected: PASS. - -- [ ] **Step 8: Commit** - -```bash -git add src/models.rs src/compare.rs src/report/compare_tsv.rs src/report/compare_multiqc.rs src/report/compare_html.rs tests/cli.rs -git commit -m "feat: aggregate submission readiness in compare mode" -``` - -## Task 7: Schema Version, Goldens, And Contract Fixtures - -**Files:** -- Modify: `Cargo.toml` -- Modify: `Cargo.lock` -- Modify: `src/models.rs` -- Modify: `schema/fastaguard.schema.json` -- Modify: `schema/finding-catalog.json` -- Modify: `tests/schema_contract.rs` -- Modify: `tests/golden/*.json` -- Modify: `examples/reports/**` -- Test: `tests/schema_contract.rs` -- Test: `tests/cli.rs` - -- [ ] **Step 1: Add failing schema assertions** - -In `tests/schema_contract.rs`, update schema version assertions: - -```rust -assert_eq!( - single_report["properties"]["schema_version"]["const"], - "0.5.0" -); -``` - -Add: - -```rust -#[test] -fn schema_supports_submission_gate_fields() { - let schema: serde_json::Value = - serde_json::from_str(fastaguard::contract::schema_json()).unwrap(); - let gate = &schema["$defs"]["single_report"]["properties"]["gate"]; - let provenance = &schema["$defs"]["single_report"]["properties"]["provenance"]; - let compare_summary = &schema["$defs"]["compare_summary"]; - let compare_sample = &schema["$defs"]["compare_sample"]; - - assert!(gate["required"] - .as_array() - .unwrap() - .iter() - .any(|value| value == "submission_target")); - assert_eq!( - gate["properties"]["mode"]["enum"], - serde_json::json!(["none", "pipeline", "submission"]) - ); - assert_eq!( - gate["properties"]["submission_target"]["enum"], - serde_json::json!(["generic", "ncbi"]) - ); - assert!(provenance["required"] - .as_array() - .unwrap() - .iter() - .any(|value| value == "submission_target")); - assert!(compare_summary["required"] - .as_array() - .unwrap() - .iter() - .any(|value| value == "submission_fail_count")); - assert!(compare_sample["required"] - .as_array() - .unwrap() - .iter() - .any(|value| value == "submission_status")); -} -``` - -- [ ] **Step 2: Run failing schema tests** - -Run: - -```bash -cargo test --locked --test schema_contract schema_supports_submission_gate_fields -``` - -Expected: FAIL because schema is still v0.4.0 and fields are missing. - -- [ ] **Step 3: Bump crate and schema versions** - -In `Cargo.toml`: - -```toml -version = "0.5.0" -``` - -In `src/models.rs`: - -```rust -pub const SCHEMA_VERSION: &str = "0.5.0"; -``` - -Run: - -```bash -cargo check --locked -``` - -If `Cargo.lock` still records `fastaguard 0.4.0`, run: - -```bash -cargo check -``` - -and verify the lockfile updates only the local package version. - -- [ ] **Step 4: Update schema** - -Update `schema/fastaguard.schema.json`: - -- `schema_version.const` from `0.4.0` to `0.5.0` for single and compare reports. -- `gate.mode.enum` to include `"submission"`. -- `gate.required` to include `"submission_target"`. -- `gate.properties.submission_target`: - -```json -{ - "type": "string", - "enum": ["generic", "ncbi"] -} -``` - -- `provenance.required` to include `"submission_target"`. -- `provenance.properties.submission_target` with the same enum. -- `readiness_category.properties.target` with the same enum. -- `compare_summary.required` and properties for `submission_ready_count`, `submission_warn_count`, `submission_fail_count`. -- `compare_sample.required` and properties for `submission_target` and `submission_status`. - -- [ ] **Step 5: Update finding catalog version** - -In `schema/finding-catalog.json`: - -```json -"schema_version": "0.5.0", -"catalog_version": "0.5.0" -``` - -Ensure every catalog `suggested_actions` still equals `finding_actions(id)`. - -- [ ] **Step 6: Regenerate goldens** - -Run the existing golden tests once to produce current temp outputs if helpers write to `target`; if they do not overwrite goldens, generate with the same commands used in `tests/cli.rs` and copy the JSON into: - -```text -tests/golden/valid_assembly.json -tests/golden/problem_assembly.json -tests/golden/invalid_empty_record.json -tests/golden/compare_mixed_status.json -tests/golden/compare_all_pass.json -examples/reports/assembly_pass/fastaguard.json -examples/reports/assembly_fail/fastaguard.json -examples/reports/assembly_pass/fastaguard.tsv -examples/reports/assembly_fail/fastaguard.tsv -examples/reports/assembly_pass/fastaguard_mqc.json -examples/reports/assembly_fail/fastaguard_mqc.json -examples/reports/assembly_pass/fastaguard_report.html -examples/reports/assembly_fail/fastaguard_report.html -``` - -Use deterministic provenance environment variables already present in `tests/cli.rs` when regenerating golden JSON. - -- [ ] **Step 7: Run contract tests** - -Run: - -```bash -cargo test --locked --test schema_contract -cargo test --locked --test cli golden -cargo test --locked contract -``` - -Expected: PASS. - -- [ ] **Step 8: Commit** - -```bash -git add Cargo.toml Cargo.lock src/models.rs schema/fastaguard.schema.json schema/finding-catalog.json tests/schema_contract.rs tests/golden examples/reports -git commit -m "chore: update v0.5 output contract" -``` - -## Task 8: Documentation, Evidence, And Release Notes - -**Files:** -- Create: `docs/evidence/fastaguard-v0.5-submission-readiness.md` -- Create: `docs/releases/v0.5.0.md` -- Modify: `README.md` -- Modify: `docs/roadmap.md` -- Modify: `docs/vision-plan.md` -- Modify: `docs/tool-landscape.md` -- Modify: `docs/output-contract.md` -- Modify: `docs/packaging.md` -- Modify: `examples/nf-core/README.md` -- Modify: `examples/snakemake/wrapper/README.md` -- Test: `tests/python/test_adoption_assets.py` -- Test: `tests/python/test_release_metadata.py` - -- [ ] **Step 1: Add failing Python docs tests** - -In `tests/python/test_adoption_assets.py`, add: - -```python - def test_v0_5_submission_readiness_docs_are_present(self): - readme = self.read("README.md") - roadmap = self.read("docs/roadmap.md") - evidence = self.read("docs/evidence/fastaguard-v0.5-submission-readiness.md") - release = self.read("docs/releases/v0.5.0.md") - - for text in [readme, roadmap, evidence, release]: - self.assertIn("--gate submission", text) - self.assertIn("--submission-target", text) - self.assertIn("official validators", text) - - self.assertIn("FastaGuard does not replace NCBI, ENA, DDBJ", roadmap) - self.assertIn("repository acceptance", evidence) -``` - -In `tests/python/test_release_metadata.py`, update `test_package_targets_v0_4_0` to `test_package_targets_v0_5_0` and assert: - -```python -self.assertIn('version = "0.5.0"', cargo_toml) -``` - -- [ ] **Step 2: Run failing docs tests** - -Run: - -```bash -python3 -m unittest tests.python.test_adoption_assets tests.python.test_release_metadata -v -``` - -Expected: FAIL because v0.5 docs and release notes are missing or stale. - -- [ ] **Step 3: Update README** - -Add a quickstart block: - -```markdown -Submission-readiness preflight: - -```bash -fastaguard sample.fa \ - --profile assembly \ - --gate submission \ - --submission-target ncbi \ - --json fastaguard.json \ - --out fastaguard_report.html -``` - -FastaGuard reports FASTA-level risks before official validators. It does not -guarantee NCBI, ENA, or DDBJ acceptance and does not replace NCBI FCS, -annotation validation, QUAST, BUSCO, BlobToolKit, or CheckM. -``` - -- [ ] **Step 4: Add evidence page** - -Create `docs/evidence/fastaguard-v0.5-submission-readiness.md`: - -```markdown -# FastaGuard v0.5 Submission Readiness Evidence - -This page records tiny local evidence cases for the v0.5 submission-readiness -gate. The goal is to show FASTA-level hazards before official validators and -expensive QC. - -## Commands - -```bash -fastaguard testdata/submission_ids.fa \ - --gate submission \ - --submission-target ncbi \ - --json target/evidence/v0.5/submission_ids.json - -fastaguard testdata/submission_warnings.fa \ - --gate submission \ - --submission-target generic \ - --json target/evidence/v0.5/submission_warnings.json -``` - -## Scope - -FastaGuard can report parse validity, identifier safety, duplicate first-token -IDs, invalid sequence symbols, gap-like N runs, high ambiguity, and tiny-record -advisories. It cannot guarantee repository acceptance, biological completeness, -annotation correctness, or contamination status. - -## Expected Follow-Up - -After FASTA-level blockers are fixed, users should continue to official -validators, NCBI FCS, QUAST, BUSCO, BlobToolKit, CheckM, annotation, or other -the next workflow step named in the report. -``` - -- [ ] **Step 5: Add release notes** - -Create `docs/releases/v0.5.0.md` with: - -```markdown -# FastaGuard v0.5.0 - -FastaGuard v0.5.0 is the Submission Readiness Gate release. - -## Highlights - -- Adds `--gate submission`. -- Adds `--submission-target generic|ncbi`. -- Adds submission-readiness fields to JSON, TSV, HTML, MultiQC, and compare outputs. -- Promotes existing identifier, header, gap, ambiguity, and tiny-record findings into a clearer submission-readiness view. - -## Boundary - -FastaGuard is a FASTA-level preflight tool. It does not replace NCBI, ENA, DDBJ, -NCBI FCS, QUAST, BUSCO, BlobToolKit, CheckM, annotation validation, or official -repository acceptance checks. -``` - -- [ ] **Step 6: Update workflow docs** - -In nf-core and Snakemake docs, add the command pattern: - -```bash -fastaguard {input.fasta} --gate submission --submission-target ncbi -``` - -State that pipeline authors should route on: - -```text -gate.mode -gate.status -gate.blocking_findings -readiness.categories[id=submission] -``` - -- [ ] **Step 7: Run docs tests** - -Run: - -```bash -python3 -m unittest discover tests/python -v -``` - -Expected: PASS. - -- [ ] **Step 8: Commit** - -```bash -git add README.md docs/roadmap.md docs/vision-plan.md docs/tool-landscape.md docs/output-contract.md docs/packaging.md docs/evidence/fastaguard-v0.5-submission-readiness.md docs/releases/v0.5.0.md examples/nf-core/README.md examples/snakemake/wrapper/README.md tests/python -git commit -m "docs: document v0.5 submission readiness" -``` - -## Task 9: Full Verification And Release Preparation - -**Files:** -- Modify only files needed for failures found by verification. - -- [ ] **Step 1: Run full Rust and Python gates** - -Run: - -```bash -python3 -m unittest discover tests/python -v -cargo fmt --check -cargo test --locked -cargo clippy --locked --all-targets --all-features -- -D warnings -git diff --check -git ls-files | xargs perl -ne 'print "$ARGV:$.:$_" if /[ \t]$/' -``` - -Expected: all commands exit 0 and trailing whitespace scan prints nothing. - -- [ ] **Step 2: Run smoke commands** - -Run: - -```bash -cargo run --locked -- --schema >/tmp/fastaguard-v0.5-schema.json -cargo run --locked -- testdata/submission_ids.fa --gate submission --submission-target ncbi --json /tmp/submission.json --out /tmp/submission.html --tsv /tmp/submission.tsv --multiqc /tmp/submission_mqc.json -cargo run --locked -- compare testdata/valid_assembly.fa testdata/submission_ids.fa --gate submission --submission-target ncbi --json /tmp/submission_compare.json --out /tmp/submission_compare.html --tsv /tmp/submission_compare.tsv --multiqc /tmp/submission_compare_mqc.json -``` - -Expected: - -- `--schema` exits 0. -- single submission run exits 2 because `testdata/submission_ids.fa` contains blocking identifier hazards. -- compare submission run exits 2 because one sample fails. - -- [ ] **Step 3: Inspect final diff** - -Run: - -```bash -git status --short -git diff --stat origin/main..HEAD -git log --oneline --decorate --max-count=12 -``` - -Expected: the branch contains the v0.5 submission-readiness commits and no unrelated file changes. - -- [ ] **Step 4: Commit verification fixes if needed** - -If verification required edits: - -```bash -git status --short -git add src tests schema docs examples Cargo.toml Cargo.lock testdata -git commit -m "fix: stabilize v0.5 submission readiness" -``` - -If verification required no edits, do not create an empty commit. - -## Self-Review Checklist - -- Spec coverage: tasks cover CLI, gate behavior, readiness, JSON/provenance/scope, HTML, TSV, MultiQC, compare mode, schema, goldens, docs, evidence, and verification. -- Scope boundary: the plan stays assembly-first and database-free; it routes to official validators and NCBI FCS without claiming to run or replace them. -- Finding IDs: the plan preserves existing v0.4 IDs for identifier/header/gap findings to avoid breaking report consumers. -- Type consistency: `SubmissionTarget` flows from CLI config into gate/provenance/readiness/compare reports as `Option` or serialized strings. -- Test strategy: every behavior change starts with a failing test, then implementation, then focused verification and commit. - -## Execution Choice - -Plan complete and saved to `docs/superpowers/plans/2026-06-11-fastaguard-v0.5-submission-readiness.md`. Two execution options: - -1. **Subagent-Driven (recommended)** - dispatch a fresh subagent per task, review between tasks, fast iteration. -2. **Inline Execution** - execute tasks in this session using executing-plans, batch execution with checkpoints. - -Recommended choice: **Subagent-Driven**, because the feature touches independent surfaces: CLI/gate, readiness/schema, report writers, compare mode, and docs. diff --git a/docs/superpowers/plans/2026-07-03-upstream-workflow-prep.md b/docs/superpowers/plans/2026-07-03-upstream-workflow-prep.md deleted file mode 100644 index 522fdd4..0000000 --- a/docs/superpowers/plans/2026-07-03-upstream-workflow-prep.md +++ /dev/null @@ -1,800 +0,0 @@ -# Upstream Workflow Prep Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Prepare FastaGuard's local nf-core and Snakemake workflow starters in the safest order before any external upstream submission. - -**Architecture:** Keep FastaGuard's stable CLI and JSON contract unchanged. Add upstream-shaped local metadata, fixture layouts, test harness files, and evidence-preserving gate examples around the existing v0.5.0 Bioconda/BioContainers package. Treat external nf-core and Snakemake PRs as a final gate after local assets and repository tests are green. - -**Tech Stack:** Rust CLI, Python unittest, Nextflow/nf-test starter assets, Snakemake wrapper starter assets, Bioconda/BioContainers v0.5.0 package metadata. - ---- - -## Safe Order - -1. Create a plan and guard tests in the FastaGuard repository. -2. Harden the nf-core starter because it has the highest workflow adoption value and strictest module shape. -3. Add an evidence-preserving collect-then-gate helper for workflow engines. -4. Harden the Snakemake wrapper starter with official-wrapper-style metadata and tests. -5. Update readiness docs with what is ready locally and what remains external. -6. Run local verification and publish a normal project PR. -7. Only after that, decide whether to open external upstream PRs. - -## File Map - -- Modify: `tests/python/test_adoption_assets.py` - - Add repository-level assertions for nf-core starter tests, Snakemake starter tests, collect-then-gate behavior, and safe-order documentation. -- Modify: `examples/nf-core/modules/local/fastaguard/main.nf` - - Add `topic: versions` to the versions output. -- Modify: `examples/nf-core/modules/local/fastaguard/meta.yml` - - Document versions as a topic output and keep HTML/JSON/TSV/MultiQC outputs clear. -- Create: `examples/nf-core/modules/local/fastaguard/tests/main.nf.test` - - Provide nf-test starter cases for pass, warn, fail, and invalid FASTA fixtures. -- Create: `examples/nf-core/modules/local/fastaguard/tests/data/pass.fa` -- Create: `examples/nf-core/modules/local/fastaguard/tests/data/warn.fa` -- Create: `examples/nf-core/modules/local/fastaguard/tests/data/fail.fa` -- Create: `examples/nf-core/modules/local/fastaguard/tests/data/invalid.fa` -- Create: `examples/workflows/check_fastaguard_gate.py` - - Read FastaGuard JSON and return 0 for PASS, 1 for WARN, 2 for FAIL, 3 for malformed tool input. -- Create: `tests/python/test_workflow_gate_helper.py` - - Verify the gate helper exits correctly from small JSON fixtures. -- Create: `examples/snakemake/wrapper/environment.linux-64.pin.txt` - - Local starter pin file for upstream wrapper preparation. -- Create: `examples/snakemake/wrapper/test/Snakefile` - - Official-wrapper-style local test Snakefile. -- Create: `examples/snakemake/wrapper/test/test_wrappers.py` - - Copy-pasteable upstream test wiring snippet. -- Create: `examples/snakemake/wrapper/test/data/pass.fa` -- Create: `examples/snakemake/wrapper/test/data/warn.fa` -- Create: `examples/snakemake/wrapper/test/data/fail.fa` -- Create: `examples/snakemake/wrapper/test/data/invalid.fa` -- Modify: `examples/nf-core/README.md` - - Add safe execution order and local/external validation boundary. -- Modify: `examples/snakemake/wrapper/README.md` - - Add safe execution order and local/external validation boundary. -- Modify: `docs/workflow-readiness.md` - - Mark local starter hardening items and leave upstream PRs as a final external step. -- Modify: `docs/adoption-plan.md` - - Keep the safe order aligned with the readiness document. - -### Task 1: Add Failing Adoption Tests For Safe-Order Assets - -**Files:** -- Modify: `tests/python/test_adoption_assets.py` - -- [ ] **Step 1: Write the failing tests** - -Append these methods inside `AdoptionAssetsTest` before `test_benchmarking_docs_include_v0_2_evidence_topics`: - -```python - def test_nf_core_starter_has_upstream_prep_test_layout(self): - module = ROOT / "examples" / "nf-core" / "modules" / "local" / "fastaguard" - main_nf = (module / "main.nf").read_text() - meta_yml = (module / "meta.yml").read_text() - nf_test = (module / "tests" / "main.nf.test").read_text() - - self.assertIn('path "versions.yml", emit: versions, topic: versions', main_nf) - self.assertIn("versions:", meta_yml) - self.assertIn("topic", meta_yml) - self.assertIn("process \"FASTAGUARD\"", nf_test) - self.assertIn("pass.fa", nf_test) - self.assertIn("warn.fa", nf_test) - self.assertIn("fail.fa", nf_test) - self.assertIn("invalid.fa", nf_test) - self.assertIn("fastaguard_mqc.json", nf_test) - for name in ("pass.fa", "warn.fa", "fail.fa", "invalid.fa"): - self.assertTrue((module / "tests" / "data" / name).exists(), name) - - def test_snakemake_wrapper_has_upstream_prep_test_layout(self): - wrapper = ROOT / "examples" / "snakemake" / "wrapper" - readme = (wrapper / "README.md").read_text() - test_snakefile = (wrapper / "test" / "Snakefile").read_text() - test_py = (wrapper / "test" / "test_wrappers.py").read_text() - pin = (wrapper / "environment.linux-64.pin.txt").read_text() - - self.assertIn("safe local order", readme) - self.assertIn("fastaguard=0.5.0", pin) - self.assertIn("rule fastaguard_pass", test_snakefile) - self.assertIn("rule fastaguard_warn", test_snakefile) - self.assertIn("rule fastaguard_fail", test_snakefile) - self.assertIn("rule fastaguard_invalid", test_snakefile) - self.assertIn("pytest", test_py) - self.assertIn("snakemake", test_py) - for name in ("pass.fa", "warn.fa", "fail.fa", "invalid.fa"): - self.assertTrue((wrapper / "test" / "data" / name).exists(), name) - - def test_workflow_readiness_safe_order_is_explicit(self): - readiness = (ROOT / "docs" / "workflow-readiness.md").read_text() - adoption = (ROOT / "docs" / "adoption-plan.md").read_text() - - self.assertIn("Safe Order", readiness) - self.assertIn("local repository tests first", readiness) - self.assertIn("external upstream PRs last", readiness) - self.assertIn("check_fastaguard_gate.py", readiness) - self.assertIn("nf-core module PR ready", adoption) - self.assertIn("Snakemake wrapper PR ready", adoption) -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: - -```bash -python3 -m unittest tests.python.test_adoption_assets.AdoptionAssetsTest.test_nf_core_starter_has_upstream_prep_test_layout tests.python.test_adoption_assets.AdoptionAssetsTest.test_snakemake_wrapper_has_upstream_prep_test_layout tests.python.test_adoption_assets.AdoptionAssetsTest.test_workflow_readiness_safe_order_is_explicit -``` - -Expected: FAIL because the new test layouts and safe-order text are missing. - -### Task 2: Harden nf-core Starter Layout - -**Files:** -- Modify: `examples/nf-core/modules/local/fastaguard/main.nf` -- Modify: `examples/nf-core/modules/local/fastaguard/meta.yml` -- Create: `examples/nf-core/modules/local/fastaguard/tests/main.nf.test` -- Create data fixtures under `examples/nf-core/modules/local/fastaguard/tests/data/` - -- [ ] **Step 1: Add topic metadata to the versions output** - -Change this output line: - -```nextflow -path "versions.yml", emit: versions -``` - -to: - -```nextflow -path "versions.yml", emit: versions, topic: versions -``` - -- [ ] **Step 2: Add versions topic metadata to `meta.yml`** - -Add this block below the existing `output:` entries and before `authors:`: - -```yaml -topics: - versions: - - - process: - type: string - description: The process the versions were collected from. - - tool: - type: string - description: The tool name. - - version: - type: string - description: The version reported by the tool. -``` - -- [ ] **Step 3: Add nf-test starter file** - -Create `examples/nf-core/modules/local/fastaguard/tests/main.nf.test`: - -```groovy -nextflow_process { - - name "Test FASTAGUARD" - script "../main.nf" - process "FASTAGUARD" - - tag "fastaguard" - tag "fastaguard_single" - - test("pass FASTA emits all reports") { - input[0] = [ - [id: "pass"], - file("data/pass.fa") - ] - - when { - process { - """ - input[0] = Channel.of(input[0]) - """ - } - } - - then { - assert process.success - assert process.out.html[0][1].name.endsWith(".fastaguard.html") - assert process.out.json[0][1].name.endsWith(".fastaguard.json") - assert process.out.tsv[0][1].name.endsWith(".fastaguard.tsv") - assert process.out.mqc[0][1].name.endsWith(".fastaguard_mqc.json") - assert process.out.versions - } - } - - test("warn FASTA preserves reports") { - input[0] = [ - [id: "warn"], - file("data/warn.fa") - ] - - when { - process { - """ - input[0] = Channel.of(input[0]) - """ - } - } - - then { - assert process.out.json - assert process.out.mqc - } - } - - test("fail FASTA preserves reports for gate review") { - input[0] = [ - [id: "fail"], - file("data/fail.fa") - ] - - when { - process { - """ - input[0] = Channel.of(input[0]) - """ - } - } - - then { - assert process.out.json - assert process.out.mqc - } - } - - test("invalid FASTA is represented in the evidence path") { - input[0] = [ - [id: "invalid"], - file("data/invalid.fa") - ] - - when { - process { - """ - input[0] = Channel.of(input[0]) - """ - } - } - - then { - assert process.out.json - assert process.out.mqc - } - } -} -``` - -- [ ] **Step 4: Add nf-core test fixtures** - -Create these files: - -`examples/nf-core/modules/local/fastaguard/tests/data/pass.fa` - -```fasta ->contig1 -ACGTACGTACGTACGT ->contig2 -GCGCGCATATAT -``` - -`examples/nf-core/modules/local/fastaguard/tests/data/warn.fa` - -```fasta ->tiny1 -ACGT ->tiny2 -NNNNNNNN -``` - -`examples/nf-core/modules/local/fastaguard/tests/data/fail.fa` - -```fasta ->dup -ACGTACGT ->dup -ACGTACGT ->bad -ACGTXYZ -``` - -`examples/nf-core/modules/local/fastaguard/tests/data/invalid.fa` - -```fasta ->empty_record ->next_record -ACGT -``` - -- [ ] **Step 5: Run targeted adoption tests** - -Run: - -```bash -python3 -m unittest tests.python.test_adoption_assets.AdoptionAssetsTest.test_nf_core_starter_has_upstream_prep_test_layout -``` - -Expected: PASS. - -### Task 3: Add Evidence-Preserving Gate Helper - -**Files:** -- Create: `examples/workflows/check_fastaguard_gate.py` -- Create: `tests/python/test_workflow_gate_helper.py` - -- [ ] **Step 1: Write failing tests** - -Create `tests/python/test_workflow_gate_helper.py`: - -```python -import json -import subprocess -import sys -import unittest -from pathlib import Path -from tempfile import TemporaryDirectory - - -ROOT = Path(__file__).resolve().parents[2] -HELPER = ROOT / "examples" / "workflows" / "check_fastaguard_gate.py" - - -class WorkflowGateHelperTest(unittest.TestCase): - def write_report(self, directory, status): - path = Path(directory) / f"{status.lower()}.json" - path.write_text(json.dumps({"gate": {"status": status}})) - return path - - def run_helper(self, path): - return subprocess.run( - [sys.executable, str(HELPER), str(path)], - cwd=ROOT, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - def test_pass_exits_zero(self): - with TemporaryDirectory() as temp_dir: - result = self.run_helper(self.write_report(temp_dir, "PASS")) - self.assertEqual(result.returncode, 0) - self.assertIn("PASS", result.stdout) - - def test_warn_exits_one(self): - with TemporaryDirectory() as temp_dir: - result = self.run_helper(self.write_report(temp_dir, "WARN")) - self.assertEqual(result.returncode, 1) - self.assertIn("WARN", result.stdout) - - def test_fail_exits_two(self): - with TemporaryDirectory() as temp_dir: - result = self.run_helper(self.write_report(temp_dir, "FAIL")) - self.assertEqual(result.returncode, 2) - self.assertIn("FAIL", result.stdout) - - def test_malformed_report_exits_three(self): - with TemporaryDirectory() as temp_dir: - path = Path(temp_dir) / "bad.json" - path.write_text("{}") - result = self.run_helper(path) - self.assertEqual(result.returncode, 3) - self.assertIn("gate.status", result.stderr) - - -if __name__ == "__main__": - unittest.main() -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: - -```bash -python3 -m unittest tests.python.test_workflow_gate_helper -``` - -Expected: FAIL because the helper does not exist. - -- [ ] **Step 3: Add helper implementation** - -Create `examples/workflows/check_fastaguard_gate.py`: - -```python -#!/usr/bin/env python3 -import json -import sys -from pathlib import Path - - -EXIT_BY_STATUS = { - "PASS": 0, - "WARN": 1, - "FAIL": 2, -} - - -def main(argv): - if len(argv) != 2: - print("usage: check_fastaguard_gate.py fastaguard.json", file=sys.stderr) - return 3 - - report_path = Path(argv[1]) - try: - report = json.loads(report_path.read_text()) - except OSError as exc: - print(f"could not read {report_path}: {exc}", file=sys.stderr) - return 3 - except json.JSONDecodeError as exc: - print(f"could not parse {report_path}: {exc}", file=sys.stderr) - return 3 - - status = report.get("gate", {}).get("status") - if status not in EXIT_BY_STATUS: - print("missing or unsupported gate.status", file=sys.stderr) - return 3 - - print(f"FastaGuard gate status: {status}") - return EXIT_BY_STATUS[status] - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv)) -``` - -- [ ] **Step 4: Run helper tests** - -Run: - -```bash -python3 -m unittest tests.python.test_workflow_gate_helper -``` - -Expected: PASS. - -### Task 4: Harden Snakemake Wrapper Starter - -**Files:** -- Create: `examples/snakemake/wrapper/environment.linux-64.pin.txt` -- Create: `examples/snakemake/wrapper/test/Snakefile` -- Create: `examples/snakemake/wrapper/test/test_wrappers.py` -- Create data fixtures under `examples/snakemake/wrapper/test/data/` -- Modify: `examples/snakemake/wrapper/README.md` - -- [ ] **Step 1: Add starter pin file** - -Create `examples/snakemake/wrapper/environment.linux-64.pin.txt`: - -```yaml -channels: - - conda-forge - - bioconda -dependencies: - - fastaguard=0.5.0 -``` - -- [ ] **Step 2: Add official-wrapper-style test Snakefile** - -Create `examples/snakemake/wrapper/test/Snakefile`: - -```python -rule fastaguard_pass: - input: - fasta="data/pass.fa" - output: - html="pass/fastaguard_report.html", - json="pass/fastaguard.json", - tsv="pass/fastaguard.tsv", - multiqc="pass/fastaguard_mqc.json" - params: - profile="assembly", - gate="pipeline", - extra="" - wrapper: - "master/bio/fastaguard" - - -rule fastaguard_warn: - input: - fasta="data/warn.fa" - output: - html="warn/fastaguard_report.html", - json="warn/fastaguard.json", - tsv="warn/fastaguard.tsv", - multiqc="warn/fastaguard_mqc.json" - params: - profile="assembly", - gate="none", - extra="" - wrapper: - "master/bio/fastaguard" - - -rule fastaguard_fail: - input: - fasta="data/fail.fa" - output: - html="fail/fastaguard_report.html", - json="fail/fastaguard.json", - tsv="fail/fastaguard.tsv", - multiqc="fail/fastaguard_mqc.json" - params: - profile="assembly", - gate="none", - extra="" - wrapper: - "master/bio/fastaguard" - - -rule fastaguard_invalid: - input: - fasta="data/invalid.fa" - output: - html="invalid/fastaguard_report.html", - json="invalid/fastaguard.json", - tsv="invalid/fastaguard.tsv", - multiqc="invalid/fastaguard_mqc.json" - params: - profile="assembly", - gate="none", - extra="" - wrapper: - "master/bio/fastaguard" -``` - -- [ ] **Step 3: Add copy-pasteable test runner snippet** - -Create `examples/snakemake/wrapper/test/test_wrappers.py`: - -```python -import subprocess -from pathlib import Path - - -def test_fastaguard_wrapper(): - snakefile = Path(__file__).with_name("Snakefile") - subprocess.run( - ["snakemake", "-s", str(snakefile), "--cores", "1", "--use-conda"], - check=True, - ) -``` - -- [ ] **Step 4: Add Snakemake test fixtures** - -Create these files: - -`examples/snakemake/wrapper/test/data/pass.fa` - -```fasta ->contig1 -ACGTACGTACGTACGT ->contig2 -GCGCGCATATAT -``` - -`examples/snakemake/wrapper/test/data/warn.fa` - -```fasta ->tiny1 -ACGT ->tiny2 -NNNNNNNN -``` - -`examples/snakemake/wrapper/test/data/fail.fa` - -```fasta ->dup -ACGTACGT ->dup -ACGTACGT ->bad -ACGTXYZ -``` - -`examples/snakemake/wrapper/test/data/invalid.fa` - -```fasta ->empty_record ->next_record -ACGT -``` - -- [ ] **Step 5: Update README safe local order** - -Add this section to `examples/snakemake/wrapper/README.md` before `The wrapper emits:`: - -```markdown -Safe local order before upstream submission: - -1. Run repository Python tests that inspect this wrapper layout. -2. Install Snakemake in a workflow test environment. -3. Run `snakemake -s test/Snakefile --cores 1 --use-conda`. -4. Generate a real upstream `environment.linux-64.pin.txt` if the upstream - wrapper repository requires a solver-produced pin file. -5. Adapt `test/test_wrappers.py` into the upstream wrapper repository test - harness. -``` - -- [ ] **Step 6: Run targeted adoption tests** - -Run: - -```bash -python3 -m unittest tests.python.test_adoption_assets.AdoptionAssetsTest.test_snakemake_wrapper_has_upstream_prep_test_layout -``` - -Expected: PASS. - -### Task 5: Update Readiness Docs In Safe Order - -**Files:** -- Modify: `docs/workflow-readiness.md` -- Modify: `docs/adoption-plan.md` -- Modify: `examples/nf-core/README.md` - -- [ ] **Step 1: Update `docs/workflow-readiness.md`** - -Add a `## Safe Order` section after `## Current State`: - -```markdown -## Safe Order - -1. Run local repository tests first. -2. Harden the nf-core starter with topic-aware versions, fixtures, and nf-test - starter coverage. -3. Add collect-then-gate examples with `examples/workflows/check_fastaguard_gate.py`. -4. Harden the Snakemake wrapper starter with metadata, fixture tests, and a - starter pin file. -5. Run external `nf-core modules lint`, `nf-core modules test`, and Snakemake - wrapper tests in dedicated upstream checkouts. -6. Open external upstream PRs last. -``` - -In the `nf-core Readiness` checklist, mark local assets as present and keep external commands as remaining validation gates. - -In the `Snakemake Readiness` checklist, mark local metadata and tests as present and keep the upstream generated pin as an external validation gate if needed. - -- [ ] **Step 2: Update `examples/nf-core/README.md`** - -Add this section after the opening checklist link: - -```markdown -Safe local order before upstream submission: - -1. Run repository Python tests that inspect this module layout. -2. Install nf-core/tools, Nextflow, and nf-test in a workflow test environment. -3. Run `nf-core modules lint fastaguard`. -4. Run `nf-core modules test fastaguard`. -5. Port the local starter into an upstream nf-core/modules checkout only after - those checks pass. -``` - -- [ ] **Step 3: Run safe-order adoption test** - -Run: - -```bash -python3 -m unittest tests.python.test_adoption_assets.AdoptionAssetsTest.test_workflow_readiness_safe_order_is_explicit -``` - -Expected: PASS. - -### Task 6: Final Verification And PR - -**Files:** -- All modified files in this plan. - -- [ ] **Step 1: Run all repository Python tests** - -Run: - -```bash -python3 -m unittest discover -s tests/python -``` - -Expected: all tests pass. - -- [ ] **Step 2: Run Rust tests** - -Run: - -```bash -cargo test --locked -``` - -Expected: all Rust, CLI, schema, and doctests pass. - -- [ ] **Step 3: Run whitespace and public naming checks** - -Run: - -```bash -git diff --check -python3 - <<'PY' -from pathlib import Path - -terms = ["co" + "dex", "Co" + "dex", "AI " + "agent", "ai " + "agent"] -paths = [ - Path("README.md"), - Path("docs/adoption-plan.md"), - Path("docs/workflow-readiness.md"), - Path("examples"), - Path("tests/python"), -] -matches = [] -for root in paths: - files = [root] if root.is_file() else [p for p in root.rglob("*") if p.is_file()] - for path in files: - text = path.read_text(errors="ignore") - for line_no, line in enumerate(text.splitlines(), start=1): - if any(term in line for term in terms): - matches.append(f"{path}:{line_no}:{line}") -if matches: - print("\n".join(matches)) - raise SystemExit(1) -PY -``` - -Expected: `git diff --check` exits 0. The naming scan returns no matches. - -- [ ] **Step 4: Commit and publish** - -Run: - -```bash -git status --short -git add tests/python/test_adoption_assets.py tests/python/test_workflow_gate_helper.py docs/workflow-readiness.md docs/adoption-plan.md examples/nf-core/README.md examples/nf-core/modules/local/fastaguard/main.nf examples/nf-core/modules/local/fastaguard/meta.yml examples/nf-core/modules/local/fastaguard/tests examples/workflows/check_fastaguard_gate.py examples/snakemake/wrapper/README.md examples/snakemake/wrapper/environment.linux-64.pin.txt examples/snakemake/wrapper/test docs/superpowers/plans/2026-07-03-upstream-workflow-prep.md -git commit -m "docs: prepare upstream workflow starters" -git push -u origin feature/upstream-workflow-prep -``` - -Expected: branch pushes successfully. - -- [ ] **Step 5: Open PR** - -Open a normal PR against `main` titled: - -```text -Prepare upstream workflow starters -``` - -PR body should include: - -```markdown -## Summary - -- harden local nf-core starter metadata and test layout for upstream preparation -- add a workflow gate helper for collect-then-gate examples -- harden local Snakemake wrapper starter metadata and test layout -- document the safe order before external upstream PRs - -## Validation - -- `python3 -m unittest discover -s tests/python` -- `cargo test --locked` -- `git diff --check` -- public naming scan -``` - -Expected: PR opens cleanly and remote checks pass before merge. - -## Self-Review - -Spec coverage: - -- nf-core starter hardening: Task 2. -- evidence-preserving workflow pattern: Task 3. -- Snakemake wrapper hardening: Task 4. -- safe order and external PR gate: Task 5. -- verification and publish: Task 6. - -Placeholder scan: - -- No `TBD`, `TODO`, or `implement later` placeholders are present. -- External upstream PRs are intentionally not performed in this plan; they are gated behind local verification and dedicated upstream checkouts. - -Type and path consistency: - -- `check_fastaguard_gate.py` is referenced consistently in docs and tests. -- nf-core fixture names match across tests and nf-test starter. -- Snakemake fixture names match across tests and wrapper test Snakefile. diff --git a/docs/superpowers/specs/2026-05-18-fastaguard-first-release-design.md b/docs/superpowers/specs/2026-05-18-fastaguard-first-release-design.md deleted file mode 100644 index 323059e..0000000 --- a/docs/superpowers/specs/2026-05-18-fastaguard-first-release-design.md +++ /dev/null @@ -1,443 +0,0 @@ -# FastaGuard First-Release Design - -Date: 2026-05-18 - -Status: approved for written spec, pending implementation plan - -## Executive Summary - -FastaGuard should become the default FASTA preflight and triage layer before heavier downstream tools run. - -It should not compete with FastQC, QUAST, BUSCO, BlobToolKit, or MultiQC. Instead, it should own the earlier question: - -```text -Is this assembly FASTA valid, sane, interpretable, and ready for downstream tools? -``` - -The v0.1 release should be assembly-first, database-free, streaming-first, and report-first. - -## Product Positioning - -Core thesis: - -```text -FastaGuard is a fast, explainable FASTA QC tool that validates assembly FASTA files, detects structural and composition red flags, and produces pipeline-ready reports before expensive downstream analysis. -``` - -Strategic wording: - -```text -Before QUAST. Before BUSCO. Before BlobToolKit. Before annotation. -Run FastaGuard first. -``` - -FastaGuard should explicitly avoid claims about biological completeness, assembly correctness, contamination classification, taxonomic assignment, or annotation quality. Those remain the domain of downstream tools. - -The v0.1 promise is: - -```text -FastaGuard catches FASTA-level assembly problems before expensive assembly QC. -``` - -## Target Users - -- genome assembly teams -- microbial genomics pipelines -- nf-core, Nextflow, and Snakemake pipeline authors -- bioinformatics core facilities -- reference database maintainers -- transcriptome and protein FASTA users in later releases - -## v0.1 Scope - -The first release supports the `assembly` profile only. - -Supported inputs: - -- plain FASTA -- gzipped FASTA - -Core checks: - -- malformed headers -- empty records -- duplicate IDs -- duplicate sequences -- invalid nucleotide/IUPAC symbols -- mixed or suspicious non-nucleotide content -- bad line endings and hidden characters where detectable -- sequence count -- total length -- min, max, mean, and median length -- N50, N90, L50, and L90 -- GC percent -- AT percent -- N percent -- ambiguity rate -- suspicious tiny contigs -- high-N scaffolds -- long gap runs - -Planned beyond v0.1: - -- per-sequence composition outliers -- length outliers -- GC-vs-length anomaly data - -Outputs: - -- HTML report -- JSON report -- TSV summary -- MultiQC-compatible JSON - -## Non-Goals - -v0.1 does not include: - -- BUSCO-style completeness -- QUAST-style reference or assembly correctness evaluation -- BlobToolKit-style taxonomy or contamination analysis -- external databases -- k-mer or minimizer sketches -- transcriptome-specific heuristics -- protein-specific checks -- cohort compare mode -- browser-based contig filtering -- AI-generated summaries - -## CLI Design - -Primary command: - -```bash -fastaguard sample.fa \ - --profile assembly \ - --out fastaguard_report.html \ - --json fastaguard.json \ - --tsv fastaguard.tsv \ - --multiqc fastaguard_multiqc.json -``` - -Default command: - -```bash -fastaguard sample.fa -``` - -Default inferred behavior: - -```text -profile = assembly -HTML report = fastaguard_report.html -JSON report = fastaguard.json -TSV summary = fastaguard.tsv -MultiQC JSON = fastaguard_multiqc.json -``` - -Pipeline controls: - -```bash -fastaguard sample.fa \ - --fail-on duplicate_ids,invalid_chars,high_n_rate,gap_runs \ - --max-n-rate 0.05 \ - --min-contig-length 200 \ - --threads 8 -``` - -Exit codes: - -```text -0 = pass -1 = warnings above configured threshold -2 = hard QC failure -3 = invalid input / tool error -``` - -## Output Contract - -FastaGuard should produce a stable FASTA QC contract: - -```text -fastaguard.json -fastaguard.tsv -fastaguard_report.html -fastaguard_multiqc.json -``` - -The JSON output should be versioned from the beginning. Pipeline authors should be able to depend on stable fields such as: - -- `schema_version` -- `tool.version` -- `input.profile` -- `verdict.status` -- `verdict.reasons` -- `summary.sequence_count` -- `summary.total_length` -- `summary.n50` -- `summary.n90` -- `summary.l50` -- `summary.l90` -- `summary.gc_percent` -- `summary.n_percent` -- `findings[].id` -- `findings[].severity` - -Example JSON shape: - -```json -{ - "schema_version": "0.1.0", - "tool": { - "name": "FastaGuard", - "version": "0.1.0" - }, - "input": { - "path": "sample.fa", - "profile": "assembly", - "compressed": false - }, - "verdict": { - "status": "WARN", - "reasons": ["high_n_rate", "duplicate_ids"] - }, - "summary": { - "sequence_count": 481, - "total_length": 5042301, - "n50": 128003, - "n90": 24013, - "l50": 12, - "l90": 81, - "gc_percent": 51.8, - "n_percent": 3.4 - }, - "findings": [ - { - "id": "high_n_rate", - "severity": "major", - "profile": "assembly", - "affected_count": 62, - "affected_fraction": 0.128, - "message": "12.8% of sequences contain more than 20% Ns.", - "why_it_matters": "High ambiguity can reduce annotation and mapping quality.", - "suggested_next_step": "Inspect high-N scaffolds or run gap closing/polishing." - } - ], - "artifacts": { - "html": "fastaguard_report.html", - "tsv": "fastaguard.tsv", - "multiqc": "fastaguard_multiqc.json" - } -} -``` - -## Rust Architecture - -Recommended stack: - -- Rust core engine -- Rust CLI with `clap` -- streaming parser with `needletail` or `noodles-fasta` -- `serde` and `serde_json` for JSON output -- `rayon` for parallel post-processing where useful -- HTML report generated from templates -- embedded Plotly or Observable-style charts -- optional WASM report viewer later - -Suggested crate shape: - -```text -fastaguard/ - Cargo.toml - src/ - main.rs - lib.rs - cli.rs - parser.rs - profile.rs - metrics.rs - validators.rs - findings.rs - report/ - mod.rs - json.rs - tsv.rs - html.rs - multiqc.rs - stats/ - mod.rs - nxx.rs - composition.rs - outliers.rs -``` - -Core data flow: - -```text -CLI args - -> ProfileConfig - -> Streaming FASTA parser - -> Per-record validation + metrics - -> Aggregated assembly summary - -> Findings engine - -> Verdict engine - -> JSON / TSV / HTML / MultiQC outputs - -> Exit code -``` - -The engine should stream records and keep compact per-sequence summaries rather than full sequences: - -- ID -- length -- stable hash or fingerprint for duplicate detection -- GC count -- AT count -- N count -- ambiguity count -- invalid character count -- longest gap run -- flags - -Duplicate sequence detection should use a strong stable hash in v0.1. Collision confirmation can be added later if needed. - -## Findings and Verdicts - -Finding model: - -```text -id -severity -profile -affected_count -affected_fraction -message -why_it_matters -suggested_next_step -evidence -``` - -Severity levels: - -```text -info -minor -major -critical -``` - -Verdict levels: - -```text -PASS -WARN -FAIL -``` - -Default FAIL conditions: - -- invalid FASTA structure -- empty input -- duplicate IDs -- invalid nucleotide symbols - -Default WARN conditions: - -- high N content -- many high-N scaffolds -- excessive tiny contigs -- suspiciously many duplicate sequences -- very long gap runs - -## Report Design - -The report should have three layers: - -```text -1. Verdict - PASS / WARN / FAIL - top reasons - recommended next action - -2. Evidence - summary stats - N50 / N90 / L50 / L90 - length summary - GC / N composition - top problematic sequences - -3. Actions - suggested follow-up tools: - QUAST, BUSCO, BlobToolKit, CheckM, seqkit -``` - -The HTML report should be self-contained, static, and shareable. In v0.1 it should embed the summary JSON directly so it can be opened without a server; plots can be added after the first report contract is stable. - -## Testing Strategy - -Unit tests: - -- N50, N90, L50, L90 calculations -- composition percentages -- verdict rules -- finding generation -- duplicate ID detection -- invalid character detection - -Fixture tests: - -- valid small assembly -- duplicate IDs -- empty records -- invalid characters -- high-N scaffolds -- many tiny contigs -- gzipped FASTA - -Snapshot or golden tests: - -- JSON output schema -- TSV output -- MultiQC JSON -- representative HTML report content - -## Packaging and Adoption - -Day one: - -- GitHub release binaries -- Docker image -- documented local build from Cargo - -Early adoption: - -- Bioconda recipe -- nf-core module -- Snakemake wrapper -- MultiQC module -- Galaxy wrapper - -Later: - -- Homebrew -- WASM/browser report viewer - -## v0.1 Success Criteria - -The first release is successful if: - -- it validates huge FASTA files without loading full sequences into memory -- it produces useful HTML, JSON, TSV, and MultiQC-compatible output -- it catches invalid FASTA structure, duplicate IDs, invalid chars, and high-N content -- it installs with one command -- it can be added to a Nextflow or Snakemake pipeline in under five minutes -- MultiQC can consume its output - -## Approved Direction - -The approved first-release approach is: - -```text -Assembly Preflight Contract -``` - -This approach makes the strategic wedge clear, delivers practical utility quickly, and avoids diluting the first release with shallow multi-profile support. diff --git a/docs/superpowers/specs/2026-05-23-fastaguard-v0.2-adoption-design.md b/docs/superpowers/specs/2026-05-23-fastaguard-v0.2-adoption-design.md deleted file mode 100644 index e9bdc89..0000000 --- a/docs/superpowers/specs/2026-05-23-fastaguard-v0.2-adoption-design.md +++ /dev/null @@ -1,463 +0,0 @@ -# FastaGuard v0.2 Design: Assembly Trust And Pipeline Adoption - -## Summary - -FastaGuard v0.2 should make the tool easier to adopt in real bioinformatics -pipelines while adding one focused assembly QC improvement. - -Release theme: - -```text -FastaGuard v0.2: Assembly Trust + Pipeline Adoption -``` - -Product promise: - -```text -Install it easily, aggregate it in MultiQC, trust its JSON contract, and catch -assembly-level outliers before heavier QC tools run. -``` - -v0.2 should keep `assembly` as the main profile. It should not rush -transcriptome, protein, or reference-panel profiles before the assembly -preflight contract is trusted by pipeline users. - -## Goals - -- Make FastaGuard feel natural in workflow engines and reporting stacks. -- Harden the MultiQC path so many samples can be scanned quickly. -- Confirm and document the distribution path after Bioconda publication. -- Add explainable assembly outlier findings for composition and length. -- Strengthen machine-readable provenance for pipelines and future tool agents. -- Preserve the v0.1 contract shape wherever possible. - -## Non-Goals - -- Do not claim contamination detection from GC outliers alone. -- Do not add external database dependencies in v0.2. -- Do not build transcriptome, protein, or reference-panel profiles yet. -- Do not make an LLM/chat feature. -- Do not replace QUAST, BUSCO, BlobToolKit, CheckM, sourmash, Kraken, or - MultiQC. - -## Product Position - -v0.1 proved the core FASTA preflight contract: - -```text -Is this assembly FASTA valid, sane, interpretable, and ready for downstream -tools? -``` - -v0.2 should prove pipeline value: - -```text -Can a pipeline author install FastaGuard, aggregate it, trust its output schema, -and route suspicious assemblies to the right downstream tools? -``` - -Recommended public message: - -```text -FastaGuard catches FASTA-level assembly problems and composition outliers before -expensive downstream QC. -``` - -## Feature Scope - -v0.2 should add one biological feature family: - -```text -Assembly outlier findings -``` - -### GC Outlier Findings - -The current GC-vs-length plot can flag GC outliers, but those flags should be -promoted into explainable findings. - -Finding ID: - -```text -gc_outliers -``` - -Suggested severity: - -```text -Major -``` - -The finding should trigger when one or more records have GC composition far from -the assembly background according to the profile z-score threshold. - -Example interpretation: - -```text -Major finding: 8 contigs have GC composition far from the assembly background. -Why it matters: unusual GC can indicate contamination, cobionts, plasmids, -assembly artifacts, or real biological variation. -Suggested next step: inspect flagged contigs and consider BlobToolKit, -sourmash, Kraken, or related taxonomic checks if the pattern is strong. -``` - -The report must avoid overclaiming. It should say "composition anomaly", not -"contamination", unless a future database-backed mode exists. - -### Length Outlier Findings - -Add careful length outlier evidence for ultra-short and ultra-long records -relative to the assembly distribution. - -Finding ID: - -```text -length_outliers -``` - -Suggested severity: - -```text -Minor -``` - -Length outliers should be presented as review evidence, not automatic failure. -A very long scaffold can be excellent; a very short contig can be expected in -some assemblies. The value is in making the records visible and machine-readable. - -### Composite Anomaly Priority - -Add a composite anomaly indicator when a record has multiple suspicious signals, -for example: - -- GC outlier plus high N -- GC outlier plus long gap run -- GC outlier plus tiny contig -- duplicate sequence plus unusual composition - -This should not be a broad new statistical engine in v0.2. It should be a -compact prioritization layer that helps humans and pipeline agents focus on the -records most worth inspecting. - -Possible finding ID: - -```text -composite_anomalies -``` - -Suggested severity: - -```text -Major -``` - -### Evidence Records - -New findings should follow the existing bounded evidence pattern: - -```json -{ - "id": "gc_outliers", - "evidence": { - "total_records": 8, - "truncated": false, - "records": [ - { - "id": "contig_42", - "length": 18004, - "reason": "GC z-score exceeded profile threshold", - "gc_percent": 72.4 - } - ] - } -} -``` - -Evidence should be deterministic and sorted in a stable way, such as by -severity signal and then length or ID. - -## Integration Scope - -### MultiQC - -The existing MultiQC plugin starter should become usable enough for real -projects. - -Scope: - -- Parse many `fastaguard_mqc.json` files. -- Add key metrics to MultiQC general stats. -- Add a FastaGuard summary section. -- Show verdict, sequence count, total length, N50, GC%, N%, duplicate IDs, - invalid sequences, high-N sequences, and outlier counts. -- Use clear color rules so WARN/FAIL and high-risk metrics are easy to scan. -- Verify with MultiQC strict mode. -- Keep FastaGuard HTML/JSON as the detailed evidence source. - -Rationale from MultiQC documentation: - -- MultiQC custom content is useful but more limited than real modules. -- Public tools benefit from proper modules or plugins. -- MultiQC modules should avoid embedding many images and should recreate plots - from raw data where needed. - -References: - -- https://docs.seqera.io/multiqc/custom_content -- https://docs.seqera.io/multiqc/development/modules/ - -### BioContainers - -Bioconda is live for FastaGuard v0.1.1. v0.2 should confirm whether the -generated BioContainers image and tags are available. - -If confirmed: - -- Document the image name and version tag. -- Add container examples for Nextflow/nf-core and Snakemake. - -If not confirmed: - -- Document the status plainly. -- Keep the local Dockerfile path as the temporary container path. - -### nf-core Local Module - -Polish the local nf-core-style module starter. - -Scope: - -- Keep input shape: `tuple val(meta), path(fasta)`. -- Keep outputs: HTML, JSON, TSV, `_mqc.json`, and `versions.yml`. -- Document Bioconda install. -- Add a pinned container directive only after BioContainers is confirmed. -- Keep the example close to nf-core module conventions. - -Rationale from nf-core documentation: - -- `nf-core modules create` is the standard path for local or shared modules. -- If Bioconda metadata exists, nf-core tooling can use it to populate container - information. - -Reference: - -- https://nf-co.re/docs/nf-core-tools/cli/modules/create - -### Snakemake Wrapper Starter - -Polish the wrapper starter so it is closer to official Snakemake wrapper style. - -Scope: - -- Add `environment.yaml` using the Bioconda package. -- Keep `params.extra` for optional flags. -- Document local usage. -- Document the future path to an upstream Snakemake wrapper. - -Rationale from Snakemake documentation: - -- Wrappers are the most fine-grained Snakemake modularization layer. -- Wrapper versions improve reproducibility by pinning behavior. -- Wrappers can use Conda integration to deploy software dependencies. - -Reference: - -- https://snakemake.readthedocs.io/en/v9.1.8/snakefiles/modularization.html - -### Benchmark And Evidence Page - -Add a concise evidence document for users and maintainers. - -Scope: - -- Synthetic fixtures showing duplicate IDs, invalid characters, high N, and GC - outliers. -- Runtime and memory table for tiny and medium fixtures. -- A section explaining what FastaGuard catches before QUAST, BUSCO, and - BlobToolKit. -- At least one real public FASTA benchmark later if a stable source is chosen. - -This page should turn product claims into inspectable evidence. - -## Data Contract - -v0.2 should strengthen the JSON contract without breaking v0.1 consumers. - -### Schema Version - -Keep tool version and schema version separate. - -Suggested values: - -```text -tool version: 0.2.0 -schema version: 0.2.0 -``` - -### Provenance - -Add richer provenance for workflow engines and future tool agents. - -Recommended new fields: - -```json -{ - "provenance": { - "command": "fastaguard sample.fa --profile assembly ...", - "started_at": "2026-05-23T12:34:56Z", - "completed_at": "2026-05-23T12:34:57Z", - "duration_ms": 842, - "input_size_bytes": 1842912 - } -} -``` - -`input_sha256` should be considered but may remain optional or behind a flag if -checksum cost is a concern for huge FASTA files. - -### Finding Taxonomy - -Add fields that make findings easier for pipelines and agents to interpret: - -```json -{ - "category": "composition", - "confidence": "moderate", - "requires_followup_tool": true -} -``` - -These fields distinguish hard validation failures from biological suspicion. - -### Routing Hints - -Upgrade `machine_summary` with compact routing hints: - -```json -{ - "routing_hints": [ - { - "condition": "composition_anomaly", - "suggested_route": "contamination_or_cobiont_triage", - "requires_external_database": true - } - ] -} -``` - -Routing hints are not commands. They are structured suggestions that workflow -engines and agents can inspect safely. - -### Compatibility Rule - -v0.2 may add fields, but should not rename or remove existing v0.1 fields unless -there is a clear contract reason. Existing JSON Schema and golden-report tests -should protect this rule. - -## Architecture Notes - -The current Rust modules already have good boundaries: - -- `parser.rs`: streaming FASTA parsing -- `metrics.rs`: record-level and assembly-level metrics -- `findings.rs`: findings and verdict logic -- `models.rs`: JSON report contract -- `report/*`: rendered outputs - -v0.2 should preserve these boundaries. - -Suggested implementation shape: - -- Add record-level outlier signal fields to metrics or a focused stats helper. -- Keep z-score and length distribution logic isolated in `src/stats/outliers.rs`. -- Promote selected plot flags into findings in `findings.rs`. -- Extend `models.rs` for provenance, finding taxonomy, and routing hints. -- Update schema, finding catalog, golden JSON, HTML, TSV, and MultiQC output as - derived views. - -## Testing Strategy - -### Rust Core Tests - -- GC outlier detection on controlled fixtures. -- Length outlier detection on skewed fixtures. -- Composite anomaly priority when multiple signals affect the same record. -- No false FAIL from outlier warnings unless `--fail-on` requests it. -- HTML contains the new outlier evidence. -- TSV and MultiQC summaries expose outlier counts where useful. - -### Contract Tests - -- New schema validates all golden JSON reports. -- Existing v0.1 fields remain present. -- `--schema`, `--finding-catalog`, and `--explain-finding` include new findings. -- Provenance fields are present and deterministic enough for tests. -- New finding taxonomy fields validate against schema. - -### Integration Tests - -- MultiQC plugin parses multiple FastaGuard reports. -- MultiQC strict mode works. -- Snakemake wrapper includes a Bioconda environment file. -- nf-core starter docs match current Bioconda and container status. - -### Packaging And Release Checks - -Run the usual gates before release: - -```bash -cargo fmt --check -cargo test --locked -cargo clippy --locked --all-targets --all-features -- -D warnings -python3 -m unittest discover tests/python -v -git diff --check -``` - -Also run targeted checks: - -```bash -multiqc --strict examples/reports -docker build -t fastaguard:local . -mamba install -c conda-forge -c bioconda fastaguard -``` - -## Release Criteria - -v0.2 is ready when: - -- Assembly outlier findings are implemented and explainable. -- JSON Schema and finding catalog are updated. -- Golden reports validate against the new schema. -- MultiQC plugin works on multiple reports. -- Docs clearly state where FastaGuard fits before QUAST, BUSCO, BlobToolKit, - and related tools. -- Benchmark/evidence page exists. -- Bioconda install remains the recommended install path. -- BioContainers status is confirmed or honestly documented as pending. - -## Recommended Next Implementation Order - -1. Commit the current Bioconda-live documentation update. -2. Confirm BioContainers status. -3. Add Snakemake `environment.yaml` and polish nf-core/Snakemake docs. -4. Harden MultiQC plugin and strict-mode verification. -5. Add outlier finding tests. -6. Implement GC, length, and composite anomaly findings. -7. Extend provenance, finding taxonomy, routing hints, schema, and golden - reports. -8. Add benchmark/evidence page. -9. Run full release checks and prepare v0.2.0 release notes. - -## Implementation Defaults - -Use these defaults for the v0.2 implementation plan: - -- Do not compute `input_sha256` by default in v0.2. Add command, timestamps, - duration, and input size first. Revisit checksums later behind an explicit - flag if needed. -- Use a robust percentile/IQR-style approach for `length_outliers` instead of a - plain z-score. Assembly length distributions are often skewed. -- Implement `composite_anomalies` as a separate finding when a record has two or - more suspicious signals. Also keep record-level flags in plot data. -- Keep the MultiQC work as a packaged plugin for v0.2. Consider upstream - MultiQC submission after more users test the module. diff --git a/docs/superpowers/specs/2026-05-27-fastaguard-v0.3-assembly-gate-design.md b/docs/superpowers/specs/2026-05-27-fastaguard-v0.3-assembly-gate-design.md deleted file mode 100644 index 48b16e4..0000000 --- a/docs/superpowers/specs/2026-05-27-fastaguard-v0.3-assembly-gate-design.md +++ /dev/null @@ -1,351 +0,0 @@ -# FastaGuard v0.3 Design: Evidence And Assembly Gate - -## Summary - -FastaGuard v0.3 should turn the current assembly preflight checker into a -pipeline-ready gate that workflow authors can add before QUAST, BUSCO, -BlobToolKit, CheckM, annotation, or submission. - -Release theme: - -```text -FastaGuard v0.3: Evidence And Assembly Gate -``` - -Product promise: - -```text -FastaGuard gives assembly pipelines a fast, explainable PASS/WARN/FAIL gate -before expensive QC. -``` - -The release should stay assembly-first. It should not add transcriptome, -protein, or reference-panel profiles yet. v0.3 should make the assembly -contract more credible, easier to enforce, and easier to cite. - -## Goals - -- Add a pipeline gate preset for common assembly preflight behavior. -- Add input checksum provenance so reports can be tied to exact FASTA bytes. -- Make gate decisions machine-readable without requiring log or HTML parsing. -- Improve report language that separates blocking failures from follow-up - recommendations. -- Run and document public assembly evidence using the existing evidence script. -- Keep the default product fast, deterministic, database-free, and easy to run - through Bioconda and BioContainers. - -## Non-Goals - -- Do not add external databases, taxonomy calls, coverage analysis, aligners, or - internet requirements to the CLI. -- Do not claim biological completeness, assembly correctness, or contamination - confirmation. -- Do not add transcriptome, protein, reference-panel, or compare mode in v0.3. -- Do not make an LLM summary feature. -- Do not break the existing `--fail-on` mechanism; the gate preset should be a - convenience layer over explicit behavior. - -## Product Position - -v0.1 proved the basic assembly preflight contract. v0.2 made the reports more -trustworthy and pipeline-friendly. v0.3 should answer the pipeline author's -practical question: - -```text -Can I add this as the first assembly QC gate and trust what it blocks? -``` - -Recommended public message: - -```text -The assembly FASTA gate before expensive QC. -``` - -## Feature Scope - -### Assembly Gate Preset - -Add a new CLI option: - -```bash -fastaguard sample.fa --profile assembly --gate pipeline -``` - -Supported values: - -```text -none -pipeline -``` - -Default behavior: - -```text ---gate none -``` - -`--gate pipeline` should encode conservative defaults for workflow engines. It -should fail the run for findings that make downstream assembly QC unreliable: - -```text -duplicate_ids -invalid_chars -invalid_fasta_structure -high_n_rate -``` - -It should not fail on `gc_outliers`, `length_outliers`, or -`composite_anomalies` by default. Those remain follow-up and prioritization -signals unless the user explicitly includes them with `--fail-on`. - -If the user supplies both `--gate pipeline` and `--fail-on`, the final failure -set should be the union of the pipeline preset and the explicit finding IDs. -This keeps the preset easy to understand and avoids surprising overrides. - -The CLI should reject unknown gate values with a clear tool error. - -### Machine-Readable Gate Decision - -Add a compact gate decision to the JSON report: - -```json -"gate": { - "mode": "pipeline", - "status": "FAIL", - "blocking_findings": ["duplicate_ids", "invalid_chars"], - "advisory_findings": ["gc_outliers"], - "fail_on": ["duplicate_ids", "high_n_rate", "invalid_chars", "invalid_fasta_structure"] -} -``` - -Rules: - -- `mode` is `none` or `pipeline`. -- `status` matches the report verdict. -- `blocking_findings` lists triggered finding IDs that are in the active - failure set. -- `advisory_findings` lists triggered finding IDs that are not in the active - failure set. -- `fail_on` records the final active failure set after applying the gate preset - and user-provided `--fail-on`. - -This field is intentionally small. Workflow engines and future tool agents -should be able to route from it without reading human prose. - -### Provenance Checksums - -Add input checksum metadata to provenance: - -```json -"input_sha256": "..." -``` - -The checksum should be computed over the exact input bytes on disk, not the -decompressed FASTA stream. For `.fa.gz` inputs, this means the checksum -identifies the compressed file that was passed to FastaGuard. - -The checksum should be enabled by default in v0.3. The implementation should -stream file bytes and must not load the whole FASTA into memory. If the input -cannot be read, normal input error handling should already fail the run before a -report is emitted. - -### Threshold Metadata - -Keep the existing provenance threshold fields, and add enough gate context for -machines to understand why a finding blocked: - -```json -"thresholds": { - "high_n_sequence_fraction": 0.2, - "high_global_n_fraction": 0.05, - "min_contig_length": 200, - "max_gap_run": 100, - "gc_outlier_zscore": 3.0 -} -``` - -No new threshold schema is required for v0.3. The key improvement is that the -active `gate.fail_on` set makes threshold-backed blocking behavior explicit. - -### Report Language - -Update HTML and release-facing docs so users see three classes of outcome: - -```text -Blocking: fix before downstream QC. -Advisory: safe to continue, but inspect. -Routing: run a deeper downstream tool if the question matters. -``` - -Examples: - -- duplicate IDs and invalid characters are blocking for `--gate pipeline` -- GC outliers are advisory and may route to BlobToolKit, sourmash, Kraken, or - related tools -- high N content can be blocking when it exceeds the configured gate threshold - -This wording should preserve the core boundary: FastaGuard is preflight QC, not -biological confirmation. - -### Public Evidence Pack - -Use the existing evidence workflow as the v0.3 proof layer. The default public -manifest should remain small and fast enough to be rerun by maintainers. - -v0.3 should commit compact evidence summaries, not downloaded FASTA files, -archives, or full generated reports. - -Commit these files when a public run is available: - -```text -docs/evidence/v0.3/evidence_summary.json -docs/evidence/v0.3/evidence_summary.tsv -docs/evidence/fastaguard-v0.3-evidence.md -``` - -The evidence page should include: - -- command used -- FastaGuard version and git commit -- platform and date -- public assembly accessions -- input size, sequence count, elapsed seconds -- verdict and top findings -- reminder that FastaGuard is preflight QC, not completeness or contamination - confirmation - -If NCBI Datasets CLI or network access is unavailable during implementation, -the local-only evidence workflow should still be tested and documented. Public -evidence summaries should only be committed after a real public run succeeds. - -## CLI And Contract - -New CLI: - -```text ---gate -``` - -Schema version should become: - -```text -0.3.0 -``` - -Cargo package version should become: - -```text -0.3.0 -``` - -JSON report additions: - -```text -gate -provenance.input_sha256 -``` - -TSV additions: - -```text -gate_mode -gate_status -gate_blocking_findings -gate_advisory_findings -input_sha256 -``` - -MultiQC custom content should include compact `gate_mode`, `gate_status`, and -`gate_blocking_findings` values in the existing custom-content table. - -HTML report should show the gate decision near the verdict. - -## Architecture - -The implementation should keep gate policy separate from finding generation. - -Recommended units: - -- CLI parsing records the requested gate mode. -- A small gate-policy module maps gate mode to default failure IDs. -- Run configuration stores the final `fail_on` set and gate mode. -- Finding generation remains responsible only for detecting findings. -- Report assembly derives the gate decision from triggered findings and active - failure IDs. -- Provenance computes `input_sha256` with streaming file reads. - -This avoids hiding gate behavior inside individual findings and keeps future -gate presets possible. - -## Testing - -Add focused tests for: - -- `--gate pipeline` adds the expected failure IDs. -- `--gate none` preserves existing default behavior. -- `--gate pipeline --fail-on gc_outliers` unions preset and explicit rules. -- unknown gate values are rejected. -- problem fixture reports include a `gate` object with blocking and advisory - findings. -- valid fixture reports include `gate.mode`, `gate.status`, empty blocking - findings, and `provenance.input_sha256`. -- gzipped input checksum is computed from the compressed bytes. -- JSON schema validates updated golden reports. -- TSV includes gate and checksum rows. -- HTML includes gate decision language. -- MultiQC output includes gate mode, gate status, and blocking findings. -- evidence script local-only path continues to pass without network access. - -Run release gates: - -```bash -python3 -m unittest discover tests/python -v -cargo fmt --check -cargo test --locked -cargo clippy --locked --all-targets --all-features -- -D warnings -git diff --check -git ls-files | xargs perl -ne 'print "$ARGV:$.:$_" if /[ \t]$/' -``` - -Optional evidence checks: - -```bash -cargo build --release --locked -python3 scripts/collect_evidence.py \ - --binary target/release/fastaguard \ - --out-dir target/evidence/v0.3-local \ - --local-only -python3 scripts/collect_evidence.py \ - --binary target/release/fastaguard \ - --out-dir target/evidence/v0.3 -``` - -The public evidence command requires NCBI Datasets CLI and network access. - -## Release And Adoption - -v0.3 should ship with: - -- GitHub release notes -- updated README quickstart for `--gate pipeline` -- updated Nextflow/nf-core and Snakemake examples -- updated output contract documentation -- updated schema and golden fixtures -- local evidence summary -- public evidence summary when available - -After the GitHub `v0.3.0` release exists, update the Bioconda recipe and let -the Bioconda update path produce the next BioContainers image. Do not open a -Bioconda update before the public GitHub source archive exists. - -## Success Criteria - -v0.3 is successful if: - -- a pipeline author can copy one command and get a conservative assembly gate -- the JSON report makes the gate decision obvious to machines -- provenance identifies the exact input file with SHA256 -- the report still routes to downstream tools without claiming to replace them -- evidence summaries show FastaGuard running on local and public FASTA cases -- all existing v0.2 outputs remain understandable with a clear schema version - bump diff --git a/docs/superpowers/specs/2026-06-02-fastaguard-v0.4-preflight-readiness-design.md b/docs/superpowers/specs/2026-06-02-fastaguard-v0.4-preflight-readiness-design.md deleted file mode 100644 index 45be703..0000000 --- a/docs/superpowers/specs/2026-06-02-fastaguard-v0.4-preflight-readiness-design.md +++ /dev/null @@ -1,989 +0,0 @@ -# FastaGuard v0.4 Design: Preflight Readiness And Compare Mode - -## Summary - -FastaGuard v0.4 should make the product's pre-QC position unmistakable: - -```text -FastaGuard is the FASTA readiness gate before interpretive QC tools run. -``` - -v0.3 made single assembly FASTA checks credible for pipelines. v0.4 should make -FastaGuard useful in the daily workflow where bioinformaticians handle many -FASTA files, not just one. The release should add compare mode and a preflight -readiness matrix that tells users whether each FASTA is ready for indexing, -mapping, BLAST database creation, annotation, submission, and deeper assembly QC. - -Release theme: - -```text -FastaGuard v0.4: Preflight Readiness + Compare Mode -``` - -Product promise: - -```text -Rank, gate, and route many assembly FASTA files before QUAST, BUSCO, -BlobToolKit, CheckM, annotation, or submission. -``` - -This release should remain assembly-first and database-free by default. It -should not add transcriptome, protein, or reference-panel profiles yet. - -## Goals - -- Add a `compare` command for many assembly FASTA files. -- Add a preflight readiness matrix to single-file and compare outputs. -- Make readiness decisions machine-readable with stable IDs and evidence. -- Add checks that catch common pre-QC failures before indexing, BLAST database - creation, annotation, submission, or heavier assembly QC. -- Preserve v0.3 report behavior for existing single-file users. -- Produce a value benchmark document with measured runtime, memory, and - downstream-work-avoided scenarios. - -## Non-Goals - -- Do not add taxonomy databases, marker-gene databases, aligners, read mapping, - or internet requirements to default runs. -- Do not claim biological completeness, assembly correctness, or contamination - confirmation. -- Do not replace official NCBI, ENA, QUAST, BUSCO, BlobToolKit, CheckM, - sourmash, Kraken, samtools, BLAST, or annotation validators. -- Do not add transcriptome, protein, or reference-panel profiles in v0.4. -- Do not add an LLM/chat feature. -- Do not introduce a workflow engine dependency. - -## Product Position - -The useful distinction is: - -```text -FastaGuard checks whether a FASTA is ready for tools. -Downstream QC tools interpret what the assembly means biologically. -``` - -FastaGuard should own analysis readiness. That includes file parsing, FASTA -structure, sequence alphabet, identifier safety, indexing safety, basic assembly -composition signals, submission-style advisories, and cohort triage. - -Recommended public message: - -```text -The FASTA readiness gate before assembly QC. -``` - -Recommended slogan: - -```text -Validate the FASTA. Explain the risk. Route the workflow. -``` - -Avoid: - -```text -FastQC for FASTA -``` - -That phrase makes the tool sound smaller than the machine-readable preflight -contract it is trying to become. - -## User Workflows - -### Single Assembly Gate - -Existing v0.3 behavior should continue: - -```bash -fastaguard sample.fa \ - --profile assembly \ - --gate pipeline \ - --out fastaguard_report.html \ - --json fastaguard.json \ - --tsv fastaguard.tsv \ - --multiqc fastaguard_mqc.json -``` - -v0.4 should add readiness evidence to that report without changing the basic -command shape. - -### Cohort Triage - -Add a compare command: - -```bash -fastaguard compare assemblies/*.fa \ - --profile assembly \ - --gate pipeline \ - --out cohort_report.html \ - --json cohort.json \ - --tsv cohort.tsv \ - --multiqc fastaguard_compare_mqc.json -``` - -The compare command should answer: - -- Which FASTA files fail the preflight gate? -- Which pass but deserve follow-up inspection? -- Which samples are outliers compared with the cohort? -- Which downstream tools are reasonable next steps? -- Which failures are likely to waste expensive jobs if ignored? - -### Pipeline Use - -Workflow authors should be able to run FastaGuard before expensive processes and -route on stable fields: - -```text -readiness.overall.status -readiness.categories[].status -gate.status -gate.blocking_findings -machine_summary.recommended_next_tools -``` - -Pipelines should not parse logs or HTML. - -## Feature Scope - -### Compare Command - -Add a subcommand: - -```text -fastaguard compare ... -``` - -Minimum behavior: - -- Accept two or more input FASTA paths. -- Support uncompressed and gzipped FASTA inputs using the existing parser path. -- Run the same assembly analysis as single-file mode for each input. -- Preserve deterministic ordering based on input path order unless an explicit - sort is requested in a future release. -- Produce combined JSON, TSV, HTML, and MultiQC-compatible outputs. -- Return an exit code based on the worst per-sample status: - - `0` when all samples pass - - `1` when one or more samples warn but none fail - - `2` when one or more samples fail the active gate - - `3` for invalid command, input, or tool errors that prevent comparison - -The compare command should not require all inputs to share the same number of -records, total length, or organism. It is a triage view, not a formal benchmark. - -### Compare JSON Contract - -Add a new compare report shape: - -```json -{ - "schema_version": "0.4.0", - "report_type": "compare", - "tool": { - "name": "fastaguard", - "version": "0.4.0" - }, - "input": { - "profile": "assembly", - "sample_count": 3 - }, - "summary": { - "sample_count": 3, - "pass_count": 1, - "warn_count": 1, - "fail_count": 1 - }, - "samples": [ - { - "sample_id": "assembly_a", - "input_path": "assemblies/a.fa", - "verdict": "PASS", - "gate_status": "PASS", - "readiness_status": "PASS", - "sequence_count": 42, - "total_length": 5123456, - "n50": 240000, - "gc_percent": 50.8, - "n_percent": 0.1, - "finding_ids": [], - "readiness_blockers": [], - "recommended_next_tools": ["QUAST", "BUSCO"] - } - ], - "cohort_findings": [ - { - "id": "cohort_total_length_outliers", - "severity": "major", - "affected_count": 1, - "evidence": { - "samples": [ - { - "sample_id": "assembly_c", - "total_length": 8723456, - "reason": "total length is high relative to cohort distribution" - } - ] - } - } - ] -} -``` - -Rules: - -- `report_type` distinguishes single-file reports from compare reports. -- `samples[]` should contain compact per-sample summaries, not full nested - copies of every single-file report. -- Full per-sample JSON files should not be generated by default in v0.4. The - compare report should stay compact. A later `--write-sample-reports` option - can add full per-sample artifacts if users need them. -- Cohort findings should be deterministic and based only on local metrics. - -### Compare TSV - -The compare TSV should be a table with one row per sample. - -Minimum columns: - -```text -sample_id -input_path -verdict -gate_status -readiness_status -sequence_count -total_length -n50 -n90 -gc_percent -n_percent -duplicate_id_count -invalid_sequence_count -high_n_sequence_count -tiny_contig_count -max_gap_run -gc_outlier_count -length_outlier_count -finding_count -readiness_blockers -recommended_next_tools -input_sha256 -``` - -This table is a major adoption surface. It should be stable, simple, and easy to -join with workflow metadata. - -### Compare HTML - -The compare HTML should be self-contained and should not rely on external -JavaScript or CDNs. - -Required sections: - -1. Verdict summary -2. Sample table -3. Readiness matrix -4. Cohort metric plots -5. Cohort findings -6. Suggested next tools - -Minimum plots: - -- total length by sample -- N50 by sample -- GC% by sample -- N% by sample -- sequence count by sample - -Inline SVG is enough for v0.4. The goal is scanability, not rich interactivity. - -### MultiQC Output - -The compare command should produce standard MultiQC custom content compatible -with the existing FastaGuard approach. - -The MultiQC data should be keyed by sample ID and include: - -- verdict -- gate status -- readiness status -- sequence count -- total length -- N50 -- GC% -- N% -- duplicate ID count -- invalid sequence count -- high-N sequence count -- finding count -- readiness blockers - -The default compare MultiQC filename should be: - -```text -fastaguard_compare_mqc.json -``` - -Single-file default remains: - -```text -fastaguard_mqc.json -``` - -## Preflight Readiness Matrix - -Add a readiness layer to single-file and compare reports. - -The readiness layer should answer: - -```text -Which downstream surfaces is this FASTA ready for? -``` - -Proposed JSON shape: - -```json -"readiness": { - "overall": { - "status": "FAIL", - "blockers": ["index.duplicate_first_token_ids", "alphabet.invalid_chars"] - }, - "categories": [ - { - "id": "file", - "label": "File readiness", - "status": "PASS", - "findings": [] - }, - { - "id": "index", - "label": "Index readiness", - "status": "FAIL", - "findings": ["duplicate_ids", "duplicate_first_token_ids"] - } - ] -} -``` - -Statuses: - -```text -PASS -WARN -FAIL -``` - -Categories: - -```text -file -structure -alphabet -index -assembly -submission -cohort -machine -``` - -`cohort` applies only to compare reports. `machine` describes whether the -report contract is complete enough for workflow routing. - -Readiness is not a second verdict system. It is a routing view over findings, -thresholds, and output-contract availability. The single report verdict and gate -status remain the authoritative pass/warn/fail decision. - -## New Findings - -### Duplicate First-Token IDs - -Finding ID: - -```text -duplicate_first_token_ids -``` - -Severity: - -```text -critical -``` - -Rationale: - -Many tools treat the first whitespace-delimited token in a FASTA header as the -record name. Two headers can look different to humans but collide in downstream -indexes if their first token is the same. - -Example: - -```text ->contig1 length=1000 ->contig1 length=2000 -``` - -This should be blocking for `--gate pipeline`. - -### Blank Or Unsafe IDs - -Finding ID: - -```text -unsafe_ids -``` - -Severity: - -```text -major -``` - -Scope: - -- blank ID after `>` -- IDs with leading or trailing whitespace -- IDs containing control characters -- IDs containing path-like separators that commonly confuse scripts - -This should be warning by default. A stricter submission gate can fail it later. - -### Long Headers - -Finding ID: - -```text -long_headers -``` - -Severity: - -```text -minor -``` - -Default threshold: - -```text -header length > 200 characters -``` - -Rationale: - -Long headers often embed coverage, coordinates, tool metadata, or free text that -can break brittle downstream scripts. This should be advisory, not blocking. - -### Reserved Header Characters - -Finding ID: - -```text -reserved_header_chars -``` - -Severity: - -```text -minor -``` - -Initial reserved characters: - -```text -| ; " ' ` < > \t -``` - -This should be advisory. Some ecosystems use pipes legitimately, so the report -must explain that the concern is tool compatibility, not invalid FASTA. - -### Terminal Ns - -Finding ID: - -```text -terminal_ns -``` - -Severity: - -```text -major -``` - -Scope: - -- records that start with one or more `N` -- records that end with one or more `N` - -Rationale: - -Terminal Ns can signal untrimmed ambiguous sequence or submission-readiness -problems. NCBI genome submission guidance explicitly advises no Ns at the ends -of submitted sequences. - -This should be advisory in normal assembly profile and blocking only under a -future submission-focused gate. - -### Gap Pattern Warnings - -Finding ID: - -```text -gap_pattern_warnings -``` - -Severity: - -```text -minor -``` - -Scope: - -- many identical N-run lengths, especially exactly 100 Ns -- mixed short ambiguity runs and long scaffold-gap runs -- records with multiple long gap runs - -Rationale: - -This does not prove a problem. It helps route users toward gap handling, -AGP/table2asn decisions, or submission validators when needed. - -### Assembly Size Out Of Expected Range - -Finding ID: - -```text -expected_size_outlier -``` - -Severity: - -```text -major -``` - -v0.4 default: - -Do not call external APIs. Support only user-provided expected size: - -```bash -fastaguard sample.fa --expected-size 5mb --expected-size-tolerance 0.25 -``` - -or: - -```bash -fastaguard compare *.fa --expected-size-column expected_size.tsv -``` - -This should compare ungapped assembly length against the expected range. It is -advisory by default and should route users to official NCBI expected genome size -checks or deeper contamination/completeness analysis. - -### Cohort Metric Outliers - -Finding IDs: - -```text -cohort_total_length_outliers -cohort_gc_outliers -cohort_n_percent_outliers -cohort_sequence_count_outliers -cohort_n50_outliers -``` - -Severity: - -```text -minor or major depending on signal strength -``` - -Scope: - -Compare mode only. These findings should rank unusual samples relative to the -batch. They should not automatically fail the pipeline unless the user includes -them with `--fail-on`. - -## Gate Behavior - -The v0.3 `--gate pipeline` preset should remain conservative. - -Add to the default blocking set: - -```text -duplicate_first_token_ids -``` - -Keep blocking: - -```text -duplicate_ids -invalid_chars -invalid_fasta_structure -high_n_rate -``` - -Do not block by default: - -```text -unsafe_ids -long_headers -reserved_header_chars -terminal_ns -gap_pattern_warnings -expected_size_outlier -cohort_*_outliers -gc_outliers -length_outliers -composite_anomalies -``` - -Rationale: - -Default blocking should mean the FASTA is likely unsafe for routine downstream -tool execution. Advisory findings should support inspection and routing without -creating false drama. - -## CLI Design - -### Existing Single-File Command - -No breaking changes. - -Add optional flags: - -```text ---expected-size ---expected-size-tolerance -``` - -Readiness should be included by default in JSON and HTML outputs once the schema -version moves to `0.4.0`. Do not add a `--readiness` flag in v0.4; a flag that -only confirms default behavior would make the CLI noisier without adding value. - -### New Compare Command - -```text -fastaguard compare ... [OPTIONS] -``` - -Options should mirror the single-file command where practical: - -```text ---profile assembly ---gate pipeline ---fail-on ---out ---json ---tsv ---multiqc ---min-contig-length ---high-n ---high-global-n ---max-gap-run ---gc-outlier-zscore ---expected-size ---expected-size-tolerance -``` - -Potential later flags, not required for v0.4: - -```text ---sample-sheet ---sample-id-regex ---write-sample-reports ---threads -``` - -## Data Model Changes - -Add models for: - -- readiness overall status -- readiness categories -- readiness category findings -- compare report -- compare sample row -- cohort findings - -The existing single-file `FastaguardReport` should gain: - -```text -readiness -``` - -The schema should be versioned to: - -```text -0.4.0 -``` - -Backward compatibility: - -- Existing v0.3 users should still receive the same core fields. -- New fields may be added, but existing field names and meanings should not be - changed. -- Existing output filenames should remain unchanged for single-file mode. - -## Implementation Notes - -### Metrics - -Extend sequence summaries with: - -- first token ID -- header length -- unsafe ID flags -- reserved header character flags -- terminal N counts -- gap run length histogram or compact gap pattern summary -- ungapped length - -Do not store full sequence strings. - -### Performance - -v0.4 should preserve the "seconds-level preflight" promise. - -Implementation should: - -- stream input records -- avoid loading whole FASTA files into memory -- keep bounded evidence lists -- cap plotted points as v0.2/v0.3 already do -- run compare samples sequentially first unless parallelism is simple and - deterministic - -Parallel compare can come later if needed. Deterministic output is more -important than squeezing early speed. - -### Evidence Ordering - -Evidence records should be deterministic: - -- blocking findings first -- stronger signal before weaker signal -- longer records before shorter records when biology relevance is similar -- ID as final tie-breaker - -### Size Parsing - -`--expected-size` should accept: - -```text -5000000 -5kb -5mb -5gb -5k -5m -5g -``` - -Use decimal units for biological readability: - -```text -1kb = 1,000 bases -1mb = 1,000,000 bases -1gb = 1,000,000,000 bases -``` - -Report the parsed value in provenance. - -## Report Design - -### Single HTML - -Add a "Readiness" section near the top after verdict/gate: - -```text -Readiness -File: PASS -Structure: PASS -Alphabet: PASS -Index: FAIL -Assembly: WARN -Submission: WARN -Machine: PASS -``` - -Each category should include short action language: - -```text -Index readiness failed because duplicate first-token IDs can make faidx, -BLAST databases, mapping references, or annotation joins ambiguous. -``` - -### Compare HTML - -The compare report should show: - -- top-line counts: PASS/WARN/FAIL -- sortable-looking static table, ordered by input order -- readiness matrix with samples as rows and categories as columns -- cohort metric plots -- cohort findings -- next-tool suggestions - -No external JavaScript. - -## Documentation - -Add or update: - -```text -docs/preflight-readiness.md -docs/compare-mode.md -docs/value-benchmark.md -README.md -docs/tool-landscape.md -docs/benchmarking.md -docs/releases/v0.4.0.md -``` - -`docs/preflight-readiness.md` should explain: - -- what FastaGuard checks before QC tools -- what FastaGuard cannot conclude -- readiness categories -- why index readiness matters -- why submission readiness is advisory -- how to route to QUAST, BUSCO, BlobToolKit, CheckM, samtools, BLAST, or - official validators - -`docs/value-benchmark.md` should include measured local benchmarks: - -```text -10 Mbp synthetic FASTA: about 0.51 seconds, about 17 MB RSS -100 Mbp synthetic FASTA: about 0.98 seconds, about 50 MB RSS -``` - -It should also include careful value scenarios: - -```text -FastaGuard costs seconds; it can save minutes, CPU-hours, or days when it blocks -a bad FASTA before heavier QC starts. -``` - -## Tests - -### Rust Unit And Integration Tests - -Add tests for: - -- duplicate first-token IDs -- unsafe IDs -- long headers -- reserved header characters -- terminal Ns -- gap pattern warnings -- expected-size parsing -- expected-size outlier findings -- readiness matrix status aggregation -- `--gate pipeline` includes `duplicate_first_token_ids` -- compare command rejects fewer than two inputs -- compare JSON shape and schema -- compare TSV columns -- compare MultiQC custom content -- compare exit codes for pass, warn, fail, and tool error -- deterministic compare output order - -### Golden Reports - -Regenerate golden JSON for: - -```text -valid_assembly -problem_assembly -invalid_empty_record -``` - -Add compare golden fixtures: - -```text -compare_mixed_status.json -compare_all_pass.json -``` - -### Python Tests - -Update adoption asset tests to check: - -- compare docs exist -- readiness docs explain boundaries -- value benchmark docs contain measured local numbers -- nf-core/Snakemake examples mention compare mode as optional/starter -- MultiQC custom content accepts compare output - -### Verification Gates - -Run: - -```bash -python3 -m unittest discover tests/python -v -cargo fmt --check -cargo test --locked -cargo clippy --locked --all-targets --all-features -- -D warnings -git diff --check -git ls-files | xargs perl -ne 'print "$ARGV:$.:$_" if /[ \t]$/' -``` - -Optional: - -```bash -cargo build --release --locked -python3 scripts/benchmark_large_fasta.py \ - --records 10000 \ - --length 1000 \ - --binary target/release/fastaguard \ - --out-dir target/benchmarks/v0.4-10mbp -``` - -## Release Criteria - -v0.4 is ready when: - -- compare mode works for many FASTA files -- single-file JSON includes readiness without breaking existing core fields -- compare JSON and TSV are stable enough for workflow authors -- MultiQC can consume compare output -- readiness findings are documented in the finding catalog -- local benchmark/value docs are committed -- all tests and lint gates pass -- release notes clearly say FastaGuard remains preflight QC, not downstream - biological interpretation - -## Open Questions - -1. Should compare mode write full per-sample reports by default, or only compact - cohort outputs? - - Recommendation: compact cohort outputs by default. Add - `--write-sample-reports` later if users ask for it. - -2. Should submission readiness be a separate gate in v0.4? - - Recommendation: no. Add submission advisories now, then consider - `--gate submission` after users validate the thresholds. - -3. Should compare mode use parallel processing immediately? - - Recommendation: no. Start sequential and deterministic. Optimize after the - contract is trusted. - -4. Should expected genome size call the NCBI API? - - Recommendation: no default internet access. Support user-provided expected - size in v0.4; document official NCBI checks as follow-up. - -## Recommended Implementation Order - -1. Add tests and models for readiness categories. -2. Add new single-file findings and evidence. -3. Add readiness output to single-file JSON/HTML/TSV/MultiQC. -4. Add compare command with compact per-sample summaries. -5. Add cohort findings and compare plots. -6. Add docs, examples, release notes, and value benchmark. -7. Regenerate schema and golden reports. -8. Run full verification gates. - -This order keeps the contract stable before building the larger compare report. diff --git a/docs/superpowers/specs/2026-06-11-fastaguard-v0.5-submission-readiness-design.md b/docs/superpowers/specs/2026-06-11-fastaguard-v0.5-submission-readiness-design.md deleted file mode 100644 index edd705d..0000000 --- a/docs/superpowers/specs/2026-06-11-fastaguard-v0.5-submission-readiness-design.md +++ /dev/null @@ -1,477 +0,0 @@ -# FastaGuard v0.5 Design: Submission Readiness Gate - -## Summary - -FastaGuard v0.5 should make the product's preflight position more concrete: - -```text -FastaGuard catches FASTA problems that break pipelines and delay submissions. -``` - -v0.4 added readiness categories and compare mode. v0.5 should turn the -submission part of readiness into a deliberate gate, focused on FASTA-level -issues that users can fix before they spend time on official validators, -annotation, QUAST, BUSCO, BlobToolKit, CheckM, NCBI FCS, or submission portals. - -Release theme: - -```text -FastaGuard v0.5: Submission Readiness Gate -``` - -Product promise: - -```text -Check whether an assembly FASTA is structurally safe, identifier-safe, and -submission-ready enough to continue into official validation and downstream QC. -``` - -This release should remain assembly-first and database-free by default. It -should not add transcriptome, protein, or reference-panel profiles yet. - -## Why This Matters - -The current bioinformatics landscape already has strong tools, but their roles -start later or solve broader problems: - -- SeqKit, SeqFu, pyfastx, and BBTools provide fast FASTA/FASTQ manipulation or - statistics. -- QUAST evaluates assembly quality and can compare assemblies. -- BUSCO estimates biological completeness. -- BlobToolKit and NCBI FCS help investigate contamination or foreign sequence - signals with supporting data or databases. -- MultiQC aggregates outputs, but custom content remains more limited than a - native module. -- NCBI, ENA, and DDBJ submission systems have their own validation rules and - submission workflows. - -FastaGuard's useful gap is the layer before those tools: - -```text -Is this FASTA safe to hand to other tools and validators? -``` - -v0.5 should turn that into a practical user workflow. - -## Goals - -- Add a `submission` gate preset for assembly FASTA preflight. -- Add a `--submission-target` option with `generic` and `ncbi` as the first - supported targets. -- Add stricter identifier and definition-line checks without changing default - `--gate pipeline` behavior. -- Add structured submission-readiness findings with stable IDs, evidence, - thresholds, and recommended next steps. -- Add report fields that tell pipelines and agents whether the FASTA is ready - for official validation, annotation, and downstream QC. -- Preserve v0.4 compare mode and single-file behavior for users who do not opt - into the submission gate. -- Update docs so users understand FastaGuard is a pre-submission preflight, not - an official substitute for NCBI, ENA, DDBJ, FCS, QUAST, BUSCO, or BlobToolKit. - -## Non-Goals - -- Do not implement official NCBI, ENA, or DDBJ validation. -- Do not claim that passing FastaGuard guarantees repository acceptance. -- Do not add taxonomy databases, marker databases, aligners, read mapping, or - internet requirements. -- Do not run NCBI FCS, BlobToolKit, QUAST, BUSCO, CheckM, or annotation tools. -- Do not infer biological completeness or confirm contamination. -- Do not add transcriptome, protein, or reference-panel profiles in v0.5. -- Do not add an LLM-facing chat feature. - -## Product Position - -Recommended public message: - -```text -Preflight your FASTA before official validators and expensive QC. -``` - -Short slogan: - -```text -Validate the FASTA before the pipeline pays for it. -``` - -Avoid: - -```text -FastaGuard replaces submission validators. -``` - -The correct boundary is: - -```text -FastaGuard finds FASTA-level risks early. Official validators and downstream -tools still decide biological, taxonomic, annotation, and submission acceptance. -``` - -## User Workflows - -### Generic Submission Readiness - -```bash -fastaguard sample.fa \ - --profile assembly \ - --gate submission \ - --submission-target generic \ - --out fastaguard_report.html \ - --json fastaguard.json \ - --tsv fastaguard.tsv \ - --multiqc fastaguard_mqc.json -``` - -This mode should answer: - -- Are FASTA records valid and non-empty? -- Are identifiers safe for common parsers and indexes? -- Are sequence characters valid for assembly FASTA? -- Are ambiguity and gap signals likely to need explanation before submission? -- Should the user fix the FASTA before running official validation? - -### NCBI-Oriented Preflight - -```bash -fastaguard sample.fa \ - --profile assembly \ - --gate submission \ - --submission-target ncbi -``` - -This mode should be stricter about SeqID-like concerns and gap reporting, while -remaining honest that it is not an official NCBI validator. - -### Compare Mode With Submission Gate - -```bash -fastaguard compare assemblies/*.fa \ - --profile assembly \ - --gate submission \ - --submission-target ncbi \ - --json submission_cohort.json \ - --tsv submission_cohort.tsv \ - --out submission_cohort.html -``` - -This should make a cohort-level table of which FASTA files are ready for -official validation and which should be fixed first. - -## CLI Design - -Extend the existing gate enum: - -```text ---gate -``` - -Add: - -```text ---submission-target -``` - -Default behavior: - -- `--gate none`: no blocking gate, same as existing behavior. -- `--gate pipeline`: existing v0.3/v0.4 behavior. -- `--gate submission`: stricter FASTA-level blocking for submission readiness. -- If `--gate submission` is used without `--submission-target`, default to - `generic`. -- If `--submission-target` is provided without `--gate submission`, include - target-aware advisories in readiness output but do not change exit behavior. - -Exit codes remain unchanged: - -```text -0 = pass -1 = warnings above configured threshold -2 = hard QC failure -3 = invalid input / tool error -``` - -## Submission Targets - -### Generic - -The generic target should encode broad, conservative FASTA hygiene: - -- no empty identifiers -- no duplicate IDs -- no duplicate first-token IDs -- no unsafe whitespace ambiguity in identifiers -- no control characters -- no invalid nucleotide/IUPAC symbols -- no empty records -- bounded identifier length advisories -- gap-run and ambiguity summaries - -### NCBI - -The NCBI target should add stricter SeqID-oriented checks inspired by public -NCBI submission guidance: - -- warn or fail on SeqID characters that are risky for submission and downstream - tools -- flag identifiers longer than the configured SeqID threshold -- flag spaces and pipe characters in first-token IDs -- flag definition lines with missing first-token IDs -- report long `N` runs as gap-like evidence -- route users to NCBI FCS when FASTA-level signals suggest contamination - follow-up, while making clear that FastaGuard does not run FCS - -The exact thresholds should be documented in provenance and schema. - -## Finding Scope - -Add or promote stable finding IDs for submission readiness: - -```text -unsafe_identifier_chars -long_identifier -duplicate_first_token_ids -empty_identifier -control_characters -gap_run_summary -submission_gap_like_ns -submission_high_ambiguity -submission_tiny_records -submission_target_scope -``` - -Some of these may reuse existing low-level evidence if a finding already exists. -Do not create duplicate concepts if a current finding ID can be extended safely. - -## Gate Behavior - -`--gate submission` should fail on problems that make the FASTA structurally -unsafe for common tooling: - -```text -invalid_fasta_structure -empty_records -empty_identifier -duplicate_ids -duplicate_first_token_ids -invalid_chars -control_characters -unsafe_identifier_chars -``` - -It should warn, not fail by default, on issues that may be legitimate but need -review or explanation: - -```text -long_identifier -submission_gap_like_ns -submission_high_ambiguity -submission_tiny_records -gap_run_summary -gc_outliers -length_outliers -``` - -Users can still make advisory findings blocking with `--fail-on`. - -## JSON Contract - -Extend the existing `gate` and `readiness` fields rather than inventing a second -submission report type. - -Recommended shape: - -```json -{ - "gate": { - "mode": "submission", - "submission_target": "ncbi", - "status": "FAIL", - "blocking_findings": ["duplicate_first_token_ids", "unsafe_identifier_chars"], - "advisory_findings": ["submission_gap_like_ns", "long_identifier"], - "fail_on": [ - "duplicate_ids", - "duplicate_first_token_ids", - "empty_identifier", - "empty_records", - "invalid_chars", - "invalid_fasta_structure", - "unsafe_identifier_chars" - ] - }, - "readiness": { - "overall": { - "status": "FAIL", - "summary": "FASTA should be fixed before official submission validation." - }, - "categories": [ - { - "id": "submission", - "status": "FAIL", - "target": "ncbi", - "blocking_findings": ["unsafe_identifier_chars"], - "advisory_findings": ["submission_gap_like_ns"] - } - ] - }, - "scope": { - "can_conclude": [ - "FASTA parse validity", - "identifier safety", - "assembly alphabet validity", - "FASTA-level submission-readiness risks" - ], - "cannot_conclude": [ - "repository acceptance", - "taxonomic contamination", - "biological completeness", - "annotation correctness" - ] - } -} -``` - -Compare reports should surface the same gate status per sample and add cohort -counts: - -```text -submission_ready_count -submission_warn_count -submission_fail_count -``` - -## HTML Report - -Add a concise "Submission Readiness" section near the top when submission -signals are present. - -The section should show: - -- target: `generic` or `ncbi` -- status: PASS / WARN / FAIL -- blocking problems -- advisory risks -- recommended next step -- scope note that official validators are still required - -Avoid long prose. The HTML should explain the result, not become a submission -manual. - -## TSV And MultiQC - -Add stable summary columns: - -```text -submission_target -submission_status -submission_blocking_findings -submission_advisory_findings -unsafe_identifier_count -long_identifier_count -duplicate_first_token_id_count -gap_like_n_run_count -``` - -The MultiQC custom-content output should include the same high-level fields so -workflow reports can sort samples by submission status. - -## Evidence And Documentation - -Add a small committed evidence page for v0.5: - -```text -docs/evidence/fastaguard-v0.5-submission-readiness.md -``` - -It should include tiny synthetic cases: - -- clean assembly FASTA -- duplicate first-token IDs -- unsafe identifier characters -- long identifier -- long N runs -- invalid sequence character - -Do not commit large public FASTA files. - -Docs to update: - -- `README.md` -- `docs/roadmap.md` -- `docs/vision-plan.md` -- `docs/tool-landscape.md` -- `docs/output-contract.md` -- `docs/packaging.md` -- `docs/releases/v0.5.0.md` -- `examples/nf-core/README.md` -- `examples/snakemake/README.md` - -## Tests - -Add focused tests for: - -- CLI accepts `--gate submission` -- CLI accepts `--submission-target generic` -- CLI accepts `--submission-target ncbi` -- unknown submission target exits with code `3` -- submission gate fails duplicate first-token IDs -- submission gate fails unsafe identifier characters -- submission gate warns on long identifiers -- submission gate warns on long N runs -- `--fail-on long_identifier` can make long identifiers blocking -- JSON schema validates new fields -- golden JSON fixtures include submission pass/warn/fail cases -- HTML contains "Submission Readiness" -- TSV includes submission columns -- MultiQC output includes submission fields -- compare mode aggregates submission status deterministically - -Run the usual gates: - -```bash -python3 -m unittest discover tests/python -v -cargo fmt --check -cargo test --locked -cargo clippy --locked --all-targets --all-features -- -D warnings -git diff --check -git ls-files | xargs perl -ne 'print "$ARGV:$.:$_" if /[ \t]$/' -``` - -## Release Strategy - -Before tagging v0.5.0: - -1. Merge v0.4.0 into Bioconda or document clearly that Bioconda remains behind. -2. Implement submission readiness behind explicit `--gate submission`. -3. Preserve `--gate pipeline` behavior unless tests intentionally prove an - unchanged report contract. -4. Regenerate schema, examples, and golden reports. -5. Add v0.5 release notes with clear boundaries. -6. Tag and publish GitHub release. -7. Update Bioconda after the public source archive exists. - -## Success Criteria - -v0.5 is successful if: - -- a user can run one command before official validation and see fixable FASTA - risks immediately -- pipeline authors can route on `gate.mode = submission` and - `readiness.categories[id=submission]` -- no downstream tool claims are overstated -- the report tells users when to continue to NCBI FCS, QUAST, BUSCO, - BlobToolKit, CheckM, annotation, or official validators -- all outputs remain deterministic and schema-validated - -## Recommended Implementation Order - -1. Add CLI enums and no-op serialization support for `submission_target`. -2. Add tests for target parsing and unchanged default behavior. -3. Add identifier-safety analyzer functions with focused unit tests. -4. Add submission gate failure/advisory mapping. -5. Extend JSON schema and golden fixtures. -6. Extend TSV, MultiQC, and HTML outputs. -7. Add compare-mode aggregation fields. -8. Update docs, examples, and release notes. -9. Run full verification gates. - -This order keeps the contract clear before touching report presentation. diff --git a/docs/tool-landscape.md b/docs/tool-landscape.md index d98305f..6e09fa9 100644 --- a/docs/tool-landscape.md +++ b/docs/tool-landscape.md @@ -81,20 +81,23 @@ Current product evidence: - Rust CLI builds and runs as a single binary. - Docker build and smoke test pass. - GitHub release workflow builds Linux and macOS binaries. -- FastaGuard v0.5.0 is published on GitHub with Linux and macOS binaries. -- FastaGuard v0.5.0 is published on Bioconda for `linux-64`, +- FastaGuard v0.6.0 is published on GitHub with Linux and macOS binaries. +- FastaGuard v0.6.0 is published on Bioconda for `linux-64`, `linux-aarch64`, `osx-64`, and `osx-arm64`. - Clean Bioconda install has been smoke-tested with `fastaguard --schema`. - BioContainers publishes - `quay.io/biocontainers/fastaguard:0.5.0--hfa8f182_0`. + `quay.io/biocontainers/fastaguard:0.6.0--hfa8f182_0`. - JSON Schema validates committed golden reports. - Reports include bounded evidence records and suggested actions. - The v0.3 gate contract exposes `gate.blocking_findings`, `gate.advisory_findings`, and `provenance.input_sha256` for workflow engines. - MultiQC custom-content JSON is emitted as `fastaguard_mqc.json`. -- A native MultiQC plugin starter exists under `integrations/multiqc/`. +- An unpublished local MultiQC plugin starter exists under `integrations/multiqc/`. - Bioconda recipe mirror exists under `packaging/bioconda/`. -- nf-core, Nextflow, and Snakemake starters exist under `examples/`. +- nf-core module PR [#12239](https://github.com/nf-core/modules/pull/12239) + merged 2026-08-21. +- Snakemake wrapper PR [#5436](https://github.com/snakemake/snakemake-wrappers/pull/5436) + merged 2026-07-27; autobump PR #5737 merged 2026-07-31. - The v0.2 evidence workflow is documented in `docs/evidence/fastaguard-v0.2-evidence.md`. - The v0.3 gate evidence workflow is documented in @@ -105,10 +108,13 @@ Current product evidence: commands, and the boundary before official validators. - v0.5 public evidence workflow defines manifest roles, expected scales, and downstream routes for compact benchmark summaries. +- The v0.6 public evidence pack commits portable JSON and TSV summaries for + three local contract cases plus `GCF_000005845.2` and `GCF_000182925.2`. + The measured public outcomes include one PASS without findings and one + non-blocking WARN with gap-pattern advisories. -Evidence still needed: +Remaining evidence gaps: -- committed benchmark summaries from public assemblies - user feedback from real pipeline authors - broader public assembly evidence runs - real cohort compare-mode examples from public assemblies diff --git a/docs/vision-plan.md b/docs/vision-plan.md index bed45ec..66283e8 100644 --- a/docs/vision-plan.md +++ b/docs/vision-plan.md @@ -119,13 +119,14 @@ Let workflows collect complete QC reports before applying their own stop/go poli Successful report generation should exit with code `0` for PASS, WARN, and FAIL reports. Argument parsing errors should use code `2`; configuration, -input-access, and runtime errors should use code `3`. JSON and TSV remain the -source of truth for `verdict.status`, `gate.status`, and blocking findings. +input-access, runtime, and output-write errors should use code `3`. JSON and TSV +remain the source of truth for `verdict.status`, `gate.status`, and blocking +findings. -Single-file TSV reports should include `input_path` alongside status fields so -workflow engines can route samples without scraping logs or HTML. Existing -workflow examples pinned to v0.5 should retain compatibility handling until a -v0.6 package and container are published. +Single-file TSV reports include `input_path` alongside status fields so workflow +engines can route samples without scraping logs or HTML. FastaGuard v0.6.0 is +published on GitHub, Bioconda, and BioContainers; future releases update +existing integrations after GitHub and package publication. ### v0.7: Transcriptome Profile @@ -249,7 +250,10 @@ time. ## Current Recommendation -The next big release should not be a huge biology expansion yet. +FastaGuard v0.6.0 is the current GitHub, Bioconda, and BioContainers release. +Bioconda serves `linux-64`, `linux-aarch64`, `osx-64`, and `osx-arm64`, and the +published BioContainers tag is `0.6.0--hfa8f182_0`. The next big release should +not be a huge biology expansion yet. Recommended sequence: diff --git a/docs/workflow-readiness.md b/docs/workflow-readiness.md index fbe9976..9cf80d9 100644 --- a/docs/workflow-readiness.md +++ b/docs/workflow-readiness.md @@ -2,12 +2,12 @@ ## Current State -FastaGuard is ready for local workflow use through the published v0.5.0 -Bioconda package and BioContainers image: +FastaGuard is ready for workflow use through the published v0.6.0 Bioconda +package and BioContainers image: ```bash -mamba install -c conda-forge -c bioconda fastaguard=0.5.0 -docker pull quay.io/biocontainers/fastaguard:0.5.0--hfa8f182_0 +mamba install -c conda-forge -c bioconda fastaguard=0.6.0 +docker pull quay.io/biocontainers/fastaguard:0.6.0--hfa8f182_0 ``` The repository includes local starters for: @@ -18,37 +18,36 @@ The repository includes local starters for: - evidence-preserving gate checks through `examples/workflows/check_fastaguard_gate.py` -These are workflow adoption starters. They are not yet an upstream nf-core module. -They are not yet an official Snakemake wrapper. +The nf-core module PR [#12239](https://github.com/nf-core/modules/pull/12239) +merged 2026-08-21. The Snakemake wrapper PR +[#5436](https://github.com/snakemake/snakemake-wrappers/pull/5436) merged +2026-07-27, and autobump PR [#5737](https://github.com/snakemake/snakemake-wrappers/pull/5737) +merged 2026-07-31. The repository starters remain local compatibility +references. -External upstream-style validation was run on 2026-07-03 in dedicated checkouts: +Historical v0.5 upstream-style validation was run on 2026-07-03 in dedicated +checkouts: - `nf-core modules lint fastaguard`: 47 tests passed, 0 warnings, 0 failures. - `nf-core modules test fastaguard --profile conda --once --no-prompts`: all - nf-test cases passed against Bioconda v0.5.0. + nf-test cases passed against the package available at that time. - Snakemake wrapper formatting: `black --check` and `snakefmt --check` passed. - Snakemake wrapper lint: `snakemake --lint --snakefile test/Snakefile` passed. - Snakemake wrapper pytest: `test_wrappers.py::test_fastaguard` passed with - PASS, WARN, FAIL, and invalid FASTA fixtures plus captured `exit_code` - outputs. + PASS, WARN, FAIL, and invalid FASTA fixtures. -The v0.6 source contract removes QC-derived process failures: successfully -written PASS, WARN, and FAIL reports all return exit code `0`. The pinned v0.5 -starters keep their compatibility capture until v0.6 packages and containers -are published. After publication, upstream wrappers can remove that workaround -and route directly from JSON or TSV status fields. +The v0.6 contract removes QC-derived process failures: successfully written +PASS, WARN, and FAIL reports all return exit code `0`. Future releases update +existing integrations after GitHub and package publication; workflow routing +should continue to use JSON or TSV status fields. ## Safe Order 1. Run local repository tests first. -2. Harden the nf-core starter with topic-aware versions, fixtures, and nf-test - starter coverage. -3. Add collect-then-gate examples with `examples/workflows/check_fastaguard_gate.py`. -4. Harden the Snakemake wrapper starter with metadata, fixture tests, and a - starter pin file. -5. Run external `nf-core modules lint`, `nf-core modules test`, and Snakemake - wrapper tests in dedicated upstream checkouts. -6. Open external upstream PRs last. +2. Preserve collect-then-gate examples with + `examples/workflows/check_fastaguard_gate.py`. +3. Verify integration updates against the current upstream test suites after a + GitHub and package publication. ## Integration Pattern @@ -59,11 +58,10 @@ official submission validators. The default workflow pattern is: collect FASTA-level evidence -> apply stop/go policy -> route downstream tools ``` -With the published v0.5 runtime, `--gate pipeline` and `--gate submission` write -JSON, TSV, HTML, and MultiQC-compatible evidence before returning a QC-derived -exit code. The pinned local starters capture that code so the evidence remains -available, then leave stop/go enforcement to a downstream gate step. The -important contract fields are: +With the published v0.6 runtime, `--gate pipeline` and `--gate submission` +write JSON, TSV, HTML, and MultiQC-compatible evidence while successful report +generation returns exit code `0`. Stop/go enforcement belongs to a downstream +gate step. The important contract fields are: - `verdict.status` - `gate.mode` @@ -72,20 +70,22 @@ important contract fields are: - `readiness.categories` - `provenance.input_sha256` -## nf-core Readiness +## nf-core Integration -The local module already carries the expected interface shape: +The merged nf-core module is tracked by +[#12239](https://github.com/nf-core/modules/pull/12239). The local reference +module carries the expected interface shape: - input channel: `tuple val(meta), path(fasta)` -- outputs: HTML, JSON, TSV, MultiQC custom-content JSON, captured exit code, - and versions metadata -- runtime: `bioconda::fastaguard=0.5.0` -- container: `quay.io/biocontainers/fastaguard:0.5.0--hfa8f182_0` +- reports: HTML, JSON, TSV, and MultiQC custom-content JSON +- version metadata on the current nf-core versions topic +- optional CLI arguments supplied by callers through `task.ext.args` +- output prefixes supplied through `task.ext.prefix ?: "${meta.id}"` - starter nf-test fixture layout for PASS, WARN, FAIL, and invalid FASTA cases - topic-aware `versions.yml` output for current nf-core version collection -Before an upstream nf-core module submission, repeat this checklist in a fresh -upstream checkout: +For future updates, repeat this checklist in a fresh upstream checkout after +GitHub and package publication: - regenerate or validate the module against the current `nf-core/tools` template @@ -99,10 +99,12 @@ upstream checkout: - document that FastaGuard remains a FASTA preflight gate, not a replacement for downstream interpretive QC -The upstream submission should keep the module boring: database-free, pinned to -Bioconda/BioContainers, and focused on stable machine-readable outputs. +The module should remain database-free, pinned to Bioconda/BioContainers, and +focused on stable machine-readable outputs. +It should not choose a profile or gate by default. Callers select policy through +`task.ext.args`, and downstream processes enforce it by parsing JSON or TSV. -## Snakemake Readiness +## Snakemake Integration The local wrapper starter already provides: @@ -112,10 +114,11 @@ The local wrapper starter already provides: - `environment.linux-64.pin.txt` as a local starter pin file - a copy-pasteable `Snakefile` - a `test/Snakefile` starter with PASS, WARN, FAIL, and invalid FASTA fixtures -- outputs for HTML, JSON, TSV, MultiQC custom-content JSON, and captured exit - code +- outputs for HTML, JSON, TSV, and MultiQC custom-content JSON -Before an official Snakemake wrapper submission, complete this checklist: +The merged wrapper is tracked by +[#5436](https://github.com/snakemake/snakemake-wrappers/pull/5436). For future +updates, complete this checklist after GitHub and package publication: - regenerate `environment.linux-64.pin.txt` from the wrapper environment if the upstream repository requires a solver-produced pin file @@ -123,8 +126,10 @@ Before an official Snakemake wrapper submission, complete this checklist: - update `test_wrappers.py` so wrapper tests run in the upstream repository - test PASS, WARN, FAIL, and invalid FASTA behavior - ensure the wrapper can handle arbitrary input and output paths -- preserve evidence on blocking FASTA results, either through workflow-specific - output handling or a collect-then-gate wrapper pattern +- keep `wrapper.py` thin, consume optional arguments only through + `params.extra`, and avoid a default profile or gate +- preserve evidence on blocking FASTA results and enforce policy downstream by + parsing JSON or TSV ## Submission Gate Usage diff --git a/examples/nf-core/README.md b/examples/nf-core/README.md index 329a50b..04d34ca 100644 --- a/examples/nf-core/README.md +++ b/examples/nf-core/README.md @@ -1,18 +1,10 @@ -# nf-core Local Module Starter +# nf-core Module Reference -This directory is a starter for a local nf-core-style FastaGuard module. It is not yet an upstream nf-core module. +This directory mirrors the interface of the FastaGuard module merged into +nf-core/modules in [PR #12239](https://github.com/nf-core/modules/pull/12239). +It remains a local compatibility reference for future FastaGuard releases. -See `../../docs/workflow-readiness.md` for the current upstream readiness -checklist before submitting this starter to nf-core. - -Safe local order before upstream submission: - -1. Run repository Python tests that inspect this module layout. -2. Install nf-core/tools, Nextflow, and nf-test in a workflow test environment. -3. Run `nf-core modules lint fastaguard`. -4. Run `nf-core modules test fastaguard`. -5. Port the local starter into an upstream nf-core/modules checkout only after - those checks pass. +See `../../docs/workflow-readiness.md` for the current update checklist. Expected input channel: @@ -20,67 +12,70 @@ Expected input channel: tuple val(meta), path(fasta) ``` -Emitted outputs: +The module emits four QC reports plus version metadata: - `html` - `json` - `tsv` - `mqc` -- `exit_code` -- `versions` +- `versions_fastaguard` on the versions topic The module assumes `fastaguard` is available on `PATH` when run without a -container. The recommended install is: +container. The published v0.6 install and pinned workflow image are: ```bash -mamba install -c conda-forge -c bioconda fastaguard=0.5.0 +mamba install -c conda-forge -c bioconda fastaguard=0.6.0 +docker pull quay.io/biocontainers/fastaguard:0.6.0--hfa8f182_0 ``` -Published BioContainers provides the pinned v0.5 image: +The reusable module does not select a profile or gate. Callers pass optional +CLI arguments through `task.ext.args` and may customize output names through +`task.ext.prefix ?: "${meta.id}"`. For example: -```text -quay.io/biocontainers/fastaguard:0.5.0--hfa8f182_0 +```nextflow +process { + withName: FASTAGUARD { + ext.args = '--profile assembly --gate pipeline' + } +} ``` -The command block is written for the v0.3 assembly gate and runs: +FastaGuard v0.6 returns `0` after successfully writing PASS, WARN, or FAIL +reports. A downstream collect-then-gate step should parse the JSON or TSV and +route on `verdict.status`, `gate.status`, and `gate.blocking_findings`. Process +status is reserved for command, input, runtime, and output-write errors. -```bash -fastaguard sample.fa --profile assembly --gate pipeline +For submission-readiness preflight before official validators, callers can use: + +```nextflow +process { + withName: FASTAGUARD { + ext.args = '--profile assembly --gate submission --submission-target ncbi' + } +} ``` -That gate contract marks duplicate IDs, invalid characters, invalid FASTA -structure, and high-N content as blocking findings. The published v0.5.0 -runtime returns `1` for WARN and `2` for FAIL. This module captures that legacy -status so JSON, HTML, TSV, and MultiQC evidence can be published; a downstream -gate step should then enforce stop/go policy from `gate.status` or the captured -exit code. Runtime errors with status `3` still fail the process. +This remains FASTA-level readiness only and does not replace repository +validators or downstream interpretive QC. -For v0.4 cohort triage, compare mode is a starter pattern: +For cohort triage, `fastaguard compare` remains a separate starter pattern: ```bash fastaguard compare assemblies/*.fa --profile assembly --gate pipeline ``` -For v0.5 submission-readiness preflight before official validators, use the -published v0.5 package or container: - -```bash -fastaguard {input.fasta} --gate submission --submission-target ncbi -``` - -Pipeline authors should route on: - -- `gate.mode` -- `gate.status` -- `gate.blocking_findings` -- `readiness.categories[id=submission]` - -This is local collect-then-gate starter guidance only. It is not yet an -upstream nf-core module submission, and the Snakemake example is not yet an -official wrapper submission. - Example include: ```nextflow include { FASTAGUARD } from './modules/local/fastaguard' ``` + +For a future upstream update, use this safe local order: + +1. Run the repository Python contract tests. +2. Validate a fresh nf-core/modules checkout with the current nf-core/tools, + Nextflow, and nf-test versions. +3. Run `nf-core modules lint fastaguard`. +4. Run `nf-core modules test fastaguard`. +5. Update the existing upstream module only after the FastaGuard release and + package are published. diff --git a/examples/nf-core/modules/local/fastaguard/environment.yml b/examples/nf-core/modules/local/fastaguard/environment.yml index 6786c8e..0448a46 100644 --- a/examples/nf-core/modules/local/fastaguard/environment.yml +++ b/examples/nf-core/modules/local/fastaguard/environment.yml @@ -2,4 +2,4 @@ channels: - conda-forge - bioconda dependencies: - - bioconda::fastaguard=0.5.0 + - bioconda::fastaguard=0.6.0 diff --git a/examples/nf-core/modules/local/fastaguard/main.nf b/examples/nf-core/modules/local/fastaguard/main.nf index 1c21b5c..27e1d4b 100644 --- a/examples/nf-core/modules/local/fastaguard/main.nf +++ b/examples/nf-core/modules/local/fastaguard/main.nf @@ -4,8 +4,8 @@ process FASTAGUARD { conda "${moduleDir}/environment.yml" container "${ workflow.containerEngine in ['singularity', 'apptainer'] && !task.ext.singularity_pull_docker_container ? - 'https://depot.galaxyproject.org/singularity/fastaguard:0.5.0--hfa8f182_0': - 'quay.io/biocontainers/fastaguard:0.5.0--hfa8f182_0' }" + 'https://depot.galaxyproject.org/singularity/fastaguard:0.6.0--hfa8f182_0': + 'quay.io/biocontainers/fastaguard:0.6.0--hfa8f182_0' }" input: tuple val(meta), path(fasta) @@ -15,30 +15,20 @@ process FASTAGUARD { tuple val(meta), path("*.fastaguard.json"), emit: json tuple val(meta), path("*.fastaguard.tsv"), emit: tsv tuple val(meta), path("*.fastaguard_mqc.json"), emit: mqc - tuple val(meta), path("*.fastaguard.exit_code"), emit: exit_code tuple val("${task.process}"), val('fastaguard'), eval('fastaguard --version | cut -d " " -f 2'), emit: versions_fastaguard, topic: versions when: task.ext.when == null || task.ext.when script: - def prefix = task.ext.prefix ?: meta.id - def args = task.ext.args ?: '--profile assembly --gate pipeline' + def prefix = task.ext.prefix ?: "${meta.id}" + def args = task.ext.args ?: '' """ - set +e fastaguard ${fasta} \ ${args} \ --out ${prefix}.fastaguard.html \ --json ${prefix}.fastaguard.json \ --tsv ${prefix}.fastaguard.tsv \ --multiqc ${prefix}.fastaguard_mqc.json - status=\$? - set -e - - printf "%s\\n" "\${status}" > ${prefix}.fastaguard.exit_code - - if [ "\${status}" -eq 3 ]; then - exit "\${status}" - fi """ } diff --git a/examples/nf-core/modules/local/fastaguard/meta.yml b/examples/nf-core/modules/local/fastaguard/meta.yml index 734cc8c..f548958 100644 --- a/examples/nf-core/modules/local/fastaguard/meta.yml +++ b/examples/nf-core/modules/local/fastaguard/meta.yml @@ -72,17 +72,6 @@ output: pattern: "*.fastaguard_mqc.json" ontologies: - edam: http://edamontology.org/format_3464 - exit_code: - - - meta: - type: map - description: | - Groovy Map containing sample information - e.g. [ id:'sample' ] - - "*.fastaguard.exit_code": - type: file - description: FastaGuard CLI exit code for collect-then-gate workflows - pattern: "*.fastaguard.exit_code" - ontologies: [] versions_fastaguard: - - ${task.process}: type: string @@ -105,4 +94,4 @@ topics: type: eval description: The expression to obtain the version of the tool authors: - - "FastaGuard contributors" + - "@ehsanestaji" diff --git a/examples/nf-core/modules/local/fastaguard/tests/data/fail.fa b/examples/nf-core/modules/local/fastaguard/tests/data/fail.fa deleted file mode 100644 index cea2c6b..0000000 --- a/examples/nf-core/modules/local/fastaguard/tests/data/fail.fa +++ /dev/null @@ -1,6 +0,0 @@ ->dup -ACGTACGT ->dup -ACGTACGT ->bad -ACGTXYZ diff --git a/examples/nf-core/modules/local/fastaguard/tests/data/invalid.fa b/examples/nf-core/modules/local/fastaguard/tests/data/invalid.fa deleted file mode 100644 index 717a70d..0000000 --- a/examples/nf-core/modules/local/fastaguard/tests/data/invalid.fa +++ /dev/null @@ -1,3 +0,0 @@ ->empty_record ->next_record -ACGT diff --git a/examples/nf-core/modules/local/fastaguard/tests/data/pass.fa b/examples/nf-core/modules/local/fastaguard/tests/data/pass.fa deleted file mode 100644 index 3d90f7c..0000000 --- a/examples/nf-core/modules/local/fastaguard/tests/data/pass.fa +++ /dev/null @@ -1,2 +0,0 @@ ->clean -ACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGT diff --git a/examples/nf-core/modules/local/fastaguard/tests/data/warn.fa b/examples/nf-core/modules/local/fastaguard/tests/data/warn.fa deleted file mode 100644 index 5642616..0000000 --- a/examples/nf-core/modules/local/fastaguard/tests/data/warn.fa +++ /dev/null @@ -1,4 +0,0 @@ ->long -ACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGT ->tiny -ACGT diff --git a/examples/nf-core/modules/local/fastaguard/tests/main.nf.test b/examples/nf-core/modules/local/fastaguard/tests/main.nf.test index abc00aa..9fb4d5d 100644 --- a/examples/nf-core/modules/local/fastaguard/tests/main.nf.test +++ b/examples/nf-core/modules/local/fastaguard/tests/main.nf.test @@ -3,6 +3,7 @@ nextflow_process { name "Test FASTAGUARD" script "../main.nf" process "FASTAGUARD" + config "./nextflow.config" tag "modules" tag "modules_nfcore" @@ -13,23 +14,25 @@ nextflow_process { when { process { """ + def fasta = file(workDir.resolve("pass.fa").toString()) + fasta.text = [ + ">clean", + ("ACGT" * 60) + ].join("\n") + "\n" + input[0] = [ [ id:'pass' ], - file("${moduleDir}/tests/data/pass.fa", checkIfExists: true) + fasta ] """ } } then { - assert process.success - assert file(process.out.html[0][1]).name.endsWith(".fastaguard.html") - assert file(process.out.json[0][1]).name.endsWith(".fastaguard.json") - assert file(process.out.tsv[0][1]).name.endsWith(".fastaguard.tsv") - assert file(process.out.mqc[0][1]).name.endsWith(".fastaguard_mqc.json") - assert file(process.out.exit_code[0][1]).name.endsWith(".fastaguard.exit_code") - assert file(process.out.exit_code[0][1]).text.trim() == "0" - assert process.out.versions_fastaguard + assertAll( + { assert process.success }, + { assert snapshot(sanitizeOutput(process.out, unstableKeys: ["html", "json", "tsv", "mqc"])).match() } + ) } } @@ -37,19 +40,27 @@ nextflow_process { when { process { """ + def fasta = file(workDir.resolve("warn.fa").toString()) + fasta.text = [ + ">long", + ("ACGT" * 60), + ">tiny", + "ACGT" + ].join("\n") + "\n" + input[0] = [ [ id:'warn' ], - file("${moduleDir}/tests/data/warn.fa", checkIfExists: true) + fasta ] """ } } then { - assert process.success - assert process.out.json - assert process.out.mqc - assert file(process.out.exit_code[0][1]).text.trim() == "1" + assertAll( + { assert process.success }, + { assert snapshot(sanitizeOutput(process.out, unstableKeys: ["html", "json", "tsv", "mqc"])).match() } + ) } } @@ -57,19 +68,29 @@ nextflow_process { when { process { """ + def fasta = file(workDir.resolve("fail.fa").toString()) + fasta.text = [ + ">dup", + "ACGTACGT", + ">dup", + "ACGTACGT", + ">bad", + "ACGTXYZ" + ].join("\n") + "\n" + input[0] = [ [ id:'fail' ], - file("${moduleDir}/tests/data/fail.fa", checkIfExists: true) + fasta ] """ } } then { - assert process.success - assert process.out.json - assert process.out.mqc - assert file(process.out.exit_code[0][1]).text.trim() == "2" + assertAll( + { assert process.success }, + { assert snapshot(sanitizeOutput(process.out, unstableKeys: ["html", "json", "tsv", "mqc"])).match() } + ) } } @@ -77,19 +98,26 @@ nextflow_process { when { process { """ + def fasta = file(workDir.resolve("invalid.fa").toString()) + fasta.text = [ + ">empty_record", + ">next_record", + "ACGT" + ].join("\n") + "\n" + input[0] = [ [ id:'invalid' ], - file("${moduleDir}/tests/data/invalid.fa", checkIfExists: true) + fasta ] """ } } then { - assert process.success - assert process.out.json - assert process.out.mqc - assert file(process.out.exit_code[0][1]).text.trim() == "2" + assertAll( + { assert process.success }, + { assert snapshot(sanitizeOutput(process.out, unstableKeys: ["html", "json", "tsv", "mqc"])).match() } + ) } } } diff --git a/examples/nf-core/modules/local/fastaguard/tests/main.nf.test.snap b/examples/nf-core/modules/local/fastaguard/tests/main.nf.test.snap new file mode 100644 index 0000000..27186f6 --- /dev/null +++ b/examples/nf-core/modules/local/fastaguard/tests/main.nf.test.snap @@ -0,0 +1,202 @@ +{ + "invalid FASTA is represented in the evidence path": { + "content": [ + { + "html": [ + [ + { + "id": "invalid" + }, + "invalid.fastaguard.html" + ] + ], + "json": [ + [ + { + "id": "invalid" + }, + "invalid.fastaguard.json" + ] + ], + "mqc": [ + [ + { + "id": "invalid" + }, + "invalid.fastaguard_mqc.json" + ] + ], + "tsv": [ + [ + { + "id": "invalid" + }, + "invalid.fastaguard.tsv" + ] + ], + "versions_fastaguard": [ + [ + "FASTAGUARD", + "fastaguard", + "0.6.0" + ] + ] + } + ], + "timestamp": "2026-07-23T12:35:19.837744", + "meta": { + "nf-test": "0.9.5", + "nextflow": "25.04.6" + } + }, + "fail FASTA preserves reports for gate review": { + "content": [ + { + "html": [ + [ + { + "id": "fail" + }, + "fail.fastaguard.html" + ] + ], + "json": [ + [ + { + "id": "fail" + }, + "fail.fastaguard.json" + ] + ], + "mqc": [ + [ + { + "id": "fail" + }, + "fail.fastaguard_mqc.json" + ] + ], + "tsv": [ + [ + { + "id": "fail" + }, + "fail.fastaguard.tsv" + ] + ], + "versions_fastaguard": [ + [ + "FASTAGUARD", + "fastaguard", + "0.6.0" + ] + ] + } + ], + "timestamp": "2026-07-23T12:34:59.917502", + "meta": { + "nf-test": "0.9.5", + "nextflow": "25.04.6" + } + }, + "warn FASTA preserves reports": { + "content": [ + { + "html": [ + [ + { + "id": "warn" + }, + "warn.fastaguard.html" + ] + ], + "json": [ + [ + { + "id": "warn" + }, + "warn.fastaguard.json" + ] + ], + "mqc": [ + [ + { + "id": "warn" + }, + "warn.fastaguard_mqc.json" + ] + ], + "tsv": [ + [ + { + "id": "warn" + }, + "warn.fastaguard.tsv" + ] + ], + "versions_fastaguard": [ + [ + "FASTAGUARD", + "fastaguard", + "0.6.0" + ] + ] + } + ], + "timestamp": "2026-07-23T12:34:47.135258", + "meta": { + "nf-test": "0.9.5", + "nextflow": "25.04.6" + } + }, + "pass FASTA emits all reports": { + "content": [ + { + "html": [ + [ + { + "id": "pass" + }, + "pass.fastaguard.html" + ] + ], + "json": [ + [ + { + "id": "pass" + }, + "pass.fastaguard.json" + ] + ], + "mqc": [ + [ + { + "id": "pass" + }, + "pass.fastaguard_mqc.json" + ] + ], + "tsv": [ + [ + { + "id": "pass" + }, + "pass.fastaguard.tsv" + ] + ], + "versions_fastaguard": [ + [ + "FASTAGUARD", + "fastaguard", + "0.6.0" + ] + ] + } + ], + "timestamp": "2026-07-23T12:34:36.226584", + "meta": { + "nf-test": "0.9.5", + "nextflow": "25.04.6" + } + } +} diff --git a/examples/nf-core/modules/local/fastaguard/tests/nextflow.config b/examples/nf-core/modules/local/fastaguard/tests/nextflow.config new file mode 100644 index 0000000..2457336 --- /dev/null +++ b/examples/nf-core/modules/local/fastaguard/tests/nextflow.config @@ -0,0 +1,5 @@ +process { + withName: 'FASTAGUARD' { + ext.args = '--profile assembly --gate pipeline' + } +} diff --git a/examples/snakemake/wrapper/README.md b/examples/snakemake/wrapper/README.md index eb14633..3748aed 100644 --- a/examples/snakemake/wrapper/README.md +++ b/examples/snakemake/wrapper/README.md @@ -1,74 +1,68 @@ -# Snakemake Wrapper Starter +# Snakemake Wrapper Reference -This is a local wrapper-style starter for FastaGuard. It assumes `fastaguard` is available on `PATH`. +This directory mirrors the FastaGuard wrapper merged into +snakemake-wrappers in +[PR #5436](https://github.com/snakemake/snakemake-wrappers/pull/5436). It is a +local compatibility reference and copy-paste starter for future releases. -See `../../../docs/workflow-readiness.md` for the current upstream readiness -checklist before submitting this starter as an official Snakemake wrapper. +See `../../../docs/workflow-readiness.md` for the current update checklist. -Published Bioconda provides v0.5.0: +Published Bioconda and BioContainers provide FastaGuard v0.6.0: ```bash -mamba install -c conda-forge -c bioconda fastaguard=0.5.0 +mamba install -c conda-forge -c bioconda fastaguard=0.6.0 +docker pull quay.io/biocontainers/fastaguard:0.6.0--hfa8f182_0 ``` -Run from this directory with a `sample.fa` input: +Run the local starter from this directory with a `sample.fa` input: ```bash snakemake -s Snakefile --cores 1 ``` -The wrapper command uses the v0.3 assembly gate: +The starter passes its example policy through the wrapper's only optional +interface, `params.extra`: -```bash -fastaguard sample.fa --profile assembly --gate pipeline +```python +params: + extra="--profile assembly --gate pipeline" ``` -That gate marks duplicate IDs, invalid characters, invalid FASTA structure, and -high-N content as blocking findings. GC and length outliers remain advisory -unless explicitly added with `--fail-on`. The wrapper captures FastaGuard's -status in `fastaguard.exit_code` so workflows can route on PASS/WARN/FAIL while -retaining the JSON/HTML evidence. Tool-error status `3` still fails the job. +The reusable `wrapper.py` itself has no default profile or gate. It emits +exactly four reports: -The wrapper also includes a v0.5 Conda environment: +- `fastaguard_report.html` +- `fastaguard.json` +- `fastaguard.tsv` +- `fastaguard_mqc.json` -```bash -snakemake -s Snakefile --cores 1 --use-conda -``` +FastaGuard v0.6 returns `0` after successfully writing PASS, WARN, or FAIL +reports. Apply workflow stop/go policy downstream by parsing the JSON or TSV, +especially `verdict.status`, `gate.status`, and `gate.blocking_findings`. -For v0.5 submission-readiness preflight before official validators, use the -published v0.5 package or container: +The wrapper includes a v0.6 Conda environment: ```bash -fastaguard {input.fasta} --gate submission --submission-target ncbi +snakemake -s Snakefile --cores 1 --use-conda ``` -Pipeline authors should route on: - -- `gate.mode` -- `gate.status` -- `gate.blocking_findings` -- `readiness.categories[id=submission]` +For submission-readiness preflight before official validators, pass a different +`params.extra` value: -For containerized workflow runs, the latest pinned BioContainers image is: - -```text -quay.io/biocontainers/fastaguard:0.5.0--hfa8f182_0 +```python +params: + extra="--profile assembly --gate submission --submission-target ncbi" ``` -Use this safe local order before upstream submission: - -1. Run repository Python tests that inspect this wrapper layout. -2. Install Snakemake in a workflow test environment. -3. Run `snakemake -s test/Snakefile --cores 1 --use-conda`. -4. Generate a real upstream `environment.linux-64.pin.txt` if the upstream - wrapper repository requires a solver-produced pin file. -5. Adapt `test/test_wrappers.py` into the upstream wrapper repository test - harness. +This remains FASTA-level readiness only and does not replace repository +validators or downstream interpretive QC. -The wrapper emits: +For a future upstream update, use this safe local order: -- `fastaguard_report.html` -- `fastaguard.json` -- `fastaguard.tsv` -- `fastaguard_mqc.json` -- `fastaguard.exit_code` +1. Run the repository Python contract tests and the local copy-paste starter. +2. Install Snakemake in an isolated workflow test environment. +3. Run the upstream `test/Snakefile` and `test_wrappers.py` suite. +4. Regenerate `environment.linux-64.pin.txt` when the upstream repository + requires a solver-produced pin file. +5. Submit an autobump or manual update only when the published dependency or + wrapper interface needs to change. diff --git a/examples/snakemake/wrapper/Snakefile b/examples/snakemake/wrapper/Snakefile index 1b22b88..78728ac 100644 --- a/examples/snakemake/wrapper/Snakefile +++ b/examples/snakemake/wrapper/Snakefile @@ -6,14 +6,11 @@ rule fastaguard_wrapped: json="fastaguard.json", tsv="fastaguard.tsv", multiqc="fastaguard_mqc.json", - exit_code="fastaguard.exit_code", log: "fastaguard.log", conda: "environment.yaml" params: - profile="assembly", - gate="pipeline", - extra="", + extra="--profile assembly --gate pipeline", wrapper: "file:." diff --git a/examples/snakemake/wrapper/environment.linux-64.pin.txt b/examples/snakemake/wrapper/environment.linux-64.pin.txt index 0c6415c..cb89752 100644 --- a/examples/snakemake/wrapper/environment.linux-64.pin.txt +++ b/examples/snakemake/wrapper/environment.linux-64.pin.txt @@ -1,9 +1,9 @@ # This file may be used to create an environment using: # $ conda create --name --file # platform: linux-64 -# created by mamba dry-run with CONDA_OVERRIDE_GLIBC=2.17 +# created-by: conda 26.1.1 @EXPLICIT https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda#faac990cb7aedc7f3a2224f2c9b0c26c https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda#a9f577daf3de00bca7c3c76c0ecbd1de https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda#57736f29cc2b0ec0b6c2952d3f101b6a -https://conda.anaconda.org/bioconda/linux-64/fastaguard-0.5.0-hfa8f182_0.conda#0a395c874ce246dbdbe476ae2c96df78 +https://conda.anaconda.org/bioconda/linux-64/fastaguard-0.6.0-hfa8f182_0.conda#d19a3733e08a394af1921501c9dbccad diff --git a/examples/snakemake/wrapper/environment.yaml b/examples/snakemake/wrapper/environment.yaml index 220c3b3..b72c5f8 100644 --- a/examples/snakemake/wrapper/environment.yaml +++ b/examples/snakemake/wrapper/environment.yaml @@ -1,5 +1,6 @@ channels: - conda-forge - bioconda + - nodefaults dependencies: - - fastaguard=0.5.0 + - fastaguard=0.6.0 diff --git a/examples/snakemake/wrapper/meta.yaml b/examples/snakemake/wrapper/meta.yaml index f14a613..6af3621 100644 --- a/examples/snakemake/wrapper/meta.yaml +++ b/examples/snakemake/wrapper/meta.yaml @@ -2,7 +2,7 @@ name: fastaguard description: FASTA preflight QC for assembly and submission-readiness workflows. url: https://github.com/ehsanestaji/FastaGuard authors: - - FastaGuard contributors + - Ehsan ESTAJI input: fasta: Assembly FASTA file to validate before downstream QC. output: @@ -10,11 +10,8 @@ output: json: Machine-readable FastaGuard report. tsv: Tabular FastaGuard summary. multiqc: MultiQC custom-content JSON. - exit_code: Captured FastaGuard process status for workflow routing. params: - profile: FastaGuard profile, normally assembly. - gate: FastaGuard gate mode, normally pipeline or submission. - extra: Additional command-line arguments passed to FastaGuard. + extra: Additional command-line arguments passed to FastaGuard, including profile and gate options. notes: > FastaGuard is a FASTA preflight layer. It should run before downstream interpretive QC tools and official validators, not replace them. diff --git a/examples/snakemake/wrapper/test/Snakefile b/examples/snakemake/wrapper/test/Snakefile index 44da6fa..eb1abf9 100644 --- a/examples/snakemake/wrapper/test/Snakefile +++ b/examples/snakemake/wrapper/test/Snakefile @@ -6,13 +6,10 @@ rule fastaguard_pass: json="pass/fastaguard.json", tsv="pass/fastaguard.tsv", multiqc="pass/fastaguard_mqc.json", - exit_code="pass/fastaguard.exit_code", log: "logs/pass.fastaguard.log", params: - profile="assembly", - gate="pipeline", - extra="", + extra="--profile assembly --gate pipeline", wrapper: "master/bio/fastaguard" @@ -25,13 +22,10 @@ rule fastaguard_warn: json="warn/fastaguard.json", tsv="warn/fastaguard.tsv", multiqc="warn/fastaguard_mqc.json", - exit_code="warn/fastaguard.exit_code", log: "logs/warn.fastaguard.log", params: - profile="assembly", - gate="pipeline", - extra="", + extra="--profile assembly --gate pipeline", wrapper: "master/bio/fastaguard" @@ -44,13 +38,10 @@ rule fastaguard_fail: json="fail/fastaguard.json", tsv="fail/fastaguard.tsv", multiqc="fail/fastaguard_mqc.json", - exit_code="fail/fastaguard.exit_code", log: "logs/fail.fastaguard.log", params: - profile="assembly", - gate="pipeline", - extra="", + extra="--profile assembly --gate pipeline", wrapper: "master/bio/fastaguard" @@ -63,12 +54,9 @@ rule fastaguard_invalid: json="invalid/fastaguard.json", tsv="invalid/fastaguard.tsv", multiqc="invalid/fastaguard_mqc.json", - exit_code="invalid/fastaguard.exit_code", log: "logs/invalid.fastaguard.log", params: - profile="assembly", - gate="pipeline", - extra="", + extra="--profile assembly --gate pipeline", wrapper: "master/bio/fastaguard" diff --git a/examples/snakemake/wrapper/test/test_wrappers.py b/examples/snakemake/wrapper/test/test_wrappers.py index 99f67e0..145f646 100644 --- a/examples/snakemake/wrapper/test/test_wrappers.py +++ b/examples/snakemake/wrapper/test/test_wrappers.py @@ -10,9 +10,5 @@ def test_fastaguard(run): "warn/fastaguard.json", "fail/fastaguard.json", "invalid/fastaguard.json", - "pass/fastaguard.exit_code", - "warn/fastaguard.exit_code", - "fail/fastaguard.exit_code", - "invalid/fastaguard.exit_code", ], ) diff --git a/examples/snakemake/wrapper/wrapper.py b/examples/snakemake/wrapper/wrapper.py index 0416ffc..cb9554b 100644 --- a/examples/snakemake/wrapper/wrapper.py +++ b/examples/snakemake/wrapper/wrapper.py @@ -1,23 +1,14 @@ from snakemake.shell import shell -profile = snakemake.params.get("profile", "assembly") -gate = snakemake.params.get("gate", "pipeline") extra = snakemake.params.get("extra", "") log = snakemake.log_fmt_shell(stdout=True, stderr=True) shell( - "set +e; " "fastaguard {snakemake.input.fasta} " - "--profile {profile} " - "--gate {gate} " "--out {snakemake.output.html} " "--json {snakemake.output.json} " "--tsv {snakemake.output.tsv} " "--multiqc {snakemake.output.multiqc} " "{extra} " - "{log}; " - 'status="$?"; ' - "set -e; " - 'printf "%s\\n" "$status" > {snakemake.output.exit_code}; ' - 'if [ "$status" -eq 3 ]; then exit "$status"; fi' + "{log}" ) diff --git a/examples/snakemake/wrapper/wrapper/fastaguard/wrapper.py b/examples/snakemake/wrapper/wrapper/fastaguard/wrapper.py deleted file mode 100644 index 0416ffc..0000000 --- a/examples/snakemake/wrapper/wrapper/fastaguard/wrapper.py +++ /dev/null @@ -1,23 +0,0 @@ -from snakemake.shell import shell - -profile = snakemake.params.get("profile", "assembly") -gate = snakemake.params.get("gate", "pipeline") -extra = snakemake.params.get("extra", "") -log = snakemake.log_fmt_shell(stdout=True, stderr=True) - -shell( - "set +e; " - "fastaguard {snakemake.input.fasta} " - "--profile {profile} " - "--gate {gate} " - "--out {snakemake.output.html} " - "--json {snakemake.output.json} " - "--tsv {snakemake.output.tsv} " - "--multiqc {snakemake.output.multiqc} " - "{extra} " - "{log}; " - 'status="$?"; ' - "set -e; " - 'printf "%s\\n" "$status" > {snakemake.output.exit_code}; ' - 'if [ "$status" -eq 3 ]; then exit "$status"; fi' -) diff --git a/examples/workflows/check_fastaguard_gate.py b/examples/workflows/check_fastaguard_gate.py index 4aa4660..d324498 100755 --- a/examples/workflows/check_fastaguard_gate.py +++ b/examples/workflows/check_fastaguard_gate.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +"""Apply downstream workflow policy after FastaGuard has written its reports.""" + import json import sys from pathlib import Path @@ -31,7 +33,7 @@ def main(argv): print("missing or unsupported gate.status", file=sys.stderr) return 3 - print(f"FastaGuard gate status: {status}") + print(f"FastaGuard report gate status: {status}") return EXIT_BY_STATUS[status] diff --git a/integrations/multiqc/README.md b/integrations/multiqc/README.md index 43148d6..62d6cba 100644 --- a/integrations/multiqc/README.md +++ b/integrations/multiqc/README.md @@ -1,13 +1,15 @@ -# MultiQC FastaGuard Module Starter +# MultiQC FastaGuard Module -This directory contains a dedicated MultiQC plugin starter for FastaGuard. +This directory contains the unpublished `multiqc-fastaguard` plugin package. +Its package version remains `0.1.0` until the initial release is approved and +published. FastaGuard already emits MultiQC custom-content JSON as `fastaguard_mqc.json`. -This plugin is the next step: a native module that can add FastaGuard verdicts, -gate status, readiness status, submission readiness, and key assembly preflight -metrics directly to MultiQC reports. +The native module adds FastaGuard verdicts, gate status, readiness status, +submission readiness, and key assembly preflight metrics directly to MultiQC +reports. -## Local Install +## Development Install From this directory: @@ -22,31 +24,75 @@ MultiQC run. The plugin looks for `fastaguard_mqc.json` and `*.fastaguard_mqc.json` files and reads the same custom content contract emitted by the CLI. -## Verification +## Build and Isolated Verification -Run the plugin against example reports in strict mode: +Create a disposable environment outside the repository, build both distribution +formats, install the wheel, and run only the FastaGuard module in strict mode: ```bash -cd integrations/multiqc -python -m pip install -e . -cd ../.. -multiqc --strict examples/reports +validation_dir="$(mktemp -d)" +python3 -m venv "$validation_dir/venv" +"$validation_dir/venv/bin/python" -m pip install build +"$validation_dir/venv/bin/python" -m build integrations/multiqc +"$validation_dir/venv/bin/python" -m pip install \ + integrations/multiqc/dist/multiqc_fastaguard-0.1.0-py3-none-any.whl +"$validation_dir/venv/bin/multiqc" \ + --strict \ + --module fastaguard \ + --outdir "$validation_dir/report" \ + examples/reports +``` + +The strict report data at +`$validation_dir/report/multiqc_data/multiqc_fastaguard.txt` should contain the +committed pass and fail examples. It includes `verdict`, `gate_mode`, +`gate_status`, `readiness_status`, `submission_target`, `submission_status`, +unsafe and long identifier counts, duplicate first-token ID counts, and +gap-like N-run counts. + +The release test builds the distributions, installs that exact wheel into its +own disposable environment, and runs the same strict command. Set +`FASTAGUARD_MULTIQC_VERSION` to exercise a specific supported MultiQC version: + +```bash +FASTAGUARD_MULTIQC_VERSION=1.28 python3 -m pytest -q \ + tests/python/test_multiqc_plugin.py +FASTAGUARD_MULTIQC_VERSION=1.35 python3 -m pytest -q \ + tests/python/test_multiqc_plugin.py ``` -To isolate just the native module while troubleshooting: +The compatibility gate covers the declared Python 3.10 floor and the current +Python 3.14 release against both MultiQC 1.28 and 1.35. The wheel also installs +this README under `share/doc/multiqc-fastaguard/README.md`; the source +distribution includes it at the archive root. + +After reviewing the output, remove the exact temporary directory and the +untracked local build artifacts: ```bash -multiqc --strict --module fastaguard examples/reports +test -n "$validation_dir" && rm -rf -- "$validation_dir" +rm -rf -- integrations/multiqc/dist ``` -## Current Scope +## Scope - Parse FastaGuard custom-content JSON. - Add verdict and summary metrics to the MultiQC general stats table. -- Add one FastaGuard summary table section with gate, readiness, and v0.5 +- Add one FastaGuard summary table section with gate, readiness, and submission fields including `submission_target`, `submission_status`, unsafe identifier counts, long identifier counts, duplicate first-token ID counts, and gap-like N-run counts. Keep the module compact. MultiQC should summarize many FastaGuard reports, not replicate every field from the full FastaGuard HTML report. + +## Publication Boundary + +This phase validates the package locally only. It does not upload a distribution +to TestPyPI or PyPI and does not open or update a MultiQC issue or pull request. + +After separate publication approval, the release owner can rebuild from a clean +checkout, inspect and check the wheel and source distribution, test the intended +index upload, publish version `0.1.0`, and verify the installed package from the +index. Any upstream MultiQC issue or pull request should be prepared only after +the public package location and installation instructions are stable. diff --git a/integrations/multiqc/pyproject.toml b/integrations/multiqc/pyproject.toml index b92c570..5a0a61e 100644 --- a/integrations/multiqc/pyproject.toml +++ b/integrations/multiqc/pyproject.toml @@ -13,6 +13,11 @@ dependencies = [ "multiqc>=1.28", ] +[project.urls] +Homepage = "https://github.com/ehsanestaji/FastaGuard" +Repository = "https://github.com/ehsanestaji/FastaGuard" +Issues = "https://github.com/ehsanestaji/FastaGuard/issues" + [project.entry-points."multiqc.modules.v1"] fastaguard = "fastaguard_multiqc:MultiqcModule" @@ -21,3 +26,6 @@ before_config = "fastaguard_multiqc.parser:register_search_patterns" [tool.hatch.build.targets.wheel] packages = ["src/fastaguard_multiqc"] + +[tool.hatch.build.targets.wheel.shared-data] +"README.md" = "share/doc/multiqc-fastaguard/README.md" diff --git a/integrations/multiqc/src/fastaguard_multiqc/multiqc_module.py b/integrations/multiqc/src/fastaguard_multiqc/multiqc_module.py index 1dea8e4..5f2682c 100644 --- a/integrations/multiqc/src/fastaguard_multiqc/multiqc_module.py +++ b/integrations/multiqc/src/fastaguard_multiqc/multiqc_module.py @@ -41,7 +41,6 @@ def __init__(self): "title": "FastaGuard FASTA preflight summary", }, ), - statuses=self._statuses(data_by_sample), ) self.write_data_file(data_by_sample, "multiqc_fastaguard") @@ -55,15 +54,6 @@ def _load_reports(self) -> dict[str, dict]: self.add_data_source(file_match, sample_name) return data_by_sample - @staticmethod - def _statuses(data_by_sample: dict[str, dict]) -> dict[str, list[str]]: - statuses = {"pass": [], "warn": [], "fail": []} - for sample_name, row in data_by_sample.items(): - verdict = str(row.get("verdict", "")).lower() - if verdict in statuses: - statuses[verdict].append(sample_name) - return statuses - @staticmethod def _general_stats_data(data_by_sample: dict[str, dict]) -> dict[str, dict]: visible_fields = ( diff --git a/packaging/bioconda/README.md b/packaging/bioconda/README.md index f362807..6352e08 100644 --- a/packaging/bioconda/README.md +++ b/packaging/bioconda/README.md @@ -1,23 +1,23 @@ # Bioconda Recipe -Upstream Bioconda currently publishes FastaGuard v0.5.0. +Upstream Bioconda currently publishes FastaGuard v0.6.0. The recipe has been merged into `bioconda/bioconda-recipes` as `recipes/fastaguard/`, and the current published package is available from Bioconda: ```bash -mamba install -c conda-forge -c bioconda fastaguard=0.5.0 +mamba install -c conda-forge -c bioconda fastaguard=0.6.0 ``` BioContainers publishes the pinned workflow image: ```bash -docker pull quay.io/biocontainers/fastaguard:0.5.0--hfa8f182_0 +docker pull quay.io/biocontainers/fastaguard:0.6.0--hfa8f182_0 ``` -This local recipe directory mirrors the FastaGuard v0.5.0 Bioconda recipe. The -v0.5.0 GitHub source archive is published and `meta.yaml` includes the real +This local recipe directory mirrors the FastaGuard v0.6.0 Bioconda recipe. The +v0.6.0 GitHub source archive is published and `meta.yaml` includes the real archive SHA256. ## Local Checks diff --git a/packaging/bioconda/meta.yaml b/packaging/bioconda/meta.yaml index c91b0fa..9f4a668 100644 --- a/packaging/bioconda/meta.yaml +++ b/packaging/bioconda/meta.yaml @@ -1,5 +1,5 @@ {% set name = "fastaguard" %} -{% set version = "0.5.0" %} +{% set version = "0.6.0" %} package: name: {{ name|lower }} @@ -7,7 +7,7 @@ package: source: url: https://github.com/ehsanestaji/FastaGuard/archive/refs/tags/v{{ version }}.tar.gz - sha256: b3de60c83cb570bb90e894c262effe4092101b1655e5c64023445078ee2c5971 + sha256: b57e967ccaa03ef5e70c14b96247a644750d8bd492360633988f2f53fe84184b build: number: 0 diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..f6107e1 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,4 @@ +build==1.5.0 +jsonschema==4.26.0 +pytest==9.1.1 +PyYAML==6.0.3 diff --git a/scripts/collect_evidence.py b/scripts/collect_evidence.py index 784507e..7020dd2 100755 --- a/scripts/collect_evidence.py +++ b/scripts/collect_evidence.py @@ -6,6 +6,7 @@ import argparse import csv import gzip +import hashlib import json import platform import shutil @@ -20,6 +21,15 @@ ROOT = Path(__file__).resolve().parents[1] LOCAL_SUCCESS_CODES = {0, 1, 2} +RUNTIME_CONTEXT = ( + "Elapsed seconds are contextual measurements from one local run, " + "not cross-platform performance guarantees." +) +PROVENANCE_SCOPE = ( + "source_commit records verified release-tag and source-tree equality. " + "fastaguard_version and binary_sha256 record the observed executable. " + "Binary-to-source reproducibility was not independently attested." +) SUMMARY_COLUMNS = [ "id", "label", @@ -45,6 +55,44 @@ "finding_count", "top_findings", ] +PORTABLE_METADATA_COLUMNS = [ + "schema_version", + "generated_at", + "fastaguard_version", + "source_commit", + "binary_sha256", + "provenance_scope", + "binary_to_source_reproducibility_attested", + "platform", + "python", + "runtime_context", +] +PORTABLE_CASE_COLUMNS = [ + "id", + "label", + "category", + "source", + "accession", + "source_url", + "evidence_role", + "expected_scale", + "downstream_route", + "input_bytes", + "input_sha256", + "elapsed_seconds", + "exit_code", + "verdict", + "gate_mode", + "gate_status", + "gate_blocking_findings", + "sequence_count", + "total_length", + "n50", + "n90", + "finding_ids", + "finding_count", +] +PORTABLE_SUMMARY_COLUMNS = PORTABLE_METADATA_COLUMNS + PORTABLE_CASE_COLUMNS def main() -> int: @@ -52,6 +100,9 @@ def main() -> int: binary = args.binary.resolve() out_dir = args.out_dir.resolve() manifest_path = args.manifest.resolve() + portable_summary_dir = ( + args.portable_summary_dir.resolve() if args.portable_summary_dir else None + ) if not binary.exists(): raise SystemExit(f"FastaGuard binary not found: {binary}") @@ -66,10 +117,11 @@ def main() -> int: if not args.local_only: cases.extend(public_cases(manifest_path, out_dir)) + version_output = fastaguard_version(binary) summary = { "schema_version": 1, "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), - "fastaguard_version": fastaguard_version(binary), + "fastaguard_version": version_output, "git_commit": git_commit(), "platform": platform.platform(), "python": platform.python_version(), @@ -77,6 +129,12 @@ def main() -> int: "local_only": args.local_only, "cases": [], } + portable_source_commit = None + portable_binary_sha256 = None + if portable_summary_dir is not None: + version = portable_fastaguard_version(version_output) + portable_source_commit = release_source_commit(version) + portable_binary_sha256 = sha256_file(binary) for case in cases: if case["source"] == "public_ncbi": @@ -85,6 +143,13 @@ def main() -> int: summary["cases"].append(result) write_summary(out_dir, summary) + if portable_summary_dir is not None: + write_portable_summary( + portable_summary_dir, + summary, + source_commit=portable_source_commit, + binary_sha256=portable_binary_sha256, + ) print(json.dumps(summary, indent=2, sort_keys=True)) return 0 @@ -105,6 +170,14 @@ def parse_args() -> argparse.Namespace: default=Path("target/evidence/v0.3"), help="Directory for evidence outputs and summaries.", ) + parser.add_argument( + "--portable-summary-dir", + type=Path, + help=( + "Write normalized evidence_summary.json and evidence_summary.tsv " + "without local paths or commands under this directory." + ), + ) parser.add_argument( "--manifest", type=Path, @@ -217,8 +290,9 @@ def write_synthetic_fasta(path: Path) -> None: def gzip_fasta(source: Path, destination: Path) -> None: - with source.open("rb") as src, gzip.open(destination, "wb") as dst: - shutil.copyfileobj(src, dst) + with source.open("rb") as src, destination.open("wb") as compressed: + with gzip.GzipFile(filename="", mode="wb", fileobj=compressed, mtime=0) as dst: + shutil.copyfileobj(src, dst) def prepare_public_input(case: dict[str, Any]) -> None: @@ -305,7 +379,8 @@ def run_case(binary: Path, case: dict[str, Any]) -> dict[str, Any]: report = json.loads(json_path.read_text()) summary = report["summary"] findings = report.get("findings", []) - top_findings = [finding.get("id", "unknown") for finding in findings[:5]] + finding_ids = [finding.get("id", "unknown") for finding in findings] + top_findings = finding_ids[:5] gate = required_mapping(report, "gate", case["id"]) provenance = required_mapping(report, "provenance", case["id"]) gate_mode = required_value(gate, "mode", "gate.mode", case["id"]) @@ -355,6 +430,7 @@ def run_case(binary: Path, case: dict[str, Any]) -> dict[str, Any]: "n50": summary["n50"], "n90": summary["n90"], "finding_count": len(findings), + "finding_ids": finding_ids, "top_findings": top_findings, "command": " ".join(command), "artifacts": { @@ -409,6 +485,53 @@ def write_summary(out_dir: Path, summary: dict[str, Any]) -> None: writer.writerow(row) +def write_portable_summary( + out_dir: Path, + summary: dict[str, Any], + *, + source_commit: str, + binary_sha256: str, +) -> None: + out_dir.mkdir(parents=True, exist_ok=True) + portable = { + "schema_version": summary["schema_version"], + "generated_at": summary["generated_at"], + "fastaguard_version": portable_fastaguard_version( + summary["fastaguard_version"] + ), + "source_commit": source_commit, + "binary_sha256": binary_sha256, + "provenance_scope": PROVENANCE_SCOPE, + "binary_to_source_reproducibility_attested": False, + "platform": summary["platform"], + "python": summary["python"], + "runtime_context": RUNTIME_CONTEXT, + "cases": [portable_case(case) for case in summary["cases"]], + } + json_path = out_dir / "evidence_summary.json" + tsv_path = out_dir / "evidence_summary.tsv" + json_path.write_text(json.dumps(portable, indent=2, sort_keys=True) + "\n") + + metadata = {key: portable[key] for key in PORTABLE_METADATA_COLUMNS} + with tsv_path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter( + handle, + fieldnames=PORTABLE_SUMMARY_COLUMNS, + delimiter="\t", + lineterminator="\n", + ) + writer.writeheader() + for case in portable["cases"]: + row = {**metadata, **case} + row["binary_to_source_reproducibility_attested"] = "false" + row["finding_ids"] = ",".join(case["finding_ids"]) + writer.writerow(row) + + +def portable_case(case: dict[str, Any]) -> dict[str, Any]: + return {key: case.get(key) for key in PORTABLE_CASE_COLUMNS} + + def fastaguard_version(binary: Path) -> str: completed = subprocess.run( [str(binary), "--version"], capture_output=True, text=True, check=False @@ -418,6 +541,24 @@ def fastaguard_version(binary: Path) -> str: return completed.stdout.strip() or "unknown" +def portable_fastaguard_version(version_output: str) -> str: + fields = version_output.split() + if len(fields) < 2 or fields[0].lower() != "fastaguard": + raise SystemExit( + "Portable evidence requires `fastaguard --version` output beginning " + "with 'fastaguard '" + ) + return fields[1] + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + def git_commit() -> str: completed = subprocess.run( ["git", "rev-parse", "--short", "HEAD"], @@ -431,5 +572,40 @@ def git_commit() -> str: return completed.stdout.strip() or "unknown" +def release_source_commit(version: str) -> str: + tag = f"v{version}^{{commit}}" + completed = subprocess.run( + ["git", "rev-parse", "--verify", tag], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + raise SystemExit(f"Release tag v{version} not found for source provenance") + commit = completed.stdout.strip() + source_diff = subprocess.run( + [ + "git", + "diff", + "--no-ext-diff", + "--quiet", + commit, + "--", + "Cargo.toml", + "Cargo.lock", + "src", + ], + cwd=ROOT, + check=False, + ) + if source_diff.returncode != 0: + raise SystemExit( + f"Tracked Rust/Cargo source differs from release tag v{version}; " + "refusing to record release provenance" + ) + return commit + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/tests/python/test_adoption_assets.py b/tests/python/test_adoption_assets.py index 3e65f9f..5d90fbd 100644 --- a/tests/python/test_adoption_assets.py +++ b/tests/python/test_adoption_assets.py @@ -1,4 +1,5 @@ import json +import re import subprocess import sys import types @@ -8,6 +9,8 @@ ROOT = Path(__file__).resolve().parents[2] +NFCORE_PR = "https://github.com/nf-core/modules/pull/12239" +SNAKEMAKE_PR = "https://github.com/snakemake/snakemake-wrappers/pull/5436" sys.path.insert(0, str(ROOT / "integrations" / "multiqc" / "src")) import fastaguard_multiqc.parser as multiqc_parser @@ -37,7 +40,11 @@ def test_v0_3_gate_docs_and_examples_are_present(self): self.assertIn("Run FastaGuard first.", readme) self.assertIn('"gate"', output_contract) self.assertIn("provenance.input_sha256", output_contract) - self.assertIn("--gate pipeline", nf_core_module) + nf_core_test_config = self.read( + "examples/nf-core/modules/local/fastaguard/tests/nextflow.config" + ) + self.assertNotIn("--gate pipeline", nf_core_module) + self.assertIn("--profile assembly --gate pipeline", nf_core_test_config) self.assertIn("--gate pipeline", snakemake) self.assertIn( '"blocking_findings": ["duplicate_ids", "invalid_chars", "high_n_rate"]', @@ -64,13 +71,7 @@ def test_v0_3_gate_examples_do_not_pin_v0_2_runtimes(self): ROOT / "examples" / "snakemake" / "wrapper" / "environment.yaml" ).read_text() wrapper_py = ( - ROOT - / "examples" - / "snakemake" - / "wrapper" - / "wrapper" - / "fastaguard" - / "wrapper.py" + ROOT / "examples" / "snakemake" / "wrapper" / "wrapper.py" ).read_text() nf_core_readme = (ROOT / "examples" / "nf-core" / "README.md").read_text() snakemake_readme = ( @@ -79,14 +80,19 @@ def test_v0_3_gate_examples_do_not_pin_v0_2_runtimes(self): self.assertNotIn("0.2.0--", nf_core_module) self.assertIn( - "quay.io/biocontainers/fastaguard:0.5.0--hfa8f182_0", + "quay.io/biocontainers/fastaguard:0.6.0--hfa8f182_0", nf_core_module, ) self.assertNotIn("fastaguard=0.2.0", wrapper_env) - self.assertIn("--gate {gate}", wrapper_py) - self.assertIn("published v0.5.0", nf_core_readme) - self.assertIn("captures that legacy", nf_core_readme) - self.assertIn("fastaguard.exit_code", snakemake_readme) + self.assertNotIn("--gate {gate}", wrapper_py) + self.assertIn('extra = snakemake.params.get("extra", "")', wrapper_py) + self.assertIn('"{extra} "', wrapper_py) + self.assertNotIn("fastaguard=0.5.0", nf_core_readme) + self.assertNotIn("fastaguard:0.5.0--", nf_core_readme) + self.assertIn("task.ext.args", nf_core_readme) + self.assertNotIn("exit_code", nf_core_readme) + self.assertIn("params.extra", snakemake_readme) + self.assertIn("gate.status", snakemake_readme) def test_v0_4_docs_explain_preflight_readiness_and_compare_mode(self): readme = ROOT / "README.md" @@ -523,15 +529,33 @@ def test_workflow_docs_reference_bioconda_and_container_status(self): self.assertIn(install, nfcore_readme) self.assertIn(install, snakemake_readme) self.assertIn( - "quay.io/biocontainers/fastaguard:0.5.0--hfa8f182_0", + "quay.io/biocontainers/fastaguard:0.6.0--hfa8f182_0", nfcore_readme, ) self.assertNotIn("0.2.0--", nfcore_module) self.assertIn( - "quay.io/biocontainers/fastaguard:0.5.0--hfa8f182_0", + "quay.io/biocontainers/fastaguard:0.6.0--hfa8f182_0", snakemake_readme, ) + def test_current_integration_assets_have_no_v0_5_runtime_pins(self): + stale_runtime = re.compile( + r"(?:fastaguard=0\.5\.0|fastaguard:0\.5\.0--|" + r'\{% set version = "0\.5\.0" %\})' + ) + paths = subprocess.check_output( + ["git", "ls-files", "examples", "packaging"], + cwd=ROOT, + text=True, + ).splitlines() + violations = [ + path + for path in paths + if stale_runtime.search((ROOT / path).read_text(errors="ignore")) + ] + + self.assertEqual(violations, []) + def test_workflow_readiness_plan_defines_upstream_submission_path(self): readme = (ROOT / "README.md").read_text() adoption = (ROOT / "docs" / "adoption-plan.md").read_text() @@ -555,8 +579,12 @@ def test_workflow_readiness_plan_defines_upstream_submission_path(self): self.assertIn("[Workflow readiness](docs/workflow-readiness.md)", readme) self.assertIn("Phase 5: Upstream workflow readiness", adoption) - self.assertIn("not yet an upstream nf-core module", readiness) - self.assertIn("not yet an official Snakemake wrapper", readiness) + self.assertIn(NFCORE_PR, adoption) + self.assertIn(SNAKEMAKE_PR, adoption) + self.assertIn(NFCORE_PR, readiness) + self.assertIn(SNAKEMAKE_PR, readiness) + self.assertNotIn("not yet an upstream nf-core module", readiness) + self.assertNotIn("not yet an official Snakemake wrapper", readiness) self.assertIn("collect-then-gate", readiness) self.assertIn("nf-core modules lint", readiness) self.assertIn("nf-core modules test", readiness) @@ -564,10 +592,10 @@ def test_workflow_readiness_plan_defines_upstream_submission_path(self): self.assertIn("environment.linux-64.pin.txt", readiness) self.assertIn("test_wrappers.py", readiness) self.assertIn("--gate submission", readiness) - self.assertIn("quay.io/biocontainers/fastaguard:0.5.0--hfa8f182_0", readiness) + self.assertIn("quay.io/biocontainers/fastaguard:0.6.0--hfa8f182_0", readiness) self.assertIn("docs/workflow-readiness.md", nfcore_readme) self.assertIn("docs/workflow-readiness.md", snakemake_readme) - self.assertIn("fastaguard=0.5.0", nfcore_environment) + self.assertIn("fastaguard=0.6.0", nfcore_environment) self.assertIn("name: fastaguard", snakemake_meta) self.assertIn("description:", snakemake_meta) self.assertIn("output:", snakemake_meta) @@ -582,13 +610,12 @@ def test_nf_core_starter_has_upstream_prep_test_layout(self): self.assertIn("versions_fastaguard:", meta_yml) self.assertIn("topic", meta_yml) self.assertIn('process "FASTAGUARD"', nf_test) - self.assertIn("pass.fa", nf_test) - self.assertIn("warn.fa", nf_test) - self.assertIn("fail.fa", nf_test) - self.assertIn("invalid.fa", nf_test) - self.assertIn("fastaguard_mqc.json", nf_test) + self.assertIn("file(workDir.resolve", nf_test) + self.assertIn("assertAll", nf_test) + self.assertIn("process.success", nf_test) + self.assertIn('unstableKeys: ["html", "json", "tsv", "mqc"]', nf_test) for name in ("pass.fa", "warn.fa", "fail.fa", "invalid.fa"): - self.assertTrue((module / "tests" / "data" / name).exists(), name) + self.assertFalse((module / "tests" / "data" / name).exists(), name) def test_nf_core_starter_matches_current_nf_core_module_shape(self): module = ROOT / "examples" / "nf-core" / "modules" / "local" / "fastaguard" @@ -599,41 +626,87 @@ def test_nf_core_starter_matches_current_nf_core_module_shape(self): self.assertIn('conda "${moduleDir}/environment.yml"', main_nf) self.assertIn("workflow.containerEngine in ['singularity', 'apptainer']", main_nf) self.assertIn( - "https://depot.galaxyproject.org/singularity/fastaguard:0.5.0--hfa8f182_0", + "https://depot.galaxyproject.org/singularity/fastaguard:0.6.0--hfa8f182_0", main_nf, ) self.assertIn( - "quay.io/biocontainers/fastaguard:0.5.0--hfa8f182_0", + "quay.io/biocontainers/fastaguard:0.6.0--hfa8f182_0", main_nf, ) self.assertIn("\n when:\n task.ext.when == null || task.ext.when\n", main_nf) self.assertIn("emit: versions_fastaguard, topic: versions", main_nf) - self.assertIn("emit: exit_code", main_nf) + self.assertNotIn("emit: exit_code", main_nf) + self.assertIn('def prefix = task.ext.prefix ?: "${meta.id}"', main_nf) + self.assertIn("def args = task.ext.args ?: ''", main_nf) + self.assertNotIn("set +e", main_nf) self.assertIn("output:\n html:", meta_yml) self.assertNotIn("output:\n - html:", meta_yml) self.assertIn("versions_fastaguard:", meta_yml) self.assertIn('fastaguard --version | cut -d " " -f 2:', meta_yml) - self.assertIn("exit_code:", meta_yml) + self.assertNotIn("exit_code:", meta_yml) + self.assertIn('- "@ehsanestaji"', meta_yml) self.assertIn('tag "modules"', nf_test) self.assertIn('tag "modules_nfcore"', nf_test) - self.assertIn('file("${moduleDir}/tests/data/pass.fa"', nf_test) - self.assertNotIn("file('data/pass.fa'", nf_test) - self.assertIn("file(process.out.html[0][1]).name.endsWith", nf_test) - self.assertNotIn("process.out.html[0][1].name.endsWith", nf_test) + self.assertIn('file(workDir.resolve("pass.fa").toString())', nf_test) + self.assertIn( + 'snapshot(sanitizeOutput(process.out, unstableKeys: ["html", "json", "tsv", "mqc"])).match()', + nf_test, + ) - warn_fasta = (module / "tests" / "data" / "warn.fa").read_text() - self.assertIn(">long", warn_fasta) - self.assertIn(">tiny", warn_fasta) - self.assertNotIn("NNNNNNNN", warn_fasta) + def test_nf_core_starter_has_complete_sanitized_snapshot_baseline(self): + snapshot_path = ( + ROOT + / "examples" + / "nf-core" + / "modules" + / "local" + / "fastaguard" + / "tests" + / "main.nf.test.snap" + ) + snapshot = json.loads(snapshot_path.read_text()) + cases = { + "pass FASTA emits all reports": "pass", + "warn FASTA preserves reports": "warn", + "fail FASTA preserves reports for gate review": "fail", + "invalid FASTA is represented in the evidence path": "invalid", + } - pass_fasta = (module / "tests" / "data" / "pass.fa").read_text() - self.assertIn(">clean", pass_fasta) - self.assertNotIn(">contig1", pass_fasta) + self.assertEqual(set(snapshot), set(cases)) + for test_name, sample in cases.items(): + with self.subTest(test_name=test_name): + self.assertEqual(len(snapshot[test_name]["content"]), 1) + outputs = snapshot[test_name]["content"][0] + self.assertEqual( + set(outputs), + {"html", "json", "mqc", "tsv", "versions_fastaguard"}, + ) + self.assertEqual( + outputs["html"], + [[{"id": sample}, f"{sample}.fastaguard.html"]], + ) + self.assertEqual( + outputs["json"], + [[{"id": sample}, f"{sample}.fastaguard.json"]], + ) + self.assertEqual( + outputs["tsv"], + [[{"id": sample}, f"{sample}.fastaguard.tsv"]], + ) + self.assertEqual( + outputs["mqc"], + [[{"id": sample}, f"{sample}.fastaguard_mqc.json"]], + ) + self.assertEqual( + outputs["versions_fastaguard"], + [["FASTAGUARD", "fastaguard", "0.6.0"]], + ) def test_snakemake_wrapper_has_upstream_prep_test_layout(self): wrapper = ROOT / "examples" / "snakemake" / "wrapper" readme = (wrapper / "README.md").read_text() wrapper_py = (wrapper / "wrapper.py").read_text() + starter_snakefile = (wrapper / "Snakefile").read_text() test_snakefile = (wrapper / "test" / "Snakefile").read_text() test_py = (wrapper / "test" / "test_wrappers.py").read_text() pin = (wrapper / "environment.linux-64.pin.txt").read_text() @@ -641,26 +714,54 @@ def test_snakemake_wrapper_has_upstream_prep_test_layout(self): self.assertIn("safe local order", readme) self.assertIn("fastaguard", pin) self.assertIn("fastaguard {snakemake.input.fasta}", wrapper_py) - self.assertIn("snakemake.output.exit_code", wrapper_py) - self.assertIn('status" -eq 3', wrapper_py) + self.assertNotIn("snakemake.output.exit_code", wrapper_py) + self.assertNotIn("set +e", wrapper_py) + self.assertIn('extra = snakemake.params.get("extra", "")', wrapper_py) + self.assertIn('"{extra} "', wrapper_py) self.assertIn('"master/bio/fastaguard"', test_snakefile) self.assertNotIn('"file:../wrapper/fastaguard"', test_snakefile) - self.assertIn('exit_code="pass/fastaguard.exit_code"', test_snakefile) + self.assertNotIn("exit_code", test_snakefile) self.assertIn("rule fastaguard_pass", test_snakefile) self.assertIn("rule fastaguard_warn", test_snakefile) self.assertIn("rule fastaguard_fail", test_snakefile) self.assertIn("rule fastaguard_invalid", test_snakefile) self.assertIn("pytest", test_py) self.assertIn("snakemake", test_py) - for name in ("pass.fa", "warn.fa", "fail.fa", "invalid.fa"): - self.assertTrue((wrapper / "test" / "data" / name).exists(), name) - - pass_fasta = (wrapper / "test" / "data" / "pass.fa").read_text() - warn_fasta = (wrapper / "test" / "data" / "warn.fa").read_text() - self.assertIn(">clean", pass_fasta) - self.assertIn(">long", warn_fasta) - self.assertIn(">tiny", warn_fasta) - self.assertNotIn("NNNNNNNN", warn_fasta) + for target in ( + "pass/fastaguard.json", + "warn/fastaguard.json", + "fail/fastaguard.json", + "invalid/fastaguard.json", + ): + with self.subTest(target=target): + self.assertIn(f'"{target}"', test_py) + self.assertNotIn("fastaguard.exit_code", test_py) + self.assertIn('extra="--profile assembly --gate pipeline"', test_snakefile) + self.assertFalse((wrapper / "wrapper" / "fastaguard" / "wrapper.py").exists()) + + output_block = starter_snakefile.split(" output:\n", 1)[1].split( + " log:\n", 1 + )[0] + outputs = dict( + re.findall(r'^\s+(\w+)="([^"]+)",$', output_block, flags=re.MULTILINE) + ) + self.assertEqual( + outputs, + { + "html": "fastaguard_report.html", + "json": "fastaguard.json", + "tsv": "fastaguard.tsv", + "multiqc": "fastaguard_mqc.json", + }, + ) + params_block = starter_snakefile.split(" params:\n", 1)[1].split( + " wrapper:\n", 1 + )[0] + self.assertEqual( + params_block.strip(), + 'extra="--profile assembly --gate pipeline",', + ) + self.assertNotIn("exit_code", starter_snakefile) def test_workflow_readiness_safe_order_is_explicit(self): readiness = (ROOT / "docs" / "workflow-readiness.md").read_text() @@ -668,10 +769,33 @@ def test_workflow_readiness_safe_order_is_explicit(self): self.assertIn("Safe Order", readiness) self.assertIn("local repository tests first", readiness) - self.assertIn("external upstream PRs last", readiness) self.assertIn("check_fastaguard_gate.py", readiness) - self.assertIn("nf-core module PR ready", adoption) - self.assertIn("Snakemake wrapper PR ready", adoption) + self.assertIn(NFCORE_PR, readiness) + self.assertIn(SNAKEMAKE_PR, readiness) + self.assertIn("merged 2026-08-21", readiness) + self.assertIn("2026-07-27", readiness) + + def test_workflow_docs_record_merged_upstream_integrations(self): + adoption = (ROOT / "docs" / "adoption-plan.md").read_text() + readiness = (ROOT / "docs" / "workflow-readiness.md").read_text() + + for text in (adoption, readiness): + self.assertIn(NFCORE_PR, text) + self.assertIn(SNAKEMAKE_PR, text) + self.assertNotIn("prepare external PR branches", text) + self.assertNotIn("Before an upstream nf-core module submission", text) + self.assertNotIn("Before an official Snakemake wrapper submission", text) + + def test_current_workflow_assets_do_not_claim_a_default_gate(self): + readme = (ROOT / "README.md").read_text() + nfcore_module = self.read( + "examples/nf-core/modules/local/fastaguard/main.nf" + ) + snakemake_wrapper = self.read("examples/snakemake/wrapper/wrapper.py") + + self.assertNotIn("default workflow gate policy", readme) + self.assertNotIn("--gate", nfcore_module) + self.assertNotIn("--gate", snakemake_wrapper) def test_benchmarking_docs_include_v0_2_evidence_topics(self): text = (ROOT / "docs" / "benchmarking.md").read_text() @@ -778,6 +902,9 @@ def test_collect_evidence_local_only_smoke_does_not_require_network(self): from pathlib import Path args = sys.argv[1:] +if args == ["--version"]: + print("fastaguard 9.8.7 test-build") + raise SystemExit(0) input_path = Path(args[0]) if "--gate" not in args or args[args.index("--gate") + 1] != "pipeline": raise SystemExit("unexpected gate mode") @@ -840,6 +967,18 @@ def option_path(flag): summary_path = out_dir / "evidence_summary.json" self.assertTrue(summary_path.exists()) summary = json.loads(summary_path.read_text()) + expected_git_commit = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + self.assertEqual( + summary["fastaguard_version"], "fastaguard 9.8.7 test-build" + ) + self.assertEqual(summary["git_commit"], expected_git_commit) + self.assertNotIn("source_commit", summary) case_ids = {case["id"] for case in summary["cases"]} self.assertEqual( case_ids, @@ -992,9 +1131,8 @@ def option_path(flag): self.assertNotEqual(completed.returncode, 0) self.assertIn("expected gate.mode pipeline", completed.stderr) - def test_deep_release_vision_is_documented_and_memorized(self): + def test_deep_release_vision_is_documented_in_project_docs(self): vision = (ROOT / "docs" / "vision-plan.md").read_text() - memory = (ROOT / "AGENTS.md").read_text() readme = (ROOT / "README.md").read_text() required_phrases = [ @@ -1014,8 +1152,6 @@ def test_deep_release_vision_is_documented_and_memorized(self): with self.subTest(phrase=phrase): self.assertIn(phrase, vision) - self.assertIn("Deep Release Vision", memory) - self.assertIn("FASTA preflight operating system", memory) self.assertIn("docs/vision-plan.md", readme) def test_snakemake_wrapper_declares_bioconda_environment(self): @@ -1032,8 +1168,9 @@ def test_snakemake_wrapper_declares_bioconda_environment(self): "channels:", " - conda-forge", " - bioconda", + " - nodefaults", "dependencies:", - " - fastaguard=0.5.0", + " - fastaguard=0.6.0", ], ) self.assertIn("conda:\n \"environment.yaml\"", snakefile.read_text()) diff --git a/tests/python/test_community_health.py b/tests/python/test_community_health.py new file mode 100644 index 0000000..cf238c9 --- /dev/null +++ b/tests/python/test_community_health.py @@ -0,0 +1,309 @@ +import re +import subprocess +import unittest +from pathlib import Path +from urllib.parse import urlparse + +import yaml + + +ROOT = Path(__file__).resolve().parents[2] +REQUIRED_PATHS = [ + "CONTRIBUTING.md", + "CODE_OF_CONDUCT.md", + "SECURITY.md", + ".github/ISSUE_TEMPLATE/bug_report.yml", + ".github/ISSUE_TEMPLATE/feature_request.yml", + ".github/ISSUE_TEMPLATE/config.yml", + ".github/pull_request_template.md", +] +REQUIRED_ISSUE_DETAILS = { + "FastaGuard version", + "Operating system", + "Installation method", + "Exact command", + "Minimal FASTA reproducer or safe description", + "Expected result", + "Actual result", +} +ASSISTANT_IDENTITY = r"(?:chatgpt|codex|claude(?:\s+code)?|openai|anthropic)" +GENERIC_AI_TOOL = r"(?:(?:an?\s+)?(?:ai|assistant)(?:\s+(?:tool|system|model))?)" +PROHIBITED_ATTRIBUTION_SOURCE = rf"(?:{ASSISTANT_IDENTITY}|{GENERIC_AI_TOOL})" +PROHIBITED_ATTRIBUTION_PATTERNS = [ + re.compile(rf"(?im)^co-authored-by:\s*{PROHIBITED_ATTRIBUTION_SOURCE}\b"), + re.compile( + rf"(?im)^generated(?:-|\s+)by\s*:?\s*{PROHIBITED_ATTRIBUTION_SOURCE}\b" + ), + re.compile(rf"(?i)written with {PROHIBITED_ATTRIBUTION_SOURCE}\b"), + re.compile( + r"(?i)\bai[- ]assisted\s+(?:contribution|change|code|content|documentation)\b" + ), + re.compile(r"(?im)^assistant\s+provenance\s*:"), +] +ATTRIBUTION_TRACE_SAMPLES = ( + "Generated" + "-by: Code" + "x", + "generated" + " by Code" + "x", + "Co-authored" + "-by: Claude" + " Code", + "Generated" + " by Anthro" + "pic", + "Generated" + " by an " + "AI tool", + "AI" + "-assisted contribution", + "Assistant " + "provenance: automation", +) + + +class CommunityHealthTest(unittest.TestCase): + def tracked_paths(self): + paths = subprocess.check_output( + ["git", "ls-files"], cwd=ROOT, text=True + ).splitlines() + return [path for path in paths if (ROOT / path).is_file()] + + @staticmethod + def has_security_policy_contact(config): + for contact in config.get("contact_links", []): + if not isinstance(contact, dict): + continue + + destination = urlparse(contact.get("url", "")) + context = " ".join( + str(contact.get(field, "")) for field in ("name", "about") + ).lower() + if ( + destination.scheme == "https" + and destination.netloc.lower() == "github.com" + and destination.path.endswith("/security/policy") + and "security" in context + ): + return True + + return False + + def test_required_community_files_exist(self): + missing = [path for path in REQUIRED_PATHS if not (ROOT / path).is_file()] + + self.assertEqual(missing, []) + + def test_contributing_documents_project_verification_and_dco(self): + text = (ROOT / "CONTRIBUTING.md").read_text() + + self.assertIn("cargo test --locked", text) + self.assertRegex(text, r"(?i)signed-off-by") + + def test_contributing_uses_ignored_venv_with_declared_python_dependencies(self): + text = (ROOT / "CONTRIBUTING.md").read_text() + create = re.search( + r"(?m)^python3\s+-m\s+venv\s+(target/[^\s]+)\s*$", + text, + ) + + self.assertIsNotNone(create) + environment = re.escape(create.group(1)) + install = re.search( + rf"(?m)^{environment}/bin/python\s+-m\s+pip\s+install\s+" + r"--requirement\s+requirements-test\.txt\s*$", + text, + ) + test = re.search( + rf"(?m)^{environment}/bin/python\s+-m\s+pytest\s+-q\s+" + r"tests/python\s*$", + text, + ) + + self.assertIsNotNone(install) + self.assertIsNotNone(test) + self.assertLess(create.start(), install.start()) + self.assertLess(install.start(), test.start()) + self.assertTrue((ROOT / "requirements-test.txt").is_file()) + self.assertIn("target/", (ROOT / ".gitignore").read_text().splitlines()) + + def test_ci_installs_dependencies_and_runs_full_python_suite(self): + workflow = yaml.safe_load( + (ROOT / ".github" / "workflows" / "ci.yml").read_text() + ) + steps = workflow["jobs"]["rust"]["steps"] + commands = [step.get("run", "") for step in steps] + python_setups = [ + step + for step in steps + if step.get("uses", "").startswith("actions/setup-python@") + ] + requirements = (ROOT / "requirements-test.txt").read_text().splitlines() + + self.assertIn( + "python3 -m pip install --requirement requirements-test.txt", + commands, + ) + self.assertEqual(len(python_setups), 1) + self.assertEqual( + python_setups[0].get("with", {}).get("python-version"), "3.12" + ) + self.assertIn("python3 -m pytest -q tests/python", commands) + self.assertEqual( + requirements, + [ + "build==1.5.0", + "jsonschema==4.26.0", + "pytest==9.1.1", + "PyYAML==6.0.3", + ], + ) + + def test_ci_checks_multiqc_plugin_python_and_multiqc_compatibility(self): + workflow = yaml.safe_load( + (ROOT / ".github" / "workflows" / "ci.yml").read_text() + ) + job = workflow["jobs"]["multiqc-plugin"] + matrix = job["strategy"]["matrix"] + steps = job["steps"] + test_step = next( + step for step in steps if step["name"] == "Test built MultiQC plugin" + ) + + self.assertEqual(matrix["python"], ["3.10", "3.14"]) + self.assertEqual(matrix["multiqc"], ["1.28", "1.35"]) + self.assertEqual( + test_step["env"]["FASTAGUARD_MULTIQC_VERSION"], + "${{ matrix.multiqc }}", + ) + self.assertEqual( + test_step["run"], + "python3 -m pytest -q tests/python/test_multiqc_plugin.py", + ) + + def test_security_policy_uses_functional_private_email(self): + text = (ROOT / "SECURITY.md").read_text() + + self.assertIn("mailto:ehsan.estaji@umu.se", text) + self.assertRegex(text, r"(?is)private.*?ehsan\.estaji@umu\.se") + self.assertNotIn("/security/advisories/new", text) + self.assertNotIn("issues/new", text) + + def test_code_of_conduct_uses_confidential_maintainer_email(self): + text = (ROOT / "CODE_OF_CONDUCT.md").read_text() + + self.assertIn("mailto:ehsan.estaji@umu.se", text) + self.assertRegex(text, r"(?is)confidential.*?ehsan\.estaji@umu\.se") + self.assertNotIn("issues/new", text) + + def test_issue_forms_are_parseable_and_collect_reproducible_context(self): + for path in ( + ".github/ISSUE_TEMPLATE/bug_report.yml", + ".github/ISSUE_TEMPLATE/feature_request.yml", + ): + with self.subTest(path=path): + form = yaml.safe_load((ROOT / path).read_text()) + self.assertIsInstance(form, dict) + self.assertTrue(form.get("name")) + self.assertTrue(form.get("description")) + self.assertIsInstance(form.get("body"), list) + + labels = { + field.get("attributes", {}).get("label") + for field in form["body"] + if isinstance(field, dict) + } + self.assertTrue(REQUIRED_ISSUE_DETAILS <= labels) + + def test_issue_template_config_is_parseable_and_structured(self): + config = yaml.safe_load( + (ROOT / ".github/ISSUE_TEMPLATE/config.yml").read_text() + ) + + self.assertIsInstance(config, dict) + self.assertIs(config.get("blank_issues_enabled"), False) + self.assertIsInstance(config.get("contact_links"), list) + self.assertTrue(self.has_security_policy_contact(config)) + config_text = ( + ROOT / ".github" / "ISSUE_TEMPLATE" / "config.yml" + ).read_text() + self.assertNotIn("/security/advisories/new", config_text) + + def test_security_contact_rejects_public_issue_destination(self): + inadequate_config = { + "contact_links": [ + { + "name": "Security vulnerability report", + "url": "https://github.com/ehsanestaji/FastaGuard/issues/new", + "about": "Use this link for security reports.", + } + ] + } + + self.assertFalse(self.has_security_policy_contact(inadequate_config)) + + disabled_advisory_config = { + "contact_links": [ + { + "name": "Security vulnerability report", + "url": "https://github.com/ehsanestaji/FastaGuard/security/advisories/new", + "about": "Use this link for security reports.", + } + ] + } + self.assertFalse(self.has_security_policy_contact(disabled_advisory_config)) + + def test_pull_request_template_covers_review_mechanisms(self): + text = (ROOT / ".github/pull_request_template.md").read_text() + + for mechanism in ( + "Scoped change description", + "Tests", + "Contract impact", + "Documentation impact", + "DCO", + "Attribution-trace review", + ): + with self.subTest(mechanism=mechanism): + self.assertIn(mechanism, text) + + def test_tracked_tree_excludes_tool_specific_planning_paths(self): + planning_paths = [ + path + for path in self.tracked_paths() + if path == "AGENTS.md" or path.startswith("docs/superpowers/") + ] + + self.assertEqual(planning_paths, []) + + def test_public_documentation_excludes_tool_specific_planning_references(self): + violations = [] + for path in self.tracked_paths(): + if path == "README.md" or path.startswith("docs/"): + text = (ROOT / path).read_text(errors="ignore") + if "docs/superpowers" in text: + violations.append(path) + + self.assertEqual(violations, []) + + def test_tracked_tree_has_no_prohibited_assistant_attribution_traces(self): + violations = [] + for path in self.tracked_paths(): + text = (ROOT / path).read_text(errors="ignore") + if any(pattern.search(text) for pattern in PROHIBITED_ATTRIBUTION_PATTERNS): + violations.append(path) + + self.assertEqual(violations, []) + + def test_attribution_trace_patterns_cover_common_provenance_forms(self): + for trace in ATTRIBUTION_TRACE_SAMPLES: + with self.subTest(trace=trace): + self.assertTrue( + any( + pattern.search(trace) + for pattern in PROHIBITED_ATTRIBUTION_PATTERNS + ) + ) + + def test_attribution_patterns_allow_non_assistant_provenance(self): + unrelated_provenance = "Generated-by: release tooling" + + self.assertFalse( + any( + pattern.search(unrelated_provenance) + for pattern in PROHIBITED_ATTRIBUTION_PATTERNS + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/python/test_evidence_summary.py b/tests/python/test_evidence_summary.py new file mode 100644 index 0000000..897038c --- /dev/null +++ b/tests/python/test_evidence_summary.py @@ -0,0 +1,211 @@ +import csv +import gzip +import json +import re +from pathlib import Path + +from scripts.collect_evidence import gzip_fasta + + +ROOT = Path(__file__).resolve().parents[2] +RESULTS_DIR = ROOT / "docs" / "evidence" / "results" / "v0.6" +JSON_PATH = RESULTS_DIR / "evidence_summary.json" +TSV_PATH = RESULTS_DIR / "evidence_summary.tsv" +EXPECTED_CASES = { + "synthetic_valid", + "problem_fixture", + "gzipped_valid", + "ecoli_k12_mg1655", + "neurospora_crassa_or74a", +} +EXPECTED_ACCESSIONS = {"GCF_000005845.2", "GCF_000182925.2"} +EXPECTED_SOURCE_COMMIT = "cf27295da0cb9b1a48318caa9e3b8739cfd0c104" +EXPECTED_BINARY_SHA256 = "6dec7b558d29b3e72a96f6b81f942947fc50f84e183bfe5a390c665b33d21103" +VALID_STATUSES = {"PASS", "WARN", "FAIL"} +SHA256 = re.compile(r"[0-9a-f]{64}\Z") +STABLE_CASE_FIELDS = ( + "id", + "label", + "category", + "source", + "accession", + "source_url", + "evidence_role", + "expected_scale", + "downstream_route", + "input_bytes", + "input_sha256", + "elapsed_seconds", + "exit_code", + "verdict", + "gate_mode", + "gate_status", + "gate_blocking_findings", + "sequence_count", + "total_length", + "n50", + "n90", + "finding_count", + "finding_ids", +) +SHARED_SUMMARY_FIELDS = ( + "schema_version", + "generated_at", + "fastaguard_version", + "source_commit", + "binary_sha256", + "provenance_scope", + "binary_to_source_reproducibility_attested", + "platform", + "python", + "runtime_context", +) + + +def tsv_value(value): + if isinstance(value, list): + return ",".join(value) + if isinstance(value, bool): + return str(value).lower() + if value is None: + return "" + return str(value) + + +def load_summaries(): + summary = json.loads(JSON_PATH.read_text(encoding="utf-8")) + with TSV_PATH.open(encoding="utf-8", newline="") as handle: + rows = list(csv.DictReader(handle, delimiter="\t")) + return summary, rows + + +def test_portable_summaries_have_expected_cases_and_release_provenance(): + summary, rows = load_summaries() + + assert summary["fastaguard_version"] == "0.6.0" + assert summary["source_commit"] == EXPECTED_SOURCE_COMMIT + case_ids = [case["id"] for case in summary["cases"]] + row_ids = [row["id"] for row in rows] + assert len(case_ids) == len(EXPECTED_CASES) + assert len(row_ids) == len(EXPECTED_CASES) + assert len(case_ids) == len(set(case_ids)) + assert len(row_ids) == len(set(row_ids)) + assert set(case_ids) == EXPECTED_CASES + assert set(row_ids) == EXPECTED_CASES + assert {case["accession"] for case in summary["cases"] if case["accession"]} == ( + EXPECTED_ACCESSIONS + ) + + for case in summary["cases"]: + assert case["exit_code"] == 0 + assert SHA256.fullmatch(case["input_sha256"]) + assert case["verdict"] in VALID_STATUSES + assert case["gate_status"] in VALID_STATUSES + assert isinstance(case["finding_ids"], list) + assert case["finding_count"] == len(case["finding_ids"]) + + for row in rows: + assert row["exit_code"] == "0" + assert row["fastaguard_version"] == "0.6.0" + assert row["source_commit"] == EXPECTED_SOURCE_COMMIT + assert SHA256.fullmatch(row["input_sha256"]) + assert row["verdict"] in VALID_STATUSES + assert row["gate_status"] in VALID_STATUSES + + +def test_portable_provenance_distinguishes_observed_binary_from_source_check(): + summary, rows = load_summaries() + + assert summary["binary_sha256"] == EXPECTED_BINARY_SHA256 + assert summary["binary_to_source_reproducibility_attested"] is False + scope = summary["provenance_scope"].lower() + assert "source-tree" in scope + assert "observed executable" in scope + assert "not independently attested" in scope + + for row in rows: + assert row["binary_sha256"] == EXPECTED_BINARY_SHA256 + assert row["binary_to_source_reproducibility_attested"] == "false" + assert row["provenance_scope"] == summary["provenance_scope"] + + +def test_standalone_tsv_carries_runtime_interpretation_context(): + _, rows = load_summaries() + + assert rows + for row in rows: + assert row["platform"] == "macOS-26.5.1-arm64-arm-64bit-Mach-O" + assert row["python"] == "3.14.5" + runtime_context = row["runtime_context"].lower() + assert "contextual" in runtime_context + assert "not cross-platform performance guarantees" in runtime_context + + +def test_json_and_tsv_agree_on_stable_case_data(): + summary, rows = load_summaries() + assert set(summary) - {"cases"} == set(SHARED_SUMMARY_FIELDS) + assert all(set(case) == set(STABLE_CASE_FIELDS) for case in summary["cases"]) + assert set(rows[0]) == set(SHARED_SUMMARY_FIELDS) | set(STABLE_CASE_FIELDS) + row_ids = [row["id"] for row in rows] + assert len(row_ids) == len(set(row_ids)) + rows_by_id = {row["id"]: row for row in rows} + + for row in rows: + for field in SHARED_SUMMARY_FIELDS: + assert row[field] == tsv_value(summary[field]), ( + f"{row['id']} differs for summary field {field}" + ) + + for case in summary["cases"]: + row = rows_by_id[case["id"]] + for field in STABLE_CASE_FIELDS: + assert row[field] == tsv_value(case[field]), ( + f"{case['id']} differs for {field}" + ) + + +def test_portable_tsv_uses_repository_line_format(): + content = TSV_PATH.read_bytes() + assert b"\r" not in content + assert all(not line.endswith((b"\t", b" ")) for line in content.splitlines()) + + +def test_portable_summaries_exclude_local_paths_commands_and_artifacts(): + repository_path = str(ROOT) + forbidden_fragments = ( + repository_path, + "/tmp/", + "/private/tmp/", + "target/evidence", + "ncbi_dataset.zip", + ) + forbidden_keys = {"input_path", "artifacts", "command"} + + for path in (JSON_PATH, TSV_PATH): + text = path.read_text(encoding="utf-8") + for fragment in forbidden_fragments: + assert fragment not in text + + summary, _ = load_summaries() + pending = [summary] + while pending: + value = pending.pop() + if isinstance(value, dict): + assert forbidden_keys.isdisjoint(value) + pending.extend(value.values()) + elif isinstance(value, list): + pending.extend(value) + elif isinstance(value, str): + assert not value.startswith("/"), f"absolute path leaked: {value}" + + +def test_gzip_evidence_fixture_has_a_reproducible_timestamp(tmp_path): + source = tmp_path / "input.fa" + destination = tmp_path / "input.fa.gz" + source.write_text(">record\nACGT\n", encoding="utf-8") + + gzip_fasta(source, destination) + + assert destination.read_bytes()[4:8] == b"\x00\x00\x00\x00" + with gzip.open(destination, "rt", encoding="utf-8") as handle: + assert handle.read() == ">record\nACGT\n" diff --git a/tests/python/test_multiqc_plugin.py b/tests/python/test_multiqc_plugin.py new file mode 100644 index 0000000..0d88fa1 --- /dev/null +++ b/tests/python/test_multiqc_plugin.py @@ -0,0 +1,316 @@ +import email +import json +import os +import subprocess +import sys +import tarfile +import unittest +import zipfile +from pathlib import Path +from tempfile import TemporaryDirectory +from urllib.parse import unquote, urlsplit + + +ROOT = Path(__file__).resolve().parents[2] +PACKAGE_ROOT = ROOT / "integrations" / "multiqc" +sys.path.insert(0, str(PACKAGE_ROOT / "src")) + +from fastaguard_multiqc.parser import load_custom_content_summary + + +class MultiqcPluginReleaseTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.test_dir = TemporaryDirectory(prefix="fastaguard-multiqc-release-") + cls.test_path = Path(cls.test_dir.name) + cls.build_path = cls.test_path / "dist" + cls.subprocess_env = os.environ.copy() + cls.subprocess_env.pop("PYTHONHOME", None) + cls.subprocess_env.pop("PYTHONPATH", None) + cls.subprocess_env.pop("VIRTUAL_ENV", None) + cls.subprocess_env.setdefault( + "PIP_CACHE_DIR", str(cls.test_path / "pip-cache") + ) + cls.subprocess_env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1" + result = subprocess.run( + [ + sys.executable, + "-m", + "build", + "--outdir", + str(cls.build_path), + str(PACKAGE_ROOT), + ], + cwd=ROOT, + env=cls.subprocess_env, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise AssertionError( + "MultiQC plugin distribution build failed:\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + + cls.wheel = next(cls.build_path.glob("*.whl")) + cls.sdist = next(cls.build_path.glob("*.tar.gz")) + + @classmethod + def tearDownClass(cls): + cls.test_dir.cleanup() + + def test_wheel_metadata_declares_release_contract(self): + with zipfile.ZipFile(self.wheel) as archive: + metadata_name = next( + name for name in archive.namelist() if name.endswith(".dist-info/METADATA") + ) + entry_points_name = next( + name + for name in archive.namelist() + if name.endswith(".dist-info/entry_points.txt") + ) + metadata = email.message_from_bytes(archive.read(metadata_name)) + entry_points = archive.read(entry_points_name).decode() + + self.assertEqual(metadata["Name"], "multiqc-fastaguard") + self.assertEqual(metadata["Version"], "0.1.0") + self.assertEqual(metadata["Requires-Python"], ">=3.10") + self.assertEqual(metadata["License-Expression"], "MIT") + self.assertIn("multiqc>=1.28", metadata.get_all("Requires-Dist", [])) + self.assertEqual( + set(metadata.get_all("Project-URL", [])), + { + "Homepage, https://github.com/ehsanestaji/FastaGuard", + "Issues, https://github.com/ehsanestaji/FastaGuard/issues", + "Repository, https://github.com/ehsanestaji/FastaGuard", + }, + ) + self.assertIn("[multiqc.modules.v1]", entry_points) + self.assertIn("fastaguard = fastaguard_multiqc:MultiqcModule", entry_points) + self.assertIn("[multiqc.hooks.v1]", entry_points) + self.assertIn( + "before_config = fastaguard_multiqc.parser:register_search_patterns", + entry_points, + ) + + def test_build_archives_contain_plugin_modules_and_readme(self): + with zipfile.ZipFile(self.wheel) as archive: + wheel_names = set(archive.namelist()) + wheel_readme_names = [ + name + for name in wheel_names + if name.endswith(".data/data/share/doc/multiqc-fastaguard/README.md") + ] + self.assertEqual(len(wheel_readme_names), 1, sorted(wheel_names)) + wheel_readme = archive.read(wheel_readme_names[0]).decode() + + self.assertIn("fastaguard_multiqc/__init__.py", wheel_names) + self.assertIn("fastaguard_multiqc/parser.py", wheel_names) + self.assertIn("fastaguard_multiqc/multiqc_module.py", wheel_names) + self.assertIn("# MultiQC FastaGuard Module", wheel_readme) + self.assertIn("This phase validates the package locally only.", wheel_readme) + + with tarfile.open(self.sdist, "r:gz") as archive: + sdist_names = set(archive.getnames()) + root = "multiqc_fastaguard-0.1.0" + sdist_readme = archive.extractfile(f"{root}/README.md").read().decode() + + self.assertIn(f"{root}/README.md", sdist_names) + self.assertIn("# MultiQC FastaGuard Module", sdist_readme) + self.assertIn("This phase validates the package locally only.", sdist_readme) + self.assertIn(f"{root}/src/fastaguard_multiqc/__init__.py", sdist_names) + self.assertIn(f"{root}/src/fastaguard_multiqc/parser.py", sdist_names) + self.assertIn(f"{root}/src/fastaguard_multiqc/multiqc_module.py", sdist_names) + + def test_parser_preserves_v0_6_pass_and_fail_fields(self): + cases = { + "assembly_pass": ( + "valid_assembly", + { + "verdict": "WARN", + "gate_mode": "none", + "gate_status": "WARN", + "readiness_status": "WARN", + "submission_status": "WARN", + "submission_target": ".", + "unsafe_identifier_count": 0, + "long_identifier_count": 0, + "duplicate_first_token_id_count": 0, + "gap_like_n_run_count": 0, + }, + ), + "assembly_fail": ( + "problem_assembly", + { + "verdict": "FAIL", + "gate_mode": "none", + "gate_status": "FAIL", + "readiness_status": "FAIL", + "submission_status": "FAIL", + "submission_target": ".", + "unsafe_identifier_count": 0, + "long_identifier_count": 0, + "duplicate_first_token_id_count": 1, + "gap_like_n_run_count": 0, + }, + ), + } + + for report_dir, (sample_name, expected) in cases.items(): + with self.subTest(report=report_dir): + report = ( + ROOT + / "examples" + / "reports" + / report_dir + / "fastaguard_mqc.json" + ) + parsed = load_custom_content_summary(report)[sample_name] + self.assertEqual( + {field: parsed[field] for field in expected}, + expected, + ) + + def test_installed_plugin_runs_strict_multiqc_and_writes_v0_6_fields(self): + multiqc_version = os.environ.get("FASTAGUARD_MULTIQC_VERSION", "1.35") + environment = self.test_path / f"multiqc-{multiqc_version}" + create = subprocess.run( + [sys.executable, "-m", "venv", str(environment)], + cwd=ROOT, + env=self.subprocess_env, + capture_output=True, + text=True, + ) + self.assertEqual( + create.returncode, + 0, + f"STDOUT:\n{create.stdout}\nSTDERR:\n{create.stderr}", + ) + + bin_dir = environment / ("Scripts" if os.name == "nt" else "bin") + python = bin_dir / ("python.exe" if os.name == "nt" else "python") + multiqc = bin_dir / ("multiqc.exe" if os.name == "nt" else "multiqc") + install = subprocess.run( + [ + str(python), + "-m", + "pip", + "install", + str(self.wheel), + f"multiqc=={multiqc_version}", + ], + cwd=ROOT, + env=self.subprocess_env, + capture_output=True, + text=True, + ) + self.assertEqual( + install.returncode, + 0, + f"STDOUT:\n{install.stdout}\nSTDERR:\n{install.stderr}", + ) + + inspect = subprocess.run( + [ + str(python), + "-c", + ( + "import json; import fastaguard_multiqc; " + "from importlib.metadata import distribution, version; " + "dist = distribution('multiqc-fastaguard'); " + "print(json.dumps({'direct_url': json.loads(dist.read_text('direct_url.json')), " + "'module_file': fastaguard_multiqc.__file__, " + "'multiqc_version': version('multiqc'), " + "'plugin_version': version('multiqc-fastaguard')}))" + ), + ], + cwd=ROOT, + env=self.subprocess_env, + capture_output=True, + text=True, + ) + self.assertEqual( + inspect.returncode, + 0, + f"STDOUT:\n{inspect.stdout}\nSTDERR:\n{inspect.stderr}", + ) + installed = json.loads(inspect.stdout) + installed_wheel = Path( + unquote(urlsplit(installed["direct_url"]["url"]).path) + ).resolve() + self.assertEqual(installed_wheel, self.wheel.resolve()) + self.assertEqual(installed["multiqc_version"], multiqc_version) + self.assertEqual(installed["plugin_version"], "0.1.0") + self.assertTrue( + Path(installed["module_file"]) + .resolve() + .is_relative_to(environment.resolve()) + ) + + output_dir = self.test_path / f"report-{multiqc_version}" + result = subprocess.run( + [ + str(multiqc), + "--strict", + "--module", + "fastaguard", + "--outdir", + str(output_dir), + str(ROOT / "examples" / "reports"), + ], + cwd=ROOT, + env=self.subprocess_env, + capture_output=True, + text=True, + ) + self.assertEqual( + result.returncode, + 0, + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}", + ) + + parsed_table = output_dir / "multiqc_data" / "multiqc_fastaguard.txt" + self.assertTrue( + parsed_table.exists(), + "missing parsed plugin data; " + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}", + ) + data_path = output_dir / "multiqc_data" / "multiqc_data.json" + saved_data = json.loads(data_path.read_text())["report_saved_raw_data"] + parsed = saved_data["multiqc_fastaguard"] + + expected = { + "valid_assembly": { + "verdict": "WARN", + "gate_mode": "none", + "gate_status": "WARN", + "readiness_status": "WARN", + "submission_status": "WARN", + "submission_target": ".", + "unsafe_identifier_count": 0, + "long_identifier_count": 0, + "duplicate_first_token_id_count": 0, + "gap_like_n_run_count": 0, + }, + "problem_assembly": { + "verdict": "FAIL", + "gate_mode": "none", + "gate_status": "FAIL", + "readiness_status": "FAIL", + "submission_status": "FAIL", + "submission_target": ".", + "unsafe_identifier_count": 0, + "long_identifier_count": 0, + "duplicate_first_token_id_count": 1, + "gap_like_n_run_count": 0, + }, + } + for sample, sample_expected in expected.items(): + self.assertEqual( + {field: parsed[sample][field] for field in sample_expected}, + sample_expected, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/python/test_release_metadata.py b/tests/python/test_release_metadata.py index 984cd20..f31c40a 100644 --- a/tests/python/test_release_metadata.py +++ b/tests/python/test_release_metadata.py @@ -7,6 +7,11 @@ ROOT = Path(__file__).resolve().parents[2] +CURRENT_VERSION = "0.6.0" +CURRENT_CONTAINER = "0.6.0--hfa8f182_0" +CURRENT_SOURCE_SHA256 = ( + "b57e967ccaa03ef5e70c14b96247a644750d8bd492360633988f2f53fe84184b" +) class ReleaseMetadataTest(unittest.TestCase): @@ -15,10 +20,10 @@ def test_package_targets_v0_6_0(self): self.assertEqual(cargo["package"]["version"], "0.6.0") - def test_bioconda_recipe_tracks_published_v0_5_0_archive(self): + def test_bioconda_recipe_tracks_published_v0_6_0_archive(self): recipe = (ROOT / "packaging" / "bioconda" / "meta.yaml").read_text() - self.assertIn('{% set version = "0.5.0" %}', recipe) + self.assertIn('{% set version = "0.6.0" %}', recipe) self.assertIn("fastaguard --version | grep {{ version }}", recipe) def test_v0_2_0_release_notes_exist(self): @@ -63,19 +68,41 @@ def test_v0_6_0_release_notes_define_conventional_exit_contract(self): self.assertIn("`input_path`", text) self.assertIn("`gate.status`", text) - def test_bioconda_recipe_has_publishable_v0_5_0_source_sha(self): + def test_v0_6_exit_contract_docs_include_output_write_failures(self): + contract_docs = [ + ROOT / "README.md", + ROOT / "docs" / "mvp-spec.md", + ROOT / "docs" / "output-contract.md", + ROOT / "docs" / "releases" / "v0.6.0.md", + ROOT / "docs" / "roadmap.md", + ROOT / "docs" / "vision-plan.md", + ] + + for path in contract_docs: + with self.subTest(path=path): + text = " ".join(path.read_text().split()) + starts = [ + match.start() + for match in re.finditer("configuration, input-access", text) + ] + self.assertTrue(starts, path) + for start in starts: + description = text[start : start + 120] + self.assertIn("output-write", description, description) + + def test_bioconda_recipe_has_publishable_v0_6_0_source_sha(self): recipe = (ROOT / "packaging" / "bioconda" / "meta.yaml").read_text() marker = "REPLACE" + "_WITH_" - self.assertTrue((ROOT / "docs" / "releases" / "v0.5.0.md").exists()) - self.assertIn('{% set version = "0.5.0" %}', recipe) + self.assertTrue((ROOT / "docs" / "releases" / "v0.6.0.md").exists()) + self.assertIn('{% set version = "0.6.0" %}', recipe) self.assertNotIn(marker, recipe) match = re.search(r"sha256: ([a-f0-9]{64})", recipe) self.assertIsNotNone(match, recipe) self.assertEqual( match.group(1), - "b3de60c83cb570bb90e894c262effe4092101b1655e5c64023445078ee2c5971", + CURRENT_SOURCE_SHA256, ) def test_release_ready_bioconda_recipe_requires_real_sha(self): @@ -98,7 +125,7 @@ def test_release_ready_bioconda_recipe_requires_real_sha(self): self.assertIsNotNone(match, recipe) self.assertEqual( match.group(1), - "b3de60c83cb570bb90e894c262effe4092101b1655e5c64023445078ee2c5971", + CURRENT_SOURCE_SHA256, ) self.assertNotIn(marker + "PUBLIC_SOURCE_ARCHIVE_SHA256", recipe) @@ -135,13 +162,13 @@ def test_bioconda_build_script_uses_portable_install(self): def test_docs_reference_published_bioconda_install(self): install_command = "mamba install -c conda-forge -c bioconda fastaguard" - pinned_install = install_command + "=0.5.0" - container = "quay.io/biocontainers/fastaguard:0.5.0--hfa8f182_0" + pinned_install = install_command + f"={CURRENT_VERSION}" + container = f"quay.io/biocontainers/fastaguard:{CURRENT_CONTAINER}" docs = [ ROOT / "README.md", ROOT / "docs" / "packaging.md", ROOT / "docs" / "adoption-plan.md", - ROOT / "packaging" / "bioconda" / "README.md", + ROOT / "docs" / "workflow-readiness.md", ] for path in docs: @@ -156,6 +183,32 @@ def test_docs_reference_published_bioconda_install(self): packaging = (ROOT / "docs" / "packaging.md").read_text() self.assertNotIn("GitHub repository is private", packaging) self.assertNotIn("placeholder SHA256", packaging) + self.assertIn('release_version="X.Y.Z"', packaging) + self.assertIn('git tag "v${release_version}"', packaging) + self.assertNotIn("git tag v0.6.0", packaging) + + def test_current_release_docs_do_not_present_v0_5_as_latest(self): + current_docs = [ + ROOT / "README.md", + ROOT / "docs" / "adoption-plan.md", + ROOT / "docs" / "workflow-readiness.md", + ROOT / "docs" / "packaging.md", + ROOT / "docs" / "roadmap.md", + ROOT / "docs" / "tool-landscape.md", + ROOT / "docs" / "vision-plan.md", + ] + stale_claims = [ + "v0.5.0 remains the latest tag", + "v0.5.0 is the latest tagged GitHub release", + "until a\nv0.6 package and container are published", + ] + + for path in current_docs: + with self.subTest(path=path): + text = path.read_text() + self.assertIn(CURRENT_VERSION, text) + for claim in stale_claims: + self.assertNotIn(claim, text) if __name__ == "__main__": diff --git a/tests/python/test_upstream_contract.py b/tests/python/test_upstream_contract.py new file mode 100644 index 0000000..12ec95e --- /dev/null +++ b/tests/python/test_upstream_contract.py @@ -0,0 +1,225 @@ +import csv +import json +import subprocess +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +import jsonschema + + +ROOT = Path(__file__).resolve().parents[2] +GATE_HELPER = ROOT / "examples" / "workflows" / "check_fastaguard_gate.py" + +FIXTURES = { + "pass": ( + ">clean\n" + "ACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGT" + "ACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGT" + "ACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGT" + "ACGTACGTACGTACGTACGTACGTACGTACGTACGTACGT\n" + ), + "warn": ( + ">long\n" + "ACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGT" + "ACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGT" + "ACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGT" + "ACGTACGTACGTACGTACGTACGTACGTACGTACGTACGT\n" + ">tiny\nACGT\n" + ), + "fail": ">dup\nACGTACGT\n>dup\nACGTACGT\n>bad\nACGTXYZ\n", + "invalid": ">empty_record\n>next_record\nACGT\n", +} + +EXPECTED = { + "pass": ("PASS", []), + "warn": ("WARN", []), + "fail": ( + "FAIL", + ["duplicate_ids", "duplicate_first_token_ids", "invalid_chars"], + ), + "invalid": ("FAIL", ["invalid_fasta_structure"]), +} + + +class UpstreamContractTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + subprocess.run( + ["cargo", "build", "--quiet", "--bin", "fastaguard"], + cwd=ROOT, + check=True, + ) + cls.binary = ROOT / "target" / "debug" / "fastaguard" + schema_result = subprocess.run( + [str(cls.binary), "--schema"], + cwd=ROOT, + check=True, + text=True, + stdout=subprocess.PIPE, + ) + schema = json.loads(schema_result.stdout) + validator_class = jsonschema.validators.validator_for(schema) + validator_class.check_schema(schema) + cls.schema_validator = validator_class(schema) + + def run_fastaguard(self, *args): + return subprocess.run( + [str(self.binary), *map(str, args)], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + def assert_report_contract(self, case, extra_args): + expected_status, expected_blockers = EXPECTED[case] + with TemporaryDirectory() as temp_dir: + output_dir = Path(temp_dir) + fasta = output_dir / f"{case}.fa" + fasta.write_text(FIXTURES[case]) + outputs = { + "html": output_dir / f"{case}.html", + "json": output_dir / f"{case}.json", + "tsv": output_dir / f"{case}.tsv", + "multiqc": output_dir / f"{case}_mqc.json", + } + result = self.run_fastaguard( + fasta, + *extra_args, + "--out", + outputs["html"], + "--json", + outputs["json"], + "--tsv", + outputs["tsv"], + "--multiqc", + outputs["multiqc"], + ) + + self.assertEqual(result.returncode, 0, result.stderr) + for path in outputs.values(): + with self.subTest(case=case, output=path.name): + self.assertTrue(path.is_file(), path) + self.assertGreater(path.stat().st_size, 0, path) + + report = json.loads(outputs["json"].read_text()) + self.schema_validator.validate(report) + self.assertEqual(report["verdict"]["status"], expected_status) + self.assertEqual(report["gate"]["status"], expected_status) + self.assertEqual(report["gate"]["blocking_findings"], expected_blockers) + + with outputs["tsv"].open(newline="") as handle: + metrics = { + row["metric"]: row["value"] + for row in csv.DictReader(handle, delimiter="\t") + } + self.assertEqual(metrics["input_path"], str(fasta)) + self.assertEqual(metrics["verdict"], expected_status) + self.assertEqual(metrics["gate_status"], expected_status) + + multiqc = json.loads(outputs["multiqc"].read_text()) + self.assertEqual(multiqc["id"], "fastaguard") + self.assertEqual(multiqc["plot_type"], "table") + self.assertEqual(len(multiqc["data"]), 1) + summary = next(iter(multiqc["data"].values())) + self.assertEqual(summary["verdict"], expected_status) + self.assertEqual(summary["gate_status"], expected_status) + + def test_default_command_shape_reports_pass_warn_fail_and_invalid(self): + for case in FIXTURES: + with self.subTest(case=case): + self.assert_report_contract(case, []) + + def test_explicit_assembly_pipeline_shape_reports_all_cases(self): + for case in FIXTURES: + with self.subTest(case=case): + self.assert_report_contract( + case, ["--profile", "assembly", "--gate", "pipeline"] + ) + + def test_version_output_is_stable_and_machine_parseable(self): + result = self.run_fastaguard("--version") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertRegex(result.stdout.strip(), r"^fastaguard [0-9]+\.[0-9]+\.[0-9]+$") + + def test_cli_parse_errors_exit_two(self): + result = self.run_fastaguard("--not-a-fastaguard-option") + + self.assertEqual(result.returncode, 2, result.stderr) + + def test_missing_input_exits_three(self): + with TemporaryDirectory() as temp_dir: + output_dir = Path(temp_dir) + result = self.run_fastaguard( + output_dir / "missing.fa", + "--out", + output_dir / "report.html", + "--json", + output_dir / "report.json", + "--tsv", + output_dir / "report.tsv", + "--multiqc", + output_dir / "report_mqc.json", + ) + + self.assertEqual(result.returncode, 3, result.stderr) + + def test_output_parent_that_is_a_file_exits_three(self): + with TemporaryDirectory() as temp_dir: + output_dir = Path(temp_dir) + fasta = output_dir / "pass.fa" + fasta.write_text(FIXTURES["pass"]) + blocked_parent = output_dir / "regular-file" + blocked_parent.write_text("not a directory\n") + result = self.run_fastaguard( + fasta, + "--out", + blocked_parent / "report.html", + "--json", + output_dir / "report.json", + "--tsv", + output_dir / "report.tsv", + "--multiqc", + output_dir / "report_mqc.json", + ) + + self.assertEqual(result.returncode, 3, result.stderr) + + def test_downstream_gate_can_reject_a_collected_fail_report(self): + with TemporaryDirectory() as temp_dir: + output_dir = Path(temp_dir) + fasta = output_dir / "fail.fa" + fasta.write_text(FIXTURES["fail"]) + json_report = output_dir / "fail.json" + report_result = self.run_fastaguard( + fasta, + "--profile", + "assembly", + "--gate", + "pipeline", + "--out", + output_dir / "fail.html", + "--json", + json_report, + "--tsv", + output_dir / "fail.tsv", + "--multiqc", + output_dir / "fail_mqc.json", + ) + gate_result = subprocess.run( + ["python3", str(GATE_HELPER), str(json_report)], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + self.assertEqual(report_result.returncode, 0, report_result.stderr) + self.assertEqual(gate_result.returncode, 2, gate_result.stderr) + self.assertEqual(gate_result.stdout.strip(), "FastaGuard report gate status: FAIL") + + +if __name__ == "__main__": + unittest.main()