From 28db9b3a145627b519ba4b8848e4b4cf7c679b9e Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Tue, 8 Sep 2026 04:13:25 +0200 Subject: [PATCH 1/4] =?UTF-8?q?ci(dev):=20add=20check-doc-sync=20=E2=80=94?= =?UTF-8?q?=20deterministic=20gates=20for=20doc/tree=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This session fixed a batch of documentation that had drifted from the tree: ten specs missing from both index files, per-family and per-mode skill counts stale in three places, and catalogue totals stale in a fourth. Every one was found by reading, not by a check. Left as is, they come back. check-doc-sync.py makes five of them mechanical: - every tools/spec-loop/specs/*.md is listed in BOTH overview.md and README.md; - README.md's family table "N skills" cells match live family: frontmatter; - docs/modes.md's Modes-at-a-glance Skill-count column matches live mode: frontmatter — the validator's modes-doc rule checks section membership but parses only the mode and status columns, so the counts were unguarded; - bare "N skills" totals in docs/setup/marketplaces.md match the catalogue; - every script in tools/dev/ is named in tools/dev/README.md. Each check was verified to fail on the exact defect it exists for, by reintroducing that defect and confirming the reported number matched what had actually been wrong. A check that cannot go red is worse than no check, since it reads as evidence — the lesson from #1161, where an unauthenticated CLI produced a fully green eval run. The fifth check found three undocumented scripts on its first run (agent-pre-commit.sh, check-placeholders.sh, run-workspace-check.sh), so tools/dev/README.md now carries a table saying what each of the seven guards rather than naming a couple in passing. The counts are deliberately narrow: the total-skills check reads an allowlist of files rather than sweeping the repo, because plenty of docs legitimately count a subset ("Nine skills cover the staged path") and a greedy scan would flag those as drift forever. Generated-by: Claude Code (Opus 5) --- .pre-commit-config.yaml | 27 +++++ tools/dev/README.md | 20 +++- tools/dev/check-doc-sync.py | 227 ++++++++++++++++++++++++++++++++++++ 3 files changed, 273 insertions(+), 1 deletion(-) create mode 100755 tools/dev/check-doc-sync.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 499737029..9030acaba 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -268,6 +268,33 @@ repos: entry: python3 tools/dev/check-family-plugins.py files: ^(skills/.*/SKILL\.md|plugins/.*|\.claude-plugin/(marketplace|plugin)\.json|\.codex-plugin/plugin\.json|\.agents/plugins/marketplace\.json|(plugin|marketplace)\.json|gemini-extension\.json|apm\.yml|pyproject\.toml|hooks/check-upgrade\.sh)$ pass_filenames: false + # Documentation claims that must track the tree. Four mechanical checks, + # each added after the drift it catches was found by hand: + # - every tools/spec-loop/specs/*.md is listed in BOTH index files + # (overview.md and README.md) — ten specs were absent from both, + # including per-family specs and one added four PRs earlier; + # - README.md's family table "N skills" cells match live `family:` + # frontmatter; + # - docs/modes.md's *Modes at a glance* Skill-count column matches live + # `mode:` frontmatter (the validator's modes-doc rule checks section + # membership but reads only the mode and status columns, so the counts + # were unguarded); + # - bare "N skills" totals in docs/setup/marketplaces.md match the + # catalogue; + # - every script in tools/dev/ is named in tools/dev/README.md — these are + # the framework's own gates, and an undocumented one is invisible to the + # next contributor deciding whether it applies to their change. + # None of these break anything when wrong, which is exactly why they rot: + # they are numbers a human must remember to update while thinking about + # something else. + - repo: local + hooks: + - id: check-doc-sync + name: check-doc-sync (spec indexes + declared skill counts) + language: system + entry: python3 tools/dev/check-doc-sync.py + files: ^(skills/[^/]+/SKILL\.md|tools/spec-loop/specs/.*\.md|README\.md|docs/modes\.md|docs/setup/marketplaces\.md|tools/dev/.*)$ + pass_filenames: false # Workspace-membership guard. The single source of truth for # which Python projects get pre-commit hooks + the CI pytest # matrix is the `[tool.uv.workspace] members` array in the root diff --git a/tools/dev/README.md b/tools/dev/README.md index d39df6ff8..336945ba6 100644 --- a/tools/dev/README.md +++ b/tools/dev/README.md @@ -3,6 +3,7 @@ **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* - [`tools/dev/`](#toolsdev) + - [The scripts](#the-scripts) - [Prerequisites](#prerequisites) @@ -18,9 +19,26 @@ Framework dev-loop helpers (placeholder check, agent pre-commit hook). Invoked by prek and CI; not consumed by any skill directly. See the individual scripts in this directory for usage. +## The scripts + +| Script | What it does | +|---|---| +| [`check-doc-sync.py`](check-doc-sync.py) | Guards the documentation claims that track the tree and rot silently: spec-index completeness (every `tools/spec-loop/specs/*.md` listed in **both** `overview.md` and `README.md`), the per-family skill counts in the root `README.md`, the per-mode counts in `docs/modes.md`'s *Modes at a glance* table, the bare catalogue totals in `docs/setup/marketplaces.md`, and that every script here is named in this file. | +| [`check-family-plugins.py`](check-family-plugins.py) | Validates the marketplace plugins against the skills' `family:` frontmatter — version parity across every ecosystem manifest, Agent Plugins 1.0 conformance, and one well-formed per-family plugin whose `skills/` symlinks match the family exactly. `--fix` regenerates them. | +| [`check-placeholders.sh`](check-placeholders.sh) | Fails the build on hardcoded project references in skill and tool docs, which must use `` / `` / `` / `` instead. Carries both casings and matches spaced variants. | +| [`check-workspace-members.py`](check-workspace-members.py) | Catches a new `tools//pyproject.toml` that was never added to `[tool.uv.workspace] members` — an omission that silently drops the tool from both the pre-commit hooks and the CI pytest matrix. | +| [`run-workspace-check.sh`](run-workspace-check.sh) | Runs one static-check or test command across every workspace member, auto-discovering which members a given check applies to. The four `workspace-*` hooks call it, so adding a tool needs no edit to the pre-commit config. | +| [`add-license-headers.py`](add-license-headers.py) | Stamps the SPDX licence header into Markdown files that lack one. | +| [`agent-pre-commit.sh`](agent-pre-commit.sh) | Wrapper for `prek run --all-files`, for agent use. An agent running `pytest` / `ruff` / `mypy` individually still misses the rest of the CI gate (doctoc, markdownlint, typos, the checks above); this runs what CI runs. | + +Each `check-doc-sync.py` check was added after the drift it catches had been +found by hand. None of them break anything when wrong, which is precisely why +they need a machine rather than a reviewer: they are numbers and index entries +a human has to remember to update while thinking about something else. + ## Prerequisites -- **Runtime:** Bash + coreutils; `check-workspace-members.py` runs under `python3`. +- **Runtime:** Bash + coreutils; `check-workspace-members.py`, `check-family-plugins.py`, `check-doc-sync.py`, and `add-license-headers.py` run under `python3` (standard library only). - **CLIs:** `uv` (the workspace checks run `uv run`), `git`, and `prek` (or `pre-commit`) — these scripts wire up the framework's hooks. - **Credentials / auth:** None. - **Network:** Local checks; `uv` may resolve workspace dependencies from PyPI (`pypi.org`, `files.pythonhosted.org`) on first sync. diff --git a/tools/dev/check-doc-sync.py b/tools/dev/check-doc-sync.py new file mode 100755 index 000000000..5aaf0c905 --- /dev/null +++ b/tools/dev/check-doc-sync.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 the documentation claims that must track the tree, and silently rot. + +Four checks, all mechanical, each one written after the drift it catches was +found by hand: + +1. **Spec-index completeness.** Every ``tools/spec-loop/specs/.md`` is + listed in both ``overview.md`` and ``README.md``. Ten specs were absent from + both — including per-family specs and one added four PRs earlier — because + nothing checked, and an index that lists two-thirds of its subject reads + exactly like one that lists all of it. + +2. **Per-family skill counts in README.md.** The family table's "N skills" cell + against the live ``family:`` frontmatter. Two cells were wrong: one went + stale when three skills landed, the other had been drifting for months. + +3. **Per-mode skill counts in docs/modes.md.** The *Modes at a glance* table's + Skill-count column against the live ``mode:`` frontmatter. The validator's + ``modes-doc`` rule checks section *membership* but reads only the mode and + status columns, so the counts were unguarded. + +4. **Total-skill claims in prose.** A small allowlist of files whose "N skills" + phrasing means the whole catalogue. + +5. **Every script in ``tools/dev/`` is named in its README.** These scripts are + the framework's own gates, and an undocumented one is invisible to the next + contributor who has to decide whether it applies to their change. Naming it + is the minimum; the README says what each guards. + +Why counting is worth a hook at all: every one of these is a number a human has +to remember to update while thinking about something else, and none of them +breaks anything when wrong. They just quietly mislead the next reader. + +Run from the repo root: + + python3 tools/dev/check-doc-sync.py +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +SKILLS_DIR = Path("skills") +SPECS_DIR = Path("tools/spec-loop/specs") +SPEC_INDEXES = (SPECS_DIR / "overview.md", SPECS_DIR / "README.md") +# The index files themselves are not specs. +SPEC_INDEX_NAMES = {p.name for p in SPEC_INDEXES} + +# Files whose bare "N skills" phrasing means the whole catalogue. Deliberately +# an allowlist rather than a repo-wide sweep: plenty of docs legitimately count +# a subset ("Nine skills cover the staged path"), and a greedy scan would flag +# those as drift forever. +TOTAL_COUNT_FILES = (Path("docs/setup/marketplaces.md"),) + +# Dev scripts must each be named in tools/dev/README.md. Suffixes rather than a +# mode check: a script is a script whether or not its executable bit survived a +# checkout. +DEV_DIR = Path("tools/dev") +DEV_SCRIPT_SUFFIXES = (".py", ".sh") + +# `| [**security**](docs/security/README.md) | opt-in | … | 15 skills, [`docs/…`] |` +_README_FAMILY_ROW = re.compile( + r"^\|\s*\[?\*\*(?P[a-z-]+)\*\*\]?[^|]*\|.*?\|\s*(?P\d+) skills?[,)]", +) +# `| **Triage** | *(Agentic Triage)* … | stable (…) | 35 |` +_MODES_GLANCE_ROW = re.compile(r"^\|\s*\*\*(?P[A-Za-z ]+?)\*\*\s*\|.*\|\s*(?P\d+)\s*\|\s*$") +_BARE_TOTAL = re.compile(r"\b(?P\d+) skills\b") + + +def _frontmatter(path: Path) -> str: + text = path.read_text(encoding="utf-8") + m = re.match(r"^---\n(.*?)\n---\n", text, re.S) + return m.group(1) if m else "" + + +def _key_counts(key: str) -> dict[str, int]: + """Count live skills by a single-valued frontmatter key (``family``/``mode``).""" + counts: dict[str, int] = {} + for skill_md in sorted(SKILLS_DIR.glob("*/SKILL.md")): + m = re.search(rf"^{key}:\s*(\S+)\s*$", _frontmatter(skill_md), re.M) + if m: + counts[m.group(1)] = counts.get(m.group(1), 0) + 1 + return counts + + +def check_spec_index(errors: list[str]) -> None: + if not SPECS_DIR.is_dir(): + return + index_text = {p: p.read_text(encoding="utf-8") for p in SPEC_INDEXES if p.is_file()} + for spec in sorted(SPECS_DIR.glob("*.md")): + if spec.name in SPEC_INDEX_NAMES: + continue + for index, text in index_text.items(): + if f"({spec.name})" not in text: + errors.append(f"{index}: spec '{spec.name}' is not listed — every spec belongs in both indexes") + + +def check_readme_family_counts(errors: list[str]) -> None: + readme = Path("README.md") + if not readme.is_file(): + return + live = _key_counts("family") + for lineno, line in enumerate(readme.read_text(encoding="utf-8").splitlines(), 1): + m = _README_FAMILY_ROW.match(line) + if not m: + continue + family, declared = m.group("family"), int(m.group("count")) + actual = live.get(family) + if actual is None: + continue # a table row that is not a skill family + if declared != actual: + errors.append( + f"README.md:{lineno}: family '{family}' says {declared} skills; " + f"live family: frontmatter has {actual}" + ) + + +def check_modes_glance_counts(errors: list[str]) -> None: + modes = Path("docs/modes.md") + if not modes.is_file(): + return + text = modes.read_text(encoding="utf-8") + if "## Modes at a glance" not in text: + return + glance = text.split("## Modes at a glance", 1)[1].split("\n## ", 1)[0] + live = _key_counts("mode") + offset = text[: text.index("## Modes at a glance")].count("\n") + 1 + for lineno, line in enumerate(glance.splitlines(), offset): + m = _MODES_GLANCE_ROW.match(line) + if not m: + continue + mode, declared = m.group("mode").strip(), int(m.group("count")) + actual = live.get(mode) + if actual is None: + # A mode with no skills (e.g. one deliberately switched off) must + # declare 0 rather than be skipped. + if declared != 0: + errors.append( + f"docs/modes.md:{lineno}: mode '{mode}' says {declared} skills; " + f"no skill declares that mode" + ) + continue + if declared != actual: + errors.append( + f"docs/modes.md:{lineno}: mode '{mode}' says {declared} skills; " + f"live mode: frontmatter has {actual}" + ) + + +def check_total_counts(errors: list[str], total: int) -> None: + for path in TOTAL_COUNT_FILES: + if not path.is_file(): + continue + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + for m in _BARE_TOTAL.finditer(line): + declared = int(m.group("count")) + if declared != total: + errors.append( + f"{path}:{lineno}: says {declared} skills; the catalogue has {total}" + ) + + +def check_dev_scripts_documented(errors: list[str]) -> None: + readme = DEV_DIR / "README.md" + if not DEV_DIR.is_dir() or not readme.is_file(): + return + text = readme.read_text(encoding="utf-8") + for script in sorted(DEV_DIR.iterdir()): + if not script.is_file() or script.suffix not in DEV_SCRIPT_SUFFIXES: + continue + if script.name not in text: + errors.append( + f"{readme}: '{script.name}' is not named — every script in " + f"{DEV_DIR}/ must be documented there" + ) + + +def main() -> int: + if not SKILLS_DIR.is_dir(): + print("check-doc-sync: run from the repository root", file=sys.stderr) + return 2 + total = len(list(SKILLS_DIR.glob("*/SKILL.md"))) + errors: list[str] = [] + check_spec_index(errors) + check_readme_family_counts(errors) + check_modes_glance_counts(errors) + check_total_counts(errors, total) + check_dev_scripts_documented(errors) + + if errors: + print("check-doc-sync: documentation is out of step with the tree.\n", file=sys.stderr) + for err in errors: + print(f" {err}", file=sys.stderr) + print( + f"\n{len(errors)} problem(s). These are counts and index entries a human has to " + "remember to update; nothing breaks when they are wrong, which is why they drift.", + file=sys.stderr, + ) + return 1 + print( + f"check-doc-sync: OK ({total} skills; spec indexes, declared counts, " + "and dev-script docs agree)." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From dce37aca69dac895468cfab907a7f1f020d97b15 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Tue, 8 Sep 2026 04:20:39 +0200 Subject: [PATCH 2/4] test(dev): bring tools/dev under the workspace checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scripts in tools/dev are the gates that guard everyone else's changes, and they were the one Python tree nothing checked: the workspace checks iterate `[tool.uv.workspace] members`, and tools/dev was not one. So ruff, mypy, and pytest all skipped it. Declares the tree as a workspace member, the same config-carrier pattern `skills/pyproject.toml` uses, and adds 20 tests for check-doc-sync.py. The tests are written around the red case. Each check builds a miniature repo in tmp_path and asserts the drift is reported with both numbers, then that the corrected tree is silent — because a gate that cannot fail is worse than no gate, it reads as evidence while measuring nothing. Also covered: a spec listed in only one of the two indexes still counts as missing, a README table row that names no family is not read as a family with a wrong count, a mode nothing declares must say 0 rather than be skipped, every stale total on a page is reported rather than just the first, and the totals check stays inside its allowlist. Script filenames are hyphenated, so the tests load the module through importlib rather than a plain import. Bringing the tree under the checks surfaced four lint findings in pre-existing scripts; three are fixed (an unused unpacked variable, two loops ruff can express directly) and RUF003 is ignored with a reason — the repo's prose style uses en dashes throughout, and rewriting comments to hyphens to satisfy a homoglyph check would make them worse. Four files were reformatted. Every script was re-run afterwards to confirm behaviour is unchanged. Generated-by: Claude Code (Opus 5) --- pyproject.toml | 1 + tools/dev/add-license-headers.py | 15 +- tools/dev/check-doc-sync.py | 13 +- tools/dev/check-family-plugins.py | 33 ++- tools/dev/check-workspace-members.py | 14 +- tools/dev/pyproject.toml | 81 +++++++ tools/dev/tests/test_check_doc_sync.py | 306 +++++++++++++++++++++++++ uv.lock | 22 ++ 8 files changed, 436 insertions(+), 49 deletions(-) create mode 100644 tools/dev/pyproject.toml create mode 100644 tools/dev/tests/test_check_doc_sync.py diff --git a/pyproject.toml b/pyproject.toml index 33c65ca6c..6b9c43e42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,6 +97,7 @@ members = [ "ai-tutors", "skills", "tools/agent-guard", + "tools/dev", "tools/agent-isolation", "tools/bitbucket", "tools/egress-gateway", diff --git a/tools/dev/add-license-headers.py b/tools/dev/add-license-headers.py index cf1bf40f5..464c2cfd8 100755 --- a/tools/dev/add-license-headers.py +++ b/tools/dev/add-license-headers.py @@ -82,10 +82,7 @@ # semantically wrong and corrupt the eval. RAT excludes them too, so they # need no header. Kept in the script (not just the prek `exclude:`) so a # direct or `--all` invocation cannot stamp them either. -EXCLUDE_PREFIXES = ( - "tools/skill-evals/evals/pr-management-code-review/" - "step-4-third-party-license/fixtures/", -) +EXCLUDE_PREFIXES = ("tools/skill-evals/evals/pr-management-code-review/step-4-third-party-license/fixtures/",) def _already_stamped(lines: list[str]) -> bool: @@ -99,10 +96,7 @@ def _already_stamped(lines: list[str]) -> bool: SPDX) is not treated as a declaration, so such a file still gets a real header. """ - for line in lines[:_SCAN_LINES]: - if "SPDX-License-Identifier:" in line: - return True - return False + return any("SPDX-License-Identifier:" in line for line in lines[:_SCAN_LINES]) def stamp(path: Path) -> bool: @@ -129,10 +123,7 @@ def _tracked_markdown() -> list[Path]: def main(argv: list[str]) -> int: args = argv[1:] - if "--all" in args: - targets = _tracked_markdown() - else: - targets = [Path(a) for a in args if a.endswith(".md")] + targets = _tracked_markdown() if "--all" in args else [Path(a) for a in args if a.endswith(".md")] modified: list[Path] = [] for path in targets: diff --git a/tools/dev/check-doc-sync.py b/tools/dev/check-doc-sync.py index 5aaf0c905..fdc3dd02c 100755 --- a/tools/dev/check-doc-sync.py +++ b/tools/dev/check-doc-sync.py @@ -111,7 +111,9 @@ def check_spec_index(errors: list[str]) -> None: continue for index, text in index_text.items(): if f"({spec.name})" not in text: - errors.append(f"{index}: spec '{spec.name}' is not listed — every spec belongs in both indexes") + errors.append( + f"{index}: spec '{spec.name}' is not listed — every spec belongs in both indexes" + ) def check_readme_family_counts(errors: list[str]) -> None: @@ -174,9 +176,7 @@ def check_total_counts(errors: list[str], total: int) -> None: for m in _BARE_TOTAL.finditer(line): declared = int(m.group("count")) if declared != total: - errors.append( - f"{path}:{lineno}: says {declared} skills; the catalogue has {total}" - ) + errors.append(f"{path}:{lineno}: says {declared} skills; the catalogue has {total}") def check_dev_scripts_documented(errors: list[str]) -> None: @@ -216,10 +216,7 @@ def main() -> int: file=sys.stderr, ) return 1 - print( - f"check-doc-sync: OK ({total} skills; spec indexes, declared counts, " - "and dev-script docs agree)." - ) + print(f"check-doc-sync: OK ({total} skills; spec indexes, declared counts, and dev-script docs agree).") return 0 diff --git a/tools/dev/check-family-plugins.py b/tools/dev/check-family-plugins.py index 128ec324d..e29bc3721 100644 --- a/tools/dev/check-family-plugins.py +++ b/tools/dev/check-family-plugins.py @@ -36,6 +36,7 @@ the family plugins + symlinks + marketplace entries from the frontmatter. A release bump therefore has one edit point: `pyproject.toml`, then `--fix`. """ + from __future__ import annotations import argparse @@ -140,9 +141,7 @@ def load_json(path: Path): return None, f"{path}: cannot read/parse ({exc})" -def validate_manifest( - path: Path, expected_name: str, inherited: dict | None = None -) -> list[str]: +def validate_manifest(path: Path, expected_name: str, inherited: dict | None = None) -> list[str]: """A plugin.json must exist, be valid JSON, name itself correctly, and declare its skills + a description. Per-family manifests additionally carry the root manifest's shared metadata (`inherited`) verbatim.""" @@ -161,8 +160,7 @@ def validate_manifest( for key, want in (inherited or {}).items(): if data.get(key) != want: errors.append( - f"{path}: {key!r} is {data.get(key)!r}, expected {want!r} " - f"(inherited from {ROOT_MANIFEST})" + f"{path}: {key!r} is {data.get(key)!r}, expected {want!r} (inherited from {ROOT_MANIFEST})" ) return errors @@ -222,9 +220,7 @@ def check_ecosystem_versions(version: str) -> list[str]: if err: errors.append(err) elif have != version: - errors.append( - f"{path}: version is {have!r}, expected {version!r} (from {PYPROJECT})" - ) + errors.append(f"{path}: version is {have!r}, expected {version!r} (from {PYPROJECT})") return errors @@ -362,7 +358,7 @@ def check(fam: dict[str, set[str]]) -> list[str]: errors.append(f"{MARKETPLACE}: missing the all-in-one 'magpie' plugin entry") if not HOOK_SCRIPT.is_file(): errors.append(f"{HOOK_SCRIPT}: missing (referenced by the all-in-one plugin's SessionStart hook)") - root_data, root_err = load_json(ROOT_MANIFEST) + root_data, _root_err = load_json(ROOT_MANIFEST) if root_data is not None and "check-upgrade.sh" not in json.dumps(root_data.get("hooks", {})): errors.append(f"{ROOT_MANIFEST}: SessionStart hook does not reference hooks/check-upgrade.sh") @@ -422,7 +418,7 @@ def check(fam: dict[str, set[str]]) -> list[str]: # 4) No orphan plugin dirs (a magpie- with no skills declaring family x). for pdir in sorted(PLUGINS.glob("magpie-*")): - family = pdir.name[len("magpie-"):] + family = pdir.name[len("magpie-") :] if family not in fam: errors.append(f"orphan plugin '{pdir.name}': no skill declares family '{family}'") @@ -483,8 +479,7 @@ def fix(fam: dict[str, set[str]]) -> int: for e in meta_errs: print(f" - {e}", file=sys.stderr) print( - f"\nCannot regenerate: the family plugins inherit " - f"{', '.join(INHERITED)} from {ROOT_MANIFEST}.", + f"\nCannot regenerate: the family plugins inherit {', '.join(INHERITED)} from {ROOT_MANIFEST}.", file=sys.stderr, ) return 1 @@ -537,12 +532,14 @@ def fix(fam: dict[str, set[str]]) -> int: ) return 1 for family, skills in sorted(fam.items()): - keep.append({ - "name": f"magpie-{family}", - "source": f"./plugins/magpie-{family}", - "version": shared["version"], - "description": f"Apache Magpie {family} family ({len(skills)} skills).", - }) + keep.append( + { + "name": f"magpie-{family}", + "source": f"./plugins/magpie-{family}", + "version": shared["version"], + "description": f"Apache Magpie {family} family ({len(skills)} skills).", + } + ) market["plugins"] = keep MARKETPLACE.write_text(json.dumps(market, indent=2) + "\n", encoding="utf-8") print("Regenerated per-family plugins + marketplace entries from frontmatter.") diff --git a/tools/dev/check-workspace-members.py b/tools/dev/check-workspace-members.py index 3262c330c..e77e0965d 100755 --- a/tools/dev/check-workspace-members.py +++ b/tools/dev/check-workspace-members.py @@ -93,9 +93,7 @@ def read_workspace_members() -> set[str]: try: return set(data["tool"]["uv"]["workspace"]["members"]) except KeyError: - sys.stderr.write( - "error: root pyproject.toml has no [tool.uv.workspace] members\n" - ) + sys.stderr.write("error: root pyproject.toml has no [tool.uv.workspace] members\n") sys.exit(2) @@ -114,10 +112,7 @@ def main() -> int: out("error: uv workspace members list drifts from on-disk pyprojects\n") out("\n") if missing: - out( - "Found `tools/.../pyproject.toml` that is NOT in " - "`[tool.uv.workspace] members`:\n" - ) + out("Found `tools/.../pyproject.toml` that is NOT in `[tool.uv.workspace] members`:\n") for p in missing: out(f" + {p!r}\n") out("\n") @@ -128,10 +123,7 @@ def main() -> int: "pytest matrix.\n\n" ) if stale: - out( - "Workspace members list references paths that no longer " - "have a pyproject.toml on disk:\n" - ) + out("Workspace members list references paths that no longer have a pyproject.toml on disk:\n") for p in stale: out(f" - {p!r}\n") out("\n") diff --git a/tools/dev/pyproject.toml b/tools/dev/pyproject.toml new file mode 100644 index 000000000..3350c286d --- /dev/null +++ b/tools/dev/pyproject.toml @@ -0,0 +1,81 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +# Config carrier, not a package. `tools/dev/` is a directory of standalone +# helper scripts invoked by prek and CI by path, never imported as a library. +# Before this file existed the tree was invisible to ruff, mypy, and pytest: +# the workspace checks iterate `[tool.uv.workspace] members`, and these gates +# — the ones that guard everyone else's changes — were themselves unguarded. +# +# Declaring the tree as one workspace member is the same lightest-fix pattern +# `skills/pyproject.toml` uses: `tools/dev/run-workspace-check.sh` auto-discovers +# which checks apply from the sections below, and the CI matrix emits a +# `pytest (dev)` job for any member with a `[tool.pytest.ini_options]` section. +# +# Script filenames are hyphenated (`check-doc-sync.py`), which is not an +# importable module name, so tests load them through `importlib.util` rather +# than a plain import. + +[project] +name = "magpie-dev" +version = "0.1.0" +description = "Framework dev-loop helper scripts invoked by prek and CI." +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +# stdlib-only by design — these run on a bare checkout before anything is synced. +dependencies = [] + +[tool.uv] +package = false + +[tool.ruff] +line-length = 110 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "B", "UP", "SIM", "C4", "RUF"] +ignore = [ + "E501", # line-too-long — the 110-char limit above is already generous + "RUF003", # ambiguous en dash in a comment — the repo's prose style uses + # en dashes throughout; rewriting comments to hyphens to satisfy + # a homoglyph check would make them worse, and these files carry + # no non-ASCII identifiers, which is what the rule guards +] + +[tool.mypy] +python_version = "3.11" +files = ["."] +ignore_missing_imports = true +warn_unused_ignores = true +check_untyped_defs = true +no_implicit_optional = true +# Scripts invoked by path, not a typed library surface. `check_untyped_defs` +# above still type-checks the bodies, which is where the value is. +disallow_untyped_defs = false +disallow_incomplete_defs = false + +[tool.pytest.ini_options] +minversion = "8.0" +addopts = "-ra -q" +testpaths = ["tests"] + +[dependency-groups] +dev = [ + "mypy>=2.3.0", + "pytest>=9.1.1", + "ruff>=0.16.2", +] diff --git a/tools/dev/tests/test_check_doc_sync.py b/tools/dev/tests/test_check_doc_sync.py new file mode 100644 index 000000000..13ce08d92 --- /dev/null +++ b/tools/dev/tests/test_check_doc_sync.py @@ -0,0 +1,306 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +"""Tests for ``check-doc-sync.py``. + +Every test builds a miniature repository in ``tmp_path`` and runs one check +against it. The point of each is the **red** case: a gate that cannot fail is +worse than no gate, because a green run reads as evidence while measuring +nothing. So each check is exercised in both directions — drift is reported, and +the corrected tree is silent. + +The script's filename is hyphenated, which is not an importable module name, so +it is loaded through ``importlib.util``. +""" + +from __future__ import annotations + +import importlib.util +import os +from collections.abc import Iterator +from pathlib import Path +from types import ModuleType + +import pytest + +_SCRIPT = Path(__file__).resolve().parents[1] / "check-doc-sync.py" + + +def _load() -> ModuleType: + spec = importlib.util.spec_from_file_location("check_doc_sync", _SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +mod = _load() + + +@pytest.fixture +def repo(tmp_path: Path) -> Iterator[Path]: + """A miniature repo, cd'd into — the script resolves paths relative to cwd.""" + (tmp_path / "skills").mkdir() + (tmp_path / "tools" / "spec-loop" / "specs").mkdir(parents=True) + (tmp_path / "tools" / "dev").mkdir(parents=True) + (tmp_path / "docs" / "setup").mkdir(parents=True) + prev = Path.cwd() + os.chdir(tmp_path) + try: + yield tmp_path + finally: + os.chdir(prev) + + +def _skill(repo: Path, name: str, family: str, mode: str) -> None: + d = repo / "skills" / name + d.mkdir() + (d / "SKILL.md").write_text( + f"---\nname: magpie-{name}\nfamily: {family}\nmode: {mode}\n---\n\n# {name}\n", + encoding="utf-8", + ) + + +def _errors(fn, *args) -> list[str]: + errs: list[str] = [] + fn(errs, *args) + return errs + + +# --------------------------------------------------------------------------- +# 1. Spec-index completeness +# --------------------------------------------------------------------------- + + +def _specs(repo: Path, names: list[str], listed_in_overview: list[str], listed_in_readme: list[str]) -> None: + specs = repo / "tools" / "spec-loop" / "specs" + for n in names: + (specs / n).write_text(f"# {n}\n", encoding="utf-8") + (specs / "overview.md").write_text( + "\n".join(f"| Area | [{n}]({n}) |" for n in listed_in_overview), encoding="utf-8" + ) + (specs / "README.md").write_text( + "\n".join(f"- [`{n}`]({n})," for n in listed_in_readme), encoding="utf-8" + ) + + +def test_spec_listed_in_both_indexes_is_silent(repo: Path) -> None: + _specs(repo, ["adapters.md"], ["adapters.md"], ["adapters.md"]) + assert _errors(mod.check_spec_index) == [] + + +def test_spec_missing_from_overview_is_reported(repo: Path) -> None: + _specs(repo, ["adapters.md"], [], ["adapters.md"]) + errs = _errors(mod.check_spec_index) + assert len(errs) == 1 + assert "overview.md" in errs[0] and "adapters.md" in errs[0] + + +def test_spec_missing_from_readme_is_reported(repo: Path) -> None: + """Listed in one index is not listed. This is the real-world shape: ten + specs were in neither, and a spec in only one reads as indexed.""" + _specs(repo, ["adapters.md"], ["adapters.md"], []) + errs = _errors(mod.check_spec_index) + assert len(errs) == 1 + assert "README.md" in errs[0] + + +def test_index_files_are_not_themselves_specs(repo: Path) -> None: + _specs(repo, [], [], []) + assert _errors(mod.check_spec_index) == [] + + +# --------------------------------------------------------------------------- +# 2. Per-family counts in README.md +# --------------------------------------------------------------------------- + + +def _readme_family(repo: Path, family: str, declared: int) -> None: + (repo / "README.md").write_text( + "| Family | Type | Modes | Purpose | Detail |\n|---|---|---|---|---|\n" + f"| [**{family}**](docs/{family}/README.md) | opt-in | Triage | Does things. " + f"| {declared} skills, [`docs/{family}/`](docs/{family}/) |\n", + encoding="utf-8", + ) + + +def test_matching_family_count_is_silent(repo: Path) -> None: + _skill(repo, "a", "security", "Triage") + _skill(repo, "b", "security", "Triage") + _readme_family(repo, "security", 2) + assert _errors(mod.check_readme_family_counts) == [] + + +def test_stale_family_count_is_reported_with_both_numbers(repo: Path) -> None: + _skill(repo, "a", "security", "Triage") + _skill(repo, "b", "security", "Triage") + _skill(repo, "c", "security", "Drafting") + _readme_family(repo, "security", 2) + errs = _errors(mod.check_readme_family_counts) + assert len(errs) == 1 + assert "says 2 skills" in errs[0] and "has 3" in errs[0] + + +def test_table_row_that_is_not_a_family_is_ignored(repo: Path) -> None: + """The table carries rows whose bolded cell names no skill family. Those + must not be read as a family with a wrong count.""" + _skill(repo, "a", "security", "Triage") + _readme_family(repo, "not-a-family", 99) + assert _errors(mod.check_readme_family_counts) == [] + + +# --------------------------------------------------------------------------- +# 3. Per-mode counts in docs/modes.md +# --------------------------------------------------------------------------- + + +def _modes(repo: Path, rows: list[tuple[str, int]]) -> None: + body = "\n".join(f"| **{m}** | *(Agentic {m})* Does things. | stable | {n} |" for m, n in rows) + (repo / "docs" / "modes.md").write_text( + "# Modes\n\n## Modes at a glance\n\n" + "| Mode | Purpose | Status | Skill count |\n|---|---|---|---|\n" + body + "\n\n## Triage\n", + encoding="utf-8", + ) + + +def test_matching_mode_count_is_silent(repo: Path) -> None: + _skill(repo, "a", "security", "Triage") + _modes(repo, [("Triage", 1)]) + assert _errors(mod.check_modes_glance_counts) == [] + + +def test_stale_mode_count_is_reported(repo: Path) -> None: + _skill(repo, "a", "security", "Triage") + _skill(repo, "b", "security", "Triage") + _modes(repo, [("Triage", 1)]) + errs = _errors(mod.check_modes_glance_counts) + assert len(errs) == 1 + assert "says 1 skills" in errs[0] and "has 2" in errs[0] + + +def test_mode_with_no_skills_must_declare_zero(repo: Path) -> None: + """A deliberately-off mode declares 0. A non-zero count for a mode nothing + uses is drift, not an exemption.""" + _skill(repo, "a", "security", "Triage") + _modes(repo, [("Triage", 1), ("Agentic Autonomous", 0)]) + assert _errors(mod.check_modes_glance_counts) == [] + + _modes(repo, [("Triage", 1), ("Agentic Autonomous", 4)]) + errs = _errors(mod.check_modes_glance_counts) + assert len(errs) == 1 + assert "no skill declares that mode" in errs[0] + + +def test_missing_glance_table_is_not_an_error(repo: Path) -> None: + (repo / "docs" / "modes.md").write_text("# Modes\n\nNo glance table here.\n", encoding="utf-8") + assert _errors(mod.check_modes_glance_counts) == [] + + +# --------------------------------------------------------------------------- +# 4. Catalogue totals in prose +# --------------------------------------------------------------------------- + + +def test_matching_total_is_silent(repo: Path) -> None: + (repo / "docs" / "setup" / "marketplaces.md").write_text("Installs 2 skills.\n", encoding="utf-8") + assert _errors(mod.check_total_counts, 2) == [] + + +def test_every_stale_total_is_reported_not_just_the_first(repo: Path) -> None: + (repo / "docs" / "setup" / "marketplaces.md").write_text( + "Installs 71 skills.\nAll 71 skills load.\nThe 71 skills are namespaced.\n", encoding="utf-8" + ) + errs = _errors(mod.check_total_counts, 74) + assert len(errs) == 3 + assert all("says 71 skills" in e and "has 74" in e for e in errs) + + +def test_totals_check_reads_only_the_allowlist(repo: Path) -> None: + """A doc outside the allowlist may legitimately count a subset — 'Nine + skills cover the staged path' — so a greedy scan would flag it forever.""" + (repo / "docs" / "elsewhere.md").write_text("Nine skills cover the staged path.\n", encoding="utf-8") + assert _errors(mod.check_total_counts, 74) == [] + + +# --------------------------------------------------------------------------- +# 5. Dev scripts are documented +# --------------------------------------------------------------------------- + + +def _dev(repo: Path, scripts: list[str], readme_names: list[str]) -> None: + dev = repo / "tools" / "dev" + for s in scripts: + (dev / s).write_text("#!/bin/sh\n", encoding="utf-8") + (dev / "README.md").write_text( + "\n".join(f"| [`{n}`]({n}) | does a thing |" for n in readme_names), encoding="utf-8" + ) + + +def test_documented_scripts_are_silent(repo: Path) -> None: + _dev(repo, ["check-a.py", "check-b.sh"], ["check-a.py", "check-b.sh"]) + assert _errors(mod.check_dev_scripts_documented) == [] + + +def test_undocumented_script_is_reported(repo: Path) -> None: + _dev(repo, ["check-a.py", "check-b.sh"], ["check-a.py"]) + errs = _errors(mod.check_dev_scripts_documented) + assert len(errs) == 1 + assert "check-b.sh" in errs[0] + + +def test_non_script_files_are_not_required_to_be_documented(repo: Path) -> None: + _dev(repo, [], []) + (repo / "tools" / "dev" / "notes.txt").write_text("scratch\n", encoding="utf-8") + (repo / "tools" / "dev" / "pyproject.toml").write_text("[project]\n", encoding="utf-8") + assert _errors(mod.check_dev_scripts_documented) == [] + + +# --------------------------------------------------------------------------- +# main() +# --------------------------------------------------------------------------- + + +def test_main_exits_2_outside_a_repo_root(repo: Path, capsys: pytest.CaptureFixture[str]) -> None: + (repo / "skills").rmdir() + assert mod.main() == 2 + assert "run from the repository root" in capsys.readouterr().err + + +def test_main_exits_1_and_names_every_problem(repo: Path, capsys: pytest.CaptureFixture[str]) -> None: + _skill(repo, "a", "security", "Triage") + _readme_family(repo, "security", 9) + _modes(repo, [("Triage", 9)]) + _specs(repo, ["adapters.md"], [], []) + _dev(repo, ["check-x.sh"], []) + (repo / "docs" / "setup" / "marketplaces.md").write_text("Installs 9 skills.\n", encoding="utf-8") + + assert mod.main() == 1 + err = capsys.readouterr().err + for fragment in ("family 'security'", "mode 'Triage'", "adapters.md", "check-x.sh", "says 9 skills"): + assert fragment in err + + +def test_main_exits_0_on_a_consistent_tree(repo: Path, capsys: pytest.CaptureFixture[str]) -> None: + _skill(repo, "a", "security", "Triage") + _readme_family(repo, "security", 1) + _modes(repo, [("Triage", 1)]) + _specs(repo, ["adapters.md"], ["adapters.md"], ["adapters.md"]) + _dev(repo, ["check-x.sh"], ["check-x.sh"]) + (repo / "docs" / "setup" / "marketplaces.md").write_text("Installs 1 skills.\n", encoding="utf-8") + + assert mod.main() == 0 + assert "OK" in capsys.readouterr().out diff --git a/uv.lock b/uv.lock index eb346942f..51bf3d398 100644 --- a/uv.lock +++ b/uv.lock @@ -25,6 +25,7 @@ members = [ "github-rollup", "jira-bridge", "magpie-bitbucket", + "magpie-dev", "magpie-fossil", "magpie-maildir", "magpie-skills", @@ -947,6 +948,27 @@ dev = [ { name = "ruff", specifier = ">=0.16.1" }, ] +[[package]] +name = "magpie-dev" +version = "0.1.0" +source = { virtual = "tools/dev" } + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=2.3.0" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "ruff", specifier = ">=0.16.2" }, +] + [[package]] name = "magpie-fossil" version = "0.1.0" From d2bf608fb3f75035b5578f77eb2bfee3b11f7ce3 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Tue, 8 Sep 2026 04:28:09 +0200 Subject: [PATCH 3/4] refactor(workspace): share the dev toolchain via magpie-dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every workspace member repeated the same three pins in its own `[dependency-groups] dev`, under an instruction in the root pyproject to "keep the version bounds here and in every member in lockstep". They were not in lockstep: mypy >=2.1.0, >=2.3.0, >=1.10 (three floors) pytest >=9.1.1, >=8.0 (two) ruff >=0.16.1, >=0.16.2 (two) tools/skill-reconciler-diff declared only pytest, and tools/fossil had no dev group at all despite being ruff-checked — its checks ran against whatever happened to be in the environment. Nothing detected any of it. The duplication was the defect and the lockstep instruction was the workaround. tools/dev becomes `magpie-dev`, a metadata-only wheel whose dependencies are the toolchain. Every other member's dev group becomes `["magpie-dev"]`, with the workspace source declared once at the root. Members with extras keep them (tools/gmail/oauth-draft retains mcp). Bumping a version is now one edit and the whole workspace moves. Each member's environment stays self-contained: the checks still run `uv run --directory --project . python -m `, so nothing leaks in from the root. Only the declaration is shared. Two properties checked rather than assumed. Check discovery keys off the config sections (`[tool.ruff]`, `[tool.mypy]`, `[tool.pytest.ini_options]`) and not the dev group, so giving a member more tools does not silently enrol it in more checks. And the whole battery was re-run after deleting every member `.venv`, not against a warm cache: ruff 30/30, ruff-format 30/30, mypy 27/27, pytest 33/33. One real behaviour change: tools/bitbucket moves from mypy>=1.10 / pytest>=8.0 to the current floors. It passes the fresh sweep, but that is a version bump rather than a no-op. Generated-by: Claude Code (Opus 5) --- ai-tutors/pyproject.toml | 14 +- pyproject.toml | 26 +- skills/pyproject.toml | 14 +- tools/agent-guard/pyproject.toml | 14 +- tools/agent-isolation/pyproject.toml | 14 +- tools/bitbucket/pyproject.toml | 6 +- .../generate-cve-json/pyproject.toml | 14 +- .../oauth-api/pyproject.toml | 14 +- tools/dev/README.md | 22 ++ tools/dev/pyproject.toml | 30 +- tools/egress-gateway/pyproject.toml | 6 +- tools/fossil/pyproject.toml | 6 + tools/github-body-field/pyproject.toml | 14 +- tools/github-rollup/pyproject.toml | 14 +- tools/gmail/oauth-draft/pyproject.toml | 13 +- tools/jira/pyproject.toml | 14 +- tools/maildir/pyproject.toml | 14 +- tools/permission-audit/pyproject.toml | 14 +- tools/pilot-report-validator/pyproject.toml | 6 +- tools/pr-management-stats/pyproject.toml | 14 +- tools/preflight-audit/pyproject.toml | 14 +- tools/privacy-llm/checker/pyproject.toml | 14 +- tools/privacy-llm/redactor/pyproject.toml | 14 +- tools/sandbox-lint/pyproject.toml | 14 +- .../pyproject.toml | 6 +- tools/skill-and-tool-validator/pyproject.toml | 6 +- tools/skill-evals/pyproject.toml | 14 +- tools/skill-reconciler-diff/pyproject.toml | 2 +- tools/sourcehut/pyproject.toml | 6 +- tools/spec-inventory/pyproject.toml | 6 +- tools/spec-status-index/pyproject.toml | 14 +- tools/spec-validator/pyproject.toml | 6 +- tools/symlink-lint/pyproject.toml | 14 +- tools/vcs/pyproject.toml | 14 +- tools/vendor-neutrality-score/pyproject.toml | 14 +- uv.lock | 330 ++++-------------- 36 files changed, 260 insertions(+), 511 deletions(-) diff --git a/ai-tutors/pyproject.toml b/ai-tutors/pyproject.toml index 66d70dbe6..942514bd1 100644 --- a/ai-tutors/pyproject.toml +++ b/ai-tutors/pyproject.toml @@ -71,12 +71,8 @@ addopts = "-ra -q" testpaths = ["."] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.3.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/pyproject.toml b/pyproject.toml index 6b9c43e42..169bf575d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,16 +41,20 @@ requires-python = ">=3.11" # # - `prek` is the static-check / pre-commit runner. CI installs it # via `uv sync --group dev` and invokes `uv run prek …`. -# - `ruff`, `mypy`, `pytest` are the shared static-analysis + -# test toolchain. They are declared here for root-level ad-hoc -# use AND, pinned to the same lower bounds, in every workspace -# member's own `[dependency-groups] dev` block — so each -# sub-project's environment is self-contained. The workspace +# - `magpie-dev` (tools/dev) carries the shared static-analysis + +# test toolchain — ruff, mypy, pytest — declared in ONE place and +# depended on by every workspace member's own `dev` group, so each +# sub-project's environment is still self-contained. The workspace # checks run each tool against the member's own project via # `uv run --directory --project . …` (see # `tools/dev/run-workspace-check.sh`), so a member is never # dependent on tools leaking in from the shared root environment. -# Keep the version bounds here and in every member in lockstep. +# +# Bump a toolchain version in `tools/dev/pyproject.toml` and the +# whole workspace moves together. The previous arrangement repeated +# the three pins in every member with an instruction to keep them in +# lockstep, and they had already drifted into three different mypy +# floors, two pytest floors, and two ruff floors. # # Lower bounds mirror what individual tools needed at their last # bump; the upper bound is enforced implicitly by the 7-day @@ -58,9 +62,7 @@ requires-python = ">=3.11" # is "the most recent that is at least 7 days old". dev = [ "prek>=0.4.12", - "ruff>=0.16.1", - "mypy>=2.3.0", - "pytest>=9.1.1", + "magpie-dev", ] [tool.uv] @@ -92,6 +94,12 @@ package = false # per-tool install time). This is desired — drift between tools' # pinned versions is harder to spot when each has its own lock # file. +[tool.uv.sources] +# `magpie-dev` is the workspace's shared dev toolchain (tools/dev). Declared +# here once so every member can name it in its `dev` group without repeating +# the source; workspace members inherit the root's sources. +magpie-dev = { workspace = true } + [tool.uv.workspace] members = [ "ai-tutors", diff --git a/skills/pyproject.toml b/skills/pyproject.toml index 12b64d470..5308937cd 100644 --- a/skills/pyproject.toml +++ b/skills/pyproject.toml @@ -90,12 +90,8 @@ addopts = "-ra -q" testpaths = ["."] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.3.0", - "pytest>=9.1.1", - "ruff>=0.16.2", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/tools/agent-guard/pyproject.toml b/tools/agent-guard/pyproject.toml index 150114200..7bc32ed7d 100644 --- a/tools/agent-guard/pyproject.toml +++ b/tools/agent-guard/pyproject.toml @@ -78,12 +78,8 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.1.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/tools/agent-isolation/pyproject.toml b/tools/agent-isolation/pyproject.toml index de03c9c43..168759ddf 100644 --- a/tools/agent-isolation/pyproject.toml +++ b/tools/agent-isolation/pyproject.toml @@ -38,12 +38,8 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.1.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/tools/bitbucket/pyproject.toml b/tools/bitbucket/pyproject.toml index bb6ae2715..56654f66c 100644 --- a/tools/bitbucket/pyproject.toml +++ b/tools/bitbucket/pyproject.toml @@ -80,8 +80,4 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -dev = [ - "mypy>=1.10", - "pytest>=8.0", - "ruff>=0.16.1", -] +dev = ["magpie-dev"] diff --git a/tools/cve-tool-vulnogram/generate-cve-json/pyproject.toml b/tools/cve-tool-vulnogram/generate-cve-json/pyproject.toml index 3686ccab1..5a760b91b 100644 --- a/tools/cve-tool-vulnogram/generate-cve-json/pyproject.toml +++ b/tools/cve-tool-vulnogram/generate-cve-json/pyproject.toml @@ -87,12 +87,8 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.3.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/tools/cve-tool-vulnogram/oauth-api/pyproject.toml b/tools/cve-tool-vulnogram/oauth-api/pyproject.toml index 81f033463..c15859864 100644 --- a/tools/cve-tool-vulnogram/oauth-api/pyproject.toml +++ b/tools/cve-tool-vulnogram/oauth-api/pyproject.toml @@ -88,12 +88,8 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.3.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/tools/dev/README.md b/tools/dev/README.md index 336945ba6..cae8e1c7a 100644 --- a/tools/dev/README.md +++ b/tools/dev/README.md @@ -3,6 +3,7 @@ **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* - [`tools/dev/`](#toolsdev) + - [The shared dev toolchain](#the-shared-dev-toolchain) - [The scripts](#the-scripts) - [Prerequisites](#prerequisites) @@ -19,6 +20,27 @@ Framework dev-loop helpers (placeholder check, agent pre-commit hook). Invoked by prek and CI; not consumed by any skill directly. See the individual scripts in this directory for usage. +## The shared dev toolchain + +`tools/dev` is also the workspace's **toolchain project**, `magpie-dev`. It +declares ruff, mypy, and pytest as its dependencies, and every other workspace +member names `magpie-dev` in its own `[dependency-groups] dev` instead of +repeating the pins. Bump a version here and the whole workspace moves together. + +Each member's environment stays self-contained — the checks still run +`uv run --directory --project . python -m `, so no member depends +on tools leaking in from the root environment. Only the *declaration* is shared. + +Why it changed: the pins used to be repeated in every member with an instruction +to keep them in lockstep. They had drifted into three different mypy floors, two +pytest floors, and two ruff floors, one member had no dev group at all, and +nothing detected any of it — the duplication was the bug, and the instruction to +keep it consistent was the workaround. + +The project builds as a metadata-only wheel: it ships no importable module, +because the scripts are hyphenated and invoked by path, but it has to be +installable for other members to depend on it. + ## The scripts | Script | What it does | diff --git a/tools/dev/pyproject.toml b/tools/dev/pyproject.toml index 3350c286d..44e1b2e61 100644 --- a/tools/dev/pyproject.toml +++ b/tools/dev/pyproject.toml @@ -36,11 +36,26 @@ version = "0.1.0" description = "Framework dev-loop helper scripts invoked by prek and CI." requires-python = ">=3.11" license = { text = "Apache-2.0" } -# stdlib-only by design — these run on a bare checkout before anything is synced. -dependencies = [] +# The shared static-analysis + test toolchain, declared once. Every other +# workspace member depends on this project in its `dev` group instead of +# repeating the three pins — see the note above `[project.dependencies]` in the +# root pyproject.toml. The scripts themselves are stdlib-only; these are the +# tools that check them and everything else. +dependencies = [ + "mypy>=2.3.0", + "pytest>=9.1.1", + "ruff>=0.16.2", +] + +# Built as a metadata-only wheel: this project ships no importable module (the +# scripts are hyphenated and invoked by path), but it has to be installable for +# other members to depend on it. +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" -[tool.uv] -package = false +[tool.hatch.build.targets.wheel] +bypass-selection = true [tool.ruff] line-length = 110 @@ -72,10 +87,3 @@ disallow_incomplete_defs = false minversion = "8.0" addopts = "-ra -q" testpaths = ["tests"] - -[dependency-groups] -dev = [ - "mypy>=2.3.0", - "pytest>=9.1.1", - "ruff>=0.16.2", -] diff --git a/tools/egress-gateway/pyproject.toml b/tools/egress-gateway/pyproject.toml index d052a2466..26b037d8a 100644 --- a/tools/egress-gateway/pyproject.toml +++ b/tools/egress-gateway/pyproject.toml @@ -66,8 +66,4 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -dev = [ - "mypy>=2.1.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +dev = ["magpie-dev"] diff --git a/tools/fossil/pyproject.toml b/tools/fossil/pyproject.toml index 7fae2426f..58fe0d483 100644 --- a/tools/fossil/pyproject.toml +++ b/tools/fossil/pyproject.toml @@ -78,3 +78,9 @@ disallow_incomplete_defs = false minversion = "8.0" addopts = "-ra -q" testpaths = ["tests"] + +[dependency-groups] +# The shared toolchain comes from `magpie-dev` (tools/dev). This member had no +# dev group at all, so its checks ran on whatever happened to be in the +# environment. +dev = ["magpie-dev"] diff --git a/tools/github-body-field/pyproject.toml b/tools/github-body-field/pyproject.toml index db102a943..8f3b0ee19 100644 --- a/tools/github-body-field/pyproject.toml +++ b/tools/github-body-field/pyproject.toml @@ -81,12 +81,8 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.1.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/tools/github-rollup/pyproject.toml b/tools/github-rollup/pyproject.toml index e6adb4230..391cbd8ff 100644 --- a/tools/github-rollup/pyproject.toml +++ b/tools/github-rollup/pyproject.toml @@ -67,12 +67,8 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.1.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/tools/gmail/oauth-draft/pyproject.toml b/tools/gmail/oauth-draft/pyproject.toml index b849770ff..f43b96290 100644 --- a/tools/gmail/oauth-draft/pyproject.toml +++ b/tools/gmail/oauth-draft/pyproject.toml @@ -110,14 +110,11 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. dev = [ - "mypy>=2.3.0", - "pytest>=9.1.1", - "ruff>=0.16.1", - # Needed to import and test `oauth_draft.mcp_server` (the `mcp` extra). + "magpie-dev", "mcp>=2.0.0", ] diff --git a/tools/jira/pyproject.toml b/tools/jira/pyproject.toml index 2c811acc1..9d0d1eccf 100644 --- a/tools/jira/pyproject.toml +++ b/tools/jira/pyproject.toml @@ -50,12 +50,8 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.1.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/tools/maildir/pyproject.toml b/tools/maildir/pyproject.toml index afe5b6d56..71601df41 100644 --- a/tools/maildir/pyproject.toml +++ b/tools/maildir/pyproject.toml @@ -80,12 +80,8 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.1.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/tools/permission-audit/pyproject.toml b/tools/permission-audit/pyproject.toml index 934e6f9ac..ff093dddb 100644 --- a/tools/permission-audit/pyproject.toml +++ b/tools/permission-audit/pyproject.toml @@ -70,12 +70,8 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.1.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/tools/pilot-report-validator/pyproject.toml b/tools/pilot-report-validator/pyproject.toml index 7c8bab72b..773928d40 100644 --- a/tools/pilot-report-validator/pyproject.toml +++ b/tools/pilot-report-validator/pyproject.toml @@ -47,11 +47,7 @@ ignore = ["E501"] "tests/**" = ["B", "SIM"] [dependency-groups] -dev = [ - "mypy>=2.1.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +dev = ["magpie-dev"] [tool.pytest.ini_options] minversion = "8.0" diff --git a/tools/pr-management-stats/pyproject.toml b/tools/pr-management-stats/pyproject.toml index 8a1763359..f9c149ab9 100644 --- a/tools/pr-management-stats/pyproject.toml +++ b/tools/pr-management-stats/pyproject.toml @@ -37,12 +37,8 @@ testpaths = ["tests"] pythonpath = [".", "tests"] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.1.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/tools/preflight-audit/pyproject.toml b/tools/preflight-audit/pyproject.toml index 337c73073..623690d56 100644 --- a/tools/preflight-audit/pyproject.toml +++ b/tools/preflight-audit/pyproject.toml @@ -80,12 +80,8 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.1.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/tools/privacy-llm/checker/pyproject.toml b/tools/privacy-llm/checker/pyproject.toml index d517ae9aa..2dcbe89cd 100644 --- a/tools/privacy-llm/checker/pyproject.toml +++ b/tools/privacy-llm/checker/pyproject.toml @@ -81,12 +81,8 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.3.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/tools/privacy-llm/redactor/pyproject.toml b/tools/privacy-llm/redactor/pyproject.toml index caa14e3ba..e69808fe2 100644 --- a/tools/privacy-llm/redactor/pyproject.toml +++ b/tools/privacy-llm/redactor/pyproject.toml @@ -84,12 +84,8 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.3.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/tools/sandbox-lint/pyproject.toml b/tools/sandbox-lint/pyproject.toml index 69d036674..050c18d4f 100644 --- a/tools/sandbox-lint/pyproject.toml +++ b/tools/sandbox-lint/pyproject.toml @@ -81,12 +81,8 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.3.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/tools/security-tracker-stats-dashboard/pyproject.toml b/tools/security-tracker-stats-dashboard/pyproject.toml index 57d47e523..620baa7c1 100644 --- a/tools/security-tracker-stats-dashboard/pyproject.toml +++ b/tools/security-tracker-stats-dashboard/pyproject.toml @@ -52,8 +52,4 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -dev = [ - "mypy>=2.1.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +dev = ["magpie-dev"] diff --git a/tools/skill-and-tool-validator/pyproject.toml b/tools/skill-and-tool-validator/pyproject.toml index 6fa33657e..40262bb5d 100644 --- a/tools/skill-and-tool-validator/pyproject.toml +++ b/tools/skill-and-tool-validator/pyproject.toml @@ -82,8 +82,4 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -dev = [ - "mypy>=2.3.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +dev = ["magpie-dev"] diff --git a/tools/skill-evals/pyproject.toml b/tools/skill-evals/pyproject.toml index 7887bf9c5..eb18e7666 100644 --- a/tools/skill-evals/pyproject.toml +++ b/tools/skill-evals/pyproject.toml @@ -56,12 +56,8 @@ minversion = "8.0" addopts = "-ra -q" [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.3.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/tools/skill-reconciler-diff/pyproject.toml b/tools/skill-reconciler-diff/pyproject.toml index 802699331..e121fcc9c 100644 --- a/tools/skill-reconciler-diff/pyproject.toml +++ b/tools/skill-reconciler-diff/pyproject.toml @@ -35,7 +35,7 @@ skill-reconciler-diff = "skill_reconciler_diff:main" packages = ["src/skill_reconciler_diff"] [dependency-groups] -dev = ["pytest>=8.0"] +dev = ["magpie-dev"] [tool.ruff] line-length = 110 diff --git a/tools/sourcehut/pyproject.toml b/tools/sourcehut/pyproject.toml index b670c1a0d..edfd95ceb 100644 --- a/tools/sourcehut/pyproject.toml +++ b/tools/sourcehut/pyproject.toml @@ -29,11 +29,7 @@ license = { text = "Apache-2.0" } dependencies = [] [dependency-groups] -dev = [ - "ruff>=0.16.1", - "mypy>=2.3.0", - "pytest>=9.1.1", -] +dev = ["magpie-dev"] [project.scripts] magpie-sourcehut = "magpie_sourcehut:main" diff --git a/tools/spec-inventory/pyproject.toml b/tools/spec-inventory/pyproject.toml index 600f3b85e..4634eee75 100644 --- a/tools/spec-inventory/pyproject.toml +++ b/tools/spec-inventory/pyproject.toml @@ -64,11 +64,7 @@ disallow_untyped_defs = false disallow_incomplete_defs = false [dependency-groups] -dev = [ - "mypy>=2.1.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +dev = ["magpie-dev"] [tool.pytest.ini_options] minversion = "8.0" diff --git a/tools/spec-status-index/pyproject.toml b/tools/spec-status-index/pyproject.toml index 76a6908ac..232514f43 100644 --- a/tools/spec-status-index/pyproject.toml +++ b/tools/spec-status-index/pyproject.toml @@ -80,12 +80,8 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.1.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/tools/spec-validator/pyproject.toml b/tools/spec-validator/pyproject.toml index 2df835b0a..ade3373a3 100644 --- a/tools/spec-validator/pyproject.toml +++ b/tools/spec-validator/pyproject.toml @@ -47,11 +47,7 @@ ignore = ["E501"] "tests/**" = ["B", "SIM"] [dependency-groups] -dev = [ - "mypy>=2.1.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +dev = ["magpie-dev"] [tool.pytest.ini_options] minversion = "8.0" diff --git a/tools/symlink-lint/pyproject.toml b/tools/symlink-lint/pyproject.toml index 784a535ac..630562fd8 100644 --- a/tools/symlink-lint/pyproject.toml +++ b/tools/symlink-lint/pyproject.toml @@ -81,12 +81,8 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.1.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/tools/vcs/pyproject.toml b/tools/vcs/pyproject.toml index 55498003c..f8197cf31 100644 --- a/tools/vcs/pyproject.toml +++ b/tools/vcs/pyproject.toml @@ -81,12 +81,8 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.1.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/tools/vendor-neutrality-score/pyproject.toml b/tools/vendor-neutrality-score/pyproject.toml index 59de74cb3..57d5702a4 100644 --- a/tools/vendor-neutrality-score/pyproject.toml +++ b/tools/vendor-neutrality-score/pyproject.toml @@ -80,12 +80,8 @@ addopts = "-ra -q" testpaths = ["tests"] [dependency-groups] -# Shared static-analysis + test toolchain, pinned to the same versions as the -# workspace root so every sub-project's own environment is self-contained. -# The workspace checks run each tool via `uv run --directory -# --project . python -m ` — see tools/dev/run-workspace-check.sh. -dev = [ - "mypy>=2.1.0", - "pytest>=9.1.1", - "ruff>=0.16.1", -] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. The checks run each tool +# via `uv run --directory --project . python -m ` — see +# tools/dev/run-workspace-check.sh. +dev = ["magpie-dev"] diff --git a/uv.lock b/uv.lock index 51bf3d398..ec53ee5b8 100644 --- a/uv.lock +++ b/uv.lock @@ -57,19 +57,13 @@ source = { editable = "tools/agent-guard" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.1.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "agent-isolation" @@ -78,19 +72,13 @@ source = { editable = "tools/agent-isolation" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.1.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "ai-tutors" @@ -99,19 +87,13 @@ source = { virtual = "ai-tutors" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.3.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "annotated-types" @@ -142,20 +124,16 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ - { name = "mypy" }, + { name = "magpie-dev" }, { name = "prek" }, - { name = "pytest" }, - { name = "ruff" }, ] [package.metadata] [package.metadata.requires-dev] dev = [ - { name = "mypy", specifier = ">=2.3.0" }, + { name = "magpie-dev", editable = "tools/dev" }, { name = "prek", specifier = ">=0.4.12" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, ] [[package]] @@ -492,19 +470,13 @@ source = { editable = "tools/privacy-llm/checker" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.3.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "click" @@ -593,20 +565,14 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] requires-dist = [{ name = "proxy-py", specifier = ">=2.4,<3" }] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.1.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "generate-cve-json" @@ -615,19 +581,13 @@ source = { editable = "tools/cve-tool-vulnogram/generate-cve-json" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.3.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "github-body-field" @@ -636,19 +596,13 @@ source = { editable = "tools/github-body-field" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.1.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "github-rollup" @@ -657,19 +611,13 @@ source = { editable = "tools/github-rollup" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.1.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "google-auth" @@ -770,19 +718,13 @@ source = { editable = "tools/jira" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.1.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "jsonschema" @@ -934,36 +876,26 @@ source = { editable = "tools/bitbucket" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=1.10" }, - { name = "pytest", specifier = ">=8.0" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "magpie-dev" version = "0.1.0" -source = { virtual = "tools/dev" } - -[package.dev-dependencies] -dev = [ +source = { editable = "tools/dev" } +dependencies = [ { name = "mypy" }, { name = "pytest" }, { name = "ruff" }, ] [package.metadata] - -[package.metadata.requires-dev] -dev = [ +requires-dist = [ { name = "mypy", specifier = ">=2.3.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "ruff", specifier = ">=0.16.2" }, @@ -974,6 +906,16 @@ name = "magpie-fossil" version = "0.1.0" source = { editable = "tools/fossil" } +[package.dev-dependencies] +dev = [ + { name = "magpie-dev" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] + [[package]] name = "magpie-maildir" version = "0.1.0" @@ -981,19 +923,13 @@ source = { editable = "tools/maildir" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.1.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "magpie-skills" @@ -1002,19 +938,13 @@ source = { virtual = "skills" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.3.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.2" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "magpie-sourcehut" @@ -1023,19 +953,13 @@ source = { editable = "tools/sourcehut" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.3.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "magpie-vcs" @@ -1044,19 +968,13 @@ source = { editable = "tools/vcs" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.1.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "mcp" @@ -1180,10 +1098,8 @@ mcp = [ [package.dev-dependencies] dev = [ + { name = "magpie-dev" }, { name = "mcp" }, - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, ] [package.metadata] @@ -1195,10 +1111,8 @@ provides-extras = ["mcp"] [package.metadata.requires-dev] dev = [ + { name = "magpie-dev", editable = "tools/dev" }, { name = "mcp", specifier = ">=2.0.0" }, - { name = "mypy", specifier = ">=2.3.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, ] [[package]] @@ -1247,19 +1161,13 @@ source = { editable = "tools/permission-audit" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.1.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "pilot-report-validator" @@ -1268,19 +1176,13 @@ source = { editable = "tools/pilot-report-validator" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.1.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "pluggy" @@ -1298,19 +1200,13 @@ source = { virtual = "tools/pr-management-stats" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.1.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "preflight-audit" @@ -1319,19 +1215,13 @@ source = { editable = "tools/preflight-audit" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.1.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "prek" @@ -1590,19 +1480,13 @@ source = { editable = "tools/privacy-llm/redactor" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.3.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "referencing" @@ -1801,19 +1685,13 @@ source = { editable = "tools/sandbox-lint" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.3.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "security-tracker-stats-dashboard" @@ -1822,19 +1700,13 @@ source = { editable = "tools/security-tracker-stats-dashboard" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.1.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "skill-and-tool-validator" @@ -1843,19 +1715,13 @@ source = { editable = "tools/skill-and-tool-validator" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.3.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "skill-evals" @@ -1864,19 +1730,13 @@ source = { editable = "tools/skill-evals" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.3.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "skill-reconciler-diff" @@ -1885,13 +1745,13 @@ source = { editable = "tools/skill-reconciler-diff" } [package.dev-dependencies] dev = [ - { name = "pytest" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8.0" }] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "spec-inventory" @@ -1900,19 +1760,13 @@ source = { editable = "tools/spec-inventory" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.1.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "spec-status-index" @@ -1921,19 +1775,13 @@ source = { editable = "tools/spec-status-index" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.1.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "spec-validator" @@ -1942,19 +1790,13 @@ source = { editable = "tools/spec-validator" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.1.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "sse-starlette" @@ -1989,19 +1831,13 @@ source = { editable = "tools/symlink-lint" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.1.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "truststore" @@ -2062,19 +1898,13 @@ source = { editable = "tools/vendor-neutrality-score" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.1.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] [[package]] name = "vulnogram-api" @@ -2083,16 +1913,10 @@ source = { editable = "tools/cve-tool-vulnogram/oauth-api" } [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, + { name = "magpie-dev" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=2.3.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.16.1" }, -] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] From 499d7090c3e3cbc9ad06d21a1ceb5b0fce7dd438 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Tue, 8 Sep 2026 04:38:25 +0200 Subject: [PATCH 4/4] ci(dev): verify every workspace member's tests actually run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the CI pytest matrix and the workspace sweep are driven by the presence of `[tool.pytest.ini_options]`. A project can therefore carry a full tests/ directory and be executed by nothing: the job is simply never emitted, so there is no red tick for anyone to notice. Nothing checked this. check-workspace-members.py now reports three shapes: - tests on disk, no [tool.pytest.ini_options] → the tests never run; - [tool.pytest.ini_options], no tests on disk → the CI job runs and collects nothing, so its green tick proves nothing; - neither → no tests at all. A project that genuinely should not be tested declares it with `[tool.magpie.checks] skip = ["pytest"]`, the same opt-out the sweep and the CI matrix already honour — so an exemption is visible in the file rather than being a silent absence from a matrix nobody reads. All 33 members pass today, so this locks in the current state rather than fixing a present gap. The hook now also re-fires on test files, since adding or deleting tests changes the answer. Ten tests cover it, including the false negative that would make the whole check worthless: a test file vendored inside a member's .venv or site-packages must not make an untested member look tested. Each of the three failure shapes was also verified by hand against a real member, and the declared opt-out confirmed to silence all three. Generated-by: Claude Code (Opus 5) --- .pre-commit-config.yaml | 19 ++- tools/dev/README.md | 2 +- tools/dev/check-workspace-members.py | 79 +++++++++- .../dev/tests/test_check_workspace_members.py | 135 ++++++++++++++++++ 4 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 tools/dev/tests/test_check_workspace_members.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9030acaba..0b53b6aac 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -301,15 +301,26 @@ repos: # `pyproject.toml`. If a new `tools//pyproject.toml` # appears without being added to that array, both surfaces # silently skip it — the very drift bug the workspace refactor - # was meant to prevent. This hook re-fires whenever any - # tools/**/pyproject.toml or the root pyproject changes. + # was meant to prevent. + # + # It also verifies every declared member's tests actually run. Both + # the CI matrix and the workspace sweep key off the presence of + # `[tool.pytest.ini_options]`, so a project can carry a full tests/ + # directory and never be executed by anything — no red tick, because + # no job. Reported: tests with no config (never run), config with no + # tests (a green job that collected nothing), and neither. A project + # that should not be tested declares `[tool.magpie.checks] skip = + # ["pytest"]` rather than being quietly absent. + # + # Re-fires on any member pyproject, the root pyproject, or any test + # file — adding or deleting tests changes the answer. - repo: local hooks: - id: check-workspace-members - name: check-workspace-members + name: check-workspace-members (membership + tests actually run) language: system entry: tools/dev/check-workspace-members.py - files: ^(tools/[^/]+(/[^/]+)?/pyproject\.toml|pyproject\.toml)$ + files: ^((tools/[^/]+(/[^/]+)?|ai-tutors|skills)/pyproject\.toml|pyproject\.toml|.*/tests?/.*\.py|.*/test_[^/]+\.py|.*_test\.py)$ pass_filenames: false # Workspace-level static checks. Iterate over every uv-workspace # member declared in the root `pyproject.toml`'s diff --git a/tools/dev/README.md b/tools/dev/README.md index cae8e1c7a..1ddaf7f58 100644 --- a/tools/dev/README.md +++ b/tools/dev/README.md @@ -48,7 +48,7 @@ installable for other members to depend on it. | [`check-doc-sync.py`](check-doc-sync.py) | Guards the documentation claims that track the tree and rot silently: spec-index completeness (every `tools/spec-loop/specs/*.md` listed in **both** `overview.md` and `README.md`), the per-family skill counts in the root `README.md`, the per-mode counts in `docs/modes.md`'s *Modes at a glance* table, the bare catalogue totals in `docs/setup/marketplaces.md`, and that every script here is named in this file. | | [`check-family-plugins.py`](check-family-plugins.py) | Validates the marketplace plugins against the skills' `family:` frontmatter — version parity across every ecosystem manifest, Agent Plugins 1.0 conformance, and one well-formed per-family plugin whose `skills/` symlinks match the family exactly. `--fix` regenerates them. | | [`check-placeholders.sh`](check-placeholders.sh) | Fails the build on hardcoded project references in skill and tool docs, which must use `` / `` / `` / `` instead. Carries both casings and matches spaced variants. | -| [`check-workspace-members.py`](check-workspace-members.py) | Catches a new `tools//pyproject.toml` that was never added to `[tool.uv.workspace] members` — an omission that silently drops the tool from both the pre-commit hooks and the CI pytest matrix. | +| [`check-workspace-members.py`](check-workspace-members.py) | Catches a new `tools//pyproject.toml` that was never added to `[tool.uv.workspace] members` — an omission that silently drops the tool from both the pre-commit hooks and the CI pytest matrix. Also verifies each member's tests actually run: both surfaces key off `[tool.pytest.ini_options]`, so a project can carry a full `tests/` directory and be executed by nothing. Reports tests-without-config, config-without-tests, and neither; `[tool.magpie.checks] skip = ["pytest"]` is the declared exemption. | | [`run-workspace-check.sh`](run-workspace-check.sh) | Runs one static-check or test command across every workspace member, auto-discovering which members a given check applies to. The four `workspace-*` hooks call it, so adding a tool needs no edit to the pre-commit config. | | [`add-license-headers.py`](add-license-headers.py) | Stamps the SPDX licence header into Markdown files that lack one. | | [`agent-pre-commit.sh`](agent-pre-commit.sh) | Wrapper for `prek run --all-files`, for agent use. An agent running `pytest` / `ruff` / `mypy` individually still misses the rest of the CI gate (doctoc, markdownlint, typos, the checks above); this runs what CI runs. | diff --git a/tools/dev/check-workspace-members.py b/tools/dev/check-workspace-members.py index e77e0965d..fef9eb95c 100755 --- a/tools/dev/check-workspace-members.py +++ b/tools/dev/check-workspace-members.py @@ -30,6 +30,27 @@ the two sets agree; 1 otherwise, with a diff explaining what to add or remove. +It also checks that every declared member's tests actually run. The CI +pytest matrix and the workspace pytest sweep are both driven by the +presence of a `[tool.pytest.ini_options]` section, so a project can carry +a full `tests/` directory and still never be executed by anything — the +job simply is not emitted, and nobody sees a failure because nobody sees +a run. Three ways that goes wrong, all reported here: + + * tests on disk, no `[tool.pytest.ini_options]` — the tests never run; + * `[tool.pytest.ini_options]`, no tests on disk — the CI job runs and + collects nothing, so a green tick proves nothing; + * neither — the project has no tests at all. + +A project that genuinely should not be tested declares it, rather than +being silently absent: + + [tool.magpie.checks] + skip = ["pytest"] + +which is the same opt-out `tools/dev/run-workspace-check.sh` and the CI +matrix already honour. + Scope: only `tools/*/pyproject.toml` and `tools/*/*/pyproject.toml` (maxdepth-3). The root `pyproject.toml` and any deeper nested pyprojects (e.g. inside `tests/` fixtures or vendored deps) are @@ -97,6 +118,52 @@ def read_workspace_members() -> set[str]: sys.exit(2) +def has_test_files(member: Path) -> bool: + """True when the member carries at least one pytest-discoverable file. + + Scans for both naming conventions and ignores anything inside a virtual + environment or an installed package, which would otherwise make a member + with no tests of its own look tested. + """ + for pattern in ("test_*.py", "*_test.py"): + for found in member.rglob(pattern): + parts = set(found.parts) + if ".venv" in parts or "site-packages" in parts or "node_modules" in parts: + continue + return True + return False + + +def check_tests_run(member_dirs: list[Path]) -> list[str]: + """Report members whose tests do not actually execute anywhere.""" + problems: list[str] = [] + for member in member_dirs: + rel = member.relative_to(ROOT) + with (member / "pyproject.toml").open("rb") as f: + tool = tomllib.load(f).get("tool", {}) + if "pytest" in tool.get("magpie", {}).get("checks", {}).get("skip", []): + continue # deliberate, declared opt-out + configured = "ini_options" in tool.get("pytest", {}) + present = has_test_files(member) + if present and not configured: + problems.append( + f"{rel}: has test files but no [tool.pytest.ini_options] — the CI " + f"matrix and the workspace pytest sweep are both driven by that " + f"section, so these tests never run" + ) + elif configured and not present: + problems.append( + f"{rel}: declares [tool.pytest.ini_options] but has no test files — " + f"the CI job runs and collects nothing, so its green tick proves nothing" + ) + elif not present and not configured: + problems.append( + f"{rel}: has no tests. Add them, or declare the exemption with " + f'[tool.magpie.checks] skip = ["pytest"]' + ) + return problems + + def main() -> int: member_dirs = find_member_dirs() declared = read_workspace_members() @@ -104,12 +171,15 @@ def main() -> int: missing = sorted(found_paths - declared) stale = sorted(declared - found_paths) + # Only meaningful for members that are actually declared; an undeclared + # one is already reported above and its test wiring is moot until it is. + untested = check_tests_run([p for p in member_dirs if str(p.relative_to(ROOT)) in declared]) - if not missing and not stale: + if not missing and not stale and not untested: return 0 out = sys.stderr.write - out("error: uv workspace members list drifts from on-disk pyprojects\n") + out("error: workspace members are not wired the way CI assumes\n") out("\n") if missing: out("Found `tools/.../pyproject.toml` that is NOT in `[tool.uv.workspace] members`:\n") @@ -128,6 +198,11 @@ def main() -> int: out(f" - {p!r}\n") out("\n") out("Remove each stale entry from the root pyproject.toml.\n\n") + if untested: + out("Workspace members whose tests do not run:\n") + for problem in untested: + out(f" ! {problem}\n") + out("\n") return 1 diff --git a/tools/dev/tests/test_check_workspace_members.py b/tools/dev/tests/test_check_workspace_members.py new file mode 100644 index 000000000..f8e1685a5 --- /dev/null +++ b/tools/dev/tests/test_check_workspace_members.py @@ -0,0 +1,135 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +"""Tests for the test-coverage half of ``check-workspace-members.py``. + +The failure this guards is quiet by construction: the CI pytest matrix and the +workspace sweep are both driven by the presence of ``[tool.pytest.ini_options]``, +so a project can carry a full ``tests/`` directory and never be executed by +anything. There is no red tick to notice — the job is simply never emitted. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType + +import pytest + +_SCRIPT = Path(__file__).resolve().parents[1] / "check-workspace-members.py" + + +def _load() -> ModuleType: + spec = importlib.util.spec_from_file_location("check_workspace_members", _SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +mod = _load() + + +def _member(root: Path, name: str, *, ini: bool, tests: bool, skip: bool = False) -> Path: + d = root / name + d.mkdir(parents=True) + body = '[project]\nname = "x"\nversion = "0.1.0"\n' + if ini: + body += '\n[tool.pytest.ini_options]\nminversion = "8.0"\n' + if skip: + body += '\n[tool.magpie.checks]\nskip = ["pytest"]\n' + (d / "pyproject.toml").write_text(body, encoding="utf-8") + if tests: + (d / "tests").mkdir() + (d / "tests" / "test_thing.py").write_text("def test_ok():\n assert True\n", encoding="utf-8") + return d + + +@pytest.fixture +def root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setattr(mod, "ROOT", tmp_path) + return tmp_path + + +def test_tests_and_config_present_is_silent(root: Path) -> None: + m = _member(root, "good", ini=True, tests=True) + assert mod.check_tests_run([m]) == [] + + +def test_tests_without_config_are_reported(root: Path) -> None: + """The quiet failure: real tests that nothing ever executes.""" + m = _member(root, "orphaned", ini=False, tests=True) + problems = mod.check_tests_run([m]) + assert len(problems) == 1 + assert "never run" in problems[0] + + +def test_config_without_tests_is_reported(root: Path) -> None: + """The inverse: a green CI job that collected nothing.""" + m = _member(root, "hollow", ini=True, tests=False) + problems = mod.check_tests_run([m]) + assert len(problems) == 1 + assert "collects nothing" in problems[0] + + +def test_neither_is_reported(root: Path) -> None: + m = _member(root, "untested", ini=False, tests=False) + problems = mod.check_tests_run([m]) + assert len(problems) == 1 + assert "has no tests" in problems[0] + + +def test_declared_opt_out_is_honoured(root: Path) -> None: + """A project that genuinely should not be tested says so, rather than + being silently absent from the matrix.""" + m = _member(root, "config-carrier", ini=False, tests=False, skip=True) + assert mod.check_tests_run([m]) == [] + + +def test_opt_out_also_silences_the_other_two_shapes(root: Path) -> None: + assert mod.check_tests_run([_member(root, "a", ini=False, tests=True, skip=True)]) == [] + assert mod.check_tests_run([_member(root, "b", ini=True, tests=False, skip=True)]) == [] + + +def test_alternative_test_naming_is_discovered(root: Path) -> None: + m = _member(root, "suffixed", ini=True, tests=False) + (m / "tests").mkdir() + (m / "tests" / "thing_test.py").write_text("def test_ok():\n assert True\n", encoding="utf-8") + assert mod.check_tests_run([m]) == [] + + +def test_vendored_tests_do_not_count_as_the_members_own(root: Path) -> None: + """A test file inside an installed package or a virtualenv would otherwise + make an untested member look tested — the exact false negative that makes + this check worthless.""" + m = _member(root, "vendored", ini=True, tests=False) + vendored = m / ".venv" / "lib" / "python3.11" / "site-packages" / "dep" / "tests" + vendored.mkdir(parents=True) + (vendored / "test_dep.py").write_text("def test_ok():\n assert True\n", encoding="utf-8") + problems = mod.check_tests_run([m]) + assert len(problems) == 1 + assert "collects nothing" in problems[0] + + +def test_every_member_is_reported_not_just_the_first(root: Path) -> None: + members = [ + _member(root, "one", ini=False, tests=True), + _member(root, "two", ini=True, tests=False), + _member(root, "three", ini=False, tests=False), + ] + assert len(mod.check_tests_run(members)) == 3