Repository navigation
--resolve must not report a merge it could not check - #4775
Conversation
Three ways `gen-manifests.py --resolve` printed "the merge can be committed" over a question it had not answered. All three are in the canonical, so all four byte-identical satellite copies carry them. Found by Copilot on MeshWeaver.Reinsurance#225 and MeshWeaver.SocialMedia#208, answered after those merged. 1. The CLOSING verification read `(git(...) or "").strip()`. `git()` answers None when it cannot run, so a git that could not answer became "no unmerged paths" and the run reported the merge ready — a verification step incapable of failing, over the one question --resolve exists to answer. `_unmerged_paths` now returns `list | None` and both ends of the function read it; the same swallow lived in `_loose_worktree_paths` (the safety refusal) and `_dirty_locks` (the #1023 staging fix), and both now propagate None and are refused by the caller rather than read as "nothing found". 2. Generated locks were classified by SUFFIX. `generate()` writes exactly `<package>/manifest.lock` for the packages the config declares, so `scripts/manifest.lock` and a nested `Mod/Nested/manifest.lock` read as generated: regeneration left them alone, `git add` staged their conflict markers, and the run reported success. Both ends now classify against `plugin_dirs(root)`. 3. The loose-path refusal asked git with `--exclude-standard`, which HIDES ignored untracked files, while `hash_files()` walks the filesystem and hashes them (only EXCLUDE_FILES are exempt). An ignored `Module/output.trx` therefore went into the regenerated lock and CI red on a lock hashing a file in nobody's commit. Rather than restate the hashing rules a second time, the enumeration is split out of the hashing (`_package_paths`, `_project_paths`, `_module_source_dirs`, `hashed_paths`) and the check reads the SAME enumerators. Ignored files outside that set — bin/obj/node_modules, the great majority — still do not block, or --resolve would be unusable. The split is enumeration-only: measured over the 24 packages of Reinsurance, Crm, SocialMedia and Manufacturing, plugin_dirs is identical and not one moduleVersion moves. Each fix has a self-test case, and each case was confirmed RED with that fix (and only that fix) deliberately reverted. `node-repo-validate.yml` runs this self-test for every satellite, so a regression reds where it happens. NOT changed, and answered on the threads instead: the pre-staged-index finding (`git merge` refuses to start against a dirty index at all — measured — so "staged" during a conflict means the merge put it there), and the `spec_from_file_location` finding (it never returns None for a `.py` path — measured; the existing `is_file()` guard is the check). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
The reviewed changes address the reported merge-resolution hazards with regression coverage.
Review effort: Lite
Findings: None
What changed in this PR
Hardens gen-manifests.py --resolve to fail safely when merge state cannot be verified.
Changes:
- Aligns conflict checks with generated and hashed paths.
- Propagates Git verification failures.
- Adds regression self-tests and documentation.
| File | Summary |
|---|---|
src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-19-a-merge-helper-that-reported-a-merge-nobody-had-checked.md |
Documents the user-visible fix. |
.github/scripts/gen-manifests.py |
Strengthens conflict resolution and adds regression coverage. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Test Results (shard 0)265 tests 265 ✅ 2m 45s ⏱️ Results for commit 15d5c38. ♻️ This comment has been updated with latest results. |
|
Two further findings from treating this set, both filed rather than folded in:
|
Test Results (shard 3)431 tests 431 ✅ 55s ⏱️ Results for commit 15d5c38. ♻️ This comment has been updated with latest results. |
This check exists here and nowhere else, and the doc did not say so. The list of what it does not see now names the other six repositories, with the measurement behind it. Measured 2026-09-19: no satellite — and NOT MeshWeaver.Plugins either, against the usual assumption — requires `Automatic review answered` or carries review-answered.yml. Every one of them does carry a "Copilot review for default branch" ruleset, so the review is REQUESTED everywhere and required to be ANSWERED only here. Over the last 20 merged pull requests of each of six repos: 120 merged, 72 carried findings, 55 merged with at least one unanswered one — and in all 55 the ratio is N of N, so this is not "occasionally missed", it is "structurally not read", at about twice the rate this repo measured before its own gate landed. The occasion was 13 findings left unanswered on five satellite PRs that merged on 2026-09-18; 8 were real and three were defects in this repo's own canonical gen-manifests.py, replicated into four repositories. Proposal and rollout order in #4776 (the order is load-bearing: an absent required context blocks every PR forever under classic protection, which five of the six use). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Caught re-deriving it against the per-finding verdicts rather than re-reading my own sentence. Only two of the thirteen could be declined — one whose premise git itself rules out (merge refuses to start against a dirty index), one whose failure branch is unreachable (spec_from_file_location never returns None for a .py path). The other eleven were real. Eleven findings, six distinct defects: three were raised twice, independently, on two repositories' copies of one file, and three were three sites of one root. Three of the six are in the platform's own canonical. The sentence now carries both numbers, because the de-duplication is what makes "one defect, five repositories" the point. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Test Results (shard 1)1 551 tests 1 551 ✅ 3m 9s ⏱️ Results for commit 15d5c38. |
Test Results (shard 2)710 tests 518 ✅ 5m 35s ⏱️ Results for commit 15d5c38. |
Test Results (shard 4) 3 files 3 suites 5m 35s ⏱️ Results for commit 15d5c38. |
Test Results (shard 5) 5 files 5 suites 11m 47s ⏱️ Results for commit 15d5c38. |
Test Results 17 files 17 suites 29m 48s ⏱️ Results for commit 15d5c38. |
Treats 13 Copilot review findings left unanswered on five satellite PRs merged on 2026-09-18 (Reinsurance#223/#225, Crm#130, Manufacturing#103, SocialMedia#208). Every one of the 13 now has a reply on its thread; this PR carries the half that belongs in core's canonical.
Why here. The findings on Reinsurance#225 and SocialMedia#208 are against
scripts/gen-manifests.py, which is a vendored copy of this repo's.github/scripts/gen-manifests.py. Measured md5 oforigin/main: core6cc07aa32f23d5770b24aeaf967e183b, and SocialMedia, Manufacturing, Reinsurance and Crm all identical. So each real finding is one defect replicated across five repositories, not a defect in one PR. Core lands first; the four re-copies follow.Three ways
--resolvereported a merge it had not checked1. A verification step incapable of failing. The closing "are any paths still unmerged?" read
(git(...) or "").strip().git()returnsNonewhen it cannot run, so a git that could not answer became an empty result and the run printed✓ … the merge can be committed._unmerged_pathsnow returnslist[str] | Noneand both ends of the function read it. The identical swallow was two lines away in_loose_worktree_paths(the safety refusal itself) and in_dirty_locks(the #1023 staging fix); both now propagateNoneand are refused by the caller, because replacing one swallow while leaving its neighbours is failing the review twice.2. A file with the right name is not a file we generate. Locks were classified by suffix (
p.endswith("/manifest.lock")).generate()writes exactly<package>/manifest.lockfor the packages the config declares, soscripts/manifest.lockand a nestedMod/Nested/manifest.lockwere classified as generated — regeneration left them untouched (neither is a package),git addstaged their conflict markers, and the closing check then found nothing unmerged and reported success. Both the refusal and_dirty_locksnow classify againstplugin_dirs(root).3. The refusal and the hashing enumerated different file sets.
_loose_worktree_pathsasked git with--exclude-standard, which deliberately hides ignored untracked files.hash_files()asks nobody — it walks the filesystem, and onlymanifest.lock/.DS_Storeare exempt. So an ignoredModule/output.trxwas hashed into the lock--resolvestaged and was invisible to the check meant to prevent exactly that; CI then reds on a lock describing a file that is in no commit. Rather than restate the hashing rules a second time (which is how they drifted apart), the enumeration is split out of the hashing —_package_paths,_project_paths,_module_source_dirs,hashed_paths— and the check reads the same enumerators. Ignored files outside that set (bin/,obj/,node_modules/— the great majority) still do not block, or the refusal would make--resolveunusable.The split moves no version
hash_fileswas refactored, so the load-bearing claim is that it still hashes the same bytes. Old canonical and new, loaded side by side against the four satellites' realmaintrees:Each fix proved able to fail
Three new self-test cases (2c, 2d, 2e), and each was re-run with that fix and only that fix deliberately reverted in a scratch copy:
an unmerged scripts/manifest.lock is NOT a generated lock … must be refused with exit 1, got 0·scripts/manifest.lock stopped being unmerged — --resolve staged a conflict the generator does not own, markers and all(and the same pair forMod/Nested/manifest.lock)an IGNORED untracked file that hash_files() hashes must be refused, got 0None--resolve reported the merge committable while git could not say whether any path was still unmergedUnreverted: exit 0. Each fixture also asserts it reproduces its own hazard before asserting the fix (the non-package lock really carries
<<<<<<<;Mod/output.trxreally is invisible togit statusand really is inhashed_paths), so none of the three can pass vacuously. Case 2d additionally carries a negative control — an ignoredscripts/scratch.trx, whichgenerate()never hashes, must NOT block — so the fix cannot degenerate into "refuse every ignored file".node-repo-validate.ymlruns--self-testfor every satellite, so a regression reds in the repo where it happens.Two findings are declined, with the measurement
Answered on their threads, not silently:
4050708444). Measured 2026-09-19:git mergerefuses to start against a dirty index at all — both a staged edit and a staged new file give "Your local changes to the following files would be overwritten by merge", exit 2, no merge. So a pre-staged edit cannot be present when--resolveruns; the remaining route is a deliberategit addof something unrelated during the conflict, which is the documented design (self-test case 2b asserts a staged file must not block) and not implementable as proposed — during a merge every merged path is staged, so "verify staged paths came from the merge" has nothing to compare against. The measurement is now recorded on case 2b.spec_from_file_locationmay return None (4050590430, Manufacturing#103). Measured: for a path ending.pyit never returnsNone— not for a missing file, not for an unreadable one (both return aModuleSpeccarrying aSourceFileLoader); it returnsNoneonly for an unrecognised suffix, and this path is alwaysproject-closure.py. The existingpath.is_file()guard already runs first. A broken or unreadable file raises fromexec_modulewith a traceback naming the file and line, which is more informative than theSystemExitthe finding asks for.Filed rather than folded in
#4774 — the dot-directory enumerator divergence (
4050708495/4050491291). Real:plugin_dirsskips.-directories,validate-repos.pydoes not, andcheck-skip-sets.pycompares only the two declared skip SETS, so the divergence is invisible to the guard that exists to catch exactly this. Not fixable here:validate-repos.pyandcheck-skip-sets.pyare satellite-local with no canonical, and their enumerators differ per repo (Crm and Plugins carry fourroot.iterdir()sites; Education enumerates throughis_course_dir).Verification
The What's New entry's first draft failed
WhatsNewEntryIntegrityTeston a:inside a plain YAML scalar — recorded because it is the guard demonstrating it can fail on this diff, not merely that it passes.🤖 Generated with Claude Code
Also in this PR: what a core-only review gate costs the fleet
Doc/Architecture/ReviewFindingsAnsweredsaid nothing about the fact that this check exists here and nowhere else. Its "What it does not see" list now names the other six repositories, with the measurement (2026-09-19):Automatic review answeredor carriesreview-answered.yml. All of them carry aCopilot review for default branchruleset, so the review is requested everywhere and required to be answered only here;Proposal in #4776.