From c93d2f328944a9164f1b4c0853cbb3a7b773e1c3 Mon Sep 17 00:00:00 2001 From: chaksaray Date: Tue, 20 Oct 2026 21:03:03 +0700 Subject: [PATCH 1/2] fix: stat block from the claims register, SCAN-3 comparison table The stat block said 51 AVE records and 121 detection rules. It is now written by scripts/sync_site_numbers.py from the expect values in bawbel/.github claims/register.yaml: 87 published, 43 with a detection engine, 12 attack chains, 6 detection engines, 120 detection rules. --check fails when the block is stale or a count is typed anywhere else on the site, including a badge. Counts in prose, the meta description, the ecosystem cards, and the README badge are removed. The comparison table is rendered from the scanner README table (SCAN-3): NVIDIA SkillSpector added, unverifiable cells as ?, the telemetry row where SkillSpector leads kept. The PiranhaDB card no longer says LIVE. The pre-commit card's "Local mode for air-gapped environments" is removed: the published hooks have no such mode. A workflow runs the tests and --check on pull requests, and weekly opens a pull request against develop when the register changes. Refs #22 Co-Authored-By: Claude Opus 5.5 Signed-off-by: chaksaray --- .github/workflows/site-numbers.yml | 68 +++++++++++ .gitignore | 2 + README.md | 15 ++- docs/index.html | 12 +- index.html | 55 +++++---- scripts/requirements.txt | 2 + scripts/sync_site_numbers.py | 181 +++++++++++++++++++++++++++++ tests/test_sync_site_numbers.py | 111 ++++++++++++++++++ 8 files changed, 413 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/site-numbers.yml create mode 100644 .gitignore create mode 100644 scripts/requirements.txt create mode 100644 scripts/sync_site_numbers.py create mode 100644 tests/test_sync_site_numbers.py diff --git a/.github/workflows/site-numbers.yml b/.github/workflows/site-numbers.yml new file mode 100644 index 0000000..138e66c --- /dev/null +++ b/.github/workflows/site-numbers.yml @@ -0,0 +1,68 @@ +# Site numbers come from the claims register in bawbel/.github (scripts/sync_site_numbers.py). +# On a pull request: fail if the stat block is stale or a count is typed outside it. +# Weekly and on demand: regenerate the block and open a pull request against develop. +name: site numbers + +on: + pull_request: + branches: [develop, main] + schedule: + - cron: "17 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - run: python -m pip install -r scripts/requirements.txt + - run: python -m pytest tests -q + - run: python scripts/sync_site_numbers.py --check + + update: + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: develop + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - run: python -m pip install -r scripts/requirements.txt + - run: python scripts/sync_site_numbers.py + - name: Open a pull request when the numbers changed + env: + GH_TOKEN: ${{ github.token }} + run: | + if git diff --quiet; then + echo "Stat block already current." + exit 0 + fi + if [ -n "$(gh pr list --base develop --state open --search 'head:chore/site-numbers-' --json number --jq '.[].number')" ]; then + echo "A site-numbers pull request is already open." + exit 0 + fi + branch="chore/site-numbers-$(date -u +%Y%m%d)" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + git commit -s -am "chore: sync site numbers from the claims register" + git push origin "$branch" + gh pr create --base develop --head "$branch" \ + --title "chore: sync site numbers from the claims register" \ + --body "Generated by scripts/sync_site_numbers.py from claims/register.yaml in bawbel/.github. Review the new values against that register before merging." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c56ff1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +.pytest_cache/ diff --git a/README.md b/README.md index 487fb81..b40cec6 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ before they reach production. Free forever. Apache 2.0. [![Scanner](https://img.shields.io/pypi/v/bawbel-scanner?label=bawbel-scanner&color=1DB894&style=flat-square)](https://pypi.org/project/bawbel-scanner/) [![Downloads](https://img.shields.io/pypi/dm/bawbel-scanner?label=downloads%2Fmonth&color=1DB894&style=flat-square)](https://pepy.tech/project/bawbel-scanner) -[![AVE Records](https://img.shields.io/badge/AVE_records-51-1DB894?style=flat-square)](https://github.com/bawbel/ave) +[![AVE Records](https://img.shields.io/badge/AVE-records-1DB894?style=flat-square)](https://github.com/bawbel/ave) [![License](https://img.shields.io/badge/license-Apache_2.0-1DB894?style=flat-square)](https://github.com/bawbel/scanner/blob/main/LICENSE) @@ -23,7 +23,7 @@ before they reach production. Free forever. Apache 2.0. | Repo | What it is | |---|---| | [bawbel/scanner](https://github.com/bawbel/scanner) | CLI scanner for agentic AI components -- SKILL.md files, MCP servers, system prompts. Confidence and evidence metadata on every finding. | -| [bawbel/ave](https://github.com/bawbel/ave) | AVE -- the behavioral classification standard for agentic AI components. 51 published records, scored with OWASP AIVSS v0.8, mapped to OWASP MCP Top 10 and MITRE ATLAS. | +| [bawbel/ave](https://github.com/bawbel/ave) | AVE -- the behavioral classification standard for agentic AI components. Published records scored with OWASP AIVSS v0.8, mapped to OWASP MCP Top 10 and MITRE ATLAS. | | [bawbel/piranha-api](https://github.com/bawbel/piranha-api) | Free, no-auth REST API serving the full AVE record set and threat intelligence. | | [bawbel/integrations](https://github.com/bawbel/integrations) | GitHub Action, VS Code extension, and pre-commit hooks. | @@ -46,6 +46,17 @@ Your CI pipeline scans dependencies for known package vulnerabilities. It does n --- +### Site numbers + +Every count on bawbel.io comes from the claims register in +[bawbel/.github](https://github.com/bawbel/.github/blob/main/claims/register.yaml), which that +repository's verifier checks against the code. `scripts/sync_site_numbers.py` writes the values +into the stat block in `index.html`. A pull request fails when the block is stale or a count is +typed anywhere else, and a weekly workflow opens a pull request when the register changes. To +change a number, change the register. + +--- + ### Links [bawbel.io](https://bawbel.io)  ยท  diff --git a/docs/index.html b/docs/index.html index baadd61..6f55812 100644 --- a/docs/index.html +++ b/docs/index.html @@ -434,12 +434,12 @@

