Skip to content

Commit f9cb4d1

Browse files
authored
Merge pull request #163 from PyAutoLabs/feature/hygiene-adjacent-docstrings
feat: detect adjacent script docstrings in hygiene
2 parents 28746f6 + fe09324 commit f9cb4d1

7 files changed

Lines changed: 403 additions & 29 deletions

File tree

agents/conductors/hygiene/AGENTS.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ never runs a heavy audit and never mutates a repo. A pre-scan is one of a few
2525
kinds, which is what makes its count comparable (or not):
2626

2727
- **debris** — finds directly-removable items; a real, rankable count (`tidy`).
28+
- **finding** — confirms a source-quality defect; a real, rankable count
29+
(`docstrings`).
2830
- **timing** — measures import cost; a real, rankable count of *slow* imports (`perf`).
2931
- **surface** — only *sizes* the audit; the real problems emerge when the
3032
delegated skill runs, so the count is **not** a problem count (`deps`, `docs`).
@@ -39,10 +41,11 @@ kinds, which is what makes its count comparable (or not):
3941
| `deps` | capped/pinned specifiers in library `pyproject.toml` (**surface**) | `/dep_audit` (Heart, hits PyPI) |
4042
| `docs` | `docs/api/*.rst` + `currentmodule` counts across the 3 doc repos (**surface**) | `/audit_docs` (Heart, imports) |
4143
| `crlf` | executable scripts (`.sh` + shebang-`755` `.py`) with CRLF — the shebang breaks on Linux/HPC (**debris**, the ranked count); library `.py` CRLF is reported separately as *cosmetic* (Python reads it fine — don't mass-normalise) | `/refactor` + `.gitattributes eol=lf` |
44+
| `docstrings` | consecutive module-level triple-quoted expressions separated only by whitespace in user-facing `*_workspace` and `HowTo*` root `*.py` entry scripts and `scripts/**/*.py` files (**finding**) | `/refactor` (mechanically merge each confirmed boundary) |
4245
| `config` | library `config/*.yaml` keys missing from the matching workspace config — recursive diff (**surface**) | `/refactor` (mirror keys) |
4346
| `artifacts` | tracked files that look like leaked run outputs / stray data (under `output/`, or data-ext outside fixtures) (**debris**) | `/repo_cleanup` (gitignore + `git rm --cached`) |
4447
| `packaging` | ignored, fully-untracked top-level `*.egg-info/` and `build/` directories in managed library repos (**debris**) | preview then run `PyAutoBrain/bin/clean_slate.sh --packaging`; repo-set, exact-name, root-depth and tracked-file guards apply |
45-
| *(default)* | all of the above (**perf timing deferred** — it spawns real imports) | a ranked `HygieneDecision` worklist — recommends the highest-count debris mode (`tidy`/`crlf`/`artifacts`/`packaging`), then `hygiene perf`, then the periodic surface audits |
48+
| *(default)* | all of the above (**perf timing deferred** — it spawns real imports) | a ranked `HygieneDecision` worklist — recommends the highest-count direct mode (`tidy`/`crlf`/`docstrings`/`artifacts`/`packaging`), then `hygiene perf`, then the periodic surface audits |
4649

4750
```
4851
pyauto-brain hygiene # pre-scan across modes → ranked worklist
@@ -54,6 +57,7 @@ pyauto-brain hygiene noise # CLI noise → /cli_noise_clean
5457
pyauto-brain hygiene deps # dependency-cap surface → /dep_audit
5558
pyauto-brain hygiene docs # API-docs surface → /audit_docs
5659
pyauto-brain hygiene crlf # CRLF .py files → /refactor
60+
pyauto-brain hygiene docstrings # adjacent top-level documentation → /refactor
5761
pyauto-brain hygiene config # library→workspace config drift → /refactor
5862
pyauto-brain hygiene artifacts # tracked leaked outputs/data → /repo_cleanup
5963
pyauto-brain hygiene packaging # ignored root packaging dirs → clean_slate.sh
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
#!/usr/bin/env python3
2+
"""Read-only scanner for adjacent top-level script documentation blocks."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import ast
8+
import json
9+
import re
10+
import warnings
11+
from dataclasses import asdict, dataclass
12+
from pathlib import Path
13+
14+
15+
TRIPLE_QUOTE = re.compile(r"(?i)^[ruf]*(?:\"\"\"|''')")
16+
17+
18+
@dataclass(frozen=True)
19+
class Finding:
20+
repo: str
21+
file: str
22+
first_line: int
23+
first_end_line: int
24+
second_line: int
25+
second_end_line: int
26+
27+
28+
@dataclass(frozen=True)
29+
class ParseError:
30+
repo: str
31+
file: str
32+
error: str
33+
line: int | None
34+
message: str
35+
36+
37+
def repository_paths(root: Path) -> list[Path]:
38+
"""Return user-facing ``*_workspace`` and ``HowTo*`` repositories."""
39+
candidates = [*root.glob("*_workspace"), *root.glob("HowTo*")]
40+
return sorted(
41+
{path.resolve() for path in candidates if (path / "scripts").is_dir()},
42+
key=lambda path: path.name.lower(),
43+
)
44+
45+
46+
def _line_offsets(source: str) -> tuple[list[str], list[int]]:
47+
lines = source.splitlines(keepends=True)
48+
offsets: list[int] = []
49+
total = 0
50+
for line in lines:
51+
offsets.append(total)
52+
total += len(line)
53+
return lines, offsets
54+
55+
56+
def _source_offset(
57+
lines: list[str], offsets: list[int], lineno: int, byte_col: int
58+
) -> int:
59+
"""Convert an AST UTF-8 byte column into a Python string offset."""
60+
line = lines[lineno - 1]
61+
char_col = len(line.encode("utf-8")[:byte_col].decode("utf-8"))
62+
return offsets[lineno - 1] + char_col
63+
64+
65+
def _source_segment(
66+
source: str, lines: list[str], offsets: list[int], node: ast.AST
67+
) -> str:
68+
start = _source_offset(lines, offsets, node.lineno, node.col_offset)
69+
end = _source_offset(lines, offsets, node.end_lineno, node.end_col_offset)
70+
return source[start:end]
71+
72+
73+
def _is_triple_quoted_string_expr(
74+
source: str, lines: list[str], offsets: list[int], node: ast.AST
75+
) -> bool:
76+
if not (
77+
isinstance(node, ast.Expr)
78+
and isinstance(node.value, ast.Constant)
79+
and isinstance(node.value.value, str)
80+
):
81+
return False
82+
literal = _source_segment(source, lines, offsets, node.value)
83+
return TRIPLE_QUOTE.match(literal) is not None
84+
85+
86+
def findings_in_source(source: str, repo: str, file: str) -> list[Finding]:
87+
"""Find adjacent top-level triple-quoted expressions in one Python file."""
88+
with warnings.catch_warnings():
89+
warnings.simplefilter("ignore", SyntaxWarning)
90+
tree = ast.parse(source, filename=file)
91+
92+
lines, offsets = _line_offsets(source)
93+
findings: list[Finding] = []
94+
for first, second in zip(tree.body, tree.body[1:]):
95+
if not _is_triple_quoted_string_expr(source, lines, offsets, first):
96+
continue
97+
if not _is_triple_quoted_string_expr(source, lines, offsets, second):
98+
continue
99+
100+
first_end = _source_offset(
101+
lines, offsets, first.end_lineno, first.end_col_offset
102+
)
103+
second_start = _source_offset(
104+
lines, offsets, second.lineno, second.col_offset
105+
)
106+
if source[first_end:second_start].strip():
107+
continue
108+
109+
findings.append(
110+
Finding(
111+
repo=repo,
112+
file=file,
113+
first_line=first.lineno,
114+
first_end_line=first.end_lineno,
115+
second_line=second.lineno,
116+
second_end_line=second.end_lineno,
117+
)
118+
)
119+
return findings
120+
121+
122+
def scan(root: Path) -> tuple[list[Finding], list[ParseError], int]:
123+
findings: list[Finding] = []
124+
errors: list[ParseError] = []
125+
repositories = repository_paths(root)
126+
127+
for repository in repositories:
128+
script_paths = {
129+
*repository.glob("*.py"),
130+
*(repository / "scripts").rglob("*.py"),
131+
}
132+
for path in sorted(script_paths):
133+
relative = path.relative_to(repository).as_posix()
134+
try:
135+
source = path.read_text(encoding="utf-8")
136+
findings.extend(findings_in_source(source, repository.name, relative))
137+
except (OSError, UnicodeError, SyntaxError) as error:
138+
errors.append(
139+
ParseError(
140+
repo=repository.name,
141+
file=relative,
142+
error=type(error).__name__,
143+
line=getattr(error, "lineno", None),
144+
message=str(error),
145+
)
146+
)
147+
return findings, errors, len(repositories)
148+
149+
150+
def summary_for(
151+
findings: list[Finding], errors: list[ParseError], repository_count: int
152+
) -> str:
153+
file_count = len({(finding.repo, finding.file) for finding in findings})
154+
affected_repositories = len({finding.repo for finding in findings})
155+
file_label = "file" if file_count == 1 else "files"
156+
return (
157+
f"{len(findings)} adjacent documentation boundaries in {file_count} {file_label} "
158+
f"across {affected_repositories}/{repository_count} repos; "
159+
f"{len(errors)} parse errors"
160+
)
161+
162+
163+
def row_for(root: Path) -> dict:
164+
findings, errors, repository_count = scan(root)
165+
if errors:
166+
status = "partial"
167+
elif findings:
168+
status = "finding"
169+
else:
170+
status = "clean"
171+
return {
172+
"mode": "docstrings",
173+
"kind": "finding",
174+
"status": status,
175+
"count": len(findings),
176+
"summary": summary_for(findings, errors, repository_count),
177+
"delegate": "/refactor",
178+
"findings": [asdict(finding) for finding in findings],
179+
"parse_errors": [asdict(error) for error in errors],
180+
}
181+
182+
183+
def render_human(row: dict) -> None:
184+
print(row["summary"])
185+
for finding in row["findings"]:
186+
print(
187+
" "
188+
f"{finding['repo']}/{finding['file']}:"
189+
f"{finding['first_end_line']} -> {finding['second_line']}"
190+
)
191+
if row["parse_errors"]:
192+
print("Parse errors (scan incomplete):")
193+
for error in row["parse_errors"]:
194+
location = f":{error['line']}" if error["line"] is not None else ""
195+
print(
196+
f" {error['repo']}/{error['file']}{location}: "
197+
f"{error['error']}: {error['message']}"
198+
)
199+
200+
201+
def main() -> int:
202+
parser = argparse.ArgumentParser()
203+
parser.add_argument("--root", type=Path, required=True)
204+
output = parser.add_mutually_exclusive_group()
205+
output.add_argument("--json-row", action="store_true")
206+
output.add_argument("--summary", action="store_true")
207+
args = parser.parse_args()
208+
209+
row = row_for(args.root.resolve())
210+
if args.json_row:
211+
print(json.dumps(row, sort_keys=True))
212+
elif args.summary:
213+
print(f"{row['count']}|{row['summary']}")
214+
else:
215+
render_human(row)
216+
return 0
217+
218+
219+
if __name__ == "__main__":
220+
raise SystemExit(main())

0 commit comments

Comments
 (0)