Fan-out follow-up to ``fc5ba50``. Two thrusts in one commit since they exercise the same surface: == Task 3: extend AUTOCOUNT with db-rows metric == New metric ``db-rows`` for tagging live SQLite row counts (alias tables, claim-pack records, etc — operator state that drifted on ``30a9488`` and earlier). Target syntax:: <!--AUTOCOUNT:db-rows:citation_aliases-->74<!--/AUTOCOUNT--> <!--AUTOCOUNT:db-rows:002.db:concept_relations-->1234<!--/AUTOCOUNT--> Default shard: ``~/.arborist/shards/000.db`` (where the alias tables live per ``arborist.cli._aliases_db_path``). Operator state is graceful-skip semantics: when DB or table is absent (CI, fresh checkout, sibling repo), the claim is logged as skipped and the test still passes. Drift only fires when the DB IS present and the count diverged. Sentinel returns: - ``_DB_MISSING`` (-2): shards dir not present → skip - ``_TABLE_MISSING`` (-3): DB present but table absent → skip - ``_DB_ERROR`` (-4): malformed table name or sqlite error → skip Table name validated against ``[A-Za-z_][A-Za-z0-9_]*`` regex before string-interpolating into ``SELECT COUNT(*) FROM <table>``; this is belt-and-suspenders since AUTOCOUNT tags are author- controlled, but the dynamic SQL surface deserves a bouncer. Smoke verified under HOME redirect to ``/tmp/<empty>``: 3 db-rows claims gracefully skip with informative line-numbered messages, suite still passes. == Task 2: backfill 15 tags == Cookbook test/code-density table (lines 569-579, 10 rows) — every ``(N tests)`` cell now machine-checked: | aliases.py | 512 | 469 (28 tests) | 0.92 | → | aliases.py | 512 | 469 (<!--AUTOCOUNT:tests:tests/test_aliases.py-->28<!--/AUTOCOUNT--> tests) | 0.92 | Markdown renderers strip HTML comments — table cells display ``28 tests`` unchanged. The ``warrant_resolver.py`` row stays untagged because its test count is split across two test files (verifier + parser) and the cell encodes a combined "~430" instead of one collected count. Cookbook alias-count surfaces (3 db-rows tags): - L364 ``citation_aliases (74 rows live as of 2026-05-10)`` - L437 ``#000041 — citation-aliases table + 74 live rows`` - L438 ``#000042 — term-aliases table + 13 live rows`` Ticket #000035 (in progress, line 274) — refresh ``20 tests`` → ``27 tests`` for ``test_anchor_prg.py`` + tag. Same drift pattern as ``5c21e83``: ticket prose was written before the ``de997f7`` 2026-05-10 pattern backfill that added 7 tests (prefix-extension closure, hand-formula, parametrized invalid-input cones). Also tagged ``L279``'s 10-vector KAT fixture claim with ``fixture-rows`` metric. == Closed-ticket counts deliberately not tagged == #000028, #000030, #000042, #000031, #000004, #000026, #000009, #000032, #000008 all carry historical "N tests pass" snapshots from their landing date. Those are point-in-time records, not live claims — drifting from current state is BY DESIGN. Tagging them would fire the test on every successive change to the codebase. Closed tickets are the design log; we don't backfill them. == Coverage summary == Total tags after this commit: 44 (was 29; +15) Tags by metric: tests: 39 fixture-rows: 2 db-rows: 3 Files with tags: docs/warrant-substrate-cookbook.md 27 (was 14) docs/soft-hash-channel-analysis.md 5 docs/tickets/ticket-000006-bench-emergent... 4 docs/seven-point-program.md 3 docs/calculator-test-patterns.md 3 docs/tickets/ticket-000035-prg-choice-phi-prg.md 2 (new) == Verification == $ .venv/bin/pytest tests/test_doc_counts.py -v 3 passed in 4.32s $ .venv/bin/pytest -q 2276 passed, 54 skipped in 168.34s $ HOME=/tmp/empty pytest tests/test_doc_counts.py -v -s 3 db-rows AUTOCOUNT claim(s) skipped: docs/warrant-substrate-cookbook.md:364 db-rows:citation_aliases skipped — /tmp/empty/.arborist/shards not present (CI / fresh checkout) docs/warrant-substrate-cookbook.md:437 db-rows:citation_aliases skipped — /tmp/empty/.arborist/shards not present (CI / fresh checkout) docs/warrant-substrate-cookbook.md:438 db-rows:term_aliases skipped — /tmp/empty/.arborist/shards not present (CI / fresh checkout) 3 passed in 4.78s No new dependencies. No schema changes.
277 lines
9.8 KiB
Python
277 lines
9.8 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.
|
|
|
|
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 _iter_claims() -> Iterator[tuple[Path, int, str, str, int]]:
|
|
"""Yield (doc_path, lineno, metric, target_path, claimed_n)."""
|
|
for md in sorted(DOCS_DIR.rglob("*.md")):
|
|
text = 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 == "db-rows":
|
|
live = _live_db_rows(target)
|
|
if live == _DB_MISSING:
|
|
skipped.append(
|
|
f"{rel}:{lineno} db-rows:{target} skipped — "
|
|
f"{_DEFAULT_SHARDS_DIR} not present (CI / fresh checkout)"
|
|
)
|
|
continue
|
|
if live == _TABLE_MISSING:
|
|
skipped.append(
|
|
f"{rel}:{lineno} db-rows:{target} skipped — table not "
|
|
f"present in shard"
|
|
)
|
|
continue
|
|
if live == _DB_ERROR:
|
|
skipped.append(
|
|
f"{rel}:{lineno} db-rows:{target} skipped — sqlite "
|
|
f"error or invalid table name"
|
|
)
|
|
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 = 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"}
|
|
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."
|
|
)
|