What is covered

-

121 detection rules

-

Pattern, YARA, and Semgrep rules mapped to all 51 AVE records. Every finding now carries confidence and evidence metadata.

+

Detection rules

+

Pattern, YARA, and Semgrep rules, each tied to the AVE record it detects. Every finding now carries confidence and evidence metadata.

-

6 detection engines

+

Detection engines

Pattern, YARA, Semgrep, LLM semantic analysis, Magika file-type verification, Docker behavioral sandbox.

@@ -788,7 +788,7 @@

AVE record reference

-

Full registry of all 51 records: ave.bawbel.io/registry.html

+

Full registry of published records: ave.bawbel.io/registry.html

@@ -847,7 +847,7 @@

Sandbox -- Docker + BAWBEL_SANDBOX_ENABLED=true

Detection

Toxic flows

-

Individual findings that are each only MEDIUM severity can combine into a CRITICAL attack chain. 12 built-in chains detect exactly this -- for example, a credential-read finding plus a data-exfiltration finding in the same component becomes a single Credential Exfiltration Chain at CRITICAL 9.8, rather than two separate MEDIUM findings a reviewer might triage as low priority individually.

+

Individual findings that are each only MEDIUM severity can combine into a CRITICAL attack chain. Built-in chains detect exactly this -- for example, a credential-read finding plus a data-exfiltration finding in the same component becomes a single Credential Exfiltration Chain at CRITICAL 9.8, rather than two separate MEDIUM findings a reviewer might triage as low priority individually.

