Conversation
Kills the contended-file conflict class at its two roots.
PART 1 -- tools/json_list_merge.py: a typed JSON list-union merge driver,
callable as a CLI and as a git merge driver on the same positional signature
(%O %A %B), registered in .gitattributes for *-baseline.json ratchets as
merge=aesop-json-union. Result = sorted(set(ours) | set(theirs)), which IS the
ancestor-aware deletion rule: an entry dropped by BOTH sides is absent from the
union and stays deleted; dropped by ONE side it survives and stays kept.
Supports a bare string array and the real {"violations": [...]} + _comment
baseline shape. Fail-closed: any parse failure, unsupported shape, side
mismatch, or non-string member exits 1 with %A untouched, so git falls back to
a normal conflict. Count-map baselines are deliberately unsupported -- union is
unsound for counts. Driver registration is one-time per clone (git never reads
driver definitions out of a repo); documented in docs/INSTALL.md and the
tools/CLAUDE.md index line.
PART 2 -- tools/generated_paths.py: the declared registry of machine-generated
repo paths plus is_generated(path) and a --list/--check CLI, wired into
hooks/pre-push-policy.sh as check_generated_paths(). A push whose diff touches
a registered path is rejected with a message naming the generator. Matching is
lexical and segment-wise, so * never crosses a directory separator and entries
can be declared before their generator exists. AESOP_ALLOW_GENERATED=1 is the
designed writer path for generator/merge-train/daemon regeneration pushes --
exactly "1", nothing else opens it.
Tests: tests/test_json_list_merge.py (32) covers union/dedup/sort, all four
ancestor-deletion cases, the live .stateapi-baseline.json shape, byte-format
preservation, 12 fail-closed cases, and a real two-lane git merge resolved by
the registered driver (plus an unregistered clone still conflicting).
tests/test_generated_paths.py (33) covers the registry contract, matching
rules, every CLI exit code, the escape hatch, and drives the pre-push function
itself against fixture git repos. hooks/pre-push-policy.sh --test grows to 21.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…registry # Conflicts: # tests/CLAUDE.md # tools/CLAUDE.md
…registry # Conflicts: # tools/CLAUDE.md # tools/generated_paths.py
…registry # Conflicts: # tests/CLAUDE.md # tests/test_generated_paths.py # tools/INDEX.md # tools/generated_paths.py
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Debottleneck lane Q4. Two mechanisms that together remove the contended-file conflict class: files that two lanes both append to now merge instead of conflicting, and files that a generator owns can no longer be hand-edited into a conflict at all.
Part 1 —
tools/json_list_merge.py(typed JSON list-union merge driver)One tool, two call shapes on the same positional signature (
ANCESTOR OURS THEIRS= git's%O %A %B); the result is written back into%A. Registered in.gitattributesfor*-baseline.jsonasmerge=aesop-json-union.Semantics.
result = sorted(set(ours) | set(theirs)). That single expression is the ancestor-aware deletion rule the spec asks for: an entry present in the ancestor but dropped by both sides is absent from the union and stays deleted; dropped by one side it survives the union and stays kept. The ancestor is still parsed and shape-checked (a corrupt ancestor means the file is not what the driver thinks it is) but contributes no members. All four cases are covered by tests, not by assertion.Shapes. A bare top-level string array, or a top-level object holding exactly one string array plus scalar keys — the real
{"violations": [...]}+_commentshape of.stateapi-baseline.json. Key order and the file's trailing-newline habit are preserved so the output is byte-compatible withstateapi_lint's ownjson.dumps(indent=2)writer.Fail-closed. Any parse failure, unsupported shape, mismatch between sides, or non-string member exits 1 with
%Aleft untouched, so git falls back to a conventional conflict. The driver never writes invalid JSON. The count-map baselines (.portability-baseline.json,.subprocess-guard-baseline.json) are matched by the.gitattributesglob but deliberately refused by shape — union is not a sound merge for counts, so they keep today's behavior, and a future list-shaped baseline gets the driver for free.One-time per-clone registration (git never reads driver definitions out of a repository, since they execute code):
Documented in
docs/INSTALL.md(new "Register the JSON list-union merge driver" section next to the pre-push hook install — the repo's only per-clone setup surface; there is no setup script to extend), in.gitattributesas a comment, and on thetools/CLAUDE.mdindex line. Skipping registration is safe: an unregistered clone just gets today's ordinary conflict, proven by a test.Part 2 —
tools/generated_paths.py(generated-path registry) + pre-push gateA generated file has exactly one legitimate writer. A hand edit is silently reverted on the next regeneration and collides with every concurrent lane that regenerates it. The registry declares those paths and the gate keeps everyone else out.
state/ledger/*.jsonl(append-only ledgers),tools/INDEX.md(A2's generated tool index),tests/SUITE-COUNTS.md(A1's suite-count marker file). The last two are declared before their generators land — matching is purely lexical, so a registered path need not exist on disk.is_generated(path)returns the owning entry. Matching is segment-wise fnmatch on a POSIX-normalized path, so*never crosses a/and a pattern is never a bare suffix match (docs/INDEX.mdandstate/ledger/sub/a.jsonlare correctly not registered).--list [--json],--check [PATH...](paths read from stdin when omitted). Exit0clean /1registered path touched /2usage.hooks/pre-push-policy.shascheck_generated_paths()— extends the existing policy chain as check fix(ci): remove empty NODE_AUTH_TOKEN blocking OIDC trusted publishing #10, no new hook chain. It turns each pushed ref tuple intogit diff --name-only <remote-sha>..<local-sha>and pipes the union to the registry over stdin, not argv, so a large diff cannot blow the command-line length limit. Rejection logsgenerated_path_hand_editand names the owning generator. Fail-open only for missing tool/python, matching every sibling check.AESOP_ALLOW_GENERATED=1. Not a gate weakening — it is the designed writer path for generators, the merge train's regeneration step, and daemon/orchestrator regeneration pushes. Exactly"1"opens it;0/true/yes/empty do not (tested).Evidence
tests/test_json_list_merge.py— 32 tests: union/dedup/sort, all four ancestor-deletion cases, the live.stateapi-baseline.jsonshape, byte-format preservation (indent, trailing newline, BOM tolerance,--stdoutnon-write,%L %Ptolerance), 12 fail-closed cases each asserting%Ais byte-unchanged, and two real end-to-end git merges: a registered driver resolving a genuine two-lane conflict with noUUin status, and an unregistered clone still conflicting.tests/test_generated_paths.py— 33 tests: registry contract, matching rules, every CLI exit code, escape-hatch behavior, and aTestPrePushGateclass that sourceshooks/pre-push-policy.shand drivescheck_generated_paths()directly against fixture git repos (rejects a registered path, names the generator, passes an ordinary path, passes a similarly-named authored path, honors the escape hatch, handles empty/delete-only stdin, fails open on missing tool). Fixture stdin is fed from a binary file — Python's text-modeinput=rewrites\nto\r\non Windows and the stray CR lands inside the parsed remote-sha, sogit diffsilently matches nothing and the gate would have passed vacuously.bash hooks/pre-push-policy.sh --test— 21/21 (was 18; three new cases for the gate).npm run test:pyexit 0; Node 315/315 pass, 0 fail; shell 13/14 with the one failuretest_reconstitute.shreproducing clean on rerun (pre-existing flake, untouched by this branch);tests/test_pre_push_policy.sh28/28.secret_scan --staged0,import_resolution_check0,claudemd_sync_gate --check0,metrics_gate0,verify_test_suite_count --check0,verify_test_coverage --check0,encoding_lint --check0,claudemd_lint0 findings (tools/CLAUDE.mdheld at its 149-line cap by condensing two entry pairs),sibling_import_check0,dispatch_lint0 on every touched file. The live push ran the full pre-push chain including the new gate.Docs updated in the same PR:
tools/CLAUDE.md(both tools + the driver config command),hooks/CLAUDE.md(check #10 + test count),tests/CLAUDE.md(Python 227 -> 229),docs/INSTALL.md.🤖 Generated with Claude Code