Files
Edward Cheng-I Wu e8bf858be7 lint: skill-inventory parity across top-level dirs, skills/ symlinks, CLAUDE.md table, and marketplace.json (#809) (#810)
Closes #809.

check_skill_inventory_parity.py: on-disk <name>/SKILL.md dirs are the
authority; set-equality against skills/ symlinks, the CLAUDE.md Skills
Overview table (exact unfenced H2, GFM header+separator, first-cell
backticked names), and marketplace.json plugins[].skills; "N skills"
count claims on plugin.json / marketplace.json / MODE_REGISTRY.md.
check_spec_consistency.py derives its skill paths from disk at call
time; table-row grammar single-sourced in _skill_lint. 60 mutation
tests; wired into spec-consistency.yml and the pytest manifest.

Dual-track pre-ship: /simplify (4 findings applied), /security-review
(none), codex gpt-5.6-sol xhigh 7 rounds (12 findings fixed, round 7
clean). CHANGELOG also covers #805.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014U5nvjKy84twtB4VYsrex1
2026-08-31 02:02:28 +08:00

825 lines
36 KiB
Python

#!/usr/bin/env python3
"""Lint: version labels stay aligned across .claude/CLAUDE.md, SKILL.md frontmatter, and CHANGELOG.md.
Invariants enforced:
1. Every skill listed in .claude/CLAUDE.md Skills Overview table has a version
equal to its own SKILL.md metadata.version.
2. .claude/CLAUDE.md "**Suite version**: X.Y.Z" equals the most recent
"## [X.Y.Z]" entry in CHANGELOG.md.
3. academic-pipeline version in the table equals the suite version (pipeline
= orchestrator, by convention tracks the suite release).
4. The plugin manifests (.claude-plugin/plugin.json "version" and
.claude-plugin/marketplace.json plugins[].version) equal the suite version.
These are the repo's OUTWARD-FACING package metadata — a user updating the
plugin sees them — and were the one surface a v3.10.0 release-doc pass missed
because no lint covered them (marketplace had silently sat at 3.7.0).
5. The README.md shields.io version badge (`badge/version-vX.Y.Z`) equals the
suite version — the most outward-facing surface of all.
6. No docs/*.md cites a `vX.Y.Z` ABOVE the suite version (a forward/unknown
reference misleads readers). Equal-to-suite is allowed.
7. Every version-bearing H2 heading in docs/<name>.md has a matching
version-bearing H2 (same version) in docs/<name>.zh-TW.md and vice versa.
Plain headings may differ — only version TAGS must stay in lockstep.
8. The plugin.json description's agent-count claim (when present; both the
legacy "N-agent" and the #753 "N prompt roles" spellings) equals the
number of unique *_agent.md files in the tree (#414: the advertised
number had silently drifted from the tree). The plugin-root agents/
mirror dir is excluded from the count — real byte-identical copies of
deep-research agents since #413 (symlinks before that), pinned as pure
aliases by check_agents_mirror_sync.py, never a source of new agents.
9. The latest CHANGELOG entry's body is >= 100 characters (#487) — a bare
heading or one-line stub is not release notes. Only the LATEST entry is
gated; historical entries may be terse.
10. .claude/CLAUDE.md "Last Updated" lies within ±7 days of the latest
CHANGELOG entry's date (#487). The CHANGELOG date is the baseline (not
"today") so re-running the lint later cannot flip the result.
11. The newest "## vX.Y… Key Additions" heading in .claude/CLAUDE.md matches
the suite version (#487), compared at the heading's own precision —
`## v3.14 Key Additions` matches suite 3.14.0.
12. The citation surfaces track the release (#754): CITATION.cff `version`
equals the suite version and its `date-released` lies within ±7 days of
the latest CHANGELOG entry's date; every `(Version X.Y.Z)` token in
POSITIONING.md equals the suite version.
Tag gate (#487): `--tag <ref>` additionally requires the given git tag
(leading `v` optional) to equal the suite version — the one comparison
nothing else performs at tag time. Wired via tag-version-match.yml.
Runs from repo root by default; `--path` lets tests point at a fake tree.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import Counter
from datetime import date, datetime
from pathlib import Path
import yaml
from _skill_lint import SKILLS_TABLE_ROW_FULL, parse_frontmatter, FrontmatterError
# Broad token captures: anything that looks like an identifier inside the
# expected position. The strict validator below then decides whether the raw
# token is a canonical semver. Using the regex as a filter (the pre-#169
# pattern) silently dropped invalid tokens and hid the very drift this lint
# is meant to surface; see dual-track review on PR for that class of bug.
TABLE_TOKEN_RE = re.compile(SKILLS_TABLE_ROW_FULL, re.MULTILINE)
SUITE_TOKEN_RE = re.compile(
r"^\s*-\s*\*\*Suite version\*\*:\s*([A-Za-z0-9.\-_+]+)", re.MULTILINE
)
CHANGELOG_TOKEN_RE = re.compile(r"^##\s*\[([A-Za-z0-9.\-_+]+)\]", re.MULTILINE)
SEMVER_STRICT_RE = re.compile(r"^\d+(?:\.\d+){2,3}$") # exactly 3 or 4 segments (N.N.N or N.N.N.N)
# Invariant 5: shields.io version badge label, e.g. `badge/version-v3.11.1-blue`.
# `_VSEG` = 3-or-4 numeric segments. The trailing `(?!\.?\d)` stops a 5th
# segment (`3.11.1.2.3` would otherwise yield `3.11.1.2`); the label's own
# `-<color>` separator already bounds the token on the right, so no broader
# suffix lookahead is needed here. Keyed off the label, not the release URL.
_VSEG = r"(\d+(?:\.\d+){2,3})"
README_BADGE_RE = re.compile(r"badge/version-v" + _VSEG + r"(?!\.?\d)", re.IGNORECASE)
# Invariant 6 only: a CANONICAL `vX.Y.Z(.W)` token in docs prose. The leading
# `(?<![\w.])` blocks mid-identifier matches; the trailing `(?![\d.\-+A-Za-z])`
# drops prerelease/build/5-segment tokens ENTIRELY (`v3.12.0-alpha`,
# `v3.11.1.2.3`) rather than partial-matching them (review finding). `_VSEG` is
# 3-or-4 segments, so a 2-segment `v3.10` never matches and is silently
# ignored (inv 6 gates only on full release tokens; a partial like `v3.10`
# is not a version this lint adjudicates). Invariant 7 uses H2_VERSION_RE.
DOCS_VERSION_RE = re.compile(r"(?<![\w.])v" + _VSEG + r"(?![\d.\-+A-Za-z])")
# Invariant 7: version tag inside an H2 heading, e.g. `## Corpus (v3.6.4+)`.
# Same canonical-token shape, but a trailing `+` IS allowed (the repo's
# "vX.Y.Z and later" heading convention, e.g. `(v3.6.4+)`); only `-`/letters/
# extra `.N` segments disqualify.
H2_RE = re.compile(r"^##\s+(.*?)\s*$", re.MULTILINE)
H2_VERSION_RE = re.compile(r"(?<![\w.])v" + _VSEG + r"(?![\d.\-A-Za-z])")
NON_VERSION_CHANGELOG_TOKENS = frozenset({"Unreleased"})
# Invariant 8: the outward-facing agent-count claim. Two licensed spellings:
# the legacy "38-agent" form and the #753 evidence-aligned "39 prompt roles"
# form (exact plural). Both bind the stated number to the tree's *_agent.md
# count, so a reworded claim surface cannot detach the number from the
# inventory. Trailing \b keeps near-miss spellings ("39-agentic",
# "39 prompt role") from counting as bound — check_distribution_surface_claims
# D4 then flags them as unbindable.
AGENT_CLAIM_RE = re.compile(r"(\d+)(?:-agent\b|\s+prompt\s+roles\b)")
# Invariant 9: minimum body length (chars, after strip) for the latest entry.
CHANGELOG_BODY_MIN_CHARS = 100
# Invariant 10: the release date on a `## [X.Y.Z] - YYYY-MM-DD` heading (ARS
# headings may append an em-dash summary after the date) and the
# `- **Last Updated**: YYYY-MM-DD` line in .claude/CLAUDE.md. The trailing
# `(?!\d)` blocks prefix-capturing a longer run (`2026-04-222` must not read
# as `2026-04-22`, codex re-review P3); the day field then fails the strict
# ISO gate downstream.
CHANGELOG_DATE_RE = re.compile(r"\]\s*-\s*(\d{4}-\d{2}-\d{2})(?!\d)")
LAST_UPDATED_RE = re.compile(
r"^\s*-\s*\*\*Last Updated\*\*:\s*(\S+)", re.MULTILINE
)
LAST_UPDATED_MAX_DAYS = 7
# Invariant 11: a version-tagged Key Additions H2, e.g. `## v3.14 Key
# Additions (...)`. 2-4 segments — headings cite major.minor or a full patch.
KEY_ADDITIONS_RE = re.compile(
r"^##\s+v(\d+(?:\.\d+){1,3})\s+Key Additions", re.MULTILINE
)
# A release-entry heading (`## [X.Y.Z]`). Applied per-line by _next_entry_offset
# (fence-aware), so no MULTILINE — the latest entry's body ends at the next
# such heading OUTSIDE a code fence, never at a `## ` inside one (codex P2-1).
# `[Unreleased]` never appears below the latest release entry, so keying on the
# `[` bracket is sufficient.
NEXT_ENTRY_RE = re.compile(r"##\s+\[")
# Strict YYYY-MM-DD guard: date.fromisoformat() accepts compact 20260422 and
# ISO week dates, so a shape check gates before parsing (codex P2-2).
ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
# Invariant 12: the POSITIONING.md citation-prose token, e.g. `(Version 3.20.1)`.
# Captures the WHOLE parenthesized payload, then strip + strict-validate (the
# post-#169 idiom above): a non-canonical spelling (`v3.20.1`, `3.20.1-rc1`,
# bare `3.20`) or a malformed clause (`3.14.0 ` with a stray space, `3.14.0
# draft`) surfaces as an error instead of the clause dropping out of a
# narrower character class and silently passing (codex round-3 P2).
# CITATION.cff has no regex — it is YAML and is parsed as YAML.
POSITIONING_VERSION_RE = re.compile(r"\(Version\s+([^)]*)\)")
PIPELINE_SKILL_NAME = "academic-pipeline"
def _parse_iso_date(raw: str) -> date | None:
"""Strict YYYY-MM-DD → date, else None (bad shape OR impossible date like
2026-02-30). Never raises — a release lint reports drift, never crashes."""
if not ISO_DATE_RE.match(raw):
return None
try:
return date.fromisoformat(raw)
except ValueError:
return None
def _version_tuple(token: str) -> tuple[int, ...]:
"""Parse a canonical N.N.N(.N) into an int tuple for ordering."""
return tuple(int(p) for p in token.split("."))
def _version_le(a: str, b: str) -> bool:
"""a <= b by segment-wise numeric compare (pads shorter with zeros)."""
ta, tb = _version_tuple(a), _version_tuple(b)
n = max(len(ta), len(tb))
return ta + (0,) * (n - len(ta)) <= tb + (0,) * (n - len(tb))
def _is_strict_semver(token: str) -> bool:
return bool(SEMVER_STRICT_RE.match(token))
def _parse_table_versions(
claude_md_text: str,
) -> tuple[dict[str, str], list[tuple[str, str]]]:
"""Return (valid_versions, invalid_rows) from the Skills Overview table.
`valid_versions` maps skill_name -> version for rows whose v-token is a
canonical N.N.N(.N)+ string. `invalid_rows` collects (skill_name, raw_token)
for rows where the v-token is present but not a canonical version; the
caller surfaces these as errors so a malformed table row does not silently
drop out of downstream invariants.
"""
valid: dict[str, str] = {}
invalid: list[tuple[str, str]] = []
for skill, raw in TABLE_TOKEN_RE.findall(claude_md_text):
if _is_strict_semver(raw):
valid[skill] = raw
else:
invalid.append((skill, raw))
return valid, invalid
def _parse_suite_version(claude_md_text: str) -> tuple[str | None, str | None]:
"""Return (valid_version, invalid_raw_token).
Exactly one of the two is non-None when a Suite version line is present.
Both are None when the line is missing entirely.
"""
m = SUITE_TOKEN_RE.search(claude_md_text)
if m is None:
return None, None
raw = m.group(1)
if _is_strict_semver(raw):
return raw, None
return None, raw
def _parse_changelog_latest(
changelog_text: str,
) -> tuple[str | None, str | None, str | None, str]:
"""Return (valid_latest, invalid_raw_token, latest_date, latest_body).
Walks `## [TOKEN]` headings in document order, skipping pseudo-entries
like `[Unreleased]`. The first remaining heading is the latest release.
If that heading's token is not a canonical version, it is returned as
`invalid_raw_token` so the caller flags it instead of silently falling
through to a predecessor and hiding the malformed release entry.
For a valid latest entry, `latest_date` is the heading's YYYY-MM-DD (None
when absent — invariant 10 flags that) and `latest_body` is the text up to
the next release heading (invariant 9); `### ` sub-headings and any `## `
line inside a fenced code block stay INSIDE the body.
"""
for m in CHANGELOG_TOKEN_RE.finditer(changelog_text):
raw = m.group(1)
if raw in NON_VERSION_CHANGELOG_TOKENS:
continue
if not _is_strict_semver(raw):
return None, raw, None, ""
line_end = changelog_text.find("\n", m.end())
if line_end == -1:
line_end = len(changelog_text)
heading_line = changelog_text[m.start():line_end]
date_m = CHANGELOG_DATE_RE.search(heading_line)
body = changelog_text[line_end : _next_entry_offset(changelog_text, line_end)]
return raw, None, date_m.group(1) if date_m else None, body
return None, None, None, ""
def _next_entry_offset(text: str, start: int) -> int:
"""Offset of the next release heading (`## [`) at or after `start`, skipping
any that fall inside a ``` fenced code block. A code sample in the release
notes can contain a line that looks exactly like a release heading (even
`## [example] - 2020-01-01`), which must NOT truncate the entry body
(codex P2-1 + re-review). Returns len(text) when none remains."""
in_fence = False
i = start
n = len(text)
while i < n:
line_end = text.find("\n", i)
if line_end == -1:
line_end = n
line = text[i:line_end]
stripped = line.lstrip()
if stripped.startswith("```") or stripped.startswith("~~~"):
in_fence = not in_fence
elif not in_fence and NEXT_ENTRY_RE.match(line):
return i
i = line_end + 1
return n
def check(root: Path, tag: str | None = None) -> list[str]:
errors: list[str] = []
claude_md = root / ".claude" / "CLAUDE.md"
if not claude_md.is_file():
errors.append(f"{claude_md}: not found")
return errors
claude_text = claude_md.read_text(encoding="utf-8")
table_versions, invalid_table_rows = _parse_table_versions(claude_text)
if not table_versions and not invalid_table_rows:
errors.append(
f"{claude_md}: Skills Overview table has no parseable "
"`<skill>` vX.Y.Z rows"
)
for skill, raw in invalid_table_rows:
errors.append(
f"{claude_md}: table row {skill!r} has invalid version "
f"token v{raw!r} (expected canonical N.N.N or N.N.N.N)"
)
suite_version, invalid_suite_token = _parse_suite_version(claude_text)
if suite_version is None and invalid_suite_token is None:
errors.append(
f"{claude_md}: missing '**Suite version**: X.Y.Z' line"
)
elif invalid_suite_token is not None:
errors.append(
f"{claude_md}: Suite version token {invalid_suite_token!r} is "
"not a canonical N.N.N or N.N.N.N version"
)
changelog = root / "CHANGELOG.md"
latest_date: str | None = None
latest: str | None = None
if not changelog.is_file():
errors.append(f"{changelog}: not found")
else:
latest, invalid_latest, latest_date, latest_body = (
_parse_changelog_latest(changelog.read_text(encoding="utf-8"))
)
if latest is None and invalid_latest is None:
errors.append(f"{changelog}: no '## [X.Y.Z]' entry found")
elif invalid_latest is not None:
errors.append(
f"{changelog}: latest entry token {invalid_latest!r} is "
"not a canonical N.N.N or N.N.N.N version"
)
elif suite_version is not None and latest != suite_version:
errors.append(
f"{claude_md}: Suite version {suite_version!r} does not match "
f"CHANGELOG latest entry {latest!r}"
)
# Invariant 9: the latest entry must carry real release notes.
if latest is not None:
body_len = len(latest_body.strip())
if body_len < CHANGELOG_BODY_MIN_CHARS:
errors.append(
f"{changelog}: latest entry [{latest}] body is {body_len} "
f"chars — release notes must be >= "
f"{CHANGELOG_BODY_MIN_CHARS} chars"
)
# Invariant 10: .claude/CLAUDE.md "Last Updated" within ±7 days of the
# latest CHANGELOG entry's date. Skipped while the CHANGELOG itself is
# missing/malformed — those already errored above.
if latest is not None:
errors.extend(
_check_last_updated_freshness(
claude_md, claude_text, changelog, latest_date
)
)
for skill_name, table_version in sorted(table_versions.items()):
skill_md = root / skill_name / "SKILL.md"
if not skill_md.is_file():
errors.append(
f"{claude_md}: table lists {skill_name!r} v{table_version} "
f"but {skill_md} does not exist"
)
continue
try:
fm = parse_frontmatter(skill_md)
except FrontmatterError as exc:
errors.append(str(exc))
continue
if fm is None:
errors.append(f"{skill_md}: missing YAML frontmatter")
continue
metadata = fm.get("metadata") or {}
declared = metadata.get("version")
if declared is None:
errors.append(f"{skill_md}: metadata.version is missing")
continue
declared_str = str(declared)
if declared_str != table_version:
errors.append(
f"{claude_md}: {skill_name!r} listed as v{table_version} but "
f"{skill_md} metadata.version is {declared_str!r}"
)
if suite_version is not None:
pipeline_in_table = table_versions.get(PIPELINE_SKILL_NAME)
if pipeline_in_table is not None and pipeline_in_table != suite_version:
errors.append(
f"{claude_md}: {PIPELINE_SKILL_NAME} listed as "
f"v{pipeline_in_table} but suite version is {suite_version!r} "
"(pipeline tracks the suite release)"
)
# Invariant 4: plugin manifests track the suite version (outward-facing
# package metadata). Only checked when a suite version is known.
if suite_version is not None:
errors.extend(_check_plugin_manifests(root, suite_version))
# Invariant 5: README version badge tracks the suite version.
errors.extend(_check_readme_badge(root, suite_version))
# Invariant 6: docs/ must not cite a version above the suite version.
errors.extend(_check_docs_versions(root, suite_version))
# Invariant 11: newest Key Additions heading matches the suite version.
errors.extend(_check_key_additions(claude_md, claude_text, suite_version))
# Invariant 12: citation surfaces (CITATION.cff / POSITIONING.md)
# track the suite version + release date.
errors.extend(_check_citation_surfaces(root, suite_version, latest_date))
# Tag gate: the pushed tag (when given) must equal the suite version. This
# runs OUTSIDE the `suite_version is not None` block on purpose: `--tag`
# promises the tag is verified at tag time, so a missing/malformed suite
# version must surface as "tag uncheckable" (a hard error naming the tag),
# never a silent pass — otherwise a garbage tag co-occurring with a broken
# CLAUDE.md would slip through the one gate meant to catch it.
if tag is not None:
tag_version = tag[1:] if tag.startswith("v") else tag
if suite_version is None:
errors.append(
f"tag {tag!r} cannot be verified: .claude/CLAUDE.md has no "
"usable Suite version to compare against (fix the suite "
"version before tagging)"
)
elif tag_version != suite_version:
errors.append(
f"tag {tag!r} does not match suite version "
f"{suite_version!r} (CHANGELOG latest entry / "
".claude/CLAUDE.md must be promoted before tagging)"
)
# Invariant 7: en<->zh-TW version-bearing-heading parity (independent of
# suite version; pairs each docs/*.md with its docs/*.zh-TW.md sibling).
errors.extend(_check_zhtw_heading_parity(root))
# Invariant 8: plugin.json "N-agent" description claim equals the tree's
# unique *_agent.md count (independent of suite version).
errors.extend(_check_agent_count_claim(root))
return errors
def _check_plugin_manifests(root: Path, suite_version: str) -> list[str]:
"""Invariant 4: .claude-plugin/plugin.json "version" and every
.claude-plugin/marketplace.json plugins[].version equal the suite version.
Missing files / malformed JSON / missing version keys are surfaced, never
crash (a release lint must report drift, not blow up on it)."""
errors: list[str] = []
plugin_json = root / ".claude-plugin" / "plugin.json"
if not plugin_json.is_file():
errors.append(f"{plugin_json}: not found")
else:
try:
data = json.loads(plugin_json.read_text(encoding="utf-8"))
except (json.JSONDecodeError, ValueError) as exc:
errors.append(f"{plugin_json}: invalid JSON ({exc})")
else:
v = data.get("version")
if v is None:
errors.append(f"{plugin_json}: missing 'version' key")
elif str(v) != suite_version:
errors.append(
f"{plugin_json}: version {str(v)!r} does not match suite "
f"version {suite_version!r}"
)
marketplace = root / ".claude-plugin" / "marketplace.json"
if not marketplace.is_file():
errors.append(f"{marketplace}: not found")
else:
try:
data = json.loads(marketplace.read_text(encoding="utf-8"))
except (json.JSONDecodeError, ValueError) as exc:
errors.append(f"{marketplace}: invalid JSON ({exc})")
else:
plugins = data.get("plugins")
if not isinstance(plugins, list) or not plugins:
errors.append(f"{marketplace}: 'plugins' is missing or empty")
else:
for i, entry in enumerate(plugins):
v = (entry or {}).get("version") if isinstance(entry, dict) else None
if v is None:
errors.append(
f"{marketplace}: plugins[{i}] missing 'version' key"
)
elif str(v) != suite_version:
errors.append(
f"{marketplace}: plugins[{i}] version {str(v)!r} does "
f"not match suite version {suite_version!r}"
)
return errors
def _check_readme_badge(root: Path, suite_version: str) -> list[str]:
"""Invariant 5: README.md shields.io version badge equals the suite version.
The badge is outward-facing — the first thing a reader sees. A v3.10.0
release-doc pass can miss it the same way it missed the marketplace
manifest (invariant 4). Missing file / no badge surfaced, never crash."""
errors: list[str] = []
readme = root / "README.md"
if not readme.is_file():
errors.append(f"{readme}: not found")
return errors
text = readme.read_text(encoding="utf-8")
matches = README_BADGE_RE.findall(text)
if not matches:
errors.append(f"{readme}: no `badge/version-vX.Y.Z` version badge found")
return errors
for raw in matches:
if raw != suite_version:
errors.append(
f"{readme}: version badge v{raw} does not match suite "
f"version {suite_version!r}"
)
return errors
def _check_docs_versions(root: Path, suite_version: str) -> list[str]:
"""Invariant 6: no docs/*.md cites a version ABOVE the suite version.
A doc that references a not-yet-released (or deleted) version is a forward
reference that misleads readers. Equal-to-suite is fine (<=). Non-canonical
tokens are surfaced, not silently dropped."""
errors: list[str] = []
docs = root / "docs"
if not docs.is_dir():
return errors # docs/ optional; absence is not drift
for md in sorted(docs.rglob("*.md")):
# docs/superpowers/ holds skill working files (specs/plans) that
# deliberately plan the NEXT release — forward references are their
# job, not a published-doc drift. Invariant 6 gates published docs.
if "superpowers" in md.relative_to(docs).parts:
continue
text = md.read_text(encoding="utf-8")
for raw in DOCS_VERSION_RE.findall(text):
if not _is_strict_semver(raw):
continue # 2-segment etc. — not a release token we gate on
if not _version_le(raw, suite_version):
errors.append(
f"{md}: cites v{raw} which is ABOVE suite version "
f"{suite_version!r} (forward/unknown reference)"
)
return errors
def _version_bearing_headings(text: str) -> Counter:
"""Count version tokens across H2s that carry a vX.Y.Z tag.
Returns a multiset {version: count}. Comparison is by version token, not
heading text, so an en heading and its zh-TW translation match despite
different wording. A MULTISET (not a set/dict) is load-bearing: two H2s in
one doc tagged the same version count as two, so a translator dropping one
of a duplicated pair surfaces as a count mismatch (review finding — a
{version: heading} dict silently collapsed duplicates and passed)."""
out: Counter = Counter()
for h in H2_RE.findall(text):
m = H2_VERSION_RE.search(h)
if m:
out[m.group(1)] += 1
return out
def _check_zhtw_heading_parity(root: Path) -> list[str]:
"""Invariant 7: every version-bearing H2 in docs/<name>.md has a matching
version-bearing H2 in docs/<name>.zh-TW.md (same version), and vice versa.
Plain (no-version) headings may differ freely — translation asymmetry is
allowed; only version TAGS must stay in lockstep. Compares by version-token
MULTISET, so wording differences don't false-flag but a dropped/added/
drifted version tag (including one of a same-version pair) does."""
errors: list[str] = []
docs = root / "docs"
if not docs.is_dir():
return errors
for zh in sorted(docs.rglob("*.zh-TW.md")):
en = zh.with_name(zh.name.replace(".zh-TW.md", ".md"))
if not en.is_file():
errors.append(f"{zh}: no English sibling {en.name}")
continue
en_vers = _version_bearing_headings(en.read_text(encoding="utf-8"))
zh_vers = _version_bearing_headings(zh.read_text(encoding="utf-8"))
# Counter subtraction keeps only positive surpluses; doing it both ways
# surfaces under- and over-translation, and a 2-vs-1 same-version
# mismatch shows as a residual count of 1.
for v, n in sorted((en_vers - zh_vers).items()):
errors.append(
f"{zh.name}: missing {n} version-bearing heading(s) for v{v} "
f"present in {en.name}"
)
for v, n in sorted((zh_vers - en_vers).items()):
errors.append(
f"{zh.name}: has {n} heading(s) citing v{v} not present in "
f"{en.name} — version drift"
)
return errors
def _check_last_updated_freshness(
claude_md: Path,
claude_text: str,
changelog: Path,
latest_date: str | None,
) -> list[str]:
"""Invariant 10: the "Last Updated" date in .claude/CLAUDE.md lies within
±7 days of the latest CHANGELOG entry's date. The CHANGELOG date is the
baseline — never "today" — so the same commit checks identically whenever
the lint re-runs."""
m = LAST_UPDATED_RE.search(claude_text)
if m is None:
return [
f"{claude_md}: missing '- **Last Updated**: YYYY-MM-DD' line"
]
raw = m.group(1)
last_updated = _parse_iso_date(raw)
if last_updated is None:
return [
f"{claude_md}: Last Updated {raw!r} is not a valid YYYY-MM-DD date"
]
if latest_date is None:
return [
f"{changelog}: latest entry has no '- YYYY-MM-DD' date — cannot "
"check Last Updated freshness against it"
]
changelog_date = _parse_iso_date(latest_date)
if changelog_date is None:
return [
f"{changelog}: latest entry date {latest_date!r} is not a valid "
"YYYY-MM-DD date"
]
delta = abs((last_updated - changelog_date).days)
if delta > LAST_UPDATED_MAX_DAYS:
return [
f"{claude_md}: Last Updated {raw} is {delta} days from the "
f"CHANGELOG latest entry date {latest_date} (must be within "
f"±{LAST_UPDATED_MAX_DAYS} days)"
]
return []
def _check_key_additions(
claude_md: Path, claude_text: str, suite_version: str
) -> list[str]:
"""Invariant 11: the newest version-tagged "Key Additions" H2 in
.claude/CLAUDE.md matches the suite version. Newest = max by version
tuple (the sections are not guaranteed document-ordered). Comparison is
at the heading's own precision, so `## v3.14 Key Additions` matches suite
3.14.0 while a stale `## v3.13 ...` as the maximum fails. Historical
headings below the newest are untouched."""
versions = KEY_ADDITIONS_RE.findall(claude_text)
if not versions:
return [
f"{claude_md}: no '## vX.Y… Key Additions' heading found — the "
"release's Key Additions section is missing"
]
newest = max(versions, key=_version_tuple)
newest_t = _version_tuple(newest)
if _version_tuple(suite_version)[: len(newest_t)] != newest_t:
return [
f"{claude_md}: newest Key Additions heading cites v{newest} but "
f"the suite version is {suite_version!r}"
]
return []
def _check_agent_count_claim(root: Path) -> list[str]:
"""Invariant 8 (#414): when plugin.json's description advertises an
agent count ("N-agent" or, since #753, "N prompt roles"), every such N
must equal the number of unique *_agent.md files in the tree. The
plugin-root agents/ mirror dir is excluded — its files are
byte-identical aliases of deep-research agents (real copies since #413,
symlinks before; check_agents_mirror_sync.py pins the byte-equality), so
counting them would double-count. resolve() additionally dedups any
remaining symlink alias. Missing/malformed manifest or a description
without a count claim is NOT an invariant-8 error — the manifest
problems are invariant 4's to report, and here the claim is optional
(only a stated number must be true; check_distribution_surface_claims
D4 is where the presence of a bindable count fails closed)."""
plugin_json = root / ".claude-plugin" / "plugin.json"
if not plugin_json.is_file():
return []
try:
data = json.loads(plugin_json.read_text(encoding="utf-8"))
except (json.JSONDecodeError, ValueError):
return []
description = data.get("description")
if not isinstance(description, str):
return []
claims = [int(m.group(1)) for m in AGENT_CLAIM_RE.finditer(description)]
if not claims:
return []
actual = len({
p.resolve()
for p in root.rglob("*_agent.md")
if ".git" not in p.parts
and p.relative_to(root).parts[0] != "agents" # plugin-root mirror = aliases (#413)
})
return [
f"{plugin_json}: description claims an agent count of {claimed} "
f"but the tree has {actual} unique *_agent.md files (agents/ "
f"mirror aliases excluded, symlinks deduplicated)"
for claimed in claims
if claimed != actual
]
def _check_citation_surfaces(
root: Path, suite_version: str, latest_date: str | None
) -> list[str]:
"""Invariant 12: citation surfaces track the release (#754: both surfaces
had silently drifted six minor releases behind — the file was added
2026-06-15, after this lint existed, and never entered its coverage).
CITATION.cff is outward-facing release metadata like README.md, so its
ABSENCE is an error — a deleted/renamed file must not silently disable
the invariant (the invariant-4 marketplace-manifest lesson). It is YAML
and is parsed as YAML: a regex scrape would misread legitimate spellings
(`version: "3.20.1"`, trailing comments) as drift. `date-released` is
required — an absent/null field errors rather than silently disabling
the freshness half — and is compared against the latest CHANGELOG
entry's date with the invariant-10 ±LAST_UPDATED_MAX_DAYS window
(window skipped only when the CHANGELOG date is unavailable — that
already errored upstream).
POSITIONING.md is repo-specific prose: absence or a token-free file is a
skip (the token is the claim; no claim, no drift), but a present token
must be canonical and equal to the suite version.
Known limitation (accepted, 2026-08-17 threat-model adjudication):
duplicate YAML keys resolve last-wins, the same semantics every CFF
consumer applies (GitHub's cite widget, Zenodo, cffconvert), so a
duplicate-key file renders the identical citation everywhere —
untidiness, not drift. Guarding it would require a custom
duplicate-rejecting loader whose complexity the harm does not pay for."""
errors: list[str] = []
cff = root / "CITATION.cff"
if not cff.is_file():
errors.append(f"{cff}: not found")
else:
try:
data = yaml.safe_load(cff.read_text(encoding="utf-8"))
except (yaml.YAMLError, ValueError):
# ValueError: PyYAML's timestamp constructor raises it (not
# YAMLError) on impossible dates like 2026-02-30 (codex P2) —
# a lint reports drift, never crashes.
data = None
if not isinstance(data, dict):
errors.append(f"{cff}: not parseable as a YAML mapping")
else:
raw = data.get("version")
if raw is None:
errors.append(f"{cff}: no 'version' key found")
else:
token = str(raw)
if not _is_strict_semver(token):
errors.append(
f"{cff}: version token {token!r} is not a canonical "
"N.N.N or N.N.N.N version"
)
elif token != suite_version:
errors.append(
f"{cff}: version {token!r} does not match suite "
f"version {suite_version!r}"
)
released = data.get("date-released")
if released is None:
# Absent or null must error, not skip — deleting the field
# would otherwise disable the freshness half of the invariant
# (codex round-2 P2; same posture as file absence).
errors.append(f"{cff}: no 'date-released' key found")
elif latest_date is not None:
base = _parse_iso_date(latest_date)
if isinstance(released, datetime):
# CFF requires a bare YYYY-MM-DD. An unquoted timestamp
# parses as datetime — a date SUBCLASS that would
# TypeError against the date baseline below (codex P2) —
# so it routes to the not-strict error, never a crash.
rel = None
elif isinstance(released, date):
rel = released
else:
rel = _parse_iso_date(str(released))
if rel is None:
errors.append(
f"{cff}: date-released {str(released)!r} is not a "
"strict YYYY-MM-DD date"
)
elif (
base is not None
and abs((rel - base).days) > LAST_UPDATED_MAX_DAYS
):
errors.append(
f"{cff}: date-released {rel.isoformat()} is more than "
f"{LAST_UPDATED_MAX_DAYS} days from the latest "
f"CHANGELOG entry date {latest_date} (stale release "
"metadata)"
)
positioning = root / "POSITIONING.md"
if positioning.is_file():
for raw_token in POSITIONING_VERSION_RE.findall(
positioning.read_text(encoding="utf-8")
):
token = raw_token.strip()
if not _is_strict_semver(token):
errors.append(
f"{positioning}: citation prose token {token!r} is not a "
"canonical N.N.N or N.N.N.N version"
)
elif token != suite_version:
errors.append(
f"{positioning}: citation prose cites Version {token} but "
f"suite version is {suite_version!r}"
)
return errors
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--path",
type=Path,
default=Path(__file__).resolve().parent.parent,
)
parser.add_argument(
"--tag",
help="git tag ref (e.g. v3.14.0, leading 'v' optional); must equal "
"the suite version — used by the tag-version-match workflow",
)
args = parser.parse_args()
errors = check(args.path, tag=args.tag)
if errors:
print("Version consistency check failed:")
for err in errors:
print(f"- {err}")
return 1
print("Version consistency check passed.")
return 0
if __name__ == "__main__":
sys.exit(main())