diff --git a/scripts/check-ledger-references.py b/scripts/check-ledger-references.py new file mode 100755 index 0000000000..5ff1d87818 --- /dev/null +++ b/scripts/check-ledger-references.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +"""Lint check (advisory): flag net-new SDLC ledger references in docs. + +Documentation in egg is meant to be a *snapshot of the current state* of the +codebase, not a *ledger of changes*. SDLC pipeline-process artifacts -- which +``slice-N`` landed a change, which ``TASK-N`` implemented it, which ``cq-N`` +HITL iteration resolved a question -- are meaningful while a pipeline runs, but +once the change is on ``main`` they are noise: they do not describe the current +system, they cost a reader implementation-roadmap knowledge to parse, and they +rot as later changes invalidate the narrative. See issue #3288 for the full +rationale and the corpus cleanup; this check is the deferred durability guard +(#3328) that keeps the cleaned corpus from re-accreting the same refs. + +This check is **advisory**: it prints warnings and returns ``0`` by default so +it never blocks a PR. The hard part -- distinguishing ledger narration from +live-runtime vocabulary that legitimately contains the same tokens (the +slice-DAG domain model, per-slice Job names, the ``TASK-N`` plan format) -- has +its own tuning cost, so the gate stays soft until the allowlist is dialled in. +Pass ``--strict`` to make net-new refs a hard failure (not wired into CI yet). + +Ratchet semantics +----------------- +``scripts/ledger-references-baseline.yaml`` records a per-file count of the +ledger tokens present when the baseline was last taken. The check flags only +*net-new* tokens -- a file whose current count exceeds its baseline -- so the +long pre-existing tail (tracked separately, decaying as docs are touched) does +not fire on every run. Lowering a count is always fine and never warns; run +``--update-baseline`` to re-snapshot after an intentional change. + +Tokens detected +--------------- +- ``slice-N`` -- per-slice rollout narration ("added in slice-4"). Matched + case-insensitively (``Slice-4`` at a sentence start is caught too). +- ``TASK-N`` -- plan task ids used as a change-log ("slice-4 TASK-4-5"). + Matched UPPERCASE-only: lowercase ``task-N`` is live runtime vocabulary + (timestamped run ids, contract task identifiers) and is intentionally ignored. +- ``cq-N`` -- HITL clarifying-question iteration ids. Case-insensitive. + +Change-log *prose* ("what was removed", "used to ... now ...") is deliberately +out of scope for v1: it cannot be matched without high false positives against +the issue links that legitimately justify *why* the current system is shaped +the way it is. Tune the token set / allowlist before extending it. + +Scope and false-positive controls +---------------------------------- +- Scanned: markdown across the repo plus non-test Python under the source + roots. Every line is scanned (not just docstrings/comments), but in practice + the hyphenated tokens only land in strings, docstrings, and comments because + ``-`` is not a Python identifier character. Test files and test directories + are excluded for *both* markdown and Python -- fixtures use ``slice-N`` / + ``task-N-N`` ids as live data, not as documentation. +- ``docs/templates/`` is excluded: ``plan.md``'s ``TASK-N`` is the live plan + format, not a ledger ref. +- ``.egg-state/`` is excluded: pipeline state and BRC transcripts are a ledger + by design. +- Per-line escape hatch: put ``ledger-ok`` in a comment on the line (e.g. a + legitimate new slice-DAG error message) to exclude it from the count. + +Usage: + scripts/check-ledger-references.py + scripts/check-ledger-references.py --strict # net-new -> exit 1 + scripts/check-ledger-references.py --update-baseline # re-snapshot counts + scripts/check-ledger-references.py --list # rank files by count + +Exit codes: + 0 no net-new refs, or advisory mode (default) + 1 net-new refs found and --strict was passed +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +BASELINE_PATH = Path(__file__).resolve().parent / "ledger-references-baseline.yaml" + +# Non-test Python source roots whose docstrings/comments are scanned. +SOURCE_ROOTS = ("orchestrator", "gateway", "shared", "sandbox") + +# Path prefixes (repo-relative, posix) excluded from the scan entirely because +# the tokens there are live machinery / pipeline state, not documentation. +EXCLUDED_PREFIXES = ( + ".egg-state/", # pipeline state + BRC transcripts -- a ledger by design + "docs/templates/", # plan.md TASK-N is the live plan format + ".venv/", + ".git/", + "node_modules/", +) + +# Directory names under a source root that are skipped (tests use slice/task +# ids as live fixture data, not as human-facing documentation). Both the +# plural ``tests`` and singular ``test`` directory conventions are covered so +# fixture data is excluded symmetrically regardless of which name a subtree uses. +EXCLUDED_DIR_NAMES = frozenset({"tests", "test", "__pycache__"}) + +# SDLC ledger token classes. Hyphenated forms are chosen deliberately: the live +# runtime vocabulary uses underscores / dotted access (``slice_id``, +# ``contract.slices``, ``EGG_*_SLICES``), so it does not match here. +# +# Case handling is asymmetric on purpose: +# - ``slice-N`` / ``cq-N`` match case-insensitively, so a sentence-initial +# ``Slice-4`` or an upper-cased ``CQ-2`` is still caught. Capitalising these +# never collides with live vocabulary (that uses ``slice_id`` / ``contract.slices``). +# - ``TASK-N`` stays UPPERCASE-only. Lowercase ``task-N`` is pervasive live +# runtime vocabulary -- timestamped run ids (``task-20251129-222239``), example +# ids in tool docs (``task-123``), and contract task identifiers in handler +# code -- none of which are ledger narration. Folding case here would flood +# false positives, so the plan-format casing is the discriminator (parallel to +# the underscore/dotted-access guards above). +LEDGER_PATTERN = re.compile( + r"\b(?i:slice)-\d+\b" # per-slice rollout narration (slice-4 / Slice-4) + r"|\bTASK-\d+(?:-\d+)?\b" # plan task ids used as a change-log (uppercase only) + r"|\b(?i:cq)-\d+\b" # HITL clarifying-question iteration ids (cq-2 / CQ-2) +) + +# Put this token in a comment on a line to exclude it from the count. +SUPPRESS_MARKER = "ledger-ok" + + +@dataclass(frozen=True) +class FileFindings: + rel: str + count: int + # (line_number, line_text) for each line that contributed a match; used for + # actionable warning output, not for the ratchet decision. + lines: tuple[tuple[int, str], ...] + + +def load_baseline(path: Path | None = None) -> dict[str, int]: + """Load the per-file ledger-token baseline counts.""" + if path is None: + path = BASELINE_PATH + if not path.exists(): + return {} + raw = yaml.safe_load(path.read_text()) or {} + files_raw: dict[str, Any] = raw.get("files") or {} + return {str(rel): int(count) for rel, count in files_raw.items()} + + +def write_baseline(counts: dict[str, int], path: Path | None = None) -> None: + """Rewrite the baseline file with sorted, non-zero per-file counts.""" + if path is None: + path = BASELINE_PATH + header = ( + "# Per-file SDLC ledger-token counts (slice-N / TASK-N / cq-N).\n" + "# Maintained by scripts/check-ledger-references.py --update-baseline.\n" + "# The advisory ratchet flags files whose current count EXCEEDS the\n" + "# value here (net-new refs); lowering a count never warns. See the\n" + "# script docstring and issue #3328 for the rationale.\n" + ) + payload = {"files": {rel: counts[rel] for rel in sorted(counts) if counts[rel] > 0}} + path.write_text(header + yaml.safe_dump(payload, sort_keys=False)) + + +def is_excluded(rel: str) -> bool: + return any(rel.startswith(prefix) for prefix in EXCLUDED_PREFIXES) + + +def is_test_path(rel: Path) -> bool: + if rel.name.startswith("test_") or rel.name.endswith("_test.py"): + return True + return any(part in EXCLUDED_DIR_NAMES for part in rel.parts) + + +def _walk_in_scope(root: Path, suffix: str, repo_root: Path) -> list[Path]: + """Walk ``root`` for files ending in ``suffix``, pruning excluded and test + directories *during* traversal so we never descend into ``.git`` / ``.venv`` + / ``node_modules`` / ``.egg-state`` / ``docs/templates`` / test dirs.""" + out: list[Path] = [] + for dirpath, dirnames, filenames in os.walk(root): + here = Path(dirpath) + # Prune in place: drop subdirs that are excluded by prefix or are test + # directories, so os.walk does not descend into them at all. + dirnames[:] = [ + name + for name in dirnames + if not is_excluded((here / name).relative_to(repo_root).as_posix() + "/") + and name not in EXCLUDED_DIR_NAMES + ] + for fn in filenames: + if not fn.endswith(suffix): + continue + p = here / fn + rel = p.relative_to(repo_root) + if is_excluded(rel.as_posix()) or is_test_path(rel): + continue + out.append(p) + return out + + +def iter_scanned_files(repo_root: Path = REPO_ROOT) -> list[Path]: + """Yield markdown (repo-wide) + non-test Python (source roots), sorted. + + Test files and test directories are excluded for both file types; excluded + prefixes (``.egg-state/``, ``docs/templates/``, ``.git/`` …) are pruned + during traversal rather than walked-then-filtered. + """ + # Never flag the check itself (its docstring lists the token patterns). + self_path = Path(__file__).resolve() + + out = _walk_in_scope(repo_root, ".md", repo_root) + for root_name in SOURCE_ROOTS: + root = repo_root / root_name + if root.is_dir(): + out.extend(_walk_in_scope(root, ".py", repo_root)) + + out = [p for p in out if p.resolve() != self_path] + out.sort() + return out + + +def scan_file(path: Path, repo_root: Path = REPO_ROOT) -> FileFindings: + """Count ledger tokens in a file, honouring the per-line suppress marker.""" + rel = path.relative_to(repo_root).as_posix() + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + # A dangling symlink or otherwise-unreadable path surfaced by os.walk + # contributes nothing -- ``errors="replace"`` tolerates bad encodings + # but not an OSError. This is an advisory-only scan, so degrade to a + # zero-count finding rather than crashing the whole run. + return FileFindings(rel=rel, count=0, lines=()) + count = 0 + hit_lines: list[tuple[int, str]] = [] + for lineno, line in enumerate(text.splitlines(), start=1): + if SUPPRESS_MARKER in line: + continue + matches = LEDGER_PATTERN.findall(line) + if matches: + count += len(matches) + hit_lines.append((lineno, line.strip())) + return FileFindings(rel=rel, count=count, lines=tuple(hit_lines)) + + +def scan_all(repo_root: Path = REPO_ROOT) -> dict[str, FileFindings]: + """Scan every in-scope file; return findings keyed by repo-relative path.""" + findings: dict[str, FileFindings] = {} + for path in iter_scanned_files(repo_root): + f = scan_file(path, repo_root) + if f.count: + findings[f.rel] = f + return findings + + +def evaluate(findings: dict[str, FileFindings], baseline: dict[str, int]) -> list[FileFindings]: + """Return findings for files whose current count exceeds their baseline.""" + net_new: list[FileFindings] = [] + for rel in sorted(findings): + f = findings[rel] + if f.count > baseline.get(rel, 0): + net_new.append(f) + return net_new + + +def update_baseline(repo_root: Path = REPO_ROOT, path: Path | None = None) -> int: + findings = scan_all(repo_root) + counts = {rel: f.count for rel, f in findings.items()} + write_baseline(counts, path) + target = path if path is not None else BASELINE_PATH + print(f"Wrote {len(counts)} entries to {target.name}") + return 0 + + +def list_files(repo_root: Path = REPO_ROOT) -> int: + findings = scan_all(repo_root) + rows = sorted(((f.count, rel) for rel, f in findings.items()), reverse=True) + for count, rel in rows: + print(f"{count:>5} {rel}") + print(f"\n{sum(c for c, _ in rows)} tokens across {len(rows)} files") + return 0 + + +def _print_net_new(net_new: list[FileFindings], baseline: dict[str, int]) -> None: + total = sum(f.count - baseline.get(f.rel, 0) for f in net_new) + print( + f"advisory: {total} net-new SDLC ledger reference(s) " + f"(slice-N / TASK-N / cq-N) across {len(net_new)} file(s):" + ) + for f in net_new: + base = baseline.get(f.rel, 0) + print(f" - {f.rel}: {f.count} (baseline {base}, +{f.count - base})") + # When the file had no prior refs every match is genuinely net-new, so + # the lines pinpoint the additions. When it was already in the baseline + # the count is the only signal -- the specific net-new token sits among + # grandfathered ones, so the PR diff (not this list) is where to look. + if base == 0: + for lineno, line in f.lines[:5]: + snippet = line if len(line) <= 100 else line[:97] + "..." + print(f" L{lineno}: {snippet}") + if len(f.lines) > 5: + print(f" ... and {len(f.lines) - 5} more line(s)") + else: + print(" (new token(s) among existing refs -- check the PR diff)") + print( + "\nDocs should snapshot current state, not narrate the SDLC pipeline " + "(see #3288).\nIf a token is legitimate live machinery, add `ledger-ok` " + "in a comment on the\nline. After an intentional change, re-snapshot " + "with:\n scripts/check-ledger-references.py --update-baseline" + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0] if __doc__ else "") + parser.add_argument( + "--strict", + action="store_true", + help="Exit 1 on net-new refs (default is advisory: warn and exit 0).", + ) + parser.add_argument( + "--update-baseline", + action="store_true", + help="Re-snapshot per-file counts into ledger-references-baseline.yaml.", + ) + parser.add_argument( + "--list", + action="store_true", + help="List every in-scope file with its ledger-token count.", + ) + args = parser.parse_args(argv) + + if args.update_baseline: + return update_baseline() + if args.list: + return list_files() + + findings = scan_all() + baseline = load_baseline() + net_new = evaluate(findings, baseline) + + if not net_new: + return 0 + + _print_net_new(net_new, baseline) + return 1 if args.strict else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ledger-references-baseline.yaml b/scripts/ledger-references-baseline.yaml new file mode 100644 index 0000000000..b567a16c94 --- /dev/null +++ b/scripts/ledger-references-baseline.yaml @@ -0,0 +1,119 @@ +# Per-file SDLC ledger-token counts (slice-N / TASK-N / cq-N). +# Maintained by scripts/check-ledger-references.py --update-baseline. +# The advisory ratchet flags files whose current count EXCEEDS the +# value here (net-new refs); lowering a count never warns. See the +# script docstring and issue #3328 for the rationale. +files: + .egg/contract-rules.md: 1 + README.md: 3 + docs/architecture/README.md: 1 + docs/architecture/logging.md: 1 + docs/architecture/on-demand-agent-lifecycle.md: 2 + docs/architecture/overseer.md: 3 + docs/architecture/sdlc-pipeline.md: 3 + docs/architecture/slice-dag.md: 13 + docs/development/STRUCTURE.md: 5 + docs/guides/concurrent-execution.md: 20 + docs/guides/decomposition-pattern.md: 1 + docs/guides/pipeline-health-monitoring.md: 6 + docs/guides/sdlc-pipeline.md: 7 + docs/hitl-decisions.md: 3 + docs/index.md: 8 + docs/reference/agent-recovery.md: 9 + docs/reference/agent-roles.md: 3 + docs/reference/agent-tools.md: 9 + docs/reference/agent-wait-patterns.md: 4 + docs/reference/conditional-ack.md: 2 + docs/reference/orchestrator-cli.md: 9 + docs/reference/post-agent-commit.md: 2 + docs/reference/sdlc-contract.md: 1 + gateway/agent_restrictions.py: 2 + gateway/anthropic_credentials.py: 1 + gateway/gateway.py: 13 + gateway/phase_filter.py: 4 + gateway/phase_transition.py: 1 + gateway/session_manager.py: 4 + gateway/upstream_registry.py: 2 + gateway/worktree_manager.py: 2 + integration_tests/regression/README.md: 2 + orchestrator/agent_model_resolution.py: 3 + orchestrator/agent_salvage.py: 1 + orchestrator/cli.py: 1 + orchestrator/concurrent_executor.py: 19 + orchestrator/consensus_wrapper.py: 15 + orchestrator/dag_visualizer.py: 1 + orchestrator/env_config.py: 2 + orchestrator/event_loop.py: 31 + orchestrator/events.py: 6 + orchestrator/gateway_client.py: 24 + orchestrator/health_checks/README.md: 3 + orchestrator/health_checks/detection_plane.py: 13 + orchestrator/health_checks/runner.py: 1 + orchestrator/health_checks/tier1/__init__.py: 3 + orchestrator/health_checks/tier1/brc_thrashing.py: 2 + orchestrator/health_checks/tier1/container_k8s.py: 4 + orchestrator/health_checks/tier1/cost_budget.py: 2 + orchestrator/health_checks/tier1/decision_queue.py: 4 + orchestrator/health_checks/tier1/gateway_health.py: 3 + orchestrator/health_checks/tier1/llm_substrate.py: 3 + orchestrator/health_checks/tier1/runtime_liveness.py: 4 + orchestrator/health_checks/tier1/worktree_branch.py: 5 + orchestrator/health_checks/types.py: 7 + orchestrator/health_monitor.py: 4 + orchestrator/heartbeat.py: 4 + orchestrator/jira_reassess.py: 3 + orchestrator/kubernetes_spawner.py: 1 + orchestrator/mcp_tools.py: 5 + orchestrator/message_store.py: 3 + orchestrator/models.py: 7 + orchestrator/overseer/corrective.py: 2 + orchestrator/overseer/decision_maker.py: 6 + orchestrator/overseer/issue_filer.py: 1 + orchestrator/overseer/monitor.py: 15 + orchestrator/overseer/self_monitor.py: 1 + orchestrator/peer_consensus.py: 5 + orchestrator/pr_obligations.py: 2 + orchestrator/routes/artifacts.py: 6 + orchestrator/routes/consensus.py: 7 + orchestrator/routes/contracts.py: 1 + orchestrator/routes/event_prompt.py: 51 + orchestrator/routes/health.py: 1 + orchestrator/routes/messages.py: 2 + orchestrator/routes/phases.py: 7 + orchestrator/routes/pipelines.py: 238 + orchestrator/routes/session_state.py: 1 + orchestrator/routes/signals.py: 15 + orchestrator/session_state_store.py: 2 + orchestrator/slice_scheduler.py: 4 + orchestrator/stacked_pr_reconciler.py: 10 + orchestrator/startup_reconciliation.py: 12 + orchestrator/state_store.py: 1 + orchestrator/supervision_policy.py: 1 + orchestrator/wontdo_drain.py: 1 + sandbox/agent-config/rules/mission.md: 1 + sandbox/agent-config/rules/orchestrator.md: 4 + sandbox/egg_agent_tools/handlers/brc.py: 6 + sandbox/egg_agent_tools/handlers/brc_memory.py: 30 + sandbox/egg_agent_tools/handlers/message.py: 2 + sandbox/egg_agent_tools/handlers/progress.py: 1 + sandbox/egg_agent_tools/handlers/task.py: 1 + sandbox/egg_lib/orch_cli.py: 31 + sandbox/egg_lib/session_state_sync.py: 1 + sandbox/llm/claude/runner.py: 1 + shared/egg_agent/client.py: 5 + shared/egg_agent/measurement.py: 15 + shared/egg_agent/midturn_messages.py: 3 + shared/egg_agent/queryable_env.py: 6 + shared/egg_agent/reseed.py: 7 + shared/egg_agent/session.py: 13 + shared/egg_contracts/agent_roles.py: 1 + shared/egg_contracts/artifact_spec.py: 9 + shared/egg_contracts/dependency_graph.py: 3 + shared/egg_contracts/models.py: 11 + shared/egg_contracts/phase_defaults.py: 1 + shared/egg_contracts/plan_parser.py: 18 + shared/egg_overseer/priority.py: 1 + shared/egg_overseer/state.py: 1 + shared/egg_restrictions/patterns.py: 1 + shared/prompts/REVIEWER-SYNC.md: 1 + skills/sdlc/SKILL.md: 3 diff --git a/tests/scripts/test_check_ledger_references.py b/tests/scripts/test_check_ledger_references.py new file mode 100644 index 0000000000..3e1cb51678 --- /dev/null +++ b/tests/scripts/test_check_ledger_references.py @@ -0,0 +1,214 @@ +"""Tests for scripts/check-ledger-references.py.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +_SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "check-ledger-references.py" +_spec = importlib.util.spec_from_file_location("check_ledger_references", _SCRIPT_PATH) +assert _spec and _spec.loader +_mod = importlib.util.module_from_spec(_spec) +# Register in sys.modules so dataclasses can resolve __module__ during class +# construction (Python 3.14 _is_type lookup). +sys.modules["check_ledger_references"] = _mod +_spec.loader.exec_module(_mod) + +LEDGER_PATTERN = _mod.LEDGER_PATTERN +evaluate = _mod.evaluate +is_excluded = _mod.is_excluded +is_test_path = _mod.is_test_path +iter_scanned_files = _mod.iter_scanned_files +load_baseline = _mod.load_baseline +scan_all = _mod.scan_all +scan_file = _mod.scan_file +update_baseline = _mod.update_baseline +write_baseline = _mod.write_baseline + + +class TestPattern: + @pytest.mark.parametrize( + "text", + [ + "added in slice-4", + "slice-12 landed it", + "Slice-4 at a sentence start", # slice-N is case-insensitive + "SLICE-7 shouted", + "see TASK-4-5", + "TASK-9 was the task", + "per cq-2", + "resolved by cq-10", + "per CQ-2", # cq-N is case-insensitive + ], + ) + def test_matches_ledger_tokens(self, text: str) -> None: + assert LEDGER_PATTERN.search(text) + + @pytest.mark.parametrize( + "text", + [ + "slice_id is the runtime key", # underscore identifier + "contract.slices iterates each slice", # dotted access + "EGG_ACTIVE_SLICES env var", # env var + "the slice DAG model", # bare word + "a task description", # bare word + "acquittal", # 'cq' substring inside a word + # TASK-N is UPPERCASE-only: lowercase task-N is live runtime + # vocabulary, not ledger narration, and must not match. + "task-5 is a runtime contract id", + "task-20251129-222239 is a timestamped run id", + "see task-123 in the tool docs example", + ], + ) + def test_ignores_live_vocabulary(self, text: str) -> None: + assert not LEDGER_PATTERN.search(text) + + +class TestScanFile: + def test_counts_occurrences_not_lines(self, tmp_path: Path) -> None: + f = tmp_path / "doc.md" + f.write_text("slice-1 then slice-2 on one line\nand TASK-3-1 here\n") + findings = scan_file(f, tmp_path) + assert findings.count == 3 + assert len(findings.lines) == 2 + + def test_suppress_marker_excludes_line(self, tmp_path: Path) -> None: + f = tmp_path / "doc.md" + f.write_text("slice-1 narration here\nlive error: slice-1 -> slice-2 \n") + findings = scan_file(f, tmp_path) + assert findings.count == 1 + assert findings.lines[0][0] == 1 + + def test_no_matches_is_zero(self, tmp_path: Path) -> None: + f = tmp_path / "doc.md" + f.write_text("This doc describes the current state of the system.\n") + assert scan_file(f, tmp_path).count == 0 + + def test_unreadable_path_degrades_to_zero(self, tmp_path: Path) -> None: + """A dangling symlink (OSError on read) yields a zero-count finding, + not a crash -- the advisory scan must tolerate it.""" + link = tmp_path / "dangling.md" + link.symlink_to(tmp_path / "does-not-exist.md") + findings = scan_file(link, tmp_path) + assert findings.count == 0 + assert findings.lines == () + + +class TestExclusions: + def test_egg_state_excluded(self) -> None: + assert is_excluded(".egg-state/brc-history/42-implement-slice-1.md") + + def test_plan_template_excluded(self) -> None: + assert is_excluded("docs/templates/plan.md") + + def test_normal_doc_not_excluded(self) -> None: + assert not is_excluded("docs/architecture/orchestrator.md") + + def test_test_files_skipped(self) -> None: + assert is_test_path(Path("orchestrator/tests/test_foo.py")) + assert is_test_path(Path("orchestrator/foo_test.py")) + assert is_test_path(Path("gateway/test_bar.py")) + assert not is_test_path(Path("orchestrator/routes/pipelines.py")) + + def test_singular_test_dir_skipped(self) -> None: + """Both ``tests/`` and singular ``test/`` directory names are excluded.""" + assert is_test_path(Path("orchestrator/test/fixtures/data.md")) + assert is_test_path(Path("gateway/test/conftest.py")) + + +class TestIterScannedFiles: + def test_collects_md_and_nontest_py(self, tmp_path: Path) -> None: + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "guide.md").write_text("slice-1\n") + (tmp_path / "orchestrator").mkdir() + (tmp_path / "orchestrator" / "src.py").write_text("# slice-1\n") + (tmp_path / "orchestrator" / "test_src.py").write_text("# slice-1\n") + rels = sorted(p.relative_to(tmp_path).as_posix() for p in iter_scanned_files(tmp_path)) + assert rels == ["docs/guide.md", "orchestrator/src.py"] + + def test_excluded_prefixes_skipped(self, tmp_path: Path) -> None: + (tmp_path / ".egg-state").mkdir() + (tmp_path / ".egg-state" / "x.md").write_text("slice-1\n") + (tmp_path / "docs" / "templates").mkdir(parents=True) + (tmp_path / "docs" / "templates" / "plan.md").write_text("TASK-1-1\n") + (tmp_path / "docs" / "real.md").write_text("slice-1\n") + rels = sorted(p.relative_to(tmp_path).as_posix() for p in iter_scanned_files(tmp_path)) + assert rels == ["docs/real.md"] + + def test_test_dir_markdown_excluded(self, tmp_path: Path) -> None: + """Markdown under a test directory is excluded too (symmetry with .py).""" + (tmp_path / "gateway" / "tests").mkdir(parents=True) + (tmp_path / "gateway" / "tests" / "README.md").write_text("slice-1 fixture\n") + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "real.md").write_text("slice-1\n") + rels = sorted(p.relative_to(tmp_path).as_posix() for p in iter_scanned_files(tmp_path)) + assert rels == ["docs/real.md"] + + def test_singular_test_dir_pruned(self, tmp_path: Path) -> None: + """A singular ``test/`` directory is pruned during traversal too.""" + (tmp_path / "gateway" / "test").mkdir(parents=True) + (tmp_path / "gateway" / "test" / "fixture.md").write_text("slice-1 fixture\n") + (tmp_path / "gateway" / "test" / "src.py").write_text("# slice-1\n") + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "real.md").write_text("slice-1\n") + rels = sorted(p.relative_to(tmp_path).as_posix() for p in iter_scanned_files(tmp_path)) + assert rels == ["docs/real.md"] + + +class TestEvaluate: + def test_over_baseline_is_net_new(self) -> None: + findings = {"a.md": _mod.FileFindings(rel="a.md", count=5, lines=())} + assert [f.rel for f in evaluate(findings, {"a.md": 3})] == ["a.md"] + + def test_at_baseline_is_clean(self) -> None: + findings = {"a.md": _mod.FileFindings(rel="a.md", count=3, lines=())} + assert evaluate(findings, {"a.md": 3}) == [] + + def test_under_baseline_is_clean(self) -> None: + """Lowering the count (de-ledgering) must never warn.""" + findings = {"a.md": _mod.FileFindings(rel="a.md", count=1, lines=())} + assert evaluate(findings, {"a.md": 3}) == [] + + def test_new_file_with_refs_is_net_new(self) -> None: + findings = {"a.md": _mod.FileFindings(rel="a.md", count=1, lines=())} + assert [f.rel for f in evaluate(findings, {})] == ["a.md"] + + +class TestBaselineRoundTrip: + def test_write_then_load(self, tmp_path: Path) -> None: + path = tmp_path / "baseline.yaml" + write_baseline({"docs/a.md": 5, "docs/b.md": 2}, path) + assert load_baseline(path) == {"docs/a.md": 5, "docs/b.md": 2} + + def test_write_drops_zero_counts(self, tmp_path: Path) -> None: + path = tmp_path / "baseline.yaml" + write_baseline({"docs/a.md": 0, "docs/b.md": 2}, path) + assert load_baseline(path) == {"docs/b.md": 2} + + def test_load_missing_file_is_empty(self, tmp_path: Path) -> None: + assert load_baseline(tmp_path / "absent.yaml") == {} + + def test_update_baseline_snapshots_current( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "a.md").write_text("slice-1 and slice-2\n") + path = tmp_path / "baseline.yaml" + rc = update_baseline(tmp_path, path) + capsys.readouterr() + assert rc == 0 + assert load_baseline(path) == {"docs/a.md": 2} + + +# NOTE: the committed-baseline freshness check intentionally lives in the +# advisory `scripts/check-ledger-references.py` script (surfaced via +# `make lint-custom`), NOT in this blocking unit suite. Asserting the live merge +# corpus against the committed baseline inside `make test-all` turned the +# advisory ratchet into a de-facto hard CI gate: any later PR that merged +# `main`'s new slice-N / TASK-N / cq-N tokens would redden the blocking suite +# until someone re-ran `--update-baseline`, a maintenance treadmill at odds with +# the issue's "advisory, never block" intent (#3328). The advisory run prints the +# net-new files and the `--update-baseline` reminder without blocking a PR.