Lock the AUTOCOUNT regression-test pattern as the design log canonical record. Previously declined when surface was 1-metric + 29 tags; now mature enough (4 metrics + 58 tags + 1 same-day drift-catch since landing) to formalize. == Ticket content == 10 sections covering: 1. Why this exists — the 4-drift-day baseline (6cbbf95/14bcb99/5c21e83/30a9488) that motivated mechanization. Five-step walk through justifying each choice (Step 5 last). 2. Format — `<!--AUTOCOUNT:metric:path-->N<!--/AUTOCOUNT-->`. 3. Four supported metrics with examples + skip semantics: `tests`, `fixture-rows`, `db-rows`, `db-where`. 4. Skip-on-absence — operator state (shards, qa.db) absence is a logged skip, not a fail. Smoke verified 2026-05-10 with HOME=/tmp/empty. 5. What NOT to tag — closed-ticket point-in-time snapshots, aggregate floors ("2000+"), historical journey arcs. 6. Install discipline at write time + at refresh time. 7. Future metrics deferred (file-lines, gh-pr-comments-count, module-loc, commit-hash-exists) with the "add a metric" recipe. 8. Empirical baseline at landing (3 test functions, 58 active tagged claims across 8 doc files, harness runtime 2-4s). 9. Scope boundaries — does NOT auto-rewrite, does NOT validate prose quality, does NOT scan docstrings, does NOT lock values, does NOT add deps. 10. References — every landing commit + sister doc. Closed at landing (status quo sincefc5ba502026-05-10 morning; this ticket is retroactive design log per the convention "every ticket flips to `closed · landed in commit <sha>` when the work ships"). == Code-fence parser fix == Adding the ticket itself surfaced an oversight: my AUTOCOUNT examples in §3.3 + §3.4 used literal tag pairs in ``` fenced code blocks. The parser was reading them as live claims and firing on the illustrative `db-rows:002.db:concept_relations` claim (compared 1234 vs live 72576 — both meaningless because it's an example). Fix: `_strip_fenced_code_blocks` substitutes the body of every triple-backtick block with newlines before regex scanning. Line numbers stay aligned (newline-preserving substitution); tags inside fences are skipped because their parent text no longer matches the regex. Both helper functions (`_iter_claims` and the well-formed-tags test) walk through the stripped text, so the strip discipline is consistent across all three test functions. == TICKETS.md index == Added #000044 row marked closed with the 5-commit landing trail. Bumped Next ID 000044 → 000045. == Verification == $ pytest tests/test_doc_counts.py 3 passed in 2.80s $ pytest tests/ -q 2337 passed, 37 skipped in 108.29s Hygiene: fox's in-flight changes to arborist/qa/runner.py + arborist/substrate/prometheus.py + tests/test_prometheus*.py left untouched in working tree.
381 lines
13 KiB
Python
381 lines
13 KiB
Python
"""Regression test for AUTOCOUNT-tagged numeric claims in ``docs/``.
|
|
|
|
The doc-drift pattern recurred four times on 2026-05-10 (commits
|
|
``6cbbf95``, ``14bcb99``, ``5c21e83``, ``30a9488``). Test counts and
|
|
alias counts in docs went stale within hours of fox writing them
|
|
because more work landed in parallel. Each refresh cost a
|
|
Read + Edit + commit cycle.
|
|
|
|
This test makes drift loud at test time instead of catching it
|
|
visually. Tag any numeric claim in ``docs/**/*.md`` with the
|
|
HTML-comment pair below; the test compares the claimed number to
|
|
the live value and fails with a clear diff message.
|
|
|
|
Format::
|
|
|
|
<!--AUTOCOUNT:metric:path-->N<!--/AUTOCOUNT-->
|
|
|
|
Where ``metric`` is one of:
|
|
|
|
- ``tests`` — ``path`` is a pytest-collectible file/directory; the
|
|
live value is ``pytest --collect-only`` count.
|
|
- ``fixture-rows`` — ``path`` is a JSONL fixture; the live value
|
|
is the non-blank-non-comment line count.
|
|
- ``db-rows`` — ``path`` is either ``<table>`` (resolves against
|
|
the default shard ``~/.arborist/shards/000.db``) or
|
|
``<shard>:<table>`` (resolves against ``~/.arborist/shards/<shard>``).
|
|
When the DB or table isn't present (CI or fresh checkout), the
|
|
claim is skipped with a logged note rather than failing — claim-
|
|
pack and alias counts are operator state, not source-state.
|
|
- ``db-where`` — single-column equality predicate. ``path`` syntax::
|
|
|
|
<table>?<column>=<value>
|
|
<shard>:<table>?<column>=<value>
|
|
|
|
Resolves to ``SELECT COUNT(*) FROM <table> WHERE <column> = ?``
|
|
with ``<value>`` bound as a parameter (no SQL injection through
|
|
the value). Same skip-on-absence semantics as ``db-rows``. Use
|
|
for filtered-row claims like ``92 claim-pack records``::
|
|
|
|
<!--AUTOCOUNT:db-where:documents?source_type=claim_pack-->92<!--/AUTOCOUNT-->
|
|
|
|
GitHub and most markdown renderers strip HTML comments, so readers
|
|
see only ``N``. The tags are invisible in rendered output but
|
|
make the claim machine-checkable.
|
|
|
|
Adding a new claim: write the surrounding prose with the number,
|
|
wrap the number in the AUTOCOUNT comment pair, save, run
|
|
``pytest tests/test_doc_counts.py``. If it passes, ship.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
from typing import Callable, Iterator
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).parent.parent
|
|
DOCS_DIR = REPO_ROOT / "docs"
|
|
|
|
AUTOCOUNT_RE = re.compile(
|
|
r"<!--\s*AUTOCOUNT:(?P<metric>[a-z][a-z0-9_-]*)"
|
|
r":(?P<path>[^\s>]+)\s*-->"
|
|
r"(?P<n>\d+)"
|
|
r"<!--\s*/AUTOCOUNT\s*-->"
|
|
)
|
|
|
|
|
|
def _live_test_count_batch(paths: list[str]) -> dict[str, int]:
|
|
"""Run ``pytest --collect-only`` once for every requested path.
|
|
|
|
Returns a {path: count} map. We invoke pytest as a subprocess so
|
|
we don't recursively collect ourselves. Single batch keeps cost
|
|
near constant (~0.5s) regardless of how many docs reference
|
|
test files.
|
|
"""
|
|
if not paths:
|
|
return {}
|
|
out = subprocess.run(
|
|
[sys.executable, "-m", "pytest", "--collect-only", "-q", *paths],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=str(REPO_ROOT),
|
|
check=False,
|
|
)
|
|
counts: dict[str, int] = defaultdict(int)
|
|
for line in out.stdout.splitlines():
|
|
if "::" not in line:
|
|
continue
|
|
# Lines look like: tests/test_foo.py::test_bar
|
|
# or: tests/test_foo.py::TestClass::test_bar[case]
|
|
file_part = line.split("::", 1)[0].strip()
|
|
counts[file_part] += 1
|
|
# Make sure every requested path got a key (zero if collected nothing).
|
|
for p in paths:
|
|
counts.setdefault(p, 0)
|
|
return dict(counts)
|
|
|
|
|
|
def _live_fixture_rows(path: str) -> int:
|
|
full = REPO_ROOT / path
|
|
if not full.exists():
|
|
return -1
|
|
return sum(
|
|
1
|
|
for ln in full.read_text().splitlines()
|
|
if ln.strip() and not ln.strip().startswith("#")
|
|
)
|
|
|
|
|
|
_DB_MISSING = -2
|
|
_TABLE_MISSING = -3
|
|
_DB_ERROR = -4
|
|
|
|
# Default location for arborist alias / claim-pack tables. Operator state,
|
|
# not source state — absence is not a failure, it's a skip signal.
|
|
_DEFAULT_SHARDS_DIR = Path.home() / ".arborist" / "shards"
|
|
_DEFAULT_SHARD = _DEFAULT_SHARDS_DIR / "000.db"
|
|
|
|
|
|
def _live_db_rows(target: str) -> int:
|
|
"""Count rows in a SQLite table.
|
|
|
|
Target syntax::
|
|
|
|
<table> # ~/.arborist/shards/000.db (default)
|
|
<shard>:<table> # ~/.arborist/shards/<shard>
|
|
|
|
Sentinel returns:
|
|
_DB_MISSING (-2) DB file not present — caller marks skipped
|
|
_TABLE_MISSING (-3) DB present but table absent — caller marks skipped
|
|
_DB_ERROR (-4) sqlite3 error — caller marks skipped
|
|
N >= 0 live row count
|
|
"""
|
|
import sqlite3
|
|
|
|
if ":" in target:
|
|
shard_name, table = target.split(":", 1)
|
|
db = _DEFAULT_SHARDS_DIR / shard_name
|
|
else:
|
|
db = _DEFAULT_SHARD
|
|
table = target
|
|
if not db.exists():
|
|
return _DB_MISSING
|
|
# Validate table name is a bare identifier — defends against the
|
|
# dynamic SQL string interpolation below. Tags are author-controlled
|
|
# but this is belt-and-suspenders.
|
|
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", table):
|
|
return _DB_ERROR
|
|
try:
|
|
c = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
try:
|
|
row = c.execute(
|
|
f"SELECT COUNT(*) FROM {table}" # noqa: S608 — validated above
|
|
).fetchone()
|
|
return int(row[0]) if row else 0
|
|
finally:
|
|
c.close()
|
|
except sqlite3.OperationalError as exc:
|
|
if "no such table" in str(exc).lower():
|
|
return _TABLE_MISSING
|
|
return _DB_ERROR
|
|
except sqlite3.Error:
|
|
return _DB_ERROR
|
|
|
|
|
|
def _live_db_where(target: str) -> int:
|
|
"""Count rows matching a single-column equality predicate.
|
|
|
|
Target syntax::
|
|
|
|
<table>?<column>=<value>
|
|
<shard>:<table>?<column>=<value>
|
|
|
|
Same sentinel returns as ``_live_db_rows``. ``<value>`` is bound
|
|
as a SQL parameter (no string interpolation), so even author-
|
|
typo'd or malicious values can't escape the predicate. Column
|
|
name is validated as a bare identifier (it goes into the SQL
|
|
text).
|
|
"""
|
|
import sqlite3
|
|
|
|
if "?" not in target:
|
|
return _DB_ERROR
|
|
head, where = target.split("?", 1)
|
|
if "=" not in where:
|
|
return _DB_ERROR
|
|
column, value = where.split("=", 1)
|
|
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", column):
|
|
return _DB_ERROR
|
|
|
|
if ":" in head:
|
|
shard_name, table = head.split(":", 1)
|
|
db = _DEFAULT_SHARDS_DIR / shard_name
|
|
else:
|
|
db = _DEFAULT_SHARD
|
|
table = head
|
|
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", table):
|
|
return _DB_ERROR
|
|
if not db.exists():
|
|
return _DB_MISSING
|
|
try:
|
|
c = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
try:
|
|
row = c.execute(
|
|
# noqa: S608 — table + column validated above as bare
|
|
# identifiers; value bound as parameter.
|
|
f"SELECT COUNT(*) FROM {table} WHERE {column} = ?",
|
|
(value,),
|
|
).fetchone()
|
|
return int(row[0]) if row else 0
|
|
finally:
|
|
c.close()
|
|
except sqlite3.OperationalError as exc:
|
|
if "no such table" in str(exc).lower():
|
|
return _TABLE_MISSING
|
|
if "no such column" in str(exc).lower():
|
|
return _DB_ERROR
|
|
return _DB_ERROR
|
|
except sqlite3.Error:
|
|
return _DB_ERROR
|
|
|
|
|
|
def _strip_fenced_code_blocks(text: str) -> str:
|
|
"""Replace contents of triple-backtick fenced code blocks with newlines.
|
|
|
|
Tags inside fenced blocks are documentation examples, not live claims —
|
|
line numbers must stay aligned (so failure messages point at the right
|
|
line) and the rest of the file must be untouched. Substituting newlines
|
|
for the block bodies keeps line offsets exact and removes the example
|
|
tag pairs from the regex scan.
|
|
|
|
Only handles the ```` ``` ```` form. Inline backticks (``code``) don't
|
|
get fenced semantics anyway — they're single-line and the regex
|
|
already requires the matching close tag on the same string.
|
|
"""
|
|
out: list[str] = []
|
|
in_fence = False
|
|
for line in text.splitlines(keepends=True):
|
|
stripped = line.lstrip()
|
|
if stripped.startswith("```"):
|
|
in_fence = not in_fence
|
|
out.append("\n")
|
|
continue
|
|
if in_fence:
|
|
out.append("\n")
|
|
else:
|
|
out.append(line)
|
|
return "".join(out)
|
|
|
|
|
|
def _iter_claims() -> Iterator[tuple[Path, int, str, str, int]]:
|
|
"""Yield (doc_path, lineno, metric, target_path, claimed_n).
|
|
|
|
Tags inside ``` fenced code blocks are skipped — those are
|
|
documentation examples, not live claims.
|
|
"""
|
|
for md in sorted(DOCS_DIR.rglob("*.md")):
|
|
text = _strip_fenced_code_blocks(md.read_text())
|
|
for m in AUTOCOUNT_RE.finditer(text):
|
|
lineno = text.count("\n", 0, m.start()) + 1
|
|
yield (
|
|
md,
|
|
lineno,
|
|
m.group("metric"),
|
|
m.group("path"),
|
|
int(m.group("n")),
|
|
)
|
|
|
|
|
|
def test_doc_autocount_claims_match_live(capsys: pytest.CaptureFixture[str]) -> None:
|
|
"""Every ``<!--AUTOCOUNT:-->`` claim in ``docs/`` matches live."""
|
|
claims = list(_iter_claims())
|
|
# Batch the pytest-collect call across every tests:* claim — one
|
|
# subprocess instead of N.
|
|
test_paths = sorted({p for _, _, m, p, _ in claims if m == "tests"})
|
|
test_counts = _live_test_count_batch(test_paths)
|
|
|
|
drifts: list[str] = []
|
|
skipped: list[str] = []
|
|
for doc, lineno, metric, target, claimed in claims:
|
|
rel = doc.relative_to(REPO_ROOT)
|
|
if metric == "tests":
|
|
live = test_counts.get(target, -1)
|
|
if live < 0:
|
|
drifts.append(
|
|
f"{rel}:{lineno} AUTOCOUNT({metric}:{target}) target "
|
|
f"missing or uncollectable"
|
|
)
|
|
continue
|
|
elif metric == "fixture-rows":
|
|
live = _live_fixture_rows(target)
|
|
if live < 0:
|
|
drifts.append(
|
|
f"{rel}:{lineno} AUTOCOUNT({metric}:{target}) target "
|
|
f"missing or uncollectable"
|
|
)
|
|
continue
|
|
elif metric in ("db-rows", "db-where"):
|
|
live = (
|
|
_live_db_rows(target)
|
|
if metric == "db-rows"
|
|
else _live_db_where(target)
|
|
)
|
|
if live == _DB_MISSING:
|
|
skipped.append(
|
|
f"{rel}:{lineno} {metric}:{target} skipped — "
|
|
f"{_DEFAULT_SHARDS_DIR} not present (CI / fresh checkout)"
|
|
)
|
|
continue
|
|
if live == _TABLE_MISSING:
|
|
skipped.append(
|
|
f"{rel}:{lineno} {metric}:{target} skipped — table not "
|
|
f"present in shard"
|
|
)
|
|
continue
|
|
if live == _DB_ERROR:
|
|
skipped.append(
|
|
f"{rel}:{lineno} {metric}:{target} skipped — sqlite "
|
|
f"error, malformed target, or invalid identifier"
|
|
)
|
|
continue
|
|
else:
|
|
drifts.append(f"{rel}:{lineno} unknown AUTOCOUNT metric {metric!r}")
|
|
continue
|
|
if live != claimed:
|
|
drifts.append(
|
|
f"{rel}:{lineno} AUTOCOUNT({metric}:{target}) claims "
|
|
f"{claimed}, live is {live}"
|
|
)
|
|
if skipped:
|
|
# Print to captured stdout — pytest -v shows it; the suite still
|
|
# passes as long as no live count actually drifted.
|
|
with capsys.disabled():
|
|
print(f"\n{len(skipped)} db-rows AUTOCOUNT claim(s) skipped:")
|
|
for s in skipped:
|
|
print(f" {s}")
|
|
assert not drifts, "Doc count drift detected:\n " + "\n ".join(drifts)
|
|
|
|
|
|
def test_autocount_tags_are_well_formed() -> None:
|
|
"""Catch typos in the comment pair (open without close, etc.)."""
|
|
open_re = re.compile(r"<!--\s*AUTOCOUNT[^>]*-->")
|
|
close_re = re.compile(r"<!--\s*/AUTOCOUNT\s*-->")
|
|
issues: list[str] = []
|
|
for md in sorted(DOCS_DIR.rglob("*.md")):
|
|
text = _strip_fenced_code_blocks(md.read_text())
|
|
opens = len(open_re.findall(text))
|
|
closes = len(close_re.findall(text))
|
|
full = len(AUTOCOUNT_RE.findall(text))
|
|
rel = md.relative_to(REPO_ROOT)
|
|
if opens != closes:
|
|
issues.append(
|
|
f"{rel}: {opens} open AUTOCOUNT tag(s) but "
|
|
f"{closes} close tag(s)"
|
|
)
|
|
if opens != full:
|
|
issues.append(
|
|
f"{rel}: {opens} open tag(s) but only {full} "
|
|
f"matched complete AUTOCOUNT pair(s) — check for "
|
|
f"missing digit, malformed close, or stray markup"
|
|
)
|
|
assert not issues, "Malformed AUTOCOUNT tags:\n " + "\n ".join(issues)
|
|
|
|
|
|
def test_autocount_metric_names_are_documented() -> None:
|
|
"""Fail-closed if a doc uses an undocumented metric."""
|
|
known = {"tests", "fixture-rows", "db-rows", "db-where"}
|
|
seen: set[str] = set()
|
|
for _, _, metric, _, _ in _iter_claims():
|
|
seen.add(metric)
|
|
unknown = seen - known
|
|
assert not unknown, (
|
|
f"AUTOCOUNT uses undocumented metric(s) {sorted(unknown)}. "
|
|
f"Add the metric to test_doc_counts.py and to the module "
|
|
f"docstring's metric list before tagging docs with it."
|
|
)
|