Skip to content

feat(tools): generated-paths registry + typed JSON merge driver (Q4 — kills the contended-file class) - #711

Open
matt82198 wants to merge 5 commits into
mainfrom
feat/generated-paths-registry
Open

matt82198 wants to merge 5 commits into
mainfrom
feat/generated-paths-registry

Conversation

@matt82198

Copy link
Copy Markdown
Owner

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 .gitattributes for *-baseline.json as merge=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": [...]} + _comment shape of .stateapi-baseline.json. Key order and the file's trailing-newline habit are preserved so the output is byte-compatible with stateapi_lint's own json.dumps(indent=2) writer.

Fail-closed. Any parse failure, unsupported shape, mismatch between sides, or non-string member exits 1 with %A left 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 .gitattributes glob 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):

git config merge.aesop-json-union.name "union-and-sort JSON string lists"
git config merge.aesop-json-union.driver "python tools/json_list_merge.py %O %A %B"

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 .gitattributes as a comment, and on the tools/CLAUDE.md index 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 gate

A 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.

  • Seeded: 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.
  • API 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.md and state/ledger/sub/a.jsonl are correctly not registered).
  • CLI: --list [--json], --check [PATH...] (paths read from stdin when omitted). Exit 0 clean / 1 registered path touched / 2 usage.
  • Wired into hooks/pre-push-policy.sh as check_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 into git 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 logs generated_path_hand_edit and names the owning generator. Fail-open only for missing tool/python, matching every sibling check.
  • Escape hatch 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.py32 tests: union/dedup/sort, all four ancestor-deletion cases, the live .stateapi-baseline.json shape, byte-format preservation (indent, trailing newline, BOM tolerance, --stdout non-write, %L %P tolerance), 12 fail-closed cases each asserting %A is byte-unchanged, and two real end-to-end git merges: a registered driver resolving a genuine two-lane conflict with no UU in status, and an unregistered clone still conflicting.
  • tests/test_generated_paths.py33 tests: registry contract, matching rules, every CLI exit code, escape-hatch behavior, and a TestPrePushGate class that sources hooks/pre-push-policy.sh and drives check_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-mode input= rewrites \n to \r\n on Windows and the stray CR lands inside the parsed remote-sha, so git diff silently matches nothing and the gate would have passed vacuously.
  • bash hooks/pre-push-policy.sh --test21/21 (was 18; three new cases for the gate).
  • Suites: Python npm run test:py exit 0; Node 315/315 pass, 0 fail; shell 13/14 with the one failure test_reconstitute.sh reproducing clean on rerun (pre-existing flake, untouched by this branch); tests/test_pre_push_policy.sh 28/28.
  • Gates green: secret_scan --staged 0, import_resolution_check 0, claudemd_sync_gate --check 0, metrics_gate 0, verify_test_suite_count --check 0, verify_test_coverage --check 0, encoding_lint --check 0, claudemd_lint 0 findings (tools/CLAUDE.md held at its 149-line cap by condensing two entry pairs), sibling_import_check 0, dispatch_lint 0 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

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>
@matt82198 matt82198 added the merge-queue Queued for the merge-queue advancer daemon label Aug 3, 2026
matt82198 and others added 4 commits August 2, 2026 22:49
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-queue Queued for the merge-queue advancer daemon

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant