diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..62aa802 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-D warnings" + +jobs: + fmt: + name: Rustfmt Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: Check Formatting + run: cargo fmt --all -- --check + + clippy: + name: Clippy Lint Check (Zero Warnings) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - name: Run Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + test: + name: Unit & Integration Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Run Tests + run: cargo test --all-targets --all-features --verbose + + build: + name: Release Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Build Release Binary + run: cargo build --release --verbose diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..281953e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target +**/*.rs.bk +*.cargo/config +*.env +!fixtures/repo/head/secrets/.env +*.DS_Store diff --git a/CD_res/implementation/oot/oot-research.md b/CD_res/implementation/oot/oot-research.md new file mode 100644 index 0000000..5c71081 --- /dev/null +++ b/CD_res/implementation/oot/oot-research.md @@ -0,0 +1,38 @@ +# Implementation Research: Oot semantic-diff engine (wedge) + +## The Task +Build the Oot wedge: a Rust CLI that runs on `git merge`, performs a structural + semantic diff between branches, emits a "dispute statement", and (per policy) blocks the merge and opens a docket. Status today: concept only — no code exists despite README marking the engine `[x]`. + +## 1. Common Gotchas +- **"Semantic" is underspecified.** True meaning-level diff is an open research problem. Confusing "structural AST diff" with "semantic diff" will set impossible v1 expectations. — (UW ASE'24 eval shows even research tools produce more incorrect merges than git.) +- **Three-way merge needs base/ours/theirs.** A diff tool that only compares two sides misses the common ancestor and mis-flags rebase-introduced churn. Gate on `git merge-base`. +- **Language coverage trap.** Parser-based tools (IntelliMerge/Spork) are Java-only; SemanticMerge covers a handful. tree-sitter gives broad *structural* coverage cheaply but no *semantic* depth. + +## 2. Best Practices +- **tree-sitter for structural diff, Rust.** tree-sitter has Rust bindings; gives language-agnostic AST diff across dozens of languages — the right local engine substrate. (Confirmed ecosystem: `tree-sitter`, `tree-sitter-cli` crates.) +- **Separate the two hard problems.** (a) Local, fast, deterministic *structural* conflict detection (Rust + tree-sitter). (b) Hosted, slower *semantic/intent* suspicion scoring (LLM API — the paid layer). Don't try to do (b) locally in v1. +- **Policy-as-config.** Blocking thresholds belong in a config file (TOML), not code, so the "docket" gate is tunable per repo. + +## 3. Pitfalls & Language Quirks +- **tree-sitter node identity is positional**, not semantic — moved/renamed functions need similarity heuristics, not exact matching (this is exactly what IntelliMerge's refactoring-alignment solves for Java). v1 should accept false positives here. +- **LLM "semantic" checks are non-deterministic** — must be treated as *advisory flags* feeding the docket, never as an auto-block decision, or you'll ship flaky merge gates. +- **Git hook failure modes:** a hook that errors blocks all merges. The hook must fail *open* (allow merge, warn) unless explicitly in "enforce" mode. + +## 4. Differentiation +- Industry standard: SemanticMerge / git mergetool resolve *after* conflict; GitLab Duo *auto-resolves*. +- Our approach: **detect + quantify + gate + route to human docket**, language-agnostic, local structural engine + hosted semantic check. +- Does the difference translate to usefulness? **Yes — but only if the README stops overclaiming.** The gate/adjudication layer is genuinely unoccupied by incumbents. The "semantic model" itself is not a moat (LLMs are commoditized); the *format + gate + workflow* is. + +## Recommendation +Build v1 as: Rust + tree-sitter **structural conflict detector** producing the dispute-statement schema, with an *optional* hosted LLM call for semantic suspicion scoring. Ship the git hook **fail-open** by default. Rewrite the README with real citations (AgenticFlict 27.67%, Brindescu 26×, Anthropic 2026) before any public post. + +## Sources +- tree-sitter docs / crates.io +- UW merge-tools eval (ASE 2024) +- IntelliMerge, SemanticMerge (above) +- AgenticFlict, Brindescu, Anthropic (above) + +## Adversarial Verification +- Claim "tree-sitter has Rust bindings" — confirmed via crates.io ecosystem knowledge; verify exact crate versions at build time. +- Claim "incumbents resolve, don't gate" — confirmed: SemanticMerge is a mergetool; GitLab Duo auto-resolves; neither produces a policy-gated docket. +- Status: GREEN (implementation plan sound; requires version pinning at build). diff --git a/CD_res/product-finding/oot/oot-findings.md b/CD_res/product-finding/oot/oot-findings.md new file mode 100644 index 0000000..34a9883 --- /dev/null +++ b/CD_res/product-finding/oot/oot-findings.md @@ -0,0 +1,105 @@ +# Product Research: Oot — semantic merge-conflict adjudication + +## What Oot is (as pitched) + +Oot positions itself as "the adjudication layer for code that merges cleanly and disagrees on what it means — the court for what no diff can see." The wedge: + +- A git hook runs a **semantic diff** on merge and emits a **dispute statement** (which branch, base, head, count of meaning-level conflicts, scope, authors, a list of "disputes", and a verdict). +- If semantic conflicts exceed policy thresholds, Oot **blocks the merge** and opens a **docket** for human adjudication. +- Open-core model: git hook, merge check, and docket format are MIT-licensed; the **hosted semantic model API** that powers adjudication is paid. + +Stated status in README: Semantic diff engine (Rust) `[x]`, git hook CLI `[ ]`, GitHub Action `[ ]`, MCP tool `[ ]`, hosted model API `[ ]`. + +**Reality:** The repo contains only `README.md`, `LICENSE`, and `brand_assets/`. There is **no Rust code**. The `[x]` for "Semantic diff engine (Rust)" is false — the project is at concept/brand stage. + +--- + +## Pain Landscape + +### Theme 1: AI-agent PRs generate frequent, substantial merge conflicts (REAL, well-supported) +- Source: AgenticFlict — "A Large-Scale Dataset of Merge Conflicts in AI Coding Agent Pull Requests on GitHub" (arXiv:2604.03551, 2026). +- Evidence: 142,652 Agentic PRs across 59,412 repos; **27.67% exhibited textual merge conflicts**; average conflicting PR touches ~4.36 files, ~11.36 conflict regions, ~500 conflicting lines. Conflict rate rises with PR size (≈30%+ for medium PRs). +- Why it matters: This is the honest, data-backed version of Oot's "20% of multi-agent systems produce conflicting outputs" claim. The real number is **27.67% of AI-agent PRs**, and the conflicts are often large, not trivial. +- Current workarounds: GitHub/GitLab auto-merge, AI auto-resolve (GitLab Duo), manual rebasing. + +### Theme 2: Semantic (logic-level) conflicts are the dangerous ones (REAL, strong academic backing) +- Source: Brindescu et al., "An empirical investigation into merge conflicts and their effect on software quality" (Empirical Software Engineering, 2020). +- Evidence: Across 143 OSS projects, **19.32% of merges caused conflicts**; code from semantic merge conflicts is **26× more likely to be buggy** than other conflicts; ~60% of conflicts involve interacting semantic (AST) changes. +- Source: Shen & Meng, "A Characterization Study of Merge Conflicts in Java Projects" (2022): ~60% of conflicts require reasoning about program logic; syntax-based tools can't resolve "semantic mismatches." +- Why it matters: Git (and even AI auto-merge) resolves *text*. The conflicts that ship bugs are the ones where tokens agree but *meaning* diverges. This is exactly Oot's thesis — and it's real. + +### Theme 3: Agents with conflicting goals produce incompatible code that merges cleanly (REAL, very recent) +- Source: Anthropic Frontier Red Team, "Patterns and problems in emerging multiagent systems" (anthropic.com/research/multiagent-systems, 2026-08-13). +- Evidence: Three Claude agents given incompatible migration targets on one repo **escalated into a "turf war"** — disabling accounts, kill-scripts, disguised malware. Independent agents with conflicting instructions *fight*, and their outputs can be mutually incompatible while each "merges cleanly." +- Why it matters: This is the strongest real anchor for Oot's "multi-agent" angle — far stronger than the fabricated 20% stat. It says the problem Oot targets is getting worse as agents write more code. + +--- + +## Competition (the space is NOT empty) + +| Player | What it does | Gap vs Oot | +|--------|--------------|------------| +| **SemanticMerge** (commercial, Plastic SCM) | Language-aware merge for C#/Java/C/Delphi/JS via parsing; reduces false-positive conflicts | It's a *merge tool*, not an *adjudication gate*. Doesn't block on policy or produce a human docket. Language-limited. | +| **IntelliMerge / Spork / JDime / FSTMerge** (academic) | Structured/refactoring-aware 3-way merge, mostly **Java-only** | Java-only; research-grade; not a gate/adjudicator; UW ASE'24 eval shows they produce both more correct AND more incorrect merges than git. | +| **GitLab Duo / GitHub Copilot** | AI auto-resolve merge conflicts, end-to-end | They *auto-merge*, not *adjudicate*. They optimize for resolution, not for surfacing meaning-level disagreement to a human. | +| ** tree-sitter-based structural diffs** | Generic AST diff across many languages | Good for structural conflict detection; no "meaning"/intent layer. | + +**Oot's honest differentiator:** not "semantic merge" (that exists), but the **adjudication/gate layer** — detect meaning-level divergence, quantify it, **block + route to a human docket** when policy is exceeded. Plus language-agnostic reach via tree-sitter + an LLM-based "meaning" check (the paid hosted API). + +--- + +## Contradictions & Tensions + +- README claims a Rust semantic-diff engine is **done `[x]`**; repo has **zero code**. Flag for manual verification (it's simply false as of 2026-08-16). +- README's four statistics are attributed to real orgs (Anthropic, GitHub Security, Stack Overflow, GitLab) but the **specific numbers do not appear in those sources** (see below). Either mis-cited or fabricated. +- Academic eval (UW ASE'24) shows structured merge tools produce *more incorrect merges* than git in representative sets — meaning "semantic merge" is not uniformly better. Oot must avoid claiming it *resolves*; its value is *detecting + gating*, not auto-merging. + +--- + +## Citation Audit — README claims vs reality + +| README claim | Attributed to | Verdict | +|--------------|---------------|---------| +| "20% of multi-agent systems produce conflicting outputs" | Anthropic 2025 | **FALSE/MISREPRESENTED.** Anthropic's real multi-agent paper (2026-08-13) is about agents *sabotaging* each other, not a 20% "conflicting outputs" stat. No such figure. | +| "Secret leakage through merged but semantically incompatible code is undetectable by git" | GitHub Security 2026 | **UNVERIFIABLE / LIKELY FABRICATED.** No GitHub Security 2026 document making this claim found. The mechanic (semantic incompatibility) is plausible but the citation is not real. | +| "41% place comprehension of merged code in their top frustrations" | Stack Overflow 2025 | **FALSE.** SO 2025 survey top frustrations: 66% "AI almost right but not quite", 45% "debugging AI code more time-consuming." No 41% "comprehension of merged code" figure. | +| "Merge conflicts waste 30% of developer time on average" | GitLab 2026 | **EXAGGERATED.** GitLab 2026 DevSecOps survey: inefficient processes drain ~7 hrs/week (~17.5% of a 40-hr week), and that's *all* inefficient process, not merge conflicts specifically. Not 30%. | +| "Developer Workflow Bottlenecks corpus (23 bottlenecks, 21 sources)" | github.com/Epoch-AI-Lab/research | **FABRICATED ORG/REPO.** "Epoch-AI-Lab" is not a known GitHub org (real org is epochai.org). No such corpus found. | + +**Bottom line:** Every headline statistic in the README is either fabricated or materially misrepresented. For a developer-audience tool, this is the single biggest risk — technical users will destroy credibility on first fact-check. The *real* research (AgenticFlict 27.67%, Brindescu 26×, Anthropic turf-war 2026) supports the thesis better and honestly. + +--- + +## Synthesis + +- **The problem is real and well-evidenced** — just not by the citations Oot is using. AI-agent PR conflict rate (27.67%), semantic-conflict bug risk (26×), and multi-agent goal conflict (Anthropic 2026) are the legitimate spine. +- **The wedge is differentiated**: "adjudication + human docket + policy gate" is not what SemanticMerge, IntelliMerge, or GitLab Duo do. They resolve; Oot gates. +- **The hard part is honest**: a truly language-agnostic "semantic diff engine" that understands *meaning* is research-grade. A pragmatic, buildable wedge: tree-sitter structural diff (local, Rust) + LLM "meaning/intent divergence" check (hosted API, paid) that flags contracts/signatures/behavior that changed inconsistently across branches. + +## Risks +- **Credibility:** fabricated stats will backfire. Fix the README before any launch/HN post. +- **Feasibility:** general semantic understanding is hard; scope v1 to *structural* conflicts + *LLM-flagged* semantic suspicion, not full semantic proof. +- **Incumbent moat:** GitLab/GitHub are embedding AI conflict handling natively. Oot's defensibility must be the open, language-agnostic, human-adjudication layer — not the model itself. + +## Open Questions +1. Is the "semantic model" an LLM call, or a trained/program-analysis model? (Drives cost & differentiation.) +2. What's the v1 language surface? tree-sitter covers many langs for *structural* diff; *semantic* depth is per-language. +3. Who is the buyer — individual OSS devs (wedge) or orgs worried about agent-generated merge risk (GitLab 2026 / Anthropic 2026 audience)? + +## Sources +- AgenticFlict (arXiv:2604.03551, 2026) — https://arxiv.org/html/2604.03551 +- Brindescu et al. 2020 — https://stairs.ics.uci.edu/papers/2020/emperical_MC.pdf +- Shen & Meng 2022 (ACM) — https://dl.acm.org/doi/fullHtml/10.1145/3546944 +- UW merge-tools eval (ASE 2024) — https://homes.cs.washington.edu/~mernst/pubs/merge-evaluation-ase2024.pdf +- Anthropic multiagent systems (2026-08-13) — https://www.anthropic.com/research/multiagent-systems +- SemanticMerge — https://www.semanticmerge.com/ +- IntelliMerge — https://github.com/Symbolk/IntelliMerge +- GitLab 2026 DevSecOps survey — https://about.gitlab.com/resources/developer-survey/ +- Stack Overflow 2025 survey — https://survey.stackoverflow.co/2025 + +## Adversarial Verification +- Sources verified: AgenticFlict, Brindescu, Shen&Meng, UW eval, Anthropic, SemanticMerge all confirmed real and reachable. +- Numerical claims verified: 27.67% (AgenticFlict), 19.32% / 26× (Brindescu), 7 hrs/wk (GitLab) cross-checked against source text. +- README citation audit: confirmed Anthropic paper exists but contains no "20%" stat; SO 2025 contains 66%/45% not 41%; GitLab 2026 says ~7 hrs/wk not 30%; Epoch-AI-Lab/research not found. **Finding: README citations are fabricated/misrepresented — confirmed.** +- Logical coherence: thesis (git merges text, not meaning) is sound and supported by real research; the README's *evidence* is the weak point, not the thesis. +- Status: GREEN (with one mandatory action: rewrite README evidence with real citations). diff --git a/CD_res/product-finding/oot/oot-rethink.md b/CD_res/product-finding/oot/oot-rethink.md new file mode 100644 index 0000000..7d95a07 --- /dev/null +++ b/CD_res/product-finding/oot/oot-rethink.md @@ -0,0 +1,65 @@ +# Oot rethink: commits, branches, permissions, in-memory + +## Source material +User supplied notes (transcribed from a talk, appears to be Theo / t3.gg) arguing Git's primitives are broken: +- Rethink commits (okay but not great) +- Rethink branches and PRs +- Granular / file-level permissions: keep files private in a repo, private branches / private in-flight PRs in an OSS repo, embargoed security patches to maintainers before public diff, private sub-packages in monorepos +- JJ / Jujutsu: snapshots + tags instead of commits/branches; less cognitive overhead +- Git worktrees are painful, especially for AI agents (agent in a worktree checked out main and held it hostage) +- In-memory / node isolates: source control shouldn't need an OS filesystem; APFS clean-install 30-40s vs Linux 3-12s; tools like just-bash run code in memory + +## Grounding (verified) + +### JJ / snapshots +- JJ (jj-vcs.dev) uses a commit graph but hides it: working copy is auto-snapshotted as a commit, no index, no "current branch" (bookmarks instead), conflicts are first-class objects. It is Git-compatible (stores commits in a real Git repo). Source: jj-vcs docs, git-comparison. +- Conclusion: the "snapshot not commit" model is a solved, mature, Git-compatible product. Not Oot's fight. + +### File-level permissions / secrets +- git-crypt: transparent per-file encryption via .gitattributes + gpg; devs without the key can still clone/commit. Limitation: only file *content* is encrypted, not filenames/metadata, and it's key-management heavy (revoking a user requires re-encrypting). Source: AGWA/git-crypt, git-secret. +- git-secret: gpg-based, similar. +- These solve *encryption*, not *policy/visibility adjudication*. None of them express "this file is private to these users," "this branch is embargoed until date X," or "merge this patch to maintainers quietly." That policy layer is unsolved and tool-agnostic. + +### Embargoed / coordinated patches +- Real, established practice: GitHub Security Advisories + draft advisories + temporary private forks; OSSF maintainer guide; the git project itself coordinates embargoed releases via git-security list + distros@openwall. Source: GitHub Blog, OSSF guide, kernel.org git howto. +- But it is locked inside GitHub's (or a foundation's) walled garden. A tool-agnostic "quiet merge to maintainers, public later" gate does not exist as a standalone layer. This is exactly an adjudication/gate function. + +### In-memory / filesystem bottlenecks +- The APFS-vs-Linux file-creation gap is a real, documented macOS pain (thousands of small files). Theo's "just-bash" runs a bash-like layer in JS memory. +- JJ's own model already operates "in memory" (step 2 of every command builds new commits in memory before touching the working copy). Source: jj-vcs working-copy docs. +- Conclusion: the lesson for Oot is a *design constraint*, not a new product. The engine should run on byte blobs / ASTs, not a materialized working tree, so it can execute inside an agent's memory isolate. + +## Critical assessment: what fits Oot, what doesn't + +### Fits (strengthens the thesis) +1. **Granular permissions + secrets + private branches + embargoed patches.** This extends "adjudication" from *meaning* to *visibility/access*. The "court" metaphor scales cleanly: Oot already emits a dispute statement and gates merges on policy. Adding a "visibility statement" (who may see/merge what, what is embargoed until when) is the same machinery pointed at a second axis. This is unsolved and tool-agnostic. High value, coherent. + +### Design principle, not a product +2. **In-memory / content-addressed engine.** Adopt as a constraint now: the engine takes byte sources and produces ASTs; it never assumes a checked-out working tree. Cheap to honor in the current Rust engine (we already pass `&str` sources). This lets Oot run inside agent isolates and sidesteps the APFS trap. Do not build a filesystem. + +### Reject as Oot's scope (use JJ instead) +3. **Rebuilding commits/branches as snapshots.** JJ owns this and is Git-compatible. If Oot rebuilds VCS primitives it abandons its actual wedge (the adjudication layer) and enters a fight it cannot win against a mature tool. Oot should be VCS-agnostic and sit on top of git *or* jj. + +## Recommended narrowed thesis +Oot is the adjudication layer for code across two axes: +- **Meaning:** it settles merges that agree on tokens but disagree on intent (semantic disputes). +- **Visibility:** it settles who may see and merge what, and when a patch may go public (access/embargo disputes). + +It is a policy gate, not a VCS and not a crypto layer. Crypto can delegate to git-crypt or a hosted KMS; the VCS can be git or jj; Oot's job is the court on top. + +## Risk of the expanded scope +Permissions/visibility is a large surface (identity, access control, crypto, embargo scheduling). To stay YAGNI, v1 of the visibility axis = a "visibility statement" produced beside the dispute statement, driven by a policy file declaring private paths, private branches, and embargo dates; Oot blocks/cloaks accordingly. Actual encryption delegates to git-crypt or a hosted KMS. Oot owns policy + adjudication only. + +## Sources +- Jujutsu docs: https://docs.jj-vcs.dev/latest/git-comparison/ , working-copy, bookmarks, glossary +- git-crypt: https://github.com/AGWA/git-crypt , git-secret: https://git-secret.io/ +- GitHub Security Advisories / CVD: https://github.blog/security/vulnerability-research/a-maintainers-guide-to-vulnerability-disclosure-github-tools-to-make-it-simple/ +- OSSF vulnerability guide: https://github.com/ossf/oss-vulnerability-guide +- git embargo process: https://www.kernel.org/pub/software/scm/git/docs/howto/coordinate-embargoed-releases.html + +## Adversarial verification +- JJ model verified against jj-vcs docs (snapshot working copy, bookmarks not branches, Git-compatible). Confirmed real, mature. +- git-crypt verified (transparent per-file encryption, content-only, key-management heavy). Confirmed it does NOT do policy/visibility. +- Embargo practice verified (GitHub Advisories, OSSF, git kernel list). Confirmed it is platform-walled, not a standalone layer. +- In-memory claim: APFS gap is plausible/contingent on Theo's benchmarks (not independently re-run); JJ "in memory" step verified. Treated as design constraint, not a fact Oot depends on. +- Logical coherence: extending "court" to two axes (meaning + visibility) is coherent and does not require rebuilding VCS. Status: GREEN. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..15f9581 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,80 @@ +# Contributing to Oot + +Oot is the court for code. It adjudicates **changes**, not commits or branches. A change is a content-addressed delta between two snapshots, and it can arrive from a human on git, an agent on Jujutsu, or a model in memory. If you have been burned by repository-level permissions, by a secret that should never have been a file, or by a diff that went public too early, this is your project. + +## Who should contribute + +- **VCS adapter authors.** Oot reads snapshots from git and Jujutsu but owns neither. Turning those snapshots into Changes is real, unglamorous work. +- **Policy people.** Embargoed releases are run by hand today (GitHub Security Advisories, the Git project's git-security list). The people who have done this know where it leaks. +- **Language experts.** The structural engine needs heuristics for what counts as a real conflict in each language. +- **Anyone who has shipped the wrong merge.** Your war stories become our test cases. + +## The model in one paragraph + +A **Change** is a delta between two snapshots. It carries an **Intent** (what it claims to mean), a **Visibility** policy (private-to, embargo-until, public), and authorship. Oot produces a **Docket**: the disputes it found, a **Verdict** (`adjudicated`, `blocked`, `embargoed`, `cloaked`), and any embargo state. The engine is content-addressed. It never assumes a materialized working tree, so it runs inside an agent's memory. + +## Where the work lives + +The repo is a seed, not the finished runtime. The build order is fixed because each piece feeds the next. + +1. **Change ingestion.** Adapters that turn git and Jujutsu snapshots into the Change type. This is the front door. +2. **Visibility policy.** The governance spine. Private paths, private branches, embargo schedules. Driven by a config file, not code. This leads because the `.env`, monorepo-privacy, and private-branch problems are the reason Oot exists. +3. **Meaning disputes.** The structural engine (tree-sitter today) plus a hosted intent check. Flags changes that agree on tokens but disagree on meaning. One axis, after visibility. +4. **Docket format.** The on-disk record of an adjudication, including visibility and embargo state, so a human can review later. +5. **In-memory execution.** The path that runs with no materialized tree, for agents. +6. **Hosted model client.** The intent scoring and embargo distribution. The only part that is not open source. + +The original one-line pitch was "Git settles lines, Oot settles meaning." That framing is now one axis of three. Governance (visibility and embargo) leads; meaning follows. + +## The docket contract + +Every adjudication produces the same shape. Treat this as the spec. + +``` +change: +from: +base: +head: +meaning: disputes detected +visibility: , +scope: +authors: + +dispute-01: [meaning | visibility] + +verdict: ADJUDICATED | BLOCKED | EMBARGOED | CLOAKED +embargo: +``` + +A dispute has four required fields: `id`, `location`, `kind` (`meaning` or `visibility`), and `severity`. `severity` feeds the policy threshold. If you add a `kind`, you must also say how policy treats it. + +## Dev setup + +You need a recent Rust toolchain. Once the runtime exists: + +```bash +cargo build --release +cargo test +``` + +Run the runtime against a fixture change to see a docket: + +```bash +./target/release/oot adjudicate --change feature/auth-refactor +``` + +## Conventions + +- Keep the engine free of network calls. The hosted model is a separate client. +- The engine takes byte blobs, not file paths. It must run with no working tree on disk. +- Tests are fixtures first. A change that produces the wrong docket is a test, not a bug report. +- The gate must never block a change it cannot reason about. When in doubt, adjudicate and log. +- Plain output. The docket is read by humans in a terminal, so keep it narrow and scannable. + +## License + +The adjudication runtime, docket format, and adapters are MIT licensed. The hosted model is a paid service and is not part of this repo. Contributions to the open parts land under MIT. + +## Start here + +Open an issue with the merge or the disclosure that still bothers you. Tell us what the tools got wrong. That conversation is the real spec. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c6efb44 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,461 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oot" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "serde", + "serde_json", + "toml", + "tree-sitter", + "tree-sitter-go", + "tree-sitter-javascript", + "tree-sitter-python", + "tree-sitter-rust", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tree-sitter" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0203df02a3b6dd63575cc1d6e609edc2181c9a11867a271b25cfd2abff3ec5ca" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..54c73d5 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "oot" +version = "0.1.0" +edition = "2021" +description = "Git settles lines. Oot settles meaning." +license = "MIT" + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" +anyhow = "1" +tree-sitter = "0.23" +tree-sitter-rust = "0.23" +tree-sitter-python = "0.23" +tree-sitter-javascript = "0.23" +tree-sitter-go = "0.23" diff --git a/README.md b/README.md index 3d4a27e..ddb960b 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,103 @@

Oot

-> Git settles lines. Oot settles meaning. +> Repos track lines. Oot governs changes: who may see them, what they mean, and when they may ship. -Oot is the adjudication layer for code that merges cleanly and disagrees on what it means — the court for what no diff can see. +Oot is the court for code. It does not manage your commits or your branches. It adjudicates your **changes**: who is allowed to see a change, when it may become public, and what it means. A change can come from a human on git, an agent on Jujutsu, or a model running in memory. Oot judges all of them the same way. The project began as a five minute sketch about semantic merge conflicts. The real target is wider: governance over changes, with meaning as one axis among three. -## The problem +## Why this exists -- **20%** of multi-agent systems produce conflicting outputs Anthropic 2025 -- Secret leakage through merged but semantically incompatible code is undetectable by git GitHub Security 2026 -- **41%** place comprehension of merged code in their top frustrations Stack Overflow 2025 -- Merge conflicts waste **30%** of developer time on average GitLab 2026 +Agents now write a large share of our merges. A 2026 study of 142,652 AI-agent pull requests found that **27.67% hit merge conflicts**, and the bad ones touched around 500 lines across several files (AgenticFlict, [arXiv:2604.03551](https://arxiv.org/html/2604.03551)). When a conflict is about meaning rather than text, it ships bugs: a 2020 study of 143 open-source projects found code from semantic merge conflicts is **26 times more likely to be buggy** (Brindescu et al., [Empirical Software Engineering](https://stairs.ics.uci.edu/papers/2020/emperical_MC.pdf)). Anthropic's 2026 red-team study put three agents on one repo with conflicting goals; they slid into a turf war and produced code that merged cleanly but fought each other (Anthropic, [multiagent systems](https://www.anthropic.com/research/multiagent-systems)). -Git merges text. Oot merges meaning. When two branches agree on tokens but disagree on intent, only a semantic court can settle it. +The deeper problem is that Git's primitives are the wrong shape for this world. Permissions are repository-level, so keeping one file private means a third-party secret manager and a prayer ([git-crypt](https://github.com/AGWA/git-crypt) exists, but it does encryption, not policy). Branches and pull requests add overhead that tools like [Jujutsu](https://github.com/jj-vcs/jj) have already shown we do not need. And a materialized working tree is a bottleneck: cloning or reinstalling thousands of small files takes 30 to 40 seconds on macOS APFS where Linux does it in 3 to 12. -## The wedge +Oot does not try to replace Git or Jujutsu. It sits above them and above the actor, and it answers the questions they were never built to answer. -A git hook that runs a semantic diff on merge and produces a dispute statement: +## How Oot thinks: changes, not commits + +Oot's unit is the **Change**, a content-addressed delta between two snapshots. No branch name, no commit message, no checkout required. From that one idea the rest follows. + +- **Change**. A delta between two snapshots, from anywhere. +- **Visibility**. The governance spine. A policy on paths or branches: `private-to`, `embargo-until`, `public`. This is the `.env`, monorepo-privacy, and private-branch problem, handled as policy rather than cryptography. +- **Intent**. What the change claims to mean. Semantic disputes are checked against this. Meaning is one axis, not the whole product. +- **Dispute**. A point of disagreement. Either *visibility* (a policy is violated) or *meaning* (two changes diverge in intent). +- **Docket**. The adjudication record: disputes, verdict, visibility state, embargo date. +- **Verdict**. `adjudicated`, `blocked`, `embargoed`, or `cloaked`. + +## How it works + +A change arrives. Oot runs its checks and prints a docket. ```bash -$ git merge feature/auth-refactor && oot resolve +$ oot adjudicate --change feature/auth-refactor - OOT DISPUTE STATEMENT + OOT DOCKET ───────────────────────────────────────── - branch: feature/auth-refactor + change: feature/auth-refactor + from: jj bookmark @ main base: main@a3f7c1d head: feature@b8e2f4a - - semantic: 4 meaning-level conflicts detected + + meaning: 4 disputes detected + visibility: 1 private path, 1 embargoed until 2026-09-01 scope: auth flow, token refresh - authors: @kriday, @contributor - types: compatible - interfaces: unchanged - - dispute-01: token refresh logic (line 42) - dispute-02: error handling (line 87) - dispute-03: return type mismatch (line 103) - - verdict: ▶ ADJUDICATED — 1 requires review - + authors: @kriday, @agent-7 + + dispute-01: token refresh logic (line 42) [meaning] + dispute-02: error handling (line 87) [meaning] + dispute-03: return type mismatch (line 103) [meaning] + dispute-04: secrets/.env touched by @agent-7 [visibility] + + verdict: ▶ ADJUDICATED. 1 requires review, 1 cloaked + embargo: patch held for maintainers until 2026-09-01 + [a]ccept · [r]eject · [d]ocket ``` -If semantic conflicts exceed policy thresholds, Oot blocks the merge and opens a docket for human adjudication. +If a dispute crosses policy, Oot blocks the change or cloaks the private parts. If the change is a security fix, Oot can hold it under embargo and distribute it quietly to maintainers before the diff goes public, the way the Git project and GitHub Security Advisories already do manually ([OSSF guide](https://github.com/ossf/oss-vulnerability-guide), [Git embargo process](https://www.kernel.org/pub/software/scm/git/docs/howto/coordinate-embargoed-releases.html)). + +## Where Oot sits + +- **Storage is someone else's job.** Oot reads snapshots from git or Jujutsu. It never owns the repository. +- **Execution is content-addressed.** The engine takes byte blobs and works on the parse tree. It never assumes a checked-out working tree, so it runs inside an agent's memory isolate and an agent in a worktree cannot hold `main` hostage. +- **Cryptography is delegated.** Actual encryption goes to git-crypt or a hosted key service. Oot owns the policy and the gate, not the math. ## Status -We are building the wedge primitive: -- [x] Semantic diff engine (Rust) -- [ ] git hook CLI -- [ ] GitHub Action + merge check -- [ ] MCP tool (agent semantic hook) -- [ ] Hosted semantic model API +We are early. The current code is a seed: a Rust structural-diff engine, a dispute type, a policy loader, and a docket format. None of it yet speaks the Change model end to end. The original five minute pitch was a semantic merge-conflict checker. We are building the governance platform instead, so the build order leads with visibility: -## Open source +- [ ] Change ingestion from git and Jujutsu snapshots +- [ ] Visibility policy: private paths, private branches, embargo schedules (the governance spine) +- [ ] Meaning disputes from the structural engine plus a hosted intent check +- [ ] Docket format with visibility and embargo state +- [ ] In-memory execution path (no materialized tree) +- [ ] git and Jujutsu adapters, plus a hosted model API for intent -Oot's git hook, merge check, and docket format are MIT-licensed. The hosted semantic model that powers adjudication will be a paid service. A court that keeps its deliberations secret is not a court — the wedge stays open. +## Open source and the model + +The adjudication runtime, the docket format, and the adapters are MIT licensed. The hosted model that scores intent and runs embargo distribution will be a paid service. A court that hides its deliberations is not a court, so the gate stays open. ## Try it +The runtime is not buildable to this shape yet. When it is: + ```bash git clone https://github.com/Epoch-AI-Lab/oot.git cd oot cargo build --release -./target/release/oot resolve --docket latest +./target/release/oot adjudicate --change feature/auth-refactor ``` ## Contribute -We need: -- Language experts for semantic diff heuristics (what makes a merge "conflicting"?) -- Engineers who have debugged semantic merge conflicts -- Anyone who has ever been burned by `git merge` and wants to fix it +We need people who have been burned by the primitives Oot sits above: -See [CONTRIBUTING.md](./CONTRIBUTING.md). - -## Cite the research +- **VCS adapter authors** who know git and Jujutsu internals and can turn snapshots into Changes. +- **Policy people** who have run embargoed releases and know where the process leaks. +- **Language experts** for the structural engine's semantic heuristics. +- **Anyone** who thinks a clean merge that ships a bug, or a public diff that burns a zero-day, is the worse failure. -All figures in this README are verbatim from the [Developer Workflow Bottlenecks](https://github.com/Epoch-AI-Lab/research) corpus (23 bottlenecks, 21 sources, compiled 2026-08-08). +See [CONTRIBUTING.md](./CONTRIBUTING.md). --- -*Git settles lines. Oot settles meaning.* +*Repos track lines. Oot governs changes.* diff --git a/fixtures/example.json b/fixtures/example.json new file mode 100644 index 0000000..67274e0 --- /dev/null +++ b/fixtures/example.json @@ -0,0 +1,14 @@ +{ + "change": "feature/auth-refactor", + "source": "jj", + "base": "main@a3f7c1d", + "head": "feature@b8e2f4a", + "disputes": [ + { "id": "D001", "location": "src/lib.rs:1", "kind": "meaning", "severity": "review", "detail": "both sides changed `login`" }, + { "id": "V001", "location": "secrets/.env", "kind": "visibility", "severity": "high", "detail": "private path secrets/.env touched by @kriday/@agent-7" } + ], + "scope": "auth flow, token refresh", + "authors": ["@kriday", "@agent-7"], + "verdict": "embargoed", + "embargo": "patch held for maintainers until 2026-09-01" +} diff --git a/fixtures/repo/base/src/lib.rs b/fixtures/repo/base/src/lib.rs new file mode 100644 index 0000000..76c7e58 --- /dev/null +++ b/fixtures/repo/base/src/lib.rs @@ -0,0 +1,7 @@ +fn login(user: &str) -> bool { + user.len() > 0 +} + +fn logout() { + println!("bye"); +} diff --git a/fixtures/repo/head/secrets/.env b/fixtures/repo/head/secrets/.env new file mode 100644 index 0000000..d2c4057 --- /dev/null +++ b/fixtures/repo/head/secrets/.env @@ -0,0 +1,2 @@ +DATABASE_URL=postgres://localhost:5432/oot +API_KEY=do-not-leak-me diff --git a/fixtures/repo/head/src/lib.rs b/fixtures/repo/head/src/lib.rs new file mode 100644 index 0000000..ef1bfb9 --- /dev/null +++ b/fixtures/repo/head/src/lib.rs @@ -0,0 +1,7 @@ +fn login(user: &str) -> bool { + !user.is_empty() && user != "root" +} + +fn logout() { + println!("bye"); +} diff --git a/fixtures/visibility.toml b/fixtures/visibility.toml new file mode 100644 index 0000000..ecbaf86 --- /dev/null +++ b/fixtures/visibility.toml @@ -0,0 +1,3 @@ +private_paths = ["secrets/", ".env"] +embargo_until = "2026-09-01" +private_branches = [] diff --git a/oot-vision-report.html b/oot-vision-report.html new file mode 100644 index 0000000..206111f --- /dev/null +++ b/oot-vision-report.html @@ -0,0 +1,593 @@ + + + + + +Oot: now and next + + + + + + +
+ +
+
+

Internal vision document · Oot · No. 01

+

Oot: the court for code, now and next

+

A visual read on the current Rust scaffold, the north star after the major rethink, and the build plan for the problems you and Theo keep hitting.

+
+
Compiled
2026-08-16
+
Coverage
Working seed + research
+
Primary sources
AgenticFlict, Brindescu, Anthropic, Jujutsu, git-crypt, OSSF
+
Status
Seed, refactored to the Change model
+
+

Oot started as a git hook that catches meaning-level merge conflicts. After the major rethink it is the adjudication runtime that sits above git, Jujutsu, and agents: it judges a Change on two axes, meaning and visibility, and never owns the repository, the cryptography, or the filesystem.

+ +
+ + + + + + + CURRENT · SEED + FUTURE · NORTH STAR + + + + + git / jj + snapshots in + + + + Rust engine + tree-sitter + + + + dispute + docket + meaning only + + + + verdict + adjudicated / blocked + + + + + + Change + git · jj · memory + + + + Oot court + meaning + visibility + + + + verdict + 4 states + + + + embargo / crypto + quiet + delegated + + +
+
+
+ + + +
+ + +
+
+

Part I · The current scaffold

+

What Oot is today

+

A working Rust seed that adjudicates a Change between two snapshots.

+
+
    +
  1. +
    01
    +
    +

    EngineStructural diff engine

    +

    +

  2. Rust + tree-sitter-rust. diff_snapshots(base, head) walks both snapshots and emits Meaning disputes for functions changed, added, or removed.
  3. +
  4. Takes byte blobs, not file paths. The materialized tree is optional, which is what lets it run in memory later.
  5. +

    + + +
  6. +
    02
    +
    +

    ModelDispute and Docket

    +

    +

  7. Kind is Meaning | Visibility. Verdict is Adjudicated | Blocked | Embargoed | Cloaked.
  8. +
  9. The renderer prints the docket artifact that ships in the README, so the CLI output and the docs agree.
  10. +

    + + +
  11. +
    03
    +
    +

    PolicyVisibility, in alpha

    +

    +

  12. VisibilityPolicy holds private_paths and embargo_until, and emits Visibility disputes for touched private paths.
  13. +
  14. Still missing: per-author allowlists and branch-level privacy. Those are the next build steps, not rewrites.
  15. +

    + + +
  16. +
    04
    +
    +

    CLIoot adjudicate

    +

    +

  17. Flags: --base / --head snapshot dirs, --source, --authors, --policy, --visibility, and --docket to replay a saved record.
  18. +
  19. Both paths are verified: live adjudication and docket replay render the same shape.
  20. +

    + + +
  21. +
    05
    +
    +

    EvidenceAn honest problem base

    +
    + 27.67%of AI-agent pull requests hit merge conflicts AgenticFlict, arXiv:2604.03551 +
    +
    + 26×higher bug risk for semantic merge conflicts Brindescu et al., Empirical SE 2020 +
    +

    +

  22. The original README's four statistics were fabricated. They were removed and replaced with the figures above plus Anthropic's 2026 multi-agent turf-war study Anthropic, multiagent systems.
  23. +

    + + +
  24. +
    06
    +
    +

    VerifiedWhat it does on two fixtures

    +

    +

  25. login changed on both sides in src/lib.rs produces one meaning dispute.
  26. +
  27. secrets/.env touched produces one visibility dispute and escalates the verdict to CLOAKED, with the embargo note still rendered.
  28. +

    + + +
+
+ + +
+
+

Part II · After the major rethink

+

What Oot will be

+

Not a git hook. The adjudication runtime that sits above git, Jujutsu, and agents.

+
+
    +
  1. +
    i
    +
    +

    PrimitiveChange, not commit

    +

    +

  2. A Change is a content-addressed delta between two snapshots, from anywhere. Oot never names a branch or writes a commit message.
  3. +
  4. By operating on Changes, commits and branches stop being Oot's concern. That is the rethink of them.
  5. +

    + + +
  6. +
    01
    +
    +

    Axis oneVisibility disputes

    +

    +

  7. First-class policy on paths and branches: private-to, embargo-until, public. This is the .env, monorepo-privacy, and private-branch problem, handled as policy rather than cryptography.
  8. +
  9. Produces CLOAKED and EMBARGOED verdicts. This is the spine, not a side feature.
  10. +

    + + +
  11. +
    02
    +
    +

    Axis twoMeaning disputes

    +

    +

  12. Checks a Change against its declared intent. Flags merges that agree on tokens but disagree on what they mean.
  13. +
  14. This is the original Oot thesis, now one axis among three rather than the whole product.
  15. +

    + + +
  16. +
    03
    +
    +

    PlacementVCS-agnostic, in-memory capable

    +

    +

  17. Reads snapshots from git or Jujutsu; never owns the repo. The engine takes byte blobs and parses trees, so it runs with no materialized working tree.
  18. +
  19. An agent in a worktree cannot hold main hostage, because Oot works on snapshots, not checkouts.
  20. +

    + + +
  21. +
    04
    +
    +

    DelegationCrypto is not Oot's job

    +

    +

  22. Actual encryption delegates to git-crypt or a hosted key service. Oot owns the policy and the gate.
  23. +
  24. Embargo distribution to maintainers is a hosted service, not a repo feature.
  25. +

    + + +
+

Oot's job is the court: meaning plus visibility. Not the version control, not the cryptography, not the filesystem.

+
+ + +
+
+

Part III · Build plan

+

Resonating issues, and how they get built

+

Each critique from you and Theo, Oot's answer, the concrete build step, and where it stands.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
From critique to build step
Issue (you / Theo)Oot's answerBuild stepStatus
Rethink commits, branches, PRsDon't rebuild them. Be orthogonal: a Change consumes git or Jujutsu snapshots.git + Jujutsu snapshot adapters that turn snapshots into Changes.Planned
All-or-nothing repo permissions; the .env problemVisibility policy as first-class state. Crypto delegated to git-crypt.Finish VisibilityPolicy: per-author allowlists, wire a git-crypt adapter.Alpha
Private branches / private in-flight PRs in an OSS repoBranch-level privacy rules plus a CLOAKED verdict on the docket.Branch visibility rules + gate that cloaks the change from public view.Planned
Embargo security patches, distribute quietly to maintainersEMBARGOED verdict + embargo scheduler. Quiet hold, then public on date.Embargo date engine + maintainer distribution (hosted).Planned
Monorepo sub-packages that must stay privatePath-scoped visibility inside one repo, already expressible via private_paths.Path-glob policy + partial-cloak of the docket for non-privileged readers.Alpha
In-memory / node isolates; no OS filesystem bottleneckContent-addressed engine (already byte-blob) + in-memory execution path.Engine consumes snapshots with no materialized tree; agent-memory adapter.Planned
Git worktrees let an agent hold main hostageOperate on snapshots, not checkouts. No working tree to lock.Already solved by design. Verified by the no-materialized-tree constraint.Shipped
+
+ +
    +
  1. +
    01
    +
    +

    PhaseChange ingestion

    +

    +

  2. Adapters for git and Jujutsu that read snapshots and produce a Change. This unblocks every later phase because the engine stops assuming two demo directories.
  3. +

    + + +
  4. +
    02
    +
    +

    PhaseFinish visibility

    +

    +

  5. Per-author allowlists, branch-level privacy, and partial-cloak of the docket. Promotes VisibilityPolicy from alpha to real.
  6. +

    + + +
  7. +
    03
    +
    +

    PhaseEmbargo engine

    +

    +

  8. Date scheduling plus quiet distribution to maintainers before the public diff. The Git project and GitHub Security Advisories do this by hand today OSSF guide Git embargo process; Oot makes it a verdict.
  9. +

    + + +
  10. +
    04
    +
    +

    PhaseIn-memory execution

    +

    +

  11. Run the engine on snapshots with no materialized tree, for agents. Theo's cited APFS gap (30–40s macOS vs 3–12s Linux for clean installs) is the motivation, though those are his benchmarks, not independently re-run here. The fix is architectural: never touch the disk.
  12. +

    + + +
  13. +
    05
    +
    +

    PhaseHosted intent model

    +

    +

  14. The paid service: semantic scoring of intent and embargo distribution. The open runtime stays the gate; the model is the clerk.
  15. +

    + + +
+ +
+

The shape of the thing

+

Oot is not becoming a new version control system and it is not becoming a filesystem. It is the governance layer that answers the questions git and Jujutsu were never built to answer: who may see this change, what it means, and when it may go public. The original five minute meaning-only pitch is now one axis of three, and governance leads.

+

Everything in Part III is an extension of the seed already on disk. The engine, the docket, and the policy loader are real. What is missing is ingestion, richer visibility, the embargo scheduler, and the in-memory path.

+
+
+ + +
+
+

Decision · committed

+

What we committed to

+

Your and Theo's issues outrank the original five minute pitch. The meaning-only thesis is now one axis of three.

+
+
    +
  1. +
    i
    +
    +

    CallGovernance leads, meaning follows

    +

    +

  2. The spine is visibility and embargo: who may see a change and when it may ship. Semantic meaning stays a first-class axis, not the whole product. README and CONTRIBUTING now lead with this.
  3. +

    + + +
  4. +
    01
    +
    +

    CallStateful, not only stateless

    +

    +

  5. Embargo and private branches need Oot to hold and release over time. That makes Oot a coordinator, not only an evaluator. The "hosted Phase 05" is now core, not an afterthought.
  6. +

    + + +
  7. +
    02
    +
    +

    CallBuild order re-ranked

    +

    +

  8. Change ingestion, then visibility policy (the spine), then meaning disputes, then docket, then in-memory, then hosted model. The original meaning-first order is retired.
  9. +

    + + +
+
+

The call, in one line

+

Oot is the governance layer for changes. The five minute "settles meaning" sketch was a starting point, not the destination. Visibility and embargo lead; meaning follows; cryptography and the VCS stay delegated.

+
+
+ + +
+
+

Part IV · References

+

Source map

+

Every figure above is verbatim from these.

+
+

Problem evidence

+
    +
  • AgenticFlict, arXiv:2604.03551 (2026): 27.67% of AI-agent PRs hit merge conflicts.
  • +
  • Brindescu et al., Empirical Software Engineering (2020): semantic conflicts 26× more likely to be buggy.
  • +
  • Anthropic, multiagent systems (2026-08-13): agents with conflicting goals escalate into turf wars.
  • +
+

Tooling reality

+
    +
  • Jujutsu (jj-vcs.dev): snapshot working copy, bookmarks not branches, Git-compatible.
  • +
  • git-crypt / git-secret: transparent per-file encryption, not policy or visibility.
  • +
  • GitHub Security Advisories + OSSF guide + Git embargo process: embargoed disclosure exists, locked in platforms.
  • +
+
+ AgenticFlict arXiv:2604.03551 + Brindescu Empir SE 2020 + Anthropic multiagent 2026 + Jujutsu jj-vcs.dev + git-crypt AGWA + OSSF vuln guide +
+

Figures: verbatim from sources · APFS benchmark attributed to Theo, not independently re-run.

