Skip to content

Commit 37642c7

Browse files
committed
hygiene: add an escapes mode — LaTeX eaten by Python string escapes
Turns the sweep built for the 41-file raw-stringing task into a standing Hygiene check, so the debt is caught continuously instead of re-derived. The mode reports TWO independent classes, which is the whole point: warned `\s`, `\l`, `\[` — escapes Python does NOT recognise. It keeps them literal but warns on every compile, and they are slated to become a SyntaxError. silent `\t` in `\theta`, `\f` in `\frac`, `\r` in `\rm`, `\b` in `\beta` — escapes it DOES recognise. The value is corrupted and NOTHING is emitted. A warning-only sweep reports a clean repo over a corrupted one, so files with ONLY silent damage are marked explicitly. Two interpreter traps are handled, both of which have already produced a false "clean" in this codebase: 1. Invalid escapes are a SyntaxWarning only on Python 3.12+; on 3.11 they are a DeprecationWarning. Both categories are collected, so the scan is interpreter-independent rather than silently returning zero on 3.11. 2. compileall needs -f or __pycache__ suppresses recompilation and the counts drop. This helper compiles from source text and never consults a cache. Shape follows the existing helper-backed modes exactly: a stdlib-only, read-only `_hygiene_escapes.py` with --summary/--json-row/human output, wired into MODE_ORDER, the delegate and kind maps, prescan dispatch, the arg parser, the json-row path, the human body and the default ranked worklist. Delegates to /refactor; Hygiene still never edits source. Validated against a known answer: run over the six workspace repos immediately after the raw-stringing merges, it reports exactly the one documented residue (autolens_workspace scripts/group/likelihood_function.py, 2 warned + 1 silent) and nothing else. A synthetic corpus confirms it flags a silent-only file, flags a warned-only file, and correctly ignores both an already-raw docstring and a deliberate `print("\nreal newline")`.
1 parent c6218da commit 37642c7

4 files changed

Lines changed: 252 additions & 11 deletions

File tree

agents/conductors/hygiene/AGENTS.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ kinds, which is what makes its count comparable (or not):
2626

2727
- **debris** — finds directly-removable items; a real, rankable count (`tidy`).
2828
- **finding** — confirms a source-quality defect; a real, rankable count
29-
(`docstrings`, `refs`, `optdeps`, `extras`).
29+
(`docstrings`, `escapes`, `refs`, `optdeps`, `extras`).
3030
- **timing** — measures import cost; a real, rankable count of *slow* imports (`perf`).
3131
- **surface** — only *sizes* the audit; the real problems emerge when the
3232
delegated skill runs, so the count is **not** a problem count (`deps`, `docs`).
@@ -63,7 +63,8 @@ than its category — because category alone gets it wrong. The config layer is
6363
| ships a distribution | has a `pyproject.toml` | `deps` |
6464
| ships api docs | has a `docs/api/` tree | `docs` |
6565

66-
The helper-backed modes (`docstrings`, `refs`, `optdeps`, `extras`, `config`)
66+
The helper-backed modes (`docstrings`, `escapes`, `refs`, `optdeps`, `extras`,
67+
`config`)
6768
are **not** on this list: they discover their own targets by walking the scan
6869
root for workspace-shaped directories, so they can find material the map never
6970
names — and they keep reporting even when the repo-array modes are `unscanned`.
@@ -78,13 +79,14 @@ names — and they keep reporting even when the repo-array modes are `unscanned`
7879
| `docs` | `docs/api/*.rst` + `currentmodule` counts across every managed repo shipping a `docs/api/` tree (**surface**) | `/audit_docs` (Heart, imports) |
7980
| `crlf` | executable scripts (`.sh` + shebang-`755` `.py`) with CRLF — the shebang breaks on Linux/HPC (**debris**, the ranked count); plain `.py` CRLF is reported separately as *cosmetic* (Python reads it fine — don't mass-normalise) | `/refactor` + `.gitattributes eol=lf` |
8081
| `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) |
82+
| `escapes` | LaTeX in non-raw docstrings eaten by Python's escape handling in user-facing `*_workspace` and `HowTo*` `scripts/**/*.py`, in **two** classes (**finding**). `\s`/`\l`/`\[` are escapes Python does not recognise: it keeps them literal but warns. `\t` in `\theta`, `\f` in `\frac`, `\r` in `\rm`, `\b` in `\beta` are escapes it **does** recognise: the value is silently corrupted (`\theta_E` becomes TAB + `heta_E`) with **no diagnostic of any kind**, so a warning-only sweep reports a clean repo over a corrupted one. Both classes are counted separately and files with **only** silent damage are marked, because those are exactly the ones a warning sweep misses. The scan collects `DeprecationWarning` as well as `SyntaxWarning`: invalid escapes are only the latter on Python 3.12+, so a `SyntaxWarning`-only sweep returns a vacuous zero on 3.11 that is indistinguishable from "already fixed" | `/refactor` (prefix `r` on the enclosing docstring — **not** doubling the backslashes, which would leak into the rendered notebook prose) |
8183
| `refs` | file/folder references in user-facing `*_workspace` and `HowTo*` prose (`scripts/**/*.py` docstrings + comments, every `scripts/**/README.md` and `config/**/README.md`, and the top-level README) whose target no longer exists — restructure debt no health sweep can see, since the scripts still run (**finding**). Covers the README idioms a `scripts/`-anchored matcher cannot see: structure-list bullets (``- `slam_pipeline`: ``), slash-less relative folder paths (`data_preparation/imaging`), and config YAML names. Scans the **inverse direction** too: a folder that exists but whose own parent README never names it — a package can ship fully working, with every reference resolving, and still be invisible to a reader browsing the folder list (`interferometer/features/datacube` sat unlisted for three months) | `/refactor` (re-point each dead reference; add an entry for each undocumented folder, sourced from that folder's own README or a script docstring) |
8284
| `optdeps` | smoke-listed workspace scripts that construct an optional-dependency-gated API (`TransformerNUFFT``nufftax`) without the house `find_spec` skip guard, so they hard-fail the CI matrices that omit the extras (**finding**). AST-confirmed — prose mentions don't count; scripts outside `smoke_tests.txt` are never flagged | `/refactor` (add the skip guard) |
8385
| `extras` | the complement of `optdeps`: an optional dependency a library **declares** (in the `[optional]` extra `mode=release` installs) that the `workspace-validation.yml` **`mode=smoke`** leg never installs (**finding**). The extras chain only reaches each library's own `[jax]`, never a sibling's `[optional]`, so those need hand-adding and silently drift — the symptom is a script red in smoke and **green in release** | `/bug` (add the install; fix the install set, **never** the script) |
8486
| `config` | library `config/*.yaml` keys missing from the matching workspace config — recursive diff (**surface**) | `/refactor` (mirror keys) |
8587
| `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`) |
8688
| `packaging` | ignored, fully-untracked top-level `*.egg-info/` and `build/` directories in the managed code repos (**debris**) | preview then run `PyAutoBrain/bin/clean_slate.sh --packaging`; repo-set, exact-name, root-depth and tracked-file guards apply |
87-
| *(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`/`refs`/`artifacts`/`packaging`), then `hygiene perf`, then the periodic surface audits |
89+
| *(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`/`escapes`/`refs`/`artifacts`/`packaging`), then `hygiene perf`, then the periodic surface audits |
8890

8991
```
9092
pyauto-brain hygiene # pre-scan across modes → ranked worklist
@@ -97,6 +99,7 @@ pyauto-brain hygiene deps # dependency-cap surface → /dep_audit
9799
pyauto-brain hygiene docs # API-docs surface → /audit_docs
98100
pyauto-brain hygiene crlf # CRLF .py files → /refactor
99101
pyauto-brain hygiene docstrings # adjacent top-level documentation → /refactor
102+
pyauto-brain hygiene escapes # LaTeX eaten by string escapes (warned + SILENT) → /refactor
100103
pyauto-brain hygiene refs # folder-list drift in workspace prose → /refactor
101104
pyauto-brain hygiene optdeps # smoke-listed scripts missing an optional-dep skip guard → /refactor
102105
pyauto-brain hygiene extras # optional deps the smoke CI leg never installs → /bug
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
#!/usr/bin/env python3
2+
"""Read-only scanner for LaTeX corrupted by Python's string-escape handling.
3+
4+
Workspace tutorial prose carries LaTeX in module-level docstrings. Unless the
5+
docstring is raw, Python's escape handling damages it in two INDEPENDENT ways,
6+
and a scan that looks for only the first reports a clean sweep over a corpus
7+
full of the second:
8+
9+
warned ``\\s``, ``\\l``, ``\\[`` -- escapes Python does NOT recognise. It keeps
10+
them literal but warns on every compile, and they are slated to become a
11+
SyntaxError.
12+
silent ``\\t`` in ``\\theta``, ``\\f`` in ``\\frac``, ``\\r`` in ``\\rm``,
13+
``\\b`` in ``\\beta``. Escapes Python DOES recognise: the value is
14+
corrupted and there is NO diagnostic of any kind. ``\\theta_E`` becomes
15+
a TAB followed by ``heta_E``.
16+
17+
TWO INTERPRETER TRAPS, both of which have already produced a false "clean":
18+
19+
1. Invalid escapes are a ``SyntaxWarning`` only on Python 3.12+; on 3.11 and
20+
earlier they are a ``DeprecationWarning``. A ``SyntaxWarning``-only sweep
21+
returns zero on 3.11 and is indistinguishable from "already fixed". Both
22+
categories are collected here, so the scan is interpreter-independent.
23+
2. ``compileall`` needs ``-f``, or ``__pycache__`` suppresses recompilation and
24+
the counts silently drop. This module compiles from source text and never
25+
consults a cache.
26+
27+
Read-only, stdlib-only, and consistent with the rest of the Hygiene Agent: it
28+
reports and delegates to /refactor; it never edits a file.
29+
"""
30+
31+
from __future__ import annotations
32+
33+
import argparse
34+
import ast
35+
import json
36+
import warnings
37+
from dataclasses import asdict, dataclass
38+
from pathlib import Path
39+
40+
SKIP_PARTS = {".git", "__pycache__", "build", "dist", ".ipynb_checkpoints"}
41+
# A newline in a string value is ordinary text, not evidence of a mangled macro.
42+
CONTROL_EXCEPT_NEWLINE = "\n"
43+
44+
45+
@dataclass(frozen=True)
46+
class Finding:
47+
repo: str
48+
file: str
49+
warned: int
50+
silent: int
51+
52+
53+
@dataclass(frozen=True)
54+
class ParseError:
55+
repo: str
56+
file: str
57+
message: str
58+
59+
60+
def repository_paths(root: Path) -> list[Path]:
61+
"""Return user-facing ``*_workspace`` and ``HowTo*`` repositories."""
62+
candidates = [*root.glob("*_workspace"), *root.glob("HowTo*")]
63+
return sorted(
64+
{path.resolve() for path in candidates if (path / "scripts").is_dir()},
65+
key=lambda path: path.name.lower(),
66+
)
67+
68+
69+
def warned_count(source: str, path: Path) -> int:
70+
"""Escapes Python does not recognise, on ANY interpreter version."""
71+
with warnings.catch_warnings(record=True) as caught:
72+
warnings.simplefilter("always")
73+
try:
74+
compile(source, str(path), "exec")
75+
except SyntaxError:
76+
return 0
77+
return sum(
78+
1
79+
for item in caught
80+
# Both categories: SyntaxWarning on 3.12+, DeprecationWarning on <=3.11.
81+
if issubclass(item.category, (SyntaxWarning, DeprecationWarning))
82+
and "invalid escape sequence" in str(item.message)
83+
)
84+
85+
86+
def silent_count(source: str) -> int:
87+
"""Escapes Python DOES recognise, which corrupt the value with no warning.
88+
89+
A non-raw literal whose SOURCE carries a backslash but whose VALUE carries a
90+
control character has had a macro eaten -- ``\\theta`` became TAB + ``heta``.
91+
"""
92+
try:
93+
tree = ast.parse(source)
94+
except SyntaxError:
95+
return 0
96+
total = 0
97+
for node in ast.walk(tree):
98+
if not (isinstance(node, ast.Constant) and isinstance(node.value, str)):
99+
continue
100+
segment = ast.get_source_segment(source, node) or ""
101+
if "\\" not in segment:
102+
continue
103+
if segment[:1] in "rR" or segment[:2].lower() in ("br", "rb", "fr", "rf"):
104+
continue # already raw: the escape never happened
105+
if any(
106+
ord(char) < 32 and char != CONTROL_EXCEPT_NEWLINE for char in node.value
107+
):
108+
total += 1
109+
return total
110+
111+
112+
def scan(root: Path) -> tuple[list[Finding], list[ParseError], int]:
113+
findings: list[Finding] = []
114+
errors: list[ParseError] = []
115+
repositories = repository_paths(root)
116+
for repository in repositories:
117+
for script in sorted((repository / "scripts").rglob("*.py")):
118+
if SKIP_PARTS & set(script.parts):
119+
continue
120+
try:
121+
source = script.read_text(encoding="utf-8")
122+
except (OSError, UnicodeDecodeError) as error:
123+
errors.append(
124+
ParseError(repository.name, str(script.relative_to(repository)), str(error))
125+
)
126+
continue
127+
warned, silent = warned_count(source, script), silent_count(source)
128+
if warned or silent:
129+
findings.append(
130+
Finding(
131+
repository.name,
132+
str(script.relative_to(repository)),
133+
warned,
134+
silent,
135+
)
136+
)
137+
return findings, errors, len(repositories)
138+
139+
140+
def summary_for(findings: list[Finding], errors: list[ParseError], repos: int) -> str:
141+
warned = sum(finding.warned for finding in findings)
142+
silent = sum(finding.silent for finding in findings)
143+
silent_only = sum(1 for finding in findings if finding.silent and not finding.warned)
144+
return (
145+
f"{len(findings)} script(s) across {repos} repo(s) with LaTeX damaged by "
146+
f"string escapes: {warned} warned, {silent} silent "
147+
f"({silent_only} file(s) have ONLY silent damage, which a warning-only "
148+
f"sweep would miss); {len(errors)} read error(s)"
149+
)
150+
151+
152+
def row_for(root: Path) -> dict:
153+
findings, errors, repository_count = scan(root)
154+
if errors:
155+
status = "partial"
156+
elif findings:
157+
status = "finding"
158+
else:
159+
status = "clean"
160+
return {
161+
"mode": "escapes",
162+
"kind": "finding",
163+
"status": status,
164+
"count": len(findings),
165+
"summary": summary_for(findings, errors, repository_count),
166+
"delegate": "/refactor",
167+
"findings": [asdict(finding) for finding in findings],
168+
"parse_errors": [asdict(error) for error in errors],
169+
}
170+
171+
172+
def render_human(row: dict) -> None:
173+
print(row["summary"])
174+
for finding in row["findings"]:
175+
marker = " <- silent only" if finding["silent"] and not finding["warned"] else ""
176+
print(
177+
f" {finding['repo']}/{finding['file']}: "
178+
f"{finding['warned']} warned, {finding['silent']} silent{marker}"
179+
)
180+
if row["parse_errors"]:
181+
print("Read errors (scan incomplete):")
182+
for error in row["parse_errors"]:
183+
print(f" {error['repo']}/{error['file']}: {error['message']}")
184+
185+
186+
def main() -> int:
187+
parser = argparse.ArgumentParser()
188+
parser.add_argument("--root", type=Path, required=True)
189+
output = parser.add_mutually_exclusive_group()
190+
output.add_argument("--json-row", action="store_true")
191+
output.add_argument("--summary", action="store_true")
192+
args = parser.parse_args()
193+
194+
row = row_for(args.root.resolve())
195+
if args.json_row:
196+
print(json.dumps(row, sort_keys=True))
197+
elif args.summary:
198+
print(f"{row['count']}|{row['summary']}")
199+
else:
200+
render_human(row)
201+
return 0
202+
203+
204+
if __name__ == "__main__":
205+
raise SystemExit(main())

0 commit comments

Comments
 (0)