Skip to content

ci(dev): deterministic gates for doc/tree drift, test coverage, and a shared toolchain - #1164

Merged
potiuk merged 4 commits into
apache:mainfrom
potiuk:doc-sync-hooks
Sep 8, 2026
Merged

ci(dev): deterministic gates for doc/tree drift, test coverage, and a shared toolchain#1164
potiuk merged 4 commits into
apache:mainfrom
potiuk:doc-sync-hooks

Conversation

@potiuk

@potiuk potiuk commented Sep 8, 2026

Copy link
Copy Markdown
Member

Four commits, each a step further into the same problem: things that should
track each other, don't, and nothing notices.

1. check-doc-sync — deterministic gates for doc/tree drift

#1159, #1162 and #1163 fixed documentation that had drifted from the tree. Every
instance was found by reading. This makes five of them mechanical, as one prek
hook:

Check Guards What it would have caught
Spec index every tools/spec-loop/specs/*.md listed in both overview.md and README.md 10 specs absent from both, one added four PRs earlier
Family counts README.md family table vs live family: frontmatter security 1215, repo-health 67
Mode counts docs/modes.md Modes at a glance vs live mode: frontmatter Triage 3435, Drafting 911
Catalogue totals bare "N skills" in docs/setup/marketplaces.md 7174, three occurrences
Dev-script docs every script in tools/dev/ named in its README 3 undocumented scripts, on first run

The validator's existing modes-doc rule looks like it covers the mode counts.
It does not — _read_mode_table() parses the mode and status columns only and
never reads the count, so those numbers had no guard at all.

2. tools/dev under the workspace checks, with tests

The scripts in tools/dev guard everyone else's changes and 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.

20 tests for check-doc-sync.py, written around the red case — each builds a
miniature repo in tmp_path, asserts the drift is reported with both numbers,
then that the corrected tree is silent. 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 index still counts as missing, a README row naming 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.

Bringing the tree under the checks surfaced four lint findings in pre-existing
scripts. Three are fixed; 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. Every script was re-run afterwards to
confirm behaviour is unchanged.

3. Shared dev toolchain via magpie-dev

Every member repeated the same three pins 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
was in the environment. The duplication was the defect; 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. Extras are preserved
(tools/gmail/oauth-draft keeps mcp). Bumping a version is now one edit.

Each member's environment stays self-contained — the checks still run
uv run --directory <member> --project ., so nothing leaks in from the root.
Only the declaration is shared.

4. Verify every member's tests actually run

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 — which already owns "the members list decides which
projects get hooks and CI matrix entries" — now also reports three shapes:

Shape Consequence
tests, no [tool.pytest.ini_options] the tests never run
[tool.pytest.ini_options], no tests the CI job runs and collects nothing, so its green tick proves nothing
neither no tests at all

[tool.magpie.checks] skip = ["pytest"] is the declared exemption — the same
opt-out the sweep and the matrix already honour, so a project that should not be
tested says so in its own 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 — which is the best moment to add it.

Ten tests cover it, including the one 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.

Verification

  • prek run --all-files passes (exit 0)
  • Whole battery 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
  • Each of the five doc-sync checks verified to fail on its specific defect
    and pass once restored
  • Each of the three tests-actually-run shapes verified by hand against a real
    member, and the declared opt-out confirmed to silence all three
  • Every tools/dev script re-run after reformatting to confirm unchanged
    behaviour
  • Check discovery confirmed section-based ([tool.ruff], [tool.mypy],
    [tool.pytest.ini_options]), not dev-group-based — so giving a member more
    tools does not silently enrol it in more checks

Notes for reviewers

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, not a no-op refactor.

Deliberate limits on the totals check: it reads an allowlist rather than
sweeping the repo. Plenty of docs legitimately count a subset ("Nine skills cover
the staged path"), and a greedy scan would flag those forever.

.last-sync staleness is not a hook. It would fail nearly every PR, so it
would be noise rather than a gate; the human rule in AGENTS.md is its home.

🤖 Generated with Claude Code

https://claude.ai/code/session_01So3JRGXrbqSGrohtZuHWKg

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 apache#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)
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)
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 <member> --project . python -m <tool>`, 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)
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)
@potiuk potiuk changed the title ci(dev): deterministic doc/tree gates, tests for tools/dev, shared toolchain ci(dev): deterministic gates for doc/tree drift, test coverage, and a shared toolchain Sep 8, 2026
@potiuk
potiuk merged commit 6e93c2d into apache:main Sep 8, 2026
43 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant