Skip to content

Commit abacdd3

Browse files
authored
Merge pull request #204 from PyAutoLabs/claude/hygiene-detail-flag-n1fgdq
feat(hygiene): --detail flag makes the config scan's findings routable
2 parents f8383c5 + c05c9e4 commit abacdd3

3 files changed

Lines changed: 227 additions & 27 deletions

File tree

agents/conductors/hygiene/_hygiene_config.py

Lines changed: 108 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@
3030
Stdlib + PyYAML only — never imports the science stack. Emits one `count|summary`
3131
line; exits non-zero (no output) if PyYAML is absent so the conductor falls back
3232
gracefully. It is a *surface* signal — the count is "items to review", not bugs.
33+
34+
Both signals come in two views over **one** traversal. `diff_detail` /
35+
`orphan_detail` return the items themselves; `diff` / `orphan_files` are the
36+
count view layered on top, so a tally can never disagree with its own listing.
37+
`--detail` prints the items grouped by the config file (or repo) they belong to
38+
— the routable form the `/refactor` hand-off needs. Without it the output is the
39+
single `count|summary` line the conductor's summary table parses, unchanged.
3340
"""
3441

3542
from __future__ import annotations
@@ -93,27 +100,52 @@ def load(path: str):
93100
return None
94101

95102

96-
def diff(root: str, pairs=PAIRS) -> tuple[int, list[str]]:
97-
total = 0
98-
detail: list[str] = []
103+
def _counts_by_repo(records) -> dict[str, int]:
104+
"""`{repo: item count}` over `(repo, …, items)` records, preserving
105+
first-seen order. Both detail shapes fit: the repo is first, items last."""
106+
counts: dict[str, int] = {}
107+
for record in records:
108+
repo, items = record[0], record[-1]
109+
counts[repo] = counts.get(repo, 0) + len(items)
110+
return counts
111+
112+
113+
def _summarise(records) -> tuple[int, list[str]]:
114+
"""The count view of a detail record list: `(total, ["repo:N", …])`."""
115+
counts = _counts_by_repo(records)
116+
return sum(counts.values()), [f"{repo}:{n}" for repo, n in counts.items()]
117+
118+
119+
def diff_detail(root: str, pairs=PAIRS) -> list[tuple[str, str, list[str]]]:
120+
"""The key-mirror drift itself: one `(workspace repo, config file name,
121+
sorted missing key paths)` record per file missing at least one key.
122+
123+
`diff()` is the count view of this same walk — the skip rules (pair not
124+
checked out, no workspace counterpart, unparseable YAML) live here only.
125+
"""
126+
records: list[tuple[str, str, list[str]]] = []
99127
for lib_rel, ws_rel in pairs:
100128
lib_dir = os.path.join(root, lib_rel)
101129
ws_dir = os.path.join(root, ws_rel)
102130
if not (os.path.isdir(lib_dir) and os.path.isdir(ws_dir)):
103131
continue
104-
missing = 0
105-
for lib_yaml in glob.glob(os.path.join(lib_dir, "*.yaml")):
106-
ws_yaml = os.path.join(ws_dir, os.path.basename(lib_yaml))
132+
repo = ws_rel.split("/")[0]
133+
for lib_yaml in sorted(glob.glob(os.path.join(lib_dir, "*.yaml"))):
134+
name = os.path.basename(lib_yaml)
135+
ws_yaml = os.path.join(ws_dir, name)
107136
if not os.path.isfile(ws_yaml):
108137
continue # workspace may intentionally not copy this file
109138
lib_data, ws_data = load(lib_yaml), load(ws_yaml)
110139
if lib_data is None or ws_data is None:
111140
continue
112-
missing += len(key_paths(lib_data) - key_paths(ws_data))
113-
if missing:
114-
total += missing
115-
detail.append(f"{ws_rel.split('/')[0]}:{missing}")
116-
return total, detail
141+
missing = key_paths(lib_data) - key_paths(ws_data)
142+
if missing:
143+
records.append((repo, name, sorted(missing)))
144+
return records
145+
146+
147+
def diff(root: str, pairs=PAIRS) -> tuple[int, list[str]]:
148+
return _summarise(diff_detail(root, pairs))
117149

118150

119151
def _yaml_relpaths(config_dir: str) -> set[str]:
@@ -142,21 +174,22 @@ def _suppressed(relpath: str) -> bool:
142174
return relpath.split("/")[0] in ORPHAN_OWNERS
143175

144176

145-
def orphan_files(root: str, libraries=LIBRARIES, lib_relpaths=None,
146-
owners=ORPHAN_OWNERS) -> tuple[int, list[str]]:
147-
"""Workspace config files with no library counterpart, after owner-map
148-
suppression.
177+
def orphan_detail(root: str, libraries=LIBRARIES, lib_relpaths=None,
178+
owners=ORPHAN_OWNERS) -> list[tuple[str, list[str]]]:
179+
"""The orphan files themselves: one `(repo, sorted orphan relpaths)` record
180+
per repo holding at least one, after owner-map suppression.
149181
150182
Only repos whose `config/` *mirrors* the library tree (shares ≥1 file with
151183
the library set) are scanned — that self-scopes to the workspace/tutorial/
152184
test/assistant repos and excludes organ repos (Brain/Heart/Mind) whose
153185
`config/` is their own thing, without a hardcoded repo list to go stale.
186+
187+
`orphan_files()` is the count view of this same walk.
154188
"""
155189
if lib_relpaths is None:
156190
lib_relpaths = library_config_relpaths(root, libraries)
157191
lib_repos = {repo for repo, _ in libraries}
158-
total = 0
159-
detail: list[str] = []
192+
records: list[tuple[str, list[str]]] = []
160193
for name in sorted(os.listdir(root)):
161194
if name in lib_repos:
162195
continue
@@ -169,27 +202,76 @@ def orphan_files(root: str, libraries=LIBRARIES, lib_relpaths=None,
169202
orphans = {r for r in (rels - lib_relpaths)
170203
if not (r.split("/")[0] in owners)}
171204
if orphans:
172-
total += len(orphans)
173-
detail.append(f"{name}:{len(orphans)}")
174-
return total, detail
205+
records.append((name, sorted(orphans)))
206+
return records
207+
208+
209+
def orphan_files(root: str, libraries=LIBRARIES, lib_relpaths=None,
210+
owners=ORPHAN_OWNERS) -> tuple[int, list[str]]:
211+
"""Workspace config files with no library counterpart, after owner-map
212+
suppression — the count view of `orphan_detail()`."""
213+
return _summarise(orphan_detail(root, libraries, lib_relpaths, owners))
214+
215+
216+
def _plural(n: int, noun: str) -> str:
217+
return f"{n} {noun}" if n == 1 else f"{n} {noun}s"
218+
219+
220+
def render_detail(key_records, orphan_records) -> list[str]:
221+
"""The routable form of both signals: every drifted key path under the
222+
workspace config file missing it, every orphan under its repo."""
223+
lines: list[str] = []
224+
if key_records:
225+
lines.append("Library config keys absent downstream, by the workspace file "
226+
"missing them:")
227+
for repo, name, keys in key_records:
228+
lines.append(f" {repo}/config/{name}{_plural(len(keys), 'key')}")
229+
lines.extend(f" - {k}" for k in keys)
230+
if orphan_records:
231+
if lines:
232+
lines.append("")
233+
lines.append("Orphan config files (no library ships one at this relative "
234+
"path), by repo:")
235+
for repo, orphans in orphan_records:
236+
lines.append(f" {repo}/config — {_plural(len(orphans), 'file')}")
237+
lines.extend(f" - {o}" for o in orphans)
238+
return lines
175239

176240

177241
def main() -> int:
178-
ap = argparse.ArgumentParser()
242+
ap = argparse.ArgumentParser(
243+
description="Config drift prescan for the hygiene conductor: library "
244+
"config keys absent downstream, and workspace config files "
245+
"with no library counterpart.")
179246
ap.add_argument("--root", default=os.path.expanduser("~/Code/PyAutoLabs"))
247+
ap.add_argument("--detail", action="store_true",
248+
help="list every drifted key path and orphan file, grouped "
249+
"by the config file / repo it belongs to. Default is "
250+
"the single 'count|summary' line the conductor parses.")
180251
ns = ap.parse_args()
181-
keys, key_detail = diff(ns.root)
182-
orphans, orphan_detail = orphan_files(ns.root)
252+
key_records = diff_detail(ns.root)
253+
orphan_records = orphan_detail(ns.root)
254+
keys, key_tally = _summarise(key_records)
255+
orphans, orphan_tally = _summarise(orphan_records)
183256
total = keys + orphans
184257
parts = []
185258
if keys:
186259
parts.append(f"{keys} library config keys absent downstream "
187-
f"(review/mirror): {' '.join(key_detail)}")
260+
f"(review/mirror): {' '.join(key_tally)}")
188261
if orphans:
189262
parts.append(f"{orphans} orphan config files with no library counterpart "
190-
f"(review/remove): {' '.join(orphan_detail)}")
263+
f"(review/remove): {' '.join(orphan_tally)}")
191264
summary = "; ".join(parts) or "config in sync (no key drift or orphan files)"
192-
print(f"{total}|{summary}")
265+
if not ns.detail:
266+
print(f"{total}|{summary}")
267+
return 0
268+
# --detail is the human/routing view: the summary sentence without the
269+
# machine `count|` prefix, then the items themselves.
270+
print(summary)
271+
lines = render_detail(key_records, orphan_records)
272+
if lines:
273+
print()
274+
print("\n".join(lines))
193275
return 0
194276

