os-doc-hygiene: deterministic file_length scanner signal (issue #25 part 1)

Objective signal (line count + estimated tokens) fires when either
threshold is exceeded; defaults 400 lines / 4000 tokens, configurable via
constructor and --max-lines/--max-tokens. Injectable token estimator for
tests. Suite: 286 passed. Parts 2-3 (implementation-status distill +
index+PD convention ADR) remain human-gated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLeMz48rvG3s9XpAsDxeho
This commit is contained in:
jared 2026-07-13 08:06:48 -04:00
parent 46ebfb1b2f
commit ef8b7e4a52
2 changed files with 206 additions and 0 deletions

View File

@ -25,6 +25,8 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Callable, Iterator, List, Optional from typing import Callable, Iterator, List, Optional
from token_estimator import TokenEstimator, default_estimator
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Glob matching (Python 3.14-compatible) # Glob matching (Python 3.14-compatible)
@ -56,6 +58,8 @@ def _file_matches_glob(rel_path_str: str, glob_pattern: str) -> bool:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
DEFAULT_SCOPE_GLOBS: List[str] = ["**/*.md"] DEFAULT_SCOPE_GLOBS: List[str] = ["**/*.md"]
DEFAULT_MAX_LINES: int = 400
DEFAULT_MAX_TOKENS: int = 4000
DEFAULT_EXCLUDED_DIRS: List[str] = [ DEFAULT_EXCLUDED_DIRS: List[str] = [
"build", "build",
"vendor", "vendor",
@ -222,6 +226,14 @@ class SignalComputer:
tests pass a fake. Pass ``None`` to disable git-based signals. tests pass a fake. Pass ``None`` to disable git-based signals.
now_fn : callable() -> float now_fn : callable() -> float
Returns current time as a Unix timestamp. 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__( def __init__(
@ -229,10 +241,16 @@ class SignalComputer:
root: Path, root: Path,
git_log_fn: Optional[Callable[[Path], List[str]]] = None, git_log_fn: Optional[Callable[[Path], List[str]]] = None,
now_fn: Optional[Callable[[], float]] = 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: ) -> None:
self._root = root self._root = root
self._git_log_fn = git_log_fn self._git_log_fn = git_log_fn
self._now_fn = now_fn or (lambda: __import__("time").time()) 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 # Public
@ -249,6 +267,7 @@ class SignalComputer:
signals.extend(self._location_signals(path, rel)) signals.extend(self._location_signals(path, rel))
signals.extend(self._archive_to_live_ratio(path)) signals.extend(self._archive_to_live_ratio(path))
signals.extend(self._frontmatter_markers(path)) signals.extend(self._frontmatter_markers(path))
signals.extend(self._file_length(path))
return signals return signals
@ -476,6 +495,36 @@ class SignalComputer:
) )
return signals 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 # Production git helper
@ -518,6 +567,13 @@ class Scanner:
Injected git-log provider. Default: real git subprocess. Injected git-log provider. Default: real git subprocess.
now_fn : callable, optional now_fn : callable, optional
Injected clock. Default: ``time.time``. 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__( def __init__(
@ -527,6 +583,9 @@ class Scanner:
excluded_dirs: Optional[List[str]] = None, excluded_dirs: Optional[List[str]] = None,
git_log_fn: Optional[Callable[[Path], List[str]]] = None, git_log_fn: Optional[Callable[[Path], List[str]]] = None,
now_fn: Optional[Callable[[], float]] = 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: ) -> None:
self._root = root.resolve() self._root = root.resolve()
self._scope_globs = scope_globs if scope_globs is not None else DEFAULT_SCOPE_GLOBS self._scope_globs = scope_globs if scope_globs is not None else DEFAULT_SCOPE_GLOBS
@ -560,6 +619,9 @@ class Scanner:
self._excluded_pairs.append((parent_name, child)) self._excluded_pairs.append((parent_name, child))
self._git_log_fn = git_log_fn self._git_log_fn = git_log_fn
self._now_fn = now_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._ignore_patterns: List[str] = _load_ignore_patterns(self._root)
def _is_excluded_child(self, parent_dirpath: str, child_name: str) -> bool: def _is_excluded_child(self, parent_dirpath: str, child_name: str) -> bool:
@ -587,6 +649,9 @@ class Scanner:
root=self._root, root=self._root,
git_log_fn=self._git_log_fn, git_log_fn=self._git_log_fn,
now_fn=self._now_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 ignore_patterns = self._ignore_patterns
@ -685,6 +750,18 @@ def main(argv: Optional[List[str]] = None) -> int:
default=None, default=None,
help="Directory names to exclude", 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) args = parser.parse_args(argv)
root = Path(args.root) if args.root else _resolve_project_root(Path.cwd()) root = Path(args.root) if args.root else _resolve_project_root(Path.cwd())
@ -694,6 +771,8 @@ def main(argv: Optional[List[str]] = None) -> int:
scope_globs=args.globs, scope_globs=args.globs,
excluded_dirs=args.excluded_dirs, excluded_dirs=args.excluded_dirs,
git_log_fn=_git_log_real, git_log_fn=_git_log_real,
max_lines=args.max_lines,
max_tokens=args.max_tokens,
) )
artifact = scanner.run() artifact = scanner.run()
print(json.dumps(artifact, indent=2)) print(json.dumps(artifact, indent=2))

