arborist/tests/test_doc_counts.py
russell@unturf.com 8da29fde69
ticket #000044: AUTOCOUNT doc-drift discipline + code-fence parser fix
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 since fc5ba50 2026-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.
2026-05-10 18:24:50 -04:00

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."
)