195277

agents/conductors/hygiene/hygiene.sh

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
# hygiene.sh refs # dead internal references in workspace prose -> /refactor
3838
# hygiene.sh optdeps # smoke-listed scripts w/ a gated API but no skip guard -> /refactor
3939
# hygiene.sh extras # optional deps declared by a library but missing from the smoke CI install -> /bug
40-
# hygiene.sh config # library config keys missing downstream -> /refactor
40+
# hygiene.sh config # library config keys missing downstream + orphan config files -> /refactor
4141
# hygiene.sh artifacts # tracked leaked outputs/data -> /repo_cleanup
4242
# hygiene.sh packaging # ignored top-level *.egg-info/build dirs -> clean_slate.sh
4343
# hygiene.sh <mode> --json # machine-readable HygieneDecision
@@ -722,6 +722,15 @@ elif [[ "$mode" == "optdeps" ]]; then
722722
elif [[ "$mode" == "extras" ]]; then
723723
echo "Optional dependencies the workspace-validation smoke leg never installs (read-only scan):"
724724
python3 "$HERE/_hygiene_extras.py" --root "$ROOT"
725+
elif [[ "$mode" == "config" ]]; then
726+
echo "Library config keys absent downstream + orphan config files (read-only scan):"
727+
python3 "$HERE/_hygiene_config.py" --root "$ROOT" --detail \
728+
|| echo "config diff unavailable (PyYAML missing?)"
729+
echo
730+
echo "→ route the mirrors/removals to /refactor; Hygiene never edits source. This is a"
731+
echo " SURFACE signal — judge each item before acting: a workspace may omit a library"
732+
echo " key deliberately, and an orphan file may be read by something the library set"
733+
echo " does not encode (add its owner to ORPHAN_OWNERS rather than deleting it)."
725734
elif [[ "$mode" == "default" ]]; then
726735
# 'debris' and 'finding' pre-scans yield directly-actionable counts (perf's
727736
# timing is deferred here — too slow for the fast scan). Rank across them and