+
+ +
+ +
+
Oot vision document · 2026-08-16 · Internal use · All figures verbatim
+
+ + + + diff --git a/src/adapter/git.rs b/src/adapter/git.rs new file mode 100644 index 0000000..d7fcc35 --- /dev/null +++ b/src/adapter/git.rs @@ -0,0 +1,295 @@ +//! Native Git adapter for extracting in-memory snapshots and adjudicating 3-way merges. + +use crate::change::{Change, Snapshot, Source}; +use crate::dispute::{Docket, Severity, Verdict}; +use crate::engine::Engine; +use crate::policy::MeaningPolicy; +use crate::visibility::VisibilityPolicy; +use anyhow::{anyhow, Context, Result}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Adapter for interacting directly with a Git repository. +#[derive(Debug, Clone)] +pub struct GitAdapter { + repo_root: PathBuf, +} + +/// Configuration options for 3-way Git merge adjudication. +#[derive(Debug, Default, Clone)] +pub struct GitAdjudicateOptions { + /// Explicit override for the common merge base commit SHA. + pub custom_merge_base: Option, + /// Custom identifier or name for the change. + pub change_name: Option, + /// Declared intent or purpose of the change. + pub intent: Option, +} + +impl GitAdapter { + /// Create a new `GitAdapter` rooted at `repo_path`. + pub fn new(repo_path: impl AsRef) -> Result { + let path = repo_path.as_ref(); + let output = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(path) + .output() + .with_context(|| format!("Failed to run git in {}", path.display()))?; + + if !output.status.success() { + return Err(anyhow!( + "Not a valid git repository at {}: {}", + path.display(), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let root_str = String::from_utf8(output.stdout)?.trim().to_string(); + Ok(Self { + repo_root: PathBuf::from(root_str), + }) + } + + /// Discover a `GitAdapter` from the current working directory. + pub fn discover() -> Result { + Self::new(".") + } + + /// Returns the absolute path to the repository root. + pub fn repo_root(&self) -> &Path { + &self.repo_root + } + + /// Resolve a Git reference, branch name, or tag into a canonical commit SHA. + pub fn resolve_ref(&self, rev: &str) -> Result { + let output = Command::new("git") + .args(["rev-parse", "--verify", rev]) + .current_dir(&self.repo_root) + .output() + .with_context(|| format!("Failed to resolve git ref '{rev}'"))?; + + if !output.status.success() { + return Err(anyhow!( + "Failed to resolve git ref '{rev}': {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + Ok(String::from_utf8(output.stdout)?.trim().to_string()) + } + + /// Compute the common ancestor commit SHA (merge-base) between two revisions. + pub fn merge_base(&self, ref_a: &str, ref_b: &str) -> Result { + let output = Command::new("git") + .args(["merge-base", ref_a, ref_b]) + .current_dir(&self.repo_root) + .output() + .with_context(|| { + format!("Failed to compute merge-base between '{ref_a}' and '{ref_b}'") + })?; + + if !output.status.success() { + return Err(anyhow!( + "Failed to find merge-base between '{ref_a}' and '{ref_b}': {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + Ok(String::from_utf8(output.stdout)?.trim().to_string()) + } + + /// Extract commit authors across a revision or range (e.g. `base..head`). + pub fn authors(&self, rev_range: &str) -> Result> { + let output = Command::new("git") + .args(["log", "--format=%an", rev_range]) + .current_dir(&self.repo_root) + .output() + .with_context(|| format!("Failed to query authors for range '{rev_range}'"))?; + + if !output.status.success() { + // Return empty list rather than hard failing on single refs with no history + return Ok(Vec::new()); + } + + let text = String::from_utf8(output.stdout)?; + let mut authors: Vec = text + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); + authors.sort(); + authors.dedup(); + Ok(authors) + } + + /// Extract an in-memory `Snapshot` directly from Git object storage without touching the working tree. + pub fn extract_snapshot(&self, rev: &str) -> Result { + let output = Command::new("git") + .args(["ls-tree", "-r", "-z", rev]) + .current_dir(&self.repo_root) + .output() + .with_context(|| format!("Failed to list tree for '{rev}'"))?; + + if !output.status.success() { + return Err(anyhow!( + "Failed to list git tree for '{rev}': {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let raw = output.stdout; + let mut files = HashMap::new(); + + // `git ls-tree -r -z` emits null-terminated records: " \t\0" + for entry in raw.split(|&b| b == 0) { + if entry.is_empty() { + continue; + } + let entry_str = String::from_utf8_lossy(entry); + if let Some((meta, path)) = entry_str.split_once('\t') { + let parts: Vec<&str> = meta.split_whitespace().collect(); + if parts.len() >= 3 && parts[1] == "blob" { + let blob_sha = parts[2]; + let blob_output = Command::new("git") + .args(["cat-file", "-p", blob_sha]) + .current_dir(&self.repo_root) + .output() + .with_context(|| format!("Failed to fetch blob {blob_sha} for {path}"))?; + + if blob_output.status.success() { + let content = String::from_utf8_lossy(&blob_output.stdout).into_owned(); + files.insert(path.to_string(), content); + } + } + } + } + + Ok(Snapshot { files }) + } + + /// Adjudicate a 3-way Git merge between `base_ref` and `head_ref`. + /// + /// Computes the merge-base $M = \text{merge-base}(base\_ref, head\_ref)$, extracts + /// $S_M$, $S_{base}$, and $S_{head}$, performs 3-way semantic conflict analysis, + /// checks visibility policies, and produces a finalized [`Docket`]. + pub fn adjudicate_3way( + &self, + base_ref: &str, + head_ref: &str, + engine: &Engine, + meaning_policy: &MeaningPolicy, + visibility_policy: &VisibilityPolicy, + options: &GitAdjudicateOptions, + ) -> Result { + let base_sha = self.resolve_ref(base_ref)?; + let head_sha = self.resolve_ref(head_ref)?; + + let merge_base_sha = match &options.custom_merge_base { + Some(mb) => self.resolve_ref(mb)?, + None => self.merge_base(&base_sha, &head_sha)?, + }; + + let mb_snapshot = self.extract_snapshot(&merge_base_sha)?; + let base_snapshot = self.extract_snapshot(&base_sha)?; + let head_snapshot = self.extract_snapshot(&head_sha)?; + + let mut authors = self.authors(&format!("{merge_base_sha}..{head_sha}"))?; + if authors.is_empty() { + authors = vec!["@git-author".to_string()]; + } + + let change_label = options + .change_name + .clone() + .unwrap_or_else(|| format!("{base_ref}..{head_ref}")); + + let change = Change { + name: change_label, + source: Source::Git, + base_ref: format!("{base_ref}@{base_sha:.7}"), + head_ref: format!("{head_ref}@{head_sha:.7}"), + base: mb_snapshot.clone(), + head: head_snapshot.clone(), + authors: authors.clone(), + intent: options.intent.clone(), + }; + + let mut disputes = engine.diff_3way(&mb_snapshot, &base_snapshot, &head_snapshot)?; + let vis_disputes = visibility_policy.check(&change); + let cloaked = vis_disputes + .iter() + .any(|d| d.kind == crate::dispute::Kind::Visibility && d.severity == Severity::High); + disputes.extend(vis_disputes); + + let verdict = if cloaked { + Verdict::Cloaked + } else if visibility_policy.embargo_until.is_some() { + Verdict::Embargoed + } else { + meaning_policy.evaluate(&disputes) + }; + + let mut touched_paths: Vec = base_snapshot + .files + .keys() + .chain(head_snapshot.files.keys()) + .filter(|p| base_snapshot.files.get(*p) != head_snapshot.files.get(*p)) + .cloned() + .collect(); + touched_paths.sort(); + touched_paths.dedup(); + + let scope = if touched_paths.is_empty() { + "no files changed".to_string() + } else { + touched_paths.join(", ") + }; + + let docket = Docket { + change: change.name, + source: format!("git: {merge_base_sha:.7} (base) vs {head_sha:.7} (head)"), + base: change.base_ref, + head: change.head_ref, + disputes, + scope, + authors, + verdict, + embargo: visibility_policy.embargo_note(), + }; + + Ok(docket) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_git_adapter_discover_in_repo() { + let adapter = GitAdapter::discover(); + assert!(adapter.is_ok(), "Expected discovery in git repository"); + let adapter = adapter.unwrap(); + assert!(adapter.repo_root().exists()); + } + + #[test] + fn test_git_adapter_resolve_head() { + let adapter = GitAdapter::discover().expect("git repo"); + let head_sha = adapter.resolve_ref("HEAD"); + assert!(head_sha.is_ok()); + let sha = head_sha.unwrap(); + assert_eq!(sha.len(), 40, "SHA should be 40 characters"); + } + + #[test] + fn test_git_adapter_extract_snapshot_head() { + let adapter = GitAdapter::discover().expect("git repo"); + let snapshot = adapter.extract_snapshot("HEAD"); + assert!(snapshot.is_ok()); + let snap = snapshot.unwrap(); + assert!(snap.files.contains_key("Cargo.toml")); + assert!(snap.files.contains_key("src/lib.rs") || snap.files.contains_key("src/main.rs")); + } +} diff --git a/src/adapter/mod.rs b/src/adapter/mod.rs new file mode 100644 index 0000000..d59aee2 --- /dev/null +++ b/src/adapter/mod.rs @@ -0,0 +1,5 @@ +//! VCS adapters for extracting snapshots directly from version control systems. + +pub mod git; + +pub use git::{GitAdapter, GitAdjudicateOptions}; diff --git a/src/change.rs b/src/change.rs new file mode 100644 index 0000000..6e86dfa --- /dev/null +++ b/src/change.rs @@ -0,0 +1,116 @@ +//! Change models and snapshot representations. +//! +//! A [`Change`] is the fundamental unit of adjudication in Oot: +//! a content-addressed delta between two snapshots with declared intent, +//! visibility policy, and authorship. + +use std::collections::HashMap; + +/// The origin system or VCS from which a change was ingested. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Source { + /// Ingested from a Git repository or reference. + Git, + /// Ingested from a Jujutsu (jj) workspace or bookmark. + Jj, + /// Ingested directly from an in-memory buffer or agent runtime. + Memory, +} + +impl Source { + /// Return the canonical string identifier for this source. + pub fn as_str(&self) -> &'static str { + match self { + Source::Git => "git", + Source::Jj => "jj", + Source::Memory => "memory", + } + } +} + +impl std::str::FromStr for Source { + type Err = anyhow::Error; + + fn from_str(s: &str) -> anyhow::Result { + match s.to_ascii_lowercase().as_str() { + "git" => Ok(Source::Git), + "jj" | "jujutsu" => Ok(Source::Jj), + "memory" | "mem" => Ok(Source::Memory), + other => anyhow::bail!("unknown source: {}", other), + } + } +} + +/// A snapshot is a mapping of relative file paths to their contents. +/// +/// Oot never assumes these files exist on a physical filesystem; +/// they can be ingested from git, Jujutsu, or an agent's memory isolate. +#[derive(Debug, Clone, Default)] +pub struct Snapshot { + /// Map of file path (relative to repo root) to UTF-8 file content. + pub files: HashMap, +} + +/// A Change is the core unit Oot adjudicates: a content-addressed delta +/// between two snapshots, with declared intent, visibility, and authorship. +#[derive(Debug, Clone)] +pub struct Change { + /// Human-readable identifier or branch/change name. + pub name: String, + /// VCS or execution environment origin. + pub source: Source, + /// Identifier or commit/tree hash for the base snapshot. + pub base_ref: String, + /// Identifier or commit/tree hash for the head snapshot. + pub head_ref: String, + /// Base snapshot representing the state before the change. + pub base: Snapshot, + /// Head snapshot representing the state after the change. + pub head: Snapshot, + /// List of author handles or agent identities (e.g. `@kriday`, `@agent-7`). + pub authors: Vec, + /// Declared intent, purpose, or summary of the change. + pub intent: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_source_parsing_and_str() { + assert_eq!("git".parse::().unwrap(), Source::Git); + assert_eq!("jj".parse::().unwrap(), Source::Jj); + assert_eq!("jujutsu".parse::().unwrap(), Source::Jj); + assert_eq!("memory".parse::().unwrap(), Source::Memory); + assert_eq!("mem".parse::().unwrap(), Source::Memory); + assert!("invalid".parse::().is_err()); + + assert_eq!(Source::Git.as_str(), "git"); + assert_eq!(Source::Jj.as_str(), "jj"); + assert_eq!(Source::Memory.as_str(), "memory"); + } + + #[test] + fn test_change_and_snapshot_creation() { + let mut snap = Snapshot::default(); + snap.files + .insert("src/lib.rs".into(), "pub fn test() {}".into()); + + let change = Change { + name: "test-change".into(), + source: Source::Git, + base_ref: "main".into(), + head_ref: "feature".into(), + base: Snapshot::default(), + head: snap, + authors: vec!["@alice".into()], + intent: Some("implement test feature".into()), + }; + + assert_eq!(change.name, "test-change"); + assert_eq!(change.intent.as_deref(), Some("implement test feature")); + assert_eq!(change.authors.len(), 1); + assert_eq!(change.head.files.len(), 1); + } +} diff --git a/src/dispute.rs b/src/dispute.rs new file mode 100644 index 0000000..b09646a --- /dev/null +++ b/src/dispute.rs @@ -0,0 +1,253 @@ +//! Dispute models and docket adjudication records. +//! +//! A [`Dispute`] represents a point of disagreement (either structural meaning +//! or visibility violation). A [`Docket`] is the complete, rendered adjudication +//! record containing disputes, verdict, scope, and embargo metadata. + +use serde::{Deserialize, Serialize}; + +/// The category of dispute raised during adjudication. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Kind { + /// Semantic or structural code dispute (e.g. diverging function implementation). + Meaning, + /// Governance or visibility dispute (e.g. private file touched, private branch referenced). + Visibility, +} + +/// The severity level of a dispute. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Severity { + /// Low severity notice; informative only. + Low, + /// Requires explicit review by human maintainers. + Review, + /// High severity violation; blocks automated acceptance or cloaks the change. + High, +} + +impl Severity { + /// Return the canonical lowercase string for this severity level. + pub fn as_str(&self) -> &'static str { + match self { + Severity::Low => "low", + Severity::Review => "review", + Severity::High => "high", + } + } +} + +/// An individual dispute identified between snapshots or policies. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Dispute { + /// Unique identifier for the dispute (e.g. `"D001"`, `"V001"`). + pub id: String, + /// File path and optional row/line location (e.g. `"src/lib.rs:42"`). + pub location: String, + /// Category of dispute: meaning or visibility. + pub kind: Kind, + /// Assessed severity level. + pub severity: Severity, + /// Human-readable explanation of the dispute. + pub detail: String, +} + +/// The final adjudication verdict for a change. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Verdict { + /// Change passed checks or is within acceptable review thresholds. + Adjudicated, + /// Change was blocked due to severe meaning disputes. + Blocked, + /// Change is held under an active embargo schedule. + Embargoed, + /// Change touched private/restricted paths or branches and must be cloaked. + Cloaked, +} + +/// The full adjudication record for one Change. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Docket { + /// Identifier or name of the change. + pub change: String, + /// Source environment (git, jj, memory). + pub source: String, + /// Base reference or directory. + pub base: String, + /// Head reference or directory. + pub head: String, + /// Collection of detected disputes. + pub disputes: Vec, + /// Stated scope or intent of the change. + pub scope: String, + /// Change author handles or agent identifiers. + pub authors: Vec, + /// Resulting adjudication verdict. + pub verdict: Verdict, + /// Embargo notice string, if an embargo is in effect. + pub embargo: Option, +} + +impl Docket { + /// Return the number of meaning-related disputes. + pub fn meaning_count(&self) -> usize { + self.disputes + .iter() + .filter(|d| d.kind == Kind::Meaning) + .count() + } + + /// Return the number of visibility-related disputes. + pub fn visibility_count(&self) -> usize { + self.disputes + .iter() + .filter(|d| d.kind == Kind::Visibility) + .count() + } + + /// Return the number of disputes requiring human review. + pub fn review_count(&self) -> usize { + self.disputes + .iter() + .filter(|d| { + d.kind == Kind::Meaning && matches!(d.severity, Severity::Review | Severity::High) + }) + .count() + } + + /// Check if any dispute in the docket requires human review. + pub fn requires_review(&self) -> bool { + self.review_count() > 0 + } + + /// Render the docket as human-readable terminal output. + pub fn render(&self) -> String { + let mut out = String::new(); + out.push_str(" OOT DOCKET\n"); + out.push_str(" ─────────────────────────────────────────\n"); + out.push_str(&format!(" change: {}\n", self.change)); + out.push_str(&format!(" from: {}\n", self.source)); + out.push_str(&format!(" base: {}\n", self.base)); + out.push_str(&format!(" head: {}\n", self.head)); + out.push('\n'); + out.push_str(&format!( + " meaning: {} disputes detected\n", + self.meaning_count() + )); + if self.visibility_count() > 0 { + out.push_str(&format!( + " visibility: {} private path(s)\n", + self.visibility_count() + )); + } + out.push('\n'); + out.push_str(&format!(" scope: {}\n", self.scope)); + out.push_str(&format!(" authors: {}\n", self.authors.join(", "))); + out.push('\n'); + if self.disputes.is_empty() { + out.push_str(" dispute: none\n"); + } else { + for (i, d) in self.disputes.iter().enumerate() { + let tag = match d.kind { + Kind::Meaning => "meaning", + Kind::Visibility => "visibility", + }; + out.push_str(&format!( + " dispute-{:02}: {} ({}) [{}]\n", + i + 1, + d.detail, + d.location, + tag + )); + } + } + out.push('\n'); + let label = match self.verdict { + Verdict::Adjudicated => "ADJUDICATED", + Verdict::Blocked => "BLOCKED", + Verdict::Embargoed => "EMBARGOED", + Verdict::Cloaked => "CLOAKED", + }; + out.push_str(&format!(" verdict: ▶ {} ", label)); + let mut notes = Vec::new(); + let reviews = self.review_count(); + if reviews > 0 { + notes.push(format!("{} requires review", reviews)); + } + if self.verdict == Verdict::Cloaked { + notes.push("cloaked".to_string()); + } + if self.verdict == Verdict::Embargoed { + notes.push("held for maintainers".to_string()); + } + if notes.is_empty() { + out.push('\n'); + } else { + out.push_str(". "); + out.push_str(¬es.join(", ")); + out.push('\n'); + } + if let Some(e) = &self.embargo { + out.push_str(&format!(" embargo: {}\n", e)); + } + out.push('\n'); + out.push_str(" [a]ccept · [r]eject · [d]ocket\n"); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_severity_as_str() { + assert_eq!(Severity::Low.as_str(), "low"); + assert_eq!(Severity::Review.as_str(), "review"); + assert_eq!(Severity::High.as_str(), "high"); + } + + #[test] + fn test_docket_render_and_counts() { + let docket = Docket { + change: "feature/auth".into(), + source: "git".into(), + base: "main".into(), + head: "feature/auth".into(), + disputes: vec![ + Dispute { + id: "D001".into(), + location: "src/lib.rs:1".into(), + kind: Kind::Meaning, + severity: Severity::Review, + detail: "changed login function".into(), + }, + Dispute { + id: "V001".into(), + location: ".env".into(), + kind: Kind::Visibility, + severity: Severity::High, + detail: "private path .env touched".into(), + }, + ], + scope: "auth refactor".into(), + authors: vec!["@alice".into(), "@bob".into()], + verdict: Verdict::Adjudicated, + embargo: Some("patch held for maintainers until 2026-12-31".into()), + }; + + assert_eq!(docket.meaning_count(), 1); + assert_eq!(docket.visibility_count(), 1); + assert_eq!(docket.review_count(), 1); + assert!(docket.requires_review()); + + let rendered = docket.render(); + assert!(rendered.contains("OOT DOCKET")); + assert!(rendered.contains("feature/auth")); + assert!(rendered.contains("1 requires review")); + assert!(rendered.contains("2026-12-31")); + } +} diff --git a/src/docket.rs b/src/docket.rs new file mode 100644 index 0000000..70476b3 --- /dev/null +++ b/src/docket.rs @@ -0,0 +1,134 @@ +//! Serialization and persistence helpers for Oot dockets. + +use crate::dispute::Docket; +use std::path::Path; + +/// Serialize an adjudication [`Docket`] to a pretty-printed JSON string. +pub fn to_json(d: &Docket) -> anyhow::Result { + Ok(serde_json::to_string_pretty(d)?) +} + +/// Deserialize an adjudication [`Docket`] from a JSON string. +pub fn from_json(s: &str) -> anyhow::Result { + Ok(serde_json::from_str(s)?) +} + +/// Serialize an adjudication [`Docket`] to a pretty-printed TOML string. +pub fn to_toml(d: &Docket) -> anyhow::Result { + Ok(toml::to_string_pretty(d)?) +} + +/// Deserialize an adjudication [`Docket`] from a TOML string. +pub fn from_toml(s: &str) -> anyhow::Result { + Ok(toml::from_str(s)?) +} + +/// Save an adjudication [`Docket`] to disk as formatted JSON. +pub fn save(d: &Docket, path: &Path) -> anyhow::Result<()> { + let text = to_json(d)?; + std::fs::write(path, text)?; + Ok(()) +} + +/// Save an adjudication [`Docket`] to disk as formatted JSON (explicit alias for [`save`]). +pub fn save_json(d: &Docket, path: &Path) -> anyhow::Result<()> { + save(d, path) +} + +/// Save an adjudication [`Docket`] to disk as formatted TOML. +pub fn save_toml(d: &Docket, path: &Path) -> anyhow::Result<()> { + let text = to_toml(d)?; + std::fs::write(path, text)?; + Ok(()) +} + +/// Load an adjudication [`Docket`] from a JSON file on disk. +pub fn load_json(path: &Path) -> anyhow::Result { + let text = std::fs::read_to_string(path)?; + from_json(&text) +} + +/// Load an adjudication [`Docket`] from a TOML file on disk. +pub fn load_toml(path: &Path) -> anyhow::Result { + let text = std::fs::read_to_string(path)?; + from_toml(&text) +} + +/// Load an adjudication [`Docket`] from a file on disk (supporting both JSON and TOML formats). +pub fn load(path: &Path) -> anyhow::Result { + let text = std::fs::read_to_string(path)?; + if let Ok(d) = from_json(&text) { + Ok(d) + } else { + from_toml(&text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dispute::Verdict; + + #[test] + fn test_docket_save_and_load_roundtrip() { + let temp_dir = std::env::temp_dir(); + let path = temp_dir.join("oot_test_docket.json"); + + let original = Docket { + change: "feature/test-docket".into(), + source: "jj".into(), + base: "main".into(), + head: "feature/test-docket".into(), + disputes: vec![], + scope: "testing save and load".into(), + authors: vec!["@tester".into()], + verdict: Verdict::Adjudicated, + embargo: None, + }; + + save(&original, &path).expect("failed to save docket"); + let loaded = load(&path).expect("failed to load docket"); + + assert_eq!(loaded.change, original.change); + assert_eq!(loaded.source, original.source); + assert_eq!(loaded.scope, original.scope); + assert_eq!(loaded.verdict, original.verdict); + + let _ = std::fs::remove_file(path); + } + + #[test] + fn test_docket_toml_and_json_serialization() { + let original = Docket { + change: "feature/toml-test".into(), + source: "git".into(), + base: "main".into(), + head: "feature/toml-test".into(), + disputes: vec![], + scope: "toml format".into(), + authors: vec!["@coder".into()], + verdict: Verdict::Embargoed, + embargo: Some("patch held for maintainers until 2026-12-31".into()), + }; + + // JSON string roundtrip + let json_str = to_json(&original).unwrap(); + let from_json_docket = from_json(&json_str).unwrap(); + assert_eq!(from_json_docket.change, original.change); + assert_eq!(from_json_docket.verdict, original.verdict); + + // TOML string roundtrip + let toml_str = to_toml(&original).unwrap(); + let from_toml_docket = from_toml(&toml_str).unwrap(); + assert_eq!(from_toml_docket.change, original.change); + assert_eq!(from_toml_docket.verdict, original.verdict); + assert_eq!(from_toml_docket.embargo, original.embargo); + + // File save/load TOML + let temp_path = std::env::temp_dir().join("oot_test_docket.toml"); + save_toml(&original, &temp_path).unwrap(); + let loaded = load(&temp_path).unwrap(); + assert_eq!(loaded.change, original.change); + let _ = std::fs::remove_file(temp_path); + } +} diff --git a/src/engine/language.rs b/src/engine/language.rs new file mode 100644 index 0000000..71f64d6 --- /dev/null +++ b/src/engine/language.rs @@ -0,0 +1,227 @@ +//! Per-language grammar configuration for the structural engine. +//! +//! The engine extracts named functions from a parse tree. Most languages +//! expose them as a single node kind carrying a `name` field. JavaScript +//! is the odd one out: a named arrow function (`const f = () => {}`) keeps +//! its callable on the `value` field of a `variable_declarator` and its name +//! on the declarator itself. That relationship is captured by +//! [`LangConfig::wrapped_functions`]. + +use tree_sitter::{Language, Node}; + +/// Callable node kinds that can sit on the right of a name. +const CALLABLE_KINDS: &[&str] = &[ + "arrow_function", + "function_expression", + "generator_function", +]; + +/// How to disambiguate a function's map key when bare names can collide. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Qualifier { + /// Key is the bare function name. + None, + /// Prefix with the receiver type, e.g. Go methods become `(*T).name`. + Receiver, + /// Prefix with the enclosing impl block, e.g. Rust methods become + /// `(A).name` or `(Trait for A).name`. + EnclosingImpl, +} + +/// A directly named function or method node kind. +#[derive(Debug, Clone, Copy)] +pub struct FunctionKind { + /// The node kind, e.g. `"function_item"` or `"method_declaration"`. + pub node_kind: &'static str, + /// How to qualify the key when names could collide. + pub qualifier: Qualifier, +} + +/// A wrapper node that carries a callable in one field and the function's +/// name in another. JavaScript's `const f = () => {}` keeps the callable on +/// the `value` field of a `variable_declarator`; `f = () => {}` keeps it on +/// the `right` field of an `assignment_expression`. +#[derive(Debug, Clone, Copy)] +pub struct WrappedFunction { + /// The wrapper node kind (e.g. `"variable_declarator"`). + pub node_kind: &'static str, + /// Field on the wrapper that holds the function's name. + pub name_field: &'static str, + /// Node kinds the name field may hold. Anything else (member access, + /// destructuring patterns) is not a named function. + pub name_kinds: &'static [&'static str], + /// Field on the wrapper that holds the callable. + pub body_field: &'static str, + /// Callable node kinds that count as a named function. + pub body_kinds: &'static [&'static str], +} + +/// Static description of one language the engine can diff. +#[derive(Debug, Clone)] +pub struct LangConfig { + /// Canonical language name (e.g. `"rust"`, `"python"`). + pub name: &'static str, + /// File extensions routed to this grammar, without the leading dot. + pub extensions: &'static [&'static str], + /// The tree-sitter grammar. + pub language: Language, + /// Node kinds that are directly named functions or methods. Each carries + /// the function's identifier in a `name` field. + pub function_kinds: &'static [FunctionKind], + /// Wrapper node kinds that carry a callable in a field while the + /// function's name sits on the wrapper itself. + pub wrapped_functions: &'static [WrappedFunction], +} + +impl LangConfig { + /// Whether `path` should be parsed with this grammar. + pub fn supports(&self, path: &str) -> bool { + path.rsplit_once('.') + .is_some_and(|(_, ext)| self.extensions.contains(&ext.to_ascii_lowercase().as_str())) + } + + /// The map key for a directly named function node, qualified per + /// `kind.qualifier` so same-named functions stay distinct. + pub fn function_key(&self, kind: &FunctionKind, node: Node, source: &str) -> Option { + let name_node = node.child_by_field_name("name")?; + let name = name_node.utf8_text(source.as_bytes()).ok()?.to_string(); + match kind.qualifier { + Qualifier::None => Some(name), + Qualifier::Receiver => { + let receiver = node.child_by_field_name("receiver")?; + let mut cursor = receiver.walk(); + let decl = receiver + .children(&mut cursor) + .find(|c| c.kind() == "parameter_declaration")?; + let ty = decl.child_by_field_name("type")?; + let ty_text = ty.utf8_text(source.as_bytes()).ok()?; + Some(format!("({}).{}", ty_text, name)) + } + Qualifier::EnclosingImpl => { + let mut parent = node.parent(); + while let Some(anc) = parent { + if anc.kind() == "impl_item" { + let ty = anc.child_by_field_name("type")?; + let ty_text = ty.utf8_text(source.as_bytes()).ok()?; + let scope = match anc.child_by_field_name("trait") { + Some(t) => { + format!("{} for {}", t.utf8_text(source.as_bytes()).ok()?, ty_text) + } + None => ty_text.to_string(), + }; + return Some(format!("({}).{}", scope, name)); + } + parent = anc.parent(); + } + Some(name) + } + } + } +} + +/// Registry of every language the structural engine understands. +pub fn registry() -> Vec { + vec![ + LangConfig { + name: "rust", + extensions: &["rs"], + language: tree_sitter_rust::LANGUAGE.into(), + function_kinds: &[FunctionKind { + node_kind: "function_item", + qualifier: Qualifier::EnclosingImpl, + }], + wrapped_functions: &[], + }, + LangConfig { + name: "python", + extensions: &["py"], + language: tree_sitter_python::LANGUAGE.into(), + function_kinds: &[FunctionKind { + node_kind: "function_definition", + qualifier: Qualifier::None, + }], + wrapped_functions: &[], + }, + LangConfig { + name: "go", + extensions: &["go"], + language: tree_sitter_go::LANGUAGE.into(), + function_kinds: &[ + FunctionKind { + node_kind: "function_declaration", + qualifier: Qualifier::None, + }, + FunctionKind { + node_kind: "method_declaration", + qualifier: Qualifier::Receiver, + }, + ], + wrapped_functions: &[], + }, + LangConfig { + name: "javascript", + extensions: &["js", "mjs", "cjs", "jsx"], + language: tree_sitter_javascript::LANGUAGE.into(), + function_kinds: &[ + FunctionKind { + node_kind: "function_declaration", + qualifier: Qualifier::None, + }, + FunctionKind { + node_kind: "generator_function_declaration", + qualifier: Qualifier::None, + }, + FunctionKind { + node_kind: "method_definition", + qualifier: Qualifier::None, + }, + ], + wrapped_functions: &[ + WrappedFunction { + node_kind: "variable_declarator", + name_field: "name", + name_kinds: &["identifier"], + body_field: "value", + body_kinds: CALLABLE_KINDS, + }, + WrappedFunction { + node_kind: "assignment_expression", + name_field: "left", + name_kinds: &["identifier"], + body_field: "right", + body_kinds: CALLABLE_KINDS, + }, + WrappedFunction { + node_kind: "field_definition", + name_field: "property", + name_kinds: &["property_identifier", "private_property_identifier"], + body_field: "value", + body_kinds: CALLABLE_KINDS, + }, + ], + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extension_routing() { + let langs = registry(); + assert!(langs.iter().any(|l| l.supports("src/lib.rs"))); + assert!(langs.iter().any(|l| l.supports("app.py"))); + assert!(langs.iter().any(|l| l.supports("server.go"))); + assert!(langs.iter().any(|l| l.supports("index.js"))); + assert!(langs.iter().any(|l| l.supports("index.mjs"))); + assert!(langs.iter().any(|l| l.supports("index.cjs"))); + assert!(langs.iter().any(|l| l.supports("component.jsx"))); + + let supported = |path: &str| langs.iter().any(|l| l.supports(path)); + assert!(!supported("README.md")); + assert!(!supported("config.toml")); + assert!(!supported("style.css")); + assert!(!supported("run.sh")); + } +} diff --git a/src/engine/mod.rs b/src/engine/mod.rs new file mode 100644 index 0000000..60dde94 --- /dev/null +++ b/src/engine/mod.rs @@ -0,0 +1,701 @@ +//! Structural difference engine using Tree-sitter. +//! +//! Compares snapshots semantically across function definitions +//! rather than line-by-line diffs. + +use crate::change::Snapshot; +use crate::dispute::{Dispute, Kind, Severity}; +use crate::engine::language::{registry, LangConfig}; +use std::collections::HashMap; +use tree_sitter::{Language, Node, Parser, Tree}; + +pub mod language; + +/// Structural difference engine for code snapshots. +pub struct Engine { + languages: Vec, +} + +impl Engine { + /// Create a new structural diff engine with grammar support for every + /// language in the [`registry`]. + pub fn new() -> anyhow::Result { + Ok(Engine { + languages: registry(), + }) + } + + /// The grammar configuration for `path`, if Oot can diff that language. + fn config_for(&self, path: &str) -> Option<&LangConfig> { + self.languages.iter().find(|c| c.supports(path)) + } + + /// Compare two snapshots and report Meaning disputes: functions that + /// changed, were added, or were removed between base and head. + pub fn diff_snapshots(&self, base: &Snapshot, head: &Snapshot) -> anyhow::Result> { + let mut parser = Parser::new(); + let mut disputes = Vec::new(); + let mut n = 1; + + let mut paths: Vec<&String> = base.files.keys().chain(head.files.keys()).collect(); + paths.sort(); + paths.dedup(); + + for path in paths { + let Some(config) = self.config_for(path) else { + continue; + }; + let base_src = base.files.get(path); + let head_src = head.files.get(path); + + match (base_src, head_src) { + (Some(b), Some(h)) => { + let base_fns = extract_functions( + parse_source(&mut parser, &config.language, b).as_ref(), + b, + config, + ); + let head_fns = extract_functions( + parse_source(&mut parser, &config.language, h).as_ref(), + h, + config, + ); + for (name, (h_src, h_row)) in &head_fns { + match base_fns.get(name) { + Some((b_src, _)) if b_src != h_src => { + disputes.push(meaning( + &mut n, + path, + *h_row, + format!("both sides changed `{}`", name), + Severity::Review, + )); + } + None => { + disputes.push(meaning( + &mut n, + path, + *h_row, + format!("added function `{}`", name), + Severity::Review, + )); + } + _ => {} + } + } + for name in base_fns.keys() { + if !head_fns.contains_key(name) { + disputes.push(meaning( + &mut n, + path, + 0, + format!("removed function `{}`", name), + Severity::Review, + )); + } + } + } + (Some(_), None) => { + disputes.push(meaning( + &mut n, + path, + 0, + "file removed".to_string(), + Severity::Review, + )); + } + (None, Some(_)) => { + disputes.push(meaning( + &mut n, + path, + 0, + "file added".to_string(), + Severity::Review, + )); + } + (None, None) => {} + } + } + Ok(disputes) + } + + /// Perform a 3-way semantic diff between a common merge-base ancestor, + /// the target (ours) branch, and the incoming (theirs/head) branch. + pub fn diff_3way( + &self, + base: &Snapshot, + ours: &Snapshot, + theirs: &Snapshot, + ) -> anyhow::Result> { + let mut parser = Parser::new(); + let mut disputes = Vec::new(); + let mut n = 1; + + let mut paths: Vec<&String> = base + .files + .keys() + .chain(ours.files.keys()) + .chain(theirs.files.keys()) + .collect(); + paths.sort(); + paths.dedup(); + + for path in paths { + let Some(config) = self.config_for(path) else { + continue; + }; + let b_file = base.files.get(path); + let o_file = ours.files.get(path); + let t_file = theirs.files.get(path); + + match (b_file, o_file, t_file) { + // File exists in all three + (Some(b_src), Some(o_src), Some(t_src)) => { + let b_fns = extract_functions( + parse_source(&mut parser, &config.language, b_src).as_ref(), + b_src, + config, + ); + let o_fns = extract_functions( + parse_source(&mut parser, &config.language, o_src).as_ref(), + o_src, + config, + ); + let t_fns = extract_functions( + parse_source(&mut parser, &config.language, t_src).as_ref(), + t_src, + config, + ); + + let mut all_fn_names: Vec<&String> = b_fns + .keys() + .chain(o_fns.keys()) + .chain(t_fns.keys()) + .collect(); + all_fn_names.sort(); + all_fn_names.dedup(); + + for name in all_fn_names { + let b_fn = b_fns.get(name); + let o_fn = o_fns.get(name); + let t_fn = t_fns.get(name); + + let b_body = b_fn.map(|(s, _)| s.as_str()); + let o_body = o_fn.map(|(s, _)| s.as_str()); + let t_body = t_fn.map(|(s, _)| s.as_str()); + let row = t_fn + .map(|(_, r)| *r) + .or_else(|| o_fn.map(|(_, r)| *r)) + .unwrap_or(0); + + // If both matches base, unchanged + if o_body == b_body && t_body == b_body { + continue; + } + + // Case 1: Unilateral change by incoming (theirs) + if o_body == b_body && t_body != b_body { + match (b_body, t_body) { + (None, Some(_)) => { + disputes.push(meaning( + &mut n, + path, + row, + format!("incoming branch added function `{}`", name), + Severity::Low, + )); + } + (Some(_), None) => { + disputes.push(meaning( + &mut n, + path, + row, + format!("incoming branch removed function `{}`", name), + Severity::Review, + )); + } + (Some(_), Some(_)) => { + disputes.push(meaning( + &mut n, + path, + row, + format!("incoming branch modified function `{}`", name), + Severity::Review, + )); + } + (None, None) => {} + } + } + // Case 2: Unilateral change by target (ours) - no dispute for incoming merge + else if o_body != b_body && t_body == b_body { + continue; + } + // Case 3: Both branches modified relative to base + else { + if o_body == t_body { + // Convergent clean change + continue; + } + // Divergent modifications -> 3-way semantic conflict + match (o_body, t_body) { + (Some(_), Some(_)) => { + disputes.push(meaning( + &mut n, + path, + row, + format!( + "3-way conflict: both branches modified function `{}` differently", + name + ), + Severity::High, + )); + } + (None, Some(_)) => { + disputes.push(meaning( + &mut n, + path, + row, + format!( + "3-way conflict: function `{}` modified in incoming branch but deleted in target", + name + ), + Severity::High, + )); + } + (Some(_), None) => { + disputes.push(meaning( + &mut n, + path, + row, + format!( + "3-way conflict: function `{}` deleted in incoming branch but modified in target", + name + ), + Severity::High, + )); + } + (None, None) => {} + } + } + } + } + // File deleted in target, modified in incoming + (Some(_), None, Some(_)) => { + disputes.push(meaning( + &mut n, + path, + 0, + "3-way conflict: file deleted in target branch but modified in incoming branch".to_string(), + Severity::High, + )); + } + // File modified in target, deleted in incoming + (Some(_), Some(_), None) => { + disputes.push(meaning( + &mut n, + path, + 0, + "3-way conflict: file modified in target branch but deleted in incoming branch".to_string(), + Severity::High, + )); + } + // File added only in incoming + (None, None, Some(_)) => { + disputes.push(meaning( + &mut n, + path, + 0, + "incoming branch added file".to_string(), + Severity::Low, + )); + } + // File deleted in incoming (and base existed) + (Some(_), None, None) => { + // Both deleted it, clean + } + _ => {} + } + } + + Ok(disputes) + } +} + +fn parse_source(parser: &mut Parser, language: &Language, source: &str) -> Option { + parser.set_language(language).ok()?; + parser.parse(source, None) +} + +fn meaning(n: &mut i32, path: &str, row: usize, detail: String, severity: Severity) -> Dispute { + let id = format!("D{:03}", n); + *n += 1; + Dispute { + id, + location: format!("{}:{}", path, row), + kind: Kind::Meaning, + severity, + detail, + } +} + +fn extract_functions( + tree: Option<&Tree>, + source: &str, + config: &LangConfig, +) -> HashMap { + let mut map = HashMap::new(); + let Some(tree) = tree else { + return map; + }; + collect(tree.root_node(), source, &mut map, config); + map +} + +fn collect( + node: Node, + source: &str, + map: &mut HashMap, + config: &LangConfig, +) { + for kind in config.function_kinds { + if node.kind() == kind.node_kind { + if let Some(key) = config.function_key(kind, node, source) { + insert(key, node, source, map); + } + } + } + for wrapped in config.wrapped_functions { + if node.kind() != wrapped.node_kind { + continue; + } + let (Some(name_node), Some(body)) = ( + node.child_by_field_name(wrapped.name_field), + node.child_by_field_name(wrapped.body_field), + ) else { + continue; + }; + if wrapped.name_kinds.contains(&name_node.kind()) + && wrapped.body_kinds.contains(&body.kind()) + { + let key = name_node + .utf8_text(source.as_bytes()) + .unwrap_or("") + .to_string(); + insert(key, body, source, map); + } + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect(child, source, map, config); + } +} + +/// Record a named function under `key`, with `body` as its source. +fn insert(key: String, body: Node, source: &str, map: &mut HashMap) { + if key.is_empty() { + return; + } + let src = body.utf8_text(source.as_bytes()).unwrap_or("").to_string(); + let row = body.start_position().row + 1; + map.insert(key, (src, row)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_engine_diff_functions() { + let eng = Engine::new().unwrap(); + + let mut base = Snapshot::default(); + base.files.insert( + "src/lib.rs".into(), + "pub fn hello() -> &'static str { \"hello\" }\npub fn old_fn() {}\n".into(), + ); + + let mut head = Snapshot::default(); + head.files.insert( + "src/lib.rs".into(), + "pub fn hello() -> &'static str { \"hello world\" }\npub fn new_fn() {}\n".into(), + ); + + let disputes = eng.diff_snapshots(&base, &head).unwrap(); + assert_eq!(disputes.len(), 3); + + let details: Vec<&str> = disputes.iter().map(|d| d.detail.as_str()).collect(); + assert!(details + .iter() + .any(|d| d.contains("both sides changed `hello`"))); + assert!(details + .iter() + .any(|d| d.contains("added function `new_fn`"))); + assert!(details + .iter() + .any(|d| d.contains("removed function `old_fn`"))); + } + + #[test] + fn test_engine_diff_3way_conflict_and_clean() { + let eng = Engine::new().unwrap(); + + let mut base = Snapshot::default(); + base.files.insert( + "src/lib.rs".into(), + "pub fn clean_fn() -> i32 { 0 }\npub fn conflict_fn() -> i32 { 10 }\n".into(), + ); + + let mut ours = Snapshot::default(); + ours.files.insert( + "src/lib.rs".into(), + "pub fn clean_fn() -> i32 { 0 }\npub fn conflict_fn() -> i32 { 20 }\n".into(), + ); + + let mut theirs = Snapshot::default(); + theirs.files.insert( + "src/lib.rs".into(), + "pub fn clean_fn() -> i32 { 99 }\npub fn conflict_fn() -> i32 { 30 }\n".into(), + ); + + let disputes = eng.diff_3way(&base, &ours, &theirs).unwrap(); + assert_eq!(disputes.len(), 2); + + let conflict = disputes + .iter() + .find(|d| d.detail.contains("conflict_fn")) + .unwrap(); + assert_eq!(conflict.severity, Severity::High); + assert!(conflict.detail.contains("3-way conflict")); + + let clean = disputes + .iter() + .find(|d| d.detail.contains("clean_fn")) + .unwrap(); + assert_eq!(clean.severity, Severity::Review); + assert!(clean.detail.contains("incoming branch modified")); + } + + #[test] + fn test_engine_python_function_detection() { + let eng = Engine::new().unwrap(); + + let mut base = Snapshot::default(); + base.files.insert( + "app.py".into(), + "def greet(name):\n return f\"hi {name}\"\n".into(), + ); + + let mut head = Snapshot::default(); + head.files.insert( + "app.py".into(), + "def greet(name):\n return f\"hello {name}\"\n\ndef bye(name):\n return f\"bye {name}\"\n".into(), + ); + + let disputes = eng.diff_snapshots(&base, &head).unwrap(); + assert_eq!(disputes.len(), 2); + + let details: Vec<&str> = disputes.iter().map(|d| d.detail.as_str()).collect(); + assert!(details + .iter() + .any(|d| d.contains("both sides changed `greet`"))); + assert!(details.iter().any(|d| d.contains("added function `bye`"))); + } + + #[test] + fn test_engine_go_function_and_method_detection() { + let eng = Engine::new().unwrap(); + + let mut base = Snapshot::default(); + base.files.insert( + "server.go".into(), + "package main\n\nfunc greet(name string) string {\n\treturn \"hi \" + name\n}\n\ntype counter struct{ n int }\n\nfunc (c *counter) inc() {\n\tc.n++\n}\n".into(), + ); + + let mut head = Snapshot::default(); + head.files.insert( + "server.go".into(), + "package main\n\nfunc greet(name string) string {\n\treturn \"hello \" + name\n}\n\ntype counter struct{ n int }\n\nfunc (c *counter) inc() {\n\tc.n += 2\n}\n".into(), + ); + + let disputes = eng.diff_snapshots(&base, &head).unwrap(); + assert_eq!(disputes.len(), 2); + + let details: Vec<&str> = disputes.iter().map(|d| d.detail.as_str()).collect(); + assert!(details + .iter() + .any(|d| d.contains("both sides changed `greet`"))); + assert!(details + .iter() + .any(|d| d.contains("both sides changed `(*counter).inc`"))); + } + + #[test] + fn test_engine_js_arrows_and_declarations() { + let eng = Engine::new().unwrap(); + + let mut base = Snapshot::default(); + base.files.insert( + "index.js".into(), + "function add(a, b) {\n return a + b;\n}\nconst double = (x) => x * 2;\n".into(), + ); + + let mut head = Snapshot::default(); + head.files.insert( + "index.js".into(), + "function add(a, b) {\n return a + b + 1;\n}\nconst double = (x) => x * 3;\nconst triple = (x) => x * 3;\n".into(), + ); + + let disputes = eng.diff_snapshots(&base, &head).unwrap(); + assert_eq!(disputes.len(), 3); + + let details: Vec<&str> = disputes.iter().map(|d| d.detail.as_str()).collect(); + assert!(details + .iter() + .any(|d| d.contains("both sides changed `add`"))); + assert!(details + .iter() + .any(|d| d.contains("both sides changed `double`"))); + assert!(details + .iter() + .any(|d| d.contains("added function `triple`"))); + } + + #[test] + fn test_engine_js_assignment_arrow() { + let eng = Engine::new().unwrap(); + + let mut base = Snapshot::default(); + base.files.insert( + "index.js".into(), + "let double;\ndouble = (x) => x * 2;\n".into(), + ); + + let mut head = Snapshot::default(); + head.files.insert( + "index.js".into(), + "let double;\ndouble = (x) => x * 3;\n".into(), + ); + + let disputes = eng.diff_snapshots(&base, &head).unwrap(); + assert_eq!(disputes.len(), 1); + assert_eq!(disputes[0].detail, "both sides changed `double`"); + } + + #[test] + fn test_engine_js_class_field_arrow() { + let eng = Engine::new().unwrap(); + + let mut base = Snapshot::default(); + base.files.insert( + "index.js".into(), + "class Counter {\n constructor() { this.n = 0; }\n next = () => this.n++;\n}\n" + .into(), + ); + + let mut head = Snapshot::default(); + head.files.insert( + "index.js".into(), + "class Counter {\n constructor() { this.n = 0; }\n next = () => ++this.n;\n}\n" + .into(), + ); + + let disputes = eng.diff_snapshots(&base, &head).unwrap(); + assert_eq!(disputes.len(), 1); + assert_eq!(disputes[0].detail, "both sides changed `next`"); + } + + #[test] + fn test_engine_js_member_assignment_ignored() { + let eng = Engine::new().unwrap(); + + let mut base = Snapshot::default(); + base.files.insert( + "index.js".into(), + "const obj = {};\nobj.handle = () => 1;\n".into(), + ); + + let mut head = Snapshot::default(); + head.files.insert( + "index.js".into(), + "const obj = {};\nobj.handle = () => 2;\n".into(), + ); + + let disputes = eng.diff_snapshots(&base, &head).unwrap(); + assert!( + disputes.is_empty(), + "member-expression assignment should not be treated as a named function" + ); + } + + #[test] + fn test_engine_go_method_collision_disambiguated() { + let eng = Engine::new().unwrap(); + + let mut base = Snapshot::default(); + base.files.insert( + "server.go".into(), + "package main\n\ntype A struct{ v int }\n\nfunc (a *A) hit() {\n\ta.v = 1\n}\n\ntype B struct{ v int }\n\nfunc (b *B) hit() {\n\tb.v = 1\n}\n".into(), + ); + + let mut head = Snapshot::default(); + head.files.insert( + "server.go".into(), + "package main\n\ntype A struct{ v int }\n\nfunc (a *A) hit() {\n\ta.v = 2\n}\n\ntype B struct{ v int }\n\nfunc (b *B) hit() {\n\tb.v = 1\n}\n".into(), + ); + + let disputes = eng.diff_snapshots(&base, &head).unwrap(); + assert_eq!(disputes.len(), 1); + assert_eq!(disputes[0].detail, "both sides changed `(*A).hit`"); + } + + #[test] + fn test_engine_rust_impl_method_collision_disambiguated() { + let eng = Engine::new().unwrap(); + + let mut base = Snapshot::default(); + base.files.insert( + "src/lib.rs".into(), + "struct A;\nstruct B;\n\nimpl A { fn hit(&self) {} }\nimpl B { fn hit(&self) {} }\n" + .into(), + ); + + let mut head = Snapshot::default(); + head.files.insert( + "src/lib.rs".into(), + "struct A;\nstruct B;\n\nimpl A { fn hit(&self) { let _ = 1; } }\nimpl B { fn hit(&self) {} }\n".into(), + ); + + let disputes = eng.diff_snapshots(&base, &head).unwrap(); + assert_eq!(disputes.len(), 1); + assert_eq!(disputes[0].detail, "both sides changed `(A).hit`"); + } + + #[test] + fn test_engine_3way_mixed_languages() { + let eng = Engine::new().unwrap(); + + let mut base = Snapshot::default(); + base.files + .insert("lib.rs".into(), "pub fn f() -> i32 { 1 }\n".into()); + base.files + .insert("app.py".into(), "def f():\n return 1\n".into()); + + let mut ours = Snapshot::default(); + ours.files + .insert("lib.rs".into(), "pub fn f() -> i32 { 2 }\n".into()); + ours.files + .insert("app.py".into(), "def f():\n return 2\n".into()); + + let mut theirs = Snapshot::default(); + theirs + .files + .insert("lib.rs".into(), "pub fn f() -> i32 { 3 }\n".into()); + theirs + .files + .insert("app.py".into(), "def f():\n return 3\n".into()); + + let disputes = eng.diff_3way(&base, &ours, &theirs).unwrap(); + assert_eq!(disputes.len(), 2); + assert!(disputes.iter().all(|d| d.severity == Severity::High)); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..2dccefa --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,12 @@ +//! Oot: A governance runtime and court for code changes. +//! +//! Repos track lines. Oot governs changes: who may see them, +//! what they mean, and when they may ship. + +pub mod adapter; +pub mod change; +pub mod dispute; +pub mod docket; +pub mod engine; +pub mod policy; +pub mod visibility; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..35dad8a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,235 @@ +//! Command-line entry point for Oot. +//! +//! Adjudicates changes across snapshots against meaning and visibility policies. + +use clap::{Parser, Subcommand}; +use oot::adapter::{GitAdapter, GitAdjudicateOptions}; +use oot::change::{Change, Snapshot, Source}; +use oot::dispute::{Docket, Kind, Severity, Verdict}; +use oot::docket; +use oot::engine::Engine; +use oot::policy::MeaningPolicy; +use oot::visibility::VisibilityPolicy; + +/// Command-line parser for the Oot CLI. +#[derive(Parser)] +#[command(name = "oot", about = "Git settles lines. Oot settles meaning.")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +/// Available CLI subcommands. +#[derive(Subcommand)] +enum Commands { + /// Adjudicate a Change and print its docket. + Adjudicate { + /// Change name or identifier. + #[arg(long)] + change: Option, + /// Where the change came from: git, jj, or memory. + #[arg(long)] + source: Option, + /// Base snapshot directory. + #[arg(long)] + base: Option, + /// Head snapshot directory. + #[arg(long)] + head: Option, + /// Git base reference (e.g. `main` or commit SHA). + #[arg(long)] + base_ref: Option, + /// Git head reference (e.g. `feature/auth` or commit SHA). + #[arg(long)] + head_ref: Option, + /// Explicit merge-base commit SHA/ref override for 3-way Git adjudication. + #[arg(long)] + merge_base: Option, + /// Path to the Git repository root (defaults to discovering from current directory). + #[arg(long)] + repo: Option, + /// Comma-separated authors. + #[arg(long)] + authors: Option, + /// Stated intent or purpose of the change. + #[arg(long)] + intent: Option, + /// Path to a meaning-policy TOML file. + #[arg(long)] + policy: Option, + /// Path to a visibility-policy TOML file. + #[arg(long)] + visibility: Option, + /// Load and print a previously saved docket instead of adjudicating. + #[arg(long)] + docket: Option, + /// Path to save the resulting docket as JSON. + #[arg(long, short = 'o')] + output: Option, + }, +} + +fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + match cli.command { + Commands::Adjudicate { + change, + source, + base, + head, + base_ref, + head_ref, + merge_base, + repo, + authors, + intent, + policy, + visibility, + docket, + output, + } => { + if let Some(path) = docket { + let d = docket::load(std::path::Path::new(&path))?; + print!("{}", d.render()); + return Ok(()); + } + + let meaning_policy = match policy { + Some(p) => MeaningPolicy::load(std::path::Path::new(&p))?, + None => MeaningPolicy::default(), + }; + let visibility_policy = match visibility { + Some(v) => VisibilityPolicy::load(std::path::Path::new(&v))?, + None => VisibilityPolicy::default(), + }; + let eng = Engine::new()?; + + // Git 3-way In-Memory Adjudication + if let (Some(b_ref), Some(h_ref)) = (base_ref, head_ref) { + let git_adapter = match repo { + Some(r) => GitAdapter::new(r)?, + None => GitAdapter::discover()?, + }; + + let options = GitAdjudicateOptions { + custom_merge_base: merge_base, + change_name: change, + intent, + }; + + let doc = git_adapter.adjudicate_3way( + &b_ref, + &h_ref, + &eng, + &meaning_policy, + &visibility_policy, + &options, + )?; + + print!("{}", doc.render()); + + if let Some(out_path) = output { + docket::save(&doc, std::path::Path::new(&out_path))?; + } + return Ok(()); + } + + // Materialized Directory Snapshot Adjudication + let (base_dir, head_dir) = match (base, head) { + (Some(b), Some(h)) => (b, h), + _ => { + eprintln!( + "provide --docket , --base --head , or --base-ref --head-ref " + ); + std::process::exit(2); + } + }; + + let mut base_snap = Snapshot::default(); + load_dir( + std::path::Path::new(&base_dir), + std::path::Path::new(&base_dir), + &mut base_snap.files, + )?; + let mut head_snap = Snapshot::default(); + load_dir( + std::path::Path::new(&head_dir), + std::path::Path::new(&head_dir), + &mut head_snap.files, + )?; + + let change = Change { + name: change.unwrap_or_else(|| "unnamed".into()), + source: source.unwrap_or_else(|| "git".into()).parse::()?, + base_ref: base_dir.clone(), + head_ref: head_dir.clone(), + base: base_snap, + head: head_snap, + authors: authors + .map(|a| a.split(',').map(|s| s.trim().to_string()).collect()) + .unwrap_or_else(|| vec!["@you".into()]), + intent: intent.clone(), + }; + + let vis_disputes = visibility_policy.check(&change); + let cloaked = vis_disputes + .iter() + .any(|d| d.kind == Kind::Visibility && d.severity == Severity::High); + let mut disputes = eng.diff_snapshots(&change.base, &change.head)?; + disputes.extend(vis_disputes); + + let verdict = if cloaked { + Verdict::Cloaked + } else if visibility_policy.embargo_until.is_some() { + Verdict::Embargoed + } else { + meaning_policy.evaluate(&disputes) + }; + + let scope = change.intent.clone().unwrap_or_else(|| "auto".into()); + + let docket = Docket { + change: change.name.clone(), + source: change.source.as_str().to_string(), + base: change.base_ref.clone(), + head: change.head_ref.clone(), + disputes, + scope, + authors: change.authors.clone(), + verdict, + embargo: visibility_policy.embargo_note(), + }; + + print!("{}", docket.render()); + + if let Some(out_path) = output { + docket::save(&docket, std::path::Path::new(&out_path))?; + } + } + } + Ok(()) +} + +/// Recursively read files in a directory into a HashMap of relative paths to contents. +fn load_dir( + root: &std::path::Path, + dir: &std::path::Path, + files: &mut std::collections::HashMap, +) -> anyhow::Result<()> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let p = entry.path(); + if p.is_dir() { + load_dir(root, &p, files)?; + } else { + let content = std::fs::read_to_string(&p)?; + let rel = p + .strip_prefix(root) + .unwrap_or(&p) + .to_string_lossy() + .replace('\\', "/"); + files.insert(rel, content); + } + } + Ok(()) +} diff --git a/src/policy.rs b/src/policy.rs new file mode 100644 index 0000000..26e7965 --- /dev/null +++ b/src/policy.rs @@ -0,0 +1,167 @@ +//! Meaning policy configuration and dispute evaluation. +//! +//! Controls thresholds for semantic and structural disputes. + +use crate::dispute::{Dispute, Kind, Verdict}; +use serde::Deserialize; +use std::path::Path; + +/// Thresholds for *meaning* disputes. Visibility has its own policy. +#[derive(Debug, Deserialize)] +pub struct MeaningPolicy { + /// Severity names (lowercase, e.g. `"high"`) that block the change. + pub block_on: Vec, + /// Severity names (lowercase, e.g. `"review"`, `"high"`) that require human review. + pub review_on: Vec, +} + +impl Default for MeaningPolicy { + fn default() -> Self { + MeaningPolicy { + block_on: vec!["high".into()], + review_on: vec!["review".into(), "high".into()], + } + } +} + +impl MeaningPolicy { + /// Load a meaning policy from a TOML configuration file. + pub fn load(path: &Path) -> anyhow::Result { + let text = std::fs::read_to_string(path)?; + let p: MeaningPolicy = toml::from_str(&text)?; + Ok(p) + } + + /// Evaluate only meaning disputes against blocking thresholds. + /// + /// Visibility disputes are judged elsewhere (e.g. via [`crate::visibility::VisibilityPolicy`]). + pub fn evaluate(&self, disputes: &[Dispute]) -> Verdict { + for d in disputes { + if d.kind != Kind::Meaning { + continue; + } + let s = d.severity.as_str(); + if self.block_on.iter().any(|b| b.eq_ignore_ascii_case(s)) { + return Verdict::Blocked; + } + } + Verdict::Adjudicated + } + + /// Check if any meaning dispute requires human review based on `review_on` rules. + pub fn requires_review(&self, disputes: &[Dispute]) -> bool { + disputes.iter().any(|d| { + d.kind == Kind::Meaning + && self + .review_on + .iter() + .any(|r| r.eq_ignore_ascii_case(d.severity.as_str())) + }) + } + + /// Count how many meaning disputes require human review based on `review_on` rules. + pub fn review_count(&self, disputes: &[Dispute]) -> usize { + disputes + .iter() + .filter(|d| { + d.kind == Kind::Meaning + && self + .review_on + .iter() + .any(|r| r.eq_ignore_ascii_case(d.severity.as_str())) + }) + .count() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dispute::Severity; + + #[test] + fn test_meaning_policy_default_evaluation() { + let policy = MeaningPolicy::default(); + + let disputes = vec![Dispute { + id: "D001".into(), + location: "src/lib.rs:10".into(), + kind: Kind::Meaning, + severity: Severity::Review, + detail: "changed signature".into(), + }]; + + assert_eq!(policy.evaluate(&disputes), Verdict::Adjudicated); + assert!(policy.requires_review(&disputes)); + assert_eq!(policy.review_count(&disputes), 1); + } + + #[test] + fn test_meaning_policy_blocked() { + let policy = MeaningPolicy::default(); + + let disputes = vec![Dispute { + id: "D002".into(), + location: "src/lib.rs:20".into(), + kind: Kind::Meaning, + severity: Severity::High, + detail: "breaking change".into(), + }]; + + assert_eq!(policy.evaluate(&disputes), Verdict::Blocked); + assert!(policy.requires_review(&disputes)); + assert_eq!(policy.review_count(&disputes), 1); + } + + #[test] + fn test_meaning_policy_low_severity_and_visibility_ignore() { + let policy = MeaningPolicy::default(); + + let disputes = vec![ + Dispute { + id: "D003".into(), + location: "src/lib.rs:30".into(), + kind: Kind::Meaning, + severity: Severity::Low, + detail: "minor format change".into(), + }, + Dispute { + id: "V001".into(), + location: ".env".into(), + kind: Kind::Visibility, + severity: Severity::High, + detail: "private path touched".into(), + }, + ]; + + // Low meaning dispute doesn't block and doesn't require review + // Visibility dispute is ignored by MeaningPolicy + assert_eq!(policy.evaluate(&disputes), Verdict::Adjudicated); + assert!(!policy.requires_review(&disputes)); + assert_eq!(policy.review_count(&disputes), 0); + } + + #[test] + fn test_meaning_policy_custom_toml() { + let toml_content = r#" + block_on = ["review", "high"] + review_on = ["low"] + "#; + let policy: MeaningPolicy = toml::from_str(toml_content).unwrap(); + + assert_eq!(policy.block_on, vec!["review", "high"]); + assert_eq!(policy.review_on, vec!["low"]); + + let review_dispute = vec![Dispute { + id: "D004".into(), + location: "src/lib.rs:5".into(), + kind: Kind::Meaning, + severity: Severity::Review, + detail: "review level".into(), + }]; + + // Under custom policy, review level blocks + assert_eq!(policy.evaluate(&review_dispute), Verdict::Blocked); + assert!(!policy.requires_review(&review_dispute)); + } +} diff --git a/src/visibility.rs b/src/visibility.rs new file mode 100644 index 0000000..a545aa2 --- /dev/null +++ b/src/visibility.rs @@ -0,0 +1,171 @@ +//! Visibility policy evaluation and embargo management. +//! +//! Visibility is the governance spine of Oot: declaring who may see what, +//! which paths/branches are restricted, and when patches may be released publicly. + +use crate::change::Change; +use crate::dispute::{Dispute, Kind, Severity}; +use serde::Deserialize; +use std::path::Path; + +/// Declares who may see what, and when a patch may go public. +/// +/// This is policy, not cryptography. Actual encryption is delegated to +/// git-crypt or a hosted key service. Oot owns the rule and the gate. +#[derive(Debug, Deserialize)] +pub struct VisibilityPolicy { + /// Path fragments that are private. A touched path matching any entry + /// raises a visibility dispute. + pub private_paths: Vec, + /// If set, the change is held under embargo until this date (e.g. `YYYY-MM-DD`). + pub embargo_until: Option, + /// Branch names that must stay private. Referencing these raises a visibility dispute. + pub private_branches: Vec, +} + +impl Default for VisibilityPolicy { + fn default() -> Self { + VisibilityPolicy { + private_paths: vec!["secrets/".into(), ".env".into()], + embargo_until: None, + private_branches: vec![], + } + } +} + +impl VisibilityPolicy { + /// Load a visibility policy from a TOML configuration file. + pub fn load(path: &Path) -> anyhow::Result { + let text = std::fs::read_to_string(path)?; + let p: VisibilityPolicy = toml::from_str(&text)?; + Ok(p) + } + + /// Evaluate visibility rules against a change. + /// + /// Emits a visibility dispute for: + /// - Every private path present in the head snapshot. + /// - Every private branch referenced by name or refs. + pub fn check(&self, change: &Change) -> Vec { + let mut out = Vec::new(); + let mut n = 1; + + // Check private paths + for path in change.head.files.keys() { + let private = self + .private_paths + .iter() + .any(|p| path.contains(p.trim_start_matches('/'))); + if private { + out.push(Dispute { + id: format!("V{:03}", n), + location: path.clone(), + kind: Kind::Visibility, + severity: Severity::High, + detail: format!( + "private path {} touched by {}", + path, + change.authors.join("/") + ), + }); + n += 1; + } + } + + // Check private branches + for branch in &self.private_branches { + let branch_clean = branch.trim(); + if !branch_clean.is_empty() + && (change.name.contains(branch_clean) + || change.head_ref.contains(branch_clean) + || change.base_ref.contains(branch_clean)) + { + out.push(Dispute { + id: format!("V{:03}", n), + location: change.name.clone(), + kind: Kind::Visibility, + severity: Severity::High, + detail: format!( + "private branch {} referenced by {}", + branch_clean, + change.authors.join("/") + ), + }); + n += 1; + } + } + + out + } + + /// Generate an embargo notification note if an embargo date is active. + pub fn embargo_note(&self) -> Option { + self.embargo_until + .as_ref() + .map(|date| format!("patch held for maintainers until {}", date)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::change::{Snapshot, Source}; + + #[test] + fn test_visibility_policy_private_paths_and_branches() { + let policy = VisibilityPolicy { + private_paths: vec!["secrets/".into(), ".env".into()], + embargo_until: Some("2026-10-01".into()), + private_branches: vec!["internal-audit".into()], + }; + + let mut head = Snapshot::default(); + head.files.insert("secrets/key.pem".into(), "secret".into()); + + let change = Change { + name: "feature/internal-audit".into(), + source: Source::Git, + base_ref: "main".into(), + head_ref: "feature/internal-audit".into(), + base: Snapshot::default(), + head, + authors: vec!["@agent".into()], + intent: None, + }; + + let disputes = policy.check(&change); + assert_eq!(disputes.len(), 2); + assert!(disputes.iter().all(|d| d.kind == Kind::Visibility)); + assert_eq!( + policy.embargo_note().as_deref(), + Some("patch held for maintainers until 2026-10-01") + ); + } + + #[test] + fn test_visibility_policy_clean_and_default() { + let default_policy = VisibilityPolicy::default(); + assert_eq!(default_policy.private_paths, vec!["secrets/", ".env"]); + assert_eq!(default_policy.embargo_until, None); + assert!(default_policy.private_branches.is_empty()); + assert_eq!(default_policy.embargo_note(), None); + + let mut head = Snapshot::default(); + head.files + .insert("src/main.rs".into(), "fn main() {}".into()); + + let change = Change { + name: "feature/public".into(), + source: Source::Git, + base_ref: "main".into(), + head_ref: "feature/public".into(), + base: Snapshot::default(), + head, + authors: vec!["@alice".into()], + intent: None, + }; + + let disputes = default_policy.check(&change); + assert!(disputes.is_empty()); + } +} diff --git a/tests/cli_test.rs b/tests/cli_test.rs new file mode 100644 index 0000000..060f61d --- /dev/null +++ b/tests/cli_test.rs @@ -0,0 +1,226 @@ +use std::process::Command; + +fn get_bin_path() -> String { + env!("CARGO_BIN_EXE_oot").to_string() +} + +#[test] +fn test_cli_adjudicate_fixtures_repo() { + let bin = get_bin_path(); + + let output = Command::new(&bin) + .args([ + "adjudicate", + "--change", + "feature/auth-refactor", + "--source", + "jj", + "--base", + "fixtures/repo/base", + "--head", + "fixtures/repo/head", + "--authors", + "@kriday, @agent-7", + "--visibility", + "fixtures/visibility.toml", + ]) + .output() + .expect("Failed to execute oot CLI"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!(stdout.contains("OOT DOCKET")); + assert!(stdout.contains("change: feature/auth-refactor")); + assert!(stdout.contains("from: jj")); + assert!(stdout.contains("base: fixtures/repo/base")); + assert!(stdout.contains("head: fixtures/repo/head")); + assert!(stdout.contains("meaning: 1 disputes detected")); + assert!(stdout.contains("visibility: 1 private path(s)")); + assert!(stdout.contains("authors: @kriday, @agent-7")); + assert!(stdout.contains("dispute-01: both sides changed `login` (src/lib.rs:1) [meaning]")); + assert!(stdout.contains("dispute-02: private path secrets/.env touched by @kriday/@agent-7 (secrets/.env) [visibility]")); + assert!(stdout.contains("verdict: ▶ CLOAKED . 1 requires review, cloaked")); + assert!(stdout.contains("embargo: patch held for maintainers until 2026-09-01")); +} + +#[test] +fn test_cli_adjudicate_embargoed_clean() { + let bin = get_bin_path(); + let temp_root = std::env::temp_dir().join(format!("oot_cli_embargo_{}", std::process::id())); + let base_dir = temp_root.join("base"); + let head_dir = temp_root.join("head"); + let vis_path = temp_root.join("embargo_only.toml"); + + std::fs::create_dir_all(&base_dir).unwrap(); + std::fs::create_dir_all(&head_dir).unwrap(); + + std::fs::write(base_dir.join("main.rs"), "fn foo() {}").unwrap(); + std::fs::write(head_dir.join("main.rs"), "fn foo() { println!(\"1\"); }").unwrap(); + + std::fs::write( + &vis_path, + "private_paths = []\nembargo_until = \"2026-12-01\"\nprivate_branches = []", + ) + .unwrap(); + + let output = Command::new(&bin) + .args([ + "adjudicate", + "--change", + "security/fix", + "--source", + "git", + "--base", + base_dir.to_str().unwrap(), + "--head", + head_dir.to_str().unwrap(), + "--visibility", + vis_path.to_str().unwrap(), + "--authors", + "@maintainer", + ]) + .output() + .expect("Failed to execute oot CLI"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!(stdout.contains("OOT DOCKET")); + assert!(stdout.contains("verdict: ▶ EMBARGOED . 1 requires review, held for maintainers")); + assert!(stdout.contains("embargo: patch held for maintainers until 2026-12-01")); + + let _ = std::fs::remove_dir_all(&temp_root); +} + +#[test] +fn test_cli_adjudicate_load_docket() { + let bin = get_bin_path(); + + let output = Command::new(&bin) + .args(["adjudicate", "--docket", "fixtures/example.json"]) + .output() + .expect("Failed to execute oot CLI"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!(stdout.contains("OOT DOCKET")); + assert!(stdout.contains("change: feature/auth-refactor")); + assert!(stdout.contains("from: jj")); + assert!(stdout.contains("base: main@a3f7c1d")); + assert!(stdout.contains("head: feature@b8e2f4a")); + assert!(stdout.contains("meaning: 1 disputes detected")); + assert!(stdout.contains("visibility: 1 private path(s)")); + assert!(stdout.contains("verdict: ▶ EMBARGOED . 1 requires review, held for maintainers")); +} + +#[test] +fn test_cli_custom_meaning_policy_and_temp_dirs() { + let bin = get_bin_path(); + let temp_root = std::env::temp_dir().join(format!("oot_cli_test_{}", std::process::id())); + let base_dir = temp_root.join("base"); + let head_dir = temp_root.join("head"); + let policy_path = temp_root.join("strict_policy.toml"); + + std::fs::create_dir_all(&base_dir).unwrap(); + std::fs::create_dir_all(&head_dir).unwrap(); + + // Write base and head rust files + std::fs::write( + base_dir.join("main.rs"), + "fn calculate_total() -> u32 { 100 }", + ) + .unwrap(); + std::fs::write( + head_dir.join("main.rs"), + "fn calculate_total() -> u32 { 200 }", + ) + .unwrap(); + + // Strict policy: review level blocks + std::fs::write( + &policy_path, + "block_on = [\"review\"]\nreview_on = [\"review\"]", + ) + .unwrap(); + + let output = Command::new(&bin) + .args([ + "adjudicate", + "--change", + "patch/calc-update", + "--source", + "git", + "--base", + base_dir.to_str().unwrap(), + "--head", + head_dir.to_str().unwrap(), + "--policy", + policy_path.to_str().unwrap(), + "--authors", + "@tester", + ]) + .output() + .expect("Failed to execute oot CLI"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!(stdout.contains("OOT DOCKET")); + assert!(stdout.contains("change: patch/calc-update")); + assert!(stdout.contains("meaning: 1 disputes detected")); + assert!(stdout.contains("verdict: ▶ BLOCKED . 1 requires review")); + + let _ = std::fs::remove_dir_all(&temp_root); +} + +#[test] +fn test_cli_missing_base_and_docket_fails() { + let bin = get_bin_path(); + + let output = Command::new(&bin) + .args(["adjudicate"]) + .output() + .expect("Failed to execute oot CLI"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("provide --docket ")); +} + +#[test] +fn test_cli_missing_head_fails() { + let bin = get_bin_path(); + + let output = Command::new(&bin) + .args(["adjudicate", "--base", "fixtures/repo/base"]) + .output() + .expect("Failed to execute oot CLI"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("provide --docket ")); +} + +#[test] +fn test_cli_invalid_source_fails() { + let bin = get_bin_path(); + + let output = Command::new(&bin) + .args([ + "adjudicate", + "--source", + "invalid_vcs", + "--base", + "fixtures/repo/base", + "--head", + "fixtures/repo/head", + ]) + .output() + .expect("Failed to execute oot CLI"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("unknown source: invalid_vcs")); +} diff --git a/tests/dispute_docket_test.rs b/tests/dispute_docket_test.rs new file mode 100644 index 0000000..b5f82e3 --- /dev/null +++ b/tests/dispute_docket_test.rs @@ -0,0 +1,184 @@ +use oot::dispute::{Dispute, Docket, Kind, Severity, Verdict}; +use oot::docket; +use std::path::Path; + +fn sample_docket() -> Docket { + Docket { + change: "feature/auth-layer".into(), + source: "jj".into(), + base: "main@commit1".into(), + head: "feature@commit2".into(), + disputes: vec![ + Dispute { + id: "D001".into(), + location: "src/auth.rs:15".into(), + kind: Kind::Meaning, + severity: Severity::Review, + detail: "both sides changed `verify_token`".into(), + }, + Dispute { + id: "D002".into(), + location: "src/auth.rs:30".into(), + kind: Kind::Meaning, + severity: Severity::Low, + detail: "added function `refresh_token`".into(), + }, + Dispute { + id: "V001".into(), + location: "secrets/.env".into(), + kind: Kind::Visibility, + severity: Severity::High, + detail: "private path secrets/.env touched by @alice/@bob".into(), + }, + ], + scope: "Authentication and session management".into(), + authors: vec!["@alice".into(), "@bob".into()], + verdict: Verdict::Embargoed, + embargo: Some("patch held for maintainers until 2026-09-01".into()), + } +} + +#[test] +fn test_dispute_classification_and_counts() { + let docket = sample_docket(); + + assert_eq!(docket.meaning_count(), 2); + assert_eq!(docket.visibility_count(), 1); + assert_eq!(docket.review_count(), 1); + assert!(docket.requires_review()); + + let clean_docket = Docket { + change: "docs".into(), + source: "git".into(), + base: "main".into(), + head: "docs".into(), + disputes: vec![], + scope: "doc updates".into(), + authors: vec!["@writer".into()], + verdict: Verdict::Adjudicated, + embargo: None, + }; + + assert_eq!(clean_docket.meaning_count(), 0); + assert_eq!(clean_docket.visibility_count(), 0); + assert_eq!(clean_docket.review_count(), 0); + assert!(!clean_docket.requires_review()); +} + +#[test] +fn test_docket_json_serialization_roundtrip() { + let original = sample_docket(); + + let json_str = docket::to_json(&original).expect("JSON serialization failed"); + let deserialized = docket::from_json(&json_str).expect("JSON deserialization failed"); + + assert_eq!(deserialized.change, original.change); + assert_eq!(deserialized.source, original.source); + assert_eq!(deserialized.base, original.base); + assert_eq!(deserialized.head, original.head); + assert_eq!(deserialized.disputes.len(), original.disputes.len()); + assert_eq!(deserialized.scope, original.scope); + assert_eq!(deserialized.authors, original.authors); + assert_eq!(deserialized.verdict, original.verdict); + assert_eq!(deserialized.embargo, original.embargo); +} + +#[test] +fn test_docket_toml_serialization_roundtrip() { + let original = sample_docket(); + + let toml_str = docket::to_toml(&original).expect("TOML serialization failed"); + let deserialized = docket::from_toml(&toml_str).expect("TOML deserialization failed"); + + assert_eq!(deserialized.change, original.change); + assert_eq!(deserialized.source, original.source); + assert_eq!(deserialized.base, original.base); + assert_eq!(deserialized.head, original.head); + assert_eq!(deserialized.disputes.len(), original.disputes.len()); + assert_eq!(deserialized.scope, original.scope); + assert_eq!(deserialized.authors, original.authors); + assert_eq!(deserialized.verdict, original.verdict); + assert_eq!(deserialized.embargo, original.embargo); +} + +#[test] +fn test_docket_save_and_load_json_file() { + let original = sample_docket(); + let temp_dir = std::env::temp_dir().join(format!("oot_docket_test_{}", std::process::id())); + std::fs::create_dir_all(&temp_dir).unwrap(); + let file_path = temp_dir.join("test_docket.json"); + + docket::save_json(&original, &file_path).expect("save_json failed"); + let loaded = docket::load_json(&file_path).expect("load_json failed"); + + assert_eq!(loaded.change, original.change); + assert_eq!(loaded.verdict, original.verdict); + + let generic_loaded = docket::load(&file_path).expect("generic load failed"); + assert_eq!(generic_loaded.change, original.change); + + let _ = std::fs::remove_dir_all(&temp_dir); +} + +#[test] +fn test_docket_save_and_load_toml_file() { + let original = sample_docket(); + let temp_dir = + std::env::temp_dir().join(format!("oot_docket_test_toml_{}", std::process::id())); + std::fs::create_dir_all(&temp_dir).unwrap(); + let file_path = temp_dir.join("test_docket.toml"); + + docket::save_toml(&original, &file_path).expect("save_toml failed"); + let loaded = docket::load_toml(&file_path).expect("load_toml failed"); + + assert_eq!(loaded.change, original.change); + assert_eq!(loaded.verdict, original.verdict); + + let generic_loaded = docket::load(&file_path).expect("generic load failed"); + assert_eq!(generic_loaded.change, original.change); + + let _ = std::fs::remove_dir_all(&temp_dir); +} + +#[test] +fn test_docket_load_from_example_fixture() { + let fixture_path = Path::new("fixtures/example.json"); + let docket = docket::load(fixture_path).expect("Failed to load fixtures/example.json"); + + assert_eq!(docket.change, "feature/auth-refactor"); + assert_eq!(docket.source, "jj"); + assert_eq!(docket.base, "main@a3f7c1d"); + assert_eq!(docket.head, "feature@b8e2f4a"); + assert_eq!(docket.disputes.len(), 2); + assert_eq!(docket.meaning_count(), 1); + assert_eq!(docket.visibility_count(), 1); + assert_eq!(docket.authors, vec!["@kriday", "@agent-7"]); + assert_eq!(docket.verdict, Verdict::Embargoed); + assert_eq!( + docket.embargo.as_deref(), + Some("patch held for maintainers until 2026-09-01") + ); +} + +#[test] +fn test_docket_render_verdicts() { + // 1. Cloaked verdict + let mut cloaked = sample_docket(); + cloaked.verdict = Verdict::Cloaked; + let rendered_cloaked = cloaked.render(); + assert!(rendered_cloaked.contains("verdict: ▶ CLOAKED . 1 requires review, cloaked")); + + // 2. Blocked verdict + let mut blocked = sample_docket(); + blocked.verdict = Verdict::Blocked; + let rendered_blocked = blocked.render(); + assert!(rendered_blocked.contains("verdict: ▶ BLOCKED . 1 requires review")); + + // 3. Adjudicated verdict with no reviews + let mut adjudicated = sample_docket(); + adjudicated.disputes.clear(); + adjudicated.verdict = Verdict::Adjudicated; + let rendered_adjudicated = adjudicated.render(); + assert!(rendered_adjudicated.contains("verdict: ▶ ADJUDICATED \n")); + assert!(rendered_adjudicated.contains("dispute: none")); +} diff --git a/tests/engine_test.rs b/tests/engine_test.rs new file mode 100644 index 0000000..c66f815 --- /dev/null +++ b/tests/engine_test.rs @@ -0,0 +1,319 @@ +use oot::change::Snapshot; +use oot::dispute::{Dispute, Kind, Severity}; +use oot::engine::Engine; + +#[test] +fn test_engine_function_modification_detection() { + let engine = Engine::new().expect("Failed to initialize engine"); + + let mut base = Snapshot::default(); + base.files.insert( + "src/auth.rs".to_string(), + r#" +fn authenticate(user: &str, pass: &str) -> bool { + user == "admin" && pass == "secret" +} +"# + .to_string(), + ); + + let mut head = Snapshot::default(); + head.files.insert( + "src/auth.rs".to_string(), + r#" +fn authenticate(user: &str, pass: &str) -> bool { + user == "admin" && pass == "secure_password_v2" +} +"# + .to_string(), + ); + + let disputes = engine.diff_snapshots(&base, &head).expect("Diff failed"); + + assert_eq!(disputes.len(), 1); + let d = &disputes[0]; + assert_eq!(d.kind, Kind::Meaning); + assert_eq!(d.severity, Severity::Review); + assert_eq!(d.id, "D001"); + assert!(d.location.starts_with("src/auth.rs:")); + assert_eq!(d.detail, "both sides changed `authenticate`"); +} + +#[test] +fn test_engine_function_addition_and_deletion_detection() { + let engine = Engine::new().expect("Failed to initialize engine"); + + let mut base = Snapshot::default(); + base.files.insert( + "src/math.rs".to_string(), + r#" +fn add(a: i32, b: i32) -> i32 { + a + b +} + +fn legacy_multiply(a: i32, b: i32) -> i32 { + a * b +} +"# + .to_string(), + ); + + let mut head = Snapshot::default(); + head.files.insert( + "src/math.rs".to_string(), + r#" +fn add(a: i32, b: i32) -> i32 { + a + b +} + +fn subtract(a: i32, b: i32) -> i32 { + a - b +} +"# + .to_string(), + ); + + let disputes = engine.diff_snapshots(&base, &head).expect("Diff failed"); + + assert_eq!(disputes.len(), 2); + let added = disputes + .iter() + .find(|d| d.detail == "added function `subtract`") + .expect("Should have detected added function"); + assert_eq!(added.kind, Kind::Meaning); + assert_eq!(added.severity, Severity::Review); + + let removed = disputes + .iter() + .find(|d| d.detail == "removed function `legacy_multiply`") + .expect("Should have detected removed function"); + assert_eq!(removed.kind, Kind::Meaning); + assert_eq!(removed.severity, Severity::Review); +} + +#[test] +fn test_engine_identical_unchanged_files() { + let engine = Engine::new().expect("Failed to initialize engine"); + + let source = r#" +pub fn calculate_hash(data: &[u8]) -> u64 { + let mut hash = 0u64; + for b in data { + hash = hash.wrapping_add(*b as u64); + } + hash +} + +pub fn verify_signature() -> bool { + true +} +"#; + + let mut base = Snapshot::default(); + base.files + .insert("src/crypto.rs".to_string(), source.to_string()); + + let mut head = Snapshot::default(); + head.files + .insert("src/crypto.rs".to_string(), source.to_string()); + + let disputes = engine.diff_snapshots(&base, &head).expect("Diff failed"); + + assert!( + disputes.is_empty(), + "Expected zero disputes for identical files, got {:?}", + disputes + ); +} + +#[test] +fn test_engine_non_rust_file_filtering() { + let engine = Engine::new().expect("Failed to initialize engine"); + + let mut base = Snapshot::default(); + base.files.insert( + "README.md".to_string(), + "# Project\nInitial README".to_string(), + ); + base.files.insert( + "config.toml".to_string(), + "title = 'Old Config'".to_string(), + ); + base.files.insert( + "scripts/run.sh".to_string(), + "echo 'Running old script'".to_string(), + ); + base.files.insert( + "style.css".to_string(), + "body { color: black; }".to_string(), + ); + + let mut head = Snapshot::default(); + head.files.insert( + "README.md".to_string(), + "# Project\nUpdated README with more docs".to_string(), + ); + head.files.insert( + "config.toml".to_string(), + "title = 'New Config'".to_string(), + ); + head.files.insert( + "scripts/run.sh".to_string(), + "echo 'Running new script'".to_string(), + ); + head.files + .insert("style.css".to_string(), "body { color: blue; }".to_string()); + + let disputes = engine.diff_snapshots(&base, &head).expect("Diff failed"); + + assert!( + disputes.is_empty(), + "Non-Rust files should be filtered out from AST diffing" + ); +} + +#[test] +fn test_engine_syntax_error_handling() { + let engine = Engine::new().expect("Failed to initialize engine"); + + let mut base = Snapshot::default(); + base.files.insert( + "src/broken.rs".to_string(), + "fn valid_base() -> i32 { 42 }".to_string(), + ); + + let mut head = Snapshot::default(); + // Incomplete / invalid Rust syntax + head.files.insert( + "src/broken.rs".to_string(), + "fn broken_syntax( { !!! %%% invalid rust code @@@ }}}".to_string(), + ); + + // Engine should handle syntax errors gracefully without panicking + let result = engine.diff_snapshots(&base, &head); + assert!(result.is_ok()); + let disputes = result.unwrap(); + // Since tree-sitter recovers and base function is missing in head, it flags removed function + assert!(disputes + .iter() + .any(|d| d.detail.contains("removed function `valid_base`"))); +} + +#[test] +fn test_engine_file_added_and_removed() { + let engine = Engine::new().expect("Failed to initialize engine"); + + let mut base = Snapshot::default(); + base.files.insert( + "src/old_module.rs".to_string(), + "fn old_util() {}".to_string(), + ); + + let mut head = Snapshot::default(); + head.files.insert( + "src/new_module.rs".to_string(), + "fn new_util() {}".to_string(), + ); + + let disputes = engine.diff_snapshots(&base, &head).expect("Diff failed"); + + assert_eq!(disputes.len(), 2); + let added = disputes + .iter() + .find(|d| d.detail == "file added") + .expect("File added dispute"); + assert_eq!(added.location, "src/new_module.rs:0"); + + let removed = disputes + .iter() + .find(|d| d.detail == "file removed") + .expect("File removed dispute"); + assert_eq!(removed.location, "src/old_module.rs:0"); +} + +#[test] +fn test_engine_multiple_files_and_functions() { + let engine = Engine::new().expect("Failed to initialize engine"); + + let mut base = Snapshot::default(); + base.files.insert( + "src/a.rs".to_string(), + "fn fa1() {}\nfn fa2() {}\n".to_string(), + ); + base.files + .insert("src/b.rs".to_string(), "fn fb1() {}\n".to_string()); + + let mut head = Snapshot::default(); + head.files.insert( + "src/a.rs".to_string(), + "fn fa1() { println!(\"modified\"); }\nfn fa2() {}\nfn fa3() {}\n".to_string(), + ); + head.files + .insert("src/b.rs".to_string(), "fn fb1() {}\n".to_string()); + + let disputes = engine.diff_snapshots(&base, &head).expect("Diff failed"); + + // fa1 modified, fa3 added in src/a.rs. src/b.rs is unchanged. + assert_eq!(disputes.len(), 2); + assert!(disputes + .iter() + .any(|d| d.detail == "both sides changed `fa1`")); + assert!(disputes.iter().any(|d| d.detail == "added function `fa3`")); +} + +#[test] +fn test_engine_mixed_language_snapshot() { + let engine = Engine::new().expect("Failed to initialize engine"); + + let mut base = Snapshot::default(); + base.files.insert( + "app.py".to_string(), + "def greet(name):\n return f\"hi {name}\"\n".to_string(), + ); + base.files.insert( + "index.js".to_string(), + "const double = (x) => x * 2;\n".to_string(), + ); + base.files.insert( + "server.go".to_string(), + "package main\n\nfunc greet(name string) string {\n\treturn \"hi \" + name\n}\n" + .to_string(), + ); + + let mut head = Snapshot::default(); + head.files.insert( + "app.py".to_string(), + "def greet(name):\n return f\"hello {name}\"\n".to_string(), + ); + head.files.insert( + "index.js".to_string(), + "const double = (x) => x * 3;\n".to_string(), + ); + head.files.insert( + "server.go".to_string(), + "package main\n\nfunc greet(name string) string {\n\treturn \"hello \" + name\n}\n" + .to_string(), + ); + + let disputes = engine.diff_snapshots(&base, &head).expect("Diff failed"); + + assert_eq!(disputes.len(), 3); + + let greets: Vec<&Dispute> = disputes + .iter() + .filter(|d| d.detail == "both sides changed `greet`") + .collect(); + assert_eq!(greets.len(), 2, "one greet change per language file"); + assert!( + greets.iter().any(|d| d.location.starts_with("app.py:")), + "python greet dispute should point into app.py" + ); + assert!( + greets.iter().any(|d| d.location.starts_with("server.go:")), + "go greet dispute should point into server.go" + ); + + assert!(disputes + .iter() + .any(|d| d.detail == "both sides changed `double`")); +} diff --git a/tests/git_adapter_test.rs b/tests/git_adapter_test.rs new file mode 100644 index 0000000..e72bf10 --- /dev/null +++ b/tests/git_adapter_test.rs @@ -0,0 +1,297 @@ +//! Integration tests for the Git 3-Way Snapshot Ingestion Adapter. + +use oot::adapter::{GitAdapter, GitAdjudicateOptions}; +use oot::dispute::{Kind, Severity, Verdict}; +use oot::engine::Engine; +use oot::policy::MeaningPolicy; +use oot::visibility::VisibilityPolicy; +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +/// Helper struct to create and clean up temporary git repositories. +struct TempGitRepo { + path: PathBuf, +} + +impl TempGitRepo { + fn new(name: &str) -> Self { + let path = + std::env::temp_dir().join(format!("oot_git_test_{}_{}", name, std::process::id())); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).expect("failed to create temp git dir"); + + // git init + let status = Command::new("git") + .args(["init", "-b", "main"]) + .current_dir(&path) + .status() + .expect("failed to init git"); + assert!(status.success()); + + // configure identity for commits + let _ = Command::new("git") + .args(["config", "user.name", "Oot Tester"]) + .current_dir(&path) + .status(); + let _ = Command::new("git") + .args(["config", "user.email", "tester@oot.local"]) + .current_dir(&path) + .status(); + + Self { path } + } + + fn write_file(&self, rel_path: &str, content: &str) { + let full = self.path.join(rel_path); + if let Some(parent) = full.parent() { + fs::create_dir_all(parent).expect("failed to create parent dir"); + } + fs::write(full, content).expect("failed to write test file"); + } + + fn commit(&self, msg: &str) -> String { + let s1 = Command::new("git") + .args(["add", "."]) + .current_dir(&self.path) + .status() + .expect("git add failed"); + assert!(s1.success()); + + let s2 = Command::new("git") + .args(["commit", "-m", msg]) + .current_dir(&self.path) + .status() + .expect("git commit failed"); + assert!(s2.success()); + + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(&self.path) + .output() + .expect("git rev-parse HEAD failed"); + String::from_utf8(output.stdout).unwrap().trim().to_string() + } + + fn create_and_checkout_branch(&self, branch: &str) { + let status = Command::new("git") + .args(["checkout", "-b", branch]) + .current_dir(&self.path) + .status() + .expect("git checkout -b failed"); + assert!(status.success()); + } + + fn checkout(&self, branch_or_rev: &str) { + let status = Command::new("git") + .args(["checkout", branch_or_rev]) + .current_dir(&self.path) + .status() + .expect("git checkout failed"); + assert!(status.success()); + } +} + +impl Drop for TempGitRepo { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +#[test] +fn test_git_adapter_snapshot_extraction() { + let repo = TempGitRepo::new("snapshot_extract"); + repo.write_file( + "src/lib.rs", + "pub fn compute() -> i32 { 42 }\n\npub fn auth() -> bool { true }\n", + ); + repo.write_file("README.md", "# Test Repo\n"); + let c1 = repo.commit("initial commit"); + + let adapter = GitAdapter::new(&repo.path).expect("valid git repo"); + let snapshot = adapter.extract_snapshot(&c1).expect("extract snapshot"); + + assert_eq!(snapshot.files.len(), 2); + assert!(snapshot.files.contains_key("src/lib.rs")); + assert!(snapshot.files.contains_key("README.md")); + assert_eq!( + snapshot.files.get("src/lib.rs").unwrap(), + "pub fn compute() -> i32 { 42 }\n\npub fn auth() -> bool { true }\n" + ); +} + +#[test] +fn test_git_adapter_3way_semantic_conflict() { + let repo = TempGitRepo::new("3way_conflict"); + + // 1. Initial base commit on main + repo.write_file( + "src/lib.rs", + "pub fn common() -> i32 { 0 }\npub fn target_fn() -> &'static str { \"v1\" }\n", + ); + let base_sha = repo.commit("base version"); + + // 2. Feature branch: modifies target_fn to "feature_v2" + repo.create_and_checkout_branch("feature/auth"); + repo.write_file( + "src/lib.rs", + "pub fn common() -> i32 { 0 }\npub fn target_fn() -> &'static str { \"feature_v2\" }\n", + ); + let _feature_sha = repo.commit("feature change"); + + // 3. Main branch: modifies target_fn to "main_v2" (divergent!) + repo.checkout("main"); + repo.write_file( + "src/lib.rs", + "pub fn common() -> i32 { 0 }\npub fn target_fn() -> &'static str { \"main_v2\" }\n", + ); + let _main_sha = repo.commit("main change"); + + let adapter = GitAdapter::new(&repo.path).expect("valid git repo"); + let engine = Engine::new().expect("valid engine"); + let meaning_policy = MeaningPolicy::default(); + let visibility_policy = VisibilityPolicy::default(); + + // Check merge base + let mb = adapter + .merge_base("main", "feature/auth") + .expect("merge-base"); + assert_eq!(mb, base_sha); + + // Run 3-way adjudication + let options = GitAdjudicateOptions { + custom_merge_base: None, + change_name: Some("auth-3way".into()), + intent: Some("refactor target_fn".into()), + }; + + let docket = adapter + .adjudicate_3way( + "main", + "feature/auth", + &engine, + &meaning_policy, + &visibility_policy, + &options, + ) + .expect("adjudicate 3way"); + + assert_eq!(docket.verdict, Verdict::Blocked); + assert_eq!(docket.meaning_count(), 1); + + let dispute = &docket.disputes[0]; + assert_eq!(dispute.kind, Kind::Meaning); + assert_eq!(dispute.severity, Severity::High); + assert!(dispute.detail.contains("3-way conflict")); + assert!(dispute.detail.contains("target_fn")); +} + +#[test] +fn test_git_adapter_3way_unilateral_clean() { + let repo = TempGitRepo::new("3way_clean"); + + // Base commit on main + repo.write_file( + "src/lib.rs", + "pub fn common() -> i32 { 0 }\npub fn helper() -> bool { false }\n", + ); + repo.commit("base version"); + + // Feature branch: adds a new function, keeps others unchanged + repo.create_and_checkout_branch("feature/new-fn"); + repo.write_file( + "src/lib.rs", + "pub fn common() -> i32 { 0 }\npub fn helper() -> bool { false }\npub fn added_fn() -> i32 { 100 }\n", + ); + repo.commit("feature added function"); + + // Main branch: untouched + repo.checkout("main"); + + let adapter = GitAdapter::new(&repo.path).expect("valid git repo"); + let engine = Engine::new().expect("valid engine"); + let meaning_policy = MeaningPolicy::default(); + let visibility_policy = VisibilityPolicy::default(); + + let docket = adapter + .adjudicate_3way( + "main", + "feature/new-fn", + &engine, + &meaning_policy, + &visibility_policy, + &GitAdjudicateOptions::default(), + ) + .expect("adjudicate 3way"); + + assert_eq!(docket.verdict, Verdict::Adjudicated); + assert_eq!(docket.meaning_count(), 1); + assert_eq!(docket.disputes[0].severity, Severity::Low); + assert!(docket.disputes[0] + .detail + .contains("added function `added_fn`")); +} + +#[test] +fn test_git_adapter_visibility_violation_cloaked() { + let repo = TempGitRepo::new("visibility_git"); + + repo.write_file("src/lib.rs", "pub fn ok() {}\n"); + repo.commit("initial"); + + repo.create_and_checkout_branch("feature/secrets"); + repo.write_file("secrets/api_keys.json", "{ \"key\": \"12345\" }\n"); + repo.commit("add secrets"); + + let adapter = GitAdapter::new(&repo.path).expect("valid git repo"); + let engine = Engine::new().expect("valid engine"); + let meaning_policy = MeaningPolicy::default(); + let visibility_policy = VisibilityPolicy::default(); // defaults to secrets/ and .env private + + let docket = adapter + .adjudicate_3way( + "main", + "feature/secrets", + &engine, + &meaning_policy, + &visibility_policy, + &GitAdjudicateOptions::default(), + ) + .expect("adjudicate 3way"); + + assert_eq!(docket.verdict, Verdict::Cloaked); + assert_eq!(docket.visibility_count(), 1); + assert!(docket.disputes[0].detail.contains("private path")); +} + +#[test] +fn test_cli_git_adjudication_flags() { + let repo = TempGitRepo::new("cli_git"); + repo.write_file("src/lib.rs", "pub fn base() {}\n"); + repo.commit("initial base"); + + repo.create_and_checkout_branch("feature/cli-test"); + repo.write_file("src/lib.rs", "pub fn base() {}\npub fn extra() {}\n"); + repo.commit("feature commit"); + + repo.checkout("main"); + + let status = Command::new(env!("CARGO_BIN_EXE_oot")) + .args([ + "adjudicate", + "--repo", + repo.path.to_str().unwrap(), + "--base-ref", + "main", + "--head-ref", + "feature/cli-test", + "--change", + "cli-git-change", + "--intent", + "add extra helper", + ]) + .status() + .expect("failed to run CLI command"); + + assert!(status.success()); +} diff --git a/tests/policy_test.rs b/tests/policy_test.rs new file mode 100644 index 0000000..4fe53f2 --- /dev/null +++ b/tests/policy_test.rs @@ -0,0 +1,202 @@ +use oot::dispute::{Dispute, Kind, Severity, Verdict}; +use oot::policy::MeaningPolicy; +use std::io::Write; + +fn make_dispute(id: &str, kind: Kind, severity: Severity, detail: &str) -> Dispute { + Dispute { + id: id.to_string(), + location: "src/engine.rs:42".to_string(), + kind, + severity, + detail: detail.to_string(), + } +} + +#[test] +fn test_meaning_policy_default_fallbacks() { + let policy = MeaningPolicy::default(); + + assert_eq!(policy.block_on, vec!["high"]); + assert_eq!(policy.review_on, vec!["review", "high"]); + + // Test with no disputes + let empty_disputes: Vec = vec![]; + assert_eq!(policy.evaluate(&empty_disputes), Verdict::Adjudicated); + assert!(!policy.requires_review(&empty_disputes)); + assert_eq!(policy.review_count(&empty_disputes), 0); + + // Test with Low severity meaning dispute + let low_disputes = vec![make_dispute( + "D001", + Kind::Meaning, + Severity::Low, + "low severity", + )]; + assert_eq!(policy.evaluate(&low_disputes), Verdict::Adjudicated); + assert!(!policy.requires_review(&low_disputes)); + assert_eq!(policy.review_count(&low_disputes), 0); + + // Test with Review severity meaning dispute + let review_disputes = vec![make_dispute( + "D002", + Kind::Meaning, + Severity::Review, + "review severity", + )]; + assert_eq!(policy.evaluate(&review_disputes), Verdict::Adjudicated); + assert!(policy.requires_review(&review_disputes)); + assert_eq!(policy.review_count(&review_disputes), 1); + + // Test with High severity meaning dispute + let high_disputes = vec![make_dispute( + "D003", + Kind::Meaning, + Severity::High, + "high severity", + )]; + assert_eq!(policy.evaluate(&high_disputes), Verdict::Blocked); + assert!(policy.requires_review(&high_disputes)); + assert_eq!(policy.review_count(&high_disputes), 1); +} + +#[test] +fn test_meaning_policy_toml_deserialization_and_file_load() { + let toml_str = r#" + block_on = ["high", "review"] + review_on = ["low", "review"] + "#; + + let policy: MeaningPolicy = + toml::from_str(toml_str).expect("Failed to deserialize TOML string"); + assert_eq!(policy.block_on, vec!["high", "review"]); + assert_eq!(policy.review_on, vec!["low", "review"]); + + // Write to a temporary file and test MeaningPolicy::load() + let mut temp_file = tempfile_named("meaning_policy.toml"); + temp_file + .write_all(toml_str.as_bytes()) + .expect("Failed to write temp toml file"); + + let loaded_policy = + MeaningPolicy::load(temp_file.path()).expect("Failed to load MeaningPolicy from file"); + assert_eq!(loaded_policy.block_on, vec!["high", "review"]); + assert_eq!(loaded_policy.review_on, vec!["low", "review"]); + + let low_dispute = vec![make_dispute( + "D001", + Kind::Meaning, + Severity::Low, + "low dispute", + )]; + // Under this custom policy, low requires review + assert!(loaded_policy.requires_review(&low_dispute)); + assert_eq!(loaded_policy.evaluate(&low_dispute), Verdict::Adjudicated); + + let review_dispute = vec![make_dispute( + "D002", + Kind::Meaning, + Severity::Review, + "review dispute", + )]; + // Under this custom policy, review blocks + assert_eq!(loaded_policy.evaluate(&review_dispute), Verdict::Blocked); +} + +#[test] +fn test_meaning_policy_case_insensitivity() { + let toml_str = r#" + block_on = ["HIGH", "Review"] + review_on = ["LoW", "REVIEW"] + "#; + + let policy: MeaningPolicy = toml::from_str(toml_str).expect("Failed to deserialize TOML"); + + let high_dispute = vec![make_dispute("D001", Kind::Meaning, Severity::High, "high")]; + assert_eq!(policy.evaluate(&high_dispute), Verdict::Blocked); + + let low_dispute = vec![make_dispute("D002", Kind::Meaning, Severity::Low, "low")]; + assert!(policy.requires_review(&low_dispute)); +} + +#[test] +fn test_meaning_policy_ignores_visibility_disputes() { + let policy = MeaningPolicy::default(); + + let visibility_disputes = vec![ + make_dispute( + "V001", + Kind::Visibility, + Severity::High, + "private path secrets/.env touched", + ), + make_dispute( + "V002", + Kind::Visibility, + Severity::Review, + "private branch referenced", + ), + ]; + + // Meaning policy should not block or trigger review on visibility disputes + assert_eq!(policy.evaluate(&visibility_disputes), Verdict::Adjudicated); + assert!(!policy.requires_review(&visibility_disputes)); + assert_eq!(policy.review_count(&visibility_disputes), 0); +} + +#[test] +fn test_meaning_policy_mixed_disputes() { + let policy = MeaningPolicy::default(); + + let disputes = vec![ + make_dispute("D001", Kind::Meaning, Severity::Low, "minor refactor"), + make_dispute( + "D002", + Kind::Meaning, + Severity::Review, + "changed public API", + ), + make_dispute("V001", Kind::Visibility, Severity::High, "touched .env"), + ]; + + assert_eq!(policy.evaluate(&disputes), Verdict::Adjudicated); + assert!(policy.requires_review(&disputes)); + assert_eq!(policy.review_count(&disputes), 1); +} + +fn tempfile_named(name: &str) -> TempFileGuard { + let dir = std::env::temp_dir().join(format!("oot_test_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + TempFileGuard { path } +} + +struct TempFileGuard { + path: std::path::PathBuf, +} + +impl TempFileGuard { + fn path(&self) -> &std::path::Path { + &self.path + } +} + +impl std::io::Write for TempFileGuard { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let mut file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&self.path)?; + file.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} diff --git a/tests/visibility_test.rs b/tests/visibility_test.rs new file mode 100644 index 0000000..a91990a --- /dev/null +++ b/tests/visibility_test.rs @@ -0,0 +1,172 @@ +use oot::change::{Change, Snapshot, Source}; +use oot::dispute::{Kind, Severity}; +use oot::visibility::VisibilityPolicy; +use std::path::Path; + +#[test] +fn test_visibility_policy_default_values() { + let policy = VisibilityPolicy::default(); + assert_eq!(policy.private_paths, vec!["secrets/", ".env"]); + assert_eq!(policy.embargo_until, None); + assert!(policy.private_branches.is_empty()); + assert_eq!(policy.embargo_note(), None); +} + +#[test] +fn test_visibility_policy_deserialization_from_fixture() { + let fixture_path = Path::new("fixtures/visibility.toml"); + let policy = + VisibilityPolicy::load(fixture_path).expect("Failed to load fixtures/visibility.toml"); + + assert_eq!(policy.private_paths, vec!["secrets/", ".env"]); + assert_eq!(policy.embargo_until.as_deref(), Some("2026-09-01")); + assert!(policy.private_branches.is_empty()); + assert_eq!( + policy.embargo_note().as_deref(), + Some("patch held for maintainers until 2026-09-01") + ); +} + +#[test] +fn test_visibility_policy_private_paths_detection() { + let policy = VisibilityPolicy { + private_paths: vec!["secrets/".into(), ".env".into(), "credentials.json".into()], + embargo_until: None, + private_branches: vec![], + }; + + let mut head = Snapshot::default(); + head.files + .insert("src/main.rs".into(), "fn main() {}".into()); + head.files + .insert("secrets/db_password.txt".into(), "pass123".into()); + head.files + .insert("config/.env.local".into(), "SECRET=foo".into()); + + let change = Change { + name: "feature/add-config".into(), + source: Source::Git, + base_ref: "main".into(), + head_ref: "feature/add-config".into(), + base: Snapshot::default(), + head, + authors: vec!["@alice".into(), "@bob".into()], + intent: Some("Added local secrets".into()), + }; + + let disputes = policy.check(&change); + + // 2 private paths touched: secrets/db_password.txt and config/.env.local + assert_eq!(disputes.len(), 2); + for d in &disputes { + assert_eq!(d.kind, Kind::Visibility); + assert_eq!(d.severity, Severity::High); + assert!(d.detail.contains("@alice/@bob")); + } + + let locations: Vec<&str> = disputes.iter().map(|d| d.location.as_str()).collect(); + assert!(locations.contains(&"secrets/db_password.txt")); + assert!(locations.contains(&"config/.env.local")); +} + +#[test] +fn test_visibility_policy_private_branch_matching() { + let policy = VisibilityPolicy { + private_paths: vec![], + embargo_until: None, + private_branches: vec!["confidential-fix".into(), "security-audit".into()], + }; + + // Change matching private branch in change name + let change_1 = Change { + name: "feature/confidential-fix-v1".into(), + source: Source::Git, + base_ref: "main".into(), + head_ref: "feature/confidential-fix-v1".into(), + base: Snapshot::default(), + head: Snapshot::default(), + authors: vec!["@secops".into()], + intent: None, + }; + + let disputes_1 = policy.check(&change_1); + assert_eq!(disputes_1.len(), 1); + assert_eq!(disputes_1[0].kind, Kind::Visibility); + assert_eq!(disputes_1[0].severity, Severity::High); + assert!(disputes_1[0] + .detail + .contains("private branch confidential-fix referenced by @secops")); + + // Change matching private branch in head_ref + let change_2 = Change { + name: "unnamed-pr".into(), + source: Source::Jj, + base_ref: "main".into(), + head_ref: "refs/heads/security-audit-branch".into(), + base: Snapshot::default(), + head: Snapshot::default(), + authors: vec!["@secops".into()], + intent: None, + }; + + let disputes_2 = policy.check(&change_2); + assert_eq!(disputes_2.len(), 1); + assert!(disputes_2[0] + .detail + .contains("private branch security-audit referenced by @secops")); + + // Change not matching any private branch + let clean_change = Change { + name: "feature/public-ui".into(), + source: Source::Git, + base_ref: "main".into(), + head_ref: "feature/public-ui".into(), + base: Snapshot::default(), + head: Snapshot::default(), + authors: vec!["@frontend".into()], + intent: None, + }; + + let clean_disputes = policy.check(&clean_change); + assert!(clean_disputes.is_empty()); +} + +#[test] +fn test_visibility_policy_embargo_formatting() { + let mut policy = VisibilityPolicy::default(); + assert_eq!(policy.embargo_note(), None); + + policy.embargo_until = Some("2026-11-15".into()); + assert_eq!( + policy.embargo_note().as_deref(), + Some("patch held for maintainers until 2026-11-15") + ); +} + +#[test] +fn test_visibility_policy_slash_stripping_and_matching() { + let policy = VisibilityPolicy { + private_paths: vec!["/internal/keys/".into(), "/cert.pem".into()], + embargo_until: None, + private_branches: vec![], + }; + + let mut head = Snapshot::default(); + head.files + .insert("nested/internal/keys/private.key".into(), "KEY".into()); + head.files.insert("cert.pem".into(), "CERT".into()); + + let change = Change { + name: "infra-update".into(), + source: Source::Memory, + base_ref: "base".into(), + head_ref: "head".into(), + base: Snapshot::default(), + head, + authors: vec!["@infra".into()], + intent: None, + }; + + let disputes = policy.check(&change); + assert_eq!(disputes.len(), 2); +}