3030Stdlib + PyYAML only — never imports the science stack. Emits one `count|summary`
3131line; exits non-zero (no output) if PyYAML is absent so the conductor falls back
3232gracefully. 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
3542from __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
119151def _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
177241def 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
0 commit comments