tests/test_hygiene_conductor.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,115 @@ def test_orphan_files_skips_non_mirror_repos(tmp_path):
431431
assert total == 0 and detail == []
432432

433433

434+
# --- config --detail: the routable view of both signals. ----------------------
435+
# The count alone cannot be routed to /refactor — these lock the fact that the
436+
# key paths and orphan paths are printed, AND that asking for them never moves
437+
# the default `count|summary` line the conductor's summary table parses.
438+
439+
CONFIG_HELPER = (
440+
BRAIN_HOME / "agents" / "conductors" / "hygiene" / "_hygiene_config.py"
441+
)
442+
443+
444+
def _run_config_helper(root, *args):
445+
_load_config_helper() # skips (SystemExit) if PyYAML absent
446+
return subprocess.run(
447+
[sys.executable, str(CONFIG_HELPER), "--root", str(root), *args],
448+
capture_output=True, text=True,
449+
)
450+
451+
452+
def _drifted_pair(root):
453+
"""A real PAIRS pair (PyAutoFit <-> autofit_workspace) with nested and
454+
top-level key drift across two config files."""
455+
_fake_library(root, {
456+
"general.yaml": {"output": {"search_internal": 1}, "keep": 2},
457+
"logging.yaml": {"total_files_open": 1},
458+
})
459+
_fake_workspace(root, "autofit_workspace", {
460+
"general.yaml": {"output": {}, "keep": 2}, # missing output.search_internal
461+
"logging.yaml": {}, # missing total_files_open
462+
})
463+
464+
465+
def test_config_detail_groups_drifted_keys_under_the_file_missing_them(tmp_path):
466+
_drifted_pair(tmp_path)
467+
r = _run_config_helper(tmp_path, "--detail")
468+
assert r.returncode == 0, r.stderr
469+
# The key paths themselves — the thing the count could not hand over.
470+
assert "- output.search_internal" in r.stdout
471+
assert "- total_files_open" in r.stdout
472+
# ...each under the workspace file it is absent from, not a flat list.
473+
general = r.stdout.index("autofit_workspace/config/general.yaml")
474+
logging_ = r.stdout.index("autofit_workspace/config/logging.yaml")
475+
assert general < r.stdout.index("- output.search_internal") < logging_
476+
assert logging_ < r.stdout.index("- total_files_open")
477+
478+
479+
def test_config_detail_groups_orphan_files_under_their_repo(tmp_path):
480+
"""The orphan signal gets the same treatment, owner suppression intact."""
481+
_fake_library(tmp_path, {
482+
"general.yaml": {"a": 1},
483+
"non_linear/GridSearch.yaml": {"grid": 1},
484+
})
485+
_fake_workspace(tmp_path, "some_workspace", {
486+
"general.yaml": {"a": 1}, # shared -> this IS a mirror
487+
"grids.yaml": {"radial_minimum": 1}, # orphan -> named
488+
"non_linear/nest.yaml": {"Nautilus": 1}, # orphan -> named
489+
"non_linear/GridSearch.yaml": {"grid": 1}, # mirrored -> absent
490+
"build/env_vars.yaml": {"X": 1}, # owned -> suppressed
491+
})
492+
r = _run_config_helper(tmp_path, "--detail")
493+
assert r.returncode == 0, r.stderr
494+
repo = r.stdout.index("some_workspace/config")
495+
assert repo < r.stdout.index("- grids.yaml")
496+
assert repo < r.stdout.index("- non_linear/nest.yaml")
497+
assert "GridSearch.yaml" not in r.stdout # has a library counterpart
498+
assert "env_vars.yaml" not in r.stdout # ORPHAN_OWNERS suppression
499+
500+
501+
def test_config_default_output_is_still_one_count_summary_line(tmp_path):
502+
"""The regression guard: `prescan_config` parses `${out%%|*}`, so adding
503+
--detail must not add a line, a prefix, or a newline to the default."""
504+
_drifted_pair(tmp_path)
505+
r = _run_config_helper(tmp_path)
506+
assert r.returncode == 0, r.stderr
507+
assert r.stdout.splitlines() == [
508+
"2|2 library config keys absent downstream (review/mirror): "
509+
"autofit_workspace:2"
510+
]
511+
512+
513+
def test_config_detail_on_a_clean_tree_reports_in_sync_and_lists_nothing(tmp_path):
514+
r = _run_config_helper(tmp_path, "--detail")
515+
assert r.returncode == 0, r.stderr
516+
assert r.stdout.strip() == "config in sync (no key drift or orphan files)"
517+
518+
519+
def test_hygiene_config_mode_hands_over_the_drifted_key_paths(tmp_path):
520+
"""The whole point: `hygiene config` must surface routable findings, not a
521+
tally the operator has to re-derive by importing the module."""
522+
_load_config_helper() # skips (SystemExit) if PyYAML absent
523+
_drifted_pair(tmp_path)
524+
r = _run(["config"], tmp_path)
525+
assert r.returncode == 0, r.stderr
526+
assert "output.search_internal" in r.stdout
527+
assert "total_files_open" in r.stdout
528+
assert "/refactor" in r.stdout
529+
530+
531+
def test_hygiene_config_json_row_is_unchanged_by_detail(tmp_path):
532+
"""The machine surface keeps reading the count line, not the detail."""
533+
_load_config_helper() # skips (SystemExit) if PyYAML absent
534+
_drifted_pair(tmp_path)
535+
r = _run(["config", "--json"], tmp_path)
536+
assert r.returncode == 0, r.stderr
537+
row = json.loads(r.stdout)["row"]
538+
assert row["mode"] == "config" and row["kind"] == "surface"
539+
assert row["count"] == 2
540+
assert "output.search_internal" not in row["summary"]
541+
542+
434543
def test_help_lists_the_usage_block(tmp_path):
435544
r = _run(["--help"], tmp_path)
436545
assert r.returncode == 0

0 commit comments

Comments
 (0)