$ bawbel scan ./skills/ --recursive
 CRIT  AVE-2026-00003  line 4   AIVSS 6.8  confidence 0.75
@@ -857,7 +857,7 @@ 

Toxic flows

Chain confidence is the minimum confidence across all contributing findings -- the weakest link in the chain, not the average. This is deliberate: a chain is only as trustworthy as its least certain component.

-

Use bawbel chain to filter specifically to delegation-chain findings (AVE-2026-00048), or run a full bawbel scan to see all 12 toxic flow chains evaluated together.

+

Use bawbel chain to filter specifically to delegation-chain findings (AVE-2026-00048), or run a full bawbel scan to see every toxic flow chain evaluated together.

diff --git a/index.html b/index.html index 8c6cd59..d3542c2 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ Bawbel: Agentic AI Security Scanner - + @@ -151,11 +151,13 @@

The security layer
for agentic AI

-
51
AVE Records
+ +
87
AVE Records published
+
43
With a detection engine
12
Attack Chains
6
Detection Engines
-
121
Detection Rules
-
0
Auth Required
+
120
Detection Rules
+
@@ -251,7 +253,6 @@

Pre-commit hook

  • Blocks commit with remediation message
  • Configurable severity threshold
  • Works with all pre-commit environments
  • -
  • Local mode for air-gapped environments
  • @@ -375,12 +376,12 @@

    Delegation Chain Scanner

    Toxic Flow Detection

    -

    12 built-in attack chains. Credential-read plus data-exfil becomes CRITICAL 9.8 automatically. Combined AIVSS score and minimum confidence across the full chain.

    +

    Built-in attack chains. Credential-read plus data-exfil becomes CRITICAL 9.8 automatically. Combined AIVSS score and minimum confidence across the full chain.

    -

    6 Detection Engines

    -

    Pattern (40 rules), YARA (39), Semgrep (41), LLM semantic, Magika content-type, Docker sandbox. 121 rules total across 51 AVE records.

    +

    Detection Engines

    +

    Pattern, YARA, and Semgrep rules, LLM semantic analysis, Magika content-type checks, and a Docker sandbox stage that is off by default. Each rule is tied to the AVE record it detects.

    @@ -450,7 +451,7 @@

    Maps to the frameworks you already report agai
    Required on every record

    OWASP MCP Top 10

    -

    owasp_mcp is required on every one of the 51 AVE records, MCP01 through MCP10. Every finding your scan produces lands in a category your security team already tracks.

    +

    owasp_mcp is required on every AVE record, MCP01 through MCP10. Every finding your scan produces lands in a category your security team already tracks.

    Mapped where applicable
    @@ -484,24 +485,28 @@

    How Bawbel compares

    - - - - - + + + + + - - - - - - - - + + + + + + + + + + +
    FeatureBawbelSnyk agent-scanClawGuardCisco DefenseClawFeatureBawbelNVIDIA SkillSpectorSnyk agent-scanCisco DefenseClaw
    Executes MCP servers during scanNeverYesNoSandboxed
    Open behavioral classification standardYes (51 records)NoNoNo
    OWASP AIVSS v0.8 scoresYesNoNoNo
    Confidence and evidence metadataYesNoNoNo
    Toxic flow detectionYes (12 chains)NoNoNo
    Justified suppression with expiryYesNoNoNo
    Conformance gradingYes (A+ to F)NoNoNo
    LicenseApache 2.0Apache 2.0MITProprietary
    Starts MCP servers during a scanNeverNeverYes (stdio servers)?
    Runs scanned skill contentOnly in the optional Docker sandbox stage, off by defaultNever??
    Open vulnerability databaseYes (AVE records, public API)No (internal rule IDs)No (internal rule IDs)?
    OWASP AIVSS v0.8 scoresYesNo (custom 0-100 risk score)No?
    Compound attack chain detectionYes (AVE capability chains)Yes (TT1-TT5 taint flows + AST8 chain)Yes (chain count not published)?
    MCP spec conformance grading (A+ to F)YesNoNo?
    Git-committed pin / rug pull detectionYesNoNo?
    Suppression with expiry and audit trailYes (reason, reviewer, expiry)Baseline (glob and fingerprint, no expiry)Baseline (ignore flags, no expiry)?
    Per-scan LLM usage and cost telemetryNo (tracked in #79)Yes (metadata.inference_usage)No?
    SARIF 2.1.0 outputYesYesNo?
    LicenseApache 2.0Apache 2.0Apache 2.0Apache 2.0
    +

    ? means not verifiable from each project's public README at the time of writing (2026-09-12). The same table, with its sources, is in the scanner README.

    @@ -514,8 +519,8 @@

    Everything links to everything

    bawbel-scanner
    CLI scanner, Python API
    LIVE v1.3.0
    VS Code Extension
    Inline diagnostics, auto-scan
    LIVE v1.1.1
    CI/CD Action
    SARIF upload, PR annotations
    LIVE v1
    -
    AVE Standard
    Behavioral classification, 51 records
    LIVE v1.1.0
    -
    PiranhaDB
    Threat intel API, 14 endpoints
    LIVE api.piranha.bawbel.io
    +
    AVE Standard
    Behavioral classification records
    LIVE v1.1.0
    +
    PiranhaDB
    Threat intel API
    OFFLINE
    Pre-commit Hook
    Blocks commits on findings
    LIVE v1.3
    AIVSS (OWASP)
    Scoring standard for agentic AI
    @@ -556,7 +561,7 @@

    Stable, shared IDs

    Public threat intelligence API

    Free, no auth, Apache 2.0. Every scanner finding includes a piranha_url linking to the full record with IOCs and remediation.

    -
    GET
    /records
    all 51 AVE records, filterable
    +
    GET
    /records
    all AVE records, filterable
    GET
    /records/{ave_id}
    full record with IOCs and AIVSS
    GET
    /registry-scan/latest
    latest Smithery scan results
    GET
    /stats/ecosystem
    aggregate stats across all sources
    diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 0000000..f3d4305 --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1,2 @@ +pyyaml>=6,<7 +pytest>=8,<10 diff --git a/scripts/sync_site_numbers.py b/scripts/sync_site_numbers.py new file mode 100644 index 0000000..4b8a141 --- /dev/null +++ b/scripts/sync_site_numbers.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Render the site's numbers from the claims register. + +Every count on bawbel.io comes from one place: the `expect` values in +claims/register.yaml in bawbel/.github. That repository's verifier checks each value +against the code. This script writes them into the stat block of index.html, between +the stat:start and stat:end markers, and a count typed anywhere else fails --check. + + python scripts/sync_site_numbers.py # rewrite the stat block + python scripts/sync_site_numbers.py --check # exit 1 if the block is stale or a + # count is typed outside it + +The register is read from --register (a file path or an https URL), else from the +SITE_CLAIMS_REGISTER environment variable, else from REGISTER_URL_DEFAULT. + +Exit status: 0 current (or rewritten), 1 stale or a typed count (--check only), 2 the +register or the stat block could not be read. Nothing is written when the exit is 2. +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +import urllib.request +from pathlib import Path +from urllib.parse import urlsplit + +import yaml + +ROOT = Path(__file__).resolve().parent.parent +INDEX = ROOT / "index.html" +PAGES = (ROOT / "index.html", ROOT / "docs" / "index.html", ROOT / "README.md") + +REGISTER_URL_DEFAULT = "https://raw.githubusercontent.com/bawbel/.github/main/claims/register.yaml" +REGISTER_ENV = "SITE_CLAIMS_REGISTER" +REGISTER_SCHEMA = "bawbel/claims-register/v1" +FETCH_TIMEOUT_SEC = 15 +MAX_REGISTER_BYTES = 1024 * 1024 + +# The stat block in display order: (claim id in the register, label). The register's +# appears_in patterns match these labels, so a label change needs a register change. +STATS = ( + ("C-01", "AVE Records published"), + ("C-03", "With a detection engine"), + ("C-06", "Attack Chains"), + ("C-08", "Detection Engines"), + ("C-07", "Detection Rules"), +) + +START = "" +END = "" +BLOCK = re.compile(r"(?P[ \t]*)\n.*?", re.S) + +# A count typed into prose: "51 AVE records", "121 rules total", "6 Detection Engines". +# One space only: sample output such as "Findings: 2 Chains: 1" uses two. +TYPED_COUNT = re.compile( + r"(?i)\b\d+ (?:(?:published|built-in|AVE|detection|toxic flow|attack|capability) )*" + r"(?:records?|rules?|engines?|chains?|endpoints?)\b" +) +# A count in a shields.io badge: "AVE_records-51". +BADGE_COUNT = re.compile(r"(?i)(? blocks are not checked. +PRE = re.compile(r"", re.S | re.I) + + +class RegisterError(Exception): + """The register could not be read, or lacks a value the site needs.""" + + +def read_register(source: str) -> dict: + """The parsed register from a file path or an https URL.""" + if os.path.isfile(source): + text = Path(source).read_text(encoding="utf-8") + else: + parts = urlsplit(source) + if parts.scheme != "https" or not parts.netloc: + raise RegisterError(f"not a file or an https URL: {source}") + req = urllib.request.Request(source, headers={"User-Agent": "bawbel-site-numbers"}) + try: + # B310: the scheme is checked above, so only https reaches urlopen. + with urllib.request.urlopen(req, timeout=FETCH_TIMEOUT_SEC) as resp: # nosec B310 + body = resp.read(MAX_REGISTER_BYTES + 1) + except OSError as exc: # URLError, HTTPError, and timeouts are all OSError + raise RegisterError(f"{source}: {exc}") from exc + if len(body) > MAX_REGISTER_BYTES: + raise RegisterError(f"{source}: larger than {MAX_REGISTER_BYTES} bytes") + text = body.decode("utf-8", errors="replace") + try: + data = yaml.safe_load(text) + except yaml.YAMLError as exc: + raise RegisterError(f"{source}: not YAML: {exc}") from exc + if not isinstance(data, dict) or data.get("schema") != REGISTER_SCHEMA: + raise RegisterError(f"{source}: not a {REGISTER_SCHEMA} document") + return data + + +def stat_values(register: dict) -> dict[str, int]: + """The expect value of every claim in STATS. Each must be a whole number.""" + claims = {c.get("id"): c for c in register.get("claims") or [] if isinstance(c, dict)} + values = {} + for claim_id, _ in STATS: + expect = (claims.get(claim_id) or {}).get("expect") + if isinstance(expect, bool) or not isinstance(expect, int) or expect < 0: + raise RegisterError(f"{claim_id}: expect is not a whole number: {expect!r}") + values[claim_id] = expect + return values + + +def render_block(values: dict[str, int], indent: str) -> str: + lines = [indent + START] + for claim_id, label in STATS: + lines.append( + f'{indent}
    {values[claim_id]}
    ' + f'
    {label}
    ' + ) + lines.append(indent + END) + return "\n".join(lines) + + +def typed_counts(path: Path) -> list[str]: + """Counts typed into a page outside the stat block and
     samples."""
    +
    +    def blank(match: re.Match) -> str:
    +        return "\n" * match.group(0).count("\n")  # keep line numbers
    +
    +    text = PRE.sub(blank, BLOCK.sub(blank, path.read_text(encoding="utf-8")))
    +    return [
    +        f"{path.relative_to(ROOT)}:{lineno}: {match.group(0)!r}"
    +        for lineno, line in enumerate(text.splitlines(), 1)
    +        for pattern in (TYPED_COUNT, BADGE_COUNT)
    +        for match in pattern.finditer(line)
    +    ]
    +
    +
    +def main(argv: list[str] | None = None) -> int:
    +    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    +    parser.add_argument("--check", action="store_true",
    +                        help="change nothing; exit 1 if the block is stale or a count is typed")
    +    parser.add_argument("--register", default=os.environ.get(REGISTER_ENV) or REGISTER_URL_DEFAULT,
    +                        help="claims register: a file path or an https URL")
    +    args = parser.parse_args(argv)
    +
    +    try:
    +        values = stat_values(read_register(args.register))
    +    except RegisterError as exc:
    +        print(f"sync_site_numbers: {exc}", file=sys.stderr)
    +        return 2
    +
    +    html = INDEX.read_text(encoding="utf-8")
    +    match = BLOCK.search(html)
    +    if match is None:
    +        print("sync_site_numbers: index.html has no stat:start/stat:end block", file=sys.stderr)
    +        return 2
    +    updated = html[: match.start()] + render_block(values, match.group("indent")) + html[match.end():]
    +    summary = ", ".join(f"{claim_id} {values[claim_id]}" for claim_id, _ in STATS)
    +
    +    if args.check:
    +        problems = []
    +        if updated != html:
    +            problems.append("index.html: the stat block is stale; run scripts/sync_site_numbers.py")
    +        for page in PAGES:
    +            problems += [f"{hit}: a count typed outside the stat block" for hit in typed_counts(page)]
    +        for problem in problems:
    +            print(problem, file=sys.stderr)
    +        if problems:
    +            return 1
    +        print(f"site numbers match the claims register: {summary}")
    +        return 0
    +
    +    if updated == html:
    +        print(f"index.html: stat block already current: {summary}")
    +    else:
    +        INDEX.write_text(updated, encoding="utf-8")
    +        print(f"index.html: stat block updated: {summary}")
    +    return 0
    +
    +
    +if __name__ == "__main__":
    +    sys.exit(main())
    diff --git a/tests/test_sync_site_numbers.py b/tests/test_sync_site_numbers.py
    new file mode 100644
    index 0000000..27f84b2
    --- /dev/null
    +++ b/tests/test_sync_site_numbers.py
    @@ -0,0 +1,111 @@
    +"""Tests for scripts/sync_site_numbers.py. Run: python -m pytest tests -q"""
    +
    +from __future__ import annotations
    +
    +import sys
    +from pathlib import Path
    +
    +import pytest
    +import yaml
    +
    +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
    +import sync_site_numbers as sync  # noqa: E402
    +
    +VALUES = {"C-01": 87, "C-03": 43, "C-06": 12, "C-07": 120, "C-08": 6}
    +
    +PAGE = """
    +
    + + +
    +

    Built-in attack chains.

    +
    Pattern     40 rules  .  always active
    +Findings: 2 Chains: 1 + +""" + + +def write_register(path: Path, values: dict, schema: str = sync.REGISTER_SCHEMA) -> Path: + claims = [{"id": cid, "expect": value} for cid, value in values.items()] + path.write_text(yaml.safe_dump({"schema": schema, "claims": claims}), encoding="utf-8") + return path + + +@pytest.fixture +def site(tmp_path, monkeypatch): + (tmp_path / "docs").mkdir() + (tmp_path / "index.html").write_text(PAGE, encoding="utf-8") + (tmp_path / "docs" / "index.html").write_text("

    Detection rules

    \n", encoding="utf-8") + (tmp_path / "README.md").write_text("Published records.\n", encoding="utf-8") + monkeypatch.setattr(sync, "ROOT", tmp_path) + monkeypatch.setattr(sync, "INDEX", tmp_path / "index.html") + monkeypatch.setattr(sync, "PAGES", tuple(tmp_path / p for p in ("index.html", "docs/index.html", "README.md"))) + return tmp_path + + +def test_writes_every_value_then_check_passes(site): + register = write_register(site / "register.yaml", VALUES) + assert sync.main(["--register", str(register)]) == 0 + html = (site / "index.html").read_text(encoding="utf-8") + for claim_id, label in sync.STATS: + assert f'stat-num">{VALUES[claim_id]}
    {label}' in html + assert sync.main(["--register", str(register), "--check"]) == 0 + + +def test_rewrite_is_idempotent(site): + register = write_register(site / "register.yaml", VALUES) + sync.main(["--register", str(register)]) + first = (site / "index.html").read_text(encoding="utf-8") + sync.main(["--register", str(register)]) + assert (site / "index.html").read_text(encoding="utf-8") == first + + +def test_check_fails_when_the_register_changes(site): + sync.main(["--register", str(write_register(site / "register.yaml", VALUES))]) + changed = write_register(site / "changed.yaml", {**VALUES, "C-01": 90}) + assert sync.main(["--register", str(changed), "--check"]) == 1 + + +def test_check_flags_a_count_typed_outside_the_block(site): + register = write_register(site / "register.yaml", VALUES) + sync.main(["--register", str(register)]) + (site / "README.md").write_text("Covers 51 AVE records.\n", encoding="utf-8") + assert sync.main(["--register", str(register), "--check"]) == 1 + + +def test_check_flags_a_count_in_a_badge(site): + (site / "README.md").write_text("![AVE](https://img.shields.io/badge/AVE_records-51-1DB894)\n", encoding="utf-8") + assert sync.typed_counts(site / "README.md") == ["README.md:1: 'records-51'"] + + +def test_samples_are_not_counts(site): + assert sync.typed_counts(site / "index.html") == [] + + +@pytest.mark.parametrize("values", [ + {k: v for k, v in VALUES.items() if k != "C-03"}, # missing + {**VALUES, "C-07": None}, # null + {**VALUES, "C-08": "6"}, # not a number + {**VALUES, "C-06": True}, # bool is not a count + {**VALUES, "C-01": -1}, # negative +]) +def test_a_bad_value_exits_2_and_writes_nothing(site, values): + before = (site / "index.html").read_text(encoding="utf-8") + assert sync.main(["--register", str(write_register(site / "register.yaml", values))]) == 2 + assert (site / "index.html").read_text(encoding="utf-8") == before + + +def test_wrong_schema_exits_2(site): + register = write_register(site / "register.yaml", VALUES, schema="something/else") + assert sync.main(["--register", str(register)]) == 2 + + +@pytest.mark.parametrize("source", ["http://example.com/register.yaml", "file:///etc/passwd", "register.yaml"]) +def test_only_a_file_or_an_https_url_is_read(site, source): + assert sync.main(["--register", source]) == 2 + + +def test_missing_markers_exit_2(site): + (site / "index.html").write_text("\n", encoding="utf-8") + register = write_register(site / "register.yaml", VALUES) + assert sync.main(["--register", str(register)]) == 2 From 537c50057bb55a8cfa66c33dc84f90d01fd2a252 Mon Sep 17 00:00:00 2001 From: chaksaray Date: Wed, 21 Oct 2026 05:57:54 +0700 Subject: [PATCH 2/2] fix: registry link on the docs page moves to aveproject.org This pull request already rewrites the line (the count is gone), so the link changes with it. The host migration in #25 makes the same edit, so the two merge cleanly in either order. aveproject.org/registry.html returns 200 and ave.bawbel.io is retired. Refs #22 Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: chaksaray --- docs/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.html b/docs/index.html index 6f55812..bdcaeba 100644 --- a/docs/index.html +++ b/docs/index.html @@ -788,7 +788,7 @@

    AVE record reference

    -

    Full registry of published records: ave.bawbel.io/registry.html

    +

    Full registry of published records: aveproject.org/registry.html