View File

@ -0,0 +1,127 @@
"""
Tests for the scanner's file_length signal (Forgejo issue #25, Part 1).
Objective fact only: line count + estimated token count vs configurable
thresholds. No classification, no report-owned fields (token_estimate is
owned by report_builder.py, not the scanner).
"""
import sys
from pathlib import Path
_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from scanner import Scanner, SignalComputer # noqa: E402
from token_estimator import TokenEstimator # noqa: E402
from test_scanner_signals import ( # noqa: E402
FORBIDDEN_SIGNAL_FIELDS,
_assert_no_classifier_fields,
_assert_signal_present,
_make_tree,
)
class _StubEstimator(TokenEstimator):
"""Deterministic stub: 1 token per line, ignoring content."""
name = "stub-per-line"
def estimate(self, text: str) -> int:
if not text:
return 0
return len(text.splitlines())
def _run(root: Path, **kwargs) -> dict:
return Scanner(
root=root,
git_log_fn=lambda p: [],
now_fn=lambda: 0.0,
token_estimator=kwargs.pop("token_estimator", _StubEstimator()),
**kwargs,
).run()
class TestFileLengthSignal:
def test_short_file_no_signal(self, tmp_path):
_make_tree(tmp_path, {"doc.md": "\n".join(f"line {i}" for i in range(10))})
result = _run(tmp_path)
sigs = result["signals"].get("doc.md", [])
names = [s["name"] for s in sigs]
assert "file_length" not in names
def test_over_line_threshold_emits_signal(self, tmp_path):
content = "\n".join(f"line {i}" for i in range(500))
_make_tree(tmp_path, {"doc.md": content})
result = _run(tmp_path, max_lines=400, max_tokens=4000)
sigs = result["signals"].get("doc.md", [])
sig = _assert_signal_present(sigs, "file_length")
_assert_no_classifier_fields(sig)
assert "500" in sig["detail"]
assert "400" in sig["detail"]
def test_over_token_threshold_emits_signal(self, tmp_path):
"""Line count under threshold but stub estimator pushes tokens over."""
content = "\n".join(f"line {i}" for i in range(50))
_make_tree(tmp_path, {"doc.md": content})
# Stub estimates 1 token/line -> 50 tokens; set max_tokens low so it fires
# even though line count (50) stays well under max_lines (400).
result = _run(tmp_path, max_lines=400, max_tokens=10)
sigs = result["signals"].get("doc.md", [])
sig = _assert_signal_present(sigs, "file_length")
_assert_no_classifier_fields(sig)
assert "threshold 400 lines / 10 tokens" in sig["detail"]
def test_configurable_threshold_respected(self, tmp_path):
content = "\n".join(f"line {i}" for i in range(50))
_make_tree(tmp_path, {"doc.md": content})
# Below default threshold -> no signal
result_default = _run(tmp_path)
assert "file_length" not in [
s["name"] for s in result_default["signals"].get("doc.md", [])
]
# Lower the threshold below the actual line count -> signal fires
result_custom = _run(tmp_path, max_lines=10, max_tokens=4000)
sigs = result_custom["signals"].get("doc.md", [])
sig = _assert_signal_present(sigs, "file_length")
assert "threshold 10 lines" in sig["detail"]
def test_silent_below_threshold_with_default_estimator(self, tmp_path):
"""Uses the real default_estimator() (no injected stub) below threshold."""
_make_tree(tmp_path, {"doc.md": "short content"})
result = Scanner(
root=tmp_path,
git_log_fn=lambda p: [],
now_fn=lambda: 0.0,
).run()
sigs = result["signals"].get("doc.md", [])
names = [s["name"] for s in sigs]
assert "file_length" not in names
def test_signal_omits_forbidden_report_fields(self, tmp_path):
"""file_length must not leak report-owned fields (e.g. token_estimate)."""
content = "\n".join(f"line {i}" for i in range(500))
_make_tree(tmp_path, {"doc.md": content})
result = _run(tmp_path, max_lines=400, max_tokens=4000)
sigs = result["signals"].get("doc.md", [])
sig = _assert_signal_present(sigs, "file_length")
for field in FORBIDDEN_SIGNAL_FIELDS:
assert field not in sig, f"forbidden field '{field}' found in file_length signal"
def test_signal_computer_direct_default_thresholds(self, tmp_path):
"""SignalComputer alone: constructor defaults are 400 lines / 4000 tokens."""
content = "\n".join(f"line {i}" for i in range(500))
_make_tree(tmp_path, {"doc.md": content})
path = tmp_path / "doc.md"
computer = SignalComputer(
root=tmp_path,
git_log_fn=None,
now_fn=lambda: 0.0,
token_estimator=_StubEstimator(),
)
sigs = computer._file_length(path)
sig = _assert_signal_present(sigs, "file_length")
assert "threshold 400 lines / 4000 tokens" in sig["detail"]