From 349347899d65065c241115710113ab5ed8935e9a Mon Sep 17 00:00:00 2001 From: James Wiesebron Date: Fri, 26 Jun 2026 15:58:28 -0700 Subject: [PATCH 1/6] Add advisory ledger-reference ratchet lint (#3328) Follow-up to #3288 (durability guardrail, per HITL cq-2): #3288 de-ledgered the docs corpus and reframed the documenter to emit snapshots, but explicitly deferred an automated guard against new slice-N / TASK-N / cq-N SDLC ledger refs creeping back into docs/docstrings. This adds that guard. scripts/check-ledger-references.py is an advisory (warn, never block) ratchet: - Detects the three unambiguous ledger token classes -- slice-N, TASK-N, cq-N -- across repo markdown plus non-test Python source. - Ratchet semantics via scripts/ledger-references-baseline.yaml (per-file counts): flags only net-new tokens, so the long pre-existing tail does not fire on every run. Lowering a count never warns. - False-positive controls: hyphenated patterns skip live runtime vocabulary (slice_id, contract.slices, EGG_*_SLICES); .egg-state/ and docs/templates/ (live plan-format TASK-N) are excluded; tests excluded; per-line `ledger-ok` escape hatch for legitimate live-machinery additions. - Auto-discovered by `make lint-custom` (scripts/check-*.py). Exits 0 by default; `--strict` makes net-new a hard failure (not wired into CI yet), per "advisory first, tune the allowlist before a hard gate". Change-log prose ("what was removed", "used to ... now ...") is deliberately out of scope for v1 -- it can't be matched without high false positives against the issue links that legitimately justify current-state rationale. --- scripts/check-ledger-references.py | 299 ++++++++++++++++++ scripts/ledger-references-baseline.yaml | 116 +++++++ tests/scripts/test_check_ledger_references.py | 177 +++++++++++ 3 files changed, 592 insertions(+) create mode 100755 scripts/check-ledger-references.py create mode 100644 scripts/ledger-references-baseline.yaml create mode 100644 tests/scripts/test_check_ledger_references.py diff --git a/scripts/check-ledger-references.py b/scripts/check-ledger-references.py new file mode 100755 index 0000000000..e63b8b11f9 --- /dev/null +++ b/scripts/check-ledger-references.py @@ -0,0 +1,299 @@ +#!/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"). +- ``TASK-N`` -- plan task ids used as a change-log ("slice-4 TASK-4-5"). +- ``cq-N`` -- HITL clarifying-question iteration ids. + +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 (docstrings/inline comments). Test files are excluded -- 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 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). +EXCLUDED_DIR_NAMES = frozenset({"tests", "__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. +LEDGER_PATTERN = re.compile( + r"\bslice-\d+\b" # per-slice rollout narration + r"|\bTASK-\d+(?:-\d+)?\b" # plan task ids used as a change-log + r"|\bcq-\d+\b" # HITL clarifying-question iteration ids +) + +# 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 iter_scanned_files(repo_root: Path = REPO_ROOT) -> list[Path]: + """Yield markdown (repo-wide) + non-test Python (source roots), sorted.""" + out: list[Path] = [] + # Never flag the check itself (its docstring lists the token patterns). + self_path = Path(__file__).resolve() + + for p in repo_root.rglob("*.md"): + rel = p.relative_to(repo_root) + if is_excluded(rel.as_posix()): + continue + out.append(p) + + for root_name in SOURCE_ROOTS: + root = repo_root / root_name + if not root.is_dir(): + continue + for p in root.rglob("*.py"): + rel = p.relative_to(repo_root) + if is_test_path(rel) or is_excluded(rel.as_posix()): + continue + out.append(p) + + 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() + text = path.read_text(encoding="utf-8", errors="replace") + 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..f037ceec0d --- /dev/null +++ b/scripts/ledger-references-baseline.yaml @@ -0,0 +1,116 @@ +# 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: 2 + docs/architecture/slice-dag.md: 13 + docs/development/STRUCTURE.md: 5 + docs/guides/concurrent-execution.md: 15 + 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: 18 + orchestrator/consensus_wrapper.py: 13 + orchestrator/dag_visualizer.py: 1 + orchestrator/env_config.py: 2 + orchestrator/event_loop.py: 26 + orchestrator/events.py: 6 + orchestrator/gateway_client.py: 22 + orchestrator/health_checks/README.md: 2 + orchestrator/health_checks/detection_plane.py: 10 + orchestrator/health_checks/runner.py: 1 + orchestrator/health_checks/tier1/__init__.py: 2 + 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: 4 + orchestrator/pr_obligations.py: 2 + orchestrator/routes/artifacts.py: 6 + orchestrator/routes/consensus.py: 6 + orchestrator/routes/contracts.py: 1 + orchestrator/routes/event_prompt.py: 48 + orchestrator/routes/health.py: 1 + orchestrator/routes/messages.py: 2 + orchestrator/routes/phases.py: 7 + orchestrator/routes/pipelines.py: 232 + orchestrator/routes/signals.py: 13 + orchestrator/slice_scheduler.py: 4 + orchestrator/stacked_pr_reconciler.py: 10 + orchestrator/startup_reconciliation.py: 10 + 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: 22 + sandbox/egg_agent_tools/handlers/message.py: 1 + sandbox/egg_agent_tools/handlers/progress.py: 1 + sandbox/egg_agent_tools/handlers/task.py: 1 + sandbox/egg_lib/orch_cli.py: 28 + 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: 6 + 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..689215c882 --- /dev/null +++ b/tests/scripts/test_check_ledger_references.py @@ -0,0 +1,177 @@ +"""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", + "see TASK-4-5", + "TASK-9 was the task", + "per cq-2", + "resolved by cq-10", + ], + ) + 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 + ], + ) + 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 + + +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")) + + +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"] + + +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} + + +class TestRepoBaselineIsClean: + """The committed baseline must match the live corpus, so a fresh checkout + runs clean (no spurious advisory noise on unrelated PRs).""" + + def test_no_net_new_against_committed_baseline(self) -> None: + findings = scan_all(_mod.REPO_ROOT) + baseline = load_baseline() + net_new = evaluate(findings, baseline) + assert net_new == [], ( + "Committed baseline is stale. Run " + "`scripts/check-ledger-references.py --update-baseline`. Net-new: " + + ", ".join(f"{f.rel} ({f.count})" for f in net_new) + ) From d31cefb60a6d588f9819aacce324cf23840f0c86 Mon Sep 17 00:00:00 2001 From: jwbron <8340608+jwbron@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:21:53 +0000 Subject: [PATCH 2/6] Refresh ledger-reference baseline after merging main Merging main into the PR branch pulls in #3332's session-store files (session_state_store.py, session_state.py, session_state_sync.py) and edits to concurrent_executor.py / consensus_wrapper.py, which add SDLC ledger tokens (slice-N / TASK-N / cq-N) not present in the committed baseline. The advisory ratchet's TestRepoBaselineIsClean flagged these as net-new in the pull/3333/merge corpus that CI tests. Regenerated via scripts/check-ledger-references.py --update-baseline so a fresh checkout of the merged corpus scans clean. --- scripts/ledger-references-baseline.yaml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/ledger-references-baseline.yaml b/scripts/ledger-references-baseline.yaml index f037ceec0d..437ee8558e 100644 --- a/scripts/ledger-references-baseline.yaml +++ b/scripts/ledger-references-baseline.yaml @@ -39,8 +39,8 @@ files: orchestrator/agent_model_resolution.py: 3 orchestrator/agent_salvage.py: 1 orchestrator/cli.py: 1 - orchestrator/concurrent_executor.py: 18 - orchestrator/consensus_wrapper.py: 13 + orchestrator/concurrent_executor.py: 19 + orchestrator/consensus_wrapper.py: 14 orchestrator/dag_visualizer.py: 1 orchestrator/env_config.py: 2 orchestrator/event_loop.py: 26 @@ -81,7 +81,9 @@ files: orchestrator/routes/messages.py: 2 orchestrator/routes/phases.py: 7 orchestrator/routes/pipelines.py: 232 + orchestrator/routes/session_state.py: 1 orchestrator/routes/signals.py: 13 + orchestrator/session_state_store.py: 2 orchestrator/slice_scheduler.py: 4 orchestrator/stacked_pr_reconciler.py: 10 orchestrator/startup_reconciliation.py: 10 @@ -96,6 +98,7 @@ files: sandbox/egg_agent_tools/handlers/progress.py: 1 sandbox/egg_agent_tools/handlers/task.py: 1 sandbox/egg_lib/orch_cli.py: 28 + 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 From 2545001e02e54417e28cb7c149d9716ce816d0ca Mon Sep 17 00:00:00 2001 From: "james-in-a-box[bot]" <246424927+james-in-a-box[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:41:40 +0000 Subject: [PATCH 3/6] Fix checks: restore #2548 doc cross-refs failing unit tests docs/reference/orchestrator-cli.md was missing the #2548 cross-reference that test_cross_references_issue_2548 pins, and the egg//context clarification in concurrent-execution.md did not share a paragraph with a slice-1 mention as test_slice_1_paragraph_ties_to_context_branch requires. Add the #2548 link to the Context PR Surfaces field intro and tie the no-separate-context-branch note to slice-1 base resolution. --- docs/guides/concurrent-execution.md | 5 ++++- docs/reference/orchestrator-cli.md | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/guides/concurrent-execution.md b/docs/guides/concurrent-execution.md index 263a85e61d..f339eabf3d 100644 --- a/docs/guides/concurrent-execution.md +++ b/docs/guides/concurrent-execution.md @@ -1019,7 +1019,10 @@ obligations) so reviewers approaching any slice PR see the strategic direction below it. The pipeline work branch is itself the context PR's head — there is no -separate `egg//context` doc-only branch. The open path is a single +separate `egg//context` doc-only branch, so slice-1 resolves its +base to `egg//work` (the Context PR head), **not** to an +`egg//context` branch: slice-1 stacks directly on the work branch +that carries the program framing. The open path is a single idempotent gateway call: `GatewayClient.lookup_open_pr("egg//work", base)` runs `gh pr list --head --base --state open --json number` first; on hit, the existing PR number is reused, on miss diff --git a/docs/reference/orchestrator-cli.md b/docs/reference/orchestrator-cli.md index a3b10299c7..79f06eceef 100644 --- a/docs/reference/orchestrator-cli.md +++ b/docs/reference/orchestrator-cli.md @@ -663,7 +663,7 @@ gh pr view gh pr list --head egg//work --base --state open ``` -The contract's `PRMetadata` carries the context-PR fields below. The single work branch is itself the Context PR's head, so no separate branch-framing fields are needed; see the [v1.1 → v1.2 schema migration note](../architecture/sdlc-pipeline.md#schema-v11--v12-migration-note-2777) for the field-name history. The context-PR fields are: +The contract's `PRMetadata` carries the context-PR fields below. The single work branch is itself the Context PR's head, so no separate branch-framing fields are needed; see the [v1.1 → v1.2 schema migration note](../architecture/sdlc-pipeline.md#schema-v11--v12-migration-note-2777) for the field-name history. The CLI-facing surfacing of these `pr.context_*` fields — and the per-slice BRC-history file naming they pair with — is owned by [#2548](https://github.com/jwbron/egg/issues/2548); read it for the rationale behind exposing the context PR through contract metadata rather than dedicated `egg-orch` verbs. The context-PR fields are: | Field | Schema | Author | Description | |-------|--------|--------|-------------| From e5ceb22c870cf8fa3ca8a465c9562066fabd95af Mon Sep 17 00:00:00 2001 From: James Wiesebron Date: Fri, 26 Jun 2026 22:26:29 -0700 Subject: [PATCH 4/6] Scope to ledger files; baseline absorbs #3325 doc counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the bundled #2548 doc edits (concurrent-execution.md, orchestrator-cli.md) that an autofixer added to chase two doc-test failures pre-existing on main. Those failures are unrelated to #3328 and are owned by the separate, green PR #3325 — keeping the edits here duplicates #3325 and would conflict on whichever lands second. Refresh the ledger baseline so the self-test stays green in both merge orderings: concurrent-execution.md goes 15->16, matching #3325's single added slice-1 token (the context-PR base clarification). With the baseline at the post-#3325 count and this PR's own tree doc-free, net-new is empty whether or not #3325 has landed yet. This PR is now scoped to its three ledger files. It still needs #3325 on main to clear the #2548 doc tests on the merge tree. --- docs/guides/concurrent-execution.md | 5 +---- docs/reference/orchestrator-cli.md | 2 +- scripts/ledger-references-baseline.yaml | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/guides/concurrent-execution.md b/docs/guides/concurrent-execution.md index f339eabf3d..263a85e61d 100644 --- a/docs/guides/concurrent-execution.md +++ b/docs/guides/concurrent-execution.md @@ -1019,10 +1019,7 @@ obligations) so reviewers approaching any slice PR see the strategic direction below it. The pipeline work branch is itself the context PR's head — there is no -separate `egg//context` doc-only branch, so slice-1 resolves its -base to `egg//work` (the Context PR head), **not** to an -`egg//context` branch: slice-1 stacks directly on the work branch -that carries the program framing. The open path is a single +separate `egg//context` doc-only branch. The open path is a single idempotent gateway call: `GatewayClient.lookup_open_pr("egg//work", base)` runs `gh pr list --head --base --state open --json number` first; on hit, the existing PR number is reused, on miss diff --git a/docs/reference/orchestrator-cli.md b/docs/reference/orchestrator-cli.md index 79f06eceef..a3b10299c7 100644 --- a/docs/reference/orchestrator-cli.md +++ b/docs/reference/orchestrator-cli.md @@ -663,7 +663,7 @@ gh pr view gh pr list --head egg//work --base --state open ``` -The contract's `PRMetadata` carries the context-PR fields below. The single work branch is itself the Context PR's head, so no separate branch-framing fields are needed; see the [v1.1 → v1.2 schema migration note](../architecture/sdlc-pipeline.md#schema-v11--v12-migration-note-2777) for the field-name history. The CLI-facing surfacing of these `pr.context_*` fields — and the per-slice BRC-history file naming they pair with — is owned by [#2548](https://github.com/jwbron/egg/issues/2548); read it for the rationale behind exposing the context PR through contract metadata rather than dedicated `egg-orch` verbs. The context-PR fields are: +The contract's `PRMetadata` carries the context-PR fields below. The single work branch is itself the Context PR's head, so no separate branch-framing fields are needed; see the [v1.1 → v1.2 schema migration note](../architecture/sdlc-pipeline.md#schema-v11--v12-migration-note-2777) for the field-name history. The context-PR fields are: | Field | Schema | Author | Description | |-------|--------|--------|-------------| diff --git a/scripts/ledger-references-baseline.yaml b/scripts/ledger-references-baseline.yaml index 437ee8558e..783b0d1695 100644 --- a/scripts/ledger-references-baseline.yaml +++ b/scripts/ledger-references-baseline.yaml @@ -13,7 +13,7 @@ files: docs/architecture/sdlc-pipeline.md: 2 docs/architecture/slice-dag.md: 13 docs/development/STRUCTURE.md: 5 - docs/guides/concurrent-execution.md: 15 + docs/guides/concurrent-execution.md: 16 docs/guides/decomposition-pattern.md: 1 docs/guides/pipeline-health-monitoring.md: 6 docs/guides/sdlc-pipeline.md: 7 From 4e6fa8bb65939b6be25b294530621ab61ce79d83 Mon Sep 17 00:00:00 2001 From: "egg-reviewer[bot]" <261018737+egg-reviewer[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 05:53:07 +0000 Subject: [PATCH 5/6] Address review: dethrone blocking self-test; case-insensitive slice/cq; prune traversal - Remove the committed-baseline freshness check from the blocking unit suite; the advisory script (make lint-custom) is the --update-baseline reminder, keeping the ratchet truly advisory (per #3328 intent). - Match slice-N / cq-N case-insensitively so sentence-initial Slice-4 / CQ-2 are caught; keep TASK-N uppercase-only (lowercase task-N is live runtime vocabulary). Baseline regenerated to absorb the newly-caught capitalized Slice-N tokens. - Apply is_test_path to the markdown loop too (symmetry with .py). - Prune .git/.venv/node_modules/.egg-state/test dirs during traversal via os.walk instead of rglob-then-filter. - Clarify docstring: every line is scanned, not just docstrings/comments. --- scripts/check-ledger-references.py | 82 ++++++++++++++----- scripts/ledger-references-baseline.yaml | 36 ++++---- tests/scripts/test_check_ledger_references.py | 39 ++++++--- 3 files changed, 104 insertions(+), 53 deletions(-) diff --git a/scripts/check-ledger-references.py b/scripts/check-ledger-references.py index e63b8b11f9..b01b21c9b1 100755 --- a/scripts/check-ledger-references.py +++ b/scripts/check-ledger-references.py @@ -29,9 +29,12 @@ Tokens detected --------------- -- ``slice-N`` -- per-slice rollout narration ("added in slice-4"). +- ``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"). -- ``cq-N`` -- HITL clarifying-question iteration ids. + 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 @@ -41,8 +44,11 @@ Scope and false-positive controls ---------------------------------- - Scanned: markdown across the repo plus non-test Python under the source - roots (docstrings/inline comments). Test files are excluded -- fixtures use - ``slice-N`` / ``task-N-N`` ids as live data, not as documentation. + 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 @@ -64,6 +70,7 @@ from __future__ import annotations import argparse +import os import re import sys from dataclasses import dataclass @@ -95,10 +102,21 @@ # 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"\bslice-\d+\b" # per-slice rollout narration - r"|\bTASK-\d+(?:-\d+)?\b" # plan task ids used as a change-log - r"|\bcq-\d+\b" # HITL clarifying-question iteration ids + 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. @@ -150,27 +168,47 @@ def is_test_path(rel: Path) -> bool: return any(part in EXCLUDED_DIR_NAMES for part in rel.parts) -def iter_scanned_files(repo_root: Path = REPO_ROOT) -> list[Path]: - """Yield markdown (repo-wide) + non-test Python (source roots), sorted.""" +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() - for p in repo_root.rglob("*.md"): - rel = p.relative_to(repo_root) - if is_excluded(rel.as_posix()): - continue - out.append(p) - + out = _walk_in_scope(repo_root, ".md", repo_root) for root_name in SOURCE_ROOTS: root = repo_root / root_name - if not root.is_dir(): - continue - for p in root.rglob("*.py"): - rel = p.relative_to(repo_root) - if is_test_path(rel) or is_excluded(rel.as_posix()): - continue - out.append(p) + 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() diff --git a/scripts/ledger-references-baseline.yaml b/scripts/ledger-references-baseline.yaml index 783b0d1695..b567a16c94 100644 --- a/scripts/ledger-references-baseline.yaml +++ b/scripts/ledger-references-baseline.yaml @@ -10,10 +10,10 @@ files: docs/architecture/logging.md: 1 docs/architecture/on-demand-agent-lifecycle.md: 2 docs/architecture/overseer.md: 3 - docs/architecture/sdlc-pipeline.md: 2 + docs/architecture/sdlc-pipeline.md: 3 docs/architecture/slice-dag.md: 13 docs/development/STRUCTURE.md: 5 - docs/guides/concurrent-execution.md: 16 + 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 @@ -40,16 +40,16 @@ files: orchestrator/agent_salvage.py: 1 orchestrator/cli.py: 1 orchestrator/concurrent_executor.py: 19 - orchestrator/consensus_wrapper.py: 14 + orchestrator/consensus_wrapper.py: 15 orchestrator/dag_visualizer.py: 1 orchestrator/env_config.py: 2 - orchestrator/event_loop.py: 26 + orchestrator/event_loop.py: 31 orchestrator/events.py: 6 - orchestrator/gateway_client.py: 22 - orchestrator/health_checks/README.md: 2 - orchestrator/health_checks/detection_plane.py: 10 + 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: 2 + 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 @@ -71,40 +71,40 @@ files: orchestrator/overseer/issue_filer.py: 1 orchestrator/overseer/monitor.py: 15 orchestrator/overseer/self_monitor.py: 1 - orchestrator/peer_consensus.py: 4 + orchestrator/peer_consensus.py: 5 orchestrator/pr_obligations.py: 2 orchestrator/routes/artifacts.py: 6 - orchestrator/routes/consensus.py: 6 + orchestrator/routes/consensus.py: 7 orchestrator/routes/contracts.py: 1 - orchestrator/routes/event_prompt.py: 48 + 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: 232 + orchestrator/routes/pipelines.py: 238 orchestrator/routes/session_state.py: 1 - orchestrator/routes/signals.py: 13 + 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: 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: 22 - sandbox/egg_agent_tools/handlers/message.py: 1 + 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: 28 + 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: 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 diff --git a/tests/scripts/test_check_ledger_references.py b/tests/scripts/test_check_ledger_references.py index 689215c882..1825fc0083 100644 --- a/tests/scripts/test_check_ledger_references.py +++ b/tests/scripts/test_check_ledger_references.py @@ -35,10 +35,13 @@ class TestPattern: [ "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: @@ -53,6 +56,11 @@ def test_matches_ledger_tokens(self, text: str) -> None: "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: @@ -116,6 +124,15 @@ def test_excluded_prefixes_skipped(self, tmp_path: Path) -> None: 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"] + class TestEvaluate: def test_over_baseline_is_net_new(self) -> None: @@ -162,16 +179,12 @@ def test_update_baseline_snapshots_current( assert load_baseline(path) == {"docs/a.md": 2} -class TestRepoBaselineIsClean: - """The committed baseline must match the live corpus, so a fresh checkout - runs clean (no spurious advisory noise on unrelated PRs).""" - - def test_no_net_new_against_committed_baseline(self) -> None: - findings = scan_all(_mod.REPO_ROOT) - baseline = load_baseline() - net_new = evaluate(findings, baseline) - assert net_new == [], ( - "Committed baseline is stale. Run " - "`scripts/check-ledger-references.py --update-baseline`. Net-new: " - + ", ".join(f"{f.rel} ({f.count})" for f in net_new) - ) +# 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. From 5abfb5c1ce4b68ea96a616db8e724c01713f6111 Mon Sep 17 00:00:00 2001 From: "egg-reviewer[bot]" <261018737+egg-reviewer[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 06:14:53 +0000 Subject: [PATCH 6/6] Exclude singular test/ dirs; harden scan_file against unreadable paths --- scripts/check-ledger-references.py | 15 +++++++++--- tests/scripts/test_check_ledger_references.py | 24 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/scripts/check-ledger-references.py b/scripts/check-ledger-references.py index b01b21c9b1..5ff1d87818 100755 --- a/scripts/check-ledger-references.py +++ b/scripts/check-ledger-references.py @@ -96,8 +96,10 @@ ) # Directory names under a source root that are skipped (tests use slice/task -# ids as live fixture data, not as human-facing documentation). -EXCLUDED_DIR_NAMES = frozenset({"tests", "__pycache__"}) +# 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``, @@ -218,7 +220,14 @@ def iter_scanned_files(repo_root: Path = REPO_ROOT) -> list[Path]: 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() - text = path.read_text(encoding="utf-8", errors="replace") + 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): diff --git a/tests/scripts/test_check_ledger_references.py b/tests/scripts/test_check_ledger_references.py index 1825fc0083..3e1cb51678 100644 --- a/tests/scripts/test_check_ledger_references.py +++ b/tests/scripts/test_check_ledger_references.py @@ -87,6 +87,15 @@ def test_no_matches_is_zero(self, tmp_path: Path) -> None: 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: @@ -104,6 +113,11 @@ def test_test_files_skipped(self) -> None: 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: @@ -133,6 +147,16 @@ def test_test_dir_markdown_excluded(self, tmp_path: Path) -> None: 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: