From 35ea4f202b801ca8dc371b05a3d2a050108c65e7 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 8 Sep 2026 18:55:54 +1200 Subject: [PATCH 1/7] feat: add agents-md, the AGENTS.md validation tool Every Charm Tech repo carries an AGENTS.md, several agents read it, and a stale line in one is worse than a missing line: agents trust the file over the repo, so a wrong line produces confident errors where absence would have produced exploration. This package is the deterministic half of the scheme for keeping them honest -- three checks and one fix, plus the ten per-repo question batteries the battery check reads. The checks were written as part of the charm-tech-baseline audit tool and would otherwise ship with it. They are separated here because the two have different consumers and very different cadences: the baseline audit runs against a repo when someone asks it to, while this runs monthly across the estate and on every PR that touches an AGENTS.md. Splitting them means the monthly routine can pin a package that is only these checks, and a change to either does not force a re-review of the other. The batteries move with the checks rather than staying with the skill, so that the code that reads them and the data it reads ship together. common.py and tier.py are the same as their charm-tech-baseline counterparts. That duplication is deliberate for now: the alternative is a third package for 150 lines of exit codes and a JSON emitter, and a forced release order between the two. Worth revisiting if a third tool wants them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XdsdR8QdcZY6GUJgd1Wt7c --- .github/workflows/ci.yaml | 2 +- README.md | 1 + agents-md/README.md | 39 ++ agents-md/pyproject.toml | 33 ++ .../src/charm_tech_code/agents_md/__init__.py | 12 + .../agents_md/assets/AGENTS.md.template | 40 ++ .../question-batteries/api_demo_server.yaml | 131 +++++ .../question-batteries/charm-ubuntu.yaml | 147 +++++ .../charmhub-listing-review.yaml | 506 +++++++++++++++++ .../assets/question-batteries/charmlibs.yaml | 428 ++++++++++++++ .../assets/question-batteries/concierge.yaml | 191 +++++++ .../assets/question-batteries/hyrum.yaml | 526 ++++++++++++++++++ .../assets/question-batteries/jubilant.yaml | 458 +++++++++++++++ .../assets/question-batteries/operator.yaml | 491 ++++++++++++++++ .../assets/question-batteries/pebble.yaml | 187 +++++++ .../question-batteries/pytest-jubilant.yaml | 166 ++++++ .../agents_md/checks/__init__.py | 0 .../agents_md/checks/agents_md.py | 77 +++ .../agents_md/checks/agents_md_battery.py | 333 +++++++++++ .../agents_md/checks/agents_md_content.py | 448 +++++++++++++++ .../src/charm_tech_code/agents_md/cli.py | 200 +++++++ .../src/charm_tech_code/agents_md/common.py | 155 ++++++ .../agents_md/fixes/__init__.py | 0 .../agents_md/fixes/add_agents_md.py | 44 ++ .../src/charm_tech_code/agents_md/tier.py | 108 ++++ .../tests/checks/test_agents_md_battery.py | 185 ++++++ .../tests/checks/test_agents_md_content.py | 200 +++++++ agents-md/tests/conftest.py | 45 ++ agents-md/tests/test_check_runner.py | 25 + agents-md/tests/test_detect_tier.py | 45 ++ agents-md/uv.lock | 224 ++++++++ 31 files changed, 5446 insertions(+), 1 deletion(-) create mode 100644 agents-md/README.md create mode 100644 agents-md/pyproject.toml create mode 100644 agents-md/src/charm_tech_code/agents_md/__init__.py create mode 100644 agents-md/src/charm_tech_code/agents_md/assets/AGENTS.md.template create mode 100644 agents-md/src/charm_tech_code/agents_md/assets/question-batteries/api_demo_server.yaml create mode 100644 agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charm-ubuntu.yaml create mode 100644 agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charmhub-listing-review.yaml create mode 100644 agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charmlibs.yaml create mode 100644 agents-md/src/charm_tech_code/agents_md/assets/question-batteries/concierge.yaml create mode 100644 agents-md/src/charm_tech_code/agents_md/assets/question-batteries/hyrum.yaml create mode 100644 agents-md/src/charm_tech_code/agents_md/assets/question-batteries/jubilant.yaml create mode 100644 agents-md/src/charm_tech_code/agents_md/assets/question-batteries/operator.yaml create mode 100644 agents-md/src/charm_tech_code/agents_md/assets/question-batteries/pebble.yaml create mode 100644 agents-md/src/charm_tech_code/agents_md/assets/question-batteries/pytest-jubilant.yaml create mode 100644 agents-md/src/charm_tech_code/agents_md/checks/__init__.py create mode 100644 agents-md/src/charm_tech_code/agents_md/checks/agents_md.py create mode 100644 agents-md/src/charm_tech_code/agents_md/checks/agents_md_battery.py create mode 100644 agents-md/src/charm_tech_code/agents_md/checks/agents_md_content.py create mode 100644 agents-md/src/charm_tech_code/agents_md/cli.py create mode 100644 agents-md/src/charm_tech_code/agents_md/common.py create mode 100644 agents-md/src/charm_tech_code/agents_md/fixes/__init__.py create mode 100644 agents-md/src/charm_tech_code/agents_md/fixes/add_agents_md.py create mode 100644 agents-md/src/charm_tech_code/agents_md/tier.py create mode 100644 agents-md/tests/checks/test_agents_md_battery.py create mode 100644 agents-md/tests/checks/test_agents_md_content.py create mode 100644 agents-md/tests/conftest.py create mode 100644 agents-md/tests/test_check_runner.py create mode 100644 agents-md/tests/test_detect_tier.py create mode 100644 agents-md/uv.lock diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 01451d9..b1177a7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,7 +14,7 @@ jobs: fail-fast: false matrix: # One entry per tool. Add a directory here when you add a package. - package: [ai-failure-notifier] + package: [agents-md, ai-failure-notifier] python-version: ['3.10', '3.12', '3.14'] defaults: run: diff --git a/README.md b/README.md index 91ba797..958db92 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Each tool is its own package in its own top-level directory, with its own `pypro | directory | what it does | |---|---| +| [`agents-md`](agents-md) | Checks that a repository's `AGENTS.md` is current and load-bearing. | | [`ai-failure-notifier`](ai-failure-notifier) | Triages and enriches the issue opened when a scheduled workflow fails. | Code here is consumed by workflow YAML in the repository that runs it, pinned by commit SHA: diff --git a/agents-md/README.md b/agents-md/README.md new file mode 100644 index 0000000..2f3debc --- /dev/null +++ b/agents-md/README.md @@ -0,0 +1,39 @@ +# agents-md + +Keeps the `AGENTS.md` files across the Charm Tech estate current and +load-bearing. It implements the deterministic half of the validation design in +the repo-setup notes: a line in `AGENTS.md` earns its place either as an +*override* (the agent would confidently do the wrong thing without it) or as a +*cache* (the agent would get there eventually, by reading the Makefile, tox +config and CI every session). A stale line is worse than a missing one, because +agents trust the file over the repo. + +## Checks + +| ID | What it does | +|---|---| +| `agents-md` | The file exists, and is a pointer rather than an encyclopaedia. | +| `agents-md-content` | Staleness. Extracts every command, path, symbol and tool version, runs or resolves each against the repo, and reports what no longer exists. | +| `agents-md-battery` | Runs the repo's question battery: question, checkable answer, source line. Tests whether the file changes what an agent does, rather than whether it conforms to a style. | + +`agents-md fix add-agents-md` writes the template into a repo that has none. + +## Use + +``` +uvx --from charm-tech-code-agents-md agents-md check +uvx --from charm-tech-code-agents-md agents-md check --only=agents-md-content --format=markdown +uvx --from charm-tech-code-agents-md agents-md list +``` + +The tier (`product`, `canonical`, `personal`) is detected from the repo's +origin remote, following a fork to its upstream, and decides which checks +apply. Pass `--tier=` to override it. + +## Question batteries + +`assets/question-batteries/*.yaml`, one per repo, keyed by upstream name. They +live here rather than in the skill so that the check and the data it reads ship +together. Each entry carries the question, the answer that counts as correct, +and the line of `AGENTS.md` it came from, so a battery failure points at the +line to fix. diff --git a/agents-md/pyproject.toml b/agents-md/pyproject.toml new file mode 100644 index 0000000..71b0615 --- /dev/null +++ b/agents-md/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "charm-tech-code-agents-md" +version = "0.1.0" +description = "Check that a repository's AGENTS.md is current and load-bearing." +readme = "README.md" +requires-python = ">=3.10" +authors = [ + {name = "The Charm Tech team at Canonical Ltd."}, +] +license = "Apache-2.0" +# PyYAML only, for the question batteries. Everything else is stdlib, and +# `gh` and `git` are called as subprocesses rather than through a library. +dependencies = ["pyyaml"] + +[project.scripts] +agents-md = "charm_tech_code.agents_md:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/charm_tech_code"] + +[dependency-groups] +unit = ["pytest"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +# Ruff configuration is at the root of the monorepo, deliberately not repeated +# here: ruff uses the closest config it finds rather than merging, so a +# [tool.ruff] block in this file would silently override the shared one. diff --git a/agents-md/src/charm_tech_code/agents_md/__init__.py b/agents-md/src/charm_tech_code/agents_md/__init__.py new file mode 100644 index 0000000..1180e6f --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/__init__.py @@ -0,0 +1,12 @@ +"""Keep the estate's AGENTS.md files honest. + +Three checks and one fix, plus the per-repo question batteries they read. +The design they implement is `agents-md-validation.md` in the repo-setup +notes: layer 1 is deterministic staleness detection, layer 2 is the +behavioural battery. The agent-facing half lives in the +`charm-tech-baseline` skill in `canonical/charm-tech`. +""" + +from .cli import main + +__all__ = ['main'] diff --git a/agents-md/src/charm_tech_code/agents_md/assets/AGENTS.md.template b/agents-md/src/charm_tech_code/agents_md/assets/AGENTS.md.template new file mode 100644 index 0000000..2615629 --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/assets/AGENTS.md.template @@ -0,0 +1,40 @@ +# AGENTS.md + + + +## What this repo is + +{{REPO_DESCRIPTION_ONE_SENTENCE}} + +## Dev setup + +```bash +{{SETUP_COMMANDS}} +``` + +## Tests + +```bash +{{TEST_COMMANDS}} +``` + +## Lint + +```bash +{{LINT_COMMANDS}} +``` + +## Conventions + +- Commits follow [Conventional Commits](https://www.conventionalcommits.org/). +- PRs are reviewed before merge; CI must pass. +- For deeper guidance see [{{DEPTH_LINK_TITLE}}]({{DEPTH_LINK}}). + +## Security + +See [SECURITY.md](SECURITY.md). diff --git a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/api_demo_server.yaml b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/api_demo_server.yaml new file mode 100644 index 0000000..8605c4a --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/api_demo_server.yaml @@ -0,0 +1,131 @@ +# Question battery for canonical/api_demo_server AGENTS.md. +# Schema: question-batteries.md in the charm-tech-baseline skill +# (skills/engineering/charm-tech-baseline/references/) in +# canonical/charm-tech. The batteries themselves live here, in the +# agents-md package, so that the check that reads them and the data it +# reads ship together. +schema_version: 1 +repo: api_demo_server +upstream: canonical/api_demo_server +source: + agents_md_ref: chore/agents-md + agents_md_sha: 64de5286216bcae8e88022e340da0e8ee791f797 + agents_md_sha256: 2e9924552dc340e268f425606e62d23e7e84287eaab42aecba9b1c415a8a3266 + seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (api_demo_server table) + seeded_on: 2026-08-19 + +entries: + - id: type-checker + question: Which type checker does this repo use? + classification: override + source_line: >- + **Type checking uses [`ty`](https://github.com/astral-sh/ty)**, not mypy or + pyright; it's pinned in the `dev` dependency group and run via `make lint`. + answer: + grade: keywords + require: + - ty + reject: + - mypy + - pyright + verify: + - kind: text_in_file + file: pyproject.toml + pattern: '"ty>=' + - kind: text_in_file + file: Makefile + pattern: ty check + ci_verifiable: true + note: >- + An agent assumes mypy or pyright. `ty` is recent enough that this is a + confident-wrong-answer case rather than a slow-derivation one. + + - id: no-unit-tests + question: Where are this repo's unit tests, and what does `make integration` actually do? + classification: cache + source_line: >- + There are no unit tests — `make integration` (`.scripts/integration-test.sh`) + brings the stack up with `docker compose`, exercises the create/add/list + endpoints over HTTP, and tears it down. + answer: + grade: judgement + rubric: >- + A correct reply must say there are no unit tests, and that the only test + target is a docker-compose smoke test over HTTP. A reply that merely + names `make integration` has not saved the fruitless hunt for a unit + suite, which is the entire value of the line — and keyword grading + cannot tell those two replies apart. + verify: + - kind: path_exists + path: .scripts/integration-test.sh + - kind: text_in_file + file: Makefile + pattern: "^integration:" + ci_verifiable: false + gated_by: Docker — no daemon in the check sandbox, and none in the Layer 1 runner + + - id: exact-pinned-runtime-deps + question: How are this repo's runtime dependencies versioned, and may you relax them? + classification: override + source_line: >- + **Runtime deps are exact-pinned** in `pyproject.toml` (e.g. `fastapi==…`); + keep them pinned and let Dependabot bump them. + answer: + grade: keywords + require: + - pin + - Dependabot + verify: + - kind: text_in_file + file: pyproject.toml + pattern: fastapi==\d + ci_verifiable: true + note: >- + The failure this prevents is an agent "helpfully" loosening `==` to `>=` + during an unrelated change. Nothing in CI would reject that, so the line + is the only guard. + + - id: lint-command + question: What command lints this repo? + classification: cache + source_line: "make lint # ruff check; ruff format --diff; ty check" + answer: + grade: command + expect: make lint + verify: + - kind: text_in_file + file: Makefile + pattern: "^lint:" + ci_verifiable: true + + - id: format-command + question: What command formats this repo? + classification: cache + source_line: "make format # uv run ruff format; ruff check --fix" + answer: + grade: command + expect: make format + verify: + - kind: text_in_file + file: Makefile + pattern: "^format:" + ci_verifiable: true + note: >- + Verifiable per scope decisions §1 — tree-mutating commands are run, then + asserted diff-clean and restored, rather than gated. Settled but not yet + implemented in agents-md-content.py, which still routes `make format` + environment-gated. + + - id: integration-command + question: What command runs this repo's tests? + classification: cache + source_line: "make integration # docker compose up + curl smoke checks (needs Docker)" + answer: + grade: command + expect: make integration + verify: + - kind: text_in_file + file: .github/workflows/integration-test.yaml + pattern: integration + ci_verifiable: false + gated_by: Docker daemon diff --git a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charm-ubuntu.yaml b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charm-ubuntu.yaml new file mode 100644 index 0000000..1da0ca6 --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charm-ubuntu.yaml @@ -0,0 +1,147 @@ +# Question battery for canonical/charm-ubuntu AGENTS.md. +# Schema: question-batteries.md in the charm-tech-baseline skill +# (skills/engineering/charm-tech-baseline/references/) in +# canonical/charm-tech. The batteries themselves live here, in the +# agents-md package, so that the check that reads them and the data it +# reads ship together. +schema_version: 1 +repo: charm-ubuntu +upstream: canonical/charm-ubuntu +source: + agents_md_ref: chore/agents-md + agents_md_sha: 6350aa338ba5a9b7fcfc0131cf44fa4576c14d16 + agents_md_sha256: f874b13341c375e0675b2d2df402466604d866d2099e3c172271c85a3654bd19 + seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (charm-ubuntu table) + seeded_on: 2026-08-19 + +entries: + - id: formatter-and-linter + question: Which formatter and linter does this repo use, and at what line length? + classification: override + source_line: >- + **`black` and `flake8`, not `ruff`** — line length 88. + answer: + grade: keywords + require: + - black + - flake8 + - "88" + reject: + - ruff + verify: + - kind: text_in_file + file: tox.ini + pattern: ^\s*black$ + - kind: text_in_file + file: tox.ini + pattern: ^\s*flake8$ + - kind: text_in_file + file: tox.ini + pattern: max-line-length = 88 + ci_verifiable: true + note: >- + The exemplar override for the whole scheme: an agent reaches for ruff + unprompted, and ruff is not installed here, so the mistake costs a failed + lint run rather than a wrong-but-working result. + + - id: test-pythonpath + question: How do this repo's tests import the charm module? + classification: override + source_line: Tests set `PYTHONPATH=src` so `import charm` resolves. + answer: + grade: keywords + require: + - PYTHONPATH + - src + verify: + - kind: text_in_file + file: tox.ini + pattern: PYTHONPATH=\{toxinidir\}/src + ci_verifiable: true + + - id: python-floor + question: Which Python versions must this charm's code stay compatible with? + classification: override + source_line: >- + **Wide Python support:** CI lints and unit-tests on 3.6, 3.8, 3.10, and 3.12. + Keep `src/charm.py` compatible with 3.6. + answer: + grade: keywords + require: + - "3.6" + verify: + - kind: text_in_file + file: .github/workflows/tests.yml + pattern: "'3\\.6'" + ci_verifiable: true + note: >- + Per the design doc this override is only ever evicted by the constraint + disappearing — i.e. by 3.6 leaving the CI matrix this entry watches, not + by an eval result. + + - id: ops-api-pin + question: Which version of `ops` does this charm target? + classification: override + source_line: >- + **`ops` is pinned to `>=1.0,<2.0`** (`requirements.txt`) — this charm tracks + the 1.x API, not current `ops`. + answer: + grade: keywords + require: + - ">=1.0,<2.0" + verify: + - kind: text_in_file + file: requirements.txt + pattern: ops>=1\.0,<2\.0 + ci_verifiable: true + note: >- + An agent writes current-ops idioms by default; on the 1.x API those are + not merely stylistically off, they do not exist. + + - id: lint-command + question: What command lints this repo? + classification: cache + source_line: "tox -e lint # flake8 + black --check (this charm uses black, not ruff)" + answer: + grade: command + expect: tox -e lint + verify: + - kind: text_in_file + file: tox.ini + pattern: \[testenv:lint\] + ci_verifiable: true + + - id: unit-test-command + question: What command runs the unit tests? + classification: cache + source_line: "tox -e unit # unit tests under tests/unit" + answer: + grade: command + expect: tox -e unit + verify: + - kind: text_in_file + file: tox.ini + pattern: \[testenv:unit\] + ci_verifiable: true + + - id: integration-prerequisites + question: What does running the integration tests require? + classification: cache + source_line: "tox -e integration # deploys to LXD; needs juju and charmcraft (packs the charm)" + answer: + grade: keywords + require: + - LXD + - juju + - charmcraft + verify: + - kind: text_in_file + file: tox.ini + pattern: \[testenv:integration\] + - kind: path_exists + path: .charmcraft-channel + ci_verifiable: false + gated_by: >- + LXD + a Juju controller + charmcraft (channel pinned in + .charmcraft-channel) — the file's own prose says it cannot run without + that environment diff --git a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charmhub-listing-review.yaml b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charmhub-listing-review.yaml new file mode 100644 index 0000000..a21330e --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charmhub-listing-review.yaml @@ -0,0 +1,506 @@ +# Question battery for canonical/charmhub-listing-review AGENTS.md. +# Schema: question-batteries.md in the charm-tech-baseline skill +# (skills/engineering/charm-tech-baseline/references/) in +# canonical/charm-tech. The batteries themselves live here, in the +# agents-md package, so that the check that reads them and the data it +# reads ship together. +schema_version: 1 +repo: charmhub-listing-review +upstream: canonical/charmhub-listing-review +source: + agents_md_ref: chore/agents-md-trim + agents_md_sha: fda55bd3933686e7f70356e721666294a0ed123c + agents_md_sha256: c017ef66e1c1180f483ea163ea00eec5493ada49dfafe07c16491c139fdb76ee + seeded_from: >- + roadmap/26.10/repo-setup/agents-md-validation.md (implementation + follow-up 4, charmhub-listing-review review-and-seed) + seeded_on: 2026-08-27 + +entries: + - id: avoid-head-tail + question: >- + Should you pipe `make lint` or `make unit` output through `head` + or `tail` to keep the output short? + classification: override + source_line: >- + Avoid using `head` and `tail` with these commands, as that masks + issues. + answer: + grade: judgement + rubric: >- + A correct reply must say no, and that piping through head/tail + risks masking a failure that appears outside the truncated + window. A reply that treats truncating output as a harmless + convenience is wrong. + verify: + - kind: none + reason: >- + A documented agent-behaviour correction, not a fact + represented anywhere in the tree. (Its origin is anchored in + git history, not the working tree: PR #61's description says + "Claude seems to have picked up a habit of adding `| tail -n + x` to tox commands" and adds this line in response - a real, + observed failure mode, not generic advice.) + ci_verifiable: false + gated_by: a documented agent-behaviour rule with no static repo anchor + + - id: make-all + question: What single command runs both linting and the unit tests? + classification: cache + source_line: "# Run linting and unit tests make all" + answer: + grade: command + expect: make all + verify: + - kind: text_in_file + file: Makefile + pattern: "^all: lint unit" + ci_verifiable: true + + - id: make-lint + question: >- + What command lints this repo, and what four things does it + check? + classification: cache + source_line: >- + # Perform linting, spell checking, and static type checks make + lint + answer: + grade: judgement + rubric: >- + A correct reply must name the command `make lint` and cover + linting, formatting, spell checking and static type checking. + A reply that names only ruff, or only the command with no + sense of what it covers, has not saved the trip to the + Makefile. + verify: + - kind: text_in_file + file: Makefile + pattern: "^lint:" + ci_verifiable: true + + - id: make-unit + question: What command runs this repo's unit tests? + classification: cache + source_line: "# Run unit tests make unit" + answer: + grade: command + expect: make unit + verify: + - kind: text_in_file + file: Makefile + pattern: "^unit:" + ci_verifiable: true + + - id: make-unit-single-test + question: How do you run a single unit test via `make unit`? + classification: cache + source_line: >- + # Run a single test make unit + ARGS='tests/unit/test_evaluate.py::test_check_charm_name' + answer: + grade: command + expect: make unit ARGS='tests/unit/test_evaluate.py::test_check_charm_name' + verify: + - kind: text_in_file + file: Makefile + pattern: \$\(ARGS\) + - kind: path_exists + path: tests/unit/test_evaluate.py + ci_verifiable: true + + - id: make-format + question: What command formats this repo's Python code? + classification: cache + source_line: "# Format the Python code make format" + answer: + grade: command + expect: make format + verify: + - kind: text_in_file + file: Makefile + pattern: "^format:" + ci_verifiable: true + note: >- + Verifiable per scope decisions §1 - tree-mutating commands are + run, then asserted diff-clean and restored, rather than gated. + Settled but not yet implemented in agents-md-content.py, which + still routes `make format` environment-gated (confirmed in this + run's own evidence: reported under `environment_gated`, not + `runnable_checked`). + + - id: make-fix + question: >- + What command auto-fixes linting and formatting issues, and how + does it differ from `make format`? + classification: cache + source_line: "# Auto-fix linting and formatting issues make fix" + answer: + grade: judgement + rubric: >- + A correct reply must name `make fix` and say it also runs + `ruff check --fix` before formatting (not just formatting + alone, which is `make format`). A reply that treats the two + commands as equivalent misses the distinction. + verify: + - kind: text_in_file + file: Makefile + pattern: "^fix:" + ci_verifiable: true + note: Same scope-decisions §1 note as make-format. + + - id: pre-commit-install + question: How do you install this repo's pre-commit hooks? + classification: cache + source_line: "# Install pre-commit hooks pre-commit install" + answer: + grade: command + expect: pre-commit install + verify: + - kind: path_exists + path: .pre-commit-config.yaml + ci_verifiable: false + gated_by: >- + installs a git hook (side-effecting), and `pre-commit` itself is + not a declared project dependency, so it is not guaranteed + present in a check sandbox either - confirmed in this run's own + evidence (`missing_tools: pre-commit`). + + - id: entry-point-update-issue + question: >- + Which file implements the `update-issue` console-script entry + point? + classification: cache + source_line: >- + `update-issue`: Updates GitHub issues with review checklists + (`src/charmhub_listing_review/update_issue.py`) + answer: + grade: keywords + require: + - src/charmhub_listing_review/update_issue.py + verify: + - kind: text_in_file + file: pyproject.toml + pattern: 'update-issue = "charmhub_listing_review.update_issue:main"' + ci_verifiable: true + + - id: entry-point-self-review + question: >- + Which file implements the `self-review` console-script entry + point? + classification: cache + source_line: >- + `self-review`: CLI tool for charm authors to self-check before + submitting (`src/charmhub_listing_review/self_review.py`) + answer: + grade: keywords + require: + - src/charmhub_listing_review/self_review.py + verify: + - kind: text_in_file + file: pyproject.toml + pattern: 'self-review = "charmhub_listing_review.self_review:main"' + ci_verifiable: true + + - id: evaluate-module + question: >- + At a high level, what does `evaluate.py` do, and where does it + get the charm's content from to check it? + classification: cache + source_line: >- + **`src/charmhub_listing_review/evaluate.py`** - Automated charm + evaluation against listing criteria. Functions clone the charm + repo, check `charmcraft.yaml`, validate naming conventions, + verify URLs, and return Markdown checklist items + (ticked/unticked based on pass/fail). + answer: + grade: keywords + require: + - clone + - charmcraft.yaml + verify: + - kind: text_in_file + file: src/charmhub_listing_review/evaluate.py + pattern: "def _clone_repo\\(" + - kind: text_in_file + file: src/charmhub_listing_review/evaluate.py + pattern: "def _get_charmcraft_yaml\\(" + ci_verifiable: true + note: >- + `charmcraft.yaml` here is the target charm's file, cloned into a + temp dir by `_clone_repo` - not a path in this repo. Layer 1's + path checker flags it as a missing local path regardless + (checker noise on a real, well-known filename in backticks, not + staleness); left as-is rather than de-styled purely to clear the + finding, since removing the backticks would cost real clarity + for no correctness gain. + + - id: update-issue-module + question: >- + Where does the reviewer checklist's best-practices content come + from, and what does `update_issue.py` use to post to GitHub? + classification: cache + source_line: >- + **`src/charmhub_listing_review/update_issue.py`** - GitHub issue + management. Extracts data from listing request issues, generates + reviewer checklists (including best practices fetched from + canonical/operator), assigns reviewers from `reviewers.yaml`, + and posts/updates comments via `gh` CLI. + answer: + grade: keywords + require: + - canonical/operator + - gh + verify: + - kind: text_in_file + file: src/charmhub_listing_review/update_issue.py + pattern: BEST_PRACTICE_SOURCE + - kind: text_in_file + file: src/charmhub_listing_review/update_issue.py + pattern: canonical/operator + ci_verifiable: true + + - id: self-review-module + question: >- + What is `self_review.py` for, and how does its output differ + from `update-issue`'s? + classification: cache + source_line: >- + **`src/charmhub_listing_review/self_review.py`** - + Console-friendly version of the evaluation for charm authors to + run locally before submitting. + answer: + grade: keywords + require: + - console + - locally + verify: + - kind: text_in_file + file: src/charmhub_listing_review/self_review.py + pattern: console in a user-friendly format + ci_verifiable: true + + - id: reviewer-assignment + question: >- + How is a reviewer chosen for a listing request - is it + round-robin, or something else? + classification: cache + source_line: >- + `reviewers.yaml` maps GitHub usernames to charming teams. The + `assign_review()` function randomly selects a team, then a + reviewer from that team. + answer: + grade: keywords + require: + - random + - team + verify: + - kind: text_in_file + file: reviewers.yaml + pattern: "team:" + - kind: text_in_file + file: src/charmhub_listing_review/update_issue.py + pattern: "def assign_review\\(" + - kind: text_in_file + file: src/charmhub_listing_review/update_issue.py + pattern: random\.choice + ci_verifiable: true + + - id: python-floor + question: What is the minimum Python version this repo supports? + classification: override + source_line: "Python 3.12+, uses uv for dependency management" + answer: + grade: keywords + require: + - "3.12" + verify: + - kind: text_in_file + file: pyproject.toml + pattern: 'requires-python = ">=3\.12"' + ci_verifiable: true + + - id: uv-dependency-mgmt + question: >- + What tool manages dependencies in this repo - should you reach + for `pip install` or `poetry`? + classification: override + source_line: "Python 3.12+, uses uv for dependency management" + answer: + grade: keywords + require: + - uv + reject: + - poetry + verify: + - kind: text_in_file + file: pyproject.toml + pattern: uv-build + - kind: text_in_file + file: Makefile + pattern: uv run + ci_verifiable: true + + - id: quote-style + question: Single or double quotes for Python strings in this repo? + classification: override + source_line: "Ruff for linting/formatting with single quotes" + answer: + grade: keywords + require: + - single + verify: + - kind: text_in_file + file: pyproject.toml + pattern: 'quote-style = "single"' + ci_verifiable: true + + - id: type-checker + question: Which type checker does this repo use? + classification: override + source_line: "Type checking via ty" + answer: + grade: keywords + require: + - ty + reject: + - mypy + - pyright + verify: + - kind: text_in_file + file: pyproject.toml + pattern: '"ty",' + - kind: text_in_file + file: Makefile + pattern: ty check + ci_verifiable: true + note: >- + Same override shape as api_demo_server's type-checker entry - an + agent defaults to mypy or pyright without this line. + + - id: docstring-style + question: What docstring convention does this repo use? + classification: override + source_line: "Google-style docstrings" + answer: + grade: keywords + require: + - Google + verify: + - kind: text_in_file + file: pyproject.toml + pattern: 'convention = "google"' + ci_verifiable: true + + - id: commit-types + question: >- + Name at least two of the conventional-commit types this repo's + commit messages use. + classification: override + source_line: "Conventional commit messages (feat, fix, docs, ci, chore, etc.)" + answer: + grade: keywords + require: + - feat + - fix + verify: + - kind: text_in_file + file: .github/check-conventional-pr-title.py + pattern: "'feat'," + - kind: text_in_file + file: .github/check-conventional-pr-title.py + pattern: "'fix'," + ci_verifiable: true + + - id: copyright-header + question: >- + What must a new source file's copyright header say, and under + what license? + classification: override + source_line: "New files need Apache 2.0 copyright header with current year" + answer: + grade: keywords + require: + - Apache + - current year + verify: + - kind: text_in_file + file: CONTRIBUTING.md + pattern: current year + - kind: text_in_file + file: pyproject.toml + pattern: '"CPY",' + ci_verifiable: true + note: >- + Forward guidance for new files, not a claim that every existing + file complies: read against the (unshallowed) repo history, + `src/charmhub_listing_review/sphinx_refs.py` (added 2026-01-21) + and `.github/check-conventional-pr-title.py` (added 2026-06-10, + copied from operator) both still carry "Copyright 2025" headers. + Neither is this pass's to fix (out of scope, and CONTRIBUTING.md + says existing files' copyright years are not updated on + modification) - noted here because it is the kind of thing + Layer 1 cannot see and a re-tester should not be surprised by. + + - id: pr-title-no-scopes + question: Do PR titles in this repo use conventional-commit scopes? + classification: override + source_line: "PR titles use conventional commit format without scopes" + answer: + grade: judgement + rubric: >- + A correct reply must establish both halves: conventional + commit format, and that scopes are not used. A reply offering + `feat(evaluate): …` as an example title is wrong. + verify: + - kind: text_in_file + file: .github/check-conventional-pr-title.py + pattern: disallows scopes + - kind: text_in_file + file: CONTRIBUTING.md + pattern: too small to use scopes + ci_verifiable: true + + - id: rebase-then-merge-commits + question: >- + Before requesting review, should you rebase or merge onto main? + What about bringing in changes after review has started? + classification: override + source_line: >- + Rebase onto `main` before requesting review; use merge commits + for subsequent updates + answer: + grade: judgement + rubric: >- + A correct reply must say rebase onto main before the first + review request, then switch to merge commits (not further + rebases) once review is under way. A reply recommending + rebasing throughout is wrong. + verify: + - kind: text_in_file + file: CONTRIBUTING.md + pattern: please use a merge commit + - kind: text_in_file + file: CONTRIBUTING.md + pattern: rebase your pull request onto the .{0,10}main + ci_verifiable: true + + - id: pr-title-becomes-commit + question: What becomes the commit message when a PR is merged here? + classification: override + source_line: "Squash merge to `main` using PR title as commit message" + answer: + grade: keywords + require: + - PR title + - squash + verify: + - kind: text_in_file + file: CONTRIBUTING.md + pattern: squashed into a single commit + ci_verifiable: false + gated_by: >- + the repo's actual GitHub squash-merge configuration, readable + only via repo settings or the API, not from a checkout - + CONTRIBUTING.md states the policy, but nothing in the tree + confirms GitHub is actually configured to enforce it. diff --git a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charmlibs.yaml b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charmlibs.yaml new file mode 100644 index 0000000..95b46d1 --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charmlibs.yaml @@ -0,0 +1,428 @@ +# Question battery for canonical/charmlibs AGENTS.md. +# Schema: question-batteries.md in the charm-tech-baseline skill +# (skills/engineering/charm-tech-baseline/references/) in +# canonical/charm-tech. The batteries themselves live here, in the +# agents-md package, so that the check that reads them and the data it +# reads ship together. +schema_version: 1 +repo: charmlibs +upstream: canonical/charmlibs +source: + agents_md_ref: chore/agents-md-trim + agents_md_sha: 53d9f2460529c0ce35c270d0aae795d590cae4af + agents_md_sha256: 5b592217687533d399dcadb2fc0f0c46b0fb30938ecb2a3023ccbe35bd3c61ad + seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (implementation follow-up 4, charmlibs trim) + seeded_on: 2026-08-26 + +entries: + - id: check-command + question: What single command runs the standard pre-commit check for a charmlibs library, and what does it do? + classification: cache + source_line: >- + This runs `just lint `, `just unit `, and `just docs + html `. **Run this before every commit on the affected + package.** + answer: + grade: keywords + require: + - just check + - lint + - unit + - docs + verify: + - kind: text_in_file + file: .scripts/just.py + pattern: '"""`lint`, `unit` test, and build the `docs` for a package\."""' + ci_verifiable: true + + - id: package-arg-format + question: When a charmlibs `just` command takes a `` argument, what do you pass for it? + classification: cache + source_line: >- + The `` argument is the path from the repo root, e.g. `pathops` + or `interfaces/tls-certificates`. + answer: + grade: keywords + require: + - path + - repo root + verify: + - kind: path_exists + path: pathops + - kind: path_exists + path: interfaces/tls-certificates + ci_verifiable: true + + - id: lint-command + question: What does `just lint ` run in charmlibs? + classification: cache + source_line: "`just lint ` | ruff + pyright" + answer: + grade: keywords + require: + - ruff + - pyright + reject: + - codespell + verify: + - kind: text_in_file + file: .scripts/just.py + pattern: 'Run fast linting \(`ruff`\) and static analysis \(`pyright`\) for a package\.' + ci_verifiable: true + note: >- + Corrected during this trim: the pre-trim file said "ruff + codespell + + pyright". codespell is declared in pyproject.toml's fast-lint + dependency group but is not invoked by `_fast_lint()`, `lint()`, or any + current workflow — confirmed by grepping .scripts/, the justfiles, and + .github/workflows/ for an actual codespell invocation and finding none. + Layer 1 has no check for this (it is prose semantics, not a missing + tool/path/symbol), so this was only found by reading the code — the + same category as concierge's "Configuration Priority" finding. + + - id: fast-lint-command + question: What does `just fast-lint [path]` run, and how is it different from `just lint`? + classification: cache + source_line: "`just fast-lint [path]` | ruff only, across the whole repo or a specific path" + answer: + grade: keywords + require: + - ruff + verify: + - kind: text_in_file + file: .scripts/just.py + pattern: "Run `ruff`, failing afterwards if any errors are found\\." + ci_verifiable: true + + - id: never-run-functional-on-host + question: Where should you run charmlibs functional tests, and why not directly on your machine? + classification: override + source_line: >- + **Do not run functional tests directly on the host.** Use Workshop + instead (see below). + answer: + grade: keywords + require: + - Workshop + - host + verify: + - kind: text_in_file + file: CONTRIBUTING.md + pattern: isolated container instead of running them directly on your host + ci_verifiable: true + note: >- + Destructive-relevant override: functional tests may install or remove + system packages, matching the design doc's "wrong without, right with" + case for irreversible-local-action prohibitions (concierge's + exec.Command() ban is the precedent). + + - id: workshop-image-names + question: Which Workshop image do you use to run functional tests against Ubuntu 24.04? + classification: cache + source_line: >- + workshop exec noble -- sudo just functional # Ubuntu + 24.04 + answer: + grade: command + expect: workshop exec noble -- sudo just functional + verify: + - kind: text_in_file + file: CONTRIBUTING.md + pattern: "workshop exec resolute -- sudo just functional" + ci_verifiable: true + note: >- + The resolute/noble/jammy → 26.04/24.04/22.04 mapping is not written + down anywhere else in the tree — cache value is the lookup, not the + existence of Workshop itself. + + - id: no-direct-uv-add + question: How do you add a dependency to a charmlibs library, and why not `uv add` directly? + classification: override + source_line: >- + **Always use `just add ` instead of calling `uv add` + directly.** This applies repo-level version constraints from + `test-requirements.txt`, which is necessary to keep the lockfile + consistent: + answer: + grade: keywords + require: + - just add + - test-requirements.txt + reject: + - uv add + verify: + - kind: text_in_file + file: CONTRIBUTING.md + pattern: rather than calling `uv add` directly + ci_verifiable: true + + - id: test-type-directory-gate + question: How do you make charmlibs skip a test type (e.g. functional) for a library entirely? + classification: cache + source_line: >- + A test type is only executed if the corresponding `tests/` + subdirectory exists. Remove a directory to skip that test type + entirely. + answer: + grade: keywords + require: + - tests/ + - remove + verify: + - kind: text_in_file + file: .github/workflows/test-package.yaml + pattern: hashFiles\(format\('\{0\}/tests/integration/pack\.sh' + ci_verifiable: true + note: >- + Anchored on the integration case (CI gates the pack/deploy jobs on + `hashFiles(.../tests/integration/pack.sh)`); unit and functional follow + the same directory-presence pattern in .scripts/just.py's package + discovery but without one single grep-able line as clean as this one. + + - id: pr-title-becomes-commit + question: What becomes the commit message when a charmlibs pull request is merged? + classification: override + source_line: >- + **PRs are squash-merged.** The PR title becomes the single commit + message on `main`. + answer: + grade: keywords + require: + - PR title + - squash + verify: + - kind: text_in_file + file: CONTRIBUTING.md + pattern: "PRs are squash-merged, so your PR title becomes the single commit message" + ci_verifiable: true + + - id: pr-title-scope-convention + question: When a charmlibs PR affects a single library, what scope do you use in the conventional-commit PR title? + classification: override + source_line: >- + When a PR affects a single library, use the distribution package name + without the leading `charmlibs-` as the scope + answer: + grade: keywords + require: + - distribution package name + - charmlibs- + verify: + - kind: text_in_file + file: .github/workflows/conventional-pr-title.yaml + pattern: conventional-pr-title + - kind: text_in_file + file: CONTRIBUTING.md + pattern: without the leading `charmlibs-` + ci_verifiable: true + + - id: one-library-per-pr + question: How many libraries should a single charmlibs PR normally touch, and why? + classification: override + source_line: >- + **One PR should normally touch only one library.** The CI uses changed + files to determine which packages to test and, on merge, which + packages to publish. + answer: + grade: keywords + require: + - one library + - changed files + verify: + - kind: path_exists + path: .github/get-changed.py + - kind: text_in_file + file: .github/workflows/ci.yaml + pattern: get-changed\.py + ci_verifiable: true + + - id: changelog-gate + question: What must you update in the same PR as a non-dev version bump, and what happens if you don't? + classification: override + source_line: >- + When bumping to a non-dev version, you **must** also update + `CHANGELOG.md`. CI will block the merge otherwise. + answer: + grade: keywords + require: + - CHANGELOG.md + - block + verify: + - kind: text_in_file + file: .github/workflows/ci.yaml + pattern: CHANGELOG\.md must be updated before merging + ci_verifiable: true + + - id: dev-version-exclusion + question: How do you land in-progress work on a library without triggering a release? + classification: cache + source_line: >- + Dev versions (`X.Y.Z.devN`) are excluded from release CI — safe for + in-progress work. + answer: + grade: keywords + require: + - dev + - release + verify: + - kind: text_in_file + file: .scripts/ls.py + pattern: Excludes changes where the new version is a dev version\. + ci_verifiable: true + + - id: interface-naming + question: What determines the directory name of an interface library in charmlibs? + classification: override + source_line: >- + Live under `interfaces//`, named exactly as the + interface name appears in `charmcraft.yaml`. + answer: + grade: keywords + require: + - charmcraft.yaml + verify: + - kind: text_in_file + file: interfaces/tls-certificates/tests/integration/charms/provider/charmcraft.yaml + pattern: "interface: tls-certificates" + - kind: text_in_file + file: .scripts/just.py + pattern: "The project name should be the canonical interface" + ci_verifiable: true + + - id: interface-no-functional-tests + question: Which of the three test types do charmlibs interface libraries typically NOT have, and why? + classification: cache + source_line: >- + Typically have unit and integration tests but no functional tests (all + meaningful interaction is through Juju). + answer: + grade: judgement + rubric: >- + A correct reply must say interface libraries typically lack + functional tests, and that this is because interfaces only interact + through Juju relation data, not real external processes — the thing + functional tests exercise. A reply that only names "no functional + tests" without the reason has not saved the agent from later adding + a needless functional/ directory. + verify: + - kind: none + reason: >- + A repo-wide absence claim (no interface library currently has a + tests/functional/ directory) is not expressible with the existing + verify kinds, which assert presence, not absence. Confirmed by + hand: `find interfaces -maxdepth 3 -type d -name functional` + (excluding the .example/.template scaffolds) returns nothing + across all 74 interfaces. + ci_verifiable: false + gated_by: >- + no verify kind for "does not exist anywhere in a set of directories" — + would need a repo-wide scan, which the battery schema deliberately + keeps out of scope (see references/question-batteries.md's note that + assertions are static single-file checks) + + - id: init-interface-scaffold + question: How do you scaffold a new interface library in charmlibs? + classification: cache + source_line: "Use `just init --interface` to scaffold." + answer: + grade: command + expect: just init --interface + verify: + - kind: text_in_file + file: .scripts/just.py + pattern: "'--interface'," + ci_verifiable: true + + - id: docstrings-appear-verbatim + question: What should you keep in mind when writing or editing a docstring in a charmlibs library's `__init__.py`? + classification: override + source_line: >- + remember they appear verbatim in the published reference at + [canonical.com/juju/docs/charmlibs](https://canonical.com/juju/docs/charmlibs). + Keep them informative for library users, not implementation notes. + answer: + grade: keywords + require: + - verbatim + - published + reject: + - implementation + verify: + - kind: text_in_file + file: CONTRIBUTING.md + pattern: appears verbatim in the published reference + ci_verifiable: true + + - id: no-blockquote-read-more + question: >- + What markdown form does charmlibs use for a "Read more" / + "See also" cross-reference line in docs, and what form should you + avoid? + classification: override + source_line: >- + Don't use block quotes (`>`) for "Read more", "See also", or similar + cross-reference sections. Instead, use bare text + answer: + grade: keywords + require: + - bare text + reject: + - "> Read more" + verify: + - kind: none + reason: >- + No repo-level linter enforces this (a .docs/ vale config exists + but its rule set was not confirmed to cover this specific case); + the only evidence is that existing docs pages (e.g. + .docs/tutorial.md) consistently use the bare-text form in + practice, which a text_in_file assertion can't distinguish from + coincidence. + ci_verifiable: false + gated_by: no confirmed linter rule; a style convention enforced by review, not tooling + + - id: no-unnecessary-refactoring + question: >- + Why should you avoid adding features or refactoring code beyond what + a charmlibs task asks for? + classification: override + source_line: >- + **Don't add unnecessary features or refactor code beyond what's + asked** — this is a multi-team monorepo with careful versioning; + unintended public API changes require major version bumps. + answer: + grade: judgement + rubric: >- + A correct reply must connect the prohibition to its actual cost: + this is a multi-team monorepo, so an unintended public API change on + a shared library forces a major version bump that affects every + consuming team, not just a local style preference. + verify: + - kind: none + reason: >- + No file states or enforces a scope-discipline rule; CI does not + gate on "did this PR change more than it needed to". Behavioral + norm only. + ci_verifiable: false + gated_by: not represented in the tree at all — an editorial norm, not a checked rule + + - id: integration-test-tooling + question: >- + What tool do charmlibs integration tests use to drive Juju, and what + sets up the Juju environment in CI? + classification: cache + source_line: >- + Integration tests use [Jubilant](https://canonical.com/juju/docs/jubilant/) + as the Juju test client, and CI provisions the Juju environment with + [Concierge](https://raw.githubusercontent.com/canonical/concierge/refs/heads/main/README.md). + answer: + grade: keywords + require: + - Jubilant + - Concierge + verify: + - kind: text_in_file + file: pathops/tests/integration/conftest.py + pattern: import jubilant + - kind: text_in_file + file: .github/workflows/test-package.yaml + pattern: concierge + ci_verifiable: true diff --git a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/concierge.yaml b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/concierge.yaml new file mode 100644 index 0000000..d94df32 --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/concierge.yaml @@ -0,0 +1,191 @@ +# Question battery for canonical/concierge AGENTS.md. +# Schema: question-batteries.md in the charm-tech-baseline skill +# (skills/engineering/charm-tech-baseline/references/) in +# canonical/charm-tech. The batteries themselves live here, in the +# agents-md package, so that the check that reads them and the data it +# reads ship together. +schema_version: 1 +repo: concierge +upstream: canonical/concierge +source: + agents_md_ref: chore/agents-md-trim + agents_md_sha: b6d4afb8045ee93a29039eca982c865788f854a9 + agents_md_sha256: af058c9133d01163ecc7c8c7915559064d364f3cf756735ab03b7ca00a3e7fc7 + seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (implementation follow-up 4, concierge trim) + seeded_on: 2026-08-26 + +entries: + - id: build-binary + question: What command builds the concierge binary? + classification: cache + source_line: go build + answer: + grade: command + expect: go build + verify: + - kind: path_exists + path: main.go + ci_verifiable: true + + - id: snapshot-release + question: How do you build a local snapshot release with goreleaser? + classification: cache + source_line: goreleaser build --clean --snapshot + answer: + grade: command + expect: goreleaser build --clean --snapshot + verify: + - kind: path_exists + path: .goreleaser.yaml + ci_verifiable: false + gated_by: >- + goreleaser must be installed first (CI does `sudo snap install --classic + goreleaser`) — not present in the check sandbox + + - id: unit-tests + question: What command runs concierge's unit tests? + classification: cache + source_line: go test ./... + answer: + grade: command + expect: go test ./... + verify: + - kind: text_in_file + file: .github/workflows/_tests.yaml + pattern: go test -v -race \./\.\.\. + ci_verifiable: true + note: >- + CI runs `go test -v -race ./...` (adds -race and -v); AGENTS.md + documents the simpler form. Not stale — both are valid invocations of + the same package target — but noted since it's a real, if harmless, + divergence from what CI actually runs. + + - id: integration-tests-lxd + question: How do you run all of concierge's integration tests locally? + classification: cache + source_line: "spread -v lxd:" + answer: + grade: command + expect: "spread -v lxd:" + verify: + - kind: text_in_file + file: CONTRIBUTING.md + pattern: "spread -v lxd:" + ci_verifiable: false + gated_by: LXD — needed to actually run the suite + + - id: integration-tests-single + question: How do you run one specific spread integration test? + classification: cache + source_line: "spread -v lxd:ubuntu-24.04:tests/juju-model-defaults" + answer: + grade: command + expect: "spread -v lxd:ubuntu-24.04:tests/juju-model-defaults" + verify: + - kind: path_exists + path: tests/juju-model-defaults + - kind: text_in_file + file: CONTRIBUTING.md + pattern: "spread -v lxd:ubuntu-24\\.04:tests/juju-model-defaults" + ci_verifiable: false + gated_by: LXD — needed to actually run the suite + + - id: integration-tests-github-ci + question: >- + How do you run concierge's integration tests on a pre-provisioned + machine instead of LXD VMs? + classification: cache + source_line: "spread -v github-ci:" + answer: + grade: command + expect: "spread -v github-ci:" + verify: + - kind: text_in_file + file: .github/workflows/_tests.yaml + pattern: 'spread -v "github-ci:ubuntu-24\.04:tests/\$\{SUITE\}"' + ci_verifiable: false + gated_by: >- + a github-ci-capable pre-provisioned host, and the spread binary + (installed via `go install`, not present in the check sandbox) + + - id: sudo-required + question: Does the concierge binary need to be run with elevated privileges? + classification: cache + source_line: >- + Note: The binary must be run with `sudo` for most operations since it + installs system packages and configures providers. + answer: + grade: keywords + require: + - sudo + verify: + - kind: text_in_file + file: README.md + pattern: sudo concierge prepare + ci_verifiable: true + + - id: no-direct-exec-command + question: >- + How must concierge's Go code invoke external commands, and why not + call exec.Command() directly? + classification: override + source_line: >- + **Never call `exec.Command()` directly.** Build commands with + `system.NewCommand(executable, []string{arg1, arg2})`, passing each + argument as a separate slice element (no string concatenation) — the + binary runs as root, so this avoids command injection. + answer: + grade: keywords + require: + - system.NewCommand + verify: + - kind: text_in_file + file: internal/system/command.go + pattern: func NewCommand + ci_verifiable: true + note: >- + Security-relevant: concierge runs as root, so an agent building a + command with exec.Command() and string-concatenated arguments opens a + command-injection surface. This is the design doc's own worked + "Symbol in path" example (system.NewCommand in + internal/system/command.go) — see scope decisions §4. + + - id: runtime-config-cache + question: >- + Where does `prepare` record what it provisioned, and what reads that + record? + classification: cache + source_line: >- + During `prepare`, the merged configuration (including all overrides) + is saved to `~/.cache/concierge/concierge.yaml`; `restore` reads this + file to undo exactly what was provisioned. + answer: + grade: keywords + require: + - concierge.yaml + - restore + verify: + - kind: text_in_file + file: internal/concierge/manager.go + pattern: path\.Join\("\.cache", "concierge", "concierge\.yaml"\) + ci_verifiable: true + + - id: snap-refresh-workaround + question: >- + What must you do before refreshing a snap to a different channel in + concierge's LXD provider? + classification: cache + source_line: >- + **Refreshing a snap to a different channel may require stopping it + first.** See `internal/providers/lxd.go` (`workaroundRefresh()`) for + the pattern. + answer: + grade: keywords + require: + - stop + - workaroundRefresh + verify: + - kind: text_in_file + file: internal/providers/lxd.go + pattern: func \(l \*LXD\) workaroundRefresh + ci_verifiable: true diff --git a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/hyrum.yaml b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/hyrum.yaml new file mode 100644 index 0000000..c0db7b6 --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/hyrum.yaml @@ -0,0 +1,526 @@ +# Question battery for canonical/hyrum AGENTS.md. +# Schema: question-batteries.md in the charm-tech-baseline skill +# (skills/engineering/charm-tech-baseline/references/) in +# canonical/charm-tech. The batteries themselves live here, in the +# agents-md package, so that the check that reads them and the data it +# reads ship together. +schema_version: 1 +repo: hyrum +upstream: canonical/hyrum +source: + agents_md_ref: chore/agents-md-trim + agents_md_sha: e1803004b19a1db17580d74e080cf4b1b5ba2d13 + agents_md_sha256: bd26161dd06a11737a5eaf26efd0c71741668990a0a80145a5baf7d228c7843b + seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (implementation follow-up 4, hyrum review-and-seed) + seeded_on: 2026-08-27 + +entries: + - id: runner-backend-auto-detected + question: >- + Does hyrum need to be told whether a charm uses tox or make, or does it + work that out itself? + classification: cache + source_line: The runner backend is either `tox` or `make`, auto-detected per charm. + answer: + grade: keywords + require: + - auto + reject: + - specify + verify: + - kind: text_in_file + file: src/hyrum/_runners/detect.py + pattern: "class AutoRunner" + ci_verifiable: true + + - id: make-lint-command + question: What single command lints hyrum, and which four tools does it run? + classification: cache + source_line: >- + make lint # ruff check + ruff format --check + codespell + pyright + (strict) + answer: + grade: command + expect: make lint + verify: + - kind: text_in_file + file: Makefile + pattern: "^lint:" + ci_verifiable: true + note: >- + `make lint` currently fails in this sandbox (ruff 0.16.0's + noqa-comments rule flagging tools/discover_launchpad_charms.py's + old-style `# noqa:` comments) - a pre-existing repo bug, not an + AGENTS.md staleness issue: the command genuinely runs those four + tools. Left unfixed, out of scope for this pass. + + - id: make-unit-command + question: What single command runs hyrum's unit tests, and what does it measure? + classification: cache + source_line: "make unit # pytest with coverage" + answer: + grade: command + expect: make unit + verify: + - kind: text_in_file + file: Makefile + pattern: "^unit:" + ci_verifiable: true + note: "Ran clean in this session: 415 passed." + + - id: make-other-wrappers + question: >- + Besides `make lint` and `make unit`, what other make targets does + hyrum's Makefile provide for formatting, running everything, and + listing targets? + classification: cache + source_line: >- + make format # apply ruff formatting make all # format + lint + unit + make help # list every target + answer: + grade: keywords + require: + - format + - all + - help + verify: + - kind: text_in_file + file: Makefile + pattern: "^format:" + - kind: text_in_file + file: Makefile + pattern: "^all:" + - kind: text_in_file + file: Makefile + pattern: "^help:" + ci_verifiable: true + + - id: uv-autosyncs-no-manual-install + question: >- + Before running `make lint` or `make unit` for the first time, do you + need a separate `uv sync` or `pip install` step? + classification: override + source_line: >- + Each `make` target runs `uv run …`, which auto-syncs the default + groups. + answer: + grade: keywords + require: + - auto-sync + reject: + - pip install + verify: + - kind: text_in_file + file: Makefile + pattern: "uv run" + - kind: text_in_file + file: pyproject.toml + pattern: "default-groups" + ci_verifiable: true + + - id: pre-commit-install-command + question: What command installs hyrum's pre-commit hooks, and what do they mirror? + classification: cache + source_line: >- + Pre-commit hooks mirror the CI checks; install them with `pre-commit + install`. + answer: + grade: command + expect: pre-commit install + verify: + - kind: path_exists + path: .pre-commit-config.yaml + ci_verifiable: true + + - id: style-guide-is-external + question: >- + Where does hyrum's documentation and Python style guide live - a local + STYLE.md in this repo, or somewhere else? + classification: override + source_line: >- + This project follows the Charm Tech team style guides. Read them if + more clarification is required + answer: + grade: keywords + require: + - charm-tech + reject: + - local STYLE.md + verify: + - kind: text_in_file + file: AGENTS.md + pattern: "github.com/canonical/charm-tech/blob/main/STYLE.md" + ci_verifiable: true + note: >- + No local STYLE.md/CONTRIBUTING.md-style pointer file exists in this + repo to anchor against, so verify checks AGENTS.md's own link text - + the fact being tested is "the guide is external", which AGENTS.md is + the only place recording. + + - id: no-prose-documentation + question: >- + Should you write prose documentation (guides, explanations) for + hyrum, or leave that to a human? + classification: override + source_line: "Avoid writing prose documentation: that is a task for humans." + answer: + grade: keywords + require: + - human + reject: + - I will write + verify: + - kind: none + reason: >- + A house policy, not represented anywhere else in the tree - + grepped README.md and CONTRIBUTING.md for "prose"/"documentation" + and found no matching statement. + ci_verifiable: false + gated_by: house convention stated only in AGENTS.md + + - id: cli-framework-is-argparse + question: >- + What CLI framework does hyrum's command line use - Click, or + something else? + classification: override + source_line: >- + Entry point: `hyrum._cli:main` — an `argparse` CLI (no Click; the + dependency was dropped) with three subcommands + answer: + grade: keywords + require: + - argparse + reject: + - click + - Click + verify: + - kind: text_in_file + file: src/hyrum/_cli.py + pattern: "^import argparse" + - kind: text_in_file + file: pyproject.toml + pattern: "\\[project\\.scripts\\]" + ci_verifiable: true + note: >- + This is the fix this pass made. The pre-review line said "a Click + group"; `click` was dropped from dependencies in #33 + ("refactor: drop click, rich, and pyyaml dependencies"), well before + AGENTS.md was added. Layer 1 has no check for a wrong framework name + in prose - found only by reading `_cli.py`'s imports. An agent + extending the CLI on the old advice would reach for `@click.command` + decorators against a codebase that has none. + + - id: cli-three-subcommands + question: What are hyrum's three CLI subcommands, and what does each do? + classification: cache + source_line: >- + three subcommands: `check` (the core bulk-runner), `compare` (diff + two `--save-results` JSON runs), and `get-charms` (clones/pulls every + repository listed in `charm-list/charms.csv` into the cache folder) + answer: + grade: keywords + require: + - check + - compare + - get-charms + verify: + - kind: text_in_file + file: src/hyrum/_cli.py + pattern: "'compare'," + - kind: text_in_file + file: src/hyrum/_cli.py + pattern: "'get-charms'" + ci_verifiable: true + note: >- + This is the other fix this pass made: the pre-review line named only + two subcommands, missing `compare` (added in #82, after AGENTS.md was + last touched). + + - id: check-needs-prepopulated-cache + question: >- + Does `hyrum check` clone or select the charms it runs against itself, + or does something else need to happen first? + classification: override + source_line: >- + The `hyrum check` subcommand does not curate the charm collection; it + expects a pre-populated cache folder. `hyrum get-charms` populates + it. + answer: + grade: keywords + require: + - get-charms + - pre-populated + verify: + - kind: text_in_file + file: src/hyrum/_cli.py + pattern: "iter_charm_repos" + ci_verifiable: true + + - id: get-charms-concurrency + question: How does `hyrum get-charms` clone/pull many charm repos - one at a time, or concurrently? + classification: cache + source_line: >- + `_get_charms` — distributed cache-population subcommand: + shallow-clones or pulls every repository in the CSV concurrently via + `asyncio`. + answer: + grade: keywords + require: + - asyncio + - concurrently + verify: + - kind: text_in_file + file: src/hyrum/_get_charms.py + pattern: "^import asyncio" + ci_verifiable: true + + - id: patcher-protocol-shape + question: What must a hyrum patcher implement to plug into the `Patcher` protocol? + classification: cache + source_line: "`Patcher` protocol (one `apply()` context manager)" + answer: + grade: keywords + require: + - apply + verify: + - kind: text_in_file + file: src/hyrum/_patchers/base.py + pattern: "def apply\\(self, repo: pathlib\\.Path\\)" + ci_verifiable: true + + - id: patcher-catalog + question: >- + Which hyrum patcher would you use to swap out a charm's `ops` + dependency, versus an arbitrary PyPI/git/local dependency, versus a + `charmlibs-*` package from a canonical/charmlibs branch, versus a + vendored `lib/charms/...` library? + classification: cache + source_line: >- + four concrete patchers: `OpsSourcePatcher` (the `ops` dependency), + `GenericDepPatcher` (any other PyPI/git/local dependency), + `CharmlibPatcher` (a `charmlibs-*` package from a branch of the + canonical/charmlibs monorepo), `VendoredLibPatcher` (a vendored + `lib/charms//v/.py` swapped for its PyPI equivalent) + answer: + grade: keywords + require: + - OpsSourcePatcher + - GenericDepPatcher + - CharmlibPatcher + - VendoredLibPatcher + verify: + - kind: text_in_file + file: src/hyrum/_patchers/ops_source.py + pattern: "^class OpsSourcePatcher" + - kind: text_in_file + file: src/hyrum/_patchers/generic.py + pattern: "^class GenericDepPatcher" + - kind: text_in_file + file: src/hyrum/_patchers/charmlib_source.py + pattern: "^class CharmlibPatcher" + - kind: text_in_file + file: src/hyrum/_patchers/vendored_lib.py + pattern: "^class VendoredLibPatcher" + ci_verifiable: true + note: >- + This is the third fix this pass made: the pre-review line described + `VendoredLibPatcher` as a hypothetical "future charm-library + patcher" and didn't mention `GenericDepPatcher` or `CharmlibPatcher` + at all - all three already existed (#59, #25, #60). Found by reading + src/hyrum/_patchers/, not by Layer 1. + + - id: make-runner-nq-probe + question: >- + When hyrum runs a make target that doesn't exist for a charm, how + does the make runner tell that apart from the target existing but + the command genuinely failing? + classification: override + source_line: >- + GNU make's missing-target ambiguity is handled by probing with + `make -nq` and falling back to stderr inspection. + answer: + grade: keywords + require: + - "-nq" + verify: + - kind: text_in_file + file: src/hyrum/_runners/make_runner.py + pattern: "-nq" + ci_verifiable: true + note: >- + GNU make exits 2 for both "no rule to make target" and a genuine + recipe failure, so a naive exit-code check would misclassify a typo'd + target as a charm failure. Getting this wrong changes what + `no_target` vs `failed` means for a whole run's tally. + + - id: pool-outcome-error-statuses + question: >- + Does hyrum's Outcome record an infrastructure problem (a runner that + wouldn't launch, a patch that wouldn't apply) with the same status as + a genuine tox/make failure, or a different one? + classification: override + source_line: >- + `Outcome` dataclass with `patcher_error` and `runner_error` as + statuses distinct from `failed` (so infrastructure problems don't + get mis-attributed to the charm) + answer: + grade: keywords + require: + - patcher_error + - runner_error + reject: + - same status + verify: + - kind: text_in_file + file: src/hyrum/_pool.py + pattern: "'runner_error'," + - kind: text_in_file + file: src/hyrum/_pool.py + pattern: "'patcher_error'," + ci_verifiable: true + + - id: preflight-before-pool + question: >- + If a `--patch` git ref is invalid or a runner executable is missing, + does hyrum find out once before starting, or once per charm as it + works through the list? + classification: cache + source_line: >- + The `check` subcommand preflights `--patch` git refs and the runner + executables before the pool starts, so a typo or a missing tool + fails once, not once per charm. + answer: + grade: keywords + require: + - once + - before + verify: + - kind: text_in_file + file: src/hyrum/_cli.py + pattern: "_preflight_patch_refs" + ci_verifiable: true + + - id: report-no-rich + question: >- + Does hyrum's terminal report use the Rich library for its coloured + output, and if not, what handles the ANSI formatting? + classification: override + source_line: >- + `_report` — tally + verbose offender lists, hand-rolled ANSI + formatting (`_ansi`); no Rich dependency. + answer: + grade: keywords + require: + - "no" + - _ansi + reject: + - rich + verify: + - kind: path_exists + path: src/hyrum/_ansi.py + - kind: text_in_file + file: src/hyrum/_report.py + pattern: "from hyrum import _ansi" + ci_verifiable: true + note: >- + Rich was dropped in the same #33 refactor that dropped Click; the + pre-review AGENTS.md still credited it, same root cause as the + cli-framework-is-argparse finding. + + - id: tools-stdlib-only + question: >- + Can a script under hyrum's tools/ directory import a third-party + package like `requests`, the same way the main package can? + classification: override + source_line: >- + `tools/` — stdlib-only maintenance scripts that are **not** shipped + in the hyrum wheel. + answer: + grade: keywords + require: + - stdlib + reject: + - "yes" + verify: + - kind: text_in_file + file: pyproject.toml + pattern: 'packages = \["src/hyrum"\]' + - kind: text_in_file + file: tools/update_charm_list.py + pattern: "^import urllib" + ci_verifiable: true + + - id: scope-lint-and-unit-only + question: >- + During the 26.10 cycle, is hyrum expected to add support for running + charms' integration test suites? + classification: override + source_line: >- + Scope during the 26.10 cycle is **lint and unit tests only**. Do not + add integration-test support. + answer: + grade: keywords + require: + - "no" + reject: + - integration test support + verify: + - kind: text_in_file + file: README.md + pattern: "lint and unit tests only" + ci_verifiable: true + + - id: fake-proc-fake-spawner + question: >- + How are hyrum's subprocess-driven runners unit-tested - do the tests + spawn real `tox`/`make` processes? + classification: cache + source_line: >- + Subprocess-driven runners are tested with a `FakeProc` / `FakeSpawner` + pair (see `tests/test_runners.py`) that monkeypatches + `asyncio.create_subprocess_exec` — no real subprocesses are spawned + in the unit suite. + answer: + grade: keywords + require: + - FakeProc + - FakeSpawner + reject: + - real subprocess + verify: + - kind: text_in_file + file: tests/test_runners.py + pattern: "class FakeProc" + - kind: text_in_file + file: tests/test_runners.py + pattern: "class FakeSpawner" + ci_verifiable: true + + - id: run-lock-monkeypatched + question: >- + Do the ops-source and generic patchers' unit tests actually invoke + `poetry lock` / `uv lock`, and if not, what's monkeypatched to stop + them? + classification: cache + source_line: >- + The `ops_source`/`generic` patchers' lockfile regeneration is + monkeypatched out (the `run_lock` helper in + `src/hyrum/_patchers/_common.py`) so unit tests don't spawn `poetry` + / `uv`. + answer: + grade: keywords + require: + - run_lock + reject: + - _run_lock + verify: + - kind: text_in_file + file: src/hyrum/_patchers/_common.py + pattern: "^def run_lock" + ci_verifiable: true + note: >- + This is the fourth fix this pass made: the pre-review line named the + helper `_run_lock` (with a leading underscore); the real symbol is + `run_lock`, imported into both ops_source.py and generic.py from + _common.py. Not a path/command Layer 1 checks, so only found by + reading the test monkeypatch targets. diff --git a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/jubilant.yaml b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/jubilant.yaml new file mode 100644 index 0000000..3e5d60e --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/jubilant.yaml @@ -0,0 +1,458 @@ +# Question battery for canonical/jubilant AGENTS.md. +# Schema: question-batteries.md in the charm-tech-baseline skill +# (skills/engineering/charm-tech-baseline/references/) in +# canonical/charm-tech. The batteries themselves live here, in the +# agents-md package, so that the check that reads them and the data it +# reads ship together. +schema_version: 1 +repo: jubilant +upstream: canonical/jubilant +source: + agents_md_ref: chore/agents-md-trim + agents_md_sha: 67396cbb0799a23d3a2283189406459832a21e5e + agents_md_sha256: a51ae65a7c8bcc25b3d5f182cd1bc2663323e14d3403b13beefc2ef3ef9d1efb + seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (implementation follow-up 4, jubilant review-and-seed) + seeded_on: 2026-08-27 + +entries: + - id: make-all-before-commit + question: What single command should you run before committing, and what three things does it do? + classification: cache + source_line: "make all # Format, lint, and unit test (run before committing)" + answer: + grade: command + expect: make all + verify: + - kind: text_in_file + file: Makefile + pattern: "^all: format lint unit" + ci_verifiable: true + note: >- + Tree-mutating (depends on `format`, which runs `ruff format`), so per + scope decisions §1 it should be run-assert-clean-restore rather than + gated. Actually run in this session (`make all`, then `git status` + confirmed no file changes - the formatter was already a no-op) but the + current `agents-md-content.py` doesn't recognise `make all` as a safe + command shape at all and reports it verify-manually, which is a + classifier gap rather than a repo problem. + + - id: unit-tests-all + question: What command runs jubilant's unit tests with coverage? + classification: cache + source_line: "make unit # Unit tests with coverage" + answer: + grade: command + expect: make unit + verify: + - kind: text_in_file + file: Makefile + pattern: "--cov=jubilant" + ci_verifiable: true + note: Ran clean in this session - 335 passed, 98% coverage. + + - id: unit-tests-single-or-named + question: How do you run just one unit test file, or select tests by name, via `make unit`? + classification: cache + source_line: "make unit ARGS='tests/unit/test_deploy.py' # Single test file" + answer: + grade: command + expect: make unit ARGS='tests/unit/test_deploy.py' + verify: + - kind: text_in_file + file: Makefile + pattern: \$\(ARGS\) + ci_verifiable: true + note: >- + One entry covers both the single-file form shown here and the + `-k test_defaults` name-pattern form a few lines below it in + AGENTS.md - both are the same `$(ARGS)` passthrough, not two separate + facts. + + - id: pre-commit-permission-fallback + question: >- + If the user won't let you install pre-commit, what must you do + instead before committing? + classification: override + source_line: >- + Ensure that `pre-commit` is installed (with the user's permission) so + that style is enforced with every commit. If the user does not permit + using `pre-commit`, *always* ensure that `make all` shows no issues + before committing. + answer: + grade: judgement + rubric: >- + A correct reply must establish both halves: ask before installing + pre-commit, and if that's refused, run `make all` clean before + committing instead. A reply that only mentions one half misses the + conditional structure the line exists to enforce. + verify: + - kind: path_exists + path: .pre-commit-config.yaml + - kind: text_in_file + file: Makefile + pattern: "^all: format lint unit" + ci_verifiable: true + note: >- + The override is asking permission before installing dev tooling, and + falling back to local verification rather than skipping it - not the + default behaviour of an agent left to itself. + + - id: style-guide-link + question: >- + Where's the detailed style guide to check when the AGENTS.md bullet + list isn't enough, especially for documentation patterns? + classification: cache + source_line: >- + Read [the Charm Tech style guide](https://github.com/canonical/operator/blob/main/STYLE.md) + if more clarification is required, and for details on documentation + patterns. + answer: + grade: keywords + require: + - STYLE.md + - operator + verify: + - kind: none + reason: >- + Points at canonical/operator's STYLE.md, a different repo not + present in a jubilant checkout - nothing here can confirm it + resolves. Checked manually 2026-08-27: the file exists, titled + "Ops Python style guide", and does cover documentation practices. + ci_verifiable: false + gated_by: a cross-repo reference (canonical/operator), not resolvable from within a jubilant checkout + + - id: docs-left-to-humans + question: Should an AI agent write jubilant's project documentation? + classification: override + source_line: >- + Avoiding writing documentation: that is a task for humans. When + reviewing documentation pay particular attention to ensuring that all + documentation across the project is consistent, and that the patterns + in the Charm Tech style guide are followed. + answer: + grade: judgement + rubric: >- + A correct reply must say documentation authoring is left to humans, + while agents may still review existing documentation for + consistency with the style guide. A reply that says agents should + write docs, or that agents have no role at all, is wrong. + verify: + - kind: none + reason: >- + An editorial policy about human/agent division of labour, not a + fact represented anywhere in the tree. + ci_verifiable: false + gated_by: a team practice not represented in the checkout + + - id: juju-class-wait-exception + question: >- + In the `Juju` class, which public method does *not* correspond + directly to a single Juju CLI command, and how are the CLI commands + actually invoked under the hood? + classification: cache + source_line: >- + The `Juju` class is the main entry point. Every public method except + `wait` corresponds to a Juju CLI command. All commands are executed + via `subprocess.run()` through the private `_cli()` method. Errors + raise `CLIError` (subclass of `CalledProcessError`). + answer: + grade: keywords + require: + - wait + - _cli + verify: + - kind: text_in_file + file: jubilant/_juju.py + pattern: "def wait\\(" + - kind: text_in_file + file: jubilant/_juju.py + pattern: "def _cli\\(" + - kind: text_in_file + file: jubilant/_juju.py + pattern: class CLIError + ci_verifiable: true + + - id: generated-dataclasses-family + question: >- + Which files hold jubilant's structured-output dataclasses that mirror + the Juju Go structs, and is it safe for an AI agent to restructure + them? + classification: override + source_line: >- + Frozen dataclasses representing structured Juju output. Each has a + `_from_dict()` class method for parsing JSON. These were originally + generated from the Juju Go code (see the header comment in each file + for the regeneration branch) and are hand-maintained since - treat + them as a stable contract and never restructure them as AI. + answer: + grade: judgement + rubric: >- + A correct reply must name statustypes.py, modeltypes.py and + unittypes.py, and say an AI agent should not restructure them + because they mirror a Go source of truth. A reply that includes + secrettypes.py in this set, or omits unittypes.py, has the file + list wrong. + verify: + - kind: text_in_file + file: jubilant/statustypes.py + pattern: originally generated from the Go structs + - kind: text_in_file + file: jubilant/modeltypes.py + pattern: originally generated from the Go structs + - kind: text_in_file + file: jubilant/unittypes.py + pattern: originally generated from the Go structs + ci_verifiable: true + note: >- + This is the correction this pass made. The pre-trim line listed + jubilant/secrettypes.py in this family (it carries no such header + comment - it's hand-written) and omitted jubilant/unittypes.py + (which does carry it). Layer 1 can't see this; it only surfaced as + three ambiguous missing-path findings from the bare + `modeltypes.py`/`secrettypes.py` spellings, which is a different, + unrelated bug (also fixed) that happened to point at the same line. + + - id: wait-helpers + question: >- + How does `Juju.wait()` decide when to stop polling, and name two of + its built-in helper conditions. + classification: cache + source_line: >- + `Juju.wait(condition)` polls status until a callable condition + returns True. Built-in helpers: `all_active()`, `any_blocked()`, + `all_agents_idle()`, etc. Supports custom lambdas. + answer: + grade: keywords + require: + - all_active + - any_blocked + verify: + - kind: text_in_file + file: jubilant/_all_any.py + pattern: "def all_active\\(" + - kind: text_in_file + file: jubilant/_all_any.py + pattern: "def any_blocked\\(" + - kind: text_in_file + file: jubilant/_all_any.py + pattern: "def all_agents_idle\\(" + ci_verifiable: true + + - id: public-api-export-point + question: Where must a new public symbol be added for it to become part of jubilant's public API? + classification: override + source_line: >- + Everything public is exported from `jubilant/__init__.py`. Internal + modules use leading underscores. Do not add public symbols without + updating `jubilant/__init__.py`. + answer: + grade: keywords + require: + - __init__.py + verify: + - kind: text_in_file + file: jubilant/__init__.py + pattern: "__all__ = \\[" + ci_verifiable: true + + - id: backwards-compatibility + question: Can jubilant make a breaking change to its public API? + classification: override + source_line: "**Always** ensure backwards compatibility of the public API." + answer: + grade: keywords + require: + - backward + verify: + - kind: text_in_file + file: README.md + pattern: avoid making breaking changes + ci_verifiable: true + note: >- + README states the commitment dates from the 1.0.0 release (April + 2025); this line is the same policy restated as an instruction to + agents rather than a fact about the project's history. + + - id: unit-test-mocking + question: How do jubilant's unit tests avoid calling the real Juju CLI? + classification: cache + source_line: "Mock `subprocess.run` using the custom `mocks.Run` helper in `tests/unit/mocks.py`" + answer: + grade: keywords + require: + - mocks.Run + verify: + - kind: text_in_file + file: tests/unit/mocks.py + pattern: "class Run" + ci_verifiable: true + + - id: machine-marker + question: >- + What pytest marker identifies integration tests that need a machine + model rather than a K8s one? + classification: cache + source_line: "Machine-specific tests use `@pytest.mark.machine` marker" + answer: + grade: keywords + require: + - pytest.mark.machine + verify: + - kind: text_in_file + file: pyproject.toml + pattern: "machine: integration tests" + - kind: text_in_file + file: tests/integration/test_machine.py + pattern: pytest\.mark\.machine + ci_verifiable: true + + - id: temp-model-fixture + question: How does an integration test get a Juju model to run against? + classification: cache + source_line: "Each test module gets a temporary Juju model via fixture" + answer: + grade: keywords + require: + - temp_model + verify: + - kind: text_in_file + file: jubilant/_test_helpers.py + pattern: "def temp_model" + - kind: text_in_file + file: tests/integration/conftest.py + pattern: temp_model + ci_verifiable: true + note: >- + Confirming the pattern is used doesn't need a live controller, only + confirming the fixture actually connects to one does - the + "requires a running Juju controller" bullet just above this one in + AGENTS.md is the reason none of these three integration-test entries + can be exercised end-to-end here. + + - id: pack-test-charms + question: Before running integration tests locally, what must you do with the test charms first? + classification: cache + source_line: "Test charms live in `tests/integration/charms/` and must be packed with `make pack`" + answer: + grade: command + expect: make pack + verify: + - kind: path_exists + path: tests/integration/charms + - kind: text_in_file + file: Makefile + pattern: "^pack:" + ci_verifiable: false + gated_by: charmcraft, not installed in the check sandbox + + - id: line-length + question: What line length does jubilant enforce for Python code? + classification: override + source_line: "**Line length**: 99 characters" + answer: + grade: keywords + require: + - "99" + verify: + - kind: text_in_file + file: pyproject.toml + pattern: "line-length = 99" + ci_verifiable: true + note: Neither ruff's 88-column default nor a common 79/80/100/120 guess. + + - id: quote-style + question: Single or double quotes for Python strings in this repo? + classification: override + source_line: "**Quotes**: Single quotes" + answer: + grade: keywords + require: + - single + verify: + - kind: text_in_file + file: pyproject.toml + pattern: 'quote-style = "single"' + ci_verifiable: true + + - id: docstring-style + question: What docstring convention does jubilant use? + classification: override + source_line: "**Docstrings**: Google style" + answer: + grade: keywords + require: + - Google + verify: + - kind: text_in_file + file: pyproject.toml + pattern: 'convention = "google"' + ci_verifiable: true + + - id: type-hints-strict + question: >- + How strict is jubilant's type checking, and what future-import must + appear in every file? + classification: override + source_line: >- + **Type hints**: Strict pyright mode; `from __future__ import + annotations` in every file + answer: + grade: keywords + require: + - strict + - from __future__ import annotations + verify: + - kind: text_in_file + file: pyproject.toml + pattern: 'typeCheckingMode = "strict"' + - kind: text_in_file + file: jubilant/_juju.py + pattern: from __future__ import annotations + ci_verifiable: true + + - id: import-style + question: >- + When should you import a module rather than a specific name from it + in this codebase, and what's the exception? + classification: cache + source_line: >- + **Imports**: Import modules, not other objects, unless they are only + used for type annotations + answer: + grade: judgement + rubric: >- + A correct reply must state the default (import the module, not + names out of it) and the exception (names used only for type + annotations may be imported directly). Stating only one half + misses the exception structure the line exists to record. + verify: + - kind: text_in_file + file: jubilant/__init__.py + pattern: "from \\. import modeltypes" + ci_verifiable: true + note: >- + Weak anchor - it confirms one example of the module-import pattern + exists, not that the convention holds project-wide, since no + selected ruff rule (no flake8-tidy-imports/TID) enforces it + mechanically. + + - id: python-floor + question: What is the minimum Python version jubilant supports? + classification: override + source_line: "**Target Python**: 3.10+" + answer: + grade: keywords + require: + - "3.10" + verify: + - kind: text_in_file + file: pyproject.toml + pattern: 'requires-python = ">=3\.10"' + - kind: text_in_file + file: pyproject.toml + pattern: 'target-version = "py310"' + ci_verifiable: true + note: >- + pytest-jubilant's own AGENTS.md inherits this exact floor from + jubilant (see pytest-jubilant.yaml's python-floor entry) - this is + the source of that constraint, not a copy of it. diff --git a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/operator.yaml b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/operator.yaml new file mode 100644 index 0000000..ce5fb4d --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/operator.yaml @@ -0,0 +1,491 @@ +# Question battery for canonical/operator AGENTS.md. +# Schema: question-batteries.md in the charm-tech-baseline skill +# (skills/engineering/charm-tech-baseline/references/) in +# canonical/charm-tech. The batteries themselves live here, in the +# agents-md package, so that the check that reads them and the data it +# reads ship together. +schema_version: 1 +repo: operator +upstream: canonical/operator +source: + agents_md_ref: chore/agents-md-trim + agents_md_sha: 3fd6b4af3125c2e6faff0e85b2b0c32da0387559 + agents_md_sha256: 653bfec0ff562dea1f51bf0db5bc15b9e67bcf966beec84d0db643d7ef5910e3 + seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (implementation follow-up 4, operator review-and-seed) + seeded_on: 2026-08-27 + +entries: + - id: ops-tests-location + question: In the ops/ (core framework) component of this monorepo, where do the unit tests live? + classification: cache + source_line: >- + - **`ops/`** - Core framework providing the event system, charm base + classes, and model abstractions. Note that the tests are in a + top-level folder `test` + answer: + grade: keywords + require: + - test/ + verify: + - kind: path_exists + path: test/ + ci_verifiable: true + + - id: testing-src-layout + question: >- + What directory layout does testing/ (the ops-scenario package) use, and + how is it imported by charm code? + classification: cache + source_line: >- + - **`testing/`** - The `ops-scenario` state transition testing + framework (accessed as `ops.testing`). This uses src-layout. + answer: + grade: keywords + require: + - src-layout + - ops.testing + verify: + - kind: path_exists + path: testing/src/scenario + ci_verifiable: true + + - id: tracing-tests-location + question: Where do tracing/'s (the ops-tracing package) tests live? + classification: cache + source_line: >- + - **`tracing/`** - The `ops-tracing` observability integration. The + tests are in a subfolder called `test`. + answer: + grade: keywords + require: + - test + verify: + - kind: path_exists + path: tracing/test + ci_verifiable: true + + - id: backward-compatibility + question: >- + Can you change or remove behaviour in one of ops's public APIs, even if + it looks like an improvement, without treating it as a breaking change? + classification: override + source_line: "- **Always** preserve backward compatibility in public APIs" + answer: + grade: judgement + rubric: >- + A correct reply says no: backward compatibility in public APIs must + always be preserved, and existing behaviour must be kept unless the + change is fixing a bug. A reply that treats a behaviour change as + routine, without flagging it as a compatibility concern, misses the + point of the line. + verify: + - kind: none + reason: >- + A team policy enforced through code review, not represented as a + rule anywhere else in the tree (no CONTRIBUTING.md/STYLE.md/ + HACKING.md section states it explicitly - grepped all three for + "backward"/"breaking" and found nothing). + ci_verifiable: false + gated_by: review norm, not represented anywhere else in the tree + + - id: document-breaking-changes + question: How should a breaking change to ops's public API be reflected in the commit history? + classification: override + source_line: "- **Document** all breaking changes in commit messages" + answer: + grade: keywords + require: + - breaking + verify: + - kind: text_in_file + file: CONTRIBUTING.md + pattern: "fix!:" + ci_verifiable: true + note: >- + Anchored to CONTRIBUTING.md's `fix!:` breaking-change PR title + convention. PRs squash to a single commit on merge (CONTRIBUTING.md's + "Branch updates" section), so the PR title becomes the commit message + - the same reasoning pytest-jubilant's pr-title-becomes-commit entry + uses for its own repo. + + - id: key-files-code + question: >- + Which files define CharmBase and the charm event types, the core event + system (Handle/Object/Framework), Juju model abstractions, the Pebble + API, and ops's public API exports, respectively? + classification: cache + source_line: "| `ops/charm.py` | CharmBase, event types, metadata parsing |" + answer: + grade: keywords + require: + - ops/charm.py + - ops/framework.py + - ops/model.py + - ops/pebble.py + - ops/__init__.py + verify: + - kind: text_in_file + file: ops/charm.py + pattern: "^class CharmBase" + - kind: text_in_file + file: ops/framework.py + pattern: "^class Framework" + - kind: text_in_file + file: ops/model.py + pattern: "^class Model:" + - kind: text_in_file + file: ops/pebble.py + pattern: "^class Client" + - kind: text_in_file + file: ops/__init__.py + pattern: "^__all__" + ci_verifiable: true + note: >- + One entry for the five ops/*.py rows of the Key Files Reference table + - they're one classification unit (an orientation map for a large + monorepo), not five independently interesting facts. + + - id: key-files-docs + question: >- + Which document has the team's Python style guide, which has the PR and + contribution process, and which has detailed development setup and + workflow instructions? + classification: cache + source_line: "| `STYLE.md` | Team Python style guide |" + answer: + grade: keywords + require: + - STYLE.md + - CONTRIBUTING.md + - HACKING.md + verify: + - kind: path_exists + path: STYLE.md + - kind: path_exists + path: CONTRIBUTING.md + - kind: path_exists + path: HACKING.md + ci_verifiable: true + note: The three doc-file rows of the Key Files Reference table, combined. + + - id: verify-with-tox + question: What single command should you run after making a change to check both linting and unit tests pass? + classification: cache + source_line: >- + 3. **Verify changes** - execute `tox` after changes to ensure linting + and unit tests pass + answer: + grade: command + expect: tox + verify: + - kind: text_in_file + file: tox.ini + pattern: "envlist = lint, unit" + ci_verifiable: true + note: >- + Ran clean in this session (`tox -e lint` and `tox -e unit` separately, + matching the envlist bare `tox` would run). + + - id: ruff-format + question: What command formats ops's Python code, and what tool does it use? + classification: cache + source_line: "- Use Ruff for formatting (`tox -e format`)" + answer: + grade: command + expect: tox -e format + verify: + - kind: text_in_file + file: tox.ini + pattern: "\\[testenv:format\\]" + ci_verifiable: true + + - id: python-floor-type-hints + question: What is the minimum Python version ops supports, and what type-hint requirement applies to new code? + classification: override + source_line: >- + - Python 3.10+ with **full type hints** required (check with `tox -e + lint`) + answer: + grade: keywords + require: + - "3.10" + verify: + - kind: text_in_file + file: pyproject.toml + pattern: 'requires-python = ">=3\.10"' + - kind: text_in_file + file: pyproject.toml + pattern: 'pythonVersion = "3.10"' + ci_verifiable: true + + - id: modern-union-syntax + question: Should new code annotate an optional parameter as `Optional[int]` or `int | None`? + classification: override + source_line: >- + - Use modern `x: int | None` annotations, not old-style `x: + Optional[int]` + answer: + grade: keywords + require: + - "int | None" + verify: + - kind: text_in_file + file: pyproject.toml + pattern: 'target-version = "py310"' + ci_verifiable: true + note: >- + Weak anchor: confirms the py310 target that makes the `|` syntax valid + and that ruff's pyupgrade ("UP") rules are selected (which flag + `Optional`/`Union` usage), not the annotation style directly - ruff has + no rule name that maps 1:1 to this English sentence. + + - id: return-type-required + question: Do function and method signatures need a return type annotation in this codebase, and what are the two exceptions? + classification: override + source_line: "- Always provide a return type, other than for `__init__` and in test code" + answer: + grade: keywords + require: + - __init__ + - test + verify: + - kind: text_in_file + file: STYLE.md + pattern: other than for `__init__` or in test code + ci_verifiable: true + note: >- + Restated from STYLE.md's "Provide return type annotations" section + (kept anyway - the rule is short and CI-adjacent enough to be worth + surfacing directly). Checked whether this is actually enforced by + pyright's strict mode: it is not - a scratch function with a missing + return type and no other issues passed `tox -e lint` cleanly in this + session. So this is a review-time convention, not a CI gate; the + AGENTS.md line doesn't claim otherwise. + + - id: import-modules-not-objects + question: >- + Should new code write `from ops import CharmBase, PebbleReadyEvent` or + `import ops` and then refer to `ops.CharmBase`? Is there an exception? + classification: override + source_line: "# DO: Import modules, not objects (except typing)" + answer: + grade: judgement + rubric: >- + A correct reply must state the default (import the module, use + `ops.CharmBase` etc., not `from ops import CharmBase`) and the + exception (names from `typing` may be imported directly). A reply + naming only one half misses the exception the line exists to record. + verify: + - kind: text_in_file + file: STYLE.md + pattern: "### Import modules, not objects" + ci_verifiable: true + + - id: comment-style + question: In this codebase, should comments explain what the code is doing or why it is doing it, and what format should they be in? + classification: override + source_line: >- + Comments are always full sentences that end with punctuation. Avoid + using comments to explain *what* the code is *doing*, use them + (sparingly, as required) to explain *why* the code is doing what it is + doing. + answer: + grade: judgement + rubric: >- + A correct reply says comments should explain why, not what, be used + sparingly, and be written as full sentences ending with punctuation. + A reply describing comments that narrate what the code does, or that + are sentence fragments, is wrong. + verify: + - kind: none + reason: >- + Not documented anywhere else in the tree - grepped STYLE.md, + CONTRIBUTING.md and HACKING.md for "comment" and found no matching + convention there. + ci_verifiable: false + gated_by: house convention stated only in AGENTS.md + + - id: docstring-google-style + question: What docstring convention does ops use, and why does it matter beyond just style? + classification: override + source_line: >- + Use Google-style docstrings for all public APIs, with proper + formatting. The text is used to generate reference documentation with + Sphinx so must be appropriate ReST. + answer: + grade: keywords + require: + - Google + - Sphinx + verify: + - kind: text_in_file + file: pyproject.toml + pattern: 'convention = "google"' + ci_verifiable: true + + - id: juju-version-directives + question: How do you note, in a docstring, that a feature needs a specific Juju version? + classification: cache + source_line: ".. jujuadded:: 3.5" + answer: + grade: keywords + require: + - jujuadded + verify: + - kind: text_in_file + file: CONTRIBUTING.md + pattern: "\\.\\. jujuadded:: x\\.y" + ci_verifiable: true + + - id: no-ops-version-in-docstrings + question: Should a docstring mention which ops version added a feature? + classification: override + source_line: "Don't document Ops version changes in docstrings - that's in the changelog." + answer: + grade: keywords + require: + - changelog + reject: + - jujuadded + verify: + - kind: path_exists + path: CHANGES.md + ci_verifiable: true + note: >- + Weak anchor: confirms the changelog file the line points at exists, not + that no docstring currently violates the rule. + + - id: pr-title-types-and-scopes + question: What conventional-commit types are valid in an ops PR title, and are scopes allowed? + classification: override + source_line: "The project does not use conventional commit \"scopes\"." + answer: + grade: keywords + require: + - feat + - fix + - docs + - refactor + - perf + - test + - chore + - ci + - revert + reject: + - scope + verify: + - kind: text_in_file + file: .github/check-conventional-pr-title.py + pattern: "'chore',\\s*'ci',\\s*'docs',\\s*'feat',\\s*'fix',\\s*'perf',\\s*'refactor',\\s*'revert',\\s*'test'," + - kind: text_in_file + file: .github/check-conventional-pr-title.py + pattern: "Scopes must not be used" + ci_verifiable: true + note: >- + This is the fix this pass made. The pre-trim line enumerated only + feat/fix/docs/refactor/test/chore/ci, omitting perf and revert - both + of which .github/check-conventional-pr-title.py's _TYPES set actually + accepts. Layer 1 has no check for an enumerated-list-vs-script + mismatch; only found by reading the enforcement script. + + - id: full-lint-command + question: What single command runs all of ops's static checks (formatting check, spelling, and type checking)? + classification: cache + source_line: "4. Run `tox -e lint` to check linting and types" + answer: + grade: command + expect: tox -e lint + verify: + - kind: text_in_file + file: tox.ini + pattern: "\\[testenv:lint\\]" + ci_verifiable: true + note: >- + Ran in this session: ruff check, ruff format --check, codespell and + pyright all passed clean against the pre-rework file. + + - id: unit-tests-coverage + question: What command runs ops's unit tests, and what alternate command checks for coverage drops? + classification: cache + source_line: >- + 5. Run `tox -e unit` to verify unit tests pass - avoid drops in + coverage (`tox -e coverage`) + answer: + grade: keywords + require: + - tox -e unit + - tox -e coverage + verify: + - kind: text_in_file + file: tox.ini + pattern: "\\[testenv:unit\\]" + - kind: text_in_file + file: tox.ini + pattern: "\\[testenv:coverage\\]" + ci_verifiable: true + note: "tox -e unit ran clean in this session: 2368 passed, 54 skipped." + + - id: build-docs + question: How do you check that ops's documentation still builds, before submitting a PR? + classification: cache + source_line: >- + 6. Run `make html` in the `docs` folder to ensure that the + documentation can be generated + answer: + grade: command + expect: make html + verify: + - kind: text_in_file + file: docs/Makefile + pattern: "^html: install" + ci_verifiable: true + + - id: doc-impact-check + question: >- + After making a code change, besides running the docs build, what + should you check about the docs/ folder before submitting? + classification: override + source_line: >- + 7. Search the explanation, how-to, and tutorial documentation in the + `docs` folder for topics related to the changes, then suggest places + that might need expanding/altering + answer: + grade: judgement + rubric: >- + A correct reply says to search the explanation, how-to, and tutorial + docs for content related to the change and flag places that may need + updating. A reply that stops at "make sure the docs build" or + "update docstrings" misses this - it's about the hand-written guides, + not the generated reference. + verify: + - kind: none + reason: >- + An editorial practice, not a fact represented in the tree - no + linter checks whether prose documentation was considered. + ci_verifiable: false + gated_by: editorial practice, not represented in the tree + + - id: environment-gated-tests + question: >- + Beyond unit tests, what two additional test suites should you run if a + virtual environment or sandbox is available? + classification: cache + source_line: >- + 8. If a virtual environment or sandbox is available, run `tox -e + pebble` and `tox -e integration` + answer: + grade: keywords + require: + - tox -e pebble + - tox -e integration + verify: + - kind: text_in_file + file: tox.ini + pattern: "\\[testenv:pebble\\]" + - kind: text_in_file + file: tox.ini + pattern: "\\[testenv:integration\\]" + ci_verifiable: false + gated_by: >- + a real Pebble binary (pebble) and a Juju controller/packed charms + (integration) - neither present in the check sandbox diff --git a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/pebble.yaml b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/pebble.yaml new file mode 100644 index 0000000..843931b --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/pebble.yaml @@ -0,0 +1,187 @@ +# Question battery for canonical/pebble AGENTS.md. +# Schema: question-batteries.md in the charm-tech-baseline skill +# (skills/engineering/charm-tech-baseline/references/) in +# canonical/charm-tech. The batteries themselves live here, in the +# agents-md package, so that the check that reads them and the data it +# reads ship together. +schema_version: 1 +repo: pebble +upstream: canonical/pebble +source: + agents_md_ref: chore/agents-md + agents_md_sha: 40e3936c1a07ea3d2e1b792471d1aeaa2a76aa29 + agents_md_sha256: 44d99acdc5011e872b66633b1e19186f3cae489ebb3de68223203c866a5f044e + seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (pebble table) + seeded_on: 2026-08-19 + +entries: + - id: unit-tests + question: What command runs pebble's unit tests? + classification: cache + source_line: "go test -race ./... # unit tests (CI runs with -race)" + answer: + grade: command + expect: go test -race ./... + verify: + - kind: text_in_file + file: .github/workflows/tests.yml + pattern: go test -race \./\.\.\. + ci_verifiable: true + note: >- + Passes on a non-root runner. As root without PEBBLE_TEST_USER/ + PEBBLE_TEST_GROUP set, servstate.TestUserGroup takes its non-skip branch + and fails — an environment artifact, not staleness (see the 2026-07-28 + Layer 1 build log). + + - id: single-gocheck-suite + question: How do you run just the PebbleSuite gocheck suite? + classification: cache + source_line: "go test ./internals/cli -check.f PebbleSuite # single gocheck suite or test" + answer: + grade: command + expect: go test ./internals/cli -check.f PebbleSuite + verify: + - kind: suite_in_package + suite: PebbleSuite + package: internals/cli + ci_verifiable: true + note: >- + The canonical staleness case. HACKING.md documented this against + ./cmd/pebble long after the suite moved to internals/cli, and cmd/pebble + has no test files at all. + + - id: root-test-env-vars + question: What must be set to run the pebble tests that require root? + classification: cache + source_line: PEBBLE_TEST_USER=$USER PEBBLE_TEST_GROUP=$USER sudo -E -H "$(which go)" test ./... + answer: + grade: keywords + require: + - PEBBLE_TEST_USER + - PEBBLE_TEST_GROUP + - sudo + verify: + - kind: text_in_file + file: .github/workflows/tests.yml + pattern: PEBBLE_TEST_USER=\w+ PEBBLE_TEST_GROUP=\w+ + ci_verifiable: false + gated_by: root/sudo — the root-tests job runs as a separate privileged CI job + + - id: integration-build-tag + question: How do you run pebble's integration tests? + classification: cache + source_line: "go test -count=1 -tags=integration ./tests/ # integration tests (build tag)" + answer: + grade: command + expect: go test -count=1 -tags=integration ./tests/ + verify: + - kind: text_in_file + file: tests/main_test.go + pattern: //go:build integration + ci_verifiable: false + gated_by: >- + integration environment — and no workflow runs these at all, so the build + tag is the only anchor available + + - id: no-empty-interface + question: What must you write instead of `interface{}` in this codebase? + classification: override + source_line: CI also rejects any use of `interface{}` — write `any`. + answer: + grade: keywords + require: + - any + verify: + - kind: text_in_file + file: .github/workflows/tests.yml + pattern: Ensure no use of empty interface + ci_verifiable: true + note: >- + The design doc places this gate in lint.yml. It is in tests.yml, in the + `format` job — corrected here. + + - id: message-casing + question: How are error messages and log messages capitalised in pebble? + classification: override + source_line: >- + **Error messages** are lowercase and start with "cannot" (`cannot create log + client: %w`); **log messages** are capitalised and start with "Cannot". + case_sensitive: true + answer: + grade: keywords + require: + - cannot + - Cannot + verify: + - kind: text_in_file + file: STYLE.md + pattern: Start error messages with "cannot" + ci_verifiable: false + gated_by: >- + style convention enforced in review, not by a linter — STYLE.md is the + only anchor + + - id: gocheck-dot-import + question: How is the gocheck package imported in pebble's tests, and what does the repo use instead of stdlib testing assertions? + classification: override + source_line: >- + Tests use [`gopkg.in/check.v1`](https://pkg.go.dev/gopkg.in/check.v1), + dot-imported (`. "gopkg.in/check.v1"`), not the stdlib `testing` assertions. + answer: + grade: judgement + rubric: >- + A correct reply must establish both halves: gocheck (gopkg.in/check.v1) + is the assertion library, and it is dot-imported. A reply naming gocheck + without the dot-import misses the load-bearing half — agents avoid dot + imports by default — so a keyword grader on "check.v1" alone would pass + answers that fail the actual test. + verify: + - kind: text_in_file + file: internals/cli/cli_test.go + pattern: '\. "gopkg\.in/check\.v1"' + ci_verifiable: true + + - id: lint-tool-pins + question: Which staticcheck and govulncheck versions does pebble's CI pin? + classification: cache + source_line: go install honnef.co/go/tools/cmd/staticcheck@v0.7.0 && staticcheck ./... + answer: + grade: keywords + require: + - v0.7.0 + - v1.1.4 + verify: + - kind: text_in_file + file: .github/workflows/lint.yml + pattern: staticcheck@v0\.7\.0 + - kind: text_in_file + file: .github/workflows/lint.yml + pattern: govulncheck@v1\.1\.4 + ci_verifiable: true + note: >- + Drift-prone, and already skewed inside the repo: tiobe.yaml pins + staticcheck@v0.6.1 against lint.yml's v0.7.0. That skew is a separate + defect (scope decisions §3) and is not this entry's business — this entry + asserts what AGENTS.md claims is what lint.yml pins. + + - id: cli-help-staleness-gate + question: After changing a pebble CLI command, what must you run so CI does not fail on stale docs? + classification: cache + source_line: >- + After changing a CLI command, run `make cli-help` in `docs/` (CI fails if + the generated CLI reference is stale). + answer: + grade: command + expect: make cli-help + verify: + - kind: text_in_file + file: docs/Makefile + pattern: "^cli-help:" + - kind: text_in_file + file: .github/workflows/tests.yml + pattern: make cli-help + ci_verifiable: true + note: >- + Per scope decisions §1 this runs with a diff-clean assertion and a + restore, rather than being gated as tree-mutating. That decision is + settled but not yet implemented in agents-md-content.py. diff --git a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/pytest-jubilant.yaml b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/pytest-jubilant.yaml new file mode 100644 index 0000000..a45d3aa --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/pytest-jubilant.yaml @@ -0,0 +1,166 @@ +# Question battery for canonical/pytest-jubilant AGENTS.md. +# Schema: question-batteries.md in the charm-tech-baseline skill +# (skills/engineering/charm-tech-baseline/references/) in +# canonical/charm-tech. The batteries themselves live here, in the +# agents-md package, so that the check that reads them and the data it +# reads ship together. +schema_version: 1 +repo: pytest-jubilant +upstream: canonical/pytest-jubilant +source: + agents_md_ref: chore/agents-md + agents_md_sha: 46276b0fe118266f61ad45f0e1c59116cd884b57 + agents_md_sha256: c00f361f0304fa22a6e6f16da30c015a6085ac8cccf98406ed43cdc54865a589 + seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (pytest-jubilant table) + seeded_on: 2026-08-19 + +entries: + - id: tox-bootstrap + question: How do you install the task runner this repo's commands go through? + classification: cache + source_line: uv tool install tox --with tox-uv + answer: + grade: command + expect: uv tool install tox --with tox-uv + verify: + - kind: text_in_file + file: .github/workflows/quality_checks.yaml + pattern: uv tool install tox --with tox-uv + ci_verifiable: false + gated_by: >- + installs a tool into the environment — Layer 1 check 2 classifies + `uv tool install` as side-effecting and does not execute it + + - id: lint-command + question: What command lints this repo? + classification: cache + source_line: "tox -e lint # ruff format --check, ruff check, pyright (must pass before pushing)" + answer: + grade: command + expect: tox -e lint + verify: + - kind: text_in_file + file: tox.ini + pattern: \[testenv:lint\] + ci_verifiable: true + + - id: unit-test-command + question: What command runs the unit tests? + classification: cache + source_line: "tox -e unit # unit tests under tests/unit with coverage" + answer: + grade: command + expect: tox -e unit + verify: + - kind: text_in_file + file: tox.ini + pattern: \[testenv:unit\] + ci_verifiable: true + + - id: integration-command + question: What command runs the integration tests? + classification: cache + source_line: "tox -e integration # integration tests under tests/integration" + answer: + grade: command + expect: tox -e integration + verify: + - kind: text_in_file + file: tox.ini + pattern: \[testenv:integration\] + ci_verifiable: false + gated_by: a Juju model and packed charms + + - id: integration-prerequisites + question: What has to be prepared before the integration tests will run locally? + classification: cache + source_line: >- + The integration tests need packed test charms; pack them and set the + `*CHARM_PATH` environment variables they reference before running locally. + answer: + grade: keywords + require: + - pack + - CHARM_PATH + verify: + - kind: text_in_file + file: tox.ini + pattern: CHARM_PATH + - kind: text_in_file + file: tests/integration/conftest.py + pattern: _CHARM_PATH + ci_verifiable: false + gated_by: packed charms — the charms must be built before the env vars mean anything + note: >- + High derivation cost: the variable names are only discoverable by reading + tests/integration/conftest.py, and the pack step is not written down + anywhere else in the tree. + + - id: python-floor + question: What is the minimum Python version this repo must support? + classification: override + source_line: "**Python floor is 3.8** (set by jubilant) — keep code compatible." + answer: + grade: keywords + require: + - "3.8" + verify: + - kind: text_in_file + file: pyproject.toml + pattern: requires-python = ">=3\.8" + ci_verifiable: true + note: >- + The override that matters — an agent left to itself writes 3.10+ syntax. + The floor is inherited from jubilant, so it can move without anything in + this repo changing except the pin this entry watches. + + - id: commit-convention + question: What commit-message convention does this repo use, and are scopes allowed? + classification: override + source_line: >- + **Commits / PR titles:** [Conventional Commits](https://www.conventionalcommits.org/); + no scopes. + answer: + grade: judgement + rubric: >- + A correct reply must establish both halves: Conventional Commits, and + that scopes are not permitted. Keyword grading on "conventional" alone + passes replies that offer `feat(plugin): …`, which is the exact mistake + this line exists to prevent. + verify: + - kind: text_in_file + file: .github/check-conventional-pr-title.py + pattern: disallows scopes + - kind: text_in_file + file: .github/workflows/validate-pr-title.yaml + pattern: check-conventional-pr-title\.py + ci_verifiable: true + note: >- + The design doc calls this line "not derivable from the tree at all + (convention lives in review practice)". That is no longer true: the + validate-pr-title sweep added .github/check-conventional-pr-title.py, + which states and enforces the no-scopes rule. Still an override — an + agent will not read a CI helper script before writing a commit message — + but it is now anchored, and the design doc's rationale needs amending. + + - id: pr-title-becomes-commit + question: What becomes the commit message when a pull request is merged here? + classification: override + source_line: The PR title becomes the squashed commit message. + answer: + grade: keywords + require: + - PR title + - squash + verify: + - kind: none + reason: >- + This is the repo's squash-merge setting, which lives in GitHub's + configuration and is not represented anywhere in the tree. Nothing + here can go stale in a way a file check would notice; it goes stale + when somebody changes the merge method. + ci_verifiable: false + gated_by: GitHub repo settings — readable only via the API, not from a checkout + note: >- + This half of the design doc's row genuinely is un-anchored, unlike the + no-scopes half. Splitting the row was what made that visible. diff --git a/agents-md/src/charm_tech_code/agents_md/checks/__init__.py b/agents-md/src/charm_tech_code/agents_md/checks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agents-md/src/charm_tech_code/agents_md/checks/agents_md.py b/agents-md/src/charm_tech_code/agents_md/checks/agents_md.py new file mode 100644 index 0000000..13ccd07 --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/checks/agents_md.py @@ -0,0 +1,77 @@ +"""Check: AGENTS.md present (best-of-class; agent-onboarding entry point). +Tier coverage: product, canonical. Personal-tier: informational only. + +Convention: keep it minimal — a short pointer file, not an encyclopaedia. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'agents-md' +APPLIES = 'product,canonical,personal' + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + p = Path('AGENTS.md') + if p.is_file(): + lines = p.read_text().count('\n') + if lines > 200: + emit_check( + CHECK_ID, + 'fail', + f"AGENTS.md present but at {lines} lines is well past the 'keep it minimal' " + f'convention.', + {'path': 'AGENTS.md', 'lines': lines}, + { + 'kind': 'judgement', + 'human_review': ( + 'Trim AGENTS.md down — point at HACKING/CONTRIBUTING for depth; keep ' + 'AGENTS.md to setup commands and conventions only.' + ), + }, + ) + return EXIT_FAIL + emit_check( + CHECK_ID, + 'pass', + f'AGENTS.md present ({lines} lines).', + {'path': 'AGENTS.md', 'lines': lines}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, + 'fail', + 'No AGENTS.md found.', + {}, + { + 'kind': 'mechanical', + 'script': 'scripts/fixes/add-agents-md.py', + 'human_review': 'Customise the dev-setup commands for this repo (uv / go / make / ' + 'just).', + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/agents-md/src/charm_tech_code/agents_md/checks/agents_md_battery.py b/agents-md/src/charm_tech_code/agents_md/checks/agents_md_battery.py new file mode 100644 index 0000000..4ef2e44 --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/checks/agents_md_battery.py @@ -0,0 +1,333 @@ +"""Check: this repo's AGENTS.md question battery still describes the repo. +Tier coverage: product, canonical, personal. + +A question battery (assets/question-batteries/.yaml) records, for each +AGENTS.md line that earns its place, the question an agent would be asked, the +checkable answer, the source line it derives from, and the override/cache +classification. It makes Layer 2 behavioural re-tests mechanical to run when +Layer 1 or Layer 3 triggers them. Schema and rationale: +references/question-batteries.md. + +This check validates the battery against the repo: + +1. Schema — every entry has the required fields with known enum values. +2. Source lines — every `source_line` still appears in AGENTS.md (whitespace + collapsed on both sides, so a line that wraps in the file still matches). +3. Assertions — every `verify` assertion still holds: paths resolve, patterns + match, named gocheck suites still live in the named package. + +Assertions are static by design. Layer 1's agents-md-content check already +classifies and executes the commands; running them here too would double the +runtime and the environment surface for no new signal. + +Batteries exist only for repos that have been through the Layer 2 authoring +gate. A repo with no battery is `na`, not a gap. + +Convention: one script emits exactly one JSON result (see lib/common.py). +""" + +from __future__ import annotations + +import hashlib +import re +import sys +from pathlib import Path + +import yaml + +from ..common import ( + ASSETS, + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + origin_url, + parse_tier, + tier_applies, +) + +CHECK_ID = 'agents-md-battery' +APPLIES = 'product,canonical,personal' + +BATTERIES_DIR = ASSETS / 'question-batteries' + +CLASSIFICATIONS = {'override', 'cache'} +GRADES = {'command', 'keywords', 'judgement'} +VERIFY_KINDS = {'path_exists', 'text_in_file', 'suite_in_package', 'none'} +REQUIRED_ENTRY_KEYS = { + 'id', + 'question', + 'classification', + 'source_line', + 'answer', + 'verify', + 'ci_verifiable', +} + + +def parse_flag(name: str) -> str: + prefix = f'--{name}=' + for arg in sys.argv[1:]: + if arg.startswith(prefix): + return arg[len(prefix) :] + return '' + + +def battery_path() -> Path | None: + """Explicit --battery= wins; otherwise the battery named after the + repo the origin URL points at.""" + explicit = parse_flag('battery') + if explicit: + p = Path(explicit) + return p if p.is_file() else None + url = origin_url() + if not url: + return None + name = url.rstrip('/').split('/')[-1] + candidate = BATTERIES_DIR / f'{name}.yaml' + return candidate if candidate.is_file() else None + + +def collapse(text: str) -> str: + return ' '.join(text.split()) + + +def validate_schema(entry: dict, index: int) -> list[str]: + where = entry.get('id') or f'entry[{index}]' + problems = [] + for key in sorted(REQUIRED_ENTRY_KEYS - set(entry)): + problems.append(f"{where}: missing required key '{key}'") + if entry.get('classification') not in CLASSIFICATIONS and 'classification' in entry: + problems.append(f'{where}: unknown classification {entry["classification"]!r}') + + answer = entry.get('answer') + if isinstance(answer, dict): + grade = answer.get('grade') + if grade not in GRADES: + problems.append(f'{where}: unknown answer.grade {grade!r}') + elif grade == 'command' and not answer.get('expect'): + problems.append(f"{where}: answer.grade 'command' needs 'expect'") + elif grade == 'keywords' and not answer.get('require'): + problems.append(f"{where}: answer.grade 'keywords' needs 'require'") + elif grade == 'judgement' and not answer.get('rubric'): + problems.append(f"{where}: answer.grade 'judgement' needs 'rubric'") + elif 'answer' in entry: + problems.append(f"{where}: 'answer' must be a mapping") + + verify = entry.get('verify') + if isinstance(verify, list) and verify: + for assertion in verify: + if not isinstance(assertion, dict): + problems.append(f'{where}: each verify assertion must be a mapping') + continue + kind = assertion.get('kind') + if kind not in VERIFY_KINDS: + problems.append(f'{where}: unknown verify kind {kind!r}') + elif kind == 'none' and not assertion.get('reason'): + problems.append(f"{where}: verify kind 'none' needs 'reason'") + elif 'verify' in entry: + problems.append(f"{where}: 'verify' must be a non-empty list") + + if entry.get('ci_verifiable') is False and not entry.get('gated_by'): + problems.append(f"{where}: ci_verifiable false needs 'gated_by'") + return problems + + +def run_assertion(assertion: dict, entry_id: str, root: Path) -> dict | None: + """Return a finding dict when the assertion fails, else None.""" + kind = assertion['kind'] + if kind == 'none': + return None + + if kind == 'path_exists': + rel = assertion.get('path', '') + if not (root / rel).exists(): + return {'entry': entry_id, 'kind': kind, 'path': rel, 'problem': 'path does not exist'} + return None + + if kind == 'text_in_file': + rel = assertion.get('file', '') + pattern = assertion.get('pattern', '') + target = root / rel + if not target.is_file(): + return {'entry': entry_id, 'kind': kind, 'file': rel, 'problem': 'file does not exist'} + try: + compiled = re.compile(pattern, re.MULTILINE) + except re.error as exc: + return { + 'entry': entry_id, + 'kind': kind, + 'file': rel, + 'pattern': pattern, + 'problem': f'invalid pattern: {exc}', + } + if not compiled.search(target.read_text(errors='replace')): + return { + 'entry': entry_id, + 'kind': kind, + 'file': rel, + 'pattern': pattern, + 'problem': 'pattern not found in file', + } + return None + + # suite_in_package + suite = assertion.get('suite', '') + package = assertion.get('package', '') + pkg_dir = root / package + if not pkg_dir.is_dir(): + return { + 'entry': entry_id, + 'kind': kind, + 'suite': suite, + 'package': package, + 'problem': 'package directory does not exist', + } + needle = re.compile(rf'\b{re.escape(suite)}\b') + for go_file in pkg_dir.rglob('*.go'): + try: + if needle.search(go_file.read_text(errors='replace')): + return None + except OSError: + continue + return { + 'entry': entry_id, + 'kind': kind, + 'suite': suite, + 'package': package, + 'problem': 'suite identifier not found anywhere in package', + } + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + root = cd_repo_root() + + path = battery_path() + if path is None: + emit_check( + CHECK_ID, + 'na', + 'No question battery for this repo — it has not been through the ' + 'Layer 2 authoring gate (see references/question-batteries.md).', + ) + return EXIT_NA + + try: + battery = yaml.safe_load(path.read_text()) or {} + except yaml.YAMLError as exc: + emit_check(CHECK_ID, 'fail', f'Battery {path.name} is not valid YAML: {exc}') + return EXIT_FAIL + + entries = battery.get('entries') or [] + agents_md = root / 'AGENTS.md' + if not agents_md.is_file(): + emit_check( + CHECK_ID, + 'fail', + f'Battery {path.name} describes {len(entries)} AGENTS.md line(s), ' + 'but the repo has no AGENTS.md.', + {'battery': path.name, 'entries_total': len(entries)}, + {'kind': 'judgement', 'human_review': 'Restore AGENTS.md or retire the battery.'}, + ) + return EXIT_FAIL + + md_text = agents_md.read_text(errors='replace') + md_collapsed = collapse(md_text) + + schema_findings: list[str] = [] + drifted: list[dict] = [] + verify_findings: list[dict] = [] + by_class: dict[str, int] = {} + by_grade: dict[str, int] = {} + unanchored: list[str] = [] + not_ci_verifiable: list[dict] = [] + + for i, entry in enumerate(entries): + schema_findings.extend(validate_schema(entry, i)) + entry_id = entry.get('id') or f'entry[{i}]' + + by_class[entry.get('classification', 'unknown')] = ( + by_class.get(entry.get('classification', 'unknown'), 0) + 1 + ) + grade = (entry.get('answer') or {}).get('grade', 'unknown') + by_grade[grade] = by_grade.get(grade, 0) + 1 + + source_line = entry.get('source_line', '') + if source_line and collapse(source_line) not in md_collapsed: + drifted.append({'entry': entry_id, 'source_line': source_line}) + + for assertion in entry.get('verify') or []: + if not isinstance(assertion, dict) or assertion.get('kind') not in VERIFY_KINDS: + continue + if assertion['kind'] == 'none': + unanchored.append(entry_id) + continue + finding = run_assertion(assertion, entry_id, root) + if finding: + verify_findings.append(finding) + + if entry.get('ci_verifiable') is False: + not_ci_verifiable.append({'entry': entry_id, 'gated_by': entry.get('gated_by', '')}) + + seeded_digest = (battery.get('source') or {}).get('agents_md_sha256', '') + current_digest = hashlib.sha256(md_text.encode()).hexdigest() + + evidence = { + 'battery': path.name, + 'entries_total': len(entries), + 'entries_by_classification': by_class, + 'entries_by_answer_grade': by_grade, + 'drifted_source_lines': drifted, + 'verify_findings': verify_findings, + 'schema_findings': schema_findings, + 'unanchored_entries': unanchored, + 'not_ci_verifiable': not_ci_verifiable, + # Non-failing re-test trigger, not a defect: the file may have improved. + 'agents_md_changed_since_seeding': bool(seeded_digest) and seeded_digest != current_digest, + } + + problems = [] + if schema_findings: + problems.append(f'{len(schema_findings)} schema finding(s)') + if drifted: + problems.append(f'{len(drifted)} source line(s) no longer in AGENTS.md') + if verify_findings: + problems.append(f'{len(verify_findings)} verify assertion(s) failed') + + if problems: + emit_check( + CHECK_ID, + 'fail', + f'Question battery {path.name}: ' + '; '.join(problems) + '.', + evidence, + { + 'kind': 'judgement', + 'human_review': ( + 'A drifted source line or failed assertion means the repo ' + 'moved under the battery. Re-run the Layer 2 gate for the ' + 'affected entries, then update AGENTS.md and the battery ' + 'together.' + ), + }, + ) + return EXIT_FAIL + + emit_check( + CHECK_ID, + 'pass', + f'Question battery {path.name} matches AGENTS.md: {len(entries)} ' + f'entr(ies) anchored, {len(unanchored)} with no repo anchor by design, ' + f'{len(not_ci_verifiable)} not confirmable by an automated run.', + evidence, + ) + return EXIT_PASS + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/agents-md/src/charm_tech_code/agents_md/checks/agents_md_content.py b/agents-md/src/charm_tech_code/agents_md/checks/agents_md_content.py new file mode 100644 index 0000000..62582f8 --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/checks/agents_md_content.py @@ -0,0 +1,448 @@ +"""Check: AGENTS.md content is trustworthy (Layer 1 staleness checks). +Tier coverage: product, canonical, personal. + +Implements the five Layer 1 checks from +roadmap/26.10/repo-setup/agents-md-validation.md (canonical-work-queue): + +1. Commands parse and their entry-point tool resolves in a dev environment. +2. Safe commands actually pass: runnable (lint/format-check/unit-test/build) + commands are executed and must exit 0; environment-gated commands + (integration needing Docker/LXD/juju, root-only tests, anything that would + mutate the tree or start a long-running process) are only parsed and + reported verify-manually, with the gating dependency named. +3. Paths and symbols resolve: every referenced file exists; every named + gocheck test suite (`-check.f ` after a `go test `) still + lives in the named package; every "`Symbol` in `path`" reference resolves. +4. Version pins mentioned in prose (`tool@vX.Y.Z`) match what + .github/workflows actually pin. +5. Scope lint: flag harness-shaped content (attribution trailers, tool + hints, per-agent config) that belongs in harness config, not AGENTS.md. + +This is a content check, not a presence check — see agents-md.py for +presence/length. If AGENTS.md is absent this check is n/a. + +Convention: one script emits exactly one JSON result (see lib/common.py); +all five sub-checks are folded into a single pass/fail with per-sub-check +evidence, following check.py's one-line-of-JSON-per-script contract. +""" + +from __future__ import annotations + +import re +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + run, + tier_applies, +) + +CHECK_ID = 'agents-md-content' +APPLIES = 'product,canonical,personal' + +RUNNABLE_TIMEOUT_SECONDS = 180 + +FENCE_RE = re.compile(r'```[ \t]*([A-Za-z0-9_+-]*)\n(.*?)\n?```', re.DOTALL) +FENCE_LANGS = {'', 'bash', 'sh', 'shell', 'console', 'zsh'} +TABLE_ROW_RE = re.compile(r'^\|(.+)\|[ \t]*$') +INLINE_CODE_RE = re.compile(r'`([^`\n]+)`') +COMMAND_ENTRYPOINTS = { + 'go', + 'tox', + 'make', + 'uv', + 'pytest', + 'docker', + 'npm', + 'cargo', + 'python', + 'python3', + 'pip', + 'sudo', + 'gofmt', + 'staticcheck', + 'govulncheck', + 'ruff', + 'black', + 'flake8', + 'pyright', + 'ty', + 'mypy', + 'just', +} +ENV_ASSIGN_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*=') + +# Check 2 classification. Order matters: side-effecting first, then +# environment-gate keywords, then the runnable allowlist. Anything matching +# none of these is treated conservatively as environment-gated ("not +# recognised as safe" rather than risk running an unknown command). +SIDE_EFFECT_RE = re.compile( + r'\bgo run\b|\bgo install\b|\bgo fmt\b(?!.*-l)|\bpip install\b' + r'|\buv tool install\b|\buv pip install\b|(?:tox -e|make)\s+format\b' + r'|\bruff format\b(?!.*(--check|--diff))|\bblack\b(?!.*--check)' + r'|\bmake\s+run\b|\bmake\s+cli-help\b|\bnpm install\b' +) +ENV_GATE_PATTERNS = [ + (re.compile(r'\bdocker\b|\bcompose\b', re.IGNORECASE), 'Docker'), + (re.compile(r'\blxd\b', re.IGNORECASE), 'LXD'), + (re.compile(r'\bjuju\b', re.IGNORECASE), 'juju'), + (re.compile(r'\bcharmcraft\b', re.IGNORECASE), 'charmcraft'), + (re.compile(r'\bsudo\b|\broot\b', re.IGNORECASE), 'root/sudo'), + (re.compile(r'\bintegration\b', re.IGNORECASE), 'integration environment'), + (re.compile(r'CHARM_PATH|packed charms?', re.IGNORECASE), 'packed charms'), +] +RUNNABLE_HINT_RE = re.compile( + r'\btest\b|\bunit\b|\bpytest\b|\blint\b|\bbuild\b|\bvet\b|--check\b|--diff\b' + r'|staticcheck|govulncheck|pyright|\bty check\b|gofmt -l', + re.IGNORECASE, +) + +VERSION_PIN_RE = re.compile(r'([A-Za-z0-9_.\-/]+)@v(\d+\.\d+(?:\.\d+)?)') +SUITE_RE = re.compile(r'go test\s+(\.[^\s]+)\s+.*-check\.f[= ]([A-Za-z_][A-Za-z0-9_]*)') +SYMBOL_IN_PATH_RE = re.compile(r'`([A-Za-z_][\w.]*)`\s+in\s+`([^`]+)`') +MD_LINK_RE = re.compile(r'\[[^\]]*\]\(([^)\s]+)\)') +FILE_EXT_RE = re.compile(r'\.(md|py|go|toml|yaml|yml|txt|cfg|ini|sh|json|lock)$', re.IGNORECASE) +KNOWN_EXTENSIONLESS_FILENAMES = {'dockerfile', 'makefile', 'license', 'copying'} + +SCOPE_LINT_PATTERNS = [ + (re.compile(r'Co-Authored-By', re.IGNORECASE), 'attribution trailer (Co-Authored-By)'), + (re.compile(r'Generated (with|by)\s*\[?Claude', re.IGNORECASE), 'Claude attribution line'), + (re.compile(r'Claude Code', re.IGNORECASE), 'harness name (Claude Code)'), + (re.compile(r'claude\.ai/code', re.IGNORECASE), 'harness URL (claude.ai/code)'), + (re.compile(r'GitHub Copilot|\bCopilot\b'), 'harness name (Copilot)'), + (re.compile(r'\bChatGPT\b|\bOpenAI\b'), 'harness name (ChatGPT/OpenAI)'), + (re.compile(r'\bAnthropic\b'), 'harness vendor name (Anthropic)'), + (re.compile(r'\U0001F916'), 'robot-emoji attribution marker'), + (re.compile(r'\.claude/'), 'harness-specific config path (.claude/)'), +] + + +def extract_commands(text: str) -> list[tuple[str, str]]: + """Return (raw_command, source) pairs from fenced shell blocks and + markdown table cells that look like commands.""" + commands: list[tuple[str, str]] = [] + for m in FENCE_RE.finditer(text): + lang = m.group(1).lower() + if lang not in FENCE_LANGS: + continue + for line in m.group(2).splitlines(): + line = line.strip() + if not line or line.startswith('#'): + continue + cmd = re.split(r'\s+#\s?', line, maxsplit=1)[0].strip() + if cmd: + commands.append((cmd, 'fenced')) + for line in text.splitlines(): + row = TABLE_ROW_RE.match(line.strip()) + if not row: + continue + for cell in row.group(1).split('|'): + cell = cell.strip() + code_m = INLINE_CODE_RE.fullmatch(cell) + if not code_m: + continue + candidate = code_m.group(1).strip() + first_tok = candidate.split()[0] if candidate.split() else '' + if first_tok in COMMAND_ENTRYPOINTS: + commands.append((candidate, 'table')) + return commands + + +def entry_point_tool(cmd: str) -> str: + """First non-assignment token of the whole command line. A tool + introduced mid-line by an explicit install step (e.g. `go install X && + X ...`) is intentionally not checked here — it's expected to be absent + until that install step runs.""" + try: + tokens = shlex.split(cmd) + except ValueError: + return '' + for tok in tokens: + if ENV_ASSIGN_RE.match(tok): + continue + return tok + return '' + + +def classify_command(cmd: str) -> tuple[str, str]: + """Return (bucket, reason). bucket is 'runnable' or 'environment-gated'.""" + if SIDE_EFFECT_RE.search(cmd): + return ( + 'environment-gated', + ( + 'would mutate the working tree or start a long-running process — not executed ' + 'automatically' + ), + ) + for pattern, name in ENV_GATE_PATTERNS: + if pattern.search(cmd): + return 'environment-gated', name + if RUNNABLE_HINT_RE.search(cmd): + return 'runnable', '' + return 'environment-gated', 'not recognised as a safe check command — verify manually' + + +def looks_like_path(cand: str) -> bool: + if not cand or ' ' in cand or cand.startswith(('http://', 'https://')): + return False + if '/' in cand: + # Exclude Go/domain-style import paths (github.com/..., gopkg.in/..., + # golang.org/..., honnef.co/...) — a dotted first segment that isn't + # itself a relative-path marker ("." / "..") means "module path", + # not "local file". + first_seg = cand.split('/', 1)[0] + if '.' in first_seg and not first_seg.startswith('.'): + return False + return True + if cand.startswith('.'): + return True + if FILE_EXT_RE.search(cand): + return True + return cand.lower() in KNOWN_EXTENSIONLESS_FILENAMES + + +def extract_referenced_paths(text: str) -> set[str]: + paths: set[str] = set() + for m in MD_LINK_RE.finditer(text): + target = m.group(1) + if target.startswith(('http://', 'https://', '#', 'mailto:')): + continue + paths.add(target) + for m in INLINE_CODE_RE.finditer(text): + cand = m.group(1).strip() + if looks_like_path(cand): + paths.add(cand) + return paths + + +def workflow_texts(root: Path) -> dict[str, str]: + wf_dir = root / '.github' / 'workflows' + out: dict[str, str] = {} + if not wf_dir.is_dir(): + return out + for p in sorted(list(wf_dir.glob('*.yml')) + list(wf_dir.glob('*.yaml'))): + try: + out[str(p.relative_to(root))] = p.read_text(errors='replace') + except OSError: + continue + return out + + +def check_version_drift( + pins: list[tuple[str, str]], workflows: dict[str, str] +) -> tuple[list[dict], list[dict]]: + """Return (drifted, checked). checked includes every pin that could be + cross-referenced against a workflow (pass or fail), for evidence + transparency — e.g. a tool version pinned differently in an unrelated + workflow is visible even when the AGENTS.md claim matches somewhere.""" + drifted: list[dict] = [] + checked: list[dict] = [] + for tool, doc_version in pins: + found: set[str] = set() + for text in workflows.values(): + for vm in re.finditer(re.escape(tool) + r'@v(\d+\.\d+(?:\.\d+)?)', text): + found.add(f'v{vm.group(1)}') + if not found: + continue + entry = {'tool': tool, 'doc_version': doc_version, 'ci_versions': sorted(found)} + checked.append(entry) + if doc_version not in found: + drifted.append(entry) + return drifted, checked + + +def scope_lint(text: str) -> list[str]: + return [label for pattern, label in SCOPE_LINT_PATTERNS if pattern.search(text)] + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + root = cd_repo_root() + + p = Path('AGENTS.md') + if not p.is_file(): + emit_check( + CHECK_ID, + 'na', + 'No AGENTS.md to content-check (see agents-md check for presence).', + ) + return EXIT_NA + + text = p.read_text(errors='replace') + + # --- Checks 1 & 2: commands --- + commands = extract_commands(text) + missing_tools: list[dict] = [] + seen_missing_tools: set[str] = set() + runnable_results: list[dict] = [] + gated: list[dict] = [] + + for cmd, _source in commands: + tool = entry_point_tool(cmd) + if tool and tool not in seen_missing_tools and shutil.which(tool) is None: + seen_missing_tools.add(tool) + missing_tools.append({'command': cmd, 'tool': tool}) + + bucket, reason = classify_command(cmd) + if bucket == 'environment-gated': + gated.append({'command': cmd, 'gating_dependency': reason}) + continue + + try: + tokens = shlex.split(cmd) + except ValueError: + runnable_results.append({'command': cmd, 'status': 'unparseable'}) + continue + try: + proc = run(tokens, cwd=root, timeout=RUNNABLE_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + runnable_results.append({'command': cmd, 'status': 'timeout'}) + continue + except OSError as exc: + runnable_results.append({'command': cmd, 'status': 'error', 'detail': str(exc)}) + continue + runnable_results.append({ + 'command': cmd, + 'status': 'pass' if proc.returncode == 0 else 'fail', + 'returncode': proc.returncode, + 'stderr_tail': proc.stderr[-500:] if proc.returncode != 0 else '', + }) + + failed_runnable = [r for r in runnable_results if r['status'] != 'pass'] + + # --- Check 3: paths & symbols --- + ref_paths = extract_referenced_paths(text) + missing_paths = sorted(rp for rp in ref_paths if not (root / rp).exists()) + + symbol_findings: list[dict] = [] + for sym, sym_path in SYMBOL_IN_PATH_RE.findall(text): + target = root / sym_path + if not target.is_file(): + symbol_findings.append({ + 'symbol': sym, + 'path': sym_path, + 'problem': 'path does not exist', + }) + continue + body = target.read_text(errors='replace') + if not re.search(rf'\b{re.escape(sym)}\b', body): + symbol_findings.append({ + 'symbol': sym, + 'path': sym_path, + 'problem': 'symbol not found in file', + }) + + suite_findings: list[dict] = [] + for pkg, suite in SUITE_RE.findall(text): + pkg_dir = (root / pkg).resolve() + if not pkg_dir.is_dir(): + suite_findings.append({ + 'suite': suite, + 'package': pkg, + 'problem': 'package directory does not exist', + }) + continue + found = False + for go_file in pkg_dir.rglob('*.go'): + try: + if re.search(rf'\b{re.escape(suite)}\b', go_file.read_text(errors='replace')): + found = True + break + except OSError: + continue + if not found: + suite_findings.append({ + 'suite': suite, + 'package': pkg, + 'problem': 'suite identifier not found anywhere in package', + }) + + # --- Check 4: version pins vs CI --- + pins = [ + (path.rstrip('/').split('/')[-1], f'v{ver}') for path, ver in VERSION_PIN_RE.findall(text) + ] + workflows = workflow_texts(root) + version_drift, version_checked = check_version_drift(pins, workflows) + + # --- Check 5: scope lint --- + scope_findings = scope_lint(text) + + problems: list[str] = [] + if missing_tools: + problems.append(f'{len(missing_tools)} command tool(s) not resolvable') + if failed_runnable: + problems.append(f'{len(failed_runnable)} runnable command(s) did not pass') + if missing_paths: + problems.append(f'{len(missing_paths)} referenced path(s) missing') + if symbol_findings: + problems.append(f'{len(symbol_findings)} referenced symbol(s) unresolved') + if suite_findings: + problems.append(f'{len(suite_findings)} named test suite(s) not found in package') + if version_drift: + problems.append(f'{len(version_drift)} version pin(s) drifted from CI') + if scope_findings: + problems.append(f'{len(scope_findings)} scope-lint finding(s) (harness-shaped content)') + + evidence = { + 'path': 'AGENTS.md', + 'commands_extracted': len(commands), + 'missing_tools': missing_tools, + 'runnable_checked': len(runnable_results), + 'runnable_failed': failed_runnable, + 'environment_gated': gated, + 'paths_checked': sorted(ref_paths), + 'missing_paths': missing_paths, + 'symbol_findings': symbol_findings, + 'suite_findings': suite_findings, + 'version_pins_checked': version_checked, + 'version_drift': version_drift, + 'scope_lint_findings': scope_findings, + } + + if problems: + emit_check( + CHECK_ID, + 'fail', + 'AGENTS.md content check: ' + '; '.join(problems) + '.', + evidence, + { + 'kind': 'judgement', + 'human_review': ( + 'Review the evidence fields for the failing sub-check(s) ' + '(missing_tools / runnable_failed / missing_paths / ' + 'symbol_findings / suite_findings / version_drift / ' + 'scope_lint_findings) and update AGENTS.md or the ' + 'underlying repo to match.' + ), + }, + ) + return EXIT_FAIL + + emit_check( + CHECK_ID, + 'pass', + f'AGENTS.md content verified: {len(commands)} command(s) parsed ' + f'({len(runnable_results)} run, {len(gated)} environment-gated/' + f'verify-manually), {len(ref_paths)} path(s) resolved, ' + f'{len(version_checked)} version pin(s) cross-checked against CI, ' + 'no scope-lint findings.', + evidence, + ) + return EXIT_PASS + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/agents-md/src/charm_tech_code/agents_md/cli.py b/agents-md/src/charm_tech_code/agents_md/cli.py new file mode 100644 index 0000000..42cf622 --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/cli.py @@ -0,0 +1,200 @@ +"""Check runner. Dispatches every check that applies to the resolved tier and +emits a single JSON report. + +Usage: + agents-md check [--tier=product|canonical|personal] + [--only=[,...]] + [--format=json|markdown] + agents-md detect-tier + agents-md fix [args...] + agents-md list + +Check IDs are the ones in the report (`agents-md`, `agents-md-content`, +`agents-md-battery`), not module names. +""" + +from __future__ import annotations + +import datetime +import importlib +import pkgutil +import sys +from types import ModuleType + +from . import checks as checks_pkg +from . import fixes as fixes_pkg +from . import tier as tier_mod +from .common import collecting, origin_url + + +def _modules(package: ModuleType) -> dict[str, ModuleType]: + """Import every module in a subpackage, keyed by its declared ID. + + Checks carry a CHECK_ID; fixes have no such constant, so their module + name with underscores turned back into hyphens is the name. + """ + found: dict[str, ModuleType] = {} + for info in pkgutil.iter_modules(package.__path__): + module = importlib.import_module(f'{package.__name__}.{info.name}') + found[getattr(module, 'CHECK_ID', info.name.replace('_', '-'))] = module + return found + + +def usage() -> None: + sys.stderr.write((__doc__ or '').strip() + '\n') + + +def _check(argv: list[str]) -> int: + tier_override = '' + only_filter = '' + fmt = 'json' + # Anything the runner does not recognise is passed through to the checks. + # A check ignores flags it does not know, so this only means anything + # alongside --only, where exactly one check is listening. + passthrough: list[str] = [] + + for arg in argv: + if arg.startswith('--tier='): + tier_override = arg[len('--tier=') :] + elif arg.startswith('--only='): + only_filter = arg[len('--only=') :] + elif arg.startswith('--format='): + fmt = arg[len('--format=') :] + elif arg in ('-h', '--help'): + print((__doc__ or '').strip()) + return 0 + elif arg.startswith('--'): + passthrough.append(arg) + else: + print(f'Unknown argument: {arg}', file=sys.stderr) + return 2 + + if tier_override: + tier = tier_override + tier_source = 'override' + else: + tier = tier_mod.detect() + tier_source = 'detected' + + if tier == 'unknown': + print( + 'Could not detect tier; pass --tier=product|canonical|personal', + file=sys.stderr, + ) + return 2 + + available = _modules(checks_pkg) + if only_filter: + selected = {} + for check_id in only_filter.split(','): + if check_id not in available: + print(f'Unknown check: {check_id}', file=sys.stderr) + return 2 + selected[check_id] = available[check_id] + else: + selected = dict(sorted(available.items())) + + results: list[dict] = [] + notes: list[str] = [] + saved_argv = sys.argv + for check_id, module in selected.items(): + # Each check reads its own flags off sys.argv, as it did when it was a + # standalone script. Set it explicitly rather than letting the check + # read the runner's own command line, so that a *detected* tier + # reaches the check just as an overridden one does. + sys.argv = [check_id, f'--tier={tier}', *passthrough] + # A check that raises is a bug in the check, not a finding about the + # repo, so it becomes a note rather than a fail. + try: + with collecting() as collected: + module.main() + except Exception as exc: # noqa: BLE001 + notes.append(f'check {check_id} raised {type(exc).__name__}: {exc}') + continue + finally: + sys.argv = saved_argv + if not collected: + notes.append(f'check {check_id} produced no result') + continue + results.extend(collected) + + generated_at = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + repo = origin_url() + + if fmt == 'json': + import json + + report = { + 'schema_version': 1, + 'repo': repo, + 'tier': tier, + 'tier_source': tier_source, + 'generated_at': generated_at, + 'checks': results, + 'notes': notes, + } + print(json.dumps(report)) + return 0 + + # Markdown summary path — human spot-checks; agents should prefer JSON. + print('# AGENTS.md audit\n') + print(f'- Repo: `{repo}`') + print(f'- Tier: **{tier}** ({tier_source})') + print(f'- Generated: {generated_at}\n') + print('## Findings\n') + for r in results: + print(f'- **{r.get("status")}** (`{r.get("id")}`) — {r.get("summary")}') + if notes: + print('\n## Notes\n') + for n in notes: + print(f'- {n}') + return 0 + + +def _fix(argv: list[str]) -> int: + if not argv: + print('Usage: agents-md fix ', file=sys.stderr) + return 2 + name, rest = argv[0], argv[1:] + available = _modules(fixes_pkg) + if name not in available: + print(f'Unknown fix: {name}', file=sys.stderr) + return 2 + # The fix scripts read sys.argv directly, as they did when each was its + # own script. + sys.argv = [f'agents-md fix {name}', *rest] + return available[name].main() + + +def _list() -> int: + print('checks:') + for check_id in sorted(_modules(checks_pkg)): + print(f' {check_id}') + print('fixes:') + for name in sorted(_modules(fixes_pkg)): + print(f' {name}') + return 0 + + +def main() -> int: + argv = sys.argv[1:] + if not argv or argv[0] in ('-h', '--help'): + usage() + return 0 if argv else 2 + command, rest = argv[0], argv[1:] + if command == 'check': + return _check(rest) + if command == 'detect-tier': + sys.argv = ['detect-tier', *rest] + return tier_mod.main() + if command == 'fix': + return _fix(rest) + if command == 'list': + return _list() + print(f'Unknown command: {command}', file=sys.stderr) + usage() + return 2 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/agents-md/src/charm_tech_code/agents_md/common.py b/agents-md/src/charm_tech_code/agents_md/common.py new file mode 100644 index 0000000..4d1854b --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/common.py @@ -0,0 +1,155 @@ +"""Shared helpers for the checks and fixes in this package. + +Imported by every check / fix script. No side effects on import. +""" + +from __future__ import annotations + +import contextlib +import json +import os +import subprocess +import sys +from collections.abc import Iterable, Iterator +from pathlib import Path +from typing import Any + +# Templates and question batteries ship with the package rather than sitting +# beside the skill, so a `uvx --from git+...` invocation carries them too. +ASSETS = Path(__file__).parent / 'assets' + + +# Exit codes. Every check script exits with one of these. +EXIT_PASS = 0 +EXIT_FAIL = 1 +EXIT_NA = 2 +EXIT_UNKNOWN = 3 + + +def repo_root() -> Path: + """Return the repo root. Falls back to CWD when not inside a git tree + (the skill can be invoked against an unpacked tarball, for example).""" + try: + out = subprocess.run( + ['git', 'rev-parse', '--show-toplevel'], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + if out: + return Path(out) + except (subprocess.CalledProcessError, FileNotFoundError): + pass + return Path.cwd() + + +def origin_url() -> str: + """Return the origin remote URL normalised to https form, without a + trailing .git. Empty string if no origin remote.""" + try: + url = subprocess.run( + ['git', 'config', '--get', 'remote.origin.url'], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + except (subprocess.CalledProcessError, FileNotFoundError): + return '' + if url.startswith('git@github.com:'): + url = 'https://github.com/' + url[len('git@github.com:') :] + if url.endswith('.git'): + url = url[:-4] + return url + + +_collector: list[dict[str, Any]] | None = None + + +@contextlib.contextmanager +def collecting() -> Iterator[list[dict[str, Any]]]: + """Capture what emit_check produces instead of printing it. + + Nesting is not supported, and does not happen: only the umbrella runner + collects, and a check never runs another check. + """ + global _collector + results: list[dict[str, Any]] = [] + _collector = results + try: + yield results + finally: + _collector = None + + +def emit_check( + check_id: str, + status: str, + summary: str, + evidence: dict[str, Any] | None = None, + remediation: dict[str, Any] | None = None, +) -> None: + """Emit a single check result as a JSON object on one line to stdout. + + status is one of: pass, fail, na, unknown. + """ + payload = { + 'id': check_id, + 'status': status, + 'summary': summary, + 'evidence': evidence if evidence is not None else {}, + 'remediation': remediation, + } + if _collector is not None: + # The umbrella runner imports each check and calls its main() in + # process, so the result is handed over directly rather than being + # printed and reparsed. + _collector.append(payload) + return + # Single-line JSON, for a check invoked on its own. + sys.stdout.write(json.dumps(payload, separators=(',', ':'))) + sys.stdout.write('\n') + + +def tier_applies(check_tiers: str | Iterable[str], current_tier: str) -> bool: + """True when the current tier is in the check's applicable tiers. + + check_tiers may be a comma-separated string ("product,canonical") or + any iterable of strings. + """ + if isinstance(check_tiers, str): + tiers = {t.strip() for t in check_tiers.split(',') if t.strip()} + else: + tiers = set(check_tiers) + return current_tier in tiers + + +def parse_tier(argv: list[str] | None = None) -> str: + """Extract --tier= from argv. Returns empty string if absent. + + Unknown flags are ignored (each check only cares about --tier).""" + args = argv if argv is not None else sys.argv[1:] + for arg in args: + if arg.startswith('--tier='): + return arg[len('--tier=') :] + return '' + + +def run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + """Convenience wrapper around subprocess.run with text=True and + capture_output=True by default. Never raises on non-zero exit — + callers should inspect .returncode.""" + kwargs.setdefault('text', True) + kwargs.setdefault('capture_output', True) + kwargs.setdefault('check', False) + return subprocess.run(cmd, **kwargs) + + +def cd_repo_root() -> Path: + """Chdir to the repo root and return it. Exits EXIT_UNKNOWN if the + root cannot be reached (matches the shell behaviour of `cd || exit 3`).""" + root = repo_root() + try: + os.chdir(root) + except OSError: + sys.exit(EXIT_UNKNOWN) + return root diff --git a/agents-md/src/charm_tech_code/agents_md/fixes/__init__.py b/agents-md/src/charm_tech_code/agents_md/fixes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agents-md/src/charm_tech_code/agents_md/fixes/add_agents_md.py b/agents-md/src/charm_tech_code/agents_md/fixes/add_agents_md.py new file mode 100644 index 0000000..aa967a4 --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/fixes/add_agents_md.py @@ -0,0 +1,44 @@ +"""Fix: copy the AGENTS.md template into the repo root. +Agent must fill in {{...}} placeholders before committing — the +template is intentionally a skeleton, not a working file. +""" + +from __future__ import annotations + +import shutil +import sys +from pathlib import Path + +from ..common import ASSETS, repo_root + +SCRIPT_DIR = Path(__file__).resolve().parent + + +def main() -> int: + try: + import os + + os.chdir(repo_root()) + except OSError: + return 3 + + if Path('AGENTS.md').exists(): + sys.stderr.write('AGENTS.md already exists; refusing to overwrite.\n') + return 1 + + template = ASSETS / 'AGENTS.md.template' + if not template.is_file(): + sys.stderr.write('Template missing.\n') + return 3 + + shutil.copy(template, 'AGENTS.md') + sys.stdout.write( + 'Copied AGENTS.md template. Replace {{REPO_DESCRIPTION_ONE_SENTENCE}}, ' + '{{SETUP_COMMANDS}}, {{TEST_COMMANDS}}, {{LINT_COMMANDS}}, {{DEPTH_LINK_TITLE}}, ' + '{{DEPTH_LINK}} before committing.\n' + ) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/agents-md/src/charm_tech_code/agents_md/tier.py b/agents-md/src/charm_tech_code/agents_md/tier.py new file mode 100644 index 0000000..89d6c1b --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/tier.py @@ -0,0 +1,108 @@ +"""Inspect the current repo's origin remote and emit one of: + + product | canonical | personal | unknown + +Detection rules (in order): + 1. URL matches https://github.com/canonical/ -> canonical or product + 2. URL matches https://github.com//: + a. If the repo is a fork of canonical/ (detected via + `gh repo view --json isFork,parent`, or an `upstream` remote + pointing at canonical/) -> canonical or product + b. Otherwise -> personal + 3. No remote / no clear org -> unknown + +The fork lookup matters because Charm Tech engineers routinely work +from a personal fork of a canonical/* repo; the baseline that applies +is the upstream repo's, not the fork owner's. + +Product-tier classification within canonical/ is driven by a small +allowlist below (Charm Tech products as of 2026-06 — operator, pebble, +jubilant, concierge, charmlibs). All other canonical/* repos are +'canonical' tier (cross-cutting requirements only). + +Override: pass an argument to force a tier (useful when auditing a +repo before transfer to the canonical org). + +detect() returns the tier; main() prints it and exits 0. +""" + +from __future__ import annotations + +import shutil +import sys + +from .common import origin_url, run + +PRODUCT_REPOS = {'operator', 'pebble', 'jubilant', 'concierge', 'charmlibs'} + + +def detect() -> str: + """Return the tier for the repo in the current working directory. + + Returns "unknown" rather than guessing when the origin remote is absent + or is not a GitHub URL. + """ + + url = origin_url() + if not url: + return 'unknown' + + prefix = 'https://github.com/' + if not url.startswith(prefix): + # Some other forwarding host; don't guess. + return 'unknown' + + path = url[len(prefix) :] + parts = path.split('/', 1) + if len(parts) != 2 or not parts[0] or not parts[1]: + return 'unknown' + org, repo = parts + + # If origin is not under canonical/, the repo may still be a fork of + # a canonical/* repo — in which case the upstream's baseline applies. + if org != 'canonical': + parent_slug = '' + if shutil.which('gh'): + result = run([ + 'gh', + 'repo', + 'view', + f'{org}/{repo}', + '--json', + 'isFork,parent', + '--jq', + r'select(.isFork) | .parent' + r' | select(.owner.login == "canonical")' + r' | "\(.owner.login)/\(.name)"', + ]) + parent_slug = result.stdout.strip() + if not parent_slug: + upstream = run(['git', 'config', '--get', 'remote.upstream.url']).stdout.strip() + if upstream.startswith('git@github.com:'): + upstream = 'https://github.com/' + upstream[len('git@github.com:') :] + if upstream.endswith('.git'): + upstream = upstream[:-4] + if upstream.startswith(prefix): + upstream_path = upstream[len(prefix) :] + if upstream_path.startswith('canonical/'): + parent_slug = upstream_path + if parent_slug: + org = 'canonical' + repo = parent_slug[len('canonical/') :] + + if org == 'canonical': + return 'product' if repo in PRODUCT_REPOS else 'canonical' + return 'personal' + + +def main() -> int: + """Print the detected tier. An argument forces a tier instead.""" + if len(sys.argv) >= 2: + arg = sys.argv[1] + if arg in ('product', 'canonical', 'personal'): + print(arg) + return 0 + print('unknown', file=sys.stderr) + return 1 + print(detect()) + return 0 diff --git a/agents-md/tests/checks/test_agents_md_battery.py b/agents-md/tests/checks/test_agents_md_battery.py new file mode 100644 index 0000000..de16ea3 --- /dev/null +++ b/agents-md/tests/checks/test_agents_md_battery.py @@ -0,0 +1,185 @@ +"""AGENTS.md question battery validation (Layer 2 seed data, checked statically).""" + +from __future__ import annotations + +import hashlib +import textwrap + +AGENTS_MD = textwrap.dedent("""\ + # AGENTS.md + + ## Test + + ```bash + go test ./internals/cli -check.f MySuite # single gocheck suite + ``` + + See [HACKING.md](HACKING.md). CI also rejects any use of `interface{}` — + write `any`. + """) + +BATTERY = textwrap.dedent("""\ + schema_version: 1 + repo: example + upstream: canonical/example + source: + agents_md_ref: chore/agents-md + agents_md_sha: deadbeef + agents_md_sha256: {digest} + seeded_from: design doc + seeded_on: 2026-08-19 + entries: + - id: single-suite + question: How do you run just MySuite? + classification: cache + source_line: "go test ./internals/cli -check.f MySuite # single gocheck suite" + answer: + grade: command + expect: go test ./internals/cli -check.f MySuite + verify: + - kind: suite_in_package + suite: MySuite + package: internals/cli + ci_verifiable: true + - id: no-empty-interface + question: What do you write instead of `interface{{}}`? + classification: override + source_line: CI also rejects any use of `interface{{}}` — write `any`. + answer: + grade: keywords + require: + - any + verify: + - kind: path_exists + path: HACKING.md + ci_verifiable: true + """) + +TREE = { + 'AGENTS.md': AGENTS_MD, + 'HACKING.md': '# Hacking\n', + 'internals/cli/suite_test.go': 'package cli\n\ntype MySuite struct{}\n', +} + + +def battery() -> str: + return BATTERY.format(digest=hashlib.sha256(AGENTS_MD.encode()).hexdigest()) + + +def run(run_check, files: dict[str, str], battery_text: str) -> dict: + return run_check( + 'agents-md-battery', + 'canonical', + {**files, 'battery.yaml': battery_text}, + ('--battery=battery.yaml',), + ) + + +def test_na_when_no_battery_for_repo(run_check): + # No --battery and no origin remote to name one: not a gap, just a repo + # that hasn't been through the Layer 2 authoring gate. + r = run_check('agents-md-battery', 'canonical', TREE) + assert r['status'] == 'na' + + +def test_pass_when_battery_matches_repo(run_check): + r = run(run_check, TREE, battery()) + assert r['status'] == 'pass' + assert r['evidence']['drifted_source_lines'] == [] + assert r['evidence']['verify_findings'] == [] + assert r['evidence']['entries_by_classification'] == {'cache': 1, 'override': 1} + + +def test_source_line_matches_across_a_wrapped_line(run_check): + # The interface{} source line wraps in AGENTS.md; whitespace is collapsed + # on both sides so a single-line entry still matches. + r = run(run_check, TREE, battery()) + assert r['status'] == 'pass' + + +def test_fail_when_source_line_no_longer_in_agents_md(run_check): + b = battery().replace('write `any`.', 'write `anything`.') + r = run(run_check, TREE, b) + assert r['status'] == 'fail' + assert [d['entry'] for d in r['evidence']['drifted_source_lines']] == ['no-empty-interface'] + + +def test_fail_when_suite_no_longer_in_named_package(run_check): + # The canonical Layer 1 case, carried into the battery: pebble's + # PebbleSuite documented against a package it has moved out of. + files = {**TREE} + files.pop('internals/cli/suite_test.go') + files['internals/cli/other_test.go'] = 'package cli\n\nfunc TestOther() {}\n' + r = run(run_check, files, battery()) + assert r['status'] == 'fail' + assert any( + f['kind'] == 'suite_in_package' + and f['problem'] == 'suite identifier not found anywhere in package' + for f in r['evidence']['verify_findings'] + ) + + +def test_fail_when_referenced_path_gone(run_check): + files = {k: v for k, v in TREE.items() if k != 'HACKING.md'} + r = run(run_check, files, battery()) + assert r['status'] == 'fail' + assert any( + f['kind'] == 'path_exists' and f['path'] == 'HACKING.md' + for f in r['evidence']['verify_findings'] + ) + + +def test_fail_when_text_in_file_pattern_missing(run_check): + b = battery().replace( + ' - kind: path_exists\n path: HACKING.md\n', + ' - kind: text_in_file\n file: HACKING.md\n pattern: no such text\n', + ) + r = run(run_check, TREE, b) + assert r['status'] == 'fail' + assert any( + f['kind'] == 'text_in_file' and f['problem'] == 'pattern not found in file' + for f in r['evidence']['verify_findings'] + ) + + +def test_verify_none_is_reported_not_failed(run_check): + b = battery().replace( + ' - kind: path_exists\n path: HACKING.md\n', + ' - kind: none\n reason: lives in GitHub settings, not the tree\n', + ) + r = run(run_check, TREE, b) + assert r['status'] == 'pass' + assert r['evidence']['unanchored_entries'] == ['no-empty-interface'] + + +def test_schema_finding_when_ungated_entry_is_not_ci_verifiable(run_check): + b = battery().replace( + ' ci_verifiable: true\n - id: no-empty-interface', + ' ci_verifiable: false\n - id: no-empty-interface', + ) + r = run(run_check, TREE, b) + assert r['status'] == 'fail' + assert any('gated_by' in f for f in r['evidence']['schema_findings']) + + +def test_schema_finding_on_unknown_answer_grade(run_check): + b = battery().replace('grade: command', 'grade: vibes') + r = run(run_check, TREE, b) + assert r['status'] == 'fail' + assert any('answer.grade' in f for f in r['evidence']['schema_findings']) + + +def test_fail_when_agents_md_absent_but_battery_present(run_check): + files = {k: v for k, v in TREE.items() if k != 'AGENTS.md'} + r = run(run_check, files, battery()) + assert r['status'] == 'fail' + assert 'no AGENTS.md' in r['summary'] + + +def test_digest_change_is_evidence_not_failure(run_check): + # A changed AGENTS.md is a Layer 2 re-test trigger, not a defect — the file + # may have improved. Surfaced as evidence, never as a fail on its own. + files = {**TREE, 'AGENTS.md': AGENTS_MD + '\nAn extra, harmless sentence.\n'} + r = run(run_check, files, battery()) + assert r['status'] == 'pass' + assert r['evidence']['agents_md_changed_since_seeding'] is True diff --git a/agents-md/tests/checks/test_agents_md_content.py b/agents-md/tests/checks/test_agents_md_content.py new file mode 100644 index 0000000..0e2208a --- /dev/null +++ b/agents-md/tests/checks/test_agents_md_content.py @@ -0,0 +1,200 @@ +"""AGENTS.md content check: the five Layer 1 staleness checks.""" + +from __future__ import annotations + +import textwrap + +CLEAN = textwrap.dedent("""\ + # AGENTS.md + + See [HACKING.md](HACKING.md) for details. Tests use `gopkg.in/check.v1`. + + ## Build and test + + ```bash + true --check # lint gate + false --lxd deploy # deploys via LXD, needs juju + ``` + """) + + +def test_na_when_agents_md_missing(run_check): + r = run_check('agents-md-content', 'canonical', {}) + assert r['status'] == 'na' + + +def test_pass_when_content_clean(run_check): + r = run_check( + 'agents-md-content', + 'canonical', + { + 'AGENTS.md': CLEAN, + 'HACKING.md': '# Hacking\n', + }, + ) + assert r['status'] == 'pass' + assert r['evidence']['runnable_failed'] == [] + assert r['evidence']['missing_paths'] == [] + + +def test_module_path_not_flagged_as_missing_file(run_check): + # gopkg.in/check.v1 is a Go module path, not a local file — must not be + # reported missing just because it contains a '/'. + r = run_check( + 'agents-md-content', + 'canonical', + { + 'AGENTS.md': CLEAN, + 'HACKING.md': '# Hacking\n', + }, + ) + assert 'gopkg.in/check.v1' not in r['evidence']['missing_paths'] + + +def test_environment_gated_command_not_executed(run_check): + # `false` would fail if run; it must be classified environment-gated + # (lxd/juju) and skipped, not executed — proven indirectly by the + # overall check still passing (see test_pass_when_content_clean). + r = run_check( + 'agents-md-content', + 'canonical', + { + 'AGENTS.md': CLEAN, + 'HACKING.md': '# Hacking\n', + }, + ) + gated_commands = [g['command'] for g in r['evidence']['environment_gated']] + assert any(c.startswith('false') for c in gated_commands) + assert not any(rr['command'].startswith('false') for rr in r['evidence']['runnable_failed']) + + +def test_fail_when_referenced_path_missing(run_check): + md = textwrap.dedent("""\ + # AGENTS.md + + See [BOGUS.md](BOGUS.md) for details. + """) + r = run_check('agents-md-content', 'canonical', {'AGENTS.md': md}) + assert r['status'] == 'fail' + assert 'BOGUS.md' in r['evidence']['missing_paths'] + + +def test_fail_when_command_tool_missing(run_check): + md = textwrap.dedent("""\ + # AGENTS.md + + ```bash + definitelynotarealbinary123 --check # lint + ``` + """) + r = run_check('agents-md-content', 'canonical', {'AGENTS.md': md}) + assert r['status'] == 'fail' + assert any(m['tool'] == 'definitelynotarealbinary123' for m in r['evidence']['missing_tools']) + + +def test_fail_when_runnable_command_fails(run_check): + md = textwrap.dedent("""\ + # AGENTS.md + + ```bash + false --check # lint gate + ``` + """) + r = run_check('agents-md-content', 'canonical', {'AGENTS.md': md}) + assert r['status'] == 'fail' + assert r['evidence']['runnable_failed'] + assert r['evidence']['runnable_failed'][0]['command'].startswith('false') + + +def test_suite_not_found_in_package_flags_finding(run_check): + # The canonical case: a gocheck suite documented against a package that + # no longer contains it (pebble's PebbleSuite/cmd-pebble staleness). + md = textwrap.dedent("""\ + # AGENTS.md + + ```bash + go test ./internals/cli -check.f MySuite # single suite + ``` + """) + r = run_check( + 'agents-md-content', + 'canonical', + { + 'AGENTS.md': md, + 'internals/cli/other_test.go': 'package cli\n\nfunc TestSomethingElse() {}\n', + }, + ) + findings = r['evidence']['suite_findings'] + assert any( + f['suite'] == 'MySuite' + and f['problem'] == 'suite identifier not found anywhere in package' + for f in findings + ) + + +def test_suite_found_in_package_no_finding(run_check): + md = textwrap.dedent("""\ + # AGENTS.md + + ```bash + go test ./internals/cli -check.f MySuite # single suite + ``` + """) + r = run_check( + 'agents-md-content', + 'canonical', + { + 'AGENTS.md': md, + 'internals/cli/suite_test.go': 'package cli\n\ntype MySuite struct{}\n', + }, + ) + assert r['evidence']['suite_findings'] == [] + + +def test_scope_lint_flags_harness_content(run_check): + md = textwrap.dedent("""\ + # AGENTS.md + + Some guidance for agents. + + Co-Authored-By: Claude + """) + r = run_check('agents-md-content', 'canonical', {'AGENTS.md': md}) + assert r['status'] == 'fail' + assert r['evidence']['scope_lint_findings'] + + +def test_version_pin_drift_flagged(run_check): + md = '# AGENTS.md\n\nPinned tool: widget/cmd/widget@v1.0.0 (see CI).\n' + r = run_check( + 'agents-md-content', + 'canonical', + { + 'AGENTS.md': md, + '.github/workflows/lint.yaml': ( + 'steps:\n - run: go install widget/cmd/widget@v2.0.0\n' + ), + }, + ) + assert r['status'] == 'fail' + assert r['evidence']['version_drift'] + assert r['evidence']['version_drift'][0]['tool'] == 'widget' + assert r['evidence']['version_drift'][0]['doc_version'] == 'v1.0.0' + assert r['evidence']['version_drift'][0]['ci_versions'] == ['v2.0.0'] + + +def test_version_pin_matches_ci(run_check): + md = '# AGENTS.md\n\nPinned tool: widget/cmd/widget@v1.0.0 (see CI).\n' + r = run_check( + 'agents-md-content', + 'canonical', + { + 'AGENTS.md': md, + '.github/workflows/lint.yaml': ( + 'steps:\n - run: go install widget/cmd/widget@v1.0.0\n' + ), + }, + ) + assert r['status'] == 'pass' + assert r['evidence']['version_drift'] == [] + assert r['evidence']['version_pins_checked'][0]['tool'] == 'widget' diff --git a/agents-md/tests/conftest.py b/agents-md/tests/conftest.py new file mode 100644 index 0000000..44eb930 --- /dev/null +++ b/agents-md/tests/conftest.py @@ -0,0 +1,45 @@ +"""Shared helpers for the agents-md check tests. + +The tests are functional: each writes a small tree into a tmp dir and runs +the real check through the installed console script, in a subprocess. No +mocking, and no importing the check into the test process, so a check that +reads the environment or shells out is exercised the way it really runs. +""" + +from __future__ import annotations + +import json +import subprocess + +import pytest + +CLI = 'agents-md' + + +@pytest.fixture +def run_check(tmp_path, monkeypatch): + """Return ``run(check_name, tier, files)`` -> parsed JSON dict. + + ``files`` is a mapping of repo-relative path -> file contents. Parent + directories are created as needed. ``args`` are extra CLI flags passed + after ``--only``. The check runs with cwd = tmp_path. + """ + + def _run(name: str, tier: str, files: dict[str, str], args: tuple[str, ...] = ()) -> dict: + for rel, body in files.items(): + dest = tmp_path / rel + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(body) + monkeypatch.chdir(tmp_path) + proc = subprocess.run( + [CLI, 'check', f'--tier={tier}', f'--only={name}', '--format=json', *args], + capture_output=True, + text=True, + check=False, + ) + assert proc.stdout, f'{name} produced no stdout (stderr: {proc.stderr!r})' + report = json.loads(proc.stdout) + assert report['checks'], f'{name} produced no result (notes: {report["notes"]})' + return report['checks'][0] + + return _run diff --git a/agents-md/tests/test_check_runner.py b/agents-md/tests/test_check_runner.py new file mode 100644 index 0000000..adbeac3 --- /dev/null +++ b/agents-md/tests/test_check_runner.py @@ -0,0 +1,25 @@ +"""The runner: one smoke test that --only dispatches and shapes a report.""" + +from __future__ import annotations + +import json +import subprocess + + +def test_only_dispatches_selected_check(tmp_path): + (tmp_path / 'AGENTS.md').write_text( + '# AGENTS.md\n\nGuidance to AI agents working in this repo.\n\n' + 'Run the unit tests with `python -m pytest`.\n' + ) + proc = subprocess.run( + ['agents-md', 'check', '--tier=canonical', '--only=agents-md'], + capture_output=True, + text=True, + check=True, + cwd=tmp_path, + ) + report = json.loads(proc.stdout) + assert report['tier'] == 'canonical' + assert report['tier_source'] == 'override' + assert [c['id'] for c in report['checks']] == ['agents-md'] + assert report['checks'][0]['status'] == 'pass' diff --git a/agents-md/tests/test_detect_tier.py b/agents-md/tests/test_detect_tier.py new file mode 100644 index 0000000..e5c50fd --- /dev/null +++ b/agents-md/tests/test_detect_tier.py @@ -0,0 +1,45 @@ +"""detect-tier: override arg is pure; git-driven paths use a real init.""" + +from __future__ import annotations + +import subprocess + + +def _run(*args, cwd=None): + return subprocess.run( + ['agents-md', 'detect-tier', *args], + capture_output=True, + text=True, + check=False, + cwd=cwd, + ) + + +def test_override_product(): + assert _run('product').stdout.strip() == 'product' + + +def test_override_rejects_garbage(): + proc = _run('something-else') + assert proc.returncode != 0 + assert proc.stderr.strip() == 'unknown' + + +def test_canonical_product_repo_from_origin(tmp_path): + subprocess.run(['git', 'init', '-q'], cwd=tmp_path, check=True) + subprocess.run( + ['git', 'remote', 'add', 'origin', 'https://github.com/canonical/operator'], + cwd=tmp_path, + check=True, + ) + assert _run(cwd=tmp_path).stdout.strip() == 'product' + + +def test_canonical_non_product_repo(tmp_path): + subprocess.run(['git', 'init', '-q'], cwd=tmp_path, check=True) + subprocess.run( + ['git', 'remote', 'add', 'origin', 'https://github.com/canonical/lxd'], + cwd=tmp_path, + check=True, + ) + assert _run(cwd=tmp_path).stdout.strip() == 'canonical' diff --git a/agents-md/uv.lock b/agents-md/uv.lock new file mode 100644 index 0000000..b8458ff --- /dev/null +++ b/agents-md/uv.lock @@ -0,0 +1,224 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "charm-tech-code-agents-md" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pyyaml" }, +] + +[package.dev-dependencies] +unit = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "pyyaml" }] + +[package.metadata.requires-dev] +unit = [{ name = "pytest" }] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] From 7c230e6c46584dd78a6afe0ab9bfc06229d41963 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 8 Sep 2026 19:05:13 +1200 Subject: [PATCH 2/7] docs: soft wrap the agents-md README Matches the root README and ai-failure-notifier's, which are both soft wrapped. GitHub wraps to the reader's window, so the hard wraps only made the paragraphs ragged there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XdsdR8QdcZY6GUJgd1Wt7c --- agents-md/README.md | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/agents-md/README.md b/agents-md/README.md index 2f3debc..fbf32c3 100644 --- a/agents-md/README.md +++ b/agents-md/README.md @@ -1,12 +1,6 @@ # agents-md -Keeps the `AGENTS.md` files across the Charm Tech estate current and -load-bearing. It implements the deterministic half of the validation design in -the repo-setup notes: a line in `AGENTS.md` earns its place either as an -*override* (the agent would confidently do the wrong thing without it) or as a -*cache* (the agent would get there eventually, by reading the Makefile, tox -config and CI every session). A stale line is worse than a missing one, because -agents trust the file over the repo. +Keeps the `AGENTS.md` files across the Charm Tech estate current and load-bearing. It implements the deterministic half of the validation design in the repo-setup notes: a line in `AGENTS.md` earns its place either as an *override* (the agent would confidently do the wrong thing without it) or as a *cache* (the agent would get there eventually, by reading the Makefile, tox config and CI every session). A stale line is worse than a missing one, because agents trust the file over the repo. ## Checks @@ -26,14 +20,8 @@ uvx --from charm-tech-code-agents-md agents-md check --only=agents-md-content -- uvx --from charm-tech-code-agents-md agents-md list ``` -The tier (`product`, `canonical`, `personal`) is detected from the repo's -origin remote, following a fork to its upstream, and decides which checks -apply. Pass `--tier=` to override it. +The tier (`product`, `canonical`, `personal`) is detected from the repo's origin remote, following a fork to its upstream, and decides which checks apply. Pass `--tier=` to override it. ## Question batteries -`assets/question-batteries/*.yaml`, one per repo, keyed by upstream name. They -live here rather than in the skill so that the check and the data it reads ship -together. Each entry carries the question, the answer that counts as correct, -and the line of `AGENTS.md` it came from, so a battery failure points at the -line to fix. +`assets/question-batteries/*.yaml`, one per repo, keyed by upstream name. They live here rather than in the skill so that the check and the data it reads ship together. Each entry carries the question, the answer that counts as correct, and the line of `AGENTS.md` it came from, so a battery failure points at the line to fix. From 1ffa1b3eb1b5aaa7534929cccfa635f65c1182f2 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 8 Sep 2026 19:07:47 +1200 Subject: [PATCH 3/7] chore: apply the review outcomes from the ai-failure-notifier PR Four conventions settled on #1 that apply here too, so that the second package in the repo does not arrive with a different set. * Apache licence header on every Python file, which none of these had. * Modules are private (`_cli`, `_common`, `_tier`, `_checks`, `_fixes`), so it stays hard to depend on tool internals from a CI hack later. * `__init__.py` is a docstring and nothing else -- the tool is a console script, not something to import -- with the entry point moved to `._cli`. * `[tool.ruff] extend = "../pyproject.toml"` rather than a comment asking people not to add a `[tool.ruff]` block, since extending means a setting added here overrides one key instead of the whole shared config. The modules inside `_checks` and `_fixes` keep their public-looking names: the runner derives check and fix identifiers from them, so `add_agents_md` is the name of the fix on the command line rather than an importable API. Also drops two comments explaining what a check used to do when it was a standalone script, which is the same leftover-from-one-file class that was picked up on #1. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XdsdR8QdcZY6GUJgd1Wt7c --- agents-md/pyproject.toml | 9 ++--- .../src/charm_tech_code/agents_md/__init__.py | 18 +++++++--- .../agents_md/_checks/__init__.py | 13 ++++++++ .../{checks => _checks}/agents_md.py | 16 ++++++++- .../{checks => _checks}/agents_md_battery.py | 16 ++++++++- .../{checks => _checks}/agents_md_content.py | 16 ++++++++- .../agents_md/{cli.py => _cli.py} | 33 +++++++++++++------ .../agents_md/{common.py => _common.py} | 14 ++++++++ .../agents_md/_fixes/__init__.py | 13 ++++++++ .../{fixes => _fixes}/add_agents_md.py | 16 ++++++++- .../agents_md/{tier.py => _tier.py} | 16 ++++++++- .../agents_md/checks/__init__.py | 0 .../agents_md/fixes/__init__.py | 0 .../tests/checks/test_agents_md_battery.py | 14 ++++++++ .../tests/checks/test_agents_md_content.py | 14 ++++++++ agents-md/tests/conftest.py | 14 ++++++++ agents-md/tests/test_check_runner.py | 14 ++++++++ agents-md/tests/test_detect_tier.py | 14 ++++++++ 18 files changed, 227 insertions(+), 23 deletions(-) create mode 100644 agents-md/src/charm_tech_code/agents_md/_checks/__init__.py rename agents-md/src/charm_tech_code/agents_md/{checks => _checks}/agents_md.py (76%) rename agents-md/src/charm_tech_code/agents_md/{checks => _checks}/agents_md_battery.py (95%) rename agents-md/src/charm_tech_code/agents_md/{checks => _checks}/agents_md_content.py (96%) rename agents-md/src/charm_tech_code/agents_md/{cli.py => _cli.py} (85%) rename agents-md/src/charm_tech_code/agents_md/{common.py => _common.py} (89%) create mode 100644 agents-md/src/charm_tech_code/agents_md/_fixes/__init__.py rename agents-md/src/charm_tech_code/agents_md/{fixes => _fixes}/add_agents_md.py (63%) rename agents-md/src/charm_tech_code/agents_md/{tier.py => _tier.py} (85%) delete mode 100644 agents-md/src/charm_tech_code/agents_md/checks/__init__.py delete mode 100644 agents-md/src/charm_tech_code/agents_md/fixes/__init__.py diff --git a/agents-md/pyproject.toml b/agents-md/pyproject.toml index 71b0615..3a44e32 100644 --- a/agents-md/pyproject.toml +++ b/agents-md/pyproject.toml @@ -13,7 +13,7 @@ license = "Apache-2.0" dependencies = ["pyyaml"] [project.scripts] -agents-md = "charm_tech_code.agents_md:main" +agents-md = "charm_tech_code.agents_md._cli:main" [build-system] requires = ["hatchling"] @@ -28,6 +28,7 @@ unit = ["pytest"] [tool.pytest.ini_options] testpaths = ["tests"] -# Ruff configuration is at the root of the monorepo, deliberately not repeated -# here: ruff uses the closest config it finds rather than merging, so a -# [tool.ruff] block in this file would silently override the shared one. +# The real ruff configuration is at the root of the monorepo; extending it +# means a setting added here overrides one key rather than the whole config. +[tool.ruff] +extend = "../pyproject.toml" diff --git a/agents-md/src/charm_tech_code/agents_md/__init__.py b/agents-md/src/charm_tech_code/agents_md/__init__.py index 1180e6f..5891064 100644 --- a/agents-md/src/charm_tech_code/agents_md/__init__.py +++ b/agents-md/src/charm_tech_code/agents_md/__init__.py @@ -1,3 +1,17 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Keep the estate's AGENTS.md files honest. Three checks and one fix, plus the per-repo question batteries they read. @@ -6,7 +20,3 @@ behavioural battery. The agent-facing half lives in the `charm-tech-baseline` skill in `canonical/charm-tech`. """ - -from .cli import main - -__all__ = ['main'] diff --git a/agents-md/src/charm_tech_code/agents_md/_checks/__init__.py b/agents-md/src/charm_tech_code/agents_md/_checks/__init__.py new file mode 100644 index 0000000..50f7e1b --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/_checks/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/agents-md/src/charm_tech_code/agents_md/checks/agents_md.py b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md.py similarity index 76% rename from agents-md/src/charm_tech_code/agents_md/checks/agents_md.py rename to agents-md/src/charm_tech_code/agents_md/_checks/agents_md.py index 13ccd07..4a40575 100644 --- a/agents-md/src/charm_tech_code/agents_md/checks/agents_md.py +++ b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md.py @@ -1,3 +1,17 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Check: AGENTS.md present (best-of-class; agent-onboarding entry point). Tier coverage: product, canonical. Personal-tier: informational only. @@ -9,7 +23,7 @@ import sys from pathlib import Path -from ..common import ( +from .._common import ( EXIT_FAIL, EXIT_NA, EXIT_PASS, diff --git a/agents-md/src/charm_tech_code/agents_md/checks/agents_md_battery.py b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_battery.py similarity index 95% rename from agents-md/src/charm_tech_code/agents_md/checks/agents_md_battery.py rename to agents-md/src/charm_tech_code/agents_md/_checks/agents_md_battery.py index 4ef2e44..b65ce7d 100644 --- a/agents-md/src/charm_tech_code/agents_md/checks/agents_md_battery.py +++ b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_battery.py @@ -1,3 +1,17 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Check: this repo's AGENTS.md question battery still describes the repo. Tier coverage: product, canonical, personal. @@ -35,7 +49,7 @@ import yaml -from ..common import ( +from .._common import ( ASSETS, EXIT_FAIL, EXIT_NA, diff --git a/agents-md/src/charm_tech_code/agents_md/checks/agents_md_content.py b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_content.py similarity index 96% rename from agents-md/src/charm_tech_code/agents_md/checks/agents_md_content.py rename to agents-md/src/charm_tech_code/agents_md/_checks/agents_md_content.py index 62582f8..6da353c 100644 --- a/agents-md/src/charm_tech_code/agents_md/checks/agents_md_content.py +++ b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_content.py @@ -1,3 +1,17 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Check: AGENTS.md content is trustworthy (Layer 1 staleness checks). Tier coverage: product, canonical, personal. @@ -35,7 +49,7 @@ import sys from pathlib import Path -from ..common import ( +from .._common import ( EXIT_FAIL, EXIT_NA, EXIT_PASS, diff --git a/agents-md/src/charm_tech_code/agents_md/cli.py b/agents-md/src/charm_tech_code/agents_md/_cli.py similarity index 85% rename from agents-md/src/charm_tech_code/agents_md/cli.py rename to agents-md/src/charm_tech_code/agents_md/_cli.py index 42cf622..c76f57e 100644 --- a/agents-md/src/charm_tech_code/agents_md/cli.py +++ b/agents-md/src/charm_tech_code/agents_md/_cli.py @@ -1,3 +1,17 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Check runner. Dispatches every check that applies to the resolved tier and emits a single JSON report. @@ -21,10 +35,10 @@ import sys from types import ModuleType -from . import checks as checks_pkg -from . import fixes as fixes_pkg -from . import tier as tier_mod -from .common import collecting, origin_url +from . import _checks as checks_pkg +from . import _fixes as fixes_pkg +from . import _tier as tier_mod +from ._common import collecting, origin_url def _modules(package: ModuleType) -> dict[str, ModuleType]: @@ -98,10 +112,10 @@ def _check(argv: list[str]) -> int: notes: list[str] = [] saved_argv = sys.argv for check_id, module in selected.items(): - # Each check reads its own flags off sys.argv, as it did when it was a - # standalone script. Set it explicitly rather than letting the check - # read the runner's own command line, so that a *detected* tier - # reaches the check just as an overridden one does. + # Each check reads its own flags off sys.argv. Set it explicitly + # rather than letting the check read the runner's own command line, so + # that a *detected* tier reaches the check just as an overridden one + # does. sys.argv = [check_id, f'--tier={tier}', *passthrough] # A check that raises is a bug in the check, not a finding about the # repo, so it becomes a note rather than a fail. @@ -160,8 +174,7 @@ def _fix(argv: list[str]) -> int: if name not in available: print(f'Unknown fix: {name}', file=sys.stderr) return 2 - # The fix scripts read sys.argv directly, as they did when each was its - # own script. + # The fixes read sys.argv directly. sys.argv = [f'agents-md fix {name}', *rest] return available[name].main() diff --git a/agents-md/src/charm_tech_code/agents_md/common.py b/agents-md/src/charm_tech_code/agents_md/_common.py similarity index 89% rename from agents-md/src/charm_tech_code/agents_md/common.py rename to agents-md/src/charm_tech_code/agents_md/_common.py index 4d1854b..0c2ed9e 100644 --- a/agents-md/src/charm_tech_code/agents_md/common.py +++ b/agents-md/src/charm_tech_code/agents_md/_common.py @@ -1,3 +1,17 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Shared helpers for the checks and fixes in this package. Imported by every check / fix script. No side effects on import. diff --git a/agents-md/src/charm_tech_code/agents_md/_fixes/__init__.py b/agents-md/src/charm_tech_code/agents_md/_fixes/__init__.py new file mode 100644 index 0000000..50f7e1b --- /dev/null +++ b/agents-md/src/charm_tech_code/agents_md/_fixes/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/agents-md/src/charm_tech_code/agents_md/fixes/add_agents_md.py b/agents-md/src/charm_tech_code/agents_md/_fixes/add_agents_md.py similarity index 63% rename from agents-md/src/charm_tech_code/agents_md/fixes/add_agents_md.py rename to agents-md/src/charm_tech_code/agents_md/_fixes/add_agents_md.py index aa967a4..7f6d40a 100644 --- a/agents-md/src/charm_tech_code/agents_md/fixes/add_agents_md.py +++ b/agents-md/src/charm_tech_code/agents_md/_fixes/add_agents_md.py @@ -1,3 +1,17 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Fix: copy the AGENTS.md template into the repo root. Agent must fill in {{...}} placeholders before committing — the template is intentionally a skeleton, not a working file. @@ -9,7 +23,7 @@ import sys from pathlib import Path -from ..common import ASSETS, repo_root +from .._common import ASSETS, repo_root SCRIPT_DIR = Path(__file__).resolve().parent diff --git a/agents-md/src/charm_tech_code/agents_md/tier.py b/agents-md/src/charm_tech_code/agents_md/_tier.py similarity index 85% rename from agents-md/src/charm_tech_code/agents_md/tier.py rename to agents-md/src/charm_tech_code/agents_md/_tier.py index 89d6c1b..38dc3d3 100644 --- a/agents-md/src/charm_tech_code/agents_md/tier.py +++ b/agents-md/src/charm_tech_code/agents_md/_tier.py @@ -1,3 +1,17 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Inspect the current repo's origin remote and emit one of: product | canonical | personal | unknown @@ -31,7 +45,7 @@ import shutil import sys -from .common import origin_url, run +from ._common import origin_url, run PRODUCT_REPOS = {'operator', 'pebble', 'jubilant', 'concierge', 'charmlibs'} diff --git a/agents-md/src/charm_tech_code/agents_md/checks/__init__.py b/agents-md/src/charm_tech_code/agents_md/checks/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/agents-md/src/charm_tech_code/agents_md/fixes/__init__.py b/agents-md/src/charm_tech_code/agents_md/fixes/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/agents-md/tests/checks/test_agents_md_battery.py b/agents-md/tests/checks/test_agents_md_battery.py index de16ea3..7d8554b 100644 --- a/agents-md/tests/checks/test_agents_md_battery.py +++ b/agents-md/tests/checks/test_agents_md_battery.py @@ -1,3 +1,17 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """AGENTS.md question battery validation (Layer 2 seed data, checked statically).""" from __future__ import annotations diff --git a/agents-md/tests/checks/test_agents_md_content.py b/agents-md/tests/checks/test_agents_md_content.py index 0e2208a..6e40826 100644 --- a/agents-md/tests/checks/test_agents_md_content.py +++ b/agents-md/tests/checks/test_agents_md_content.py @@ -1,3 +1,17 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """AGENTS.md content check: the five Layer 1 staleness checks.""" from __future__ import annotations diff --git a/agents-md/tests/conftest.py b/agents-md/tests/conftest.py index 44eb930..07ed5e0 100644 --- a/agents-md/tests/conftest.py +++ b/agents-md/tests/conftest.py @@ -1,3 +1,17 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Shared helpers for the agents-md check tests. The tests are functional: each writes a small tree into a tmp dir and runs diff --git a/agents-md/tests/test_check_runner.py b/agents-md/tests/test_check_runner.py index adbeac3..e7250aa 100644 --- a/agents-md/tests/test_check_runner.py +++ b/agents-md/tests/test_check_runner.py @@ -1,3 +1,17 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """The runner: one smoke test that --only dispatches and shapes a report.""" from __future__ import annotations diff --git a/agents-md/tests/test_detect_tier.py b/agents-md/tests/test_detect_tier.py index e5c50fd..e93a4ca 100644 --- a/agents-md/tests/test_detect_tier.py +++ b/agents-md/tests/test_detect_tier.py @@ -1,3 +1,17 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """detect-tier: override arg is pure; git-driven paths use a real init.""" from __future__ import annotations From 1b31dc08bc0e25750ef433ad377c5cb3eaccccf3 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 8 Sep 2026 19:17:26 +1200 Subject: [PATCH 4/7] refactor: drop the tier system All three checks declared `product,canonical,personal`, so the tier gate never excluded anything -- it computed a value and then applied it to nothing. That is not an accident of this extraction: a well-maintained AGENTS.md is worth the same in a personal fork as in a product repo, which is not true of the requirements the tiers were built for (SBOM submission, TICS targets, security documentation). The baseline audit needs tiers; this does not have to inherit them. Removing it takes a network round trip out of every run: on a fork, tier detection shelled out to `gh repo view` to find the upstream, for a check that is meant to run in CI on every PR touching the file. Goes with it: `--tier=`, the `detect-tier` command, `parse_tier` and `tier_applies`, and the `tier`/`tier_source` fields in the report. Anything reading the JSON should not miss them, since they never varied by anything except the flag it passed in. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XdsdR8QdcZY6GUJgd1Wt7c --- agents-md/README.md | 2 +- .../agents_md/_checks/agents_md.py | 10 -- .../agents_md/_checks/agents_md_battery.py | 9 -- .../agents_md/_checks/agents_md_content.py | 9 -- .../src/charm_tech_code/agents_md/_cli.py | 39 +----- .../src/charm_tech_code/agents_md/_common.py | 26 +--- .../src/charm_tech_code/agents_md/_tier.py | 122 ------------------ .../tests/checks/test_agents_md_battery.py | 3 +- .../tests/checks/test_agents_md_content.py | 17 +-- agents-md/tests/conftest.py | 6 +- agents-md/tests/test_check_runner.py | 4 +- agents-md/tests/test_detect_tier.py | 59 --------- 12 files changed, 17 insertions(+), 289 deletions(-) delete mode 100644 agents-md/src/charm_tech_code/agents_md/_tier.py delete mode 100644 agents-md/tests/test_detect_tier.py diff --git a/agents-md/README.md b/agents-md/README.md index fbf32c3..ef3bc48 100644 --- a/agents-md/README.md +++ b/agents-md/README.md @@ -20,7 +20,7 @@ uvx --from charm-tech-code-agents-md agents-md check --only=agents-md-content -- uvx --from charm-tech-code-agents-md agents-md list ``` -The tier (`product`, `canonical`, `personal`) is detected from the repo's origin remote, following a fork to its upstream, and decides which checks apply. Pass `--tier=` to override it. +Every check applies to every repository. A well-maintained `AGENTS.md` is worth the same in a personal fork as in a product repository, so there is no tier system here and nothing to configure per repo beyond the battery. ## Question batteries diff --git a/agents-md/src/charm_tech_code/agents_md/_checks/agents_md.py b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md.py index 4a40575..f248f7b 100644 --- a/agents-md/src/charm_tech_code/agents_md/_checks/agents_md.py +++ b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md.py @@ -13,7 +13,6 @@ # limitations under the License. """Check: AGENTS.md present (best-of-class; agent-onboarding entry point). -Tier coverage: product, canonical. Personal-tier: informational only. Convention: keep it minimal — a short pointer file, not an encyclopaedia. """ @@ -25,24 +24,15 @@ from .._common import ( EXIT_FAIL, - EXIT_NA, EXIT_PASS, cd_repo_root, emit_check, - parse_tier, - tier_applies, ) CHECK_ID = 'agents-md' -APPLIES = 'product,canonical,personal' def main() -> int: - tier = parse_tier() - if not tier_applies(APPLIES, tier): - emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') - return EXIT_NA - cd_repo_root() p = Path('AGENTS.md') diff --git a/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_battery.py b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_battery.py index b65ce7d..a7db84f 100644 --- a/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_battery.py +++ b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_battery.py @@ -13,7 +13,6 @@ # limitations under the License. """Check: this repo's AGENTS.md question battery still describes the repo. -Tier coverage: product, canonical, personal. A question battery (assets/question-batteries/.yaml) records, for each AGENTS.md line that earns its place, the question an agent would be asked, the @@ -57,12 +56,9 @@ cd_repo_root, emit_check, origin_url, - parse_tier, - tier_applies, ) CHECK_ID = 'agents-md-battery' -APPLIES = 'product,canonical,personal' BATTERIES_DIR = ASSETS / 'question-batteries' @@ -215,11 +211,6 @@ def run_assertion(assertion: dict, entry_id: str, root: Path) -> dict | None: def main() -> int: - tier = parse_tier() - if not tier_applies(APPLIES, tier): - emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') - return EXIT_NA - root = cd_repo_root() path = battery_path() diff --git a/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_content.py b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_content.py index 6da353c..59b40a8 100644 --- a/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_content.py +++ b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_content.py @@ -13,7 +13,6 @@ # limitations under the License. """Check: AGENTS.md content is trustworthy (Layer 1 staleness checks). -Tier coverage: product, canonical, personal. Implements the five Layer 1 checks from roadmap/26.10/repo-setup/agents-md-validation.md (canonical-work-queue): @@ -55,13 +54,10 @@ EXIT_PASS, cd_repo_root, emit_check, - parse_tier, run, - tier_applies, ) CHECK_ID = 'agents-md-content' -APPLIES = 'product,canonical,personal' RUNNABLE_TIMEOUT_SECONDS = 180 @@ -279,11 +275,6 @@ def scope_lint(text: str) -> list[str]: def main() -> int: - tier = parse_tier() - if not tier_applies(APPLIES, tier): - emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') - return EXIT_NA - root = cd_repo_root() p = Path('AGENTS.md') diff --git a/agents-md/src/charm_tech_code/agents_md/_cli.py b/agents-md/src/charm_tech_code/agents_md/_cli.py index c76f57e..f9391b2 100644 --- a/agents-md/src/charm_tech_code/agents_md/_cli.py +++ b/agents-md/src/charm_tech_code/agents_md/_cli.py @@ -12,14 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Check runner. Dispatches every check that applies to the resolved tier and -emits a single JSON report. +"""Check runner. Dispatches every check and emits a single JSON report. Usage: - agents-md check [--tier=product|canonical|personal] - [--only=[,...]] + agents-md check [--only=[,...]] [--format=json|markdown] - agents-md detect-tier agents-md fix [args...] agents-md list @@ -37,7 +34,6 @@ from . import _checks as checks_pkg from . import _fixes as fixes_pkg -from . import _tier as tier_mod from ._common import collecting, origin_url @@ -59,7 +55,6 @@ def usage() -> None: def _check(argv: list[str]) -> int: - tier_override = '' only_filter = '' fmt = 'json' # Anything the runner does not recognise is passed through to the checks. @@ -68,9 +63,7 @@ def _check(argv: list[str]) -> int: passthrough: list[str] = [] for arg in argv: - if arg.startswith('--tier='): - tier_override = arg[len('--tier=') :] - elif arg.startswith('--only='): + if arg.startswith('--only='): only_filter = arg[len('--only=') :] elif arg.startswith('--format='): fmt = arg[len('--format=') :] @@ -83,20 +76,6 @@ def _check(argv: list[str]) -> int: print(f'Unknown argument: {arg}', file=sys.stderr) return 2 - if tier_override: - tier = tier_override - tier_source = 'override' - else: - tier = tier_mod.detect() - tier_source = 'detected' - - if tier == 'unknown': - print( - 'Could not detect tier; pass --tier=product|canonical|personal', - file=sys.stderr, - ) - return 2 - available = _modules(checks_pkg) if only_filter: selected = {} @@ -113,10 +92,8 @@ def _check(argv: list[str]) -> int: saved_argv = sys.argv for check_id, module in selected.items(): # Each check reads its own flags off sys.argv. Set it explicitly - # rather than letting the check read the runner's own command line, so - # that a *detected* tier reaches the check just as an overridden one - # does. - sys.argv = [check_id, f'--tier={tier}', *passthrough] + # rather than letting the check read the runner's own command line. + sys.argv = [check_id, *passthrough] # A check that raises is a bug in the check, not a finding about the # repo, so it becomes a note rather than a fail. try: @@ -141,8 +118,6 @@ def _check(argv: list[str]) -> int: report = { 'schema_version': 1, 'repo': repo, - 'tier': tier, - 'tier_source': tier_source, 'generated_at': generated_at, 'checks': results, 'notes': notes, @@ -153,7 +128,6 @@ def _check(argv: list[str]) -> int: # Markdown summary path — human spot-checks; agents should prefer JSON. print('# AGENTS.md audit\n') print(f'- Repo: `{repo}`') - print(f'- Tier: **{tier}** ({tier_source})') print(f'- Generated: {generated_at}\n') print('## Findings\n') for r in results: @@ -197,9 +171,6 @@ def main() -> int: command, rest = argv[0], argv[1:] if command == 'check': return _check(rest) - if command == 'detect-tier': - sys.argv = ['detect-tier', *rest] - return tier_mod.main() if command == 'fix': return _fix(rest) if command == 'list': diff --git a/agents-md/src/charm_tech_code/agents_md/_common.py b/agents-md/src/charm_tech_code/agents_md/_common.py index 0c2ed9e..818a29f 100644 --- a/agents-md/src/charm_tech_code/agents_md/_common.py +++ b/agents-md/src/charm_tech_code/agents_md/_common.py @@ -24,7 +24,7 @@ import os import subprocess import sys -from collections.abc import Iterable, Iterator +from collections.abc import Iterator from pathlib import Path from typing import Any @@ -124,30 +124,6 @@ def emit_check( sys.stdout.write('\n') -def tier_applies(check_tiers: str | Iterable[str], current_tier: str) -> bool: - """True when the current tier is in the check's applicable tiers. - - check_tiers may be a comma-separated string ("product,canonical") or - any iterable of strings. - """ - if isinstance(check_tiers, str): - tiers = {t.strip() for t in check_tiers.split(',') if t.strip()} - else: - tiers = set(check_tiers) - return current_tier in tiers - - -def parse_tier(argv: list[str] | None = None) -> str: - """Extract --tier= from argv. Returns empty string if absent. - - Unknown flags are ignored (each check only cares about --tier).""" - args = argv if argv is not None else sys.argv[1:] - for arg in args: - if arg.startswith('--tier='): - return arg[len('--tier=') :] - return '' - - def run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: """Convenience wrapper around subprocess.run with text=True and capture_output=True by default. Never raises on non-zero exit — diff --git a/agents-md/src/charm_tech_code/agents_md/_tier.py b/agents-md/src/charm_tech_code/agents_md/_tier.py deleted file mode 100644 index 38dc3d3..0000000 --- a/agents-md/src/charm_tech_code/agents_md/_tier.py +++ /dev/null @@ -1,122 +0,0 @@ -# Copyright 2026 Canonical Ltd. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Inspect the current repo's origin remote and emit one of: - - product | canonical | personal | unknown - -Detection rules (in order): - 1. URL matches https://github.com/canonical/ -> canonical or product - 2. URL matches https://github.com//: - a. If the repo is a fork of canonical/ (detected via - `gh repo view --json isFork,parent`, or an `upstream` remote - pointing at canonical/) -> canonical or product - b. Otherwise -> personal - 3. No remote / no clear org -> unknown - -The fork lookup matters because Charm Tech engineers routinely work -from a personal fork of a canonical/* repo; the baseline that applies -is the upstream repo's, not the fork owner's. - -Product-tier classification within canonical/ is driven by a small -allowlist below (Charm Tech products as of 2026-06 — operator, pebble, -jubilant, concierge, charmlibs). All other canonical/* repos are -'canonical' tier (cross-cutting requirements only). - -Override: pass an argument to force a tier (useful when auditing a -repo before transfer to the canonical org). - -detect() returns the tier; main() prints it and exits 0. -""" - -from __future__ import annotations - -import shutil -import sys - -from ._common import origin_url, run - -PRODUCT_REPOS = {'operator', 'pebble', 'jubilant', 'concierge', 'charmlibs'} - - -def detect() -> str: - """Return the tier for the repo in the current working directory. - - Returns "unknown" rather than guessing when the origin remote is absent - or is not a GitHub URL. - """ - - url = origin_url() - if not url: - return 'unknown' - - prefix = 'https://github.com/' - if not url.startswith(prefix): - # Some other forwarding host; don't guess. - return 'unknown' - - path = url[len(prefix) :] - parts = path.split('/', 1) - if len(parts) != 2 or not parts[0] or not parts[1]: - return 'unknown' - org, repo = parts - - # If origin is not under canonical/, the repo may still be a fork of - # a canonical/* repo — in which case the upstream's baseline applies. - if org != 'canonical': - parent_slug = '' - if shutil.which('gh'): - result = run([ - 'gh', - 'repo', - 'view', - f'{org}/{repo}', - '--json', - 'isFork,parent', - '--jq', - r'select(.isFork) | .parent' - r' | select(.owner.login == "canonical")' - r' | "\(.owner.login)/\(.name)"', - ]) - parent_slug = result.stdout.strip() - if not parent_slug: - upstream = run(['git', 'config', '--get', 'remote.upstream.url']).stdout.strip() - if upstream.startswith('git@github.com:'): - upstream = 'https://github.com/' + upstream[len('git@github.com:') :] - if upstream.endswith('.git'): - upstream = upstream[:-4] - if upstream.startswith(prefix): - upstream_path = upstream[len(prefix) :] - if upstream_path.startswith('canonical/'): - parent_slug = upstream_path - if parent_slug: - org = 'canonical' - repo = parent_slug[len('canonical/') :] - - if org == 'canonical': - return 'product' if repo in PRODUCT_REPOS else 'canonical' - return 'personal' - - -def main() -> int: - """Print the detected tier. An argument forces a tier instead.""" - if len(sys.argv) >= 2: - arg = sys.argv[1] - if arg in ('product', 'canonical', 'personal'): - print(arg) - return 0 - print('unknown', file=sys.stderr) - return 1 - print(detect()) - return 0 diff --git a/agents-md/tests/checks/test_agents_md_battery.py b/agents-md/tests/checks/test_agents_md_battery.py index 7d8554b..9a58bab 100644 --- a/agents-md/tests/checks/test_agents_md_battery.py +++ b/agents-md/tests/checks/test_agents_md_battery.py @@ -83,7 +83,6 @@ def battery() -> str: def run(run_check, files: dict[str, str], battery_text: str) -> dict: return run_check( 'agents-md-battery', - 'canonical', {**files, 'battery.yaml': battery_text}, ('--battery=battery.yaml',), ) @@ -92,7 +91,7 @@ def run(run_check, files: dict[str, str], battery_text: str) -> dict: def test_na_when_no_battery_for_repo(run_check): # No --battery and no origin remote to name one: not a gap, just a repo # that hasn't been through the Layer 2 authoring gate. - r = run_check('agents-md-battery', 'canonical', TREE) + r = run_check('agents-md-battery', TREE) assert r['status'] == 'na' diff --git a/agents-md/tests/checks/test_agents_md_content.py b/agents-md/tests/checks/test_agents_md_content.py index 6e40826..0213be3 100644 --- a/agents-md/tests/checks/test_agents_md_content.py +++ b/agents-md/tests/checks/test_agents_md_content.py @@ -33,14 +33,13 @@ def test_na_when_agents_md_missing(run_check): - r = run_check('agents-md-content', 'canonical', {}) + r = run_check('agents-md-content', {}) assert r['status'] == 'na' def test_pass_when_content_clean(run_check): r = run_check( 'agents-md-content', - 'canonical', { 'AGENTS.md': CLEAN, 'HACKING.md': '# Hacking\n', @@ -56,7 +55,6 @@ def test_module_path_not_flagged_as_missing_file(run_check): # reported missing just because it contains a '/'. r = run_check( 'agents-md-content', - 'canonical', { 'AGENTS.md': CLEAN, 'HACKING.md': '# Hacking\n', @@ -71,7 +69,6 @@ def test_environment_gated_command_not_executed(run_check): # overall check still passing (see test_pass_when_content_clean). r = run_check( 'agents-md-content', - 'canonical', { 'AGENTS.md': CLEAN, 'HACKING.md': '# Hacking\n', @@ -88,7 +85,7 @@ def test_fail_when_referenced_path_missing(run_check): See [BOGUS.md](BOGUS.md) for details. """) - r = run_check('agents-md-content', 'canonical', {'AGENTS.md': md}) + r = run_check('agents-md-content', {'AGENTS.md': md}) assert r['status'] == 'fail' assert 'BOGUS.md' in r['evidence']['missing_paths'] @@ -101,7 +98,7 @@ def test_fail_when_command_tool_missing(run_check): definitelynotarealbinary123 --check # lint ``` """) - r = run_check('agents-md-content', 'canonical', {'AGENTS.md': md}) + r = run_check('agents-md-content', {'AGENTS.md': md}) assert r['status'] == 'fail' assert any(m['tool'] == 'definitelynotarealbinary123' for m in r['evidence']['missing_tools']) @@ -114,7 +111,7 @@ def test_fail_when_runnable_command_fails(run_check): false --check # lint gate ``` """) - r = run_check('agents-md-content', 'canonical', {'AGENTS.md': md}) + r = run_check('agents-md-content', {'AGENTS.md': md}) assert r['status'] == 'fail' assert r['evidence']['runnable_failed'] assert r['evidence']['runnable_failed'][0]['command'].startswith('false') @@ -132,7 +129,6 @@ def test_suite_not_found_in_package_flags_finding(run_check): """) r = run_check( 'agents-md-content', - 'canonical', { 'AGENTS.md': md, 'internals/cli/other_test.go': 'package cli\n\nfunc TestSomethingElse() {}\n', @@ -156,7 +152,6 @@ def test_suite_found_in_package_no_finding(run_check): """) r = run_check( 'agents-md-content', - 'canonical', { 'AGENTS.md': md, 'internals/cli/suite_test.go': 'package cli\n\ntype MySuite struct{}\n', @@ -173,7 +168,7 @@ def test_scope_lint_flags_harness_content(run_check): Co-Authored-By: Claude """) - r = run_check('agents-md-content', 'canonical', {'AGENTS.md': md}) + r = run_check('agents-md-content', {'AGENTS.md': md}) assert r['status'] == 'fail' assert r['evidence']['scope_lint_findings'] @@ -182,7 +177,6 @@ def test_version_pin_drift_flagged(run_check): md = '# AGENTS.md\n\nPinned tool: widget/cmd/widget@v1.0.0 (see CI).\n' r = run_check( 'agents-md-content', - 'canonical', { 'AGENTS.md': md, '.github/workflows/lint.yaml': ( @@ -201,7 +195,6 @@ def test_version_pin_matches_ci(run_check): md = '# AGENTS.md\n\nPinned tool: widget/cmd/widget@v1.0.0 (see CI).\n' r = run_check( 'agents-md-content', - 'canonical', { 'AGENTS.md': md, '.github/workflows/lint.yaml': ( diff --git a/agents-md/tests/conftest.py b/agents-md/tests/conftest.py index 07ed5e0..0017a1c 100644 --- a/agents-md/tests/conftest.py +++ b/agents-md/tests/conftest.py @@ -32,21 +32,21 @@ @pytest.fixture def run_check(tmp_path, monkeypatch): - """Return ``run(check_name, tier, files)`` -> parsed JSON dict. + """Return ``run(check_name, files)`` -> parsed JSON dict. ``files`` is a mapping of repo-relative path -> file contents. Parent directories are created as needed. ``args`` are extra CLI flags passed after ``--only``. The check runs with cwd = tmp_path. """ - def _run(name: str, tier: str, files: dict[str, str], args: tuple[str, ...] = ()) -> dict: + def _run(name: str, files: dict[str, str], args: tuple[str, ...] = ()) -> dict: for rel, body in files.items(): dest = tmp_path / rel dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text(body) monkeypatch.chdir(tmp_path) proc = subprocess.run( - [CLI, 'check', f'--tier={tier}', f'--only={name}', '--format=json', *args], + [CLI, 'check', f'--only={name}', '--format=json', *args], capture_output=True, text=True, check=False, diff --git a/agents-md/tests/test_check_runner.py b/agents-md/tests/test_check_runner.py index e7250aa..3e1334c 100644 --- a/agents-md/tests/test_check_runner.py +++ b/agents-md/tests/test_check_runner.py @@ -26,14 +26,12 @@ def test_only_dispatches_selected_check(tmp_path): 'Run the unit tests with `python -m pytest`.\n' ) proc = subprocess.run( - ['agents-md', 'check', '--tier=canonical', '--only=agents-md'], + ['agents-md', 'check', '--only=agents-md'], capture_output=True, text=True, check=True, cwd=tmp_path, ) report = json.loads(proc.stdout) - assert report['tier'] == 'canonical' - assert report['tier_source'] == 'override' assert [c['id'] for c in report['checks']] == ['agents-md'] assert report['checks'][0]['status'] == 'pass' diff --git a/agents-md/tests/test_detect_tier.py b/agents-md/tests/test_detect_tier.py deleted file mode 100644 index e93a4ca..0000000 --- a/agents-md/tests/test_detect_tier.py +++ /dev/null @@ -1,59 +0,0 @@ -# Copyright 2026 Canonical Ltd. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""detect-tier: override arg is pure; git-driven paths use a real init.""" - -from __future__ import annotations - -import subprocess - - -def _run(*args, cwd=None): - return subprocess.run( - ['agents-md', 'detect-tier', *args], - capture_output=True, - text=True, - check=False, - cwd=cwd, - ) - - -def test_override_product(): - assert _run('product').stdout.strip() == 'product' - - -def test_override_rejects_garbage(): - proc = _run('something-else') - assert proc.returncode != 0 - assert proc.stderr.strip() == 'unknown' - - -def test_canonical_product_repo_from_origin(tmp_path): - subprocess.run(['git', 'init', '-q'], cwd=tmp_path, check=True) - subprocess.run( - ['git', 'remote', 'add', 'origin', 'https://github.com/canonical/operator'], - cwd=tmp_path, - check=True, - ) - assert _run(cwd=tmp_path).stdout.strip() == 'product' - - -def test_canonical_non_product_repo(tmp_path): - subprocess.run(['git', 'init', '-q'], cwd=tmp_path, check=True) - subprocess.run( - ['git', 'remote', 'add', 'origin', 'https://github.com/canonical/lxd'], - cwd=tmp_path, - check=True, - ) - assert _run(cwd=tmp_path).stdout.strip() == 'canonical' From a65c3a049620a529369c525d5df669e8a4f4ac34 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 8 Sep 2026 19:24:20 +1200 Subject: [PATCH 5/7] style: import modules rather than objects Follows the team Python style guide in canonical/charm-tech (style/python.md): a name prefixed with its module says where it came from. So `import pathlib` and `pathlib.Path`, `import types` and `types.ModuleType`, and `from .. import _common` with every use prefixed, rather than pulling the names in one at a time. `typing.Any` and `collections.abc.Iterator` stay as they are, under the guide's exception for typing names -- `Iterator` is only ever an annotation here, and the same verbosity argument applies to it. Worth noting for the review: `run` is a common enough word that prefixing its uses also caught it inside two regexes and a summary string, where `go run` and `make run` are side-effect keywords. Those are back as they were. The checks report the same findings against concierge and operator clones as they did before this change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XdsdR8QdcZY6GUJgd1Wt7c --- .../agents_md/_checks/agents_md.py | 25 +++++------ .../agents_md/_checks/agents_md_battery.py | 44 ++++++++----------- .../agents_md/_checks/agents_md_content.py | 33 ++++++-------- .../src/charm_tech_code/agents_md/_cli.py | 12 ++--- .../src/charm_tech_code/agents_md/_common.py | 12 ++--- .../agents_md/_fixes/add_agents_md.py | 12 ++--- 6 files changed, 59 insertions(+), 79 deletions(-) diff --git a/agents-md/src/charm_tech_code/agents_md/_checks/agents_md.py b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md.py index f248f7b..0164bbb 100644 --- a/agents-md/src/charm_tech_code/agents_md/_checks/agents_md.py +++ b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md.py @@ -19,27 +19,22 @@ from __future__ import annotations +import pathlib import sys -from pathlib import Path -from .._common import ( - EXIT_FAIL, - EXIT_PASS, - cd_repo_root, - emit_check, -) +from .. import _common CHECK_ID = 'agents-md' def main() -> int: - cd_repo_root() + _common.cd_repo_root() - p = Path('AGENTS.md') + p = pathlib.Path('AGENTS.md') if p.is_file(): lines = p.read_text().count('\n') if lines > 200: - emit_check( + _common.emit_check( CHECK_ID, 'fail', f"AGENTS.md present but at {lines} lines is well past the 'keep it minimal' " @@ -53,16 +48,16 @@ def main() -> int: ), }, ) - return EXIT_FAIL - emit_check( + return _common.EXIT_FAIL + _common.emit_check( CHECK_ID, 'pass', f'AGENTS.md present ({lines} lines).', {'path': 'AGENTS.md', 'lines': lines}, ) - return EXIT_PASS + return _common.EXIT_PASS - emit_check( + _common.emit_check( CHECK_ID, 'fail', 'No AGENTS.md found.', @@ -74,7 +69,7 @@ def main() -> int: 'just).', }, ) - return EXIT_FAIL + return _common.EXIT_FAIL if __name__ == '__main__': diff --git a/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_battery.py b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_battery.py index a7db84f..a9e47a1 100644 --- a/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_battery.py +++ b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_battery.py @@ -42,25 +42,17 @@ from __future__ import annotations import hashlib +import pathlib import re import sys -from pathlib import Path import yaml -from .._common import ( - ASSETS, - EXIT_FAIL, - EXIT_NA, - EXIT_PASS, - cd_repo_root, - emit_check, - origin_url, -) +from .. import _common CHECK_ID = 'agents-md-battery' -BATTERIES_DIR = ASSETS / 'question-batteries' +BATTERIES_DIR = _common.ASSETS / 'question-batteries' CLASSIFICATIONS = {'override', 'cache'} GRADES = {'command', 'keywords', 'judgement'} @@ -84,14 +76,14 @@ def parse_flag(name: str) -> str: return '' -def battery_path() -> Path | None: +def battery_path() -> pathlib.Path | None: """Explicit --battery= wins; otherwise the battery named after the repo the origin URL points at.""" explicit = parse_flag('battery') if explicit: - p = Path(explicit) + p = pathlib.Path(explicit) return p if p.is_file() else None - url = origin_url() + url = _common.origin_url() if not url: return None name = url.rstrip('/').split('/')[-1] @@ -144,7 +136,7 @@ def validate_schema(entry: dict, index: int) -> list[str]: return problems -def run_assertion(assertion: dict, entry_id: str, root: Path) -> dict | None: +def run_assertion(assertion: dict, entry_id: str, root: pathlib.Path) -> dict | None: """Return a finding dict when the assertion fails, else None.""" kind = assertion['kind'] if kind == 'none': @@ -211,28 +203,28 @@ def run_assertion(assertion: dict, entry_id: str, root: Path) -> dict | None: def main() -> int: - root = cd_repo_root() + root = _common.cd_repo_root() path = battery_path() if path is None: - emit_check( + _common.emit_check( CHECK_ID, 'na', 'No question battery for this repo — it has not been through the ' 'Layer 2 authoring gate (see references/question-batteries.md).', ) - return EXIT_NA + return _common.EXIT_NA try: battery = yaml.safe_load(path.read_text()) or {} except yaml.YAMLError as exc: - emit_check(CHECK_ID, 'fail', f'Battery {path.name} is not valid YAML: {exc}') - return EXIT_FAIL + _common.emit_check(CHECK_ID, 'fail', f'Battery {path.name} is not valid YAML: {exc}') + return _common.EXIT_FAIL entries = battery.get('entries') or [] agents_md = root / 'AGENTS.md' if not agents_md.is_file(): - emit_check( + _common.emit_check( CHECK_ID, 'fail', f'Battery {path.name} describes {len(entries)} AGENTS.md line(s), ' @@ -240,7 +232,7 @@ def main() -> int: {'battery': path.name, 'entries_total': len(entries)}, {'kind': 'judgement', 'human_review': 'Restore AGENTS.md or retire the battery.'}, ) - return EXIT_FAIL + return _common.EXIT_FAIL md_text = agents_md.read_text(errors='replace') md_collapsed = collapse(md_text) @@ -306,7 +298,7 @@ def main() -> int: problems.append(f'{len(verify_findings)} verify assertion(s) failed') if problems: - emit_check( + _common.emit_check( CHECK_ID, 'fail', f'Question battery {path.name}: ' + '; '.join(problems) + '.', @@ -321,9 +313,9 @@ def main() -> int: ), }, ) - return EXIT_FAIL + return _common.EXIT_FAIL - emit_check( + _common.emit_check( CHECK_ID, 'pass', f'Question battery {path.name} matches AGENTS.md: {len(entries)} ' @@ -331,7 +323,7 @@ def main() -> int: f'{len(not_ci_verifiable)} not confirmable by an automated run.', evidence, ) - return EXIT_PASS + return _common.EXIT_PASS if __name__ == '__main__': diff --git a/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_content.py b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_content.py index 59b40a8..67569e1 100644 --- a/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_content.py +++ b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_content.py @@ -41,21 +41,14 @@ from __future__ import annotations +import pathlib import re import shlex import shutil import subprocess import sys -from pathlib import Path - -from .._common import ( - EXIT_FAIL, - EXIT_NA, - EXIT_PASS, - cd_repo_root, - emit_check, - run, -) + +from .. import _common CHECK_ID = 'agents-md-content' @@ -234,7 +227,7 @@ def extract_referenced_paths(text: str) -> set[str]: return paths -def workflow_texts(root: Path) -> dict[str, str]: +def workflow_texts(root: pathlib.Path) -> dict[str, str]: wf_dir = root / '.github' / 'workflows' out: dict[str, str] = {} if not wf_dir.is_dir(): @@ -275,16 +268,16 @@ def scope_lint(text: str) -> list[str]: def main() -> int: - root = cd_repo_root() + root = _common.cd_repo_root() - p = Path('AGENTS.md') + p = pathlib.Path('AGENTS.md') if not p.is_file(): - emit_check( + _common.emit_check( CHECK_ID, 'na', 'No AGENTS.md to content-check (see agents-md check for presence).', ) - return EXIT_NA + return _common.EXIT_NA text = p.read_text(errors='replace') @@ -312,7 +305,7 @@ def main() -> int: runnable_results.append({'command': cmd, 'status': 'unparseable'}) continue try: - proc = run(tokens, cwd=root, timeout=RUNNABLE_TIMEOUT_SECONDS) + proc = _common.run(tokens, cwd=root, timeout=RUNNABLE_TIMEOUT_SECONDS) except subprocess.TimeoutExpired: runnable_results.append({'command': cmd, 'status': 'timeout'}) continue @@ -418,7 +411,7 @@ def main() -> int: } if problems: - emit_check( + _common.emit_check( CHECK_ID, 'fail', 'AGENTS.md content check: ' + '; '.join(problems) + '.', @@ -434,9 +427,9 @@ def main() -> int: ), }, ) - return EXIT_FAIL + return _common.EXIT_FAIL - emit_check( + _common.emit_check( CHECK_ID, 'pass', f'AGENTS.md content verified: {len(commands)} command(s) parsed ' @@ -446,7 +439,7 @@ def main() -> int: 'no scope-lint findings.', evidence, ) - return EXIT_PASS + return _common.EXIT_PASS if __name__ == '__main__': diff --git a/agents-md/src/charm_tech_code/agents_md/_cli.py b/agents-md/src/charm_tech_code/agents_md/_cli.py index f9391b2..a71e616 100644 --- a/agents-md/src/charm_tech_code/agents_md/_cli.py +++ b/agents-md/src/charm_tech_code/agents_md/_cli.py @@ -30,20 +30,20 @@ import importlib import pkgutil import sys -from types import ModuleType +import types from . import _checks as checks_pkg +from . import _common from . import _fixes as fixes_pkg -from ._common import collecting, origin_url -def _modules(package: ModuleType) -> dict[str, ModuleType]: +def _modules(package: types.ModuleType) -> dict[str, types.ModuleType]: """Import every module in a subpackage, keyed by its declared ID. Checks carry a CHECK_ID; fixes have no such constant, so their module name with underscores turned back into hyphens is the name. """ - found: dict[str, ModuleType] = {} + found: dict[str, types.ModuleType] = {} for info in pkgutil.iter_modules(package.__path__): module = importlib.import_module(f'{package.__name__}.{info.name}') found[getattr(module, 'CHECK_ID', info.name.replace('_', '-'))] = module @@ -97,7 +97,7 @@ def _check(argv: list[str]) -> int: # A check that raises is a bug in the check, not a finding about the # repo, so it becomes a note rather than a fail. try: - with collecting() as collected: + with _common.collecting() as collected: module.main() except Exception as exc: # noqa: BLE001 notes.append(f'check {check_id} raised {type(exc).__name__}: {exc}') @@ -110,7 +110,7 @@ def _check(argv: list[str]) -> int: results.extend(collected) generated_at = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') - repo = origin_url() + repo = _common.origin_url() if fmt == 'json': import json diff --git a/agents-md/src/charm_tech_code/agents_md/_common.py b/agents-md/src/charm_tech_code/agents_md/_common.py index 818a29f..8b65dc2 100644 --- a/agents-md/src/charm_tech_code/agents_md/_common.py +++ b/agents-md/src/charm_tech_code/agents_md/_common.py @@ -22,15 +22,15 @@ import contextlib import json import os +import pathlib import subprocess import sys from collections.abc import Iterator -from pathlib import Path from typing import Any # Templates and question batteries ship with the package rather than sitting # beside the skill, so a `uvx --from git+...` invocation carries them too. -ASSETS = Path(__file__).parent / 'assets' +ASSETS = pathlib.Path(__file__).parent / 'assets' # Exit codes. Every check script exits with one of these. @@ -40,7 +40,7 @@ EXIT_UNKNOWN = 3 -def repo_root() -> Path: +def repo_root() -> pathlib.Path: """Return the repo root. Falls back to CWD when not inside a git tree (the skill can be invoked against an unpacked tarball, for example).""" try: @@ -51,10 +51,10 @@ def repo_root() -> Path: check=True, ).stdout.strip() if out: - return Path(out) + return pathlib.Path(out) except (subprocess.CalledProcessError, FileNotFoundError): pass - return Path.cwd() + return pathlib.Path.cwd() def origin_url() -> str: @@ -134,7 +134,7 @@ def run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: return subprocess.run(cmd, **kwargs) -def cd_repo_root() -> Path: +def cd_repo_root() -> pathlib.Path: """Chdir to the repo root and return it. Exits EXIT_UNKNOWN if the root cannot be reached (matches the shell behaviour of `cd || exit 3`).""" root = repo_root() diff --git a/agents-md/src/charm_tech_code/agents_md/_fixes/add_agents_md.py b/agents-md/src/charm_tech_code/agents_md/_fixes/add_agents_md.py index 7f6d40a..2336a49 100644 --- a/agents-md/src/charm_tech_code/agents_md/_fixes/add_agents_md.py +++ b/agents-md/src/charm_tech_code/agents_md/_fixes/add_agents_md.py @@ -19,28 +19,28 @@ from __future__ import annotations +import pathlib import shutil import sys -from pathlib import Path -from .._common import ASSETS, repo_root +from .. import _common -SCRIPT_DIR = Path(__file__).resolve().parent +SCRIPT_DIR = pathlib.Path(__file__).resolve().parent def main() -> int: try: import os - os.chdir(repo_root()) + os.chdir(_common.repo_root()) except OSError: return 3 - if Path('AGENTS.md').exists(): + if pathlib.Path('AGENTS.md').exists(): sys.stderr.write('AGENTS.md already exists; refusing to overwrite.\n') return 1 - template = ASSETS / 'AGENTS.md.template' + template = _common.ASSETS / 'AGENTS.md.template' if not template.is_file(): sys.stderr.write('Template missing.\n') return 3 From 36c8854489b496f85654d2a53c85a74fbebf19d2 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 8 Sep 2026 20:06:36 +1200 Subject: [PATCH 6/7] docs: drop the references to the private planning tree Nothing in a public repo should point at a path only some of us can read. * `seeded_from:` in all ten batteries named a file in that tree and nothing else, so the field goes rather than gets reworded. `seeded_on` and the AGENTS.md ref/SHA it was seeded against stay, since those are the provenance a reader can actually check. * The "Layer 1/2/3" numbering came from that document too, and without it the numbers say nothing. Each is now named for what it does, which mostly means saying `agents-md-content` where the text said Layer 1. * Two docstrings pointed at `lib/common.py` and `check.py`, which is the layout this code had two moves ago. Ten batteries still parse, entry counts unchanged, and the checks report the same findings against concierge and operator clones. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XdsdR8QdcZY6GUJgd1Wt7c --- agents-md/README.md | 2 +- .../src/charm_tech_code/agents_md/__init__.py | 9 +++++---- .../agents_md/_checks/agents_md_battery.py | 18 ++++++++++-------- .../agents_md/_checks/agents_md_content.py | 12 ++++++------ .../question-batteries/api_demo_server.yaml | 3 +-- .../question-batteries/charm-ubuntu.yaml | 1 - .../charmhub-listing-review.yaml | 7 ++----- .../assets/question-batteries/charmlibs.yaml | 3 +-- .../assets/question-batteries/concierge.yaml | 1 - .../assets/question-batteries/hyrum.yaml | 7 +++---- .../assets/question-batteries/jubilant.yaml | 3 +-- .../assets/question-batteries/operator.yaml | 3 +-- .../assets/question-batteries/pebble.yaml | 3 +-- .../question-batteries/pytest-jubilant.yaml | 3 +-- .../tests/checks/test_agents_md_battery.py | 9 ++++----- .../tests/checks/test_agents_md_content.py | 2 +- 16 files changed, 38 insertions(+), 48 deletions(-) diff --git a/agents-md/README.md b/agents-md/README.md index ef3bc48..399c7fa 100644 --- a/agents-md/README.md +++ b/agents-md/README.md @@ -1,6 +1,6 @@ # agents-md -Keeps the `AGENTS.md` files across the Charm Tech estate current and load-bearing. It implements the deterministic half of the validation design in the repo-setup notes: a line in `AGENTS.md` earns its place either as an *override* (the agent would confidently do the wrong thing without it) or as a *cache* (the agent would get there eventually, by reading the Makefile, tox config and CI every session). A stale line is worse than a missing one, because agents trust the file over the repo. +Keeps the `AGENTS.md` files across the Charm Tech estate current and load-bearing. It implements the deterministic half of the scheme for doing that: a line in `AGENTS.md` earns its place either as an *override* (the agent would confidently do the wrong thing without it) or as a *cache* (the agent would get there eventually, by reading the Makefile, tox config and CI every session). A stale line is worse than a missing one, because agents trust the file over the repo. ## Checks diff --git a/agents-md/src/charm_tech_code/agents_md/__init__.py b/agents-md/src/charm_tech_code/agents_md/__init__.py index 5891064..d0c1ab9 100644 --- a/agents-md/src/charm_tech_code/agents_md/__init__.py +++ b/agents-md/src/charm_tech_code/agents_md/__init__.py @@ -14,9 +14,10 @@ """Keep the estate's AGENTS.md files honest. -Three checks and one fix, plus the per-repo question batteries they read. -The design they implement is `agents-md-validation.md` in the repo-setup -notes: layer 1 is deterministic staleness detection, layer 2 is the -behavioural battery. The agent-facing half lives in the +Three checks and one fix, plus the per-repo question batteries they read. A +line in AGENTS.md earns its place either as an override (the agent would +confidently do the wrong thing without it) or as a cache (the agent would get +there eventually, by reading the Makefile, tox config and CI every session). +The checks here test both, and the agent-facing half lives in the `charm-tech-baseline` skill in `canonical/charm-tech`. """ diff --git a/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_battery.py b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_battery.py index a9e47a1..835fc39 100644 --- a/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_battery.py +++ b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_battery.py @@ -17,9 +17,10 @@ A question battery (assets/question-batteries/.yaml) records, for each AGENTS.md line that earns its place, the question an agent would be asked, the checkable answer, the source line it derives from, and the override/cache -classification. It makes Layer 2 behavioural re-tests mechanical to run when -Layer 1 or Layer 3 triggers them. Schema and rationale: -references/question-batteries.md. +classification. It makes a behavioural re-test mechanical to run whenever +something triggers one: a staleness finding, or a mistake mined from a month of +agent-authored PRs. Schema and rationale: question-batteries.md in the +charm-tech-baseline skill. This check validates the battery against the repo: @@ -29,14 +30,14 @@ 3. Assertions — every `verify` assertion still holds: paths resolve, patterns match, named gocheck suites still live in the named package. -Assertions are static by design. Layer 1's agents-md-content check already +Assertions are static by design. The agents-md-content check already classifies and executes the commands; running them here too would double the runtime and the environment surface for no new signal. -Batteries exist only for repos that have been through the Layer 2 authoring +Batteries exist only for repos that have been through the authoring gate. A repo with no battery is `na`, not a gap. -Convention: one script emits exactly one JSON result (see lib/common.py). +One check emits exactly one JSON result (see _common.emit_check). """ from __future__ import annotations @@ -211,7 +212,8 @@ def main() -> int: CHECK_ID, 'na', 'No question battery for this repo — it has not been through the ' - 'Layer 2 authoring gate (see references/question-batteries.md).', + 'authoring gate (see question-batteries.md in the ' + 'charm-tech-baseline skill).', ) return _common.EXIT_NA @@ -307,7 +309,7 @@ def main() -> int: 'kind': 'judgement', 'human_review': ( 'A drifted source line or failed assertion means the repo ' - 'moved under the battery. Re-run the Layer 2 gate for the ' + 'moved under the battery. Re-run the authoring gate for the ' 'affected entries, then update AGENTS.md and the battery ' 'together.' ), diff --git a/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_content.py b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_content.py index 67569e1..a17d645 100644 --- a/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_content.py +++ b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md_content.py @@ -12,10 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Check: AGENTS.md content is trustworthy (Layer 1 staleness checks). +"""Check: AGENTS.md content is trustworthy. -Implements the five Layer 1 checks from -roadmap/26.10/repo-setup/agents-md-validation.md (canonical-work-queue): +Staleness detection, in five parts. A stale line is worse than a missing one, +because agents trust the file over the repo, so this is the part of the scheme +that has to be cheap enough to run on every change: 1. Commands parse and their entry-point tool resolves in a dev environment. 2. Safe commands actually pass: runnable (lint/format-check/unit-test/build) @@ -34,9 +35,8 @@ This is a content check, not a presence check — see agents-md.py for presence/length. If AGENTS.md is absent this check is n/a. -Convention: one script emits exactly one JSON result (see lib/common.py); -all five sub-checks are folded into a single pass/fail with per-sub-check -evidence, following check.py's one-line-of-JSON-per-script contract. +One check emits exactly one JSON result (see _common.emit_check), so all five +sub-checks fold into a single pass/fail carrying per-sub-check evidence. """ from __future__ import annotations diff --git a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/api_demo_server.yaml b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/api_demo_server.yaml index 8605c4a..a1fa70a 100644 --- a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/api_demo_server.yaml +++ b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/api_demo_server.yaml @@ -11,7 +11,6 @@ source: agents_md_ref: chore/agents-md agents_md_sha: 64de5286216bcae8e88022e340da0e8ee791f797 agents_md_sha256: 2e9924552dc340e268f425606e62d23e7e84287eaab42aecba9b1c415a8a3266 - seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (api_demo_server table) seeded_on: 2026-08-19 entries: @@ -62,7 +61,7 @@ entries: file: Makefile pattern: "^integration:" ci_verifiable: false - gated_by: Docker — no daemon in the check sandbox, and none in the Layer 1 runner + gated_by: Docker — no daemon in the check sandbox, and none in the agents-md-content runner - id: exact-pinned-runtime-deps question: How are this repo's runtime dependencies versioned, and may you relax them? diff --git a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charm-ubuntu.yaml b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charm-ubuntu.yaml index 1da0ca6..2c30450 100644 --- a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charm-ubuntu.yaml +++ b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charm-ubuntu.yaml @@ -11,7 +11,6 @@ source: agents_md_ref: chore/agents-md agents_md_sha: 6350aa338ba5a9b7fcfc0131cf44fa4576c14d16 agents_md_sha256: f874b13341c375e0675b2d2df402466604d866d2099e3c172271c85a3654bd19 - seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (charm-ubuntu table) seeded_on: 2026-08-19 entries: diff --git a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charmhub-listing-review.yaml b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charmhub-listing-review.yaml index a21330e..9a708b5 100644 --- a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charmhub-listing-review.yaml +++ b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charmhub-listing-review.yaml @@ -11,9 +11,6 @@ source: agents_md_ref: chore/agents-md-trim agents_md_sha: fda55bd3933686e7f70356e721666294a0ed123c agents_md_sha256: c017ef66e1c1180f483ea163ea00eec5493ada49dfafe07c16491c139fdb76ee - seeded_from: >- - roadmap/26.10/repo-setup/agents-md-validation.md (implementation - follow-up 4, charmhub-listing-review review-and-seed) seeded_on: 2026-08-27 entries: @@ -228,7 +225,7 @@ entries: ci_verifiable: true note: >- `charmcraft.yaml` here is the target charm's file, cloned into a - temp dir by `_clone_repo` - not a path in this repo. Layer 1's + temp dir by `_clone_repo` - not a path in this repo. agents-md-content's path checker flags it as a missing local path regardless (checker noise on a real, well-known filename in backticks, not staleness); left as-is rather than de-styled purely to clear the @@ -440,7 +437,7 @@ entries: Neither is this pass's to fix (out of scope, and CONTRIBUTING.md says existing files' copyright years are not updated on modification) - noted here because it is the kind of thing - Layer 1 cannot see and a re-tester should not be surprised by. + agents-md-content cannot see and a re-tester should not be surprised by. - id: pr-title-no-scopes question: Do PR titles in this repo use conventional-commit scopes? diff --git a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charmlibs.yaml b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charmlibs.yaml index 95b46d1..a72fce8 100644 --- a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charmlibs.yaml +++ b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/charmlibs.yaml @@ -11,7 +11,6 @@ source: agents_md_ref: chore/agents-md-trim agents_md_sha: 53d9f2460529c0ce35c270d0aae795d590cae4af agents_md_sha256: 5b592217687533d399dcadb2fc0f0c46b0fb30938ecb2a3023ccbe35bd3c61ad - seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (implementation follow-up 4, charmlibs trim) seeded_on: 2026-08-26 entries: @@ -75,7 +74,7 @@ entries: dependency group but is not invoked by `_fast_lint()`, `lint()`, or any current workflow — confirmed by grepping .scripts/, the justfiles, and .github/workflows/ for an actual codespell invocation and finding none. - Layer 1 has no check for this (it is prose semantics, not a missing + agents-md-content has no check for this (it is prose semantics, not a missing tool/path/symbol), so this was only found by reading the code — the same category as concierge's "Configuration Priority" finding. diff --git a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/concierge.yaml b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/concierge.yaml index d94df32..115b810 100644 --- a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/concierge.yaml +++ b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/concierge.yaml @@ -11,7 +11,6 @@ source: agents_md_ref: chore/agents-md-trim agents_md_sha: b6d4afb8045ee93a29039eca982c865788f854a9 agents_md_sha256: af058c9133d01163ecc7c8c7915559064d364f3cf756735ab03b7ca00a3e7fc7 - seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (implementation follow-up 4, concierge trim) seeded_on: 2026-08-26 entries: diff --git a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/hyrum.yaml b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/hyrum.yaml index c0db7b6..72b9ddb 100644 --- a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/hyrum.yaml +++ b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/hyrum.yaml @@ -11,7 +11,6 @@ source: agents_md_ref: chore/agents-md-trim agents_md_sha: e1803004b19a1db17580d74e080cf4b1b5ba2d13 agents_md_sha256: bd26161dd06a11737a5eaf26efd0c71741668990a0a80145a5baf7d228c7843b - seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (implementation follow-up 4, hyrum review-and-seed) seeded_on: 2026-08-27 entries: @@ -205,7 +204,7 @@ entries: This is the fix this pass made. The pre-review line said "a Click group"; `click` was dropped from dependencies in #33 ("refactor: drop click, rich, and pyyaml dependencies"), well before - AGENTS.md was added. Layer 1 has no check for a wrong framework name + AGENTS.md was added. agents-md-content has no check for a wrong framework name in prose - found only by reading `_cli.py`'s imports. An agent extending the CLI on the old advice would reach for `@click.command` decorators against a codebase that has none. @@ -327,7 +326,7 @@ entries: `VendoredLibPatcher` as a hypothetical "future charm-library patcher" and didn't mention `GenericDepPatcher` or `CharmlibPatcher` at all - all three already existed (#59, #25, #60). Found by reading - src/hyrum/_patchers/, not by Layer 1. + src/hyrum/_patchers/, not by agents-md-content. - id: make-runner-nq-probe question: >- @@ -522,5 +521,5 @@ entries: This is the fourth fix this pass made: the pre-review line named the helper `_run_lock` (with a leading underscore); the real symbol is `run_lock`, imported into both ops_source.py and generic.py from - _common.py. Not a path/command Layer 1 checks, so only found by + _common.py. Not a path/command agents-md-content checks, so only found by reading the test monkeypatch targets. diff --git a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/jubilant.yaml b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/jubilant.yaml index 3e5d60e..a8087b8 100644 --- a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/jubilant.yaml +++ b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/jubilant.yaml @@ -11,7 +11,6 @@ source: agents_md_ref: chore/agents-md-trim agents_md_sha: 67396cbb0799a23d3a2283189406459832a21e5e agents_md_sha256: a51ae65a7c8bcc25b3d5f182cd1bc2663323e14d3403b13beefc2ef3ef9d1efb - seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (implementation follow-up 4, jubilant review-and-seed) seeded_on: 2026-08-27 entries: @@ -207,7 +206,7 @@ entries: This is the correction this pass made. The pre-trim line listed jubilant/secrettypes.py in this family (it carries no such header comment - it's hand-written) and omitted jubilant/unittypes.py - (which does carry it). Layer 1 can't see this; it only surfaced as + (which does carry it). agents-md-content can't see this; it only surfaced as three ambiguous missing-path findings from the bare `modeltypes.py`/`secrettypes.py` spellings, which is a different, unrelated bug (also fixed) that happened to point at the same line. diff --git a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/operator.yaml b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/operator.yaml index ce5fb4d..34f321f 100644 --- a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/operator.yaml +++ b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/operator.yaml @@ -11,7 +11,6 @@ source: agents_md_ref: chore/agents-md-trim agents_md_sha: 3fd6b4af3125c2e6faff0e85b2b0c32da0387559 agents_md_sha256: 653bfec0ff562dea1f51bf0db5bc15b9e67bcf966beec84d0db643d7ef5910e3 - seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (implementation follow-up 4, operator review-and-seed) seeded_on: 2026-08-27 entries: @@ -385,7 +384,7 @@ entries: This is the fix this pass made. The pre-trim line enumerated only feat/fix/docs/refactor/test/chore/ci, omitting perf and revert - both of which .github/check-conventional-pr-title.py's _TYPES set actually - accepts. Layer 1 has no check for an enumerated-list-vs-script + accepts. agents-md-content has no check for an enumerated-list-vs-script mismatch; only found by reading the enforcement script. - id: full-lint-command diff --git a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/pebble.yaml b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/pebble.yaml index 843931b..caaede5 100644 --- a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/pebble.yaml +++ b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/pebble.yaml @@ -11,7 +11,6 @@ source: agents_md_ref: chore/agents-md agents_md_sha: 40e3936c1a07ea3d2e1b792471d1aeaa2a76aa29 agents_md_sha256: 44d99acdc5011e872b66633b1e19186f3cae489ebb3de68223203c866a5f044e - seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (pebble table) seeded_on: 2026-08-19 entries: @@ -31,7 +30,7 @@ entries: Passes on a non-root runner. As root without PEBBLE_TEST_USER/ PEBBLE_TEST_GROUP set, servstate.TestUserGroup takes its non-skip branch and fails — an environment artifact, not staleness (see the 2026-07-28 - Layer 1 build log). + agents-md-content build log). - id: single-gocheck-suite question: How do you run just the PebbleSuite gocheck suite? diff --git a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/pytest-jubilant.yaml b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/pytest-jubilant.yaml index a45d3aa..2d9885e 100644 --- a/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/pytest-jubilant.yaml +++ b/agents-md/src/charm_tech_code/agents_md/assets/question-batteries/pytest-jubilant.yaml @@ -11,7 +11,6 @@ source: agents_md_ref: chore/agents-md agents_md_sha: 46276b0fe118266f61ad45f0e1c59116cd884b57 agents_md_sha256: c00f361f0304fa22a6e6f16da30c015a6085ac8cccf98406ed43cdc54865a589 - seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (pytest-jubilant table) seeded_on: 2026-08-19 entries: @@ -28,7 +27,7 @@ entries: pattern: uv tool install tox --with tox-uv ci_verifiable: false gated_by: >- - installs a tool into the environment — Layer 1 check 2 classifies + installs a tool into the environment — the agents-md-content check classifies `uv tool install` as side-effecting and does not execute it - id: lint-command diff --git a/agents-md/tests/checks/test_agents_md_battery.py b/agents-md/tests/checks/test_agents_md_battery.py index 9a58bab..575d9d1 100644 --- a/agents-md/tests/checks/test_agents_md_battery.py +++ b/agents-md/tests/checks/test_agents_md_battery.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""AGENTS.md question battery validation (Layer 2 seed data, checked statically).""" +"""AGENTS.md question battery validation, checked statically.""" from __future__ import annotations @@ -40,7 +40,6 @@ agents_md_ref: chore/agents-md agents_md_sha: deadbeef agents_md_sha256: {digest} - seeded_from: design doc seeded_on: 2026-08-19 entries: - id: single-suite @@ -90,7 +89,7 @@ def run(run_check, files: dict[str, str], battery_text: str) -> dict: def test_na_when_no_battery_for_repo(run_check): # No --battery and no origin remote to name one: not a gap, just a repo - # that hasn't been through the Layer 2 authoring gate. + # that hasn't been through the authoring gate. r = run_check('agents-md-battery', TREE) assert r['status'] == 'na' @@ -118,7 +117,7 @@ def test_fail_when_source_line_no_longer_in_agents_md(run_check): def test_fail_when_suite_no_longer_in_named_package(run_check): - # The canonical Layer 1 case, carried into the battery: pebble's + # The canonical staleness case, carried into the battery: pebble's # PebbleSuite documented against a package it has moved out of. files = {**TREE} files.pop('internals/cli/suite_test.go') @@ -190,7 +189,7 @@ def test_fail_when_agents_md_absent_but_battery_present(run_check): def test_digest_change_is_evidence_not_failure(run_check): - # A changed AGENTS.md is a Layer 2 re-test trigger, not a defect — the file + # A changed AGENTS.md is a re-test trigger, not a defect — the file # may have improved. Surfaced as evidence, never as a fail on its own. files = {**TREE, 'AGENTS.md': AGENTS_MD + '\nAn extra, harmless sentence.\n'} r = run(run_check, files, battery()) diff --git a/agents-md/tests/checks/test_agents_md_content.py b/agents-md/tests/checks/test_agents_md_content.py index 0213be3..ef6a6b1 100644 --- a/agents-md/tests/checks/test_agents_md_content.py +++ b/agents-md/tests/checks/test_agents_md_content.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""AGENTS.md content check: the five Layer 1 staleness checks.""" +"""AGENTS.md content check: the five staleness checks.""" from __future__ import annotations From 0bba17b9a9881c6cb60c7f1381b1e2bda49bed1c Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 10 Sep 2026 13:03:09 +1200 Subject: [PATCH 7/7] docs: apply the review comments on the docstrings and README All wording, no behaviour change. * The `agents-md` check docstring led with "best-of-class; agent-onboarding entry point", which labels the check rather than saying what it does. * `_common.py` explained that it is imported by every check and fix script and has no import side effects. Both are leftovers from when these were standalone scripts on a path, and neither is something a reader of this module needs to be told. * The two test module docstrings each carried a subordinate clause on the summary line; they are a summary and a body now. * The README paragraph explaining that there is no tier system was left over from the commit that removed it -- there is no absence to account for once the tiers were never here. * The opening paragraph pointed at "the scheme for doing that" without saying whose scheme, which was the last trace of the reference to the private planning tree. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YWerXBch3kzCs64fPXxPgU --- agents-md/README.md | 4 +--- agents-md/src/charm_tech_code/agents_md/_checks/agents_md.py | 2 +- agents-md/src/charm_tech_code/agents_md/_common.py | 5 +---- agents-md/tests/checks/test_agents_md_battery.py | 5 ++++- agents-md/tests/checks/test_agents_md_content.py | 5 ++++- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/agents-md/README.md b/agents-md/README.md index 399c7fa..de3a233 100644 --- a/agents-md/README.md +++ b/agents-md/README.md @@ -1,6 +1,6 @@ # agents-md -Keeps the `AGENTS.md` files across the Charm Tech estate current and load-bearing. It implements the deterministic half of the scheme for doing that: a line in `AGENTS.md` earns its place either as an *override* (the agent would confidently do the wrong thing without it) or as a *cache* (the agent would get there eventually, by reading the Makefile, tox config and CI every session). A stale line is worse than a missing one, because agents trust the file over the repo. +Keeps the `AGENTS.md` files across the Charm Tech estate current and load-bearing. This is the deterministic half of the validation: a line in `AGENTS.md` earns its place either as an *override* (the agent would confidently do the wrong thing without it) or as a *cache* (the agent would get there eventually, by reading the Makefile, tox config and CI every session). A stale line is worse than a missing one, because agents trust the file over the repo. ## Checks @@ -20,8 +20,6 @@ uvx --from charm-tech-code-agents-md agents-md check --only=agents-md-content -- uvx --from charm-tech-code-agents-md agents-md list ``` -Every check applies to every repository. A well-maintained `AGENTS.md` is worth the same in a personal fork as in a product repository, so there is no tier system here and nothing to configure per repo beyond the battery. - ## Question batteries `assets/question-batteries/*.yaml`, one per repo, keyed by upstream name. They live here rather than in the skill so that the check and the data it reads ship together. Each entry carries the question, the answer that counts as correct, and the line of `AGENTS.md` it came from, so a battery failure points at the line to fix. diff --git a/agents-md/src/charm_tech_code/agents_md/_checks/agents_md.py b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md.py index 0164bbb..d6b817a 100644 --- a/agents-md/src/charm_tech_code/agents_md/_checks/agents_md.py +++ b/agents-md/src/charm_tech_code/agents_md/_checks/agents_md.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Check: AGENTS.md present (best-of-class; agent-onboarding entry point). +"""Check: AGENTS.md present. Convention: keep it minimal — a short pointer file, not an encyclopaedia. """ diff --git a/agents-md/src/charm_tech_code/agents_md/_common.py b/agents-md/src/charm_tech_code/agents_md/_common.py index 8b65dc2..f9a42de 100644 --- a/agents-md/src/charm_tech_code/agents_md/_common.py +++ b/agents-md/src/charm_tech_code/agents_md/_common.py @@ -12,10 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Shared helpers for the checks and fixes in this package. - -Imported by every check / fix script. No side effects on import. -""" +"""Shared helpers for the checks and fixes in this package.""" from __future__ import annotations diff --git a/agents-md/tests/checks/test_agents_md_battery.py b/agents-md/tests/checks/test_agents_md_battery.py index 575d9d1..f28fc7b 100644 --- a/agents-md/tests/checks/test_agents_md_battery.py +++ b/agents-md/tests/checks/test_agents_md_battery.py @@ -12,7 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""AGENTS.md question battery validation, checked statically.""" +"""AGENTS.md question battery validation. + +The battery data is checked statically, without running the check against a repo. +""" from __future__ import annotations diff --git a/agents-md/tests/checks/test_agents_md_content.py b/agents-md/tests/checks/test_agents_md_content.py index ef6a6b1..39c62ec 100644 --- a/agents-md/tests/checks/test_agents_md_content.py +++ b/agents-md/tests/checks/test_agents_md_content.py @@ -12,7 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""AGENTS.md content check: the five staleness checks.""" +"""AGENTS.md content check. + +This tests the five top-level staleness checks. +""" from __future__ import annotations