cc-os/plugins/os-doc-hygiene/scripts/scanner.py

1020 lines
39 KiB
Python
Raw Normal View History

"""
doc-hygiene scanner deterministic, no model, no network.
Produces an intermediate artifact:
{
"project_root": str,
"scope_globs": [str, ...],
"excluded_dirs": [str, ...],
"files_scanned": int,
"shortlist": [str, ...], # project-root-relative paths
"signals": { path: [{"name": str, "detail": str}, ...] }
}
Signal shape matches entries[].signals in the frozen report schema.
"""
from __future__ import annotations
import fnmatch
import json
import os
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Iterator, List, Optional
from token_estimator import TokenEstimator, default_estimator
try: # rulebook.py is optional-at-import (requires Python >= 3.13 for its
# own glob.translate usage); the scanner itself has no hard dependency
# on it — a Scanner constructed without a `rulebook=` argument behaves
# exactly as before this change (design.md Decision 2).
from rulebook import RuleMatch # noqa: F401
except Exception: # pragma: no cover - defensive, see comment above
RuleMatch = object # type: ignore
# ---------------------------------------------------------------------------
# Glob matching (Python 3.14-compatible)
# ---------------------------------------------------------------------------
def _file_matches_glob(rel_path_str: str, glob_pattern: str) -> bool:
"""Return True if *rel_path_str* matches *glob_pattern*.
Works around a Python 3.14 behavior where ``Path('a.md').match('**/*.md')``
returns False for root-level files (no parent component). For ``**/X``
patterns we fall back to matching just ``X`` against the filename so that
root-level files are included.
"""
p = Path(rel_path_str)
# Try direct PurePath.match() first — handles nested files correctly
if p.match(glob_pattern):
return True
# Fallback for '**/<pat>' patterns: match just the trailing pattern against
# the filename, so root-level 'a.md' is caught by '**/*.md'.
if glob_pattern.startswith("**/"):
suffix_pat = glob_pattern[3:]
if fnmatch.fnmatch(p.name, suffix_pat):
return True
return False
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
DEFAULT_SCOPE_GLOBS: List[str] = ["**/*.md"]
DEFAULT_MAX_LINES: int = 400
DEFAULT_MAX_TOKENS: int = 4000
DEFAULT_EXCLUDED_DIRS: List[str] = [
"build",
"vendor",
"archive",
"graphify-out",
".cc-os",
".dochygiene", # legacy state dir (pre-ADR-027); still excluded for old trees
# Test-fixture dirs intentionally contain stale/bloated docs as scanner
# inputs. Bare directory-*name* match (consistent with the entries above):
# any child dir named "fixtures" at any depth is pruned — same breadth as
# "archive"/"vendor".
"fixtures",
# Golden classifier inputs also contain deliberately-stale docs, but a bare
# "golden" name-match would silently skip legitimate `golden/` dirs in
# unrelated projects (doc-hygiene installs globally). So this entry is
# PATH-AWARE: an entry containing "/" is matched as a parent/child name pair
# anywhere in the tree — here, a dir named "golden" whose immediate parent is
# named "examples". A root-level `golden/` with a different parent is still
# scanned. See Scanner._is_excluded_child for the matcher.
"examples/golden",
]
# ---------------------------------------------------------------------------
# Helpers — frontmatter
# ---------------------------------------------------------------------------
def _parse_frontmatter_value(path: Path, key: str) -> Optional[str]:
"""Return the string value of *key* in YAML frontmatter, or None.
Reads only the frontmatter block (between the first pair of ``---`` lines).
Uses stdlib only (no PyYAML).
"""
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return None
in_block = True
for line in lines[1:]:
stripped = line.strip()
if stripped == "---" or stripped == "...":
break
# Match: key: value (simple scalar, no quoting)
m = re.match(r"^(\w[\w\-]*):\s*(.+)$", stripped)
if m and m.group(1) == key:
return m.group(2).strip()
return None
def _is_frozen(path: Path) -> bool:
"""Return True if the file has ``hygiene: frozen`` in its frontmatter."""
return _parse_frontmatter_value(path, "hygiene") == "frozen"
# ---------------------------------------------------------------------------
# Helpers — append-only detection (content-based, no git)
# ---------------------------------------------------------------------------
_APPEND_ONLY_MARKER_RE = re.compile(
r"<!--\s*append[-_]only\s*-->|<!--\s*hygiene:\s*append[-_]only\s*-->",
re.IGNORECASE,
)
# A dated section header: starts with a 4-digit year, or ISO date, or
# common changelog-style prefix e.g. "## 2026-06-18" or "# v1.2 (2026-06-18)"
_DATED_HEADER_RE = re.compile(
r"^#{1,3}\s+(?:v?\d+\.\d|\d{4}[-/]\d{2}[-/]\d{2}|\d{4}-\d{2})"
)
def _is_append_only(path: Path) -> bool:
"""Heuristic: return True if the file looks like an append-only log.
Two independent paths (either is sufficient):
1. File contains an explicit ``<!-- append-only -->`` marker.
2. Every non-blank, non-header line cluster lives under a dated section
header, and the section count is 2 (a multi-entry changelog pattern).
Deterministic: reads content only, no git, no network.
"""
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return False
# Path 1: explicit marker anywhere in the file
if _APPEND_ONLY_MARKER_RE.search(text):
return True
# Path 2: structural — look for ≥2 dated-section headers and NO prose
# outside of dated sections (aside from a preamble).
lines = text.splitlines()
dated_header_count = 0
in_dated_section = False
non_section_content_found = False
preamble_done = False # first dated header ends the preamble
for line in lines:
stripped = line.strip()
if not stripped:
continue
if _DATED_HEADER_RE.match(stripped):
dated_header_count += 1
in_dated_section = True
preamble_done = True
elif not preamble_done:
# still in preamble (before first dated header) — allowed
pass
elif not in_dated_section:
# content outside any dated section after preamble
non_section_content_found = True
break
if dated_header_count >= 2 and not non_section_content_found:
return True
return False
# ---------------------------------------------------------------------------
# Helpers — .dochygiene-ignore
# ---------------------------------------------------------------------------
def _load_ignore_patterns(root: Path) -> List[str]:
"""Load patterns from ``<root>/.dochygiene-ignore``. One pattern per line."""
ignore_file = root / ".dochygiene-ignore"
try:
text = ignore_file.read_text(encoding="utf-8", errors="replace")
return [
line.strip()
for line in text.splitlines()
if line.strip() and not line.strip().startswith("#")
]
except OSError:
return []
def _matches_ignore(rel_path: str, patterns: List[str]) -> bool:
"""Return True if *rel_path* matches any ignore pattern (fnmatch)."""
for pat in patterns:
if fnmatch.fnmatch(rel_path, pat) or fnmatch.fnmatch(
os.path.basename(rel_path), pat
):
return True
return False
# ---------------------------------------------------------------------------
# Signal computation
# ---------------------------------------------------------------------------
class SignalComputer:
"""Compute objective signals for a single file.
Injected dependencies
----------------------
git_log_fn : callable(path: Path) -> list[str]
Returns git log lines for the file. Production uses ``_git_log_real``;
tests pass a fake. Pass ``None`` to disable git-based signals.
now_fn : callable() -> float
Returns current time as a Unix timestamp.
max_lines : int
Line-count threshold for the ``file_length`` signal.
max_tokens : int
Estimated-token threshold for the ``file_length`` signal.
token_estimator : TokenEstimator, optional
Injectable estimator used for the ``file_length`` signal's token
count. Defaults to ``token_estimator.default_estimator()``. Tests
can inject a deterministic stub.
"""
def __init__(
self,
root: Path,
git_log_fn: Optional[Callable[[Path], List[str]]] = None,
now_fn: Optional[Callable[[], float]] = None,
max_lines: int = DEFAULT_MAX_LINES,
max_tokens: int = DEFAULT_MAX_TOKENS,
token_estimator: Optional[TokenEstimator] = None,
) -> None:
self._root = root
self._git_log_fn = git_log_fn
self._now_fn = now_fn or (lambda: __import__("time").time())
self._max_lines = max_lines
self._max_tokens = max_tokens
self._token_estimator = token_estimator or default_estimator()
# ------------------------------------------------------------------
# Public
# ------------------------------------------------------------------
def compute(self, path: Path) -> List[dict]:
"""Return a list of signal dicts for *path*."""
signals: List[dict] = []
rel = str(path.relative_to(self._root))
signals.extend(self._broken_references(path, rel))
signals.extend(self._version_skew(path))
signals.extend(self._edit_recency_vs_churn(path))
signals.extend(self._location_signals(path, rel))
signals.extend(self._archive_to_live_ratio(path))
signals.extend(self._frontmatter_markers(path))
signals.extend(self._file_length(path))
return signals
# ------------------------------------------------------------------
# Individual signal detectors
# ------------------------------------------------------------------
def _broken_references(self, path: Path, rel: str) -> List[dict]:
"""Detect Markdown links/images whose targets do not exist on disk."""
signals = []
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return signals
# Match [text](target) — skip anchors, URLs, and mailto
link_re = re.compile(r"\[(?:[^\[\]]*)\]\(([^)]+)\)")
for m in link_re.finditer(text):
target = m.group(1).split("#")[0].strip() # strip fragment
if not target:
continue
if re.match(r"https?://|mailto:", target):
continue
# Resolve relative to the file's directory
if target.startswith("/"):
resolved = self._root / target.lstrip("/")
else:
resolved = path.parent / target
if not resolved.exists():
signals.append(
{
"name": "broken_reference",
"detail": f"links to '{target}' which does not exist",
}
)
return signals
def _version_skew(self, path: Path) -> List[dict]:
"""Detect explicit version declarations that differ from the repo version.
Looks for patterns like ``version: X.Y.Z`` or ``# vX.Y.Z`` in the file
and compares against a ``pyproject.toml`` or ``package.json`` at the
project root, if present. Objective fact only no judgment.
"""
signals = []
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return signals
# Find declared versions in the doc (e.g. ``version: 1.2.3``)
doc_versions = re.findall(r"\bversion[:\s]+['\"]?(\d+\.\d+[\.\d]*)['\"]?", text, re.IGNORECASE)
if not doc_versions:
return signals
# Try to read authoritative version from pyproject.toml or package.json
repo_version: Optional[str] = None
pyproject = self._root / "pyproject.toml"
if pyproject.exists():
try:
pp_text = pyproject.read_text(encoding="utf-8", errors="replace")
m = re.search(r'^version\s*=\s*["\'](\d+\.\d+[\.\d]*)["\']', pp_text, re.MULTILINE)
if m:
repo_version = m.group(1)
except OSError:
pass
if repo_version is None:
pkg_json = self._root / "package.json"
if pkg_json.exists():
try:
pkg = json.loads(pkg_json.read_text(encoding="utf-8", errors="replace"))
repo_version = pkg.get("version")
except (OSError, json.JSONDecodeError):
pass
if repo_version is None:
return signals
for doc_ver in doc_versions:
if doc_ver != repo_version:
signals.append(
{
"name": "version_skew",
"detail": (
f"doc declares version {doc_ver!r} but repo version is {repo_version!r}"
),
}
)
break # one signal per file is sufficient
return signals
def _edit_recency_vs_churn(self, path: Path) -> List[dict]:
"""Flag files edited very recently relative to their historical git churn.
Signal: file was modified within the last 7 days (mtime) but has a
high commit frequency ( 5 commits in git history), suggesting it is
actively evolving and may have outpaced its documentation.
If no git_log_fn is provided this signal is skipped.
"""
if self._git_log_fn is None:
return []
try:
stat = path.stat()
except OSError:
return []
age_days = (self._now_fn() - stat.st_mtime) / 86400.0
if age_days > 7:
return [] # not recently edited
try:
log_lines = self._git_log_fn(path)
except Exception:
return []
commit_count = len([l for l in log_lines if l.strip()])
if commit_count >= 5:
return [
{
"name": "edit_recency_vs_churn",
"detail": (
f"file modified {age_days:.1f} days ago and has {commit_count} commits"
" — actively changing, may need review"
),
}
]
return []
def _location_signals(self, path: Path, rel: str) -> List[dict]:
"""Flag files whose location suggests staleness risk.
Detects:
- Files named ``*old*``, ``*deprecated*``, ``*legacy*``, ``*obsolete*``
that are NOT inside an archive/ dir (which is excluded).
- Files in a ``docs/`` subdirectory that have no counterpart in the
current codebase module they claim to document (heuristic: if the
filename mentions a module that no longer exists).
"""
signals = []
basename = path.stem.lower()
stale_names = {"old", "deprecated", "legacy", "obsolete", "archive"}
for keyword in stale_names:
if keyword in basename:
signals.append(
{
"name": "stale_name_location",
"detail": (
f"filename '{path.name}' contains '{keyword}'"
"may indicate superseded content"
),
}
)
break
return signals
def _archive_to_live_ratio(self, path: Path) -> List[dict]:
"""Detect high proportion of 'archived' / resolved-problem content.
Intra-doc heuristic: count lines under headings that contain
'archive', 'resolved', 'completed', 'done', 'old', 'deprecated',
'legacy' vs total non-blank lines. If > 60% of content is under
such headings, emit a signal.
"""
signals = []
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return signals
lines = text.splitlines()
archive_heading_re = re.compile(
r"^#{1,4}\s+.*\b(archive|resolved|completed|done|old|deprecated|legacy)\b",
re.IGNORECASE,
)
in_archive_section = False
archive_lines = 0
total_lines = 0
for line in lines:
stripped = line.strip()
if not stripped:
continue
total_lines += 1
if re.match(r"^#{1,4}\s+", stripped):
in_archive_section = bool(archive_heading_re.match(stripped))
if in_archive_section:
archive_lines += 1
if total_lines > 0 and (archive_lines / total_lines) > 0.6:
pct = int(100 * archive_lines / total_lines)
signals.append(
{
"name": "archive_to_live_ratio",
"detail": (
f"{pct}% of content is under archive/resolved/completed headings"
),
}
)
return signals
def _frontmatter_markers(self, path: Path) -> List[dict]:
"""Emit signals for notable frontmatter keys that flag provisional content.
Objective markers only: ``status: draft``, ``status: provisional``,
``status: wip``, ``draft: true``. No classification just fact.
"""
signals = []
for key, expected_values in [
("status", {"draft", "provisional", "wip", "todo"}),
("draft", {"true", "yes"}),
]:
value = _parse_frontmatter_value(path, key)
if value and value.lower() in expected_values:
signals.append(
{
"name": "frontmatter_marker",
"detail": f"frontmatter has {key}: {value!r}",
}
)
return signals
def _file_length(self, path: Path) -> List[dict]:
"""Flag files whose line count or estimated token count exceeds threshold.
Objective fact only: reports the measured line count and estimated
token count against the configured thresholds. Fires if EITHER
threshold is exceeded. No classification.
"""
signals = []
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return signals
line_count = len(text.splitlines())
token_count = self._token_estimator.estimate(text)
if line_count > self._max_lines or token_count > self._max_tokens:
token_count_k = token_count / 1000.0
signals.append(
{
"name": "file_length",
"detail": (
f"{line_count} lines (~{token_count_k:.1f}k tokens est), "
f"threshold {self._max_lines} lines / {self._max_tokens} tokens"
),
}
)
return signals
# ---------------------------------------------------------------------------
# Production git helper
# ---------------------------------------------------------------------------
def _git_log_real(path: Path) -> List[str]:
"""Return one line per commit touching *path* (commit hash only)."""
try:
result = subprocess.run(
["git", "log", "--oneline", "--follow", "--", str(path)],
capture_output=True,
text=True,
timeout=10,
)
return result.stdout.splitlines()
except Exception:
return []
def _git_commit_time_real(path: Path) -> Optional[str]:
"""Return the ISO-8601 committer time of *path*'s most recent commit.
Returns ``None`` when the path is untracked (no commit history) or on
any subprocess failure callers fall back to filesystem mtime.
"""
try:
result = subprocess.run(
["git", "log", "-1", "--format=%cI", "--", str(path)],
capture_output=True,
text=True,
timeout=10,
)
out = result.stdout.strip()
return out or None
except Exception:
return None
# ---------------------------------------------------------------------------
# Lifecycle signal computation (design.md Decision 2 / 4)
# ---------------------------------------------------------------------------
_LIFECYCLE_SIGNAL_NAME = "lifecycle"
def _substitute_served_when_path(pattern: str, basename: str) -> str:
"""Substitute ``{id}``/``{name}`` in a served_when_path pattern with the
matched entry's basename."""
return pattern.replace("{id}", basename).replace("{name}", basename)
class LifecycleSignalBuilder:
"""Builds lifecycle signal dicts from rulebook matches.
Injected dependencies mirror ``SignalComputer``: a git-commit-time
provider and a clock, both fakeable for tests. Age/retention are the
only stateful parts retention ranking requires all matches for a rule
to be known first, so ``rank_temporary_matches`` is a separate pass run
once the whole walk has completed.
"""
def __init__(
self,
root: Path,
git_commit_time_fn: Optional[Callable[[Path], Optional[str]]] = None,
now_fn: Optional[Callable[[], float]] = None,
) -> None:
self._root = root
self._git_commit_time_fn = git_commit_time_fn
self._now_fn = now_fn or (lambda: __import__("time").time())
# ------------------------------------------------------------------
def age_days(self, rel_path: str, is_dir: bool) -> Optional[float]:
"""Age in days: git commit time, falling back to mtime for
untracked paths. Untracked directories use one stat() on the
directory inode itself (never a recursive max-mtime walk)."""
abs_path = self._root / rel_path
commit_iso: Optional[str] = None
if self._git_commit_time_fn is not None:
try:
commit_iso = self._git_commit_time_fn(abs_path)
except Exception:
commit_iso = None
if commit_iso:
try:
commit_dt = datetime.fromisoformat(commit_iso)
commit_ts = commit_dt.timestamp()
return (self._now_fn() - commit_ts) / 86400.0
except ValueError:
pass # fall through to mtime
# Untracked (or unparseable commit time): filesystem mtime. For a
# directory this is a single stat() on the directory inode itself —
# never a recursive walk over its contents (design.md Decision 4).
try:
stat = abs_path.stat()
except OSError:
return None
return (self._now_fn() - stat.st_mtime) / 86400.0
# ------------------------------------------------------------------
def build(self, rel_path: str, match: "RuleMatch", is_dir: bool) -> dict:
"""Build the lifecycle signal dict for a matched (non-ignore) rule."""
rule = match.rule
lifetime = rule.get("lifetime")
basename = Path(rel_path).name
sig: dict = {
"name": _LIFECYCLE_SIGNAL_NAME,
"rule_ref": rule.get("glob"),
"lifetime": lifetime,
"detail": f"matched lifecycle rule {rule.get('glob')!r} (lifetime={lifetime})",
}
if rule.get("extract"):
sig["extract"] = True
served_when_path = rule.get("served_when_path")
served_when = rule.get("served_when")
if served_when_path:
resolved = _substitute_served_when_path(served_when_path, basename)
satisfied = (self._root / resolved).exists()
sig["served"] = {
"kind": "scanner-proven",
"served_when_path": served_when_path,
"resolved_path": resolved,
"satisfied": satisfied,
}
elif served_when:
sig["served"] = {
"kind": "classifier-judged",
"served_when": served_when,
}
if lifetime == "temporary":
sig["age_days"] = self.age_days(rel_path, is_dir)
sig["retain_recent"] = rule.get("retain_recent", 3)
sig["max_age_days"] = rule.get("max_age_days", 3)
return sig
def rank_temporary_matches(entries: List[dict]) -> None:
"""Mutate *entries*' lifecycle signal dicts in-place with retention info.
*entries* is a list of ``{"rule": <rule dict>, "signal": <sig dict>}``
covering every temporary-lifetime match produced during a single scan.
Grouped by rule identity (the same rule dict object is shared across all
its matches within one Rulebook), ranked newest-first by age_days: the
top ``retain_recent`` are always kept; rank ``retain_recent + 1`` or
older is flagged deletable once it exceeds ``max_age_days``.
"""
groups: dict = {}
for entry in entries:
groups.setdefault(id(entry["rule"]), []).append(entry)
for group in groups.values():
# Newest first: smallest age_days first. None age sorts last.
group.sort(key=lambda e: (e["signal"].get("age_days") is None, e["signal"].get("age_days") or 0.0))
retain_recent = group[0]["signal"].get("retain_recent", 3) if group else 3
max_age_days = group[0]["signal"].get("max_age_days", 3) if group else 3
for rank, entry in enumerate(group, start=1):
sig = entry["signal"]
age = sig.get("age_days")
kept_by_rank = rank <= retain_recent
deletable = (not kept_by_rank) and (age is not None) and (age > max_age_days)
sig["retention"] = {
"rank": rank,
"kept": kept_by_rank,
"deletable": deletable,
}
# ---------------------------------------------------------------------------
# Scanner
# ---------------------------------------------------------------------------
class Scanner:
"""Deterministic doc-hygiene scanner.
Parameters
----------
root : Path
Resolved project root (absolute). All output paths are relative to
this directory.
scope_globs : list[str]
Glob patterns to match candidate files (relative to *root*).
excluded_dirs : list[str]
Entries to prune during walk. A bare entry (no ``/``) is a directory
*name* matched at any depth; an entry of the form ``parent/child`` is a
path-aware parent/child name pair pruned only where the child's
immediate parent matches ``parent`` (depth-independent).
git_log_fn : callable, optional
Injected git-log provider. Default: real git subprocess.
now_fn : callable, optional
Injected clock. Default: ``time.time``.
max_lines : int
Line-count threshold for the ``file_length`` signal. Default 400.
max_tokens : int
Estimated-token threshold for the ``file_length`` signal. Default 4000.
token_estimator : TokenEstimator, optional
Injectable estimator for the ``file_length`` signal. Default:
``token_estimator.default_estimator()``.
"""
def __init__(
self,
root: Path,
scope_globs: Optional[List[str]] = None,
excluded_dirs: Optional[List[str]] = None,
git_log_fn: Optional[Callable[[Path], List[str]]] = None,
now_fn: Optional[Callable[[], float]] = None,
max_lines: int = DEFAULT_MAX_LINES,
max_tokens: int = DEFAULT_MAX_TOKENS,
token_estimator: Optional[TokenEstimator] = None,
rulebook: Optional["Any"] = None,
git_commit_time_fn: Optional[Callable[[Path], Optional[str]]] = None,
) -> None:
self._root = root.resolve()
self._scope_globs = scope_globs if scope_globs is not None else DEFAULT_SCOPE_GLOBS
# Always include .cc-os (canonical, ADR-027) and .dochygiene (legacy) in
# excluded dirs (self-exclusion invariant #9). Preserve insertion order
# so the artifact echoes the canonical default order (matches
# valid_report.json golden fixture).
base_excluded = excluded_dirs if excluded_dirs is not None else DEFAULT_EXCLUDED_DIRS
seen: set = set()
deduped: List[str] = []
for d in list(base_excluded) + [".cc-os", ".dochygiene"]:
if d not in seen:
seen.add(d)
deduped.append(d)
self._excluded_dirs: List[str] = deduped
# Partition the exclude list into two matchers (both echoed verbatim in
# the artifact's ``excluded_dirs``):
# * bare names (no "/") → prune any child dir with that exact name.
# * "parent/child" pairs → prune a child dir named ``child`` only
# when its immediate parent is ``parent``,
# anywhere in the tree (path-aware).
# Only the trailing two segments of a "/"-entry are used as the pair; the
# match is depth-independent (not anchored to the project root) so it
# catches e.g. ``doc-hygiene/examples/golden`` in a monorepo.
self._excluded_name_set: set = {d for d in deduped if "/" not in d}
self._excluded_pairs: List[tuple] = []
for d in deduped:
if "/" in d:
parent, child = d.rsplit("/", 1)
parent_name = parent.rsplit("/", 1)[-1] # trailing segment only
self._excluded_pairs.append((parent_name, child))
self._git_log_fn = git_log_fn
self._now_fn = now_fn
self._max_lines = max_lines
self._max_tokens = max_tokens
self._token_estimator = token_estimator
self._ignore_patterns: List[str] = _load_ignore_patterns(self._root)
self._rulebook = rulebook
self._lifecycle_builder = LifecycleSignalBuilder(
root=self._root,
git_commit_time_fn=git_commit_time_fn,
now_fn=now_fn,
)
# Directory-rule aggregate entries pruned during the walk (design.md
# Decision 2): populated by _walk_scoped, consumed by run().
self._pruned_dir_matches: List[tuple] = [] # (rel_path, RuleMatch)
def _is_excluded_child(self, parent_dirpath: str, child_name: str) -> bool:
"""Return True if walking would prune *child_name* under *parent_dirpath*.
Bare-name excludes match on ``child_name`` alone; path-aware excludes
(``parent/child``) match only when the child's immediate parent name
equals ``parent``.
"""
if child_name in self._excluded_name_set:
return True
parent_name = os.path.basename(parent_dirpath)
for ex_parent, ex_child in self._excluded_pairs:
if child_name == ex_child and parent_name == ex_parent:
return True
return False
# ------------------------------------------------------------------
# Public
# ------------------------------------------------------------------
def run(self) -> dict:
"""Execute the scan and return the intermediate artifact dict."""
signal_computer = SignalComputer(
root=self._root,
git_log_fn=self._git_log_fn,
now_fn=self._now_fn,
max_lines=self._max_lines,
max_tokens=self._max_tokens,
token_estimator=self._token_estimator,
)
ignore_patterns = self._ignore_patterns
files_scanned = 0
shortlist: List[str] = []
signals_map: dict = {}
# Collected for the post-walk retention-ranking pass (design.md
# Decision 4): every temporary-lifetime match, dir or file, in one
# flat list so rank_temporary_matches can group by rule identity.
temporary_entries: List[dict] = []
for path in self._walk_scoped():
files_scanned += 1
rel = str(path.relative_to(self._root))
# Exclusion pipeline (short-circuit, ordered per D7)
# (1) dir-prune already applied by _walk_scoped at walk time
# (2) ignore-match
if _matches_ignore(rel, ignore_patterns):
continue
# (3) frozen frontmatter
if _is_frozen(path):
continue
# (4) append-only
if _is_append_only(path):
continue
# Survived exclusions — compute signals
sigs = signal_computer.compute(path)
# Rulebook file-rule lifecycle signal attachment (design.md
# Decision 2): alongside any pre-existing objective signals.
if self._rulebook is not None:
match = self._rulebook.query(rel, is_dir=False)
if match is not None and not match.is_ignore:
lifecycle_sig = self._lifecycle_builder.build(
rel, match, is_dir=False
)
sigs = list(sigs) + [lifecycle_sig]
if lifecycle_sig.get("lifetime") == "temporary":
temporary_entries.append(
{"rule": match.rule, "signal": lifecycle_sig}
)
shortlist.append(rel)
if sigs:
signals_map[rel] = sigs
# Directory-rule aggregate entries pruned during the walk: exactly
# one shortlist/signal entry per matched directory (design.md
# Decision 2) — never one entry per file beneath it (those files
# were never opened).
for dir_rel, match in self._pruned_dir_matches:
lifecycle_sig = self._lifecycle_builder.build(
dir_rel, match, is_dir=True
)
shortlist.append(dir_rel)
signals_map[dir_rel] = [lifecycle_sig]
if lifecycle_sig.get("lifetime") == "temporary":
temporary_entries.append(
{"rule": match.rule, "signal": lifecycle_sig}
)
# Retention ranking requires every match for a rule to be known
# first, so this runs once, after the full walk (design.md
# Decision 4).
if temporary_entries:
rank_temporary_matches(temporary_entries)
return {
"project_root": str(self._root),
"scope_globs": self._scope_globs,
"excluded_dirs": self._excluded_dirs,
"files_scanned": files_scanned,
"shortlist": shortlist,
"signals": signals_map,
}
# ------------------------------------------------------------------
# Walk
# ------------------------------------------------------------------
def _walk_scoped(self) -> Iterator[Path]:
"""Yield files matching scope_globs, with excluded dirs pruned at walk time."""
self._pruned_dir_matches = []
for dirpath, dirnames, filenames in os.walk(str(self._root)):
# Prune excluded dirs IN-PLACE so os.walk never descends them (D7).
# Path-aware pairs (e.g. examples/golden) are matched against the
# current dirpath's basename so only the right parent prunes them.
dirnames[:] = [
d for d in dirnames if not self._is_excluded_child(dirpath, d)
]
# Rulebook-driven directory-rule pruning (design.md Decision 2).
# Checked BEFORE opening any file beneath a matched directory —
# a directory-rule match (including IGNORE-surface entries) is
# pruned from dirnames so os.walk never descends into it.
if self._rulebook is not None:
survivors: List[str] = []
for d in dirnames:
dir_abs = Path(dirpath) / d
dir_rel = str(dir_abs.relative_to(self._root))
match = self._rulebook.query(dir_rel, is_dir=True)
if match is None:
survivors.append(d)
continue
# Matched: prune regardless of is_ignore.
if not match.is_ignore:
self._pruned_dir_matches.append((dir_rel, match))
# IGNORE-surface matches: pruned with zero emission.
dirnames[:] = survivors
for filename in filenames:
filepath = Path(dirpath) / filename
rel = filepath.relative_to(self._root)
rel_str = str(rel)
for glob in self._scope_globs:
if _file_matches_glob(rel_str, glob):
yield filepath
break
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def _resolve_project_root(start: Path) -> Path:
"""Walk upward from *start* to find a .git directory; fallback to *start*."""
current = start.resolve()
for parent in [current, *current.parents]:
if (parent / ".git").exists():
return parent
return current
def main(argv: Optional[List[str]] = None) -> int:
import argparse
parser = argparse.ArgumentParser(
description="doc-hygiene scanner — emits intermediate artifact JSON"
)
parser.add_argument(
"--root",
default=None,
help="Project root (default: auto-resolved from cwd)",
)
parser.add_argument(
"--globs",
nargs="*",
default=None,
help="Scope globs (default: **/*.md)",
)
parser.add_argument(
"--excluded-dirs",
nargs="*",
default=None,
help="Directory names to exclude",
)
parser.add_argument(
"--max-lines",
type=int,
default=DEFAULT_MAX_LINES,
help=f"Line-count threshold for the file_length signal (default: {DEFAULT_MAX_LINES})",
)
parser.add_argument(
"--max-tokens",
type=int,
default=DEFAULT_MAX_TOKENS,
help=f"Estimated-token threshold for the file_length signal (default: {DEFAULT_MAX_TOKENS})",
)
args = parser.parse_args(argv)
root = Path(args.root) if args.root else _resolve_project_root(Path.cwd())
scanner = Scanner(
root=root,
scope_globs=args.globs,
excluded_dirs=args.excluded_dirs,
git_log_fn=_git_log_real,
git_commit_time_fn=_git_commit_time_real,
max_lines=args.max_lines,
max_tokens=args.max_tokens,
)
artifact = scanner.run()
print(json.dumps(artifact, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())