diff --git a/plugins/disk-hygiene/.claude-plugin/plugin.json b/plugins/disk-hygiene/.claude-plugin/plugin.json index 7d58bf0fe0..bc36c62c73 100644 --- a/plugins/disk-hygiene/.claude-plugin/plugin.json +++ b/plugins/disk-hygiene/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "disk-hygiene", - "version": "0.21.9", + "version": "0.22.0", "description": "Context-aware disk hygiene for arbitrary directory trees: inventories orphaned and temporary artifacts, classifies evidence into review tiers, and offers exact-path cleanup only after a fresh safety preview and explicit per-tier approval. The target is read-only by default; OS-managed paths, links and mount points, VCS-tracked content without the complete checkout evidence bundle, changed entries, and live-handle uncertainty fail closed.", "author": { "name": "Melodic Software", diff --git a/plugins/disk-hygiene/CHANGELOG.md b/plugins/disk-hygiene/CHANGELOG.md index ca1c85f5f5..3be38754f3 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -3,6 +3,31 @@ All notable changes to the `disk-hygiene` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.22.0] + +### Added + +- **clean:** `scan --quiet` omits `children_rollup` from stdout and shortens the closing note, + keeping `status`, `target`, `snapshot`, the three coverage terms, `empty_directory_count`, both + byte totals, `truncated_paths`, `errors`, `policy_sources` and `os_autoclean`. The snapshot file + carries the rollup in full in both modes, so the flag drops a duplicate rather than data. Default + output is unchanged: a caller already parsing the rollup off stdout keeps it. On a real + `--max-depth 1` home-directory scan the payload fell from 7,247 to 893 bytes (88 %); on a + 200-child target, from 67,262 to 5,052 (93 %), because the rollup is the only part that grows + with the frontier. +- **clean:** the Bash guard admits `--quiet` as a third valueless scan flag, at most one per + invocation and with no trailing value, so the flag is reachable without widening the grammar. No + pipe, redirect, or shell-operator allowance is added; that rejection is unchanged. +- **clean:** `--root-children` scans get their own quiet note, and report `empty_directory_count` + on stdout like an ordinary scan does. Root-children mode's default note carries a coverage + qualification nothing else on stdout encodes: the volume root and every skipped + OS-owned/hidden/system/reparse entry were never walked, so the inventory is partial by + construction and `children_rollup` covers the selected children only. The skipped entries are + recorded as `root_children_skipped` in the snapshot alone and `truncated_paths` does not stand in + for them, so quieting that sentence away would drop a fact rather than a duplicate. The quiet + root-children note keeps the coverage sentence and drops only the rollup prose, and the field set + `--quiet` documents now holds in both scan modes rather than in ordinary scans alone. + ## [0.21.9] ### Changed diff --git a/plugins/disk-hygiene/skills/clean/SKILL.md b/plugins/disk-hygiene/skills/clean/SKILL.md index 854d9c665e..b45e2fab20 100644 --- a/plugins/disk-hygiene/skills/clean/SKILL.md +++ b/plugins/disk-hygiene/skills/clean/SKILL.md @@ -1,6 +1,6 @@ --- description: "Audit an arbitrary directory tree for orphaned, temporary, stale-lock, failed-write, partial-download, and empty leftover artifacts; classify evidence into confidence tiers; and optionally remove exact validated paths after explicit per-tier approval. Read-only by default and manual-only. Use when: 'audit this directory', 'find orphaned files', 'what junk can I clean up', 'reclaim disk space', 'find temp or lock leftovers', 'clean up my home directory'. Skip when: repository cache/build cleanup belongs to repo-hygiene, a product has its own prune/GC command, or the target is an OS-managed root." -argument-hint: "[--execute] [--policy ] [--max-depth ] [--confirmed-large-scan] [--root-children [--root-child ]...] " +argument-hint: "[--execute] [--policy ] [--max-depth ] [--confirmed-large-scan] [--quiet] [--root-children [--root-child ]...] " user-invocable: true disable-model-invocation: true hooks: @@ -39,12 +39,17 @@ primary objective; reclaimed bytes are secondary.** Read Parse `$ARGUMENTS` as the complete user-facing surface: optional `--execute`, optional `--policy `, optional `--max-depth `, optional `--confirmed-large-scan`, optional +`--quiet`, optional `--root-children` with zero or more `--root-child `, and one target directory. Remaining engine flags (`--output`, `--project-dir`, `--data-root` on scan; `--snapshot`, `--plan`, `--report`, `--confirm-tier`, `--approval-token`, `--paths`, and `--vcs-evidence` on the other subcommands) are supplied by this skill's command templates, not typed by the user. `--execute` means "deletion may be offered" on every platform, the gated engine lane where the -platform supports it, the manual handoff elsewhere; it is not approval. `--max-depth ` bounds a +platform supports it, the manual handoff elsewhere; it is not approval. `--quiet` shapes the +scan's stdout and nothing else: it omits `children_rollup` and shortens the closing note, leaving +every counter, byte total, coverage gap, error and policy source in place. The snapshot file +carries the rollup in full in both modes, so read per-child detail there and pass `--quiet` +whenever the run only needs the frontier summary. `--max-depth ` bounds a scan to depth N (preferred for large targets); `--confirmed-large-scan` opts into an unbounded full walk after the human clears the [confirmation gate](#confirmation-gate)'s scan-scope row. `--root-children` is the only way to address an OS-managed volume root (for example `C:\` or `/`): @@ -135,7 +140,7 @@ stay there, never in the target or `${CLAUDE_PLUGIN_ROOT}`. Run: "" "${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/hygiene.py" scan \ --target "" --output "/snapshot.json" [--policy ""] \ --project-dir "${CLAUDE_PROJECT_DIR}" --data-root "${CLAUDE_PLUGIN_DATA}" \ - [--max-depth ] [--confirmed-large-scan] \ + [--max-depth ] [--confirmed-large-scan] [--quiet] \ [--root-children [--root-child ]...] ``` @@ -242,7 +247,9 @@ returns `depth-cut`/`null` for every NON-EMPTY child: the frontier is complete, fanning a deeper scan out over that subtree, report those rows as coverage gaps, never as small or clean. `scan-complete` also carries `unhinted_entries`, `entries` minus `hinted_entries`, every inventoried entry no hint judged. So quote hint coverage as a rate: 7 hinted of 40,247 is 0.017 %, nothing like "7 findings". Fields, reasons -and the measurement: [the safety model](reference/safety-model.md). +and the measurement: [the safety model](reference/safety-model.md). When a run needs the frontier ranked but not +the rows themselves in context, add `--quiet`: the rollup stays complete in the snapshot and stops being duplicated +onto stdout, where one row per immediate child dominates a wide target's payload. **Relocation is out of scope.** This skill offers exactly two outcomes per finding, keep it, or approve its exact path for deletion. There is no relocation lane and no move primitive in the engine, by design: a move diff --git a/plugins/disk-hygiene/skills/clean/reference/safety-model.md b/plugins/disk-hygiene/skills/clean/reference/safety-model.md index 4c90b3a56b..d2a83e086a 100644 --- a/plugins/disk-hygiene/skills/clean/reference/safety-model.md +++ b/plugins/disk-hygiene/skills/clean/reference/safety-model.md @@ -195,8 +195,10 @@ guard as `--plugin-root` and mapped to `/data/` per the documented layout. A host that can substitute `${CLAUDE_PLUGIN_DATA}` itself may instead pass it directly as `--authorized-data-root`, and the `CLAUDE_PLUGIN_DATA` environment variable is honored last; absent every channel the flag fails closed. `--max-depth` accepts only a bare positive-integer literal. -`--confirmed-large-scan` and `--root-children` are the valueless scan flags; the guard permits at -most one of each and rejects any trailing value, so the scan grammar stays exact. +`--confirmed-large-scan`, `--quiet` and `--root-children` are the valueless scan flags; the guard +permits at most one of each and rejects any trailing value, so the scan grammar stays exact. +`--quiet` is admitted because it shapes the engine's stdout only: it reaches no path, and skips no +check, that the same invocation without it would not already reach. Deriving the data root from `${CLAUDE_PLUGIN_ROOT}` couples to the one undocumented part of that layout — the `cache///` shape of the installation root (the install root @@ -442,6 +444,26 @@ NON-EMPTY child is not knowable without walking it, so every such child reads `d loose files and empty children; a per-child total is bought by fanning a deeper scan out over that subtree. +The roll-up is written to the snapshot file on every run, so `scan --quiet` omits it from stdout +and nothing else. The two copies are otherwise identical, and the snapshot is the copy the engine +treats as the record: the flag drops a duplicate, never data. Quiet output keeps `snapshot`, +`status`, `target`, the three coverage terms, `empty_directory_count`, both byte totals, +`truncated_paths`, `errors`, `policy_sources` and `os_autoclean`, so every field a keep-or-review +decision rests on survives, and it replaces the closing note with a short one naming where the +rows went. That field set holds in `--root-children` mode too, which reports +`empty_directory_count` on stdout for the same reason an ordinary scan does. The default stays the +full payload: a caller already parsing `children_rollup` off stdout must not be quietened by an +upgrade. + +Root-children mode's quiet note is its own. That mode's default note carries a coverage +qualification the ordinary one has no reason to: the volume root itself and every skipped +OS-owned, hidden, system or reparse entry were never walked, so the inventory is partial by +construction. Nothing else on stdout encodes that. The skipped entries are recorded as +`root_children_skipped` in the snapshot alone, and `truncated_paths` does not stand in for them, +so a quiet note that dropped the qualification would be dropping a fact rather than a duplicate. +The quiet root-children note therefore keeps the coverage sentence and drops only the rollup +prose. + The `scan-complete` summary reports hint coverage in three terms — `entries`, `hinted_entries`, and `unhinted_entries` (`entries` minus `hinted_entries`). The third is what makes the first two readable: without a denominator for what no hint judged, a run that annotated 7 of 40,247 entries is diff --git a/plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py b/plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py index 04cbe79c69..d1c48b335b 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py +++ b/plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py @@ -883,16 +883,20 @@ def classify_exact_engine_command(command: str, authority: str | None) -> str | or not _argument(tokens[6]) ): return None - # --confirmed-large-scan and --root-children are the valueless scan - # flags; strip at most one of each so the remainder is the pure + # --confirmed-large-scan, --quiet and --root-children are the valueless + # scan flags; strip at most one of each so the remainder is the pure # flag/value-pair grammar every other optional follows. --root-child # is repeatable (one basename per occurrence) and is stripped next. + # --quiet only shapes the engine's stdout, so admitting it widens no + # capability: it cannot reach a path the same invocation without it + # could not already reach. optionals = list(tokens[7:]) - confirmed = optionals.count("--confirmed-large-scan") - if confirmed > 1: - return None - if confirmed: - optionals.remove("--confirmed-large-scan") + for valueless in ("--confirmed-large-scan", "--quiet"): + occurrences = optionals.count(valueless) + if occurrences > 1: + return None + if occurrences: + optionals.remove(valueless) root_children = optionals.count("--root-children") if root_children > 1: return None diff --git a/plugins/disk-hygiene/skills/clean/scripts/hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/hygiene.py index bed35af056..64bc7a7db3 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/hygiene.py @@ -121,11 +121,67 @@ class HygieneError(Exception): """Expected invalid input or a blocked safety precondition.""" +QUIET_SCAN_NOTE = ( + "Quiet output: children_rollup is omitted from stdout only. The snapshot " + 'file named by "snapshot" carries every row in full, in this mode exactly ' + "as in the default one; read per-child detail there. Re-run without " + "--quiet for the rollup and the full interpretation note. Hints are " + "discovery signals, never cleanup verdicts." +) + +# Root-children mode's coverage qualification is not a restatement of the +# rollup, so the quiet note cannot drop it the way it drops the rest of the +# interpretation prose: the volume root and every excluded entry were never +# walked, and that limit is not recoverable from any other stdout field. +# `root_children_skipped` lives in the snapshot alone, and `truncated_paths` +# does not represent those entries, so a caller reading only quiet stdout has +# no other signal that the inventory is partial by construction. +QUIET_ROOT_CHILDREN_SCAN_NOTE = ( + "Quiet output: children_rollup is omitted from stdout only; the snapshot " + 'file named by "snapshot" carries every row in full. Coverage limit, ' + "unchanged by --quiet: root-children mode inventoried only the selected " + "immediate directories, so the volume root itself and every skipped " + "OS-owned/hidden/system/reparse entry were never walked, and " + "children_rollup covers the selected children only. The skipped entries " + 'are listed as "root_children_skipped" in the snapshot and are not ' + "represented in truncated_paths. Re-run without --quiet for the rollup " + "and the full interpretation note. Hints are discovery signals, never " + "cleanup verdicts." +) + + def emit(payload: dict[str, Any], code: int = 0) -> int: print(json.dumps(payload, indent=2, sort_keys=True)) return code +def scan_stdout_payload(payload: dict[str, Any], quiet: bool) -> dict[str, Any]: + """Shape a ``scan-complete`` payload for stdout at the requested verbosity. + + ``children_rollup`` is written to the snapshot file on every run, so + emitting it to stdout as well is duplication for a caller that only needs + the frontier and will read detail from the snapshot. ``--quiet`` drops that + stdout copy, and with it the long interpretation note that mostly explains + the rollup, and changes nothing else: the same run happens, the same + snapshot is written, and the decision-relevant fields all survive. + + Root-children mode gets its own quiet note. Its default note carries a + coverage qualification (the volume root and the skipped OS-owned, hidden, + system and reparse entries were never walked) that no other stdout field + encodes, so replacing it with the ordinary quiet note would drop a fact + rather than a duplicate. + """ + if not quiet: + return payload + trimmed = {key: value for key, value in payload.items() if key != "children_rollup"} + trimmed["note"] = ( + QUIET_ROOT_CHILDREN_SCAN_NOTE + if payload.get("root_children_mode") + else QUIET_SCAN_NOTE + ) + return trimmed + + def load_json(path: Path) -> dict[str, Any]: try: value = json.loads(path.read_text(encoding="utf-8")) @@ -3337,6 +3393,15 @@ def build_parser() -> argparse.ArgumentParser: scan.add_argument("--data-root") scan.add_argument("--max-depth", type=int) scan.add_argument("--confirmed-large-scan", action="store_true") + scan.add_argument( + "--quiet", + action="store_true", + help=( + "omit children_rollup from stdout and shorten the note; the " + "snapshot file still carries every row, so read per-child detail " + "there. Stdout shaping only: the scan itself is unchanged" + ), + ) scan.add_argument( "--root-children", action="store_true", @@ -3508,36 +3573,43 @@ def main(argv: list[str] | None = None) -> int: write_json(output_path, snapshot) hinted = sum(1 for entry in snapshot["entries"] if entry["hints"]) return emit( - { - "status": "scan-complete", - "target": str(target), - "root_children_mode": True, - "root_children_selected": resolved_children, - "snapshot": str(output_path), - "entries": len(snapshot["entries"]), - "hinted_entries": hinted, - "unhinted_entries": len(snapshot["entries"]) - hinted, - "target_logical_bytes": snapshot["target_logical_bytes"], - "target_reclaimable_local_bytes": snapshot[ - "target_reclaimable_local_bytes" - ], - "truncated_paths": snapshot["truncated_paths"], - "children_rollup": snapshot["children_rollup"], - "errors": snapshot["errors"], - "policy_sources": policy["policy_sources"], - "os_autoclean": advisory, - "note": ( - "Root-children mode inventoried only the selected " - "immediate directories; the volume root itself and " - "every skipped OS-owned/hidden/system/reparse entry " - "were never walked — so children_rollup covers the " - "selected children only. unhinted_entries is " - "entries minus hinted_entries: every inventoried " - "entry no hint judged, left to positional review. " - "Hints are discovery signals, never cleanup " - "verdicts." - ), - } + scan_stdout_payload( + { + "status": "scan-complete", + "target": str(target), + "root_children_mode": True, + "root_children_selected": resolved_children, + "snapshot": str(output_path), + "entries": len(snapshot["entries"]), + "hinted_entries": hinted, + "unhinted_entries": len(snapshot["entries"]) - hinted, + "empty_directory_count": snapshot[ + "empty_directory_count" + ], + "target_logical_bytes": snapshot["target_logical_bytes"], + "target_reclaimable_local_bytes": snapshot[ + "target_reclaimable_local_bytes" + ], + "truncated_paths": snapshot["truncated_paths"], + "children_rollup": snapshot["children_rollup"], + "errors": snapshot["errors"], + "policy_sources": policy["policy_sources"], + "os_autoclean": advisory, + "note": ( + "Root-children mode inventoried only the " + "selected immediate directories; the volume " + "root itself and every skipped " + "OS-owned/hidden/system/reparse entry were " + "never walked — so children_rollup covers the " + "selected children only. unhinted_entries is " + "entries minus hinted_entries: every " + "inventoried entry no hint judged, left to " + "positional review. Hints are discovery " + "signals, never cleanup verdicts." + ), + }, + args.quiet, + ) ) large_reasons = large_scan_reasons(target) if ( @@ -3578,43 +3650,48 @@ def main(argv: list[str] | None = None) -> int: write_json(output_path, snapshot) hinted = sum(1 for entry in snapshot["entries"] if entry["hints"]) return emit( - { - "status": "scan-complete", - "target": str(target), - "snapshot": str(output_path), - "entries": len(snapshot["entries"]), - "hinted_entries": hinted, - "unhinted_entries": len(snapshot["entries"]) - hinted, - "empty_directory_count": snapshot["empty_directory_count"], - "target_logical_bytes": snapshot["target_logical_bytes"], - "target_reclaimable_local_bytes": snapshot[ - "target_reclaimable_local_bytes" - ], - "truncated_paths": snapshot["truncated_paths"], - "children_rollup": snapshot["children_rollup"], - "errors": snapshot["errors"], - "policy_sources": policy["policy_sources"], - "os_autoclean": advisory, - "note": ( - "Safe tidiness is the primary objective; reclaimable " - "bytes are a secondary signal. empty_directory_count " - "names walked empty directories (logical_size 0, not " - "truncated) so zero-byte residue stays visible. " - "unhinted_entries is entries minus hinted_entries — " - "every inventoried entry no hint judged, left to " - "positional review — so hint coverage reads as a rate, " - "not a bare count. Hints are discovery signals, " - "never cleanup verdicts. children_rollup carries one " - "row per immediate child; its logical_bytes, " - "entry_count and newest_mtime_ns are exact where " - "walked is true and null where it is false, never 0. " - "target_reclaimable_local_bytes excludes every entry " - "whose size_qualifiers is non-empty (cloud-placeholder, " - "hardlinked, sparse, not-walked); target_logical_bytes " - "is the walked roll-up and may understate truncated " - "subtrees." - ), - } + scan_stdout_payload( + { + "status": "scan-complete", + "target": str(target), + "snapshot": str(output_path), + "entries": len(snapshot["entries"]), + "hinted_entries": hinted, + "unhinted_entries": len(snapshot["entries"]) - hinted, + "empty_directory_count": snapshot["empty_directory_count"], + "target_logical_bytes": snapshot["target_logical_bytes"], + "target_reclaimable_local_bytes": snapshot[ + "target_reclaimable_local_bytes" + ], + "truncated_paths": snapshot["truncated_paths"], + "children_rollup": snapshot["children_rollup"], + "errors": snapshot["errors"], + "policy_sources": policy["policy_sources"], + "os_autoclean": advisory, + "note": ( + "Safe tidiness is the primary objective; " + "reclaimable bytes are a secondary signal. " + "empty_directory_count names walked empty " + "directories (logical_size 0, not truncated) so " + "zero-byte residue stays visible. " + "unhinted_entries is entries minus " + "hinted_entries — every inventoried entry no hint " + "judged, left to positional review — so hint " + "coverage reads as a rate, not a bare count. " + "Hints are discovery signals, never cleanup " + "verdicts. children_rollup carries one row per " + "immediate child; its logical_bytes, entry_count " + "and newest_mtime_ns are exact where walked is " + "true and null where it is false, never 0. " + "target_reclaimable_local_bytes excludes every " + "entry whose size_qualifiers is non-empty " + "(cloud-placeholder, hardlinked, sparse, " + "not-walked); target_logical_bytes is the walked " + "roll-up and may understate truncated subtrees." + ), + }, + args.quiet, + ) ) snapshot = load_json(Path(args.snapshot)) if args.command == "handoff-verify": diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py index 18d0dd45a4..6782395659 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py @@ -1157,6 +1157,50 @@ def test_root_children_scans_only_selected_admitted_directories(self) -> None: self.assertTrue(snapshot["root_children_mode"]) self.assertEqual(["builds", "tmp"], snapshot["root_children_selected"]) + def test_root_children_scan_honours_quiet_without_losing_the_snapshot( + self, + ) -> None: + """Root-children mode emits its own scan-complete, so quiet must reach it. + + A flag that shapes one of the two scan-complete payloads and silently + does nothing in the other is a trap for the caller who reaches for it + exactly where the frontier is widest. + """ + with tempfile.TemporaryDirectory() as temporary: + base = Path(temporary) + target = base / "os-root" + data_root = base / "plugin-data" + target.mkdir() + data_root.mkdir() + (target / "builds").mkdir() + (target / "builds" / "orphan.tmp").write_text("left", encoding="utf-8") + code, payload = self._scan_target( + target, + data_root, + self._os_managed_volume_root_patches(target), + extra_args=["--root-children", "--root-child", "builds", "--quiet"], + ) + self.assertEqual(0, code) + self.assertEqual("scan-complete", payload["status"]) + self.assertNotIn("children_rollup", payload) + # Root-children mode keeps its own quiet note: the coverage + # qualification it carries is not recoverable from any other + # stdout field, so quieting must not replace it with the ordinary + # note the way it does for a full scan. + self.assertEqual(hygiene.QUIET_ROOT_CHILDREN_SCAN_NOTE, payload["note"]) + self.assertNotEqual(hygiene.QUIET_SCAN_NOTE, payload["note"]) + self.assertIn("never walked", payload["note"]) + self.assertIn("root_children_skipped", payload["note"]) + # The documented quiet field set applies to this mode too. + self.assertIn("empty_directory_count", payload) + self.assertEqual(["builds"], payload["root_children_selected"]) + snapshot = json.loads( + (data_root / "snapshot.json").read_text(encoding="utf-8") + ) + self.assertEqual( + ["builds"], [row["name"] for row in snapshot["children_rollup"]] + ) + def test_root_children_rejects_unadmitted_or_path_selection(self) -> None: with tempfile.TemporaryDirectory() as temporary: base = Path(temporary) @@ -2734,6 +2778,167 @@ def test_scan_command_emits_the_third_coverage_term(self) -> None: ) +class ScanOutputVerbosityTests(unittest.TestCase): + """``scan --quiet`` shapes stdout only; the snapshot is never shaped. + + The rollup is written to the snapshot on every run, so the stdout copy is + duplication for a caller that only needs the frontier. These pin the three + ways that trade could go wrong: quiet dropping a field the caller decides + on, quiet reaching the snapshot on disk, and the default quietly becoming + the quiet mode under an existing caller that parses the full payload. + """ + + @staticmethod + def _fixture(root: Path, children: int) -> None: + root.mkdir(parents=True) + for index in range(children): + child = root / f"child_{index:03d}" + (child / "nested").mkdir(parents=True) + (child / "nested" / "a.log").write_text("a" * (index + 1), encoding="utf-8") + (root / "loose.tmp").write_text("x" * 42, encoding="utf-8") + + def _scan( + self, root: Path, data_root: Path, extra: list[str] + ) -> tuple[int, dict[str, object], str, dict[str, object]]: + output = data_root / "runs" / "snapshot.json" + stdout_io = io.StringIO() + with redirect_stdout(stdout_io): + code = hygiene.main( + [ + "scan", + "--target", + str(root), + "--output", + str(output), + "--data-root", + str(data_root), + "--max-depth", + "1", + *extra, + ] + ) + raw = stdout_io.getvalue() + payload = cast("dict[str, object]", json.loads(raw)) + snapshot = cast( + "dict[str, object]", + json.loads(Path(str(payload["snapshot"])).read_text(encoding="utf-8")), + ) + return code, payload, raw, snapshot + + def _both_modes( + self, children: int + ) -> tuple[tuple[dict[str, object], str, dict[str, object]], ...]: + results = [] + for extra in ([], ["--quiet"]): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "target" + self._fixture(root, children) + data_root = Path(temporary) / "data" + data_root.mkdir() + code, payload, raw, snapshot = self._scan(root, data_root, extra) + self.assertEqual(0, code, extra) + results.append((payload, raw, snapshot)) + return tuple(results) + + def test_default_scan_still_carries_the_rollup_and_the_explaining_note( + self, + ) -> None: + """Pins the default so a future flip of it cannot pass silently. + + An existing caller parses `children_rollup` off stdout. Making quiet + the default would break it, so the default has to be asserted, not + assumed. + """ + (default, _, _), _ = self._both_modes(4) + self.assertEqual("scan-complete", default["status"]) + self.assertEqual( + { + "status", + "target", + "snapshot", + "entries", + "hinted_entries", + "unhinted_entries", + "empty_directory_count", + "target_logical_bytes", + "target_reclaimable_local_bytes", + "truncated_paths", + "children_rollup", + "errors", + "policy_sources", + "os_autoclean", + "note", + }, + set(default), + ) + rows = cast("list[dict[str, object]]", default["children_rollup"]) + self.assertEqual(5, len(rows)) + self.assertNotEqual(hygiene.QUIET_SCAN_NOTE, default["note"]) + self.assertIn("children_rollup carries one row", str(default["note"])) + + def test_quiet_drops_the_rollup_and_keeps_every_decision_field(self) -> None: + (default, _, _), (quiet, _, _) = self._both_modes(4) + self.assertNotIn("children_rollup", quiet) + self.assertEqual(set(default) - {"children_rollup"}, set(quiet)) + # Every field the caller decides on survives, with the same value the + # default run reported: quiet is a projection, never a recomputation. + for field in set(quiet) - {"note", "target", "snapshot"}: + self.assertEqual(default[field], quiet[field], field) + self.assertEqual(hygiene.QUIET_SCAN_NOTE, quiet["note"]) + self.assertIn("snapshot", quiet) + + def test_snapshot_on_disk_keeps_the_rollup_in_both_modes(self) -> None: + """A quiet run whose snapshot lost the rollup is data loss, not brevity.""" + (_, _, default_snapshot), (_, _, quiet_snapshot) = self._both_modes(4) + for snapshot in (default_snapshot, quiet_snapshot): + rows = cast("list[dict[str, object]]", snapshot["children_rollup"]) + self.assertEqual(5, len(rows)) + self.assertTrue(all(row["name"] for row in rows)) + self.assertEqual( + [ + row["name"] + for row in cast( + "list[dict[str, object]]", default_snapshot["children_rollup"] + ) + ], + [ + row["name"] + for row in cast( + "list[dict[str, object]]", quiet_snapshot["children_rollup"] + ) + ], + ) + + def test_quiet_stdout_stops_growing_with_the_child_count(self) -> None: + """The saving is the whole point, so pin that it scales with the frontier.""" + (small_default, small_raw, _), (_, small_quiet_raw, _) = self._both_modes(4) + (large_default, large_raw, _), (_, large_quiet_raw, _) = self._both_modes(40) + self.assertEqual(5, len(cast("list[object]", small_default["children_rollup"]))) + self.assertEqual( + 41, len(cast("list[object]", large_default["children_rollup"])) + ) + self.assertLess(len(small_quiet_raw), len(small_raw)) + self.assertLess(len(large_quiet_raw), len(large_raw)) + # The default payload grows a whole rollup row per extra child. Quiet + # carries no per-child rows, so its only per-child growth is the one + # truncated path each depth-cut child adds: an order of magnitude + # flatter, which is the saving this flag exists to buy. + default_growth = len(large_raw) - len(small_raw) + quiet_growth = len(large_quiet_raw) - len(small_quiet_raw) + self.assertGreater(default_growth, 5000) + self.assertLess(quiet_growth * 10, default_growth) + self.assertLess(len(large_quiet_raw) * 4, len(large_raw)) + + def test_shaping_without_quiet_returns_the_payload_untouched(self) -> None: + payload = {"status": "scan-complete", "children_rollup": [1], "note": "keep"} + self.assertIs(payload, hygiene.scan_stdout_payload(payload, False)) + trimmed = hygiene.scan_stdout_payload(payload, True) + self.assertNotIn("children_rollup", trimmed) + # The caller's dict is never mutated in place. + self.assertIn("children_rollup", payload) + self.assertEqual("keep", payload["note"]) + + class VersionFloorTests(unittest.TestCase): """The Python floor has one origin: hygiene.MIN_PYTHON.""" @@ -6489,6 +6694,42 @@ def test_guard_scan_accepts_single_confirmed_large_scan_flag(self) -> None: command, ) + def test_guard_scan_accepts_single_quiet_flag(self) -> None: + """The verbosity flag has to be reachable from the lane that needs it. + + `--quiet` shapes stdout and nothing else, so admitting it widens no + capability. Admitting it once is the whole allowance: a repeat, or a + value attached to it, is still a shape the classifier has never seen + and still fails closed. + """ + script = SCRIPT_DIR / "hygiene.py" + base = f'"{self.python_command()}" "{script}" scan --target t --output s' + allowed = ( + f"{base} --quiet", + f"{base} --quiet --max-depth 1", + f"{base} --max-depth 1 --quiet", + f"{base} --confirmed-large-scan --quiet", + f"{base} --quiet --root-children --root-child builds", + ) + denied = ( + f"{base} --quiet --quiet", + f"{base} --quiet v", + f"{base} --quiet=1", + f"{base} -q", + ) + for command in allowed: + self.assertEqual( + "allow", + self.run_guard(command)["hookSpecificOutput"]["permissionDecision"], + command, + ) + for command in denied: + self.assertEqual( + "deny", + self.run_guard(command)["hookSpecificOutput"]["permissionDecision"], + command, + ) + def test_guard_scan_accepts_root_children_selection_flags(self) -> None: script = SCRIPT_DIR / "hygiene.py" base = f'"{self.python_command()}" "{script}" scan --target t --output s'