diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6f3dcd5..1c9b87e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,3 +19,5 @@ jobs: run: python -m py_compile audit-local-files/scripts/*.py - name: Run smoke test run: python tests/smoke_test.py + - name: Run contract test + run: python tests/contract_test.py diff --git a/README.md b/README.md index 3b32c3a..4f09533 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,27 @@ python3 audit-local-files/scripts/compare_reports.py \ The comparison highlights disk usage, target areas, Codex workspace counts, Git dirty changes, and rebuildable artifacts that changed. Keep snapshots local unless you review their paths first. +## Evidence, Not Guesswork + +The JSON report now carries a small decision contract: + +- `measurement_status` distinguishes measured, timeout, error, missing, and unknown; +- `coverage` records which target families were actually measured; +- `findings` links each recommendation to evidence, owner, risk, confidence, and rollback/rebuild guidance; +- `action_gate` keeps the result in `review_only` when evidence is incomplete or dirty Git work must be preserved. + +Validate a saved snapshot before handing it to another Agent: + +```bash +python3 audit-local-files/scripts/validate_report.py snapshots/before.json +``` + +Artifact rows are candidate subsets. They may already be included in a parent workspace total, so the report does not present them as additive reclaimable space. + +## How We Iterate The Skill + +The project uses a maintainer-only evaluation loop: run a real local audit, aggregate and redact the evidence, ask independent roles to review it, synthesize the smallest change, then run contract and fixture tests before publishing. Raw local reports never enter the repository. See [the evaluation loop](audit-local-files/references/agentic-evaluation.md). + ## What A Useful Result Looks Like The report is designed to turn raw measurements into a next step: @@ -204,10 +225,16 @@ python3 audit-local-files/scripts/audit_local_files.py \ 保存 JSON 快照后,可以用 `audit-local-files/scripts/compare_reports.py` 比较两次审计,观察哪些区域在增长。 +交给其他 Agent 之前,可以先运行 `python3 audit-local-files/scripts/validate_report.py snapshots/before.json` 校验报告契约。 + +JSON 报告还会区分 `measured`、`timeout`、`error`、`missing` 和 `unknown`,记录 coverage、证据关联的 findings,以及是否因为证据不足或 Git dirty 状态而只能停留在 `review_only`。artifact 可能已经包含在父工作区大小中,不会被当成可回收空间重复相加。 + ### 隐私与安全 扫描器只读取路径、占用大小、修改时间和可选的 Git 状态计数;不会读取聊天、浏览器历史、邮件正文、文档正文、源码、凭据或 keychain。默认把 home 目录显示为 `~`,默认不输出 Git origin URL。任何公开分享前,都应检查项目名和文件夹名。 +项目维护时会使用真实本机审计的聚合匿名证据包,让不同 Agent 角色独立评审,再通过确定性 fixture 和 schema 测试后发布;原始本机报告不会进入仓库。 + ### 依赖 Python 3.9+;macOS/Linux 上的 `du`;只有在进行 Git 检查时才需要 `git`。不需要第三方 Python 包。 diff --git a/audit-local-files/SKILL.md b/audit-local-files/SKILL.md index ee34ce6..57a7b2a 100644 --- a/audit-local-files/SKILL.md +++ b/audit-local-files/SKILL.md @@ -41,7 +41,13 @@ python3 scripts/audit_local_files.py \ --format markdown ``` -Use `--output` to save a report. Use JSON when saving a snapshot for comparison. Read `references/target-taxonomy.md` when adding or interpreting app patterns. Read `references/action-playbook.md` when turning findings into an action plan. +Use `--output` to save a report. Use JSON when saving a snapshot for comparison. Read `references/target-taxonomy.md` when adding or interpreting app patterns. Read `references/report-schema.md` when consuming JSON or building an evidence-linked decision. Read `references/action-playbook.md` when turning findings into an action plan. + +Before comparing or sharing a JSON snapshot with another Agent, run: + +```bash +python3 scripts/validate_report.py report.json +``` ### 3. Interpret the evidence @@ -56,6 +62,8 @@ Organize the explanation in this order: Do not equate “large” with “safe to delete.” A large app container may contain user data; a small untracked file may be important. +Treat `measurement_status: timeout|error|unknown` as unknown, never as zero. Treat artifact rows as candidate subsets that may already be included in a parent workspace total. Use `coverage`, `findings`, and `action_gate` to preserve what was measured, why a conclusion was made, and whether exact cleanup review is blocked. + ### 4. Produce a decision-ready report Lead with a short organization conclusion. Then include: @@ -86,6 +94,8 @@ If the user requests cleanup, present an approval table before changing anything Prefer reversible moves to Trash for user-visible folders when the user approves them. Never bulk-delete app containers, cloud-sync roots, dirty repositories, or unknown paths. +If the report's `action_gate.status` is `review_only`, do not produce an executable cleanup plan. Resolve the listed evidence blockers first. The scanner itself never performs cleanup, even when the gate is `approval_required`. + ## Snapshot Comparison The JSON report is intentionally suitable for local snapshots: @@ -100,6 +110,10 @@ python3 scripts/compare_reports.py before.json after.json The comparison script reports changes in disk usage, target areas, Codex counts, Git buckets, and artifacts. It preserves whatever redaction was present in the input reports and never reads file contents. +## Maintainer Evaluation Loop + +For a repeatable skill iteration using local evidence and independent roles, read `references/agentic-evaluation.md`. Keep raw reports in a temporary local directory, pass only an aggregated redacted packet to reviewers, record timed-out roles as incomplete, then require contract and fixture tests before publishing. + ## Failure Handling - If a size probe times out, report it as unknown or timed out. Do not silently treat it as zero. diff --git a/audit-local-files/references/agentic-evaluation.md b/audit-local-files/references/agentic-evaluation.md new file mode 100644 index 0000000..241c911 --- /dev/null +++ b/audit-local-files/references/agentic-evaluation.md @@ -0,0 +1,37 @@ +# Agentic Evaluation Loop + +Use this loop when improving the skill. It is a maintainer workflow, not a user cleanup workflow. + +```text +real local audit + -> aggregate and redact evidence packet + -> independent role reviews + -> main-agent synthesis + -> bounded code/doc change + -> deterministic fixture and contract tests + -> public release +``` + +## Evidence Packet + +Create a temporary packet containing only aggregate counts, sizes, statuses, and scanner limitations. Remove project names, repository names, exact paths, Git origins, prompts, and file contents. Keep the raw report local and never commit it. + +## Recommended Roles + +- **Audit operator**: check coverage, timeouts, duplicate counting, and whether conclusions are supported. +- **Privacy guardian**: check redaction, content boundaries, origin URLs, command scope, and report sharing risk. +- **First-time user**: check whether the Agent can start quickly and produce a decision instead of a raw size list. +- **Loop architect**: check whether findings have evidence references, confidence, coverage state, and regression tests. + +Require each role to return findings, evidence, severity, and the smallest concrete improvement. A role that times out is recorded as incomplete, not as approval. + +## Acceptance Gates + +Before publishing an iteration: + +1. No cleanup or migration was executed during evaluation. +2. Non-measured sizes are represented as `null` with an explicit status. +3. Coverage and findings are linked to evidence. +4. Parent and artifact sizes are not summed as independent reclaimable space. +5. Privacy defaults and synthetic fixtures pass regression tests. +6. The README explains the user-facing outcome and the Agent handoff. diff --git a/audit-local-files/references/report-schema.md b/audit-local-files/references/report-schema.md new file mode 100644 index 0000000..22d3044 --- /dev/null +++ b/audit-local-files/references/report-schema.md @@ -0,0 +1,51 @@ +# Report Contract + +The JSON report is a local evidence artifact, not a cleanup authorization. `schema_version` is incremented when the contract changes. + +## Core Fields + +| Field | Meaning | +| --- | --- | +| `settings.scope_id` | Logical scan scope. The default home scan uses `home`. Do not combine reports with different scopes without saying so. | +| `settings.size_kind` | `allocated_bytes`; these are filesystem allocation estimates, not apparent file size. | +| `target_areas[]` | Discovered known roots. `allocated_bytes` may be `null`. | +| `target_areas[].measurement_status` | `measured`, `timeout`, `error`, `missing`, or `unknown`. Never treat a non-measured row as zero. | +| `coverage[]` | Coverage ledger for configured target families, including matches, unknown rows, measured bytes, and status. | +| `findings[]` | Evidence-linked decision records with owner, risk, confidence, recommendation, and rollback/rebuild guidance. | +| `action_gate` | Whether the report is `review_only` or only `approval_required`. The scanner never authorizes or executes cleanup. | + +## Finding Contract + +Every finding should preserve these fields: + +```json +{ + "finding_id": "target-opaque-id", + "scope_id": "home", + "path_redacted": "~/example", + "category": "workspace", + "owner": "project or agent workflow", + "size_bytes": 123, + "status": "measured", + "evidence_refs": ["target_areas[0]"], + "confidence": "strong_inference", + "risk": "user-data", + "recommendation": "Review and promote durable outputs before archiving.", + "approval_required": true, + "rollback_or_rebuild": "Restore from the archive or original project location." +} +``` + +Confidence is deliberately qualitative: + +- `confirmed`: direct local metadata supports the claim, such as a Git status count; +- `strong_inference`: a known path pattern and metadata support the classification; +- `low`: timeout, error, or incomplete evidence prevents a reliable conclusion. + +## Overlap Rules + +Target areas are containers. Artifacts are candidate subsets and may already be included in a parent target's size. Use `parent_target_id`, `counted_in_total`, and `nested_artifact` to avoid adding parent and child bytes together as reclaimable space. + +## Action Gate + +`review_only` is required when any target or artifact is not measured, Git status is missing, or dirty Git changes exist. Even without blockers, `approval_required` remains true. App-state and cloud-sync findings remain owner-managed. diff --git a/audit-local-files/scripts/audit_local_files.py b/audit-local-files/scripts/audit_local_files.py index 273e7ba..bd1595b 100644 --- a/audit-local-files/scripts/audit_local_files.py +++ b/audit-local-files/scripts/audit_local_files.py @@ -10,6 +10,7 @@ import argparse import glob +import hashlib import json import os import platform @@ -291,6 +292,56 @@ def sanitize_text(text: str, home: Path, redact: bool) -> str: return text.replace(str(home), "~") +def measurement_status(size: Optional[int], error: str, timed_out: bool) -> str: + if timed_out: + return "timeout" + if size is not None: + return "measured" + if error == "missing": + return "missing" + if error: + return "error" + return "unknown" + + +def stable_id(prefix: str, value: str) -> str: + digest = hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()[:12] + return f"{prefix}-{digest}" + + +def owner_for_category(category: str) -> str: + return { + "app-state": "owning app", + "cloud-sync": "sync provider", + "inbox": "user workflow", + "workspace": "project or agent workflow", + "cache": "project toolchain", + "deliverable": "user or project library", + }.get(category, "unknown") + + +def action_for_category(category: str) -> str: + return { + "app-state": "Use the owning app's storage controls; do not delete the container.", + "cloud-sync": "Check sync status and retention before changing files.", + "inbox": "Review and apply an age policy before archiving.", + "workspace": "Review and promote durable outputs before archiving.", + "cache": "Confirm the rebuild command before removal.", + "deliverable": "Promote the selected output to a stable library or project root.", + }.get(category, "Review shallow metadata before deciding.") + + +def rollback_for_category(category: str) -> str: + return { + "app-state": "Restore through the owning app or backup; direct deletion may lose local state.", + "cloud-sync": "Restore through the sync provider and verify sync state.", + "inbox": "Restore from Trash or the dated archive.", + "workspace": "Restore from the archive, Git, or the original project location.", + "cache": "Reinstall dependencies or rebuild the project.", + "deliverable": "Restore from the stable library or backup.", + }.get(category, "No rollback is defined until the owner is identified.") + + def expand_pattern(pattern: str, home: Path) -> list[Path]: expanded = os.path.expandvars(pattern.replace("~", str(home), 1)) if any(ch in expanded for ch in "*?["): @@ -378,6 +429,7 @@ def scan_targets(home: Path, timeout: int, deadline: Optional[float], redact: bo "modified_at": mtime_iso(path), "measurement_error": sanitize_text(error, home, redact), "timed_out": timed_out, + "measurement_status": measurement_status(size, error, timed_out), } ) records.sort(key=lambda item: item.get("allocated_bytes") or -1, reverse=True) @@ -404,6 +456,7 @@ def top_children(root: Path, home: Path, min_bytes: int, limit: int, timeout: in "modified_at": mtime_iso(child), "measurement_error": sanitize_text(error, home, redact), "timed_out": timed_out, + "measurement_status": measurement_status(None, error, timed_out), } ) continue @@ -417,6 +470,7 @@ def top_children(root: Path, home: Path, min_bytes: int, limit: int, timeout: in "modified_at": mtime_iso(child), "measurement_error": sanitize_text(error, home, redact), "timed_out": timed_out, + "measurement_status": measurement_status(size, error, timed_out), } ) rows.sort(key=lambda item: item["allocated_bytes"] if item["allocated_bytes"] is not None else -1, reverse=True) @@ -469,7 +523,7 @@ def read_git_origin(repo: Path) -> str: return "" -def git_status_count(repo: Path) -> tuple[int | None, str]: +def git_status_count(repo: Path) -> tuple[Optional[int], str]: if not shutil.which("git"): return None, "git not found" try: @@ -549,7 +603,12 @@ def find_git_repos(roots: list[Path], home: Path, max_depth: int, limit: int, in item["dirty_repo_count"] += 1 item["dirty_change_count"] += repo["dirty_count"] bucket_rows = sorted(buckets.values(), key=lambda item: item["repo_count"], reverse=True) - return {"repos": repos, "buckets": bucket_rows, "truncated": len(repos) >= limit} + return { + "repos": repos, + "buckets": bucket_rows, + "truncated": len(repos) >= limit, + "status_collected": include_status, + } def scan_artifacts(roots: list[Path], home: Path, max_depth: int, min_bytes: int, limit: int, timeout: int, redact: bool) -> list[dict[str, Any]]: @@ -567,7 +626,7 @@ def scan_artifacts(roots: list[Path], home: Path, max_depth: int, min_bytes: int child = current_path / dirname if dirname in ARTIFACT_NAMES: size, error, timed_out = du_size(child, timeout) - if size is not None and size >= min_bytes: + if (size is not None and size >= min_bytes) or (size is None and (error or timed_out)): category, risk, rebuild = ARTIFACT_NAMES[dirname] rows.append( { @@ -581,6 +640,7 @@ def scan_artifacts(roots: list[Path], home: Path, max_depth: int, min_bytes: int "rebuild_hint": rebuild, "measurement_error": sanitize_text(error, home, redact), "timed_out": timed_out, + "measurement_status": measurement_status(size, error, timed_out), } ) continue @@ -591,7 +651,10 @@ def scan_artifacts(roots: list[Path], home: Path, max_depth: int, min_bytes: int break if len(rows) >= limit: break - rows.sort(key=lambda item: item["allocated_bytes"], reverse=True) + rows.sort( + key=lambda item: item["allocated_bytes"] if item["allocated_bytes"] is not None else -1, + reverse=True, + ) return rows[:limit] @@ -619,8 +682,6 @@ def codex_summary(home: Path, measure_sizes: bool, timeout: int, deadline: Optio if budget_exhausted(deadline): break size, error, timed_out = du_size(date_dir, timeout) - if size is None: - continue top_dates.append( { "path": rel_home(date_dir, home), @@ -629,9 +690,10 @@ def codex_summary(home: Path, measure_sizes: bool, timeout: int, deadline: Optio "modified_at": mtime_iso(date_dir), "measurement_error": sanitize_text(error, home, redact), "timed_out": timed_out, + "measurement_status": measurement_status(size, error, timed_out), } ) - top_dates.sort(key=lambda item: item["allocated_bytes"], reverse=True) + top_dates.sort(key=lambda item: item["allocated_bytes"] if item["allocated_bytes"] is not None else -1, reverse=True) return { "path": rel_home(root, home), "date_dir_count": len(date_dirs), @@ -668,6 +730,178 @@ def disk_summary(home: Path, redact: bool) -> dict[str, Any]: "total": human_size(usage.total), "used": human_size(usage.used), "free": human_size(usage.free), + "measurement_status": "measured", + } + + +def target_coverage(records: list[dict[str, Any]]) -> list[dict[str, Any]]: + definitions: dict[str, dict[str, Any]] = {} + for target in TARGETS: + item = definitions.setdefault( + target.label, + { + "label": target.label, + "category": target.category, + "risk": target.risk, + "patterns_checked": 0, + }, + ) + item["patterns_checked"] += len(target.patterns) + + grouped: dict[str, list[dict[str, Any]]] = {} + for record in records: + grouped.setdefault(record["label"], []).append(record) + + coverage: list[dict[str, Any]] = [] + for label, definition in definitions.items(): + rows = grouped.get(label, []) + statuses = {row.get("measurement_status", "unknown") for row in rows} + measured_bytes = sum( + row["allocated_bytes"] + for row in rows + if isinstance(row.get("allocated_bytes"), int) + ) + if "timeout" in statuses: + status = "timeout" + elif "error" in statuses: + status = "error" + elif not rows: + status = "not_found" + elif "unknown" in statuses: + status = "unknown" + else: + status = "measured" + coverage.append( + { + **definition, + "scope_id": "home", + "matches": len(rows), + "measured": sum(row.get("measurement_status") == "measured" for row in rows), + "unknown": sum(row.get("measurement_status") != "measured" for row in rows), + "measured_bytes": measured_bytes, + "status": status, + } + ) + return coverage + + +def path_is_within(child: str, parent: str) -> bool: + normalized_parent = parent.rstrip("/") + return child == normalized_parent or child.startswith(normalized_parent + "/") + + +def build_findings( + records: list[dict[str, Any]], + git: dict[str, Any], + artifacts: list[dict[str, Any]], +) -> list[dict[str, Any]]: + findings: list[dict[str, Any]] = [] + target_records: list[dict[str, Any]] = [] + for index, row in enumerate(records): + finding_id = stable_id("target", row["path"]) + row["finding_id"] = finding_id + status = row.get("measurement_status", "unknown") + target_records.append(row) + findings.append( + { + "finding_id": finding_id, + "scope_id": "home", + "path_redacted": row["path"], + "category": row["category"], + "owner": owner_for_category(row["category"]), + "size_bytes": row.get("allocated_bytes"), + "status": status, + "evidence_refs": [f"target_areas[{index}]"], + "confidence": "strong_inference" if status == "measured" else "low", + "risk": row["risk"], + "recommendation": action_for_category(row["category"]), + "approval_required": True, + "rollback_or_rebuild": rollback_for_category(row["category"]), + } + ) + + artifact_paths = [row.get("path", "") for row in artifacts] + for index, row in enumerate(artifacts): + path = row.get("path", "") + parents = [ + target + for target in target_records + if path and path_is_within(path, target["path"]) + ] + parent = max(parents, key=lambda target: len(target["path"])) if parents else None + nested = any( + other_path != path and path_is_within(path, other_path) + for other_path in artifact_paths + if other_path + ) + status = row.get("measurement_status", "unknown") + findings.append( + { + "finding_id": stable_id("artifact", path), + "scope_id": "home", + "path_redacted": path, + "category": row.get("category", "cache"), + "owner": "project toolchain", + "size_bytes": row.get("allocated_bytes"), + "status": status, + "evidence_refs": ["artifacts[" + str(index) + "]"] + + (["target_areas[" + str(records.index(parent)) + "]"] if parent else []), + "confidence": "strong_inference" if status == "measured" else "low", + "risk": row.get("risk", "review"), + "recommendation": "Confirm the rebuild command before removal.", + "approval_required": True, + "rollback_or_rebuild": row.get("rebuild_hint", "Rebuild the owning project."), + "parent_target_id": parent.get("finding_id") if parent else None, + "counted_in_total": bool(parent and parent.get("measurement_status") == "measured"), + "nested_artifact": nested, + } + ) + + for index, bucket in enumerate(git.get("buckets", [])): + dirty_repos = bucket.get("dirty_repo_count", 0) + dirty_changes = bucket.get("dirty_change_count", 0) + if not dirty_repos and not dirty_changes: + continue + status = "dirty" if isinstance(dirty_repos, int) and isinstance(dirty_changes, int) else "unknown" + findings.append( + { + "finding_id": stable_id("git", str(bucket.get("bucket", index))), + "scope_id": "home", + "path_redacted": bucket.get("bucket", "unknown"), + "category": "workspace", + "owner": "project owner", + "size_bytes": None, + "status": status, + "evidence_refs": [f"git.buckets[{index}]"], + "confidence": "confirmed" if status == "dirty" else "low", + "risk": "user-data", + "recommendation": "Commit, stash, or export dirty changes before moving repositories.", + "approval_required": True, + "rollback_or_rebuild": "Restore the original repository or recover from Git/stash/export.", + "dirty_repo_count": dirty_repos, + "dirty_change_count": dirty_changes, + } + ) + return findings + + +def action_gate(records: list[dict[str, Any]], git: dict[str, Any], artifacts: list[dict[str, Any]]) -> dict[str, Any]: + blockers: list[str] = [] + if any(row.get("measurement_status") != "measured" for row in records): + blockers.append("Some target size probes are incomplete; resolve timeout or error states before exact cleanup decisions.") + if git.get("skipped") or not git.get("status_collected", False): + blockers.append("Git status was not collected; repository moves require a status check first.") + dirty_changes = sum(row.get("dirty_change_count", 0) for row in git.get("buckets", [])) + if dirty_changes: + blockers.append(f"{dirty_changes} dirty Git changes require preservation before repository migration.") + if any(row.get("measurement_status") != "measured" for row in artifacts): + blockers.append("Some artifact measurements are incomplete; do not treat them as reclaimable totals.") + return { + "status": "review_only" if blockers else "approval_required", + "scanner_mutates_files": False, + "exact_cleanup_allowed": False, + "requires_exact_approval": True, + "blockers": blockers, } @@ -702,12 +936,14 @@ def build_report(args: argparse.Namespace) -> dict[str, Any]: if include_git: git = find_git_repos(roots, home, args.git_depth, args.git_limit, args.git_status, args.include_git_origins, redact) else: - git = {"repos": [], "buckets": [], "truncated": False, "skipped": True} + git = {"repos": [], "buckets": [], "truncated": False, "skipped": True, "status_collected": False} artifacts = [] if args.mode == "full" or args.artifacts: artifacts = scan_artifacts(roots, home, args.artifact_depth, min_bytes, args.artifact_limit, child_timeout, redact) + coverage = target_coverage(records) + findings = build_findings(records, git, artifacts) return { - "schema_version": "1.0", + "schema_version": "1.1", "generated_at": now_iso(), "read_only": True, "host": { @@ -717,6 +953,8 @@ def build_report(args: argparse.Namespace) -> dict[str, Any]: }, "settings": { "home": display_home(home, redact), + "scope_id": "home", + "size_kind": "allocated_bytes", "mode": args.mode, "redact": redact, "min_mb": args.min_mb, @@ -731,10 +969,13 @@ def build_report(args: argparse.Namespace) -> dict[str, Any]: }, "disk": disk_summary(home, redact), "target_areas": records, + "coverage": coverage, "top_children": children, "codex": codex, "git": git, "artifacts": artifacts, + "findings": findings, + "action_gate": action_gate(records, git, artifacts), "recommendations": recommendations(records, git, artifacts, codex), } @@ -772,10 +1013,21 @@ def markdown_table(headers: list[str], rows: list[list[str]]) -> str: return "" out = ["| " + " | ".join(headers) + " |", "| " + " | ".join(["---"] * len(headers)) + " |"] for row in rows: - out.append("| " + " | ".join(cell.replace("\n", " ") for cell in row) + " |") + out.append("| " + " | ".join(cell.replace("\n", " ").replace("|", "\\|") for cell in row) + " |") return "\n".join(out) +def display_measurement(row: dict[str, Any]) -> str: + status = row.get("measurement_status", "unknown") + if status == "timeout": + return "unknown (timeout)" + if status in {"error", "unknown"}: + return f"unknown ({status})" + if status == "missing": + return "not found" + return row.get("human_size", "unknown") + + def render_markdown(report: dict[str, Any]) -> str: lines: list[str] = [] lines.append("# Local File Audit") @@ -789,14 +1041,35 @@ def render_markdown(report: dict[str, Any]) -> str: lines.append("") lines.append(f"- Home: `{disk['path']}`") lines.append(f"- Disk: {disk['used']} used, {disk['free']} free, {disk['total']} total") + gate = report.get("action_gate", {}) + lines.append(f"- Decision state: `{gate.get('status', 'review_only')}`") + if gate.get("blockers"): + lines.append("- Exact cleanup decisions are blocked until the following evidence gaps are reviewed:") + for blocker in gate["blockers"]: + lines.append(f" - {blocker}") for rec in report["recommendations"]: lines.append(f"- {rec}") lines.append("") + if report.get("coverage"): + lines.append("## Coverage") + lines.append("") + lines.append("Coverage statuses distinguish measured data from timeout, error, and not-found states. Missing variants are not treated as zero bytes.") + rows = [] + for row in report["coverage"]: + rows.append([ + row["status"], + str(row["matches"]), + str(row["unknown"]), + human_size(row["measured_bytes"]), + row["label"], + ]) + lines.append(markdown_table(["Status", "Matches", "Unknown", "Measured", "Target"], rows)) + lines.append("") lines.append("## Largest Target Areas") rows = [] for row in report["target_areas"][:25]: - rows.append([row["human_size"], row["category"], row["risk"], f"`{row['path']}`", row["label"]]) - lines.append(markdown_table(["Size", "Category", "Risk", "Path", "Label"], rows)) + rows.append([display_measurement(row), row.get("measurement_status", "unknown"), row["category"], row["risk"], f"`{row['path']}`", row["label"]]) + lines.append(markdown_table(["Size", "Status", "Category", "Risk", "Path", "Label"], rows)) lines.append("") app_rows = [ row for row in report["target_areas"] @@ -806,8 +1079,8 @@ def render_markdown(report: dict[str, Any]) -> str: lines.append("## App And Cloud Sediment") lines.append("") lines.append("Treat these as app-managed or sync-managed data. Prefer in-app cleanup and do not bulk-delete containers.") - rows = [[row["human_size"], row["risk"], f"`{row['path']}`", row["label"]] for row in app_rows] - lines.append(markdown_table(["Size", "Risk", "Path", "Label"], rows)) + rows = [[display_measurement(row), row.get("measurement_status", "unknown"), row["risk"], f"`{row['path']}`", row["label"]] for row in app_rows] + lines.append(markdown_table(["Size", "Status", "Risk", "Path", "Label"], rows)) lines.append("") if report.get("codex"): codex = report["codex"] @@ -817,10 +1090,10 @@ def render_markdown(report: dict[str, Any]) -> str: lines.append(f"- Date directories: {codex['date_dir_count']}") lines.append(f"- `work` directories: {codex['work_dir_count']}") lines.append(f"- `outputs` directories: {codex['outputs_dir_count']}") - rows = [[row["human_size"], f"`{row['path']}`", row.get("modified_at") or ""] for row in codex["top_date_dirs"][:10]] + rows = [[display_measurement(row), row.get("measurement_status", "unknown"), f"`{row['path']}`", row.get("modified_at") or ""] for row in codex["top_date_dirs"][:10]] if rows: lines.append("") - lines.append(markdown_table(["Size", "Date Dir", "Modified"], rows)) + lines.append(markdown_table(["Size", "Status", "Date Dir", "Modified"], rows)) lines.append("") lines.append("## Workspace Children") lines.append("") @@ -834,8 +1107,8 @@ def render_markdown(report: dict[str, Any]) -> str: continue any_children = True lines.append(f"### `{root}`") - rows = [[row["human_size"], f"`{row['path']}`", row.get("modified_at") or ""] for row in rows_data[:15]] - lines.append(markdown_table(["Size", "Path", "Modified"], rows)) + rows = [[display_measurement(row), row.get("measurement_status", "unknown"), f"`{row['path']}`", row.get("modified_at") or ""] for row in rows_data[:15]] + lines.append(markdown_table(["Size", "Status", "Path", "Modified"], rows)) lines.append("") if not any_children: lines.append("No workspace children above the configured size threshold.") @@ -864,14 +1137,37 @@ def render_markdown(report: dict[str, Any]) -> str: if report["artifacts"]: lines.append("## Large Rebuildable Or Review Artifacts") lines.append("") + lines.append("Artifact sizes may already be included in their parent workspace totals. Treat them as candidate subsets, not additive reclaimable space.") + lines.append("") rows = [] for row in report["artifacts"][:30]: - rows.append([row["human_size"], row["risk"], row["name"], f"`{row['path']}`", row["rebuild_hint"]]) - lines.append(markdown_table(["Size", "Risk", "Name", "Path", "Rebuild"], rows)) + finding = next((item for item in report.get("findings", []) if item.get("path_redacted") == row.get("path")), {}) + rows.append([display_measurement(row), row.get("measurement_status", "unknown"), row["risk"], row["name"], f"`{row['path']}`", "yes" if finding.get("counted_in_total") else "unknown", row["rebuild_hint"]]) + lines.append(markdown_table(["Size", "Status", "Risk", "Name", "Path", "In Parent", "Rebuild"], rows)) + lines.append("") + if report.get("findings"): + lines.append("## Decision Ledger") + lines.append("") + lines.append("Each finding links back to evidence in the JSON report. This ledger is for review; the scanner never executes the recommended action.") + lines.append("") + rows = [] + for finding in report["findings"][:40]: + rows.append([ + finding["status"], + finding["confidence"], + finding["category"], + finding["owner"], + f"`{finding['path_redacted']}`", + finding["recommendation"], + ]) + lines.append(markdown_table(["Status", "Confidence", "Category", "Owner", "Path", "Recommended review"], rows)) lines.append("") lines.append("## Suggested Next Step") lines.append("") - lines.append("Create a promotion map before deleting anything: choose which outputs belong in stable folders, which workspaces should be archived, and which app-managed data should be cleaned inside the owning app.") + if gate.get("blockers"): + lines.append("Resolve the coverage and preservation blockers first. Do not turn this report into a cleanup plan until every exact target is measured and dirty work is preserved.") + else: + lines.append("Create a promotion map before deleting anything: choose which outputs belong in stable folders, which workspaces should be archived, and which app-managed data should be cleaned inside the owning app.") lines.append("") return "\n".join(lines) diff --git a/audit-local-files/scripts/compare_reports.py b/audit-local-files/scripts/compare_reports.py index 941e43c..cebebaf 100644 --- a/audit-local-files/scripts/compare_reports.py +++ b/audit-local-files/scripts/compare_reports.py @@ -48,10 +48,33 @@ def diff_rows(before_rows: list[dict[str, Any]], after_rows: list[dict[str, Any] new = after.get(path, {}) old_size = old.get("allocated_bytes") new_size = new.get("allocated_bytes") + old_status = old.get("measurement_status", "measured" if isinstance(old_size, int) else "unknown") + new_status = new.get("measurement_status", "measured" if isinstance(new_size, int) else "unknown") if path not in before and isinstance(new_size, int): old_size = 0 + old_status = "missing" if path not in after and isinstance(old_size, int): new_size = 0 + new_status = "missing" + incomplete_statuses = {"timeout", "error", "unknown"} + if old_status in incomplete_statuses or new_status in incomplete_statuses: + result.append( + { + "path": path, + "label": new.get("label") or old.get("label") or new.get("name") or old.get("name") or "", + "category": new.get("category") or old.get("category") or "", + "before_bytes": old_size if isinstance(old_size, int) else None, + "after_bytes": new_size if isinstance(new_size, int) else None, + "delta_bytes": None, + "before": human_size(old_size if isinstance(old_size, int) else None), + "after": human_size(new_size if isinstance(new_size, int) else None), + "delta": "unknown", + "status": "incomplete", + "measurement_status_before": old_status, + "measurement_status_after": new_status, + } + ) + continue if not isinstance(old_size, int) or not isinstance(new_size, int): continue delta = new_size - old_size @@ -69,9 +92,11 @@ def diff_rows(before_rows: list[dict[str, Any]], after_rows: list[dict[str, Any] "after": human_size(new_size), "delta": human_size(delta), "status": "new" if path not in before else "removed" if path not in after else "changed", + "measurement_status_before": old_status, + "measurement_status_after": new_status, } ) - return sorted(result, key=lambda row: abs(row["delta_bytes"]), reverse=True) + return sorted(result, key=lambda row: abs(row["delta_bytes"] or 0), reverse=True) def diff_buckets(before_rows: list[dict[str, Any]], after_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -92,6 +117,31 @@ def diff_buckets(before_rows: list[dict[str, Any]], after_rows: list[dict[str, A return sorted(result, key=lambda row: abs(row["dirty_change_delta"]) + abs(row["repo_delta"]), reverse=True) +def diff_coverage(before_rows: list[dict[str, Any]], after_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + before = {str(row.get("label")): row for row in before_rows if row.get("label")} + after = {str(row.get("label")): row for row in after_rows if row.get("label")} + result: list[dict[str, Any]] = [] + for label in sorted(set(before) | set(after)): + old = before.get(label, {}) + new = after.get(label, {}) + old_status = old.get("status", "not_found") + new_status = new.get("status", "not_found") + old_bytes = old.get("measured_bytes") + new_bytes = new.get("measured_bytes") + delta = new_bytes - old_bytes if isinstance(old_bytes, int) and isinstance(new_bytes, int) and old_status == new_status == "measured" else None + if old_status != new_status or delta not in (None, 0): + result.append( + { + "label": label, + "before_status": old_status, + "after_status": new_status, + "delta_bytes": delta, + "delta": human_size(delta), + } + ) + return result + + def compare(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]: before_disk = before.get("disk", {}) after_disk = after.get("disk", {}) @@ -110,7 +160,7 @@ def compare(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]: for key in ("date_dir_count", "work_dir_count", "outputs_dir_count") } return { - "schema_version": "1.0", + "schema_version": "1.1", "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"), "before_generated_at": before.get("generated_at"), "after_generated_at": after.get("generated_at"), @@ -122,12 +172,17 @@ def compare(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]: }, "disk": disk, "target_areas": diff_rows(before.get("target_areas", []), after.get("target_areas", [])), + "coverage": diff_coverage(before.get("coverage", []), after.get("coverage", [])), "codex": codex, "git_buckets": diff_buckets( (before.get("git") or {}).get("buckets", []), (after.get("git") or {}).get("buckets", []), ), "artifacts": diff_rows(before.get("artifacts", []), after.get("artifacts", [])), + "action_gate": { + "before": (before.get("action_gate") or {}).get("status", "unknown"), + "after": (after.get("action_gate") or {}).get("status", "unknown"), + }, } @@ -161,9 +216,19 @@ def render_markdown(result: dict[str, Any]) -> str: f"work `{codex['work_dir_count']:+d}`, " f"outputs `{codex['outputs_dir_count']:+d}`" ) + lines.append(f"- Action gate: `{result['action_gate']['before']}` -> `{result['action_gate']['after']}`") if result["redaction"]["origins_included"]: lines.append("- Privacy warning: one input report included Git origins; keep this comparison private.") lines.append("") + if result["coverage"]: + lines.append("## Coverage Changes") + lines.append("") + rows = [ + [row["before_status"], row["after_status"], row["delta"], row["label"]] + for row in result["coverage"][:20] + ] + lines.append(markdown_table(["Before", "After", "Measured change", "Target"], rows)) + lines.append("") lines.append("## Largest Target Changes") lines.append("") rows = [ diff --git a/audit-local-files/scripts/validate_report.py b/audit-local-files/scripts/validate_report.py new file mode 100644 index 0000000..2fede76 --- /dev/null +++ b/audit-local-files/scripts/validate_report.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Validate the Clean Your Data JSON report contract.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + + +MEASUREMENT_STATUSES = {"measured", "timeout", "error", "missing", "unknown"} +COVERAGE_STATUSES = MEASUREMENT_STATUSES | {"not_found"} +GATE_STATUSES = {"review_only", "approval_required"} +FINDING_REQUIRED_FIELDS = { + "finding_id", + "scope_id", + "path_redacted", + "category", + "owner", + "size_bytes", + "status", + "evidence_refs", + "confidence", + "risk", + "recommendation", + "approval_required", + "rollback_or_rebuild", +} + + +def validate(report: dict[str, Any]) -> list[str]: + errors: list[str] = [] + if report.get("schema_version") != "1.1": + errors.append("schema_version must be 1.1") + if report.get("read_only") is not True: + errors.append("read_only must be true") + settings = report.get("settings") or {} + if settings.get("scope_id") != "home": + errors.append("settings.scope_id must be home") + if settings.get("size_kind") != "allocated_bytes": + errors.append("settings.size_kind must be allocated_bytes") + + for index, row in enumerate(report.get("target_areas", [])): + status = row.get("measurement_status") + if status not in MEASUREMENT_STATUSES: + errors.append(f"target_areas[{index}] has invalid measurement_status") + if status != "measured" and row.get("allocated_bytes") is not None: + errors.append(f"target_areas[{index}] must use null size when not measured") + + for index, row in enumerate(report.get("artifacts", [])): + status = row.get("measurement_status") + if status not in MEASUREMENT_STATUSES: + errors.append(f"artifacts[{index}] has invalid measurement_status") + if status != "measured" and row.get("allocated_bytes") is not None: + errors.append(f"artifacts[{index}] must use null size when not measured") + + for index, row in enumerate(report.get("coverage", [])): + if row.get("status") not in COVERAGE_STATUSES: + errors.append(f"coverage[{index}] has invalid status") + + findings = report.get("findings") + if not isinstance(findings, list): + errors.append("findings must be a list") + else: + for index, finding in enumerate(findings): + missing = sorted(FINDING_REQUIRED_FIELDS - set(finding)) + if missing: + errors.append(f"findings[{index}] missing: {', '.join(missing)}") + if not isinstance(finding.get("evidence_refs"), list): + errors.append(f"findings[{index}].evidence_refs must be a list") + if finding.get("approval_required") is not True: + errors.append(f"findings[{index}].approval_required must be true") + + gate = report.get("action_gate") or {} + if gate.get("status") not in GATE_STATUSES: + errors.append("action_gate.status is invalid") + if gate.get("exact_cleanup_allowed") is not False: + errors.append("action_gate.exact_cleanup_allowed must be false") + if gate.get("scanner_mutates_files") is not False: + errors.append("action_gate.scanner_mutates_files must be false") + return errors + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate a Clean Your Data JSON report.") + parser.add_argument("report", type=Path) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + try: + report = json.loads(args.report.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"invalid report: {exc}", file=sys.stderr) + return 2 + errors = validate(report) + if errors: + for error in errors: + print(f"error: {error}", file=sys.stderr) + return 1 + print("report contract ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/examples/sample-report.md b/examples/sample-report.md index 40c0251..1174abb 100644 --- a/examples/sample-report.md +++ b/examples/sample-report.md @@ -7,16 +7,17 @@ This is synthetic data. It is not a report from a real machine. - Read-only: no files were changed. - Main pattern: the largest areas are an AI workspace, app-managed collaboration data, and rebuildable project artifacts. - Highest-risk area: a Git repository with uncommitted changes. +- Decision state: `review_only`; exact cleanup decisions are blocked until incomplete measurements and dirty work are reviewed. - First action: preserve project changes and promote durable outputs before considering cleanup. ## Findings -| Size | Classification | Owner | Interpretation | Recommended action | -| ---: | --- | --- | --- | --- | -| 8.4 GB | `workspace` | AI workflow | Date-based workspaces contain scratch work and selected outputs | Review outputs; promote durable results; archive old dates | -| 6.1 GB | `app-state` | Collaboration app | Local app data may include offline files, previews, and databases | Use the app's storage controls; do not delete the container | -| 3.7 GB | `cache` | Project toolchain | Dependency or build output may be regenerated | Confirm the rebuild command, then request approval | -| 420 MB | `workspace` | Git project | Repository has uncommitted changes | Commit, stash, or export before moving it | +| Size | Status | Confidence | Classification | Owner | Recommended action | +| ---: | --- | --- | --- | --- | --- | +| 8.4 GB | `measured` | `strong_inference` | `workspace` | AI workflow | Review outputs; promote durable results; archive old dates | +| 6.1 GB | `measured` | `strong_inference` | `app-state` | Collaboration app | Use the app's storage controls; do not delete the container | +| 3.7 GB | `measured` | `strong_inference` | `cache` | Project toolchain | Confirm the rebuild command, then request approval | +| unknown | `timeout` | `low` | `workspace` | Git project | Preserve or inspect before moving anything | ## Safe Next Actions diff --git a/tests/contract_test.py b/tests/contract_test.py new file mode 100644 index 0000000..126d6d2 --- /dev/null +++ b/tests/contract_test.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Deterministic contract checks for incomplete measurements.""" + +from __future__ import annotations + +import importlib.util +import sys +import tempfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "audit-local-files" / "scripts" / "audit_local_files.py" + + +def load_scanner(): + spec = importlib.util.spec_from_file_location("audit_local_files_contract", SCRIPT) + if spec is None or spec.loader is None: + raise RuntimeError("cannot load scanner") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def main() -> int: + scanner = load_scanner() + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) + (home / "Desktop").mkdir() + original_du_size = scanner.du_size + + def timed_out(_path: Path, _timeout: int): + return None, "simulated timeout", True + + scanner.du_size = timed_out + try: + records = scanner.scan_targets(home, 1, None, True) + finally: + scanner.du_size = original_du_size + + desktop = next(row for row in records if row["label"] == "Desktop") + assert desktop["allocated_bytes"] is None + assert desktop["measurement_status"] == "timeout" + coverage = scanner.target_coverage(records) + desktop_coverage = next(row for row in coverage if row["label"] == "Desktop") + assert desktop_coverage["status"] == "timeout" + findings = scanner.build_findings(records, {"buckets": [], "status_collected": True}, []) + assert findings[0]["status"] == "timeout" + gate = scanner.action_gate(records, {"buckets": [], "status_collected": True}, []) + assert gate["status"] == "review_only" + assert gate["exact_cleanup_allowed"] is False + print("contract test ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/smoke_test.py b/tests/smoke_test.py index 6d99e8d..527c966 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -13,6 +13,7 @@ ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / "audit-local-files" / "scripts" / "audit_local_files.py" COMPARE_SCRIPT = ROOT / "audit-local-files" / "scripts" / "compare_reports.py" +VALIDATE_SCRIPT = ROOT / "audit-local-files" / "scripts" / "validate_report.py" def main() -> int: @@ -50,7 +51,9 @@ def main() -> int: report = json.loads(result.stdout) raw = result.stdout assert report["read_only"] is True + assert report["schema_version"] == "1.1" assert report["settings"]["home"] == "~" + assert report["settings"]["scope_id"] == "home" assert report["disk"]["path"] == "~" assert str(home) not in raw assert "https://github.com/example/private-repo.git" not in raw @@ -58,15 +61,27 @@ def main() -> int: assert len(report["git"]["repos"]) == 1 assert all("origin" not in repo for repo in report["git"]["repos"]) assert any(item["name"] == "node_modules" for item in report["artifacts"]) + assert any(item["label"] == "Desktop" for item in report["coverage"]) + assert all("measurement_status" in item for item in report["target_areas"]) + assert report["findings"] + assert report["action_gate"]["status"] == "approval_required" + assert report["action_gate"]["exact_cleanup_allowed"] is False + artifact_finding = next( + item for item in report["findings"] + if item.get("path_redacted", "").endswith("node_modules") + ) + assert artifact_finding["counted_in_total"] is True before = home / "before.json" before.write_text(result.stdout, encoding="utf-8") + subprocess.run([sys.executable, str(VALIDATE_SCRIPT), str(before)], check=True) (home / "Desktop" / "growth.bin").write_bytes(b"x" * (2 * 1024 * 1024)) (home / "Downloads").mkdir() (home / "Downloads" / "new.bin").write_bytes(b"x" * (2 * 1024 * 1024)) after_result = subprocess.run(command, text=True, capture_output=True, check=True) after = home / "after.json" after.write_text(after_result.stdout, encoding="utf-8") + subprocess.run([sys.executable, str(VALIDATE_SCRIPT), str(after)], check=True) comparison = subprocess.run( [ sys.executable, @@ -81,6 +96,9 @@ def main() -> int: check=True, ) comparison_report = json.loads(comparison.stdout) + assert comparison_report["schema_version"] == "1.1" + assert "coverage" in comparison_report + assert comparison_report["action_gate"]["before"] == "approval_required" desktop_change = next( row for row in comparison_report["target_areas"] if row["path"] == "~/Desktop" )