From bcbbbe016ff47697b5c494f90aca2eb9262f379c Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 16 Jul 2026 21:43:11 -0700 Subject: [PATCH 1/5] audit.py: blob-filter the branch-drift compare against cherry-picks The three-dot develop...main compare lists files changed on main since the merge-base and is blind to cherry-picked promotions - develop may already hold identical content under different commit SHAs (promote/* branches), re-triggering a false forward-sync DRIFT on every audit until a merge promotion realigns the merge-base (HomeAutomation-Config #21 task 4 was worked against exactly this false positive). Post-filter the compare's files by blob equality at the two heads (one recursive trees call per head): content develop already has is not "content develop lacks". Only the remainder raises the DRIFT finding, which now names the lacking files (up to 8) so a residual false positive is cheap to spot downstream. If either tree is truncated the filter is skipped and the unfiltered finding kept, marked as such (conservative). Verified live: HomeAutomation-Config no longer reports the finding (cherry-picked cspell-scope files are blob-identical at both heads; repo audits clean). Genuine positives still fire with named files: homeassistant-purpleair (requirements-test.txt, requirements.txt) and DevKitCIoT (12 files). Fixes #336. Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/audit.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/spec/audit.py b/spec/audit.py index be05929a..e9ff0c37 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -89,7 +89,23 @@ def audit_repo(entry, spec): if branch_main["commit"]["commit"]["tree"]["sha"] != branch_dev["commit"]["commit"]["tree"]["sha"]: cmp = gh(f"repos/{slug}/compare/develop...main", ok404=True) if cmp and cmp.get("files"): - findings.append(("DRIFT", f"branch: main carries {len(cmp['files'])}+ changed file(s) develop lacks (forward-sync needed)")) + # The three-dot compare lists files changed on main since the merge-base and is blind + # to cherry-picked promotions: develop may already hold identical content under + # different commit SHAs (patch-equivalent commits, e.g. promote/* branches). Post- + # filter by blob equality at the two heads - content develop already has is not + # "content develop lacks" (#336). One recursive trees call per head; if either tree + # is truncated the filter is skipped and the unfiltered finding kept (conservative). + dev_tree = gh(f"repos/{slug}/git/trees/{branch_dev['commit']['commit']['tree']['sha']}?recursive=1") + main_tree = gh(f"repos/{slug}/git/trees/{branch_main['commit']['commit']['tree']['sha']}?recursive=1") + if dev_tree.get("truncated") or main_tree.get("truncated"): + findings.append(("DRIFT", f"branch: main carries {len(cmp['files'])}+ changed file(s) develop lacks (forward-sync needed; tree too large to blob-filter cherry-pick noise)")) + else: + dev_blobs = {e["path"]: e["sha"] for e in dev_tree["tree"] if e["type"] == "blob"} + main_blobs = {e["path"]: e["sha"] for e in main_tree["tree"] if e["type"] == "blob"} + lacking = sorted(f["filename"] for f in cmp["files"] if main_blobs.get(f["filename"]) != dev_blobs.get(f["filename"])) + if lacking: + shown = ", ".join(lacking[:8]) + (" ..." if len(lacking) > 8 else "") + findings.append(("DRIFT", f"branch: main carries {len(lacking)} file(s) develop lacks (forward-sync needed): {shown}")) # --- General settings --- expected = dict(spec["settings"]) From a49117a42a7aa6e37a678fef5d61e7ab90602cf5 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 16 Jul 2026 21:48:19 -0700 Subject: [PATCH 2/5] Derive main-side changes from the merge-base tree, not the capped compare (Copilot #337) compare files[] caps at 300, so blob-filtering only the listed files could return an empty remainder while unlisted files carry real drift - silent suppression. The main-side change set is now computed from the merge-base tree (base->main blob differences, additions and deletions included, uncapped; the compare supplies only the merge-base sha), then blob-filtered against develop as before. Re-verified: HomeAutomation- Config clean, homeassistant-purpleair (2 files) and DevKitCIoT (12 files) unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/audit.py | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/spec/audit.py b/spec/audit.py index e9ff0c37..04d91055 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -88,21 +88,27 @@ def audit_repo(entry, spec): # main carries content develop lacks (forward-sync needed); develop merely ahead is normal. if branch_main["commit"]["commit"]["tree"]["sha"] != branch_dev["commit"]["commit"]["tree"]["sha"]: cmp = gh(f"repos/{slug}/compare/develop...main", ok404=True) - if cmp and cmp.get("files"): - # The three-dot compare lists files changed on main since the merge-base and is blind - # to cherry-picked promotions: develop may already hold identical content under - # different commit SHAs (patch-equivalent commits, e.g. promote/* branches). Post- - # filter by blob equality at the two heads - content develop already has is not - # "content develop lacks" (#336). One recursive trees call per head; if either tree - # is truncated the filter is skipped and the unfiltered finding kept (conservative). - dev_tree = gh(f"repos/{slug}/git/trees/{branch_dev['commit']['commit']['tree']['sha']}?recursive=1") - main_tree = gh(f"repos/{slug}/git/trees/{branch_main['commit']['commit']['tree']['sha']}?recursive=1") - if dev_tree.get("truncated") or main_tree.get("truncated"): - findings.append(("DRIFT", f"branch: main carries {len(cmp['files'])}+ changed file(s) develop lacks (forward-sync needed; tree too large to blob-filter cherry-pick noise)")) + if cmp: + # The three-dot compare's files[] is blind to cherry-picked promotions (develop may + # already hold identical content under different commit SHAs, e.g. promote/* branches) + # AND capped at 300 entries - so neither raw files[] nor a filter over it is reliable + # (#336). Instead, derive the main-side change set from the merge-base tree (paths + # whose blob differs base->main, additions and deletions included - no cap), then drop + # paths whose blobs already match at develop: content develop already has is not + # "content develop lacks". Three recursive trees calls; if any tree is truncated the + # filter is skipped and the compare's unfiltered count kept (conservative, marked). + trees = { + "base": gh(f"repos/{slug}/git/trees/{cmp['merge_base_commit']['commit']['tree']['sha']}?recursive=1"), + "develop": gh(f"repos/{slug}/git/trees/{branch_dev['commit']['commit']['tree']['sha']}?recursive=1"), + "main": gh(f"repos/{slug}/git/trees/{branch_main['commit']['commit']['tree']['sha']}?recursive=1"), + } + if any(t.get("truncated") for t in trees.values()): + if cmp.get("files"): + findings.append(("DRIFT", f"branch: main carries {len(cmp['files'])}+ changed file(s) develop lacks (forward-sync needed; tree too large to blob-filter cherry-pick noise)")) else: - dev_blobs = {e["path"]: e["sha"] for e in dev_tree["tree"] if e["type"] == "blob"} - main_blobs = {e["path"]: e["sha"] for e in main_tree["tree"] if e["type"] == "blob"} - lacking = sorted(f["filename"] for f in cmp["files"] if main_blobs.get(f["filename"]) != dev_blobs.get(f["filename"])) + blobs = {name: {e["path"]: e["sha"] for e in t["tree"] if e["type"] == "blob"} for name, t in trees.items()} + changed_on_main = {p for p in set(blobs["base"]) | set(blobs["main"]) if blobs["base"].get(p) != blobs["main"].get(p)} + lacking = sorted(p for p in changed_on_main if blobs["main"].get(p) != blobs["develop"].get(p)) if lacking: shown = ", ".join(lacking[:8]) + (" ..." if len(lacking) > 8 else "") findings.append(("DRIFT", f"branch: main carries {len(lacking)} file(s) develop lacks (forward-sync needed): {shown}")) From 8c800d45ffee67ff7301979e248865be0fcd8999 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 16 Jul 2026 21:51:17 -0700 Subject: [PATCH 3/5] Finding wording: 'main-side path change(s)', not 'carries file(s)' (Copilot #337) Deletions and renames on main are drift the check flags, but main does not 'carry' those paths - the message now matches the logic (base->main blob differences develop lacks). Docstring aligned. Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/audit.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/audit.py b/spec/audit.py index 04d91055..3d5e2830 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -9,8 +9,8 @@ fleet-wide changes. Read-only - it never modifies a target. Findings: DEFECT (an applicable check fails outright), LETTER (a required file is absent - intent -unverified, judge per AUDIT.md section 7), DRIFT (non-breaking divergence, e.g. main carrying -content develop lacks, a stale secret, a registry field contradicting reality), ERROR (a gh call +unverified, judge per AUDIT.md section 7), DRIFT (non-breaking divergence, e.g. main-side +changes develop lacks, a stale secret, a registry field contradicting reality), ERROR (a gh call failed, so the repo could not be fully audited). Exits non-zero when any repo has a DEFECT, LETTER, or ERROR finding. @@ -104,14 +104,14 @@ def audit_repo(entry, spec): } if any(t.get("truncated") for t in trees.values()): if cmp.get("files"): - findings.append(("DRIFT", f"branch: main carries {len(cmp['files'])}+ changed file(s) develop lacks (forward-sync needed; tree too large to blob-filter cherry-pick noise)")) + findings.append(("DRIFT", f"branch: {len(cmp['files'])}+ main-side path change(s) develop lacks (forward-sync needed; tree too large to blob-filter cherry-pick noise)")) else: blobs = {name: {e["path"]: e["sha"] for e in t["tree"] if e["type"] == "blob"} for name, t in trees.items()} changed_on_main = {p for p in set(blobs["base"]) | set(blobs["main"]) if blobs["base"].get(p) != blobs["main"].get(p)} lacking = sorted(p for p in changed_on_main if blobs["main"].get(p) != blobs["develop"].get(p)) if lacking: shown = ", ".join(lacking[:8]) + (" ..." if len(lacking) > 8 else "") - findings.append(("DRIFT", f"branch: main carries {len(lacking)} file(s) develop lacks (forward-sync needed): {shown}")) + findings.append(("DRIFT", f"branch: {len(lacking)} main-side path change(s) develop lacks (forward-sync needed): {shown}")) # --- General settings --- expected = dict(spec["settings"]) From e2087a36bd424d33326119d584ba5ad25d2b8442 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 16 Jul 2026 21:55:56 -0700 Subject: [PATCH 4/5] Gate the tree fetches on non-empty compare files; fix stale rationale (Copilot #337) Empty compare files[] means develop is merely ahead (no main-side changes since the merge-base) - the common case now short-circuits with no tree fetches, and the truncated-tree fallback always has a non-empty files[] count to report (the dead inner guard is gone). The lead-in comment no longer describes the superseded files[]-as-signal rationale. Re-verified: HomeAutomation-Config clean, homeassistant-purpleair (2) and DevKitCIoT (12) unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/audit.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/spec/audit.py b/spec/audit.py index 3d5e2830..6fd7e4d9 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -84,27 +84,27 @@ def audit_repo(entry, spec): findings.append(("DRIFT", f"registry: hasDevelop={entry.get('hasDevelop')} but develop {'exists' if dev_exists else 'is absent'}")) if main_exists and dev_exists: # Commit counts mislead here: merge-commit promotions leave main permanently "ahead" while the - # trees are identical. Content is the signal - a develop...main compare with changed files means - # main carries content develop lacks (forward-sync needed); develop merely ahead is normal. + # head trees are identical, so tree equality is the no-drift fast path. When the head trees + # differ, empty compare files[] means develop is merely ahead (no main-side changes since the + # merge-base) - normal, no finding, no further API calls. if branch_main["commit"]["commit"]["tree"]["sha"] != branch_dev["commit"]["commit"]["tree"]["sha"]: cmp = gh(f"repos/{slug}/compare/develop...main", ok404=True) - if cmp: - # The three-dot compare's files[] is blind to cherry-picked promotions (develop may - # already hold identical content under different commit SHAs, e.g. promote/* branches) - # AND capped at 300 entries - so neither raw files[] nor a filter over it is reliable - # (#336). Instead, derive the main-side change set from the merge-base tree (paths - # whose blob differs base->main, additions and deletions included - no cap), then drop - # paths whose blobs already match at develop: content develop already has is not - # "content develop lacks". Three recursive trees calls; if any tree is truncated the - # filter is skipped and the compare's unfiltered count kept (conservative, marked). + if cmp and cmp.get("files"): + # Non-empty files[] signals main-side changes, but is not usable directly: it is blind + # to cherry-picked promotions (develop may already hold identical content under + # different commit SHAs, e.g. promote/* branches) AND capped at 300 entries (#336). + # Instead, derive the main-side change set from the merge-base tree (paths whose blob + # differs base->main, additions and deletions included - no cap), then drop paths + # whose blobs already match at develop: content develop already has is not "content + # develop lacks". Three recursive trees calls; if any tree is truncated the filter is + # skipped and the compare's unfiltered count kept (conservative, marked). trees = { "base": gh(f"repos/{slug}/git/trees/{cmp['merge_base_commit']['commit']['tree']['sha']}?recursive=1"), "develop": gh(f"repos/{slug}/git/trees/{branch_dev['commit']['commit']['tree']['sha']}?recursive=1"), "main": gh(f"repos/{slug}/git/trees/{branch_main['commit']['commit']['tree']['sha']}?recursive=1"), } if any(t.get("truncated") for t in trees.values()): - if cmp.get("files"): - findings.append(("DRIFT", f"branch: {len(cmp['files'])}+ main-side path change(s) develop lacks (forward-sync needed; tree too large to blob-filter cherry-pick noise)")) + findings.append(("DRIFT", f"branch: {len(cmp['files'])}+ main-side path change(s) develop lacks (forward-sync needed; tree too large to blob-filter cherry-pick noise)")) else: blobs = {name: {e["path"]: e["sha"] for e in t["tree"] if e["type"] == "blob"} for name, t in trees.items()} changed_on_main = {p for p in set(blobs["base"]) | set(blobs["main"]) if blobs["base"].get(p) != blobs["main"].get(p)} From 2c216183ce5c9b33795fede8228a8ede79a611b4 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 16 Jul 2026 21:59:40 -0700 Subject: [PATCH 5/5] Round-4 hardening: None-safe trees, submodule pointers, object-SHA wording (Copilot #337) A non-dict trees response now degrades to the unfiltered fallback instead of raising; the path maps include submodule pointer entries (type commit) so main-side submodule bumps are not invisible; comments say object SHA (blob or submodule pointer) rather than blob. All three live cases re-verified unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/audit.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/spec/audit.py b/spec/audit.py index 6fd7e4d9..03298047 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -93,22 +93,23 @@ def audit_repo(entry, spec): # Non-empty files[] signals main-side changes, but is not usable directly: it is blind # to cherry-picked promotions (develop may already hold identical content under # different commit SHAs, e.g. promote/* branches) AND capped at 300 entries (#336). - # Instead, derive the main-side change set from the merge-base tree (paths whose blob - # differs base->main, additions and deletions included - no cap), then drop paths - # whose blobs already match at develop: content develop already has is not "content - # develop lacks". Three recursive trees calls; if any tree is truncated the filter is - # skipped and the compare's unfiltered count kept (conservative, marked). + # Instead, derive the main-side change set from the merge-base tree - paths whose + # object SHA (blob, or submodule pointer) differs base->main, additions and deletions + # included, no cap - then drop paths whose objects already match at develop: content + # develop already has is not "content develop lacks". Three recursive trees calls; if + # any tree is truncated (or unexpectedly not a dict) the filter is skipped and the + # compare's unfiltered count kept (conservative, marked). trees = { "base": gh(f"repos/{slug}/git/trees/{cmp['merge_base_commit']['commit']['tree']['sha']}?recursive=1"), "develop": gh(f"repos/{slug}/git/trees/{branch_dev['commit']['commit']['tree']['sha']}?recursive=1"), "main": gh(f"repos/{slug}/git/trees/{branch_main['commit']['commit']['tree']['sha']}?recursive=1"), } - if any(t.get("truncated") for t in trees.values()): - findings.append(("DRIFT", f"branch: {len(cmp['files'])}+ main-side path change(s) develop lacks (forward-sync needed; tree too large to blob-filter cherry-pick noise)")) + if not all(isinstance(t, dict) for t in trees.values()) or any(t.get("truncated") for t in trees.values()): + findings.append(("DRIFT", f"branch: {len(cmp['files'])}+ main-side path change(s) develop lacks (forward-sync needed; tree unavailable or too large to filter cherry-pick noise)")) else: - blobs = {name: {e["path"]: e["sha"] for e in t["tree"] if e["type"] == "blob"} for name, t in trees.items()} - changed_on_main = {p for p in set(blobs["base"]) | set(blobs["main"]) if blobs["base"].get(p) != blobs["main"].get(p)} - lacking = sorted(p for p in changed_on_main if blobs["main"].get(p) != blobs["develop"].get(p)) + objs = {name: {e["path"]: e["sha"] for e in t["tree"] if e["type"] in ("blob", "commit")} for name, t in trees.items()} + changed_on_main = {p for p in set(objs["base"]) | set(objs["main"]) if objs["base"].get(p) != objs["main"].get(p)} + lacking = sorted(p for p in changed_on_main if objs["main"].get(p) != objs["develop"].get(p)) if lacking: shown = ", ".join(lacking[:8]) + (" ..." if len(lacking) > 8 else "") findings.append(("DRIFT", f"branch: {len(lacking)} main-side path change(s) develop lacks (forward-sync needed): {shown}"))