diff --git a/.github/workflows/benchmark-gate.yml b/.github/workflows/benchmark-gate.yml new file mode 100644 index 00000000..a6b0bb0a --- /dev/null +++ b/.github/workflows/benchmark-gate.yml @@ -0,0 +1,80 @@ +# Lot G — l'accord avec les merges humains en garde-fou de régression. +# +# Rejoue le corpus épinglé (benchmark/corpus.json) contre le moteur du PR et +# échoue si l'accord baisse au-delà du bruit par rapport à la baseline commitée +# (benchmark/results/*-baseline.json). Généralise le procès de +# token_level_merge (PR #117) : aucun pattern n'entre si le corpus dit qu'il +# rend le moteur moins juste. +# +# Coût maîtrisé : les clones (bare + blobless, ~1,5 Go) sont mis en cache avec +# pour clé le hash de corpus.json — seul le premier run après un re-pin paie le +# clonage. Le replay lui-même prend quelques minutes. + +name: benchmark-gate + +on: + pull_request: + paths: + - "packages/core/**" + - "benchmark/**" + - "scripts/replay-conflicts.mjs" + - ".github/workflows/benchmark-gate.yml" + workflow_dispatch: + +concurrency: + group: benchmark-gate-${{ github.ref }} + cancel-in-progress: true + +jobs: + gate: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + # merge-tree --write-tree exige git >= 2.38 ; ubuntu-latest est bien au-delà, + # mais on échoue explicitement plutôt que de mesurer zéro conflit en silence. + - name: Check git version + run: | + git --version + v=$(git --version | grep -oE '[0-9]+\.[0-9]+' | head -1) + if [ "$(printf '%s\n2.38\n' "$v" | sort -V | head -1)" != "2.38" ]; then + echo "::error::git >= 2.38 required (merge-tree --write-tree)"; exit 1 + fi + + - name: Install & build the engine + run: | + pnpm install --frozen-lockfile --filter @gitwand/core + pnpm --filter @gitwand/core build + + # La clé de cache est le hash du corpus : un re-pin invalide le cache, + # tout le reste le réutilise. restore-keys volontairement absent — un + # cache partiel d'un ancien corpus fausserait la mesure. + - name: Cache the pinned corpus clones + uses: actions/cache@v4 + with: + path: benchmark/.cache + key: benchmark-corpus-${{ hashFiles('benchmark/corpus.json') }} + + - name: Replay the corpus + run: node benchmark/run.mjs --out results/ci.json + + - name: Gate on agreement vs the committed baseline + run: | + baseline=$(ls benchmark/results/*-baseline.json | sort | tail -1) + echo "baseline: $baseline" + node benchmark/compare.mjs "$baseline" results/ci.json + + - name: Upload fresh results + if: always() + uses: actions/upload-artifact@v4 + with: + name: benchmark-results + path: results/ci.json diff --git a/.gitignore b/.gitignore index e3b221f3..0cb2c4c1 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,9 @@ apps/desktop/public/grammars/ # Superpowers .superpowers/ + +# Impeccable (local tool-lease cache, no repo content) +.impeccable/ website/.vitepress/cache website/.vitepress/dist research/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 895d1521..a7f47b88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,33 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added - -- **The Merge Room on `/agent`.** The page is now a shared workspace rather than a tool listing. Tool handlers no longer just return text to the agent: they file a case into live page state that the person sitting there watches fill up. The engine settles every hunk that carries no decision; every hunk where the two branches genuinely disagree queues for a human, who picks a side and gets the assembled file back, ready to paste. No tool on the page can make that call, which is the boundary the whole thing is built around. A live journal records who did what, agent and human alike. -- **`list_cases`**, a third WebMCP tool, lets an agent read the room back: what is filed, what the engine settled, what is still waiting on a person, what they have already decided. It is what turns a stateless calculator into a workspace an agent can pick back up. - -### Fixed - -- **`parse_git_error` did not recognise a rebase that stopped on a conflict.** The catalogue only knew the phrases git prints when you try to *start* a rebase while one is already unfinished (`rebase-merge directory`), not the ones it prints when a rebase *halts*, which is the far more common paste. Reported by an agent audit of the live page. It now keys on the rebase-specific commands, and a halted cherry-pick gets its own entry rather than being mislabelled a rebase: both print `could not apply`, so matching on that phrase would have handed out `git rebase --continue` to someone mid-cherry-pick. - -### Added - - **`/agent`, a WebMCP page.** gitwand.app/agent exposes two read-only git tools to any agent browsing it, over the W3C WebMCP standard: `parse_git_error` explains a failing git command and gives the commands that fix it, `resolve_conflict` runs the deterministic engine over a conflicted file and reports per hunk what was resolved and what still needs a human. Both execute in the visitor's tab, so nothing is uploaded and there is no backend. `@gitwand/core` is imported on demand rather than at module scope, which keeps it out of the shared theme chunk every page downloads. - The page states its own registration status live, including when the browser has no WebMCP at all, and writes both tool contracts out in HTML and JSON-LD for the majority of visitors and crawlers that will never run the script. - **A try-it panel on `/agent`.** Both tools can be run by hand from the page, against editable sample inputs, so the majority of visitors whose browser has no WebMCP can still see what an agent gets back. It calls the same `execute` an agent calls rather than a mock, and deliberately does not feed the agent call counter. +- **The Merge Room on `/agent`.** The page is now a shared workspace rather than a tool listing. Tool handlers no longer just return text to the agent: they file a case into live page state that the person sitting there watches fill up. The engine settles every hunk that carries no decision; every hunk where the two branches genuinely disagree queues for a human, who picks a side and gets the assembled file back, ready to paste. No tool on the page can make that call, which is the boundary the whole thing is built around. A live journal records who did what, agent and human alike. +- **`list_cases`**, a third WebMCP tool, lets an agent read the room back: what is filed, what the engine settled, what is still waiting on a person, what they have already decided. It is what turns a stateless calculator into a workspace an agent can pick back up. +- **The engine now knows what merge it is in.** A new optional `mergeContext` (operation + which side is the target branch) flows from the CLI, the MCP server and the desktop into `@gitwand/core`. Its first use: a version scalar set differently on both sides — `'13.x-dev'` vs `'12.54.1'` — resolves to the **target branch's value**, which is what teams actually ship. Measured by replaying laravel/framework's real merges: agreement with the human resolution on fully-resolved files jumps from 36.6 % to **81.9 %**. Without context, that case is now *proposed* instead of applied — the old fallback was a coin flip measured wrong three times out of four. Orderable dependency bumps deliberately keep "newest wins": flipping those to the target regressed three other corpora, and the benchmark caught it before it shipped. +- **`format_semantic` classification.** A hunk the textual classifier calls `complex` but a format-aware resolver (JSON, Markdown, YAML, lockfiles…) can merge semantically is now reclassified, scored and traced like every other pattern — no more files reported as containing `complex` hunks that were silently applied without a confidence score. +- **Format invariants in post-merge validation.** A resolution that would produce a changelog with two `Unreleased` sections, a duplicated version heading, or a JSON object with duplicate keys is retracted — syntax validation alone passed all three. +- **Key-wise merging for manifest fragments.** Conflicts in `package.json` / `composer.json` are almost always fragments — a few `"key": value,` lines — which the line-level engine merged at exactly the wrong granularity. Those fragments are now merged **by key** (three-way, deletions and one-sided changes handled), with one bounded arbitration: two constraints on the same operator (`^7.23.0` vs `^7.23.3`) resolve to the newer, which is what teams ship. Anything else — operator changes, `workspace:*` migrations — is a human decision and is declined. On the benchmark this is the first change that raises coverage *and* agreement at once, on all four measured repositories. +- **`resolveGeneratedFiles` option** (`.gitwandrc`, and `--resolve-generated` on the CLI). ### Changed - **The engine speaks English.** Every explanation, resolution reason, decision-trace step, confidence booster and penalty `@gitwand/core` produces was written in French. None of the consumers translate them, so the desktop merge editor, the CLI summary and the `@gitwand/mcp` `explanation` / `resolutionReason` fields have been handing French text to every user and every agent, whatever their locale. 194 strings translated across 38 files. Comments and test names stay French: this is only about what leaves the engine. - A regression guard (`__tests__/english-output.test.ts`) runs the engine over the whole corpus and asserts that no string it hands back is French, checking real output rather than scanning source so it cannot be fooled by how a string is assembled. It caught four strings a source scan had missed, including one with no accented characters in it. +- **Generated files decline by default.** Lockfiles, minified bundles and `dist/` outputs are regenerated by tools, not merged — measured on 1,662 real merges, auto-merging them diverged from what teams shipped in almost every case. GitWand now explains what to regenerate instead of writing a plausible-but-wrong merge; only the patterns that fabricate nothing (same change, one-sided change, deletion, whitespace) still apply. The previous behaviour is one `.gitwandrc` key away. +- **A reproducible benchmark now backs every accuracy claim** — `benchmark/` pins 8 public repositories to exact commits, replays ~1,700 merges through the engine and compares the output byte-for-byte with what the teams actually committed. The engine changes above were driven, and one of them corrected, by its numbers. ### Fixed +- **`parse_git_error` did not recognise a rebase that stopped on a conflict.** The catalogue only knew the phrases git prints when you try to *start* a rebase while one is already unfinished (`rebase-merge directory`), not the ones it prints when a rebase *halts*, which is the far more common paste. Reported by an agent audit of the live page. It now keys on the rebase-specific commands, and a halted cherry-pick gets its own entry rather than being mislabelled a rebase: both print `could not apply`, so matching on that phrase would have handed out `git rebase --continue` to someone mid-cherry-pick. - **`pnpm test` was non-deterministic (#172).** Suites that use real git repositories are subprocess-bound, not CPU-bound, and were timing out under the load of the whole monorepo testing at once. Every git-backed suite now shares a 60s timeout, chosen to catch a hang rather than to enforce a performance budget. Running workspaces one at a time turned out to be **faster** as well as deterministic (49s against 119s), because five packages each fanning out to one worker per core oversubscribes the machine several times over, so `pnpm test` now passes `--workspace-concurrency=1`. Eleven consecutive full runs green, against roughly one failure in three before. - - **Site-wide WebMCP tools went dark on browsers without `navigator.modelContext`.** The registration script bailed out entirely unless the deprecated `navigator` location existed, so the three documentation tools would disappear the day Chrome removes the alias it deprecated in 150. It now prefers `document.modelContext`, where the spec has put the entry point since 27 May 2026, and falls back to `navigator` only when that is all the browser offers. It registers once either way: on the versions exposing both names they alias the same object, so registering on both would have duplicated every tool. - **WebMCP tools could never be unregistered.** `signal` was passed as a property of the tool dictionary, which declares no such member, so it was silently dropped. It now goes in the options argument where `ModelContextRegisterToolOptions` expects it. - - **Signed commits failed from the GUI with a gpg-agent/ssh-agent socket error (#171)**, even though the identical `git commit` succeeded from Terminal. On macOS, a Finder/Dock-launched GitWand backfills its minimal `launchd` environment by reading `$SHELL -l -c env` once at startup, but `-l` (login) alone does not make zsh source `~/.zshrc` — only an interactive shell does, and setup guides for gpg/ssh agents (`export GPG_TTY=$(tty)`, agent-socket exports for tools like 1Password/YubiKey) conventionally live there. The probe now runs `$SHELL -i -l -c env`, so those exports are captured like any other shell-rc variable. ## [3.8.0] - 2026-08-24 diff --git a/README.md b/README.md index 9d50154a..d9750cfc 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ The registry holds **12 patterns**, of which **8 auto-apply**. The other four ei The confidence column is indicative: every hunk carries a computed `ConfidenceScore` (see below), not a fixed label. `value_only_change`, for instance, scores on the ratio of volatile tokens to total tokens and rejects the hunk outright below its threshold. -**Not a registry pattern:** `generated_file` is a separate reclassification pass that runs after classification. When a hunk lands in `complex` and its path matches a generated-file glob (lockfiles, bundles, `dist/`, plus anything in `generatedFiles`), the resolver rewrites it to `generated_file` and resolves to *theirs*, on the assumption the file will be regenerated. It appears in `ConflictType` but never in the classifier registry. +**Not a registry pattern:** `generated_file` is a separate reclassification pass that runs after classification. When a hunk's path matches a generated-file glob (lockfiles, bundles, `dist/`, plus anything in `generatedFiles`), the resolver rewrites it to `generated_file` and, by default, declines with an actionable reason rather than guessing — [measured on 1,662 real merges](https://github.com/devlint/GitWand/tree/main/benchmark), auto-merging generated files diverged from what teams actually shipped in almost every case. GitWand tells you to resolve the source file and re-run the installer/build instead. Opt back into the old accept-theirs/semantic-merge behavior with `.gitwandrc`'s `resolveGeneratedFiles: true` or `gitwand resolve --resolve-generated`. `generated_file` appears in `ConflictType` but never in the classifier registry. ### Composite confidence score @@ -192,6 +192,8 @@ Every resolution carries a `ConfidenceScore` object rather than a simple label: } ``` +(Shape shown for a `generated_file` hunk with `resolveGeneratedFiles: true` — the default is to decline generated files rather than score and apply them; see the pattern table above.) + Score formula: `score = typeClassification − dataRisk×0.4 − scopeImpact×0.15` ### Format-aware resolvers diff --git a/ROADMAP.md b/ROADMAP.md index 1b497e56..dea73f76 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,10 +6,11 @@ ## What's Next -_Ordered by priority, last verified 2026-08-24 (current after v3.8.0 shipped Time Machine, which laid the safety net the auto-apply work needed). The thread: make the app reactive and fast (v3.9), close the resolution loop (v3.10), then workflow & comparison primitives (v3.11–v3.12), experimental voice input (v3.13), and the v4.0 code-intelligence headline. Full renumbering history: `git log -p -- roadmap.md`._ +_Ordered by priority, last verified 2026-08-26 (current after v3.8.0 shipped Time Machine, and after the Engine Accuracy work landed on `feat/conflict-engine-accuracy`, ready to ship as the next release). The thread: ship the measured-accuracy engine first (v3.8.x/v3.9 — it re-founds the trust every later auto-apply feature spends), make the app reactive and fast (Live Repo), close the resolution loop (preview-to-apply, whose confidence threshold is only meaningful **because** of the accuracy work), then workflow & comparison primitives, experimental voice input, and the v4.0 code-intelligence headline. Full renumbering history: `git log -p -- roadmap.md`._ | Version | Codename | Why now | |---------|----------|---------| +| **next release** | Engine Accuracy | **Implemented** on `feat/conflict-engine-accuracy` — measured agreement with real human merges: laravel 24 → 83 %, prettier 25 → 50 % | | **v3.9.0** | Live Repo | Reactive & fast — FS events replace polling, libgit2 phase 1 | | **v3.10.0** | Merge preview-to-apply | Close the resolution loop — apply straight from preview, editable diff | | **v3.11.0** | Stacked Branches | Native stacked PRs, sequenced after v3.10 (leans on preview→apply) | @@ -17,6 +18,34 @@ _Ordered by priority, last verified 2026-08-24 (current after v3.8.0 shipped Tim | **v3.13.0** | Voice Input | Experimental — local dictation via embedded Whisper | | **v4.0.0** (candidate) | Blast Radius | Code-graph impact before merge — the code-intelligence headline | +### Next release — Engine Accuracy (implemented, `feat/conflict-engine-accuracy`) + +_The engine's claims are now measured instead of asserted, and three measured failure modes were fixed. Spec: [`docs/superpowers/specs/2026-08-26-conflict-engine-accuracy.md`](docs/superpowers/specs/2026-08-26-conflict-engine-accuracy.md) · benchmark: [`benchmark/`](benchmark/). Takes the next free version number at bump time — in-code comments reference these as "accuracy lot 1/C/E" to avoid colliding with the numbers below._ + +**Shipped on the branch (7 commits, all suites green):** + +- **Reproducible benchmark** (`benchmark/`) — 8 public repos pinned to SHAs, ~1,700 real merges replayed, engine output compared byte-for-byte with what the teams actually committed. Two metrics: coverage (varies 0–76 % by repo — a property of the *codebase*) and agreement (a property of the *engine*). Every claim below is a measurement from it. +- **Lot 1 — classifier contract, format invariants, generated files decline.** No `complex` hunk is ever silently applied (format resolvers now classify as `format_semantic`, scored and traced); a resolution that breaks a format invariant (double `Unreleased`, duplicate JSON key) is retracted; lockfiles/bundles decline with an actionable message instead of an auto-merge measured wrong ~100 % of the time (`resolveGeneratedFiles` opt-in in `.gitwandrc` / `--resolve-generated`). +- **Lot C — MergeContext.** The engine finally knows what merge it is in (operation + target side, detected by CLI/MCP/desktop from `.git` state). Version-identity conflicts (`'13.x-dev'` vs `'12.54.1'`) resolve to the target branch — laravel agreement 36.6 → 81.9 %. The first version of the rule also flipped orderable dep bumps and regressed three repos; **the benchmark caught it before it shipped** (the whole point). +- **Lot E — key-wise manifest fragments.** `package.json`/`composer.json` conflict hunks merged by key (3-way), with one bounded arbitration: same-operator ranges (`^7.23.0` vs `^7.23.3`) → newer. First change to raise coverage AND agreement at once, on all four measured repos. + +| Agreement with the human merge | v3.8.0 | after | +|---|---:|---:| +| laravel/framework | 24.3 % | **83.3 %** | +| prettier/prettier | 25.3 % | **49.6 %** | +| expressjs/express | 59.6 % | **61.3 %** | +| vuejs/core | 92.5 % | **90.1 %** (denominator artefact — zero per-file flips; see benchmark/README) | + +**Follow-ups (each wants its own plan):** + +- **Lot F — derive the repo's own conventions** (the moat): point `scripts/replay-conflicts.mjs` at the *user's* repository to measure their policies — regenerate-vs-merge lockfiles, who wins version scalars, changelog discipline — instead of assuming them. Feeds the same `useResolutionMemory` feedback loop v4.0 plans; this is its active, measured form. Nobody else in the market can do this, and the mechanism already exists. +- **Lot G — agreement as a CI gate**: a new pattern must not lower agreement on the pinned corpus (generalizes the `token_level_merge` trial, PR #117). Needs a corpus cache strategy — a cold clone is several GB. +- **Lot D (full) — sandboxed regeneration** ([plan](docs/superpowers/plans/2026-08-26-regenerate-tier.md)): for declared-generated files, resolve the source manifest then run the ecosystem's own tool (`npm install --package-lock-only`, …) in a sandbox with explicit consent; today's interim (decline + explain) stays the fallback. +- **Corpus re-pin**: select on `git rev-list --merges --count` — cargo contributes zero conflicted merges, django ten; language diversity is worthless without merge history. +- Website tie-in: the site stopped claiming "95 %" (circular denominator) and links the benchmark; keep site numbers sourced from `benchmark/results/` only. + +--- + ### v3.9.0 — Live Repo: filesystem events + libgit2 phase 1 _Inspired by GitUp's Live Map. Replace the 2s status poll with real FS events, and start the shell-out → libgit2 migration on the cheap-refresh path._ @@ -42,7 +71,7 @@ _Inspired by Aurees. Close the loop between the Conflict Predictor (v2.20.0) and **Today's baseline** — `preview_merge` / `preview_rebase` / `preview_cherry_pick` + `useMergePreview.ts` already compute per-hunk auto-resolvability side-effect-free, but the preview is display-only: the user then merges blind or detours via scratch worktree. `DiffViewer.vue` is read-only; `MergeEditor.vue` edits via a bare textarea. CodeMirror 6 ships in-app since v3.2.0 (File Explorer/Editor). - **Apply from preview** — "Apply N auto-resolutions & merge" straight from `MergePreviewPanel`: run the operation, apply the engine's resolutions, stop only on the residual manual hunks -- **Hunk-level opt-out + confidence threshold** — untick individual auto-resolutions, or set a global bar ("apply only ≥ 90% confidence") surfacing the engine's per-hunk confidence (audit-trail preserved, cf. v2.5.0) +- **Hunk-level opt-out + confidence threshold** — untick individual auto-resolutions, or set a global bar ("apply only ≥ 90% confidence") surfacing the engine's per-hunk confidence (audit-trail preserved, cf. v2.5.0); this threshold is only meaningful since the Engine Accuracy release — format resolvers now carry a real confidence instead of bypassing the gate, and the benchmark measures what the scores are worth - **History-aware LLM fallback** — enrich `llm_proposed` prompts with the blame/history of the conflicting lines (Greptile-style multi-hop context, computed locally) - **Editable diff** — inline editing in the diff view (CodeMirror 6, reusing the v3.2 editor setup): fix a typo or resolve a trivial conflict where you see it, without switching to the merge editor - **MergeEditor upgrade** — replace the textarea with the same CodeMirror 6 component (syntax highlighting, line numbers already themed); while in this code, re-surface the "Split this commit…" / edit affordance after a mid-rebase conflict handoff (#128 follow-up) — today only Continue/Skip/Abort survive once `RebaseEditor` unmounts for the conflict banner @@ -105,7 +134,7 @@ _Inspired by Snipara's project-intelligence layer. Before a merge/rebase, answer - **Co-change analysis** — "these files historically change together" mined from local `git log` (zero cloud, cheap); a second impact signal complementing the static import graph, exactly the history hop Greptile does server-side - **Blast Radius panel** — new tab in `MergePreviewPanel`: impacted files ranked, affected symbols, suggested test scope; feeds a `blastRadius` dimension alongside `postMergeRisk` - **Review ordering** — blast radius reused in the PR review (v3.5.0): files ranked by impact, "start with these 2 files" -- **Feedback loop** — rejected impact predictions / auto-resolutions lower the pattern's confidence (extends `useResolutionMemory`), the local analog of Greptile v4's false-positive reduction +- **Feedback loop** — rejected impact predictions / auto-resolutions lower the pattern's confidence (extends `useResolutionMemory`), the local analog of Greptile v4's false-positive reduction; the Engine Accuracy release's lot F (derive the repo's conventions by replaying its own history) is the active, measured form of the same idea — the two should share one store - **Agents too** — exposed via `@gitwand/mcp` (`gitwand_blast_radius`) and CLI, so AI agents can check impact before committing a resolution. Positioning: Greptile sells this as a paid API ("Genius API", $0.45/req) — ours is local, free, open source - **Opt-in & lazy** — computed post-preview, never blocking the merge flow; enabled in Settings diff --git a/apps/desktop/src/composables/useGitWand.ts b/apps/desktop/src/composables/useGitWand.ts index e4a14f44..114593e4 100644 --- a/apps/desktop/src/composables/useGitWand.ts +++ b/apps/desktop/src/composables/useGitWand.ts @@ -1,5 +1,5 @@ import { ref, computed } from "vue"; -import { parseGitwandrc, type MergeResult, type ConflictHunk, type GitWandOptions, type MergePolicy, type LlmFallbackConfig } from "@gitwand/core"; +import { parseGitwandrc, type MergeResult, type ConflictHunk, type GitWandOptions, type MergePolicy, type LlmFallbackConfig, type MergeContext } from "@gitwand/core"; // `resolve`, `resolveAsync` and `parseConflictMarkers` are loaded lazily via // `engine()` (see ../utils/coreEngine.ts) — they pull in the classifier + // full pattern registry (~243 KB raw / ~73 KB gzip) and must stay out of the @@ -18,6 +18,7 @@ import { resolveTreeConflict, reconstructConflict, gitStage, + gitRepoState, } from "../utils/backend"; import { useFolderHistory } from "./useFolderHistory"; import { useAIProvider } from "./useAIProvider"; @@ -426,6 +427,9 @@ export function useGitWand() { policy: cfg.policy, patternOverrides: cfg.patterns, generatedFiles: cfg.generatedFiles, + // accuracy lot 1 — opt-in repo-level : ré-autorise l'auto-résolution des + // fichiers générés (le défaut du moteur est de décliner). + resolveGeneratedFiles: cfg.resolveGeneratedFiles, }; } // v2.5 — `llmFallback` n'est pas géré par `parseGitwandrc` (qui @@ -480,12 +484,42 @@ export function useGitWand() { // Non-fatal : visible dans le toast d'erreur, mais on continue. error.value = msg; } + // accuracy lot C — Contexte de merge : l'app sait quelle opération est en cours + // (git_repo_state lit .git directement). Convention des marqueurs git : + // « ours » est la branche cible pour merge, rebase ET cherry-pick — déclaré + // explicitement pour que le moteur n'ait jamais à re-dériver l'inversion + // ours/theirs du rebase. `null` hors opération : le moteur propose au lieu + // d'appliquer sur les décisions qui dépendent du contexte. + let mergeContext: MergeContext | null = null; + try { + const st = await gitRepoState(cwd); + const OP: Record = { + merge: "merge", rebase: "rebase", rebase_interactive: "rebase", + cherry_pick: "cherry-pick", revert: "revert", + }; + const operation = OP[st.state]; + if (operation) { + // `st.targetBranch` vient de rebase-merge/head-name : c'est la branche + // EN COURS DE REBASE (le travail de l'utilisateur) — donc « theirs » + // dans la convention des marqueurs, pas la branche onto. Pour merge / + // cherry-pick / revert, le backend ne renvoie pas de ref (null). + mergeContext = { + operation, + targetSide: "ours", + theirsRef: (operation === "rebase" ? st.targetBranch : null) ?? undefined, + }; + } + } catch { + // état illisible → contexte inconnu, comportement conservateur du moteur + } + const resolveOptionsWithLlm: GitWandOptions = (llmCfg?.enabled && aiEndpoint) ? { ...resolveOptions.value, + mergeContext, llmFallback: { ...llmCfg, endpoint: aiEndpoint }, } - : resolveOptions.value; + : { ...resolveOptions.value, mergeContext }; // Lazily load the engine once for this whole batch — memoized by // `engine()`, so the dynamic import only actually happens on the very diff --git a/benchmark/README.md b/benchmark/README.md index 247f9147..efbf4987 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -111,17 +111,33 @@ reproduce. So the metric is a **lower bound on correctness**, not a score, and i is reported as exact and whitespace-normalised counts with retained examples rather than as a single grade. -### The corpus needs re-pinning - -This run also indicts the corpus. `rust-lang/cargo` contributed **zero** merges -with conflicts, `django/django` ten, `vuejs/core` thirty-five — these projects -squash-merge or use a merge queue, so there is almost nothing to replay. Four -repositories carry the entire result. - -The next pin should select for *projects that actually merge feature branches*, -verified by `git rev-list --merges --count HEAD` before adding them, rather than -for language coverage. Language diversity is worth nothing if the repository has -no conflicted merges in it. +### Corpus v2 (pinned 2026-08-26) — selected on measured merge history + +The v1 corpus indicted itself: cargo contributed zero conflicted merges, django +ten, vue thirty-five. v2 was re-pinned after **probing** candidates +(`rev-list --merges` + a merge-tree conflict-rate sample): kubernetes, rails and +godot were rejected at 0 conflicted merges per 60 (merge queues); symfony +(back-merge culture, composer.json in half its conflicts), git/git (integration +branches, maintainer-resolved conflicts — the best human ground truth available) +and bootstrap (an adversarial `_variables.scss` family no resolver special-cases) +came in. vue was dropped *despite* being the 92–95 % showcase — keeping it would +have been flattering rather than informative. + +Current-engine baseline on v2 (`results/v3.8.0-corpus2-baseline.json`): +**1 927 merges, 634 with conflicts, 5 675 hunks — 59.2 % of end-to-end-resolved +files byte-identical to the human merge (391/660), per-repo spread 17.5–65.4 %.** +This file is the reference the CI gate compares against. + +### The CI gate (lot G) + +`.github/workflows/benchmark-gate.yml` replays the corpus on every PR touching +the engine and fails via [`compare.mjs`](compare.mjs) when agreement drops +beyond noise (−1.5 pts corpus-wide, −5 pts on any repo) or coverage collapses +(>−25 % files resolved end-to-end) — a deliberate decline policy must update +the baseline in the same PR, with the reasoning in the commit message. Clones +are cached keyed on the corpus hash, so only the first run after a re-pin pays +the cloning. This generalizes the `token_level_merge` trial (PR #117): no +pattern ships if the corpus says it makes the engine less right. ## What it does NOT measure @@ -203,7 +219,587 @@ GitWand answers — and points here for the numbers, with their denominators attached. When the corpus is re-pinned and the regenerate-by-convention paths are settled, there will be a figure worth putting on a landing page. -## Results +## Measured impact of the engine changes + +The corpus is already earning its keep. Same pins, three engine states +(files resolved end-to-end that are byte-identical to the human merge): + +| Repo | v3.8.0 baseline | + lot 1 (contract/invariants/decline) | + merge context | + key-wise manifests | +|---|---:|---:|---:|---:| +| `laravel/framework` | 24.3 % | 36.6 % | 81.9 % | **83.3 %** (245 files) | +| `prettier/prettier` | 25.3 % | 45.0 % | 45.0 % | **49.6 %** (117 files) | +| `vuejs/core` | 92.5 % | 95.0 % | 90.0 %* | **90.1 %** (222 files) | +| `expressjs/express` | 59.6 % | 59.2 % | 59.2 % | **61.3 %** (62 files) | + +The key-wise manifest merge (lot E) is the first change that raises **both** +metrics at once: more files resolved end-to-end (laravel 216 → 245, express +49 → 62) *and* a higher share of them byte-identical — because merging +`"key": value` fragments by key, with a bounded same-operator version +arbitration, replaces the line-level union that produced plausible-but-wrong +dependency blocks. + +\* vue's apparent drop is a **denominator artefact, not a regression**: a +per-file flip scan found zero files where the previous engine agreed and the +new one doesn't. Fixing the version-identity hunk pulls previously-excluded +files into the comparable set, where they disagree on *other* hunks — all 11 +in one merge, dominated by a `workspace:*` protocol migration the humans did +while merging (an evil merge nothing reproduces). + +The merge-context rule also went through one refinement this table forced: +its first version sent *orderable* semver pairs to the target side too, and +agreement regressed on prettier (45.0 → 39.0), vue and express — teams do take +the newer dependency brought by the source branch. Target-wins now applies +only to unorderable version pairs (the file's version identity: `13.x-dev`, +`2.9.0-dev`), which is where all of laravel's gain lives. This is exactly the +kind of decision the benchmark exists to make. + +## Split-half validation of derived conventions (lot F gate) + +Lot F derives a repo's own merge conventions from its history. Gate protocol: +derive on the older half of each corpus repo's merges, measure agreement on +the recent half with and without the derived conventions applied. + +Result (engine at lot E): **flat everywhere — zero regressions, zero gains.** +prettier and vue derive `generatedFiles: regenerate` at 100 % agreement (16 +and 5 samples); laravel and express clear no evidence floor. Nothing changes +behaviour because every verdict *confirms the engine's defaults*. + +That is not a null result — it is a circularity warning worth recording: the +defaults were calibrated on this corpus, so conventions derived from the same +corpus can only agree with them. The layer's value is (a) **provenance** — +"declined because your repo regenerates lockfiles, measured on 16 merges" is a +different product than "declined because we say so" — and (b) repos that +**diverge** from the defaults: a team that genuinely merges its lockfiles gets +its auto-resolution back (verdict `merge`), a tool-rebuilt changelog gets its +unions declined. Both behaviours are pinned by unit tests on fabricated +histories; demonstrating them on real public repos needs corpus candidates +*selected for divergent conventions*, which the next re-pin should include. + +Per the gate, the desktop surface is deferred; core + CLI ship (the +measurement itself, `gitwand conventions`, has standalone value). + +Re-run on the v2 corpus additions (symfony, git/git, bootstrap): still flat — +each half of their histories yields too few per-question samples to clear the +evidence floors. The verdict stands, and sharpens: the layer will prove itself +either on repos with *dense* lockfile/changelog conflict histories, or once +pathPolicies graduate from report-only to applied (lot F v2). + +## Regenerate-tier replay (accuracy lot D, task 4) + +The agreement metric above (`run.mjs`/`replay-conflicts.mjs`) replays merges with +`git merge-tree --write-tree`, which never touches a working tree — it can score +whether a *textual* merge would match the human's, but it cannot score +regeneration, because regeneration is "resolve `package.json`/`composer.json`, +then re-run the ecosystem's installer and take *its* output as the answer". +That needs a real checkout and a real `npm install`/`composer update`/`yarn +install` invocation. `scripts/replay-regenerate.mjs` is that harness: + +1. **Cheap stage** — same `merge-tree --write-tree` (diff3) sweep as + `replay-conflicts.mjs`, over the already-cloned corpus repo, to find + candidate merges: ones whose conflict set includes a lockfile from the v1 + registry (`packages/core/src/regenerate/registry.ts` — npm, pnpm, + yarn-berry, composer, cargo). +2. **Expensive stage**, bounded to `--max-real` merges per ecosystem (the + plan's own ceiling: ≤ 20) — for each candidate: settle the ecosystem's + `sourcesOfTruth` from the merge-tree result (clean, or resolved via + `@gitwand/core`'s `resolve()`; a still-conflicted source makes the plan + non-runnable, exactly as `buildRegenerationPlan` decides for the real + CLI), then run the **actual production executor** — + `runRegeneration()` from `packages/cli/src/regenerate-runner.ts` — in a + disposable `git worktree`, and structurally compare the regenerated + lockfile against the one the team actually committed + (`scripts/lib/regenerate-compare.mjs`). + +Reusing `runRegeneration` (rather than reimplementing the executor for the +harness) means this measures the exact code path the CLI ships, not a stand-in. + +### Structural comparison + +Byte-exact comparison almost never holds — dependency resolvers vary resolved +URLs, integrity hashes and key ordering run-to-run even with unchanged inputs. +`regenerate-compare.mjs` extracts the `name@version` identity set from each of +the five registry lockfile formats (`packages`/`dependencies` for npm, +`packages`/`packages-dev` for composer, the `packages` map keys for pnpm, the +locator blocks for yarn-berry, `[[package]]` tables for cargo) and compares +those sets — ignoring hashes, resolved URLs and ordering by construction. If +format-aware parsing fails (corrupt output, an unexpected variant), +it falls back to a text compare via `stripVolatileValues` +(`@gitwand/core`, exported from `packages/core/src/resolver/generated-detection.ts` +for this purpose) rather than crashing the run. Both paths are covered by +fixture tests — `node --test scripts/lib/regenerate-compare.test.mjs` (also +`pnpm run test:scripts-lib` from the repo root, which runs every +`scripts/lib/*.test.mjs` file, including `seed-index.test.mjs`) — fast, no +network, no real installs: hand-built lockfile pairs that are +identical-modulo-volatile-values (must match) and pairs with a genuinely +different dependency graph (must not). + +### Running it + +```bash +pnpm --filter @gitwand/core build # replay imports the built engine +pnpm --filter @gitwand/cli build # replay reuses the real runRegeneration() executor +node scripts/replay-regenerate.mjs \ + [--max-merges N] [--max-real N] [--ecosystem npm,composer,...] [--timeout-ms N] [--json] +``` + +`` must already be a local clone with the target commit reachable +(bare + blobless + pinned, exactly like `benchmark/run.mjs`'s `prepare()` — this +script does not clone for you, same separation of concerns as +`replay-conflicts.mjs`). Requires the ecosystem's own toolchain in `PATH` +(`npm`/`pnpm`/`yarn`/`composer`/`cargo`) and network access to the relevant +package registry; a missing toolchain or offline registry is reported as a +graceful per-candidate skip, not a crash. + +### Why this lives outside the CI gate + +Same reasoning as `replay-conflicts.mjs`/`run.mjs` being operator-run tools: +this script needs the corpus repos already cloned, needs real network access to +package registries, spawns real installers with real wall-clock timeouts, and a +dependency resolver's output is not byte-for-byte deterministic run to run — +none of that belongs in a required CI check. `scripts/replay-regenerate.mjs` +is run manually/in the container, same as its siblings. + +### Pilot run (2026-08-27) — superseded by the full sweep below + +Before the merge-index-seeding fix (a follow-up plan's tasks 2–3: +`replay-regenerate.mjs` and the CLI's disposable worktree both now overlay +every already-resolved (stage-0) path of the real 3-way merge index onto the +`HEAD` worktree, in place of the `HEAD`-only scaffold this pilot ran against +— this makes `theirs`-only files visible to the installer; it does not, and +never did, change the seed state of the still-conflicted lockfile itself, +which stays at its `HEAD` content either way — see the fix's own doc comment +in `packages/cli/src/regenerate-runner.ts` for the precise scope), a bounded pilot +(`prettier/prettier`, yarn-berry, `--max-real 5`) measured **66.7 % (2/3)** +agreement, n = 3, and flagged the `HEAD`-only seeding as hypothesis (d) for +why the number might be low. That pilot's full write-up (including the +`laravel/framework`/`symfony/symfony` composer infeasibility finding, which +still stands unchanged) is preserved in git history; see the section below for +the real, full-scale numbers gathered after the fix. + +### Full corpus sweep (2026-08-28) — post merge-index-seeding fix, superseded by "Full corpus sweep re-run #2" below (the first conclusive result) + +> **This section's numbers are INVALIDATED, not corrected — do not treat any +> figure below as reliable.** The harness that produced this sweep had a real +> bug: `scripts/lib/seed-index.mjs`'s `seedScratchIndex` built its scratch +> index via `git read-tree ` of a single tree, which puts EVERY path +> in that tree at stage 0 — including paths that were genuinely conflicted in +> the 3-way merge. `merge-tree --write-tree`'s conflicted blobs hold literal +> diff3 conflict-marker text as their content, so `checkout-index --all +> --force` wrote marker-laden content into the disposable worktree for every +> conflicted path in each candidate merge — a worktree state the real +> production CLI can never produce (a genuine in-progress merge's index keeps +> conflicted paths at stages 1/2/3, which `checkout-index --all` always +> skips). This most likely explains the dominant `spawn-failed` failure mode +> in the numbers below (11 of 13 runnable `prettier/prettier` candidates +> failed inside `yarn install` itself). The bug is now fixed (see the final +> review fix wave that added `skipPaths` to `seedScratchIndex` and +> `conflictedPaths` to candidate discovery in `scripts/replay-regenerate.mjs`) +> — but **a fresh full sweep against the fixed harness is required before this +> gate can be evaluated at all.** No estimate of what the corrected numbers +> would be is given here; none is implied by anything below. + +Per this follow-up plan's task 4: the fix from tasks 2–3 is merged, so this is +the real ≤ 20-real-attempts-per-ecosystem sweep the pilot deferred, run against +all four corpus v2 repos whose language makes a v1-registry lockfile plausible +(`prettier/prettier`, `tauri-apps/tauri`, `expressjs/express`, +`twbs/bootstrap` — `laravel/framework`/`symfony/symfony` are still excluded, +confirmed infeasible for composer per the pilot's finding above; +`gohugoio/hugo`/`git/git` are outside the v1 registry's ecosystems entirely). +Each repo was cloned bare+blobless and pinned to its exact `benchmark/corpus.json` +SHA (`prepare()`'s recipe), then run through +`node scripts/replay-regenerate.mjs --max-real 20 --json`. + +| Repo | Merges scanned | Ecosystem | Candidates found | Attempted | Runnable plans | Ran | Comparable | Matched | Agreement rate | +|---|---:|---|---:|---:|---:|---:|---:|---:|---:| +| `expressjs/express` | 485 | *(none)* | 0 | — | — | — | — | — | no candidates | +| `twbs/bootstrap` | 500 | *(none)* | 0 | — | — | — | — | — | no candidates | +| `prettier/prettier` | 237 | yarn-berry | 85 | 20 | 13 | 1 | 1 | 1 | 100.0 % (1/1) | +| `tauri-apps/tauri` | 56 | cargo | 22 | 20 | 0 | 0 | 0 | 0 | n/a (0 runnable) | +| `tauri-apps/tauri` | 56 | yarn-berry | 10 | 10 | 0 | 0 | 0 | 0 | n/a (0 runnable) | + +**TOTAL, weighted by comparable attempts across all repos/ecosystems: 1/1 matched = 100.0 %.** + +Detail per repo, exactly as measured, no rounding or omission: + +- **`expressjs/express`** — 485 merges scanned, **zero** candidate merges + across all five v1-registry ecosystems. `git ls-tree -r HEAD` confirms this + repo carries **no lockfile at all** (no `package-lock.json`, + `pnpm-lock.yaml`, `yarn.lock`, `composer.lock` or `Cargo.lock`) at the + pinned commit — the regenerate tier has literally nothing to measure here. + This matches corpus.json's own framing of `expressjs/express` as a control + repo ("a repo where the engine should have little to do"). +- **`twbs/bootstrap`** — 500 merges scanned, **zero** candidate merges, despite + a committed `package-lock.json` existing in the tree (confirmed via + `git ls-tree`). None of the 500 scanned merges happened to conflict on it. +- **`prettier/prettier`** — 237 merges scanned (unchanged from the pilot, same + pin), 85 yarn-berry candidates found (unchanged from the pilot — candidate + discovery is deterministic and pin-stable). Of the 20 attempted (the + script's own cap): 7 **not-runnable** (`package.json` didn't fully settle via + `resolve()`), 13 runnable, and of those 13: **11 `spawn-failed`** (`yarn + install --mode=update-lockfile` exited 1), **1 `error`** (an unrelated + partial-clone/promisor-fetch failure on one historical blob, not a + regeneration-logic failure), and **1 `success`** — which also + structurally matched the human-committed `yarn.lock`. Comparable sample: + **n = 1**, agreement **100.0 %**. +- **`tauri-apps/tauri`** — only **56** merge commits are reachable from the + pinned SHA (`rev-list --merges` walked the real, smaller history at this + pin; not a truncation bug). 22 cargo candidates and 10 yarn-berry candidates + were found (**32 candidates found**), but cargo's attempts were capped at + `--max-real 20`, so only **30 candidates attempted** (20 cargo + all 10 + yarn-berry) — **all 30 attempted candidates across both ecosystems came back + `not-runnable`** — `@gitwand/core`'s `resolve()` never fully settled + `Cargo.toml`/`package.json` for any of them, so zero plans ever reached the + regeneration step. Zero runnable, zero ran, zero comparable. + +### The gate verdict (2026-08-28 sweep — invalidated, see re-run below) + +**n = 1, comparable.** The literal number, `1/1 = 100.0 %`, is arithmetically +above the ≥ 80 % target — but reporting that as "target met" would be exactly +the kind of rounding-up this project's discipline forbids. One data point is +not evidence of reliability in either direction. **Verdict: genuinely +inconclusive**, not "met." The real, full-scale sweep this task ran produced +a *smaller* comparable sample (n = 1) than the pilot it was meant to supersede +(n = 3) — running the harness against real network access and all four +in-scope corpus repos did not produce more comparable data; it mostly +produced a different, larger population of **non-comparable** outcomes +(`not-runnable`, `spawn-failed`, zero candidates). + +Per the plan's own instruction for an inconclusive/below-target outcome: +**keep CLI opt-in only** (already true — `--regenerate`/`.gitwandrc` +`regenerate: true` already gate every regeneration behind explicit consent, +since tasks 1–3 of the original plan), **document findings, stop here.** The +desktop surface (task 5 of the original plan) is **not** justified by this +evidence — n = 1 justifies nothing either way. Do not read this section as +"the fix worked" or "the fix didn't work"; neither claim is supportable from +one data point. + +On hypothesis (d) specifically (does merge-index seeding move the number): +**this sweep cannot confirm or refute it, for a reason stronger than "different +failure surface" — see the invalidation notice above this section.** The +dominant `spawn-failed` bottleneck (11 of 13 runnable `prettier/prettier` +candidates failing inside `yarn install --mode=update-lockfile` itself) has +since been root-caused: the harness's scratch-index construction had a real +bug that materialized diff3 conflict-marker text into the disposable +worktree for paths a genuine in-progress merge would have left untouched — +exactly the kind of corrupted input that would make `yarn install` fail. That +bug is now fixed (see the invalidation notice). The "side effect of a more +realistic merge-index state" explanation this paragraph previously floated is +superseded by that finding — it is not a competing hypothesis still worth +weighing, it was this sweep measuring its own harness bug. **The numbers in +this section remain invalidated regardless of which explanation is +correct; a fresh sweep against the fixed harness is required either way.** + +Before revisiting: (a) a corpus re-pin adding an application-shaped PHP repo +so the composer leg becomes measurable at all is still needed and is +explicitly out of scope for this plan; (b) the harness bug behind the +`spawn-failed` bottleneck is now fixed (see the invalidation notice above) — +what's still needed is the fresh full sweep itself, not further root-causing; +(c) `tauri-apps/tauri`'s 100 % `not-runnable` rate across both +its ecosystems (32 candidates found, 30 attempted — cargo capped at +`--max-real 20`, all 10 yarn-berry attempted) suggests `resolve()`'s handling +of `Cargo.toml`/`package.json` conflicts in a large mixed-language monorepo +may itself be a bigger practical ceiling on this feature than the +regeneration step being measured here — worth its own investigation; (d) the +fresh sweep against the fixed harness needs a materially larger comparable +sample (not just a larger attempted count) before the ≥ 80 % target can be +honestly called met or missed. + +### Full corpus sweep re-run (2026-08-28) — against the fixed harness, itself superseded by "Full corpus sweep re-run #2" below (the first conclusive result) + +The `skipPaths` fix from the whole-branch review (commit `43be17e`, "fix: harden +regenerate-tier merge-index seeding after whole-branch review") is merged, so +this is the fresh, real re-run the invalidation notice above called for: same +recipe, same four corpus v2 repos, `@gitwand/core`/`@gitwand/cli` rebuilt from +source immediately before running, the same already-cached bare+blobless clones +under `benchmark/.cache/` re-verified against `benchmark/corpus.json`'s current +SHAs (`cat-file -e ^{commit}` and `rev-parse HEAD` both matched the pin for +all four, no re-clone needed), then +`node scripts/replay-regenerate.mjs --max-real 20 --json` run against +each, for real, with real network access and real installer invocations +(`npm`/`pnpm`/`yarn`/`cargo` all present in `PATH`). + +| Repo | Merges scanned | Ecosystem | Candidates found | Attempted | Runnable plans | Ran | Comparable | Matched | Agreement rate | +|---|---:|---|---:|---:|---:|---:|---:|---:|---:| +| `expressjs/express` | 485 | *(none)* | 0 | — | — | — | — | — | no candidates | +| `twbs/bootstrap` | 500 | *(none)* | 0 | — | — | — | — | — | no candidates | +| `prettier/prettier` | 237 | yarn-berry | 85 | 20 | 13 | **0** | 0 | 0 | n/a (0 ran) | +| `tauri-apps/tauri` | 56 | cargo | 22 | 20 | 0 | 0 | 0 | 0 | n/a (0 runnable) | +| `tauri-apps/tauri` | 56 | yarn-berry | 10 | 10 | 0 | 0 | 0 | 0 | n/a (0 runnable) | + +**TOTAL, weighted by comparable attempts across all repos/ecosystems: sum(matched) = 0, +sum(comparable) = 0. The ratio is undefined — not 0 %, not 100 %. This re-run +produced no comparable data point at all.** + +Every number that isn't `prettier/prettier`'s `yarn-berry` outcome column is +byte-identical to the invalidated sweep above (485/500/56 merges scanned, +0/0/22/10 candidates found) — the corpus is genuinely pin-stable and this is +the same population being re-measured, not a different sample reacting to a +different corpus state. + +#### What actually changed, and what didn't + +The fix does exactly what its doc comment says: for a runnable candidate, after +`git read-tree ` it force-removes every path the historical merge left +genuinely conflicted (`skipPaths`, threaded through as `conflictedPaths` from +candidate discovery) from the scratch index, via +`git update-index --force-remove -- ` with `GIT_INDEX_FILE` pointed at +the scratch index. + +But **`git update-index` — including `--force-remove`, which only edits the +index and never touches the filesystem — is still subject to git's +`NEED_WORK_TREE` plumbing rule**, and the corpus caches this harness targets are +bare by design (`git clone --bare --filter=blob:none`, exactly +`benchmark/run.mjs`'s own `prepare()` recipe, which this file's "Running it" +section above documents as the required input shape). Verified independently, +outside the harness entirely, against the real cache directory, reproduced +identically with the agent sandbox both enabled and disabled (so it is not a +sandbox artefact), and reproduced again on a from-scratch, freshly-verified +`pnpm install` + rebuild (so it is not related to an unrelated node_modules +corruption hit once mid-measurement from a stale concurrent build process): + +``` +$ git -C benchmark/.cache/prettier__prettier.git rev-parse --is-bare-repository +true +$ GIT_INDEX_FILE=/path/to/scratch-index git -C benchmark/.cache/prettier__prettier.git read-tree HEAD +(succeeds) +$ GIT_INDEX_FILE=/path/to/scratch-index git -C benchmark/.cache/prettier__prettier.git update-index --force-remove -- package.json +fatal: this operation must be run in a work tree +``` + +`scripts/lib/seed-index.test.mjs`'s own unit tests pass because they build +their fixture with `git init` inside a `mkdtemp` directory — an ordinary, +non-bare repository with a real work tree — so this failure mode never +triggers there. It only fires against the bare corpus clones the script is +documented to require. And since a "runnable" candidate is defined as one +whose lockfile is *still conflicted* (only its `sourcesOfTruth` settled), every +runnable candidate's `skipPaths` is non-empty by construction — this bug is not +probabilistic or environment-sensitive, it fires on 100 % of runnable +candidates, everywhere in the corpus, deterministically. + +Net effect on `prettier/prettier`'s 13 runnable candidates: the dominant +failure mode changed from `spawn-failed` (11/13, `yarn install +--mode=update-lockfile` itself exiting 1, the invalidated sweep's finding) to +`error` (13/13, `fatal: this operation must be run in a work tree`) — one bug +fully replaced the other, and this time not a single candidate got far enough +to reach `yarn install` at all. **This neither confirms nor refutes the +marker-corruption hypothesis the merged fix targeted** — no candidate reached +the point where that hypothesis could be tested. It confirms only that the fix, +as merged, cannot run to completion against this harness's own documented +target repos. + +#### The gate verdict (2026-08-28 corrected re-run) + +**0 comparable, 0 matched.** Not close to met, and not confidently missed +either — there is no percentage to react to, because zero candidates in this +fresh, real, honestly-executed run ever reached a state where +`runRegeneration()`'s output could be compared against the human-committed +lockfile. This is a *smaller* comparable sample than both predecessors it was +meant to improve on: the original pilot's n = 3 (66.7 %) and the invalidated +sweep's n = 1 (100 %). Running the harness fix for real did not make the +regenerate-tier measurement more conclusive — it made it strictly less +conclusive, by trading a bug that at least let one candidate run to completion +for one that blocks every runnable candidate before its worktree is even +populated. + +Per the plan's own instruction for an inconclusive/below-target outcome: +**keep CLI opt-in only** (unchanged — already true), **document findings, stop +here.** The desktop surface remains unjustified by this evidence: zero +comparable data justifies nothing in either direction, more decisively than the +prior n = 1 did. + +Before any further regenerate-tier gate evaluation is possible, +`scripts/lib/seed-index.mjs`'s `skipPaths` removal step needs its own fix — for +example, building the filtered scratch index via `git ls-tree` piped into +`git update-index --index-info` (both operate purely on an index file and carry +no `NEED_WORK_TREE` requirement), instead of `update-index --force-remove` +against a bare `-C `. That fix, and the sweep re-run it would require, is +out of scope for this task; it is the concrete next action for whoever picks +this back up. + +### Full corpus sweep re-run #2 (2026-08-28) — first CONCLUSIVE sweep, target NOT met + +`scripts/lib/seed-index.mjs`'s `skipPaths` removal step was rebuilt again, +this time via pure object-database plumbing that never needs a work tree at +all: `git ls-tree -z` + `git mktree -z --missing` walking only the directory +chain from the tree root down to each skipped path (every sibling subtree +keeps its original oid untouched — no full-tree rebuild). Before trusting this +description, it was tested against the real corpus and, doing so, turned up +two more real bugs no hand-built fixture had ever exercised, in order: + +1. **`git mktree` cannot ingest `ls-tree -r`'s flat recursive listing + directly** — it rejects any entry whose name contains a slash with `fatal: + path ... contains slash`. A first attempt fed a fully-flattened `ls-tree -r` + straight into `mktree`; fixed by walking and rewriting only the actual + directory chain of each skip path instead (see above) — which also turned a + full-recursive-tree rebuild (thousands of directories for a repo this size) + into effectively zero-to-a-few `mktree` calls per candidate, since + `prettier/prettier`'s skip paths are always at the tree root. +2. **Filenames with embedded quotes/spaces/unicode get C-quoted by git's + default (non-`-z`) `ls-tree`/`mktree` output**, and hand-parsing a quoted, + escaped name (e.g. splitting on `/`) corrupts it — surfaced as `fatal: + invalid quoting` against `prettier/prettier`'s real tree. Fixed by using + `-z` (NUL-terminated, unquoted raw bytes) for both commands throughout, + which avoids the quoting problem entirely rather than parsing around it. +3. **`git mktree` verifies every referenced object exists locally by + default**, and does not lazily fetch a missing one the way most git + commands do under a partial clone's promisor-remote mechanism — a real + problem specifically because `benchmark/run.mjs`'s `prepare()` clones the + corpus **blobless** (`--filter=blob:none`), so most historical blobs are + not present locally yet. Surfaced as `fatal: entry '' object + is unavailable`. Fixed with `mktree --missing`, safe here because every + sha passed to `mktree` was read moments earlier from a real `ls-tree` of + the same repository's own object database — nothing is invented, so there + is nothing to validate. + +#1 and #2 are now covered by `scripts/lib/seed-index.test.mjs`: a bare-repo +fixture with nested paths (catches #1), a fixture with a sibling filename +containing a literal quote and spaces (catches #2). `node --test +scripts/lib/*.test.mjs` passes, 17/17, including these two new regression +tests. + +**#3 (the blobless `mktree --missing` fix) shipped without a regression +test, on a claim later found to be wrong.** This section originally stated a +from-scratch fixture "cannot reproduce" a blobless clone since it is "never +blobless" — false: a local, hermetic blobless bare clone is reproducible with +no network (`git config uploadpack.allowFilter true` on a temp origin, then +`git clone --bare --filter=blob:none file://`), and an independent +review proved it by doing exactly that and reproducing bug #3 on demand. That +test does not exist yet — a real, if currently blast-radius-zero, gap. + +**A fourth, still-open gap, found by the same review, after this section was +first written:** `conflictedPaths` comes from `git merge-tree --write-tree +--name-only`'s default (non-`-z`) output, which C-quotes any path containing +a `"` or non-ASCII byte. `seedScratchIndex`'s skip-matching uses raw `-z` +bytes, so a C-quoted skip path silently fails to match and is never removed — +the exact marker-leak failure mode bug #1 (the original Critical finding) was +supposed to eliminate, now one layer upstream. Confirmed via review: switching +`merge-tree`'s own `--name-only` call to `-z` fixes it cleanly. **This did +not affect the sweep numbers below** — all 76 lockfile-conflicting merges +across the 237 `prettier/prettier` merges scanned were independently checked, +and none carry a C-quoted conflicted path — but it is a live latent bug for +any future corpus repo (or a re-pin) whose conflicts touch a quote- or +non-ASCII-containing filename. Not fixed here; recorded plainly rather than +left for a fourth round to rediscover. + +**Cheap real-bare-repo sanity check, run before the full sweep**: a real +candidate merge (`63503cd4142585c9b54629929078a7dbab8ec1f0`, conflicting on +`package.json` and `yarn.lock`) was pulled directly from +`benchmark/.cache/prettier__prettier.git` (confirmed bare) via the same +candidate-discovery logic `replay-regenerate.mjs` uses, and `seedScratchIndex` +was called directly against it. No error; the resulting scratch index has +exactly 9337 entries against the tree's 9339 total, i.e. precisely the two +skipped paths removed and nothing else disturbed; both `package.json` and +`yarn.lock` confirmed absent via `git ls-files`. Only after this passed did the +full sweep run. + +Before the full sweep could run for real, one thing needed re-verifying and +one environment issue needed working around, both worth recording plainly: + +- `benchmark/.cache/prettier__prettier.git`'s cached `HEAD` had drifted from + `corpus.json`'s current pin (`0bc958e734b00907e2bae2bae45c664ad8a1a2f7`) — + re-pinned via `git update-ref HEAD ` (the commit was already reachable + locally; no re-clone needed). The other three repos were already correctly + pinned. +- The measurement environment's own sandbox routes all network egress through + an HTTP CONNECT proxy and denies raw `dns.lookup()` calls outright (even for + `github.com`) — this collided with `runRegeneration`'s own pre-flight + offline probe (`isOffline()` in `packages/cli/src/regenerate-runner.ts`, + a bare `dns.lookup()` against the ecosystem's registry host), which + therefore declined every runnable candidate as `offline` on the first + attempt at this sweep, before any installer ran. This is an environment + property of the sandbox this measurement happened to run in, not a defect + in `isOffline()` or in the code touched by this task — confirmed by + disabling the sandbox for the sweep, at which point DNS resolution and the + real installs both worked normally. Recorded here in case a future + measurement run hits the same thing. + +Recipe, same as the prior two sweeps: all four corpus v2 repos whose language +makes a v1-registry lockfile plausible, `@gitwand/core`/`@gitwand/cli` +rebuilt from source immediately before running, each repo confirmed bare and +correctly pinned, `node scripts/replay-regenerate.mjs --max-real 20 +--json` run against each, for real, with real network access and real +installer invocations. + +| Repo | Merges scanned | Ecosystem | Candidates found | Attempted | Runnable plans | Ran | Comparable | Matched | Agreement rate | +|---|---:|---|---:|---:|---:|---:|---:|---:|---:| +| `expressjs/express` | 485 | *(none)* | 0 | — | — | — | — | — | no candidates | +| `twbs/bootstrap` | 500 | *(none)* | 0 | — | — | — | — | — | no candidates | +| `prettier/prettier` | 237 | yarn-berry | 85 | 20 | 13 | **13** | **13** | **5** | **38.5 % (5/13)** | +| `tauri-apps/tauri` | 56 | cargo | 22 | 20 | 0 | 0 | 0 | 0 | n/a (0 runnable) | +| `tauri-apps/tauri` | 56 | yarn-berry | 10 | 10 | 0 | 0 | 0 | 0 | n/a (0 runnable) | + +**TOTAL, weighted by comparable attempts across all repos/ecosystems: sum(matched) = 5, +sum(comparable) = 13 → 5/13 = 38.5 %.** + +`expressjs/express`, `twbs/bootstrap` and `tauri-apps/tauri` are byte-identical +to both prior sweeps on every field that isn't the harness bug itself (same +merges scanned, same candidates found, same zero/`not-runnable` outcomes) — +the corpus is genuinely pin-stable and this is the same population, not a +different sample reacting to a different corpus state. `prettier/prettier`'s +`yarn-berry` candidates are the only ones that ever reached a real installer +across all three sweeps of this fix: all 13 runnable candidates ran the real +`yarn install --mode=update-lockfile` to completion (`ran = 13`), all 13 had +both a regenerated and an actually-committed lockfile available for structural +comparison (`comparable = 13`), and 5 of those 13 structurally matched the +lockfile the `prettier` team actually committed. + +#### The gate verdict (2026-08-28 re-run #2) — CONCLUSIVE: target not met + +**n = 13 comparable, 5 matched, 38.5 % agreement.** This is not close to the +≥ 80 % target, and — unlike the two prior sweeps of this fix — this sample is +large enough that the shortfall is not plausibly sampling noise: a one-sided +exact binomial test against the 80 % target gives P(X ≤ 5 | n = 13, p = 0.80) +≈ 1.2×10⁻³, and the 95 % Wilson interval on 5/13 is roughly [17.7 %, 64.5 %] — +entirely below the target. It is also more than 4× the comparable sample size +of either predecessor (pilot n = 3, invalidated sweep n = 1), and every one of +the 13 *runnable* candidates ran to completion, so nothing was left half-measured +among those 13. **Verdict: target NOT met**, plainly, not "inconclusive." + +**Caveat this verdict is scoped to, disclosed plainly rather than left implicit:** +`prettier/prettier`'s yarn-berry leg had **85 candidates found**, but +`--max-real 20` means only the 20 most *recent* were attempted (a `rev-list` +prefix — recency-biased, not a random sample), leaving **65 candidates never +classified** as runnable or not. The 13 comparable results above are exactly +what this sweep measured, and the statistical argument above is valid for +those 13 — but they are not necessarily representative of the full 85 if the +regeneration tool's behavior (or the ecosystem's own tooling) changed across +`prettier/prettier`'s history. A full, uncapped sweep of all 85 would close +this gap; not attempted here (real cost, real time, diminishing returns on a +verdict the CI already puts at P ≈ 1.2×10⁻³). + +Per the plan's own instruction for a below-target outcome: **keep CLI opt-in +only** (unchanged — already true), **document findings, stop here.** The +desktop surface remains unjustified: 38.5 % agreement on real historical +merges means the majority of automatic `yarn.lock` regenerations in this +sample would have silently produced a lockfile different from what the +`prettier` team actually shipped — not evidence to build a user-facing surface +on. Whether the 8 mismatches are genuine wrong answers or artifacts of +replaying an old merge with today's yarn/registry state (dependency resolvers +are not deterministic run-to-run, and installing against 2020s-era +`package.json` ranges with today's registry can legitimately resolve +different transitive versions than what was available at merge time) is an +open question this sweep does not answer — `structuralMatch()` already +ignores hashes/resolved-URLs/ordering, so the 8 disagreements are graph-level, +not cosmetic, but distinguishing "engine got it wrong" from "the ecosystem +moved on" needs looking at the actual diverging dependency identities +per-example, which is out of scope for this task. + +On hypothesis (d) from the prior section (does merge-index seeding move the +number, relative to the pilot's `HEAD`-only seeding): **this sweep cannot +answer that comparison, and it should not be read as answering it.** The +pilot's 66.7 % (n = 3) and this sweep's 38.5 % (n = 13) are not a matched-pair +comparison — the pilot never ran these same 13 candidates under the old +`HEAD`-only seeding, so there is no controlled before/after to attribute a +change to. The pilot's own interval at n = 3 is enormous (a single flip would +swing it by ±33 points) and overlaps this sweep's [17.7 %, 64.5 %] Wilson +interval entirely; the two numbers are statistically indistinguishable from +each other, not evidence that seeding made things worse. What this sweep DOES +support, on its own and without reference to the pilot: **this fix's +real-world lockfile-regeneration accuracy, measured on 13 real historical +`prettier/prettier` merges with the seeding bug fixed, is 38.5 % agreement, +95 % upper bound around 65–68 %, well below the 80 % bar.** That is sufficient +on its own to keep the desktop surface unjustified — no comparison to the +pilot is needed to reach that conclusion, and none should be implied. `results/` holds one JSON file per measured GitWand version, plus the corpus pin date that produced it. Keep old files: the whole reason for pinning is to be able diff --git a/benchmark/compare.mjs b/benchmark/compare.mjs new file mode 100644 index 00000000..70fd113f --- /dev/null +++ b/benchmark/compare.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * benchmark/compare.mjs — le garde-fou du lot G. + * + * Compare un run frais à la baseline commitée et échoue (exit 1) si le moteur + * a régressé au-delà du bruit. Les seuils encodent la leçon des lots C/E/F : + * l'ACCORD est la métrique protégée (une baisse = le moteur se trompe plus), + * la COUVERTURE peut baisser volontairement (décliner ce qui était appliqué à + * tort est un progrès) mais pas s'effondrer en silence. + * + * node benchmark/compare.mjs results/v2-baseline.json results/ci.json + */ + +import { readFileSync } from "node:fs"; + +// Bruit toléré : l'accord par dépôt varie de ±1-2 pts entre runs identiques +// (fichiers limites, ordre de fs). Au-delà, c'est un vrai mouvement. +const MAX_AGREEMENT_DROP_TOTAL = 1.5; // points de % sur l'agrégat +const MAX_AGREEMENT_DROP_REPO = 5; // points de % sur un dépôt +const MAX_COVERAGE_DROP_RATIO = 0.25; // -25 % de fichiers résolus e2e max sans justification + +const [baselinePath, freshPath] = process.argv.slice(2); +if (!baselinePath || !freshPath) { + console.error("usage: node benchmark/compare.mjs "); + process.exit(2); +} +const base = JSON.parse(readFileSync(baselinePath, "utf-8")); +const fresh = JSON.parse(readFileSync(freshPath, "utf-8")); + +const failures = []; +const notes = []; + +const agree = (r) => (r.headline.agreementExactShare ?? null); +const files = (r) => r.headline.agreementComparableFiles ?? 0; + +// ── agrégat ──────────────────────────────────────────────── +const aBase = agree(base); +const aFresh = agree(fresh); +if (aBase !== null && aFresh !== null) { + const delta = aFresh - aBase; + (delta < -MAX_AGREEMENT_DROP_TOTAL ? failures : notes).push( + `agreement (corpus): ${aBase}% → ${aFresh}% (${delta >= 0 ? "+" : ""}${delta.toFixed(2)} pts)`, + ); +} +{ + const fBase = files(base); + const fFresh = files(fresh); + if (fBase > 0 && fFresh < fBase * (1 - MAX_COVERAGE_DROP_RATIO)) { + failures.push(`coverage collapsed: ${fBase} → ${fFresh} files resolved end-to-end (>-25%). A deliberate decline policy must update the baseline in the same PR, with the reasoning in the commit message.`); + } else { + notes.push(`coverage: ${fBase} → ${fFresh} files resolved end-to-end`); + } +} + +// ── par dépôt ────────────────────────────────────────────── +const baseByRepo = new Map(base.perRepo.filter((r) => !r.error).map((r) => [r.repo, r])); +for (const r of fresh.perRepo) { + if (r.error) { failures.push(`${r.repo}: run failed — ${r.error}`); continue; } + const b = baseByRepo.get(r.repo); + if (!b) { notes.push(`${r.repo}: new in corpus, no baseline`); continue; } + const ba = b.agreement?.exactShare; + const fa = r.agreement?.exactShare; + if (ba != null && fa != null) { + const delta = fa - ba; + (delta < -MAX_AGREEMENT_DROP_REPO ? failures : notes).push( + `${r.repo}: ${ba}% → ${fa}% (${delta >= 0 ? "+" : ""}${delta.toFixed(1)} pts, ${r.agreement.comparable} files)`, + ); + } +} + +console.log("═══ benchmark gate ═══"); +for (const n of notes) console.log(" ·", n); +if (failures.length) { + console.log("\n✗ REGRESSIONS:"); + for (const f of failures) console.log(" ✗", f); + process.exit(1); +} +console.log("\n✓ no regression beyond noise"); diff --git a/benchmark/corpus.json b/benchmark/corpus.json index 61c3a326..15c20668 100644 --- a/benchmark/corpus.json +++ b/benchmark/corpus.json @@ -1,33 +1,9 @@ { - "version": 1, + "version": 2, "pinnedAt": "2026-08-26", "note": "Every repository is pinned to a commit SHA rather than a branch, so a run today and a run in two years scan the same merges. Re-pinning is a deliberate act: bump `pinnedAt`, update the SHAs, and keep the old results file — a corpus that silently drifts cannot be used to compare two versions of an engine.", - "selection": "Public, permissively licensed projects with a real multi-author merge history, chosen to spread across languages and file formats rather than to flatter any particular pattern. Deliberately excluded: repositories that squash-merge everything (no merge commits to replay) and repositories dominated by generated files.", + "selection": "v2 — selected on MEASURED merge history, not language coverage: candidates were probed with `git rev-list --merges` plus a merge-tree conflict-rate sample (60 recent merges). Dropped: rust-lang/cargo (zero conflicted merges — merge queue), django/django (10), vuejs/core (35, squash-merge; it was also the 92-95% showcase, which is exactly why keeping it would have been flattering rather than informative). Probed and rejected: kubernetes, rails, godot (0/60 conflicted — merge queues). Every repository is pinned to a commit SHA; re-pinning is a deliberate act recorded in this field.", "repos": [ - { - "name": "vuejs/core", - "url": "https://github.com/vuejs/core.git", - "sha": "e2bede96134f757aad5c5b33ac9be055022dbfc8", - "language": "TypeScript", - "maxMerges": 300, - "why": "TypeScript monorepo with heavy import-block churn — exercises the import and JSON resolvers." - }, - { - "name": "rust-lang/cargo", - "url": "https://github.com/rust-lang/cargo.git", - "sha": "94ba974179df2adb3c911fadf361f03b84aa8f14", - "language": "Rust", - "maxMerges": 300, - "why": "High merge volume from bors-style integration, plus Cargo.lock churn." - }, - { - "name": "django/django", - "url": "https://github.com/django/django.git", - "sha": "0b40210e4808937a7c0922e8b7502bff4752faa3", - "language": "Python", - "maxMerges": 300, - "why": "Two decades of history and a large contributor base — the long tail of ordinary conflicts." - }, { "name": "prettier/prettier", "url": "https://github.com/prettier/prettier.git", @@ -67,6 +43,30 @@ "language": "JavaScript", "maxMerges": 200, "why": "Small and old. Included as a control: a repo where the engine should have little to do." + }, + { + "name": "symfony/symfony", + "url": "https://github.com/symfony/symfony.git", + "sha": "f1072d8902e3d397ab5f9190877fb947d259b0c3", + "language": "PHP", + "maxMerges": 300, + "why": "Back-merge culture (5.4 → 6.4 → 7.x) with composer.json conflicts in half the conflicted merges — the divergent-conventions candidate the lot-F gate needs." + }, + { + "name": "git/git", + "url": "https://github.com/git/git.git", + "sha": "f78ce2f7b6df702f93d40b85d6bda92a3f65da79", + "language": "C", + "maxMerges": 300, + "why": "Integration-branch workflow, conflicts hand-resolved by the maintainers — the highest-quality human-merge ground truth available." + }, + { + "name": "twbs/bootstrap", + "url": "https://github.com/twbs/bootstrap.git", + "sha": "ae7d4c5313121f9da1f63974c7bbc373665a979b", + "language": "SCSS/JS", + "maxMerges": 300, + "why": "Design-system repo: _variables.scss conflicts — a file family none of the format resolvers special-case, kept in as an adversarial case." } ] -} +} \ No newline at end of file diff --git a/benchmark/results/v3.8.0-corpus2-baseline.json b/benchmark/results/v3.8.0-corpus2-baseline.json new file mode 100644 index 00000000..1f91a824 --- /dev/null +++ b/benchmark/results/v3.8.0-corpus2-baseline.json @@ -0,0 +1,1039 @@ +{ + "gitwandVersion": "3.8.0", + "corpusPinnedAt": "2026-08-26", + "refactoringAwareEnabled": false, + "reposRun": 8, + "reposFailed": 0, + "totals": { + "mergesScanned": 1927, + "mergesWithConflicts": 634, + "conflictedFiles": 2431, + "skippedFiles": 5914, + "resolveErrors": 0, + "mergeTreeErrors": 4, + "totalHunks": 5675, + "byType": { + "complex": 2248, + "generated_file": 1268, + "value_only_change": 901, + "format_semantic": 531, + "non_overlapping": 517, + "insertion_at_boundary": 111, + "token_level_merge": 74, + "whitespace_only": 12, + "one_side_change": 8, + "same_change": 4, + "reorder_only": 1 + }, + "byTier": { + "trivial": 1554, + "advancedDeterministic": 605, + "model": 0, + "unresolved": 3516 + }, + "agreement": { + "filesFullyResolved": 660, + "comparable": 660, + "agreeExact": 391, + "agreeNormalized": 395, + "unavailable": 0 + } + }, + "headline": { + "autoResolvedHunks": 2159, + "autoResolvedShare": 38.04, + "residualHunks": 3516, + "residualShare": 61.96, + "agreementExactShare": 59.24, + "agreementComparableFiles": 660 + }, + "perRepo": [ + { + "repo": "prettier/prettier", + "language": "JavaScript", + "pinnedSha": "0bc958e734b00907e2bae2bae45c664ad8a1a2f7", + "mergesScanned": 237, + "maxMergesRequested": 300, + "mergesWithConflicts": 116, + "totalHunks": 1281, + "byTier": { + "trivial": 246, + "advancedDeterministic": 102, + "model": 0, + "unresolved": 933 + }, + "agreement": { + "filesFullyResolved": 117, + "comparable": 117, + "agreeExact": 58, + "exactShare": 49.57, + "disagreeExamples": [ + { + "merge": "8609180f56", + "path": "website/versioned_docs/version-stable/browser.md", + "hunks": 1 + }, + { + "merge": "9c06bb2d01", + "path": "package.json", + "hunks": 2 + }, + { + "merge": "f80a7dc8dc", + "path": ".github/ISSUE_TEMPLATE/integration.md", + "hunks": 1 + }, + { + "merge": "f80a7dc8dc", + "path": "CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "f80a7dc8dc", + "path": "docs/browser.md", + "hunks": 7 + }, + { + "merge": "f80a7dc8dc", + "path": "website/versioned_docs/version-stable/browser.md", + "hunks": 7 + }, + { + "merge": "883324dbb6", + "path": "scripts/tools/bundle-test/package.json", + "hunks": 1 + }, + { + "merge": "883324dbb6", + "path": "website/package.json", + "hunks": 1 + }, + { + "merge": "4a26d88f9a", + "path": "tests/config/format-test.js", + "hunks": 1 + }, + { + "merge": "a51568db85", + "path": "src/language-js/needs-parens.js", + "hunks": 1 + }, + { + "merge": "a51568db85", + "path": "src/language-js/utils/index.js", + "hunks": 1 + }, + { + "merge": "a51568db85", + "path": "tests/format/misc/errors/js/assignment/jsfmt.spec.js", + "hunks": 1 + }, + { + "merge": "a51568db85", + "path": "tests/format/misc/typescript-only/__snapshots__/jsfmt.spec.js.snap", + "hunks": 1 + }, + { + "merge": "daeb90f1ca", + "path": "package.json", + "hunks": 1 + }, + { + "merge": "a8869bef55", + "path": "package.json", + "hunks": 1 + }, + { + "merge": "a8869bef55", + "path": "tests/format/misc/typescript-only/jsfmt.spec.js", + "hunks": 1 + }, + { + "merge": "55fa0e9e0f", + "path": "package.json", + "hunks": 1 + }, + { + "merge": "9411aa2e47", + "path": "changelog_unreleased/typescript/13764.md", + "hunks": 1 + }, + { + "merge": "bb04cf072e", + "path": "src/cli/format-results-cache.js", + "hunks": 1 + }, + { + "merge": "bb04cf072e", + "path": "src/cli/format.js", + "hunks": 1 + }, + { + "merge": "bb04cf072e", + "path": "tests/integration/__tests__/cache.js", + "hunks": 12 + }, + { + "merge": "0b072060b5", + "path": "src/language-js/print/literal.js", + "hunks": 1 + }, + { + "merge": "298347c9fb", + "path": "netlify.toml", + "hunks": 1 + }, + { + "merge": "4d4947c284", + "path": "website/package.json", + "hunks": 1 + }, + { + "merge": "0e0f879c1a", + "path": "package.json", + "hunks": 2 + } + ] + }, + "autoResolvedShare": 27.17 + }, + { + "repo": "gohugoio/hugo", + "language": "Go", + "pinnedSha": "a25af7facfc9de3f17bcd82a9268ded595f0adb4", + "mergesScanned": 234, + "maxMergesRequested": 300, + "mergesWithConflicts": 125, + "totalHunks": 507, + "byTier": { + "trivial": 77, + "advancedDeterministic": 104, + "model": 0, + "unresolved": 326 + }, + "agreement": { + "filesFullyResolved": 63, + "comparable": 63, + "agreeExact": 19, + "exactShare": 30.16, + "disagreeExamples": [ + { + "merge": "30a20122b7", + "path": ".github/workflows/stale.yml", + "hunks": 1 + }, + { + "merge": "30a20122b7", + "path": "AGENTS.md", + "hunks": 1 + }, + { + "merge": "304a7e5e74", + "path": "README.md", + "hunks": 1 + }, + { + "merge": "0c453420e6", + "path": "go.sum", + "hunks": 1 + }, + { + "merge": "e99eba39e7", + "path": "go.sum", + "hunks": 1 + }, + { + "merge": "3758456b31", + "path": "docs/content/en/functions/images/AutoOrient.md", + "hunks": 1 + }, + { + "merge": "3758456b31", + "path": "docs/content/en/getting-started/configuration.md", + "hunks": 2 + }, + { + "merge": "d19ed4d4e6", + "path": "docs/data/docs.yaml", + "hunks": 1 + }, + { + "merge": "e2dd4cd05f", + "path": "go.sum", + "hunks": 1 + }, + { + "merge": "db45dbbee8", + "path": "go.sum", + "hunks": 1 + }, + { + "merge": "8859be1c01", + "path": "go.sum", + "hunks": 1 + }, + { + "merge": "a838a27e4c", + "path": "go.sum", + "hunks": 1 + }, + { + "merge": "b95e156940", + "path": "go.sum", + "hunks": 1 + }, + { + "merge": "7e539cb398", + "path": "go.sum", + "hunks": 1 + }, + { + "merge": "b661132e0a", + "path": "docs/content/en/hugo-pipes/introduction.md", + "hunks": 1 + }, + { + "merge": "b661132e0a", + "path": "go.sum", + "hunks": 1 + }, + { + "merge": "9a215d6950", + "path": "go.sum", + "hunks": 1 + }, + { + "merge": "c9f2fa2663", + "path": "go.sum", + "hunks": 1 + }, + { + "merge": "ef518485ce", + "path": "go.sum", + "hunks": 1 + }, + { + "merge": "f04cc581e1", + "path": "go.sum", + "hunks": 1 + }, + { + "merge": "af23cdca9c", + "path": "go.sum", + "hunks": 1 + }, + { + "merge": "9d76b8fa34", + "path": "go.sum", + "hunks": 1 + }, + { + "merge": "6183184b96", + "path": "docs/content/en/functions/images/index.md", + "hunks": 1 + }, + { + "merge": "6183184b96", + "path": "go.sum", + "hunks": 1 + }, + { + "merge": "4b36498a85", + "path": ".gitignore", + "hunks": 1 + } + ] + }, + "autoResolvedShare": 35.7 + }, + { + "repo": "tauri-apps/tauri", + "language": "Rust + TypeScript", + "pinnedSha": "5e2856e3209d4ab16d21a1f828ff94b46a35a0b6", + "mergesScanned": 56, + "maxMergesRequested": 300, + "mergesWithConflicts": 22, + "totalHunks": 2129, + "byTier": { + "trivial": 726, + "advancedDeterministic": 85, + "model": 0, + "unresolved": 1318 + }, + "agreement": { + "filesFullyResolved": 103, + "comparable": 103, + "agreeExact": 39, + "exactShare": 37.86, + "disagreeExamples": [ + { + "merge": "c426c0dca2", + "path": "core/tauri-build/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "c426c0dca2", + "path": "core/tauri-codegen/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "c426c0dca2", + "path": "core/tauri-macros/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "c426c0dca2", + "path": "core/tauri-runtime-wry/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "c426c0dca2", + "path": "core/tauri-runtime/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "c426c0dca2", + "path": "core/tauri/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "c426c0dca2", + "path": "tooling/api/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "c426c0dca2", + "path": "tooling/bundler/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "c426c0dca2", + "path": "tooling/cli/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "c426c0dca2", + "path": "tooling/cli/node/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "a9b87c057d", + "path": "core/tauri-macros/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "a9b87c057d", + "path": "core/tauri-runtime-wry/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "a9b87c057d", + "path": "core/tauri-runtime/src/lib.rs", + "hunks": 1 + }, + { + "merge": "a9b87c057d", + "path": "core/tauri-utils/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "a9b87c057d", + "path": "core/tauri/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "a9b87c057d", + "path": "tooling/api/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "a9b87c057d", + "path": "tooling/bundler/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "a9b87c057d", + "path": "tooling/cli/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "a9b87c057d", + "path": "tooling/cli/node/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "c6c59cf237", + "path": ".github/workflows/audit.yml", + "hunks": 1 + }, + { + "merge": "c6c59cf237", + "path": ".github/workflows/covector-version-or-publish.yml", + "hunks": 1 + }, + { + "merge": "c6c59cf237", + "path": "core/tauri-build/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "c6c59cf237", + "path": "core/tauri-codegen/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "c6c59cf237", + "path": "core/tauri-macros/CHANGELOG.md", + "hunks": 1 + }, + { + "merge": "c6c59cf237", + "path": "core/tauri-runtime-wry/CHANGELOG.md", + "hunks": 1 + } + ] + }, + "autoResolvedShare": 38.09 + }, + { + "repo": "laravel/framework", + "language": "PHP", + "pinnedSha": "bdc52237f0b7999916e5f09dd179e3f415762dd0", + "mergesScanned": 300, + "maxMergesRequested": 300, + "mergesWithConflicts": 189, + "totalHunks": 696, + "byTier": { + "trivial": 290, + "advancedDeterministic": 165, + "model": 0, + "unresolved": 241 + }, + "agreement": { + "filesFullyResolved": 245, + "comparable": 245, + "agreeExact": 204, + "exactShare": 83.27, + "disagreeExamples": [ + { + "merge": "d9485b4c16", + "path": "src/Illuminate/Cache/composer.json", + "hunks": 1 + }, + { + "merge": "f096efedb1", + "path": "src/Illuminate/Collections/composer.json", + "hunks": 1 + }, + { + "merge": "062de19cb9", + "path": "src/Illuminate/Container/composer.json", + "hunks": 1 + }, + { + "merge": "062de19cb9", + "path": "src/Illuminate/Support/Facades/Http.php", + "hunks": 1 + }, + { + "merge": "062de19cb9", + "path": "src/Illuminate/Support/composer.json", + "hunks": 1 + }, + { + "merge": "062de19cb9", + "path": "tests/Http/HttpClientTest.php", + "hunks": 1 + }, + { + "merge": "1cd1d477b9", + "path": "src/Illuminate/Events/Dispatcher.php", + "hunks": 1 + }, + { + "merge": "8a69a6e2a6", + "path": "composer.json", + "hunks": 2 + }, + { + "merge": "8a69a6e2a6", + "path": "src/Illuminate/Pipeline/composer.json", + "hunks": 1 + }, + { + "merge": "8a69a6e2a6", + "path": "src/Illuminate/Testing/composer.json", + "hunks": 1 + }, + { + "merge": "8a69a6e2a6", + "path": "src/Illuminate/Validation/composer.json", + "hunks": 1 + }, + { + "merge": "7f20c6184f", + "path": ".github/workflows/databases.yml", + "hunks": 1 + }, + { + "merge": "f979990df3", + "path": "src/Illuminate/Foundation/Console/ApiInstallCommand.php", + "hunks": 1 + }, + { + "merge": "f979990df3", + "path": "src/Illuminate/Testing/TestResponse.php", + "hunks": 1 + }, + { + "merge": "4de9631236", + "path": "composer.json", + "hunks": 1 + }, + { + "merge": "7a732d5893", + "path": ".github/workflows/tests.yml", + "hunks": 3 + }, + { + "merge": "7a732d5893", + "path": "src/Illuminate/Database/composer.json", + "hunks": 1 + }, + { + "merge": "d86e742cdd", + "path": "src/Illuminate/Concurrency/composer.json", + "hunks": 1 + }, + { + "merge": "bd8aeb64d3", + "path": "src/Illuminate/Foundation/Application.php", + "hunks": 1 + }, + { + "merge": "f53c7bcddf", + "path": "composer.json", + "hunks": 1 + }, + { + "merge": "8bd7a9b02b", + "path": "src/Illuminate/Database/Query/Processors/MySqlProcessor.php", + "hunks": 1 + }, + { + "merge": "63797d30aa", + "path": "composer.json", + "hunks": 1 + }, + { + "merge": "6cb77505fd", + "path": ".github/workflows/tests.yml", + "hunks": 2 + }, + { + "merge": "428f86d273", + "path": "composer.json", + "hunks": 1 + }, + { + "merge": "428f86d273", + "path": "tests/Integration/Events/ShouldDispatchAfterCommitEventTest.php", + "hunks": 1 + } + ] + }, + "autoResolvedShare": 65.37 + }, + { + "repo": "expressjs/express", + "language": "JavaScript", + "pinnedSha": "023767fe9872e029271df1418f73401bff20ff40", + "mergesScanned": 200, + "maxMergesRequested": 200, + "mergesWithConflicts": 82, + "totalHunks": 586, + "byTier": { + "trivial": 136, + "advancedDeterministic": 111, + "model": 0, + "unresolved": 339 + }, + "agreement": { + "filesFullyResolved": 60, + "comparable": 60, + "agreeExact": 37, + "exactShare": 61.67, + "disagreeExamples": [ + { + "merge": "e5feb9fcc9", + "path": "History.md", + "hunks": 1 + }, + { + "merge": "ea49706052", + "path": ".github/workflows/ci.yml", + "hunks": 2 + }, + { + "merge": "e9f9aaeebd", + "path": "appveyor.yml", + "hunks": 2 + }, + { + "merge": "e9f9aaeebd", + "path": "package.json", + "hunks": 5 + }, + { + "merge": "318fd4b543", + "path": "package.json", + "hunks": 2 + }, + { + "merge": "121fe9982b", + "path": "lib/request.js", + "hunks": 1 + }, + { + "merge": "62e12fe710", + "path": "lib/utils.js", + "hunks": 1 + }, + { + "merge": "c319fe260a", + "path": "lib/application.js", + "hunks": 1 + }, + { + "merge": "c319fe260a", + "path": "lib/utils.js", + "hunks": 1 + }, + { + "merge": "c319fe260a", + "path": "package.json", + "hunks": 2 + }, + { + "merge": "501e24e0a9", + "path": "lib/application.js", + "hunks": 1 + }, + { + "merge": "501e24e0a9", + "path": "package.json", + "hunks": 2 + }, + { + "merge": "7cafdb5824", + "path": "package.json", + "hunks": 3 + }, + { + "merge": "cd6df7699d", + "path": ".travis.yml", + "hunks": 1 + }, + { + "merge": "f6ec710534", + "path": "lib/utils.js", + "hunks": 1 + }, + { + "merge": "531f024e48", + "path": "LICENSE", + "hunks": 1 + }, + { + "merge": "531f024e48", + "path": "lib/utils.js", + "hunks": 3 + }, + { + "merge": "531f024e48", + "path": "test/app.use.js", + "hunks": 1 + }, + { + "merge": "f34944c539", + "path": "lib/request.js", + "hunks": 1 + }, + { + "merge": "f34944c539", + "path": "lib/response.js", + "hunks": 1 + }, + { + "merge": "35c50601bd", + "path": "test/app.router.js", + "hunks": 1 + }, + { + "merge": "49abd7bec1", + "path": "lib/response.js", + "hunks": 1 + } + ] + }, + "autoResolvedShare": 42.15 + }, + { + "repo": "symfony/symfony", + "language": "PHP", + "pinnedSha": "f1072d8902e3d397ab5f9190877fb947d259b0c3", + "mergesScanned": 300, + "maxMergesRequested": 300, + "mergesWithConflicts": 30, + "totalHunks": 229, + "byTier": { + "trivial": 37, + "advancedDeterministic": 3, + "model": 0, + "unresolved": 189 + }, + "agreement": { + "filesFullyResolved": 19, + "comparable": 19, + "agreeExact": 14, + "exactShare": 73.68, + "disagreeExamples": [ + { + "merge": "ff7ac156ab", + "path": "CHANGELOG-8.0.md", + "hunks": 1 + }, + { + "merge": "f69beb9e95", + "path": "src/Symfony/Bridge/Monolog/composer.json", + "hunks": 1 + }, + { + "merge": "f69beb9e95", + "path": "src/Symfony/Component/Security/Core/Tests/Authentication/Token/Storage/UsageTrackingTokenStorageTest.php", + "hunks": 1 + }, + { + "merge": "4782b420f1", + "path": "src/Symfony/Component/VarDumper/Tests/Dumper/CliDumperTest.php", + "hunks": 1 + }, + { + "merge": "6654dd511f", + "path": "src/Symfony/Component/Scheduler/Generator/MessageGenerator.php", + "hunks": 1 + } + ] + }, + "autoResolvedShare": 17.47 + }, + { + "repo": "git/git", + "language": "C", + "pinnedSha": "f78ce2f7b6df702f93d40b85d6bda92a3f65da79", + "mergesScanned": 300, + "maxMergesRequested": 300, + "mergesWithConflicts": 34, + "totalHunks": 118, + "byTier": { + "trivial": 25, + "advancedDeterministic": 0, + "model": 0, + "unresolved": 93 + }, + "agreement": { + "filesFullyResolved": 18, + "comparable": 18, + "agreeExact": 11, + "exactShare": 61.11, + "disagreeExamples": [ + { + "merge": "c9a92e239f", + "path": "t/t1410-reflog.sh", + "hunks": 0 + }, + { + "merge": "c9a92e239f", + "path": "t/t1800-hook.sh", + "hunks": 0 + }, + { + "merge": "c9a92e239f", + "path": "t/t3903-stash.sh", + "hunks": 0 + }, + { + "merge": "c9a92e239f", + "path": "t/t4141-apply-too-large.sh", + "hunks": 0 + }, + { + "merge": "c9a92e239f", + "path": "t/t7450-bad-git-dotfiles.sh", + "hunks": 0 + }, + { + "merge": "883a47ef64", + "path": "object-file.c", + "hunks": 1 + }, + { + "merge": "c5e6e497ac", + "path": "t/t3903-stash.sh", + "hunks": 0 + } + ] + }, + "autoResolvedShare": 21.19 + }, + { + "repo": "twbs/bootstrap", + "language": "SCSS/JS", + "pinnedSha": "ae7d4c5313121f9da1f63974c7bbc373665a979b", + "mergesScanned": 300, + "maxMergesRequested": 300, + "mergesWithConflicts": 36, + "totalHunks": 129, + "byTier": { + "trivial": 17, + "advancedDeterministic": 35, + "model": 0, + "unresolved": 77 + }, + "agreement": { + "filesFullyResolved": 35, + "comparable": 35, + "agreeExact": 9, + "exactShare": 25.71, + "disagreeExamples": [ + { + "merge": "fca7531897", + "path": "scss/_variables.scss", + "hunks": 2 + }, + { + "merge": "5ad1049622", + "path": "scss/_variables.scss", + "hunks": 1 + }, + { + "merge": "38271b21d5", + "path": "scss/_variables.scss", + "hunks": 1 + }, + { + "merge": "babdf36c42", + "path": "docs/4.0/migration.md", + "hunks": 1 + }, + { + "merge": "1f42d79561", + "path": "scss/_forms.scss", + "hunks": 1 + }, + { + "merge": "1f42d79561", + "path": "scss/_input-group.scss", + "hunks": 1 + }, + { + "merge": "1f42d79561", + "path": "scss/_variables.scss", + "hunks": 1 + }, + { + "merge": "9501ed8725", + "path": "scss/mixins/_buttons.scss", + "hunks": 2 + }, + { + "merge": "64008ad721", + "path": "docs/4.0/migration.md", + "hunks": 1 + }, + { + "merge": "e62b121226", + "path": "scss/bootstrap.scss", + "hunks": 1 + }, + { + "merge": "d7302c221a", + "path": "docs/4.0/migration.md", + "hunks": 1 + }, + { + "merge": "21b874d19d", + "path": "scss/_variables.scss", + "hunks": 2 + }, + { + "merge": "d4eb0d4e73", + "path": "scss/_navbar.scss", + "hunks": 1 + }, + { + "merge": "5463d8436b", + "path": "scss/_variables.scss", + "hunks": 1 + }, + { + "merge": "b7cc8871be", + "path": "scss/_variables.scss", + "hunks": 1 + }, + { + "merge": "0c12ccbeb6", + "path": "docs/components/navbar.md", + "hunks": 1 + }, + { + "merge": "0c12ccbeb6", + "path": "scss/_navbar.scss", + "hunks": 1 + }, + { + "merge": "c4867cfedb", + "path": "js/src/dropdown.js", + "hunks": 1 + }, + { + "merge": "047d4a77da", + "path": "docs/assets/scss/_nav.scss", + "hunks": 1 + }, + { + "merge": "50d5f60696", + "path": "scss/_variables.scss", + "hunks": 1 + }, + { + "merge": "be4fc23fdb", + "path": "scss/_variables.scss", + "hunks": 5 + }, + { + "merge": "e11e6ec913", + "path": "scss/_list-group.scss", + "hunks": 1 + }, + { + "merge": "864343a3cc", + "path": "scss/_variables.scss", + "hunks": 1 + }, + { + "merge": "ccb5248205", + "path": "scss/_variables.scss", + "hunks": 1 + }, + { + "merge": "61b01f9b28", + "path": "scss/_variables.scss", + "hunks": 1 + } + ] + }, + "autoResolvedShare": 40.31 + } + ] +} diff --git a/docs/superpowers/plans/2026-08-26-merge-context.md b/docs/superpowers/plans/2026-08-26-merge-context.md new file mode 100644 index 00000000..037bacb5 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-merge-context.md @@ -0,0 +1,83 @@ +# Merge Context (accuracy lot C) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give the engine the one input it has never had — *what merge is this?* — so the patterns that currently guess can decide. Measured on the benchmark corpus (`benchmark/`), the largest remaining source of disagreement with what teams actually ship is `value_only_change` picking "the newer semver" on back-merges where the only correct answer is *the target branch's value* (laravel: ~112 wrong resolutions from this single rule; `'13.x-dev'` does not even parse as semver). Nothing in `resolve(content, filePath, options)` knows which branch is being merged into which. + +**Architecture:** A new optional `mergeContext` on `GitWandOptions`, plain data, fully serialisable: + +```ts +/** v3.10 — What operation produced these conflict markers. */ +export interface MergeContext { + /** The git operation in progress. */ + operation: "merge" | "rebase" | "cherry-pick" | "revert"; + /** + * Which side of the markers is the branch being merged INTO. + * In git's own marker convention this is "ours" for merge, rebase + * (ours = the branch rebased onto) AND cherry-pick — but callers state it + * explicitly so the engine never re-derives the famous rebase inversion. + */ + targetSide: "ours" | "theirs"; + /** Ref names, for traces and explanations only — never parsed for decisions. */ + oursRef?: string; + theirsRef?: string; +} +``` + +Detection lives with the callers, not the core: the CLI and MCP read `.git` state (`MERGE_HEAD`, `rebase-merge/`/`rebase-apply/`, `CHERRY_PICK_HEAD`, `REVERT_HEAD`) via a shared helper; the desktop already knows its own operation state and passes it directly. The core stays a pure function — context in, decision out, context echoed in the trace. + +**Behavioural rules (the whole point):** + +1. `value_only_change` on a **version-like scalar** (semver-ish, or same key as a known version field): + - context present → resolve to the **target side**, trace says why ("back-merge: the target branch's version survives"). + - context absent → **propose, never auto-apply** (like `token_level_merge`). The current "pick the newer semver" heuristic measured 27–47 % agreement; a coin-flip has no business auto-applying. Non-version scalars (hashes, timestamps) keep today's behaviour. +2. Changelog-shaped markdown (same detector as the invariant check): context present → the target side's section *structure* wins; incoming release sections are surfaced as a proposal, not silently unioned. Context absent → unchanged (lot-1 invariants already retract the bad unions). +3. The trace records the context on every hunk it influenced (`trace.steps` entry + `explanation`), so the desktop can show "resolved because this is a back-merge into 13.x". + +**Tech Stack:** TypeScript (`@gitwand/core`, `@gitwand/cli`, `@gitwand/mcp`), Vue 3 composables, Node `dev-server.mjs` parity if any new Tauri command is needed (expected: none — the desktop's existing state knows the operation), Vitest + corpus fixtures, `benchmark/` for the before/after. + +**Spec:** [`docs/superpowers/specs/2026-08-26-conflict-engine-accuracy.md`](../specs/2026-08-26-conflict-engine-accuracy.md) § C. Lot 1 (A/B/D-interim) landed in `3c2a4b4` — this plan assumes it. + +## Global Constraints + +- Package manager is **pnpm only**. Never edit version files by hand — `./scripts/bump-version.sh X.Y.Z`. +- `mergeContext` is **optional everywhere**; every existing call site keeps compiling and behaving identically except rule 1's context-absent demotion, which is deliberate and test-pinned. +- No shell string interpolation in git commands — `.args([...])` with discrete values; any new FS access through `safe_repo_path()` if Rust ends up involved. +- Every user-visible string (desktop trace display) in all 5 locales: `en`, `fr`, `es`, `pt-BR`, `zh-CN`. +- Tests use real temporary git repos (`TempRepo`, `fixtures.mjs`) — do not mock the git layer. +- The golden-funnel snapshot WILL change (value_only demotion). Regenerate it in its own commit with the numbers in the message, never silently. + +## Tasks + +### 1 — Core: the type and the plumbing +- [x] `types.ts`: add `MergeContext`, add `mergeContext?: MergeContext` to `GitWandOptions`; `DEFAULT_OPTIONS.mergeContext: undefined` (typed `MergeContext | undefined`; keep `Required` compiling). +- [x] Thread `options.mergeContext` into `resolveHunk` / `assembleResolution` (already receive full options — verify, no signature change expected). +- [x] Unit: `resolve()` with and without context returns identical results on a corpus fixture that context should NOT influence. + +### 2 — Core: version-aware `value_only_change` +- [x] In `patterns/value-only-change.ts` (or `assemble.ts` case): add `isVersionLikeScalar()` — semver-ish values, or the changed token sits in a `version`-named key (`"version":`, `const VERSION`, `version =`). Deliberately conservative; when unsure, it is not version-like. +- [x] Context present + version-like → resolve to `targetSide`, confidence `high`, trace step naming the operation and refs. +- [x] Context absent + version-like → `lines: null`, reason explaining both candidate values and how to enable the deterministic path (run from a repo where GitWand can see the operation, or pass `mergeContext`). +- [x] Unit tests: the laravel `Application.php` shape (back-merge, target wins), the rebase inversion (targetSide "ours" while user perceives it as theirs), absent-context demotion, non-version scalar untouched. + +### 3 — Detection helper (callers' side) +- [ ] `packages/cli/src/git.ts`: `detectMergeContext(cwd): MergeContext | null` from `.git` state files + `git rev-parse --abbrev-ref HEAD` / `MERGE_HEAD` for the ref names. Cover worktrees (`.git` as file). +- [x] Unit tests with `TempRepo`: mid-merge, mid-rebase, mid-cherry-pick, clean repo → null. +- [x] CLI `resolve` / `preview`: call it, pass it, print one line in verbose mode ("context: merging feature/x into main"). +- [x] MCP `gitwand_resolve_conflicts` (+ preview tool): same detection from the tool's cwd; echo the detected context in the tool result so agents can reason about it. + +### 4 — Desktop +- [x] `useGitWand.ts`: build `mergeContext` from the state the app already tracks (merge in progress / rebase in progress / cherry-pick — the same signals the conflict banner uses) and merge it into `resolveOptions`. +- [ ] Trace display: show the context line in the hunk explanation panel; 5-locale strings. +- [ ] Verify the dev-server parity suite still passes; add a parity fixture only if a new backend read is actually needed. + +### 5 — Measure, then decide what ships +- [x] `scripts/replay-conflicts.mjs`: pass `mergeContext: { operation: "merge", targetSide: "ours" }` — in a replayed merge commit, the first parent IS the target branch, so the benchmark exercises the real rule. +- [x] Re-run `benchmark/run.mjs`; expected: laravel agreement jumps (the ~112 Application.php cases flip), corpus agreement moves accordingly. Record `results/v.json` and update the tables in `benchmark/README.md`. +- [ ] If agreement does NOT improve on at least two repos, stop and re-open the spec before wiring the desktop — the rule, not the plumbing, would be wrong. + +### 6 — Close +- [ ] Corpus fixtures: add 2 context-dependent fixtures (back-merge version, rebase inversion) to `src/__tests__/corpus.ts`. +- [ ] Golden funnel: regenerate, numbers in the commit message. +- [ ] `website/reference/config.md` + `guide/conflict-resolution.md`: document `mergeContext` (auto-detected; API consumers can pass it explicitly). +- [ ] CHANGELOG entry; `./scripts/bump-version.sh` per release train. diff --git a/docs/superpowers/plans/2026-08-26-regenerate-tier.md b/docs/superpowers/plans/2026-08-26-regenerate-tier.md new file mode 100644 index 00000000..18a0bf5f --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-regenerate-tier.md @@ -0,0 +1,83 @@ +# Regenerate Tier for Generated Files (accuracy lot D, full) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** For declared-generated files, stop declining and start producing the *right* answer: resolve the source-of-truth file (`package.json`, `composer.json`, `Cargo.toml`…), then re-run the ecosystem's generator in a sandbox and take its output as the resolution. Lockfiles are the single biggest class of conflicts the interim lot D declines today; regeneration is the only correct resolution for them — a textual merge of a lockfile is wrong ~100 % of the time, which is why the interim ships "decline and explain". + +**Why this is its own plan (spec § D):** regeneration executes repository-triggered tooling. That makes it categorically different from every other engine feature: it needs explicit user consent, a sandbox, script suppression (an `npm install` runs lifecycle scripts from the repo — an attack vector on untrusted clones), a timeout, an offline fallback that declines rather than merges, and a failure path that hands the conflict back intact. None of this can be "on by default". + +**Architecture — plan in core, execution at the edges:** core stays pure and Node-free. The engine never spawns a process; it emits a **regeneration plan** (a data object) when a generated file's source of truth is resolvable. Callers (CLI first, desktop later) own execution, exactly like `detectMergeContext` and the conventions runner. + +```ts +/** accuracy lot D — one ecosystem the regenerate tier knows how to drive. */ +export interface RegenEcosystem { + id: "npm" | "pnpm" | "yarn-berry" | "composer" | "cargo"; + /** The generated file this entry owns (matches GENERATED_FILE_PATTERNS). */ + lockfile: RegExp; + /** Files that must be conflict-free (or engine-resolved) before regeneration makes sense. */ + sourcesOfTruth: string[]; + /** Lockfile-only, script-suppressed command. Never a full install. */ + command: { bin: string; args: string[] }; + network: "required" | "offline-capable"; + defaultTimeoutMs: number; +} + +/** What the engine emits instead of resolving; the caller decides whether to run it. */ +export interface RegenerationPlan { + file: string; + ecosystem: RegenEcosystem["id"]; + /** Every source of truth and how it was settled (clean | engine-resolved(confidence) | conflicted). */ + sources: Array<{ path: string; state: "clean" | "resolved" | "conflicted"; confidence?: number }>; + /** Plan is only runnable when no source is "conflicted". */ + runnable: boolean; +} +``` + +**v1 registry (deliberately small):** only ecosystems with a lockfile-only, script-suppressed mode: +`npm install --package-lock-only --ignore-scripts`, `pnpm install --lockfile-only --ignore-scripts`, +`yarn install --mode=update-lockfile` (berry only — classic yarn has no lockfile-only mode: excluded), +`composer update --lock --no-scripts --no-install`, `cargo generate-lockfile` (resolves, never builds). +`go.sum` (`go mod tidy` rewrites sources), `Gemfile.lock`, `poetry.lock` and snapshot regeneration (`jest -u` — runs arbitrary test code) are explicitly **out of scope for v1**; the registry is designed so adding one is one entry + one fixture. + +**Consent & precedence:** regeneration never runs by itself. Explicit `.gitwandrc` `regenerate: true` or per-invocation `--regenerate` > conventions (`generatedFiles: "regenerate"` verdict makes the CLI *offer* it, still gated on the flag/config) > default off (interim decline message, now ending with "or re-run with --regenerate"). `resolveGeneratedFiles: true` (textual opt-in) and regeneration are mutually exclusive; the explicit textual opt-in wins and skips the plan. + +**Execution sandbox (caller side):** run in a disposable `git worktree` populated from the in-progress merge index with the resolved sources written in — never in the user's working tree. Wall-clock timeout (default 120 s, configurable), stdout/stderr captured into the trace, `--ignore-scripts`-family flags are **non-negotiable registry constants** (not user-overridable). On any failure — non-zero exit, timeout, missing toolchain (`which` probe first), offline while `network: "required"` — the file comes back as the untouched conflict with the actionable interim reason plus the failure detail. Regeneration output only replaces the conflict if the generated file parses (reuse lot B validators where a format validator exists). + +**Measurement (its own harness — the gate cannot use merge-tree):** `merge-tree --write-tree` replays never touch a working tree, so the existing benchmark cannot score this lot. New `scripts/replay-regenerate.mjs`: full (non-bare) clones, for each historical corpus merge whose conflicts include a v1-registry lockfile, check out the merge state, run the plan, byte/structurally compare against the committed lockfile. Bounded (≤ 20 merges per ecosystem), network required → runs manually/in the container, **not** in the CI gate; results and method documented in `benchmark/README.md` alongside the agreement metric. The CI gate (lot G) keeps guarding the text engine, unchanged. + +**Spec:** [`docs/superpowers/specs/2026-08-26-conflict-engine-accuracy.md`](../specs/2026-08-26-conflict-engine-accuracy.md) § D. Assumes lots 1/C/E/F-core (`feat/conflict-engine-accuracy`). + +## Global Constraints + +- pnpm only; no shell interpolation in git/tool commands (`.args([...])`); `safe_repo_path()` for any Rust FS access. +- Core emits plans, never executes. Every executed regeneration is traced: command, duration, exit code, and provenance in the resolution reason (`regenerated via pnpm install --lockfile-only (4.2 s)`). +- Script suppression flags are registry constants; a registry entry without them must not compile past review. +- Hermetic git env in every test that spawns git (see merge-context-detect.test.ts) **and** explicit `{ timeout: 30_000 }` on every integration `it()` (macOS XProtect). +- New user-visible strings in all 5 locales. `Required` keeps compiling. +- Offline is a first-class path, not an error: decline with the interim message, never a partial lockfile. + +## Tasks + +### 1 — Core: registry + plan emission +- [ ] `packages/core/src/regenerate/registry.ts` — `RegenEcosystem`, the 5 v1 entries, `findEcosystem(path)`. +- [ ] `packages/core/src/regenerate/plan.ts` — pure `buildRegenerationPlan(file, hunks, options)`: locate sources of truth in the same conflict set, mark each clean/resolved/conflicted, set `runnable`. +- [ ] Resolver integration: when the generated gate declines AND an ecosystem matches, attach the plan to the declined resolution (`resolution.regenerationPlan?`); reason text gains the "--regenerate" hint. Mutual exclusion with `resolveGeneratedFiles: true`. +- [ ] Unit tests: plan runnable only when sources settle, conflicted source → runnable:false with the source named, non-registry generated file (`.min.js`) → no plan, textual opt-in wins. + +### 2 — CLI: the executor +- [ ] `packages/cli/src/regenerate-runner.ts` — toolchain probe, disposable worktree from the merge index + resolved sources, spawn with timeout, capture, validate output, clean up the worktree in `finally`. +- [ ] `gitwand resolve --regenerate` (+ `.gitwandrc` `regenerate: true`): execute runnable plans after the engine pass; per-file verbose line (ecosystem, command, duration, outcome). Failure → untouched conflict + detailed reason. +- [ ] Tests on fabricated temp repos (one per ecosystem where the toolchain exists on the runner; `describe.skipIf` per missing binary): success path, timeout path, missing-toolchain path, output-fails-validation path, worktree always cleaned. + +### 3 — Conventions & context interplay +- [ ] `generatedFiles` convention verdict "regenerate" → CLI prints the offer when declining without the flag; verdict "merge" → conventions already flip the textual path, plan suppressed. Precedence test: `.gitwandrc` beats both. +- [ ] MCP: expose `regenerate` as a tool option on the 3 resolve() sites (duplicate the small helper — mcp must not depend on cli). +- [ ] Reference docs: `website/reference/config.md` § Generated Files gains the regenerate tier (consent model, sandbox, what runs, what never runs). + +### 4 — Measurement harness + gate +- [ ] `scripts/replay-regenerate.mjs` per the design above; run in the container against corpus v2 repos with lockfile conflicts (laravel/composer, prettier/npm…). +- [ ] **GATE:** ship the desktop surface and any default-on behaviour ONLY if measured agreement on regenerated lockfiles is materially better than decline (target: ≥ 80 % structural match on runnable plans). Below target → keep CLI opt-in only, document findings, stop here. +- [ ] `benchmark/README.md`: method, results table, why this metric lives outside the CI gate. + +### 5 — Desktop surface — gated on task 4 +- [ ] Consent dialog (what command, what it touches, network), per-repo remembered choice; progress + trace in the resolution panel; Tauri command with `safe_repo_path()`. Own plan if the gate passes — not started before. diff --git a/docs/superpowers/plans/2026-08-26-repo-conventions.md b/docs/superpowers/plans/2026-08-26-repo-conventions.md new file mode 100644 index 00000000..05c3ee3e --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-repo-conventions.md @@ -0,0 +1,77 @@ +# Repo Conventions (accuracy lot F) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop assuming a repository's merge conventions — **measure them**, from the repository's own history. `scripts/replay-conflicts.mjs` already replays a repo's merges through the engine and compares against what the team actually committed; pointed at the *user's* repo instead of a benchmark corpus, the same mechanism answers, with evidence: does this team regenerate or merge its lockfiles? who wins version-identity scalars here? does their changelog get unioned or rebuilt by tooling? Derived answers become per-repo engine defaults with a visible provenance ("measured on your last N merges"), replacing guesses. Nobody else in this market can do this, and every building block already exists. + +**Why this is the moat:** the engine's rules were calibrated on a public corpus. Lot C's own history proves conventions differ per repo (laravel keeps the target's version identity; prettier takes the newer dep). A convention *measured on the user's repo* is the strongest possible form of the product's claim — not "we know what's trivial" but "we measured what your team does." + +**Architecture:** one new pure module in core + one derivation runner + consumers. + +```ts +/** accuracy lot F — a convention measured from the repo's own merge history. */ +export interface RepoConventions { + /** How many merges/files the derivation actually saw — consumers must gate on this. */ + evidence: { mergesReplayed: number; conflictedFiles: number; derivedAt: string; engineVersion: string }; + /** Per-question verdicts, each with its own sample size and agreement rate. */ + generatedFiles?: { verdict: "regenerate" | "merge"; samples: number; agreement: number }; + versionIdentity?: { verdict: "target-wins" | "newest-wins"; samples: number; agreement: number }; + changelog?: { verdict: "target-structure" | "union" | "tool-rebuilt"; samples: number; agreement: number }; + /** Per-path-glob overrides discovered (e.g. docs/** always theirs). Bounded, top-N only. */ + pathPolicies?: Array<{ glob: string; policy: "prefer-ours" | "prefer-theirs"; samples: number; agreement: number }>; +} +``` + +Derivation is a **replay**: for each historical merge with conflicts, re-run the engine under each candidate rule and score which candidate matches the committed result. A verdict is only emitted above a floor (`samples >= 5 && agreement >= 0.8`); below it, the field is absent and the engine keeps its measured public-corpus defaults. Everything is local — no network, no telemetry. + +**Storage & precedence:** derived conventions are written to `.git/gitwand/conventions.json` (per-clone, never committed, invisible to the repo). Precedence: explicit `.gitwandrc` > derived conventions > engine defaults. `.gitwandrc` always wins — a team that states its policy is never overridden by inference, and the UI says which layer decided. + +**Tech Stack:** TypeScript. Derivation logic in `@gitwand/core` (pure: takes replay observations, returns `RepoConventions`); the git-walking runner in a shared caller-side helper (like `detectMergeContext` — core stays Node-free); Tauri command + dev-server parity route for the desktop; Vitest with real temp repos; the pinned benchmark to prove the loop closes. + +**Spec:** [`docs/superpowers/specs/2026-08-26-conflict-engine-accuracy.md`](../specs/2026-08-26-conflict-engine-accuracy.md) § F. Assumes lots 1/C/E (`feat/conflict-engine-accuracy`). + +## Global Constraints + +- pnpm only; `./scripts/bump-version.sh` for versions; no shell interpolation in git commands (`.args([...])`); `safe_repo_path()` for any Rust FS access. +- Derivation must be **bounded**: default cap 200 merges / 60s wall, resumable, and runs off the UI thread (worker or backend). A 100k-commit monorepo must not freeze the app. +- Conventions carry provenance everywhere they act: every resolution influenced by a derived convention says so in its trace (`convention: regenerate-lockfiles (measured on 41 merges, 97%)`). +- New user-visible strings in all 5 locales. Tests on real temp repos, **hermetic git env** (see merge-context-detect.test.ts — global config must never leak in). +- `Required` keeps compiling: `conventions?: RepoConventions | null`, default `null`. + +## Tasks + +### 1 — Core: the observation → verdict engine +- [x] `packages/core/src/conventions/types.ts` — `RepoConventions`, `ConventionObservation` (one replayed conflicted file: path, hunk classes, what each candidate rule would produce, what the humans committed). +- [x] `packages/core/src/conventions/derive.ts` — pure `deriveConventions(observations: ConventionObservation[]): RepoConventions`, with the sample/agreement floors and per-question scoring. No git, no fs. +- [x] Unit tests: floors respected (4 samples → no verdict), conflicting evidence → no verdict, agreement math, engineVersion stamped. + +### 2 — Core: conventions as an input +- [x] `GitWandOptions.conventions?: RepoConventions | null` (default null) + precedence: explicit `.gitwandrc` keys win over conventions, conventions win over defaults. Implement for the three questions that already have engine switches: `resolveGeneratedFiles` (generatedFiles verdict "merge" → behave as opt-in true), version-identity side (versionIdentity verdict feeds the lot-C rule when `mergeContext` is absent), changelog handling (verdict "tool-rebuilt" → decline changelog unions outright). +- [x] Trace provenance: every influenced resolution's reason names the convention, its sample count and agreement. +- [x] Unit tests per question + a precedence test (.gitwandrc beats conventions). + +### 3 — The derivation runner (caller side) +- [x] `packages/cli/src/conventions-runner.ts` — walk `rev-list --merges` (cap + `--since` window), re-create each conflict via `merge-tree --write-tree` (git ≥ 2.38 guard), build `ConventionObservation`s, call `deriveConventions`, write `.git/gitwand/conventions.json` atomically. Shares the merge-walk shape with `scripts/replay-conflicts.mjs` — extract the common walk into the runner and have the benchmark script consume it, so there is ONE replay implementation. +- [x] `gitwand conventions` CLI command: derive (`--max-merges`, `--json`), show current verdicts with evidence, `--clear`. Verbose prints the per-question table. +- [x] Tests: temp repo with a fabricated history (team regenerates lockfiles in 6 merges → verdict; 4 merges → no verdict), worktree case, cap respected. + +### 4 — Desktop — **DEFERRED by the task-5 gate** (2026-08-26) + +_Split-half on the corpus: flat everywhere — every derived verdict confirms the +engine defaults, because the defaults were calibrated on this very corpus +(circularity, recorded in benchmark/README). The desktop surface waits for a +corpus re-pin that includes repos with divergent conventions; core + CLI ship +now (provenance + `gitwand conventions` have standalone value)._ +- [ ] Tauri command `derive_conventions` (Rust spawns the same runner logic via the existing node sidecar? NO — implement the walk in Rust `git/conventions.rs` OR call the CLI runner as a subprocess; decide by effort at implementation time, parity route in `dev-server.mjs` either way) + typed wrapper in `utils/backend.ts` + `invoke_handler!` registration. +- [ ] `useGitWand.ts`: load `.git/gitwand/conventions.json` alongside `.gitwandrc` at repo open; merge into `resolveOptions` at the documented precedence. +- [ ] Settings > repo section: "Measure this repo's merge conventions" action with progress, results table (question / verdict / evidence), re-run and clear. 5 locales. +- [ ] The conflict UI shows convention provenance when a hunk was influenced (reuses the trace string from task 2). + +### 5 — Prove the loop closes (gate) +- [x] Benchmark: derive conventions on each corpus repo from its FIRST half of merges, then measure agreement on the SECOND half with conventions applied vs not. Ship the desktop surface only if agreement improves (or stays flat with better coverage) on at least two repos and regresses on none beyond noise. +- [x] Record the split-half results in `benchmark/README.md`. + +### 6 — Close +- [ ] `website/reference/config.md` + `/conflict-engine`: document the layer and its precedence; `llms.txt` line. +- [ ] CHANGELOG; corpus fixtures if any new decline/resolve behaviours emerged; golden funnel if the funnel moved. +- [ ] Note the v4.0 tie-in in ROADMAP: `useResolutionMemory` (manual-choice memory) and conventions (history-derived) should share the provenance display, and eventually one store. diff --git a/docs/superpowers/plans/2026-08-27-regenerate-tier-followup.md b/docs/superpowers/plans/2026-08-27-regenerate-tier-followup.md new file mode 100644 index 00000000..3cf8b04f --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-regenerate-tier-followup.md @@ -0,0 +1,655 @@ +# Regenerate Tier Follow-up — Merge-Index Seeding & Full Corpus Sweep Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the three follow-up items left after the "Regenerate Tier for Generated Files" plan's accuracy gate came back below target (66.7% agreement, n=3): seed the disposable regeneration worktree from the real merge result instead of `ours`-only `HEAD`, re-run the measurement at real scale now that the sourcing is fixed, and fill in the CLI docs gap the final review flagged. + +**Architecture:** The disposable worktree the CLI's `regenerate-runner.ts` spawns installers in is currently built from `git worktree add --detach HEAD` — i.e. `ours` only. A file that exists solely on `theirs'` side (a new workspace member's `package.json`, say) is invisible to the installer, and the seed lockfile is `ours'`, biasing regeneration toward an incremental update instead of a fresh resolution. This plan overlays that worktree with the *actual* merge result: for the real CLI (a genuine in-progress merge), that means checking out the repo's own live index's already-resolved (stage-0) paths on top of the `HEAD` scaffold — paths still mid-conflict are silently skipped by `checkout-index`, which is fine since the engine's own resolved source content overwrites those anyway. For the measurement harness (replaying *historical* merges with no real in-progress merge state), the same mechanism is reused by feeding it a scratch index built from the tree `git merge-tree --write-tree` already computed during candidate discovery — no new git machinery, just pointing the existing primitive at a different index file via `GIT_INDEX_FILE`. Once both call sites are fixed, the harness runs for real against every corpus repo with a v1-registry lockfile (not just the 3-merge pilot), and `benchmark/README.md`'s gate section gets updated with whatever that measures — honestly, same discipline as the original plan. + +**Tech Stack:** TypeScript (`packages/core`, `packages/cli`), Node.js `.mjs` scripts (`scripts/`), git plumbing (`worktree`, `checkout-index`, `read-tree`, `merge-tree`), Vitest (`packages/cli`), `node:test` (`scripts/lib`). + +**Spec:** [`docs/superpowers/specs/2026-08-26-conflict-engine-accuracy.md`](../specs/2026-08-26-conflict-engine-accuracy.md) § D. Builds directly on `docs/superpowers/plans/2026-08-26-regenerate-tier.md` (already shipped, `feat/conflict-engine-accuracy`) — read that plan's Task 2 (`regenerate-runner.ts`) and Task 4 (`scripts/replay-regenerate.mjs`) sections for the code this plan modifies. + +## Global Constraints + +- pnpm only; no shell interpolation in git/tool commands — every spawn uses an args array (`execFileSync`/`execFileAsync` with an argv array), never string concatenation. +- Real temp git repos in every test, never mocked — and **always** create them via Node's `mkdtempSync(join(tmpdir(), "-"))` (`node:fs` + `node:os` + `node:path`), never a raw shell `mktemp`. A raw `mktemp -d` inside this sandbox's Bash tool has silently failed and fallen back to the current working directory before — Node's `tmpdir()`/`mkdtempSync` do not have this failure mode and are what every existing test in this codebase already uses. +- Hermetic git env + explicit `{ timeout: 30_000 }` on every integration `it()`/`test()` that spawns git, matching `packages/cli/src/__tests__/merge-context-detect.test.ts`'s `HERMETIC_GIT_ENV`/`IT_TIMEOUT` pattern. +- Script-suppression flags on the 5 registry ecosystems remain constants — this plan does not touch `packages/core/src/regenerate/registry.ts` at all. +- `packages/core` stays zero-Node.js/browser-compatible — this plan does not touch `packages/core` (only `packages/cli` and `scripts/`). +- Offline is a first-class path, not an error — untouched by this plan, already handled upstream of the worktree-seeding step. +- `packages/cli/dist/` and `packages/core/dist/` must be rebuilt (`pnpm --filter @gitwand/cli build`, `pnpm --filter @gitwand/core build`) before `scripts/replay-regenerate.mjs` picks up any change, since it imports from `dist/`, not `src/`. + +--- + +## Task 1: Docs — fill in the CLI reference gap the final review flagged + +**Files:** +- Modify: `website/reference/cli-commands.md` + +**Interfaces:** None — pure documentation, no code. + +- [ ] **Step 1: Add the missing `resolve` options to the existing Options table** + +Find the `### Options` table under `## \`gitwand resolve\`` (currently 5 rows: `--dry-run`, `--verbose`, `--no-whitespace`, `--ci`, `--json`). Insert these rows, matching the exact wording already used in `packages/cli/src/cli.ts`'s `printHelp()`: + +```markdown +| `--resolve-generated` | Auto-resolve generated files (lockfiles, `dist/`) — declined by default: regenerate them instead | +| `--regenerate` | Re-run the ecosystem's generator (npm/pnpm/yarn-berry/composer/cargo) for declined lockfiles once their source of truth is clean/resolved (sandboxed git worktree, opt-in — see `.gitwandrc` `"regenerate": true`) | +| `--concurrency=N` | Parallel file workers (default 8, min 1) | +| `--llm-fallback` | Enable LLM fallback for unresolved conflicts (opt-in, experimental) | +| `--llm-provider=X` | LLM provider: `claude` (default) \| `openai` \| `ollama` | +| `--llm-model=X` | Model name (e.g. `claude-sonnet-4-6`, `gpt-4o-mini`, `llama3`) | +``` + +- [ ] **Step 2: Add a `## \`gitwand conventions\`` section** + +Add a new `##` section after `## \`gitwand status\`` and before `## \`gitwand --help\``: + +```markdown +## `gitwand conventions` + +Measures this repo's own merge conventions from its historical merges (which side wins version scalars, whether the team regenerates or merges lockfiles, how the changelog is maintained) and writes the verdicts to `.git/gitwand/conventions.json` — per clone, never committed, always beaten by an explicit `.gitwandrc`. + +### Options + +| Option | Description | +|--------|-------------| +| `--show` | Print the currently persisted conventions without re-measuring | +| `--clear` | Delete the persisted conventions file | +| `--max-merges=N` | Cap on historical merges replayed (default 200) | +| `--json` | Machine-readable output | + +### Example + +```bash +$ gitwand conventions + measured on 187 merges / 412 conflicted files (engine 3.8.0, 2026-08-27) + + generated files regenerate (11 samples, 91 %) + changelog tool-rebuilt (8 samples, 100 %) + +✓ written to .git/gitwand/conventions.json (per-clone, never committed; an explicit .gitwandrc always wins) +``` +``` + +- [ ] **Step 3: Verify the additions landed correctly** + +Run: +```bash +grep -n -- "--regenerate\|--resolve-generated\|--llm-fallback\|--concurrency" website/reference/cli-commands.md +grep -n "gitwand conventions" website/reference/cli-commands.md +``` +Expected: the first `grep` prints 4+ matching lines inside the Options table; the second prints at least 2 matches (the new `##` heading and the example's `$ gitwand conventions` line). + +- [ ] **Step 4: Commit** + +```bash +git add website/reference/cli-commands.md +git commit -m "docs(website): document --regenerate/--resolve-generated and gitwand conventions in cli-commands.md" +``` + +--- + +## Task 2: CLI — seed the disposable worktree from the real merge result, not `ours`-only `HEAD` + +**Files:** +- Modify: `packages/cli/src/regenerate-runner.ts` +- Test: `packages/cli/src/__tests__/regenerate-runner.test.ts` + +**Interfaces:** +- Produces: `RegenerationRunParams.seedIndexFile?: string` — an optional path to an alternate git index file to seed the worktree from. Omitted (the CLI's real production call site in `packages/cli/src/commands/resolve.ts` — **not modified by this task**, it inherits the fix automatically) means "use `repoRoot`'s own live index," which during a real in-progress merge already holds the correct 3-way-merged state for every non-conflicted path. Task 3 supplies this for the measurement harness. + +- [ ] **Step 1: Write the failing test — a `theirs`-only file must be visible inside the worktree** + +`packages/cli/src/__tests__/regenerate-runner.test.ts` already provides everything this test needs via its module-level `beforeEach`/helpers: a fresh `repo` (created with `mkdtempSync`, hermetic env, cleaned up in `afterEach`), `initRepo(repo)`, `writeAndAdd(repo, path, content)`, `commit(repo, msg)`, the hermetic `git(cwd, args)` helper, `listWorktrees(repo)`, `ecosystemFor(id)`, and the shared `IT_TIMEOUT`. Reuse all of them — don't create a second temp-dir, a second git helper, or a second hermetic-env setup; every other test in this file follows this exact pattern (see e.g. the `"returns spawn-failed on a non-zero exit code"` test right above where you're inserting this one). + +Add this test to the `describe("runRegeneration — failure paths", ...)` block's sibling scope, or its own new `describe` block right after it — either is fine, this file uses both styles already: + +```typescript +describe("runRegeneration — worktree reflects the real merge index", () => { + it("a theirs-only file is visible inside the disposable worktree", IT_TIMEOUT, async () => { + initRepo(repo); + writeAndAdd(repo, "package.json", '{"v":1}\n'); + writeAndAdd(repo, "package-lock.json", '{"base":true}\n'); + commit(repo, "base"); + + git(repo, ["checkout", "-b", "theirs"]); + // theirs adds a brand-new file that ours never sees committed. + writeAndAdd(repo, "theirs-only.txt", "only on theirs\n"); + writeAndAdd(repo, "package-lock.json", '{"theirs":true}\n'); + commit(repo, "theirs: add file + bump lock"); + + git(repo, ["checkout", "main"]); + writeAndAdd(repo, "package-lock.json", '{"main":true}\n'); + commit(repo, "main: bump lock"); + + try { + git(repo, ["merge", "theirs"]); + } catch { + // conflict on package-lock.json expected; package.json and + // theirs-only.txt auto-merge cleanly and land in the live index. + } + + const fakeEcosystem: RegenEcosystem = { + ...ecosystemFor("npm"), + sourcesOfTruth: [], + network: "offline-capable", // exerce le worktree, pas la sonde réseau + // Prouve que theirs-only.txt a atteint le worktree : `cat` échoue + // (exit non-zéro → spawn-failed, pas success) si le fichier est absent. + command: { bin: "sh", args: ["-c", "cat theirs-only.txt > package-lock.json"] }, + }; + + const outcome = await runRegeneration({ + repoRoot: repo, + file: "package-lock.json", + ecosystem: fakeEcosystem, + resolvedSources: [], + }); + + expect(outcome.kind).toBe("success"); + expect(outcome.content).toBe("only on theirs\n"); + expect(listWorktrees(repo)).not.toContain("gitwand-regen-"); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +cd packages/cli && pnpm vitest run src/__tests__/regenerate-runner.test.ts -t "worktree reflects the real merge" +``` + +Expected: **FAIL** — `outcome.kind` is `"spawn-failed"` (the stubbed `cat theirs-only.txt` fails with "No such file or directory" because today's `addWorktree` only checks out `ours'` `HEAD`, which never had `theirs-only.txt`), not `"success"`. + +- [ ] **Step 3: Fix `addWorktree` to overlay the worktree from the real merge index** + +In `packages/cli/src/regenerate-runner.ts`, replace: + +```typescript +async function addWorktree(repoRoot: string, worktreeDir: string): Promise { + await execFileAsync("git", ["worktree", "add", "--detach", worktreeDir, "HEAD"], { + cwd: repoRoot, + env: buildGitSpawnEnv(), + }); +} +``` + +with: + +```typescript +/** + * Fix (follow-up plan, "merge-index seeding") — step 1 still worktrees at + * `HEAD` (a disposable, always-valid scaffold), but step 2 overlays every + * already-resolved (stage-0) path from the REAL merge index on top of it — + * this is what makes a `theirs`-only file (a new workspace member's + * `package.json`, say) visible to the installer, and what stops the seed + * lockfile from being biased toward `ours'` incremental state. Paths still + * mid-conflict (multi-stage) are silently skipped by `checkout-index`; the + * caller overwrites those explicitly via `resolvedSources` right after this + * returns, so leaving them at their `HEAD` scaffold content is harmless. + * + * `seedIndexFile`, when given, points `checkout-index` at an alternate index + * instead of `repoRoot`'s own live one — used by the measurement harness + * (`scripts/replay-regenerate.mjs`) to replay a *historical* merge, which has + * no real in-progress-merge index to read from. + */ +async function addWorktree( + repoRoot: string, + worktreeDir: string, + seedIndexFile?: string, +): Promise { + await execFileAsync("git", ["worktree", "add", "--detach", worktreeDir, "HEAD"], { + cwd: repoRoot, + env: buildGitSpawnEnv(), + }); + + const env = buildGitSpawnEnv(); + if (seedIndexFile) env.GIT_INDEX_FILE = seedIndexFile; + await execFileAsync( + "git", + ["--work-tree", worktreeDir, "checkout-index", "--all", "--force"], + { cwd: repoRoot, env }, + ); +} +``` + +- [ ] **Step 4: Thread `seedIndexFile` through `RegenerationRunParams` and the call site** + +In the same file, add the field to `RegenerationRunParams`: + +```typescript +export interface RegenerationRunParams { + /** Racine du dépôt git réel — jamais écrite, seulement lue pour créer le worktree. */ + repoRoot: string; + /** Chemin repo-relatif du fichier généré à régénérer (ex: "package-lock.json"). */ + file: string; + ecosystem: RegenEcosystem; + resolvedSources: ResolvedSource[]; + /** Surcharge de `ecosystem.defaultTimeoutMs` (tests notamment). */ + timeoutMs?: number; + /** + * Alternate git index file to seed the disposable worktree from (via + * `GIT_INDEX_FILE`), instead of `repoRoot`'s own live index. Omitted in + * production (the real CLI always has a genuine in-progress merge whose + * live index is exactly what should seed the worktree) — supplied by the + * measurement harness, which has no real in-progress merge to read from. + */ + seedIndexFile?: string; +} +``` + +Then find the call site inside `runRegeneration` (`await addWorktree(repoRoot, worktreeDir);`) and change it to: + +```typescript + await addWorktree(repoRoot, worktreeDir, params.seedIndexFile); +``` + +- [ ] **Step 5: Update the module's header doc — the "LIMITATION CONNUE" paragraph no longer applies** + +Replace the `LIMITATION CONNUE` block at the top of the file (the one describing HEAD-only seeding as a known gap left over from the final review) with: + +```typescript + * Sandbox d'exécution (voir le brief de la tâche, § "Worktree sourcing") : + * 1. `git worktree add --detach HEAD` — HEAD est un point jetable, + * jamais la branche réelle de l'utilisateur. + * 2. superposer sur ce worktree chaque chemin déjà résolu (stage 0) de + * l'index de merge réel (`git checkout-index --all --force`, ciblé via + * `--work-tree`) — c'est ce qui rend visibles les fichiers qui n'existent + * QUE côté "theirs" (follow-up plan, résout la limitation identifiée par + * la revue finale du plan original — voir git blame pour l'historique). + * 3. écraser dans ce worktree chaque source de vérité (`package.json`…) + * par son contenu déjà résolu en pass 1 (fourni par l'appelant — ce + * module ne re-résout rien). + * 4. lancer la commande du registre (flags de suppression de scripts déjà + * bakés dans `ecosystem.command.args` — jamais surchargeables ici). + * 5. sur succès : relire + valider le lockfile régénéré depuis le + * filesystem du worktree. + * 6. `finally` : toujours supprimer le worktree, succès ou échec. +``` + +(Keep the paragraph below it about tracing/provenance unchanged — only the "Sandbox d'exécution" numbered list and the "LIMITATION CONNUE" paragraph are replaced; delete the "LIMITATION CONNUE" paragraph entirely, it's resolved.) + +- [ ] **Step 6: Run the test to verify it passes** + +```bash +cd packages/cli && pnpm vitest run src/__tests__/regenerate-runner.test.ts -t "worktree reflects the real merge" +``` + +Expected: **PASS**. + +- [ ] **Step 7: Run the full existing suite to confirm nothing regressed** + +```bash +cd packages/cli && pnpm build && pnpm vitest run +``` + +Expected: all pre-existing tests still pass (the `seedIndexFile` param is additive and optional — every existing caller that omits it keeps its prior behavior of reading `repoRoot`'s own live index, which for a real repo with no in-progress merge is simply whatever `HEAD` already reflects, i.e. no behavior change for those tests). + +- [ ] **Step 8: Commit** + +```bash +git add packages/cli/src/regenerate-runner.ts packages/cli/src/__tests__/regenerate-runner.test.ts +git commit -m "fix(cli): seed the disposable regeneration worktree from the real merge index, not ours-only HEAD" +``` + +--- + +## Task 3: Measurement harness — replay historical merges through the same fixed seeding + +**Files:** +- Create: `scripts/lib/seed-index.mjs` +- Test: `scripts/lib/seed-index.test.mjs` +- Modify: `scripts/replay-regenerate.mjs` + +**Interfaces:** +- Consumes: `RegenerationRunParams.seedIndexFile?: string` (Task 2). +- Produces: `seedScratchIndex(repo: string, treeOid: string, indexPath: string): void` — exported from `scripts/lib/seed-index.mjs`, consumed by `scripts/replay-regenerate.mjs`. + +- [ ] **Step 1: Write the failing test for the scratch-index helper** + +Create `scripts/lib/seed-index.test.mjs`, matching this repo's existing `scripts/lib/regenerate-compare.test.mjs`'s `node:test` style: + +```javascript +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { seedScratchIndex } from "./seed-index.mjs"; + +function git(repo, args, opts = {}) { + return execFileSync("git", ["-C", repo, ...args], { encoding: "utf-8", ...opts }); +} + +test("seedScratchIndex materializes a theirs-only file into a scratch index without touching the repo's real index", () => { + const repo = mkdtempSync(join(tmpdir(), "gw-seed-index-")); + try { + git(repo, ["init", "-q", "-b", "main"]); + git(repo, ["config", "user.email", "t@t.com"]); + git(repo, ["config", "user.name", "t"]); + writeFileSync(join(repo, "package.json"), '{"v":1}\n'); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "base"]); + + git(repo, ["checkout", "-q", "-b", "theirs"]); + writeFileSync(join(repo, "theirs-only.txt"), "only on theirs\n"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "theirs adds a file"]); + const theirsSha = git(repo, ["rev-parse", "HEAD"]).trim(); + + git(repo, ["checkout", "-q", "main"]); + writeFileSync(join(repo, "package.json"), '{"v":2}\n'); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "main bumps a value"]); + const mainSha = git(repo, ["rev-parse", "HEAD"]).trim(); + + const merged = git(repo, [ + "-c", "merge.conflictstyle=diff3", + "merge-tree", "--write-tree", mainSha, theirsSha, + ]).trim(); + const treeOid = merged.split("\n")[0]; + + const realIndexBefore = readFileSync(join(repo, ".git", "index")); + + const scratchIndex = join(repo, ".git", "scratch-test-index"); + seedScratchIndex(repo, treeOid, scratchIndex); + + assert.ok(existsSync(scratchIndex), "scratch index file must be created"); + // The repo's own index must be byte-for-byte untouched. + assert.deepEqual(readFileSync(join(repo, ".git", "index")), realIndexBefore); + + const listing = git(repo, ["ls-tree", "-r", "--name-only", treeOid]); + assert.ok(listing.includes("theirs-only.txt"), "merged tree must include the theirs-only file"); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +node --test scripts/lib/seed-index.test.mjs +``` + +Expected: **FAIL** — `Cannot find module './seed-index.mjs'` (the module doesn't exist yet). + +- [ ] **Step 3: Write the minimal implementation** + +Create `scripts/lib/seed-index.mjs`: + +```javascript +/** + * Populates a SCRATCH git index file with the contents of `treeOid` (a tree + * object — typically the output of `git merge-tree --write-tree`), scoped to + * `repo`. Never touches `repo`'s own index: `GIT_INDEX_FILE` redirects git's + * plumbing to `indexPath` for this one call only. The caller later points + * `checkout-index --work-tree=` at the same `indexPath` (via + * `GIT_INDEX_FILE`) to materialize the tree's files into a disposable + * worktree — see `scripts/replay-regenerate.mjs` and + * `packages/cli/src/regenerate-runner.ts`'s `addWorktree`. + */ +import { execFileSync } from "node:child_process"; + +export function seedScratchIndex(repo, treeOid, indexPath) { + execFileSync("git", ["-C", repo, "read-tree", treeOid], { + env: { ...process.env, GIT_INDEX_FILE: indexPath }, + }); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +node --test scripts/lib/seed-index.test.mjs +``` + +Expected: **PASS**. + +- [ ] **Step 5: Wire it into `scripts/replay-regenerate.mjs`** + +Add these imports near the top of `scripts/replay-regenerate.mjs`, alongside the existing `execFileSync`/core/cli imports: + +```javascript +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { rm } from "node:fs/promises"; +import { seedScratchIndex } from "./lib/seed-index.mjs"; +``` + +Then find the candidate-execution block (inside the `for (const candidate of candidates)` loop, right where it does `git(["update-ref", "HEAD", candidate.parents[0]]);` immediately before calling `runRegeneration`) and replace: + +```javascript + // Point the corpus repo's HEAD at this merge's target side (first + // parent) so `runRegeneration`'s `git worktree add --detach HEAD` + // reproduces the right commit — see module doc. + git(["update-ref", "HEAD", candidate.parents[0]]); + + const regenOutcome = await runRegeneration({ + repoRoot: repo, + file: candidate.lockfilePath, + ecosystem: candidate.ecosystem, + resolvedSources, + timeoutMs: TIMEOUT_MS_OVERRIDE, + }); +``` + +with: + +```javascript + // Point the corpus repo's HEAD at this merge's target side (first + // parent) so `runRegeneration`'s `git worktree add --detach HEAD` + // reproduces the right commit — see module doc. + git(["update-ref", "HEAD", candidate.parents[0]]); + + // Follow-up plan ("merge-index seeding"): seed the disposable worktree + // from the ACTUAL 3-way merge result — the tree `merge-tree + // --write-tree` already computed during candidate discovery + // (`candidate.treeOid`) — not just `candidate.parents[0]`'s bare HEAD. + // A scratch index is a throwaway file; it never touches this corpus + // repo's own index. + const seedIndexFile = join(tmpdir(), `gitwand-replay-index-${randomUUID()}`); + seedScratchIndex(repo, candidate.treeOid, seedIndexFile); + + let regenOutcome; + try { + regenOutcome = await runRegeneration({ + repoRoot: repo, + file: candidate.lockfilePath, + ecosystem: candidate.ecosystem, + resolvedSources, + timeoutMs: TIMEOUT_MS_OVERRIDE, + seedIndexFile, + }); + } finally { + await rm(seedIndexFile, { force: true }); + } +``` + +- [ ] **Step 6: Syntax-check the script** + +```bash +node --check scripts/replay-regenerate.mjs +``` + +Expected: no output (valid syntax). + +- [ ] **Step 7: Rebuild the packages the script imports from** + +```bash +pnpm --filter @gitwand/core build && pnpm --filter @gitwand/cli build +``` + +- [ ] **Step 8: Smoke-test the full wiring against a tiny synthetic bare repo (no network required)** + +This proves the new `seedScratchIndex` call, the scratch-index cleanup, and `runRegeneration`'s `seedIndexFile` consumption all fit together end-to-end — without depending on a real npm/network toolchain being available (a `missing-toolchain` or `offline` outcome is an expected, valid result here, not a failure of this smoke test). + +```bash +cd /tmp && rm -rf gw-smoke-source gw-smoke-bare +mkdir gw-smoke-source && cd gw-smoke-source +git init -q -b main +git config user.email t@t.com +git config user.name t +echo '{"v":1}' > package.json +echo '{"base":true}' > package-lock.json +git add -A && git commit -q -m base + +git checkout -q -b theirs +echo "only on theirs" > theirs-only.txt +echo '{"theirs":true}' > package-lock.json +git add -A && git commit -q -m "theirs: add file + bump lock" + +git checkout -q main +echo '{"main":true}' > package-lock.json +git add -A && git commit -q -m "main: bump lock" + +# Both sides touched package-lock.json differently, so this conflicts — +# resolve it trivially (take ours) just to land one real 2-parent merge +# commit in history; replay-regenerate.mjs recomputes the merge itself via +# merge-tree, it doesn't trust what this commit's tree actually recorded. +git merge theirs -q -m "merge theirs" 2>/dev/null || { + git checkout -q --ours -- package-lock.json + git add package-lock.json + git commit -q -m "merge theirs" +} + +cd /tmp +git clone -q --bare gw-smoke-source gw-smoke-bare +cd /Users/laurent/Documents/GitHub/GitWand +node scripts/replay-regenerate.mjs /tmp/gw-smoke-bare --max-merges 5 --json +``` + +Expected: the script prints a JSON report to stdout and exits without throwing an unhandled exception or printing a stack trace. `report.mergesScanned` should be `1` (the one real merge commit created above), and `report.perEcosystem.npm.ran` should be `1` (one real regeneration attempt on `package-lock.json`, the only file both sides changed differently — `package.json` wasn't touched by `theirs`, so it auto-merges cleanly and doesn't itself produce a candidate). The specific `outcome` kind recorded for that attempt (`success`, `missing-toolchain`, `offline`, `spawn-failed`, etc.) depends on whatever toolchains/network this machine actually has — any of them is an acceptable smoke-test result; what this step is checking is that the pipeline runs cleanly with the new `seedScratchIndex` wiring in place, not that a specific outcome occurred. If the script throws instead, read the stack trace: a `SyntaxError` or `ReferenceError` here means Step 5's edit has a mistake (most likely a missing import or a variable name typo) — fix it before moving on. + +- [ ] **Step 9: Clean up the smoke-test scratch repos** + +```bash +rm -rf /tmp/gw-smoke-source /tmp/gw-smoke-bare +``` + +- [ ] **Step 10: Run the full existing test suites to confirm nothing regressed** + +```bash +node --test scripts/lib/regenerate-compare.test.mjs scripts/lib/seed-index.test.mjs +pnpm --filter @gitwand/cli test +``` + +Expected: all pass. + +- [ ] **Step 11: Commit** + +```bash +git add scripts/lib/seed-index.mjs scripts/lib/seed-index.test.mjs scripts/replay-regenerate.mjs +git commit -m "feat(scripts): seed the measurement harness's worktree from the real merge-tree result" +``` + +--- + +## Task 4: Run the full corpus sweep and update the gate verdict + +**Files:** +- Modify: `benchmark/README.md` + +**Interfaces:** None — this is an operator-run measurement task, not new code. It depends on Tasks 2 and 3 being merged and built. + +**Scope note:** `laravel/framework` and `symfony/symfony` (the corpus's two PHP repos) are confirmed structurally infeasible for this measurement — both are libraries that never commit `composer.lock` (verified via `git log --all -- composer.lock` returning zero commits on both, independently confirmed via GitHub's commit-history API during the original plan's review). `gohugoio/hugo` (Go) and `git/git` (C) use ecosystems outside the v1 registry's scope entirely. This sweep therefore targets the 4 remaining corpus repos whose language makes a v1-registry lockfile plausible: `prettier/prettier`, `tauri-apps/tauri`, `expressjs/express`, `twbs/bootstrap`. If a repo turns out to have zero matching candidates once actually scanned, record that plainly — same honesty discipline as the original plan's pilot. + +- [ ] **Step 1: Rebuild the packages the harness imports from** + +```bash +pnpm --filter @gitwand/core build && pnpm --filter @gitwand/cli build +``` + +- [ ] **Step 2: Prepare each target repo as a bare, blobless, pinned clone** + +Mirrors `benchmark/run.mjs`'s `prepare()` exactly (same cache directory, same slug convention: `__.git`). Read `benchmark/corpus.json` first to confirm the 4 target repos' current pinned `sha` values before running these (they're pinned deliberately — use whatever the file says, the values below are illustrative of the *shape* of the commands, not a value to copy blind): + +```bash +mkdir -p benchmark/.cache +for entry in \ + "prettier/prettier" \ + "tauri-apps/tauri" \ + "expressjs/express" \ + "twbs/bootstrap" +do + name="${entry//\//__}" + path="benchmark/.cache/${name}.git" + if [ ! -d "$path" ]; then + url=$(node -e "const c=require('./benchmark/corpus.json');const r=c.repos.find(x=>x.name==='$entry');console.log(r.url)") + echo "cloning $entry..." + git clone --bare --filter=blob:none "$url" "$path" + fi + sha=$(node -e "const c=require('./benchmark/corpus.json');const r=c.repos.find(x=>x.name==='$entry');console.log(r.sha)") + git -C "$path" cat-file -e "${sha}^{commit}" 2>/dev/null || git -C "$path" fetch --filter=blob:none origin "$sha" + git -C "$path" update-ref HEAD "$sha" +done +``` + +- [ ] **Step 3: Run the harness for real against each prepared repo, capturing output** + +```bash +mkdir -p /tmp/regen-sweep-results +for entry in "prettier__prettier" "tauri-apps__tauri" "expressjs__express" "twbs__bootstrap"; do + echo "=== $entry ===" + node scripts/replay-regenerate.mjs "benchmark/.cache/${entry}.git" --max-real 20 --json \ + | tee "/tmp/regen-sweep-results/${entry}.json" +done +``` + +This is the real measurement: full clones already prepared, real installer invocations (whatever toolchains — npm, pnpm, yarn, cargo — are available on the machine running this; ecosystems whose toolchain is missing come back as `missing-toolchain` outcomes, which is a valid, honestly-reported result, not a script failure), up to 20 real regeneration attempts per ecosystem per repo, network required. Expect this to take real wall-clock time (multiple minutes per repo) — that's expected, not a hang. + +- [ ] **Step 4: Aggregate the results** + +Each `.json` file is shaped `{ repo, mergesScanned, mergeTreeErrors, maxMerges, maxRealPerEcosystem, perEcosystem }`, where `perEcosystem[ecosystemId]` is `{ runnablePlans, ran, comparable, matched, agreementRate, outcomes: { : count, ... }, examples }` (`agreementRate` is already `matched/comparable * 100`, rounded to 1 decimal, or `null` if `comparable` is 0 — computed by the script itself, don't recompute it differently here): + +```bash +node -e ' +const fs = require("fs"); +const files = fs.readdirSync("/tmp/regen-sweep-results").filter(f => f.endsWith(".json")); +let totalComparable = 0, totalMatched = 0; +const byRepoEcosystem = []; +for (const f of files) { + const r = JSON.parse(fs.readFileSync(`/tmp/regen-sweep-results/${f}`, "utf-8")); + for (const [ecoId, eco] of Object.entries(r.perEcosystem ?? {})) { + byRepoEcosystem.push({ + repo: r.repo, + ecosystem: ecoId, + runnablePlans: eco.runnablePlans, + ran: eco.ran, + comparable: eco.comparable, + matched: eco.matched, + agreementRate: eco.agreementRate, + outcomes: eco.outcomes, + }); + totalComparable += eco.comparable ?? 0; + totalMatched += eco.matched ?? 0; + } +} +console.log(JSON.stringify(byRepoEcosystem, null, 2)); +console.log(`\nTOTAL (weighted by comparable attempts): ${totalMatched}/${totalComparable} = ${totalComparable ? ((totalMatched / totalComparable) * 100).toFixed(1) : "n/a"}%`); +' +``` + +- [ ] **Step 5: Update `benchmark/README.md`'s gate section with the real results** + +Find the section Task 4 of the original plan added (results table + "The gate verdict" + "Before revisiting" list with hypotheses (a)-(d)). Replace the pilot's n=3 table and verdict with the full sweep's real numbers — report every repo's actual outcome, including any that turned out to have zero candidates. State plainly whether the ≥80% target was met on this real, larger sample, and whether hypothesis (d) (merge-index seeding, now fixed by this plan's Tasks 2-3) measurably moved the number compared to the original pilot's 66.7%. Do not round up, do not omit an unfavorable repo's numbers, do not soften a result that still misses the target — same discipline as the original measurement. + +If the target is met: say so, and note that Task 5 (the desktop surface) from the original plan can now be scoped as its own follow-up plan — do not start building it here, that's out of scope for this plan. + +If the target is still not met: say so, name what's left to investigate (a corpus re-pin adding an app-shaped PHP repo so composer can be measured at all is explicitly out of scope for this plan and worth flagging as the next open question), and confirm the CLI-opt-in-only status quo stands. + +- [ ] **Step 6: Clean up the sweep's scratch output** + +```bash +rm -rf /tmp/regen-sweep-results +``` + +- [ ] **Step 7: Commit** + +```bash +git add benchmark/README.md +git commit -m "benchmark: full corpus sweep for the regenerate-tier gate, post merge-index-seeding fix" +``` diff --git a/package.json b/package.json index 55b97c5a..b40a3427 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,13 @@ "scripts": { "build": "pnpm -r run build", "test": "pnpm -r --workspace-concurrency=1 run test", + "test:scripts-lib": "node --test scripts/lib/*.test.mjs", "clean": "pnpm -r run clean", "postinstall": "node scripts/fix-spawn-helper.mjs" }, "devDependencies": { - "@tauri-apps/cli": "^2.11.4" + "@tauri-apps/cli": "^2.11.4", + "smol-toml": "^1.8.0", + "yaml": "^2.9.0" } } diff --git a/packages/cli/package.json b/packages/cli/package.json index ac775a66..5d2ab6a0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -40,7 +40,9 @@ "clean": "rm -rf dist" }, "dependencies": { - "@gitwand/core": "workspace:*" + "@gitwand/core": "workspace:*", + "smol-toml": "^1.6.1", + "yaml": "^2.8.3" }, "devDependencies": { "@types/node": "^25.5.0", diff --git a/packages/cli/src/__tests__/conventions-derive.test.ts b/packages/cli/src/__tests__/conventions-derive.test.ts new file mode 100644 index 00000000..12d6fc67 --- /dev/null +++ b/packages/cli/src/__tests__/conventions-derive.test.ts @@ -0,0 +1,133 @@ +/** + * accuracy lot F — `deriveFromHistory` : le replay mesure réellement les + * conventions d'une équipe sur de vrais dépôts temporaires (git hermétique, + * jamais de mock de la couche git). + * + * Le dépôt fabriqué simule une équipe qui : régénère son package-lock.json + * après chaque merge (le commit ne correspond jamais à la fusion sémantique), + * reconstruit son CHANGELOG à l'outillage (ni union ni côté cible), et prend + * toujours theirs sur les fichiers .snap. + */ + +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { deriveFromHistory, conventionsPath, supportsMergeTreeWriteTree } from "../commands/conventions.js"; + +// Le replay exige git >= 2.38 (merge-tree --write-tree) — sur un git plus +// ancien ces tests se skippent explicitement au lieu d'échouer en silence. +const MODERN_GIT = supportsMergeTreeWriteTree(); + +const HERMETIC_GIT_ENV = { + ...process.env, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + GIT_EDITOR: "true", + GIT_SEQUENCE_EDITOR: "true", + GIT_PAGER: "cat", +}; + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], + env: HERMETIC_GIT_ENV, timeout: 10_000, + }); +} + +const LOCK = "package-lock.json"; +const CHANGELOG = "CHANGELOG.md"; +const SNAP = "ui.snap"; + +function writeAll(cwd: string, suffix: string): void { + writeFileSync(join(cwd, LOCK), `{\n "lockfileVersion": 3,\n "shared": "${suffix}"\n}\n`); + writeFileSync(join(cwd, CHANGELOG), `# Changelog\n\n- entry ${suffix}\n`); + writeFileSync(join(cwd, SNAP), `snapshot ${suffix}\n`); +} + +/** Fabrique `n` merges conflictuels résolus selon les conventions simulées. */ +function buildHistory(cwd: string, n: number): void { + git(cwd, ["init", "-b", "main"]); + git(cwd, ["config", "user.email", "t@t.t"]); + git(cwd, ["config", "user.name", "t"]); + git(cwd, ["config", "commit.gpgsign", "false"]); + git(cwd, ["config", "core.hooksPath", "/dev/null"]); + writeAll(cwd, "base-0"); + git(cwd, ["add", "-A"]); + git(cwd, ["commit", "-m", "base"]); + + for (let i = 0; i < n; i++) { + git(cwd, ["checkout", "-b", `feature-${i}`]); + writeAll(cwd, `feature-${i}`); + git(cwd, ["add", "-A"]); + git(cwd, ["commit", "-m", `feature ${i}`]); + + git(cwd, ["checkout", "main"]); + writeAll(cwd, `main-${i}`); + git(cwd, ["add", "-A"]); + git(cwd, ["commit", "-m", `main ${i}`]); + + try { git(cwd, ["merge", `feature-${i}`]); } catch { /* conflit attendu */ } + + // Résolutions « humaines » simulées : + // lockfile régénéré (≠ toute fusion), changelog reconstruit à l'outil, + // .snap : theirs (le côté feature) tel quel. + writeFileSync(join(cwd, LOCK), `{\n "lockfileVersion": 3,\n "shared": "regenerated-${i}"\n}\n`); + writeFileSync(join(cwd, CHANGELOG), `# Changelog\n\n## v1.${i}.0\n\n- rebuilt by tooling\n`); + writeFileSync(join(cwd, SNAP), `snapshot feature-${i}\n`); + git(cwd, ["add", "-A"]); + git(cwd, ["commit", "-m", `merge feature-${i}`]); + } +} + +let repo: string; +beforeEach(() => { repo = mkdtempSync(join(tmpdir(), "gw-conv-")); }); +afterEach(() => { rmSync(repo, { recursive: true, force: true }); }); + +// Tests d'intégration git : des dizaines de spawns par test, et macOS taxe +// chaque exec (XProtect) — 5 s de timeout vitest ne suffisent pas sur un vrai +// Mac alors que la suite passe en <1 s sur Linux. Budget explicite, distinct du +// timeout dur de 10 s par appel git qui attrape les vrais blocages. +const IT_TIMEOUT = { timeout: 30_000 }; + +describe.skipIf(!MODERN_GIT)("deriveFromHistory", () => { + it("measures the simulated team's conventions from six real merges", IT_TIMEOUT, () => { + buildHistory(repo, 6); + const { conventions } = deriveFromHistory(repo, 200); + + expect(conventions.evidence.mergesReplayed).toBe(6); + expect(conventions.generatedFiles?.verdict).toBe("regenerate"); + expect(conventions.generatedFiles?.samples).toBeGreaterThanOrEqual(5); + expect(conventions.changelog?.verdict).toBe("tool-rebuilt"); + expect(conventions.pathPolicies).toEqual([ + expect.objectContaining({ glob: "**/*.snap", policy: "prefer-theirs" }), + ]); + }); + + it("emits no verdict below the evidence floor (4 merges)", IT_TIMEOUT, () => { + buildHistory(repo, 4); + const { conventions } = deriveFromHistory(repo, 200); + expect(conventions.generatedFiles).toBeUndefined(); + expect(conventions.changelog).toBeUndefined(); + expect(conventions.pathPolicies).toBeUndefined(); + }); + + it("respects the merge cap", IT_TIMEOUT, () => { + buildHistory(repo, 6); + const { conventions } = deriveFromHistory(repo, 3); + expect(conventions.evidence.mergesReplayed).toBe(3); + expect(conventions.generatedFiles).toBeUndefined(); // 3 < plancher + }); + + it("conventionsPath resolves inside .git, worktree-safe", IT_TIMEOUT, () => { + buildHistory(repo, 1); + const p = conventionsPath(repo); + expect(p).toContain(".git"); + expect(p.endsWith(join("gitwand", "conventions.json"))).toBe(true); + expect(existsSync(join(repo, ".git"))).toBe(true); + }); +}); diff --git a/packages/cli/src/__tests__/merge-context-detect.test.ts b/packages/cli/src/__tests__/merge-context-detect.test.ts new file mode 100644 index 00000000..846c4527 --- /dev/null +++ b/packages/cli/src/__tests__/merge-context-detect.test.ts @@ -0,0 +1,132 @@ +/** + * accuracy lot C — detectMergeContext : détection de l'opération git en cours depuis + * l'état du répertoire .git, sur de vrais dépôts temporaires (jamais de mock + * de la couche git, conformément aux contraintes du repo). + */ + +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { detectMergeContext } from "../git.js"; + +// Environnement git HERMÉTIQUE : sans ça, la config globale/système de la +// machine hôte s'invite dans le dépôt temporaire — un core.hooksPath global +// (husky…), une signature GPG qui attend une passphrase ou un éditeur +// configuré suffisent à faire pendre `git rebase` jusqu'au timeout du test. +const HERMETIC_GIT_ENV = { + ...process.env, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + GIT_EDITOR: "true", + GIT_SEQUENCE_EDITOR: "true", + GIT_PAGER: "cat", +}; + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + env: HERMETIC_GIT_ENV, + // Un git qui attend une entrée doit échouer vite et fort, pas pendre + // silencieusement jusqu'au timeout de vitest. + timeout: 10_000, + }); +} + +function initRepo(cwd: string): void { + git(cwd, ["init", "-b", "main"]); + git(cwd, ["config", "user.email", "t@t.t"]); + git(cwd, ["config", "user.name", "t"]); + git(cwd, ["config", "commit.gpgsign", "false"]); + git(cwd, ["config", "core.hooksPath", "/dev/null"]); +} + +function commitFile(cwd: string, name: string, content: string, msg: string): void { + writeFileSync(join(cwd, name), content); + git(cwd, ["add", name]); + git(cwd, ["commit", "-m", msg]); +} + +/** main et feature modifient la même ligne → toute intégration conflicte. */ +function makeDivergence(cwd: string): void { + initRepo(cwd); + commitFile(cwd, "a.txt", "base\n", "base"); + git(cwd, ["checkout", "-b", "feature"]); + commitFile(cwd, "a.txt", "feature\n", "feature change"); + git(cwd, ["checkout", "main"]); + commitFile(cwd, "a.txt", "main\n", "main change"); +} + +let repo: string; +beforeEach(() => { repo = mkdtempSync(join(tmpdir(), "gw-ctx-")); }); +afterEach(() => { rmSync(repo, { recursive: true, force: true }); }); + +// Tests d'intégration git : des dizaines de spawns par test, et macOS taxe +// chaque exec (XProtect) — 5 s de timeout vitest ne suffisent pas sur un vrai +// Mac alors que la suite passe en <1 s sur Linux. Budget explicite, distinct du +// timeout dur de 10 s par appel git qui attrape les vrais blocages. +const IT_TIMEOUT = { timeout: 30_000 }; + +describe("detectMergeContext", () => { + it("returns null on a clean repo", IT_TIMEOUT, () => { + initRepo(repo); + commitFile(repo, "a.txt", "x\n", "init"); + expect(detectMergeContext(repo)).toBeNull(); + }); + + it("returns null outside a git repo", IT_TIMEOUT, () => { + expect(detectMergeContext(repo)).toBeNull(); + }); + + it("detects a merge in progress, ours = the checked-out target", IT_TIMEOUT, () => { + makeDivergence(repo); + try { git(repo, ["merge", "feature"]); } catch { /* conflit attendu */ } + const ctx = detectMergeContext(repo); + expect(ctx?.operation).toBe("merge"); + expect(ctx?.targetSide).toBe("ours"); + expect(ctx?.oursRef).toBe("main"); + expect(ctx?.theirsRef).toContain("feature"); + }); + + it("detects a rebase in progress, ours = the branch rebased onto", IT_TIMEOUT, () => { + makeDivergence(repo); + git(repo, ["checkout", "feature"]); + try { git(repo, ["rebase", "main"]); } catch { /* conflit attendu */ } + const ctx = detectMergeContext(repo); + expect(ctx?.operation).toBe("rebase"); + // L'inversion célèbre : pendant un rebase, « ours » est la branche CIBLE + // (main), pas le travail de l'utilisateur. targetSide la déclare. + expect(ctx?.targetSide).toBe("ours"); + expect(ctx?.theirsRef).toContain("feature"); + }); + + it("detects a cherry-pick in progress", IT_TIMEOUT, () => { + makeDivergence(repo); + const sha = git(repo, ["rev-parse", "feature"]).trim(); + try { git(repo, ["cherry-pick", sha]); } catch { /* conflit attendu */ } + const ctx = detectMergeContext(repo); + expect(ctx?.operation).toBe("cherry-pick"); + expect(ctx?.targetSide).toBe("ours"); + expect(ctx?.oursRef).toBe("main"); + }); + + it("works from a linked worktree (.git is a file)", IT_TIMEOUT, () => { + makeDivergence(repo); + const wt = join(repo, "..", "gw-ctx-wt-" + Date.now()); + git(repo, ["worktree", "add", wt, "feature"]); + try { + try { git(wt, ["merge", "main"]); } catch { /* conflit attendu */ } + const ctx = detectMergeContext(wt); + expect(ctx?.operation).toBe("merge"); + expect(ctx?.oursRef).toBe("feature"); + } finally { + rmSync(wt, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/__tests__/regenerate-runner-env-split.test.ts b/packages/cli/src/__tests__/regenerate-runner-env-split.test.ts new file mode 100644 index 00000000..6d529d37 --- /dev/null +++ b/packages/cli/src/__tests__/regenerate-runner-env-split.test.ts @@ -0,0 +1,146 @@ +/** + * Final review Finding 4 — the env allowlist's `GIT_*` prefix must reach the + * two git plumbing spawns (`git worktree add`/`remove`/`prune`) but NOT the + * spawned ecosystem installer (npm/pnpm/yarn/composer/cargo). + * + * CI systems commonly inject credentials via `GIT_CONFIG_COUNT`/ + * `GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` (e.g. + * `http.extraheader=Authorization: Basic `) or + * `GIT_ASKPASS`/`GIT_SSH_COMMAND`. None of the 5 registry installers need + * any of these — this test proves they never reach the installer's spawned + * environment, while confirming git worktree plumbing still gets `GIT_*` + * (a real regression, fixed in an earlier round of this same lot, would + * otherwise break `git worktree add` itself). + * + * Uses a real `cargo generate-lockfile` spawn (offline-capable, no network + * dependency, skipped when `cargo` isn't on PATH) rather than mocking the + * installer — only the environment actually delivered to each spawned + * process is observed, via a real passthrough wrapper around + * `node:child_process`. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { execFileSync as realExecFileSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { REGEN_ECOSYSTEMS, type RegenEcosystem } from "@gitwand/core"; + +const { spawnCalls } = vi.hoisted(() => ({ + spawnCalls: [] as Array<{ bin: string; env: NodeJS.ProcessEnv }>, +})); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFile: ( + bin: string, + args: string[], + options: Record, + callback: (...cbArgs: unknown[]) => void, + ) => { + spawnCalls.push({ bin, env: (options?.env as NodeJS.ProcessEnv) ?? {} }); + return actual.execFile(bin, args, options as any, callback as any); + }, + }; +}); + +const { runRegeneration, isToolchainAvailable } = await import("../regenerate-runner.js"); + +function ecosystemFor(id: RegenEcosystem["id"]): RegenEcosystem { + const eco = REGEN_ECOSYSTEMS.find((e) => e.id === id); + if (!eco) throw new Error(`registre : écosystème "${id}" introuvable`); + return eco; +} + +const IT_TIMEOUT = { timeout: 30_000 }; +const SUSPECT_KEYS = [ + "GIT_CONFIG_COUNT", + "GIT_CONFIG_KEY_0", + "GIT_CONFIG_VALUE_0", + "GIT_ASKPASS", + "GIT_SSH_COMMAND", +]; + +describe.skipIf(!isToolchainAvailable("cargo"))( + "regenerate-runner — env allowlist split (Finding 4, final review)", + () => { + let repo: string; + let prevEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + repo = mkdtempSync(join(tmpdir(), "gw-regen-env-split-")); + realExecFileSync("git", ["init", "-b", "main"], { cwd: repo }); + realExecFileSync("git", ["config", "user.email", "t@t.t"], { cwd: repo }); + realExecFileSync("git", ["config", "user.name", "t"], { cwd: repo }); + realExecFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: repo }); + + prevEnv = { ...process.env }; + Object.assign(process.env, { + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + GIT_EDITOR: "true", + GIT_SEQUENCE_EDITOR: "true", + GIT_PAGER: "cat", + // Simulated CI-injected credential-carrying vars — must never reach + // the installer spawn's environment. + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "http.extraheader", + GIT_CONFIG_VALUE_0: "Authorization: Basic super-secret-token", + GIT_ASKPASS: "/bin/false-askpass", + GIT_SSH_COMMAND: "ssh -o SomethingSecret=1", + }); + spawnCalls.length = 0; + }); + + afterEach(() => { + process.env = prevEnv; + rmSync(repo, { recursive: true, force: true }); + }); + + it( + "strips GIT_CONFIG_*/GIT_ASKPASS/GIT_SSH_COMMAND from the cargo spawn but keeps them for git worktree plumbing", + IT_TIMEOUT, + async () => { + const cargoToml = '[package]\nname = "t"\nversion = "0.1.0"\nedition = "2021"\n'; + mkdirSync(join(repo, "src"), { recursive: true }); + writeFileSync(join(repo, "Cargo.toml"), cargoToml, "utf-8"); + writeFileSync(join(repo, "src/main.rs"), "fn main() {}\n", "utf-8"); + realExecFileSync("git", ["add", "-A"], { cwd: repo }); + realExecFileSync("git", ["commit", "-m", "init"], { cwd: repo }); + + const outcome = await runRegeneration({ + repoRoot: repo, + file: "Cargo.lock", + ecosystem: ecosystemFor("cargo"), + resolvedSources: [{ path: "Cargo.toml", content: cargoToml }], + }); + + expect(outcome.kind).toBe("success"); + + const gitCalls = spawnCalls.filter((c) => c.bin === "git"); + const cargoCalls = spawnCalls.filter((c) => c.bin === "cargo"); + expect(gitCalls.length).toBeGreaterThan(0); + expect(cargoCalls.length).toBeGreaterThan(0); + + // Git plumbing keeps GIT_* — this is the property fixed earlier in + // this lot (regression: `git worktree add` fails without it). + for (const call of gitCalls) { + expect(call.env.GIT_CONFIG_GLOBAL).toBe("/dev/null"); + } + + // The installer spawn must not carry ANY of the credential-shaped + // GIT_* vars, even though it's still allowed ordinary PATH/HOME. + for (const call of cargoCalls) { + for (const key of SUSPECT_KEYS) { + expect(call.env[key]).toBeUndefined(); + } + expect(call.env.PATH).toBeDefined(); + } + }, + ); + }, +); diff --git a/packages/cli/src/__tests__/regenerate-runner.test.ts b/packages/cli/src/__tests__/regenerate-runner.test.ts new file mode 100644 index 00000000..e2f63135 --- /dev/null +++ b/packages/cli/src/__tests__/regenerate-runner.test.ts @@ -0,0 +1,558 @@ +/** + * accuracy lot D — Tier de régénération, exécuteur CLI. + * + * Tout sur de vrais dépôts git temporaires ET de vrais binaires + * npm/pnpm/composer/cargo quand ils sont disponibles sur la machine qui + * lance les tests (`describe.skipIf` par écosystème absent) — jamais de + * mock de la couche git ni des installeurs eux-mêmes, conformément aux + * contraintes du repo. + * + * Rappel important sur les commandes du registre v1 (`packages/core`) : + * elles sont volontairement des commandes "lockfile-only" qui METTENT À + * JOUR un lockfile existant plutôt que d'en créer un depuis rien (c'est + * particulièrement vrai pour `composer update --lock` qui échoue s'il n'y + * a aucun `composer.lock` préexistant, et pour `cargo generate-lockfile` + * qui exige un crate valide avec `src/main.rs`/`src/lib.rs`). Les repos de + * test committent donc systématiquement un état HEAD valide et complet — + * exactement le rôle que joue le `git worktree add --detach HEAD` + * en production : le worktree jetable démarre du dernier état connu-bon, + * seules les "sources de vérité" (`package.json`…) sont écrasées par leur + * contenu résolu en pass 1. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { REGEN_ECOSYSTEMS, type RegenEcosystem } from "@gitwand/core"; + +import { + runRegeneration, + isToolchainAvailable, + isOffline, + validateRegeneratedContent, + loadGitwandrcRegenerateFlag, +} from "../regenerate-runner.js"; + +// Environnement git HERMÉTIQUE — même raison que merge-context-detect.test.ts : +// sans ça, la config globale/système de la machine hôte (hooksPath, signature +// GPG, éditeur…) peut faire pendre `git worktree add`/`git commit` jusqu'au +// timeout du test. +const HERMETIC_GIT_ENV = { + ...process.env, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + GIT_EDITOR: "true", + GIT_SEQUENCE_EDITOR: "true", + GIT_PAGER: "cat", +}; + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + env: HERMETIC_GIT_ENV, + timeout: 10_000, + }); +} + +function initRepo(cwd: string): void { + git(cwd, ["init", "-b", "main"]); + git(cwd, ["config", "user.email", "t@t.t"]); + git(cwd, ["config", "user.name", "t"]); + git(cwd, ["config", "commit.gpgsign", "false"]); + git(cwd, ["config", "core.hooksPath", "/dev/null"]); +} + +function writeAndAdd(cwd: string, relPath: string, content: string): void { + const abs = join(cwd, relPath); + mkdirSync(join(abs, ".."), { recursive: true }); + writeFileSync(abs, content, "utf-8"); + git(cwd, ["add", "--", relPath]); +} + +function commit(cwd: string, msg: string): void { + git(cwd, ["commit", "-m", msg]); +} + +function listWorktrees(cwd: string): string { + return git(cwd, ["worktree", "list", "--porcelain"]); +} + +function ecosystemFor(id: RegenEcosystem["id"]): RegenEcosystem { + const eco = REGEN_ECOSYSTEMS.find((e) => e.id === id); + if (!eco) throw new Error(`registre : écosystème "${id}" introuvable`); + return eco; +} + +let repo: string; +let prevEnv: NodeJS.ProcessEnv; + +beforeEach(() => { + repo = mkdtempSync(join(tmpdir(), "gw-regen-")); + // `regenerate-runner.ts` construit l'environnement de ses propres spawns + // (git worktree, installeurs) à partir de `process.env` — on le rend + // hermétique pour la durée du test, même intention que HERMETIC_GIT_ENV + // ci-dessus mais côté code sous test plutôt que côté harness. + prevEnv = { ...process.env }; + Object.assign(process.env, { + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + GIT_EDITOR: "true", + GIT_SEQUENCE_EDITOR: "true", + GIT_PAGER: "cat", + }); +}); + +afterEach(() => { + process.env = prevEnv; + rmSync(repo, { recursive: true, force: true }); +}); + +// Tests d'intégration git + installeurs réels : macOS taxe chaque exec +// (XProtect) et un `npm install`/`composer update` réel peut prendre +// plusieurs centaines de ms même sans dépendance. Budget explicite. +const IT_TIMEOUT = { timeout: 30_000 }; + +describe("runRegeneration — success path (real toolchains)", () => { + describe.skipIf(!isToolchainAvailable("npm"))("npm", () => { + it("regenerates package-lock.json from a clean package.json", IT_TIMEOUT, async () => { + initRepo(repo); + const pkgJson = '{"name":"t","version":"1.0.0"}\n'; + writeAndAdd(repo, "package.json", pkgJson); + commit(repo, "init"); + + const outcome = await runRegeneration({ + repoRoot: repo, + file: "package-lock.json", + ecosystem: ecosystemFor("npm"), + resolvedSources: [{ path: "package.json", content: pkgJson }], + }); + + expect(outcome.kind).toBe("success"); + expect(outcome.content).not.toBeNull(); + expect(() => JSON.parse(outcome.content as string)).not.toThrow(); + expect(outcome.trace.exitCode).toBe(0); + expect(outcome.reason).toContain("regenerated via"); + expect(outcome.reason).toContain("npm install --package-lock-only --ignore-scripts"); + expect(listWorktrees(repo)).not.toContain("gitwand-regen-"); + }); + }); + + describe.skipIf(!isToolchainAvailable("pnpm"))("pnpm", () => { + it("regenerates pnpm-lock.yaml from a clean package.json", IT_TIMEOUT, async () => { + initRepo(repo); + const pkgJson = '{"name":"t","version":"1.0.0"}\n'; + writeAndAdd(repo, "package.json", pkgJson); + commit(repo, "init"); + + const outcome = await runRegeneration({ + repoRoot: repo, + file: "pnpm-lock.yaml", + ecosystem: ecosystemFor("pnpm"), + resolvedSources: [{ path: "package.json", content: pkgJson }], + }); + + expect(outcome.kind).toBe("success"); + expect(outcome.content).toContain("lockfileVersion"); + expect(listWorktrees(repo)).not.toContain("gitwand-regen-"); + }); + }); + + describe.skipIf(!isToolchainAvailable("composer"))("composer", () => { + it("regenerates composer.lock from a clean composer.json (existing lock at HEAD)", IT_TIMEOUT, async () => { + initRepo(repo); + const composerJson = '{"name": "acme/test"}\n'; + writeAndAdd(repo, "composer.json", composerJson); + // `composer update --lock` REFUSES to run without a pre-existing lock + // file (message : "Cannot update lock file information without a lock + // file present") — le worktree jetable checkout HEAD, qui doit donc + // déjà avoir un composer.lock committé, exactement comme un vrai repo. + const composerLock = JSON.stringify({ _readme: ["generated"], "content-hash": "x", packages: [], "packages-dev": [] }); + writeAndAdd(repo, "composer.lock", composerLock); + commit(repo, "init"); + + const outcome = await runRegeneration({ + repoRoot: repo, + file: "composer.lock", + ecosystem: ecosystemFor("composer"), + resolvedSources: [{ path: "composer.json", content: composerJson }], + }); + + expect(outcome.kind).toBe("success"); + expect(() => JSON.parse(outcome.content as string)).not.toThrow(); + expect(listWorktrees(repo)).not.toContain("gitwand-regen-"); + }); + }); + + describe.skipIf(!isToolchainAvailable("cargo"))("cargo", () => { + it("regenerates Cargo.lock from a clean Cargo.toml", IT_TIMEOUT, async () => { + initRepo(repo); + const cargoToml = '[package]\nname = "t"\nversion = "0.1.0"\nedition = "2021"\n'; + writeAndAdd(repo, "Cargo.toml", cargoToml); + // `cargo generate-lockfile` exige un crate valide (cible src/main.rs). + writeAndAdd(repo, "src/main.rs", "fn main() {}\n"); + commit(repo, "init"); + + const outcome = await runRegeneration({ + repoRoot: repo, + file: "Cargo.lock", + ecosystem: ecosystemFor("cargo"), + resolvedSources: [{ path: "Cargo.toml", content: cargoToml }], + }); + + expect(outcome.kind).toBe("success"); + expect(outcome.content).toContain('name = "t"'); + expect(listWorktrees(repo)).not.toContain("gitwand-regen-"); + }); + }); +}); + +describe("runRegeneration — failure paths", () => { + it("returns missing-toolchain when the ecosystem binary isn't on PATH", IT_TIMEOUT, async () => { + initRepo(repo); + writeAndAdd(repo, "package.json", "{}\n"); + commit(repo, "init"); + + const fakeEcosystem: RegenEcosystem = { + ...ecosystemFor("npm"), + command: { bin: "gitwand-tool-that-does-not-exist-xyz", args: ["install"] }, + }; + + const outcome = await runRegeneration({ + repoRoot: repo, + file: "package-lock.json", + ecosystem: fakeEcosystem, + resolvedSources: [{ path: "package.json", content: "{}\n" }], + }); + + expect(outcome.kind).toBe("missing-toolchain"); + expect(outcome.content).toBeNull(); + expect(outcome.reason).toContain("not found in PATH"); + // Pas de worktree tenté du tout — la sonde toolchain échoue avant. + expect(listWorktrees(repo)).not.toContain("gitwand-regen-"); + }); + + it("returns timeout when the command exceeds the configured budget", IT_TIMEOUT, async () => { + initRepo(repo); + writeAndAdd(repo, "package.json", "{}\n"); + commit(repo, "init"); + + const fakeEcosystem: RegenEcosystem = { + ...ecosystemFor("npm"), + // "offline-capable" : ce test exerce le chemin timeout, pas la sonde + // réseau (déjà couverte par `describe("isOffline")` plus bas). + network: "offline-capable", + command: { bin: "sleep", args: ["5"] }, + defaultTimeoutMs: 200, + }; + + const outcome = await runRegeneration({ + repoRoot: repo, + file: "package-lock.json", + ecosystem: fakeEcosystem, + resolvedSources: [{ path: "package.json", content: "{}\n" }], + }); + + expect(outcome.kind).toBe("timeout"); + expect(outcome.content).toBeNull(); + expect(outcome.reason).toContain("timeout"); + expect(outcome.trace.durationMs).toBeGreaterThanOrEqual(180); + expect(listWorktrees(repo)).not.toContain("gitwand-regen-"); + }); + + it("returns validation-failed when the regenerated file doesn't parse", IT_TIMEOUT, async () => { + initRepo(repo); + writeAndAdd(repo, "package.json", "{}\n"); + // Un `package-lock.json` préexistant à HEAD, que la "commande" (un + // simple shell) écrase avec du contenu non-JSON — simule un installeur + // qui exit 0 mais produit un lockfile corrompu. + writeAndAdd(repo, "package-lock.json", "{}\n"); + commit(repo, "init"); + + const fakeEcosystem: RegenEcosystem = { + ...ecosystemFor("npm"), + network: "offline-capable", // exerce validation, pas la sonde réseau + command: { bin: "sh", args: ["-c", "echo not-json > package-lock.json"] }, + }; + + const outcome = await runRegeneration({ + repoRoot: repo, + file: "package-lock.json", + ecosystem: fakeEcosystem, + resolvedSources: [{ path: "package.json", content: "{}\n" }], + }); + + expect(outcome.kind).toBe("validation-failed"); + expect(outcome.content).toBeNull(); + expect(outcome.reason).toContain("invalid content"); + expect(listWorktrees(repo)).not.toContain("gitwand-regen-"); + }); + + it("returns spawn-failed on a non-zero exit code", IT_TIMEOUT, async () => { + initRepo(repo); + writeAndAdd(repo, "package.json", "{}\n"); + commit(repo, "init"); + + const fakeEcosystem: RegenEcosystem = { + ...ecosystemFor("npm"), + network: "offline-capable", // exerce spawn-failed, pas la sonde réseau + command: { bin: "sh", args: ["-c", "echo boom >&2; exit 3"] }, + }; + + const outcome = await runRegeneration({ + repoRoot: repo, + file: "package-lock.json", + ecosystem: fakeEcosystem, + resolvedSources: [{ path: "package.json", content: "{}\n" }], + }); + + expect(outcome.kind).toBe("spawn-failed"); + expect(outcome.content).toBeNull(); + expect(outcome.trace.exitCode).toBe(3); + expect(listWorktrees(repo)).not.toContain("gitwand-regen-"); + }); +}); + +describe("runRegeneration — worktree reflects the real merge index", () => { + it("a theirs-only file is visible inside the disposable worktree", IT_TIMEOUT, async () => { + initRepo(repo); + writeAndAdd(repo, "package.json", '{"v":1}\n'); + writeAndAdd(repo, "package-lock.json", '{"base":true}\n'); + commit(repo, "base"); + + git(repo, ["checkout", "-b", "theirs"]); + // theirs adds a brand-new file that ours never sees committed. + writeAndAdd(repo, "theirs-only.txt", "only on theirs\n"); + writeAndAdd(repo, "package-lock.json", '{"theirs":true}\n'); + commit(repo, "theirs: add file + bump lock"); + + git(repo, ["checkout", "main"]); + writeAndAdd(repo, "package-lock.json", '{"main":true}\n'); + commit(repo, "main: bump lock"); + + try { + git(repo, ["merge", "theirs"]); + } catch { + // conflict on package-lock.json expected; package.json and + // theirs-only.txt auto-merge cleanly and land in the live index. + } + + const fakeEcosystem: RegenEcosystem = { + ...ecosystemFor("npm"), + sourcesOfTruth: [], + network: "offline-capable", // exerce le worktree, pas la sonde réseau + // Prouve que theirs-only.txt a atteint le worktree : `cat … > /dev/null` + // échoue (exit non-zéro → spawn-failed, pas success) si le fichier est + // absent, court-circuitant le `&&` avant que `package-lock.json` ne soit + // écrasé. Écrit du JSON valide (plutôt que le contenu brut du fichier) + // pour ne pas se heurter au validateur JSON de l'écosystème "npm" — ce + // test vérifie la visibilité du fichier dans le worktree, pas le format + // de sortie d'un vrai installeur. + command: { + bin: "sh", + args: ["-c", "cat theirs-only.txt > /dev/null && echo '{\"sawTheirsOnly\":true}' > package-lock.json"], + }, + }; + + const outcome = await runRegeneration({ + repoRoot: repo, + file: "package-lock.json", + ecosystem: fakeEcosystem, + resolvedSources: [], + }); + + expect(outcome.kind).toBe("success"); + expect(outcome.content).toBe('{"sawTheirsOnly":true}\n'); + expect(listWorktrees(repo)).not.toContain("gitwand-regen-"); + }); +}); + +describe("runRegeneration — explicit seedIndexFile (final review, Important #7)", () => { + // The Task 2 ↔ Task 3 interface (`seedIndexFile`) previously had zero + // coverage from the CLI test suite — only from the harness's own manual + // sweep. This test drives it directly, with a HAND-BUILT scratch index + // (no real `git merge`), proving BOTH halves of the Critical #1 fix from + // the CLI side: + // 1. a theirs-only path (present at stage 0 in the scratch index) is + // visible inside the disposable worktree (positive case — same as the + // "worktree reflects the real merge index" describe block above, but + // via an explicit seedIndexFile instead of a real live merge index). + // 2. a still-conflicted path (force-removed from the scratch index, + // simulating a real merge index's multi-stage skip) is left UNTOUCHED + // by the overlay — it must still hold whatever the HEAD-only scaffold + // from step 1 put there, never any diff3-marker content the scratch + // index's source tree carries for that path (negative case — this is + // exactly what `seedScratchIndex`'s `skipPaths` parameter now makes + // the measurement harness do too, see `scripts/lib/seed-index.mjs`). + it("theirs-only file becomes visible, still-conflicted file stays at its HEAD scaffold content", IT_TIMEOUT, async () => { + initRepo(repo); + writeAndAdd(repo, "package.json", '{"v":1}\n'); + writeAndAdd(repo, "package-lock.json", '{"base":true}\n'); + commit(repo, "base"); + + git(repo, ["checkout", "-b", "theirs"]); + writeAndAdd(repo, "theirs-only.txt", "only on theirs\n"); + writeAndAdd(repo, "package-lock.json", '{"theirs":true}\n'); + commit(repo, "theirs: add file + bump lock"); + const theirsSha = git(repo, ["rev-parse", "HEAD"]).trim(); + + git(repo, ["checkout", "main"]); + writeAndAdd(repo, "package-lock.json", '{"main":true}\n'); + commit(repo, "main: bump lock"); + // HEAD now sits on main, at this exact commit — `addWorktree`'s + // `git worktree add --detach HEAD` will scaffold from THIS tree, + // i.e. package.json@v1 + package-lock.json@'{"main":true}\n', no + // theirs-only.txt (never committed to main). + + // Compute the merge-tree's tree oid by hand — no real `git merge` is run + // in this test, only `merge-tree --write-tree`, which exits 1 on + // conflict (package-lock.json conflicts; theirs-only.txt merges clean). + // Tree oid is still the first stdout line even on exit 1. + let mergeTreeStdout: string; + try { + mergeTreeStdout = execFileSync( + "git", + ["-C", repo, "-c", "merge.conflictstyle=diff3", "merge-tree", "--write-tree", "main", theirsSha], + { encoding: "utf-8", env: HERMETIC_GIT_ENV, timeout: 10_000 }, + ); + } catch (err) { + const e = err as NodeJS.ErrnoException & { stdout?: string }; + if (typeof e.stdout !== "string") throw err; + mergeTreeStdout = e.stdout; + } + const treeOid = mergeTreeStdout.trim().split("\n")[0]; + + // Hand-build the scratch index: read-tree puts every path (including + // the still-conflicted package-lock.json) at stage 0, then + // update-index --force-remove simulates "this path is still multi-stage + // in a real merge index" — exactly what Critical #1's fix + // (`seedScratchIndex`'s `skipPaths`) now does for the harness. + const scratchIndexFile = join(repo, ".git", "scratch-explicit-seed-index"); + const scratchEnv = { ...HERMETIC_GIT_ENV, GIT_INDEX_FILE: scratchIndexFile }; + execFileSync("git", ["-C", repo, "read-tree", treeOid], { env: scratchEnv, timeout: 10_000 }); + execFileSync("git", ["-C", repo, "update-index", "--force-remove", "--", "package-lock.json"], { + env: scratchEnv, + timeout: 10_000, + }); + + const fakeEcosystem: RegenEcosystem = { + ...ecosystemFor("npm"), + sourcesOfTruth: [], + network: "offline-capable", // exerce le worktree, pas la sonde réseau + // Positive case: fails (non-zero exit -> spawn-failed, not success) if + // theirs-only.txt is missing from the worktree. Negative case: never + // writes to package-lock.json itself, so whatever `runRegeneration` + // later reads back from it is exactly whatever `checkout-index --all` + // left on disk — the HEAD scaffold content if (and only if) the + // still-conflicted path was correctly skipped, never the scratch + // index's diff3-marker blob for that path. + command: { bin: "sh", args: ["-c", "cat theirs-only.txt > /dev/null"] }, + }; + + const outcome = await runRegeneration({ + repoRoot: repo, + file: "package-lock.json", + ecosystem: fakeEcosystem, + resolvedSources: [], + seedIndexFile: scratchIndexFile, + }); + + expect(outcome.kind).toBe("success"); + // Negative case: package-lock.json must still be the HEAD scaffold's + // content, not overwritten with diff3-marker garbage from the scratch + // index's source tree. + expect(outcome.content).toBe('{"main":true}\n'); + expect(outcome.content).not.toContain("<<<<<<<"); + expect(outcome.content).not.toContain('"theirs":true'); + expect(listWorktrees(repo)).not.toContain("gitwand-regen-"); + }); +}); + +describe("validateRegeneratedContent", () => { + it("accepts valid JSON for npm/composer", () => { + expect(validateRegeneratedContent("npm", '{"a":1}').valid).toBe(true); + expect(validateRegeneratedContent("composer", '{"a":1}').valid).toBe(true); + }); + it("rejects invalid JSON for npm/composer", () => { + expect(validateRegeneratedContent("npm", "not json").valid).toBe(false); + }); + it("accepts valid YAML for pnpm/yarn-berry", () => { + expect(validateRegeneratedContent("pnpm", "a: 1\nb: 2\n").valid).toBe(true); + expect(validateRegeneratedContent("yarn-berry", "a: 1\n").valid).toBe(true); + }); + it("accepts valid TOML for cargo", () => { + expect(validateRegeneratedContent("cargo", '[package]\nname = "t"\n').valid).toBe(true); + }); + it("rejects malformed TOML for cargo", () => { + expect(validateRegeneratedContent("cargo", "[[[not toml").valid).toBe(false); + }); +}); + +describe("isToolchainAvailable", () => { + it("finds a binary known to exist (git itself)", () => { + expect(isToolchainAvailable("git")).toBe(true); + }); + it("returns false for a binary that doesn't exist", () => { + expect(isToolchainAvailable("gitwand-tool-that-does-not-exist-xyz")).toBe(false); + }); +}); + +describe("isOffline", () => { + it("returns false immediately for an ecosystem with no probe host (cargo)", async () => { + expect(await isOffline("cargo")).toBe(false); + }); + + it("returns true when the DNS lookup rejects", async () => { + vi.doMock("node:dns/promises", () => ({ lookup: vi.fn().mockRejectedValue(new Error("ENOTFOUND")) })); + vi.resetModules(); + const mod = await import("../regenerate-runner.js"); + await expect(mod.isOffline("npm")).resolves.toBe(true); + vi.doUnmock("node:dns/promises"); + vi.resetModules(); + }); +}); + +describe("loadGitwandrcRegenerateFlag", () => { + let originalCwd: string; + + beforeEach(() => { + originalCwd = process.cwd(); + }); + + afterEach(() => { + process.chdir(originalCwd); + }); + + it("returns false when there is no .gitwandrc", IT_TIMEOUT, () => { + initRepo(repo); + writeAndAdd(repo, "a.txt", "x\n"); + commit(repo, "init"); + process.chdir(repo); + expect(loadGitwandrcRegenerateFlag()).toBe(false); + }); + + it("returns true when .gitwandrc declares regenerate: true", IT_TIMEOUT, () => { + initRepo(repo); + writeAndAdd(repo, "a.txt", "x\n"); + commit(repo, "init"); + writeFileSync(join(repo, ".gitwandrc"), JSON.stringify({ regenerate: true }), "utf-8"); + process.chdir(repo); + expect(loadGitwandrcRegenerateFlag()).toBe(true); + }); + + it("returns false when .gitwandrc declares regenerate: false", IT_TIMEOUT, () => { + initRepo(repo); + writeAndAdd(repo, "a.txt", "x\n"); + commit(repo, "init"); + writeFileSync(join(repo, ".gitwandrc"), JSON.stringify({ regenerate: false }), "utf-8"); + process.chdir(repo); + expect(loadGitwandrcRegenerateFlag()).toBe(false); + }); +}); diff --git a/packages/cli/src/__tests__/resolve-conventions.test.ts b/packages/cli/src/__tests__/resolve-conventions.test.ts new file mode 100644 index 00000000..cf7c2d08 --- /dev/null +++ b/packages/cli/src/__tests__/resolve-conventions.test.ts @@ -0,0 +1,262 @@ +/** + * Task 3 (accuracy lot D, "regenerate tier" plan) — conventions/`.gitwandrc` + * wiring into `cmdResolve`, plus the default-output regenerate offer. + * + * Covers the two prerequisite bugs identified by the controller's pre-flight + * scan (see task-3-brief.md) and the checklist items that depend on them: + * + * - Bug A: `resolveGeneratedFiles` reaching `resolve()`/`resolveAsync()` as + * `undefined` (not a concrete `false`) whenever `--resolve-generated` is + * not passed, so core's own convention-precedence logic can engage. + * - Bug B: `.git/gitwand/conventions.json` (written by `gitwand conventions`) + * actually being loaded into `options.conventions` on both calls. + * - Checklist 1: the default (non-verbose) summary offers `--regenerate` + * whenever an ecosystem match exists on a declined `generated_file`. + * - Checklist 2: a "merge" verdict flips the textual path end-to-end (no + * regeneration offer printed) — this only happens because of the Bug A/B + * fixes above. + * - Checklist 3: an explicit `.gitwandrc` `resolveGeneratedFiles` beats the + * measured convention in both directions. + * + * Real temp git repos throughout — no mocking of the git layer (AGENTS.md). + * None of these scenarios need an actual npm/toolchain: the lockfile content + * doesn't need to be valid npm output, only the *filename* needs to match the + * `package-lock.json` pattern (`isGeneratedFile` matches by path, not by + * content) — mirrors the technique already used by `conventions-derive.test.ts`. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { cmdResolve } from "../commands/resolve.js"; +import { conventionsPath } from "../commands/conventions.js"; + +const HERMETIC_GIT_ENV = { + ...process.env, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + GIT_EDITOR: "true", + GIT_SEQUENCE_EDITOR: "true", + GIT_PAGER: "cat", +}; + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + env: HERMETIC_GIT_ENV, + timeout: 10_000, + }); +} + +function initRepo(cwd: string): void { + git(cwd, ["init", "-b", "main"]); + git(cwd, ["config", "user.email", "t@t.t"]); + git(cwd, ["config", "user.name", "t"]); + git(cwd, ["config", "commit.gpgsign", "false"]); + git(cwd, ["config", "core.hooksPath", "/dev/null"]); +} + +const LOCK = "package-lock.json"; + +function lockContent(shared: string): string { + return `{\n "name": "e2e",\n "lockfileVersion": 3,\n "shared": "${shared}"\n}\n`; +} + +/** + * Builds a repo with exactly one conflicted file: `package-lock.json`, whose + * only diverging line changed on BOTH branches (relative to base) — this is + * the shape that classifies as "complex" then gets reclassified to + * "generated_file" by `reclassifyIfGenerated` (matched on filename alone). + */ +function buildConflictedLockRepo(cwd: string): void { + initRepo(cwd); + writeFileSync(join(cwd, LOCK), lockContent("base"), "utf-8"); + git(cwd, ["add", "-A"]); + git(cwd, ["commit", "-m", "init"]); + + git(cwd, ["checkout", "-b", "feature"]); + writeFileSync(join(cwd, LOCK), lockContent("feature"), "utf-8"); + git(cwd, ["commit", "-a", "-m", "feature: bump lock"]); + + git(cwd, ["checkout", "main"]); + writeFileSync(join(cwd, LOCK), lockContent("main"), "utf-8"); + git(cwd, ["commit", "-a", "-m", "main: bump lock"]); + + try { + git(cwd, ["merge", "feature"]); + } catch { + // conflict expected + } +} + +function writeConventions(repo: string, verdict: "merge" | "regenerate"): void { + const path = conventionsPath(repo); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync( + path, + JSON.stringify( + { + evidence: { + mergesReplayed: 20, + conflictedFiles: 20, + derivedAt: new Date().toISOString(), + engineVersion: "test", + }, + generatedFiles: { verdict, samples: 20, agreement: 0.95 }, + }, + null, + 2, + ) + "\n", + "utf-8", + ); +} + +function writeGitwandrc(repo: string, resolveGeneratedFiles: boolean): void { + writeFileSync( + join(repo, ".gitwandrc"), + JSON.stringify({ resolveGeneratedFiles }, null, 2) + "\n", + "utf-8", + ); +} + +const IT_TIMEOUT = { timeout: 30_000 }; + +describe("cmdResolve — conventions & .gitwandrc wiring (task 3)", () => { + let repo: string; + let originalCwd: string; + let logSpy: ReturnType; + + beforeEach(() => { + originalCwd = process.cwd(); + repo = mkdtempSync(join(tmpdir(), "gw-resolve-conv-")); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + process.chdir(originalCwd); + logSpy.mockRestore(); + rmSync(repo, { recursive: true, force: true }); + }); + + function output(): string { + return logSpy.mock.calls.map((c) => c.join(" ")).join("\n"); + } + + it( + // Bug A + Bug B + checklist item 2, combined: this is the only way a + // "merge" verdict can visibly take effect — if Bug A were still present + // (resolveGeneratedFiles forced to a concrete `false`), core's + // `userOptions.resolveGeneratedFiles === undefined` precedence gate would + // never let the convention engage, no matter what Bug B loads. + "verdict 'merge' + no flag + no .gitwandrc → auto-resolves via the textual path, no regeneration offer", + IT_TIMEOUT, + async () => { + buildConflictedLockRepo(repo); + writeConventions(repo, "merge"); + + process.chdir(repo); + await cmdResolve([], {}); + + const lock = readFileSync(join(repo, LOCK), "utf-8"); + // "accept theirs" is the textual path's behavior for generated_file + // once resolveGeneratedFiles resolves to true (assemble.ts). + expect(JSON.parse(lock).shared).toBe("feature"); + expect(lock).not.toContain("<<<<<<<"); + + const out = output(); + expect(out).toContain("conflict(s) auto-resolved out of"); + expect(out).toContain("All conflicts resolved!"); + expect(out).not.toContain("--regenerate"); + }, + ); + + it( + // Checklist item 1: default (non-verbose) output must surface the offer + // whenever an ecosystem match exists on a declined generated_file — no + // conventions and no flags at all is the minimal case. + "no conventions, ecosystem match, declined → default summary offers --regenerate", + IT_TIMEOUT, + async () => { + buildConflictedLockRepo(repo); + + process.chdir(repo); + await cmdResolve([], {}); + + const lock = readFileSync(join(repo, LOCK), "utf-8"); + expect(lock).toContain("<<<<<<<"); // declined — still conflicted on disk + + const out = output(); + expect(out).toContain("1 conflict(s) remaining"); + expect(out).toContain("--regenerate"); + }, + ); + + it( + "verdict 'regenerate' → default summary offers --regenerate (per-file provenance stays verbose-only)", + IT_TIMEOUT, + async () => { + buildConflictedLockRepo(repo); + writeConventions(repo, "regenerate"); + + process.chdir(repo); + await cmdResolve([], {}); + + const out = output(); + expect(out).toContain("--regenerate"); + // Convention provenance text is only surfaced via --verbose (see + // resolve.ts's `if (verbose)` block) — a default run must not print it. + expect(out).not.toContain("convention measured"); + }, + ); + + it( + // Checklist item 3a: .gitwandrc explicit `true` beats a "regenerate" verdict. + ".gitwandrc resolveGeneratedFiles: true overrides a 'regenerate' convention verdict", + IT_TIMEOUT, + async () => { + buildConflictedLockRepo(repo); + writeConventions(repo, "regenerate"); + writeGitwandrc(repo, true); + + process.chdir(repo); + await cmdResolve([], {}); + + const lock = readFileSync(join(repo, LOCK), "utf-8"); + expect(JSON.parse(lock).shared).toBe("feature"); + expect(lock).not.toContain("<<<<<<<"); + + const out = output(); + expect(out).toContain("conflict(s) auto-resolved out of"); + expect(out).toContain("All conflicts resolved!"); + expect(out).not.toContain("--regenerate"); + }, + ); + + it( + // Checklist item 3b: .gitwandrc explicit `false` beats a "merge" verdict. + ".gitwandrc resolveGeneratedFiles: false overrides a 'merge' convention verdict", + IT_TIMEOUT, + async () => { + buildConflictedLockRepo(repo); + writeConventions(repo, "merge"); + writeGitwandrc(repo, false); + + process.chdir(repo); + await cmdResolve([], {}); + + const lock = readFileSync(join(repo, LOCK), "utf-8"); + expect(lock).toContain("<<<<<<<"); // declined despite the "merge" verdict + + const out = output(); + expect(out).toContain("1 conflict(s) remaining"); + expect(out).toContain("--regenerate"); + }, + ); +}); diff --git a/packages/cli/src/__tests__/resolve-regenerate-nested.test.ts b/packages/cli/src/__tests__/resolve-regenerate-nested.test.ts new file mode 100644 index 00000000..2cce090e --- /dev/null +++ b/packages/cli/src/__tests__/resolve-regenerate-nested.test.ts @@ -0,0 +1,149 @@ +/** + * Final review Finding 1 — nested (non-root) lockfiles must never come back + * `runnable` from `--regenerate`. + * + * `findEcosystem`/`GENERATED_FILE_PATTERNS` intentionally match nested + * lockfiles too (e.g. `packages/x/package-lock.json` → npm — see + * `packages/core/src/__tests__/regenerate/registry.test.ts`). Before this + * fix, nothing downstream was directory-aware: the CLI's regenerate-runner + * wrote each resolved source of truth at the WORKTREE ROOT and spawned the + * installer with `cwd` = that root, then read the regenerated lockfile back + * from its nested path — which the root-level install never touched. In a + * monorepo where the root `package.json` merges cleanly, that made a nested + * lockfile conflict come back `runnable: true`, regenerate the ROOT + * lockfile, and read back a stale/untouched nested one that still parses (a + * false "regenerated" success that's actually silent take-ours). + * + * This test drives `cmdResolve` end to end on a real small-monorepo repo and + * asserts the nested lockfile conflict is left alone (still conflicted on + * disk) rather than silently marked resolved. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { cmdResolve } from "../commands/resolve.js"; + +const HERMETIC_GIT_ENV = { + ...process.env, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + GIT_EDITOR: "true", + GIT_SEQUENCE_EDITOR: "true", + GIT_PAGER: "cat", +}; + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + env: HERMETIC_GIT_ENV, + timeout: 10_000, + }); +} + +function initRepo(cwd: string): void { + git(cwd, ["init", "-b", "main"]); + git(cwd, ["config", "user.email", "t@t.t"]); + git(cwd, ["config", "user.name", "t"]); + git(cwd, ["config", "commit.gpgsign", "false"]); + git(cwd, ["config", "core.hooksPath", "/dev/null"]); +} + +const IT_TIMEOUT = { timeout: 30_000 }; +const NESTED_LOCK = "packages/x/package-lock.json"; + +function bumpNestedLockVersion(repo: string, version: string): void { + const path = join(repo, NESTED_LOCK); + const lock = JSON.parse(readFileSync(path, "utf-8")); + lock.version = version; + writeFileSync(path, JSON.stringify(lock, null, 2) + "\n", "utf-8"); +} + +describe("cmdResolve --regenerate — nested lockfile regression (Finding 1, final review)", () => { + let repo: string; + let originalCwd: string; + let logSpy: ReturnType; + + beforeEach(() => { + originalCwd = process.cwd(); + repo = mkdtempSync(join(tmpdir(), "gw-resolve-regen-nested-")); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + process.chdir(originalCwd); + logSpy.mockRestore(); + rmSync(repo, { recursive: true, force: true }); + }); + + it( + "does NOT mark a nested package-lock.json conflict as regenerated, even though its (root) package.json merges cleanly", + IT_TIMEOUT, + async () => { + initRepo(repo); + + // Root package.json — never touched by either branch, merges cleanly. + writeFileSync(join(repo, "package.json"), '{"name":"root","private":true,"workspaces":["packages/*"]}\n', "utf-8"); + + // Nested workspace package, with its OWN lockfile — the one that + // actually gets conflicted. + mkdirSync(join(repo, "packages/x"), { recursive: true }); + writeFileSync(join(repo, "packages/x/package.json"), '{"name":"x","version":"1.0.0"}\n', "utf-8"); + writeFileSync( + join(repo, NESTED_LOCK), + JSON.stringify({ name: "x", version: "1.0.0", lockfileVersion: 3 }, null, 2) + "\n", + "utf-8", + ); + + git(repo, ["add", "-A"]); + git(repo, ["commit", "-m", "init"]); + + git(repo, ["checkout", "-b", "feature"]); + bumpNestedLockVersion(repo, "1.1.0"); + git(repo, ["commit", "-a", "-m", "feature: bump nested lock version"]); + + git(repo, ["checkout", "main"]); + bumpNestedLockVersion(repo, "1.0.0-main"); + git(repo, ["commit", "-a", "-m", "main: bump nested lock version"]); + + try { + git(repo, ["merge", "feature"]); + } catch { + // conflit attendu + } + + // Precondition: only the nested lockfile is conflicted; root + // package.json merged cleanly (never appears in the conflicted set). + const conflicted = git(repo, ["diff", "--name-only", "--diff-filter=U"]).trim().split("\n"); + expect(conflicted).toEqual([NESTED_LOCK]); + + process.chdir(repo); + await cmdResolve([], { regenerate: true, verbose: true }); + + // Not a false success: the printed line for this file must NOT read + // "success" for a regeneration that never should have run. + const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n"); + expect(output).not.toContain("regenerated via"); + expect(output).not.toMatch(/regenerate:.*success/); + + // The nested lockfile must be left exactly as the merge left it — + // still carrying conflict markers, never silently overwritten with + // regenerated (actually stale/untouched) content. + const nestedContent = readFileSync(join(repo, NESTED_LOCK), "utf-8"); + expect(nestedContent).toContain("<<<<<<<"); + + // Root package.json must never have been touched by gitwand either — + // it was clean, and the nested file's plan must be blocked before any + // write to sources of truth happens. + const status = git(repo, ["status", "--short"]).trim(); + expect(status).not.toContain(" packages/x/package.json"); + }, + ); +}); diff --git a/packages/cli/src/__tests__/resolve-regenerate-yarn-classic.test.ts b/packages/cli/src/__tests__/resolve-regenerate-yarn-classic.test.ts new file mode 100644 index 00000000..30b618f9 --- /dev/null +++ b/packages/cli/src/__tests__/resolve-regenerate-yarn-classic.test.ts @@ -0,0 +1,118 @@ +/** + * Final review Finding 2 — "not conflicted" must not be conflated with + * "clean" in the CLI's pass-2 sibling-map seeding either. + * + * A yarn-CLASSIC repo (has `yarn.lock`, no `.yarnrc.yml` at all — the berry + * marker `packages/core/src/regenerate/registry.ts` requires) has + * `.yarnrc.yml` trivially "not conflicted" simply because it never existed. + * Before this fix, `cmdResolve`'s pass-2 pre-seed loop + * (`packages/cli/src/commands/resolve.ts`) marked ANY sourceOfTruth absent + * from the conflicted-file set as "clean" unconditionally — which would have + * made the re-derived yarn-berry plan come back `runnable: true` for this + * repo, directly contradicting the registry's own documented berry-marker + * guard (only saved in practice by an unrelated `readFile` failure later on, + * per the final review). This test proves the CLI never marks the yarn.lock + * conflict as regenerated in this shape. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { cmdResolve } from "../commands/resolve.js"; + +const HERMETIC_GIT_ENV = { + ...process.env, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + GIT_EDITOR: "true", + GIT_SEQUENCE_EDITOR: "true", + GIT_PAGER: "cat", +}; + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + env: HERMETIC_GIT_ENV, + timeout: 10_000, + }); +} + +function initRepo(cwd: string): void { + git(cwd, ["init", "-b", "main"]); + git(cwd, ["config", "user.email", "t@t.t"]); + git(cwd, ["config", "user.name", "t"]); + git(cwd, ["config", "commit.gpgsign", "false"]); + git(cwd, ["config", "core.hooksPath", "/dev/null"]); +} + +const IT_TIMEOUT = { timeout: 30_000 }; +const YARN_LOCK = "yarn.lock"; + +function bumpLock(repo: string, marker: string): void { + writeFileSync(join(repo, YARN_LOCK), `# yarn lockfile v1\n# marker: ${marker}\n`, "utf-8"); +} + +describe("cmdResolve --regenerate — yarn-classic repo regression (Finding 2, final review)", () => { + let repo: string; + let originalCwd: string; + let logSpy: ReturnType; + + beforeEach(() => { + originalCwd = process.cwd(); + repo = mkdtempSync(join(tmpdir(), "gw-resolve-regen-yarn-classic-")); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + process.chdir(originalCwd); + logSpy.mockRestore(); + rmSync(repo, { recursive: true, force: true }); + }); + + it( + "does NOT mark a yarn.lock conflict as regenerated when .yarnrc.yml (berry marker) never existed", + IT_TIMEOUT, + async () => { + initRepo(repo); + writeFileSync(join(repo, "package.json"), '{"name":"e2e","version":"1.0.0"}\n', "utf-8"); + bumpLock(repo, "base"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-m", "init"]); + + git(repo, ["checkout", "-b", "feature"]); + bumpLock(repo, "feature"); + git(repo, ["commit", "-a", "-m", "feature: bump lock"]); + + git(repo, ["checkout", "main"]); + bumpLock(repo, "main"); + git(repo, ["commit", "-a", "-m", "main: bump lock"]); + + try { + git(repo, ["merge", "feature"]); + } catch { + // conflit attendu + } + + // Preconditions: only yarn.lock conflicted, no .yarnrc.yml anywhere. + const conflicted = git(repo, ["diff", "--name-only", "--diff-filter=U"]).trim().split("\n"); + expect(conflicted).toEqual([YARN_LOCK]); + expect(existsSync(join(repo, ".yarnrc.yml"))).toBe(false); + + process.chdir(repo); + await cmdResolve([], { regenerate: true, verbose: true }); + + const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n"); + expect(output).not.toContain("regenerated via"); + + const lockContent = readFileSync(join(repo, YARN_LOCK), "utf-8"); + expect(lockContent).toContain("<<<<<<<"); + }, + ); +}); diff --git a/packages/cli/src/__tests__/resolve-regenerate.test.ts b/packages/cli/src/__tests__/resolve-regenerate.test.ts new file mode 100644 index 00000000..57fb3845 --- /dev/null +++ b/packages/cli/src/__tests__/resolve-regenerate.test.ts @@ -0,0 +1,136 @@ +/** + * Fix round 1 (review Important #1/#3) — régression pour le trou de la + * carte de siblings de la pass 2 (accuracy lot D). + * + * `RegenerationContext.siblingFiles` documente sa clé comme « chaque AUTRE + * fichier de ce merge » (types.ts), pas « chaque autre fichier CONFLICTÉ ». + * Le cas le plus courant — un lockfile seul en conflit, sa source de vérité + * (`package.json`) ayant fusionné proprement sans le moindre marqueur — ne + * fait JAMAIS apparaître `package.json` dans `getConflictedFiles()` / + * `outcomes` de la pass 1. Ce test drive `cmdResolve` de bout en bout (pas + * seulement `regenerate-runner.ts` en isolation) sur un vrai dépôt où + * exactement ce scénario se produit, pour prouver que la pass 2 marque bien + * la source "clean" et régénère malgré tout. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { cmdResolve } from "../commands/resolve.js"; +import { isToolchainAvailable } from "../regenerate-runner.js"; + +const HERMETIC_GIT_ENV = { + ...process.env, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + GIT_EDITOR: "true", + GIT_SEQUENCE_EDITOR: "true", + GIT_PAGER: "cat", +}; + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + env: HERMETIC_GIT_ENV, + timeout: 10_000, + }); +} + +function initRepo(cwd: string): void { + git(cwd, ["init", "-b", "main"]); + git(cwd, ["config", "user.email", "t@t.t"]); + git(cwd, ["config", "user.name", "t"]); + git(cwd, ["config", "commit.gpgsign", "false"]); + git(cwd, ["config", "core.hooksPath", "/dev/null"]); +} + +const IT_TIMEOUT = { timeout: 30_000 }; + +describe.skipIf(!isToolchainAvailable("npm"))("cmdResolve --regenerate — clean sibling regression", () => { + let repo: string; + let originalCwd: string; + let logSpy: ReturnType; + + beforeEach(() => { + originalCwd = process.cwd(); + repo = mkdtempSync(join(tmpdir(), "gw-resolve-regen-")); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + process.chdir(originalCwd); + logSpy.mockRestore(); + rmSync(repo, { recursive: true, force: true }); + }); + + it( + "regenerates package-lock.json when package.json merged cleanly (never conflicted, never in outcomes)", + IT_TIMEOUT, + async () => { + initRepo(repo); + const pkgJson = '{"name":"e2e","version":"1.0.0"}\n'; + writeFileSync(join(repo, "package.json"), pkgJson, "utf-8"); + execFileSync("npm", ["install", "--package-lock-only", "--ignore-scripts"], { + cwd: repo, + stdio: ["ignore", "pipe", "pipe"], + env: HERMETIC_GIT_ENV, + }); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-m", "init"]); + + git(repo, ["checkout", "-b", "feature"]); + bumpLockVersion(repo, "1.1.0"); + git(repo, ["commit", "-a", "-m", "feature: lock version bump"]); + + git(repo, ["checkout", "main"]); + bumpLockVersion(repo, "1.0.0-main"); + git(repo, ["commit", "-a", "-m", "main: lock version tweak"]); + + try { + git(repo, ["merge", "feature"]); + } catch { + // conflit attendu + } + + // Précondition du test : package.json n'a JAMAIS été signalé en + // conflit — c'est exactement le trou couvert par ce test. + const conflicted = git(repo, ["diff", "--name-only", "--diff-filter=U"]).trim().split("\n"); + expect(conflicted).toEqual(["package-lock.json"]); + + process.chdir(repo); + await cmdResolve([], { regenerate: true, verbose: true }); + + const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n"); + expect(output).toContain("regenerate:"); + expect(output).toContain("success"); + + const lockContent = readFileSync(join(repo, "package-lock.json"), "utf-8"); + const lock = JSON.parse(lockContent); + // Le lockfile régénéré doit refléter `package.json` (version 1.0.0, + // jamais modifié par le merge) — pas l'un ou l'autre côté du conflit + // qu'on avait artificiellement injecté dans le vieux lockfile. + expect(lock.version).toBe("1.0.0"); + expect(lockContent).not.toContain("<<<<<<<"); + + const status = git(repo, ["status", "--short"]).trim(); + // package.json n'a jamais été touché par gitwand (il n'était pas en + // conflit) — seul package-lock.json doit porter une modification. + expect(status).toContain("package-lock.json"); + }, + ); +}); + +function bumpLockVersion(repo: string, version: string): void { + const path = join(repo, "package-lock.json"); + const lock = JSON.parse(readFileSync(path, "utf-8")); + lock.version = version; + lock.packages[""].version = version; + writeFileSync(path, JSON.stringify(lock, null, 2) + "\n", "utf-8"); +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6aa7b49c..e5b38cee 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -23,6 +23,7 @@ import { cmdResolve } from "./commands/resolve.js"; import { cmdStatus } from "./commands/status.js"; import { cmdPreview } from "./commands/preview.js"; import { cmdScan } from "./commands/scan.js"; +import { cmdConventions } from "./commands/conventions.js"; function printHelp(): void { printBanner(); @@ -31,12 +32,15 @@ function printHelp(): void { console.log(` gitwand status Show conflict status`); console.log(` gitwand preview Predict conflicts before merge/rebase/cherry-pick`); console.log(` gitwand scan Scan staged changes for secrets`); + console.log(` gitwand conventions Measure this repo's merge conventions from its own history (--show, --clear, --max-merges=N)`); console.log(` gitwand --help Show this help`); console.log(); console.log(`${c.bold}Options:${c.reset}`); console.log(` --dry-run Analyze without writing files`); console.log(` --verbose Show details for each resolution`); console.log(` --no-whitespace Don't resolve whitespace-only conflicts`); + console.log(` --resolve-generated Auto-resolve generated files (lockfiles, dist/) — declined by default: regenerate them instead`); + console.log(` --regenerate Re-run the ecosystem's generator (npm/pnpm/yarn-berry/composer/cargo) for declined lockfiles once their source of truth is clean/resolved (sandboxed git worktree, opt-in — see .gitwandrc "regenerate": true)`); console.log(` --concurrency=N Parallel file workers (default ${DEFAULT_CONCURRENCY}, min 1)`); console.log(` --ci CI mode: JSON output + exit code 1 if unresolved`); console.log(` --json Output results as JSON (implies --ci behavior)`); @@ -110,6 +114,8 @@ export async function main(): Promise { await cmdPreview(flags); } else if (command === "scan") { await cmdScan(flags); + } else if (command === "conventions") { + await cmdConventions(flags); } else { console.error(`${c.red}Unknown command: ${command}${c.reset}`); printHelp(); diff --git a/packages/cli/src/commands/conventions.ts b/packages/cli/src/commands/conventions.ts new file mode 100644 index 00000000..d1716045 --- /dev/null +++ b/packages/cli/src/commands/conventions.ts @@ -0,0 +1,281 @@ +/** + * `gitwand conventions` — mesurer les conventions de merge du dépôt courant + * sur son propre historique (accuracy lot F). + * + * Rejoue les merges passés (git merge-tree, sans toucher au working tree), + * compare les sorties de règles candidates à ce que l'équipe a réellement + * commité, et n'émet un verdict qu'au-dessus des planchers de preuve. Résultat + * écrit dans `.git/gitwand/conventions.json` — par clone, jamais commité, et + * toujours battu par un `.gitwandrc` explicite. + */ + +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { + deriveConventions, + isChangelogFile, + isGeneratedFile, + parseConflictMarkers, + resolve as gwResolve, + type ConventionObservation, + type RepoConventions, +} from "@gitwand/core"; + +import { c, printBanner } from "../ui.js"; + +const MAX_FILE_BYTES = 1_000_000; +const DEFAULT_MAX_MERGES = 200; + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf-8", + maxBuffer: 64 * 1024 * 1024, + stdio: ["ignore", "pipe", "ignore"], + }); +} + +/** Remplace chaque bloc de conflit par un seul côté — le candidat « tel quel ». */ +function takeSide(content: string, side: "ours" | "theirs"): string { + const { segments } = parseConflictMarkers(content); + const out: string[] = []; + for (const seg of segments) { + if (seg.type === "text") out.push(...seg.lines); + else out.push(...(side === "ours" ? seg.conflict.oursLines : seg.conflict.theirsLines)); + } + return out.join("\n"); +} + +/** Famille de chemins pour pathPolicy : l'extension. */ +function bucketOf(path: string): string | null { + const m = path.match(/\.([A-Za-z0-9]+)$/); + return m ? `**/*.${m[1].toLowerCase()}` : null; +} + +export interface DeriveRunResult { + conventions: RepoConventions; + skipped: { unreadable: number; tooLarge: number }; + /** Matière première du verdict — exposée pour l'audit et la validation split-half. */ + observations: ConventionObservation[]; +} + +/** + * Le replay lui-même. Borné (maxMerges), en lecture seule sur l'objet-store + * (`merge-tree --write-tree` n'écrit que des objets non référencés, ramassés + * par gc), jamais le working tree. + */ +/** `merge-tree --write-tree` (le cœur du replay) exige git >= 2.38. */ +export function supportsMergeTreeWriteTree(): boolean { + try { + const out = execFileSync("git", ["--version"], { encoding: "utf-8" }); + const m = out.match(/(\d+)\.(\d+)/); + if (!m) return false; + const [maj, min] = [Number(m[1]), Number(m[2])]; + return maj > 2 || (maj === 2 && min >= 38); + } catch { + return false; + } +} + +export function deriveFromHistory(cwd: string, maxMerges: number): DeriveRunResult { + if (!supportsMergeTreeWriteTree()) { + throw new Error("gitwand conventions requires git >= 2.38 (merge-tree --write-tree). Update git and retry."); + } + const merges = git(cwd, ["rev-list", "--merges", `--max-count=${String(maxMerges)}`, "HEAD"]) + .split("\n") + .filter(Boolean); + + const observations: ConventionObservation[] = []; + const skipped = { unreadable: 0, tooLarge: 0 }; + + for (const merge of merges) { + const parents = git(cwd, ["rev-list", "--parents", "-n", "1", merge]).trim().split(" ").slice(1); + if (parents.length !== 2) continue; // octopus hors périmètre + + let treeOid = ""; + let files: string[] = []; + try { + git(cwd, ["-c", "merge.conflictstyle=diff3", "merge-tree", "--write-tree", "--name-only", parents[0], parents[1]]); + continue; // merge propre → aucune observation + } catch (err: any) { + if (err.status !== 1 || typeof err.stdout !== "string") continue; + const head = err.stdout.split("\n\n")[0].split("\n").filter(Boolean); + treeOid = head[0]; + files = head.slice(1); + } + + for (const path of new Set(files)) { + let conflicted: string; + let human: string; + try { + conflicted = git(cwd, ["show", `${treeOid}:${path}`]); + human = git(cwd, ["show", `${merge}:${path}`]); + } catch { + skipped.unreadable++; + continue; + } + if (conflicted.length > MAX_FILE_BYTES || conflicted.includes("\0") || !conflicted.includes("<<<<<<<")) { + skipped.tooLarge++; + continue; + } + + // Le premier parent d'un commit de merge EST la branche cible. + const mergeContext = { operation: "merge" as const, targetSide: "ours" as const }; + + if (isGeneratedFile(path).generated) { + // Candidat « merge » : la fusion sémantique (opt-in forcé) reproduit-elle + // le commit ? Une fusion impossible compte comme un non — si le moteur ne + // peut même pas produire de fusion, elle ne reproduit certainement pas ce + // que l'équipe livre, et c'est une preuve de plus pour « regenerate ». + try { + const r = gwResolve(conflicted, path, { resolveGeneratedFiles: true, mergeContext }); + observations.push({ + question: "generatedFiles", + path, + candidates: { merge: r.mergedContent !== null && r.mergedContent === human }, + }); + } catch { skipped.unreadable++; } + continue; + } + + if (isChangelogFile(path)) { + let union = false; + try { + const r = gwResolve(conflicted, path, { mergeContext }); + union = r.mergedContent !== null && r.mergedContent === human; + } catch { /* union reste false */ } + const target = takeSide(conflicted, "ours") === human; + observations.push({ question: "changelog", path, candidates: { union, "target-structure": target } }); + continue; + } + + // pathPolicy : le fichier livré est-il un côté tel quel ? + const bucket = bucketOf(path); + if (bucket) { + const ours = takeSide(conflicted, "ours") === human; + const theirs = takeSide(conflicted, "theirs") === human; + observations.push({ question: "pathPolicy", path, bucket, candidates: { "prefer-ours": ours, "prefer-theirs": theirs } }); + } + } + } + + const enginePkg = JSON.parse( + readFileSync(new URL("../../node_modules/@gitwand/core/package.json", import.meta.url), "utf-8"), + ) as { version: string }; + + const conventions = deriveConventions(observations, { + mergesReplayed: merges.length, + derivedAt: new Date().toISOString(), + engineVersion: enginePkg.version, + }); + return { conventions, skipped, observations }; +} + +// ─── Stockage ───────────────────────────────────────────── + +export function conventionsPath(cwd: string): string { + const gitDir = git(cwd, ["rev-parse", "--absolute-git-dir"]).trim(); + return join(gitDir, "gitwand", "conventions.json"); +} + +/** + * Lit les conventions dérivées persistées (`.git/gitwand/conventions.json`) + * pour le dépôt à `cwd`, si elles existent. Tolérant — jamais de throw : + * hors d'un repo git, fichier absent, ou JSON invalide retournent `null`, + * exactement le "pas de conventions" que le moteur (`@gitwand/core`) attend + * par défaut sur `options.conventions`. + * + * Exporté (task 3 — accuracy lot D) pour être réutilisé par `cmdResolve` + * (Bug B fix : les conventions n'étaient jamais chargées dans `resolve()`) — + * cette même fonction alimente aussi `cmdConventions --show` ci-dessous, pour + * ne pas dupliquer une troisième fois la même lecture inline. + */ +export function loadPersistedConventions(cwd: string): RepoConventions | null { + let path: string; + try { + path = conventionsPath(cwd); + } catch { + return null; + } + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf-8")) as RepoConventions; + } catch { + return null; + } +} + +function writeAtomic(path: string, data: string): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.tmp`; + writeFileSync(tmp, data, "utf-8"); + renameSync(tmp, path); +} + +// ─── Commande ───────────────────────────────────────────── + +function printVerdicts(conv: RepoConventions): void { + const pct = (n: number) => `${Math.round(n * 100)} %`; + console.log(`${c.dim} measured on ${conv.evidence.mergesReplayed} merges / ${conv.evidence.conflictedFiles} conflicted files (engine ${conv.evidence.engineVersion}, ${conv.evidence.derivedAt})${c.reset}\n`); + const row = (q: string, v: string, s: number, a: number) => + console.log(` ${q.padEnd(16)} ${c.bold}${v}${c.reset} ${c.dim}(${s} samples, ${pct(a)})${c.reset}`); + if (conv.generatedFiles) row("generated files", conv.generatedFiles.verdict, conv.generatedFiles.samples, conv.generatedFiles.agreement); + if (conv.changelog) row("changelog", conv.changelog.verdict, conv.changelog.samples, conv.changelog.agreement); + if (conv.versionIdentity) row("version identity", conv.versionIdentity.verdict, conv.versionIdentity.samples, conv.versionIdentity.agreement); + if (!conv.generatedFiles && !conv.changelog && !conv.versionIdentity) { + console.log(` ${c.dim}no verdict cleared the evidence floor (≥5 samples, ≥80 % agreement) — engine defaults apply${c.reset}`); + } + if (conv.pathPolicies?.length) { + console.log(`\n ${c.bold}suggested .gitwandrc patternOverrides${c.reset} ${c.dim}(reported, never auto-applied)${c.reset}:`); + const patterns = Object.fromEntries(conv.pathPolicies.map((p) => [p.glob, p.policy])); + console.log( + JSON.stringify({ patterns }, null, 2) + .split("\n") + .map((l) => ` ${l}`) + .join("\n"), + ); + } +} + +export async function cmdConventions(flags: Record): Promise { + const cwd = process.cwd(); + const asJson = flags.json === true; + const path = conventionsPath(cwd); + + if (flags.clear === true) { + rmSync(path, { force: true }); + if (!asJson) console.log(`${c.green}✓ derived conventions cleared${c.reset}`); + return; + } + + if (flags.show === true) { + const conv = loadPersistedConventions(cwd); + if (conv === null) { + console.log(asJson ? "null" : `${c.dim}no derived conventions — run \`gitwand conventions\` to measure them${c.reset}`); + return; + } + if (asJson) console.log(JSON.stringify(conv, null, 2)); + else { printBanner(); printVerdicts(conv); } + return; + } + + const maxMerges = typeof flags["max-merges"] === "string" ? Math.max(1, Number(flags["max-merges"]) || DEFAULT_MAX_MERGES) : DEFAULT_MAX_MERGES; + if (!asJson) { + printBanner(); + console.log(`${c.dim} replaying up to ${maxMerges} historical merges (read-only)…${c.reset}\n`); + } + + const { conventions, skipped } = deriveFromHistory(cwd, maxMerges); + writeAtomic(path, JSON.stringify(conventions, null, 2) + "\n"); + + if (asJson) { + console.log(JSON.stringify({ conventions, skipped }, null, 2)); + } else { + printVerdicts(conventions); + if (skipped.unreadable + skipped.tooLarge > 0) { + console.log(`\n${c.dim} skipped: ${skipped.unreadable} unreadable, ${skipped.tooLarge} too large/binary${c.reset}`); + } + console.log(`\n${c.green}✓ written to .git/gitwand/conventions.json${c.reset} ${c.dim}(per-clone, never committed; an explicit .gitwandrc always wins)${c.reset}`); + } +} diff --git a/packages/cli/src/commands/resolve.ts b/packages/cli/src/commands/resolve.ts index e0fbadec..1297289f 100644 --- a/packages/cli/src/commands/resolve.ts +++ b/packages/cli/src/commands/resolve.ts @@ -18,16 +18,42 @@ */ import { readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; import { resolve as resolvePath } from "node:path"; -import { resolve, resolveAsync, summarizeTiers, type MergeResult, type ConflictType } from "@gitwand/core"; +import { + resolve, + resolveAsync, + summarizeTiers, + findEcosystem, + buildRegenerationPlan, + type MergeResult, + type ConflictType, + type RegenerationContext, +} from "@gitwand/core"; import { c, printBanner, WAND } from "../ui.js"; -import { getConflictedFiles } from "../git.js"; +import { getConflictedFiles, detectMergeContext } from "../git.js"; import { parseConcurrency, runPool } from "../concurrency.js"; import { buildPartialContent } from "../partial-content.js"; import { buildCIReport } from "../reporting.js"; import { buildLlmEndpoint } from "../llm-endpoint.js"; -import { resolveLlmConfig, buildResolveLlmOptions } from "../llm-config.js"; +import { + resolveLlmConfig, + buildResolveLlmOptions, + findGitRoot, + loadGitwandrcResolveGeneratedFiles, +} from "../llm-config.js"; +import { + runRegeneration, + loadGitwandrcRegenerateFlag, + type ResolvedSource, +} from "../regenerate-runner.js"; +import { loadPersistedConventions } from "./conventions.js"; + +/** Un marqueur de conflit résiduel dans un contenu régénéré serait un bug de + * l'installeur (ou un ré-échantillonnage malheureux) — même garde que celle + * appliquée en pass 1 avant toute écriture. */ +const RESIDUAL_MARKER_RE = /^(?:<{7}|={7}|>{7})/m; export async function cmdResolve( files: string[], @@ -36,6 +62,28 @@ export async function cmdResolve( const isCIMode = flags.ci || flags.json; const verbose = !isCIMode && (flags.verbose === true || typeof flags.verbose === "string"); const resolveWhitespace = !(flags["no-whitespace"] === true); + // accuracy lot 1 — les fichiers générés déclinent par défaut ; ce flag rétablit + // l'auto-résolution (équivalent CLI de resolveGeneratedFiles: true). + // + // Fix (task 3 brief, Bug A) — précédence, de la plus à la moins spécifique : + // --resolve-generated (true explicite) > .gitwandrc resolveGeneratedFiles + // (true/false explicite) > undefined (laisse la convention mesurée décider) + // > défaut du moteur (false). L'ancien code passait TOUJOURS un booléen + // concret (`=== true`, donc `false` même quand le flag n'était jamais + // fourni) — cela bloquait silencieusement pour toujours la précédence lot F + // de core (`resolver/index.ts` : un verdict "merge" ne s'applique que si + // `userOptions.resolveGeneratedFiles === undefined`). + const resolveGeneratedFiles: boolean | undefined = + flags["resolve-generated"] === true ? true : loadGitwandrcResolveGeneratedFiles(); + // accuracy lot F (Bug B fix, task 3) — conventions mesurées sur l'historique du + // dépôt (`gitwand conventions`), si elles ont été dérivées. Jusqu'ici jamais + // chargées ici : `options.conventions` restait toujours `undefined`, et la + // précédence lot F de core ne pouvait donc jamais s'exercer depuis le CLI. + const conventions = loadPersistedConventions(process.cwd()); + // accuracy lot C — contexte de merge : détecté depuis l'état .git ; null hors opération. + // Rend déterministes les décisions qui en dépendent (versions modifiées des + // deux côtés → la branche cible garde sa valeur). + const mergeContext = detectMergeContext(); const concurrency = parseConcurrency(flags.concurrency); const llmFallbackEnabled = flags["llm-fallback"] === true; @@ -67,6 +115,12 @@ export async function cmdResolve( if (!isCIMode) { printBanner(); + if (verbose && mergeContext) { + const refs = mergeContext.oursRef && mergeContext.theirsRef + ? ` — ${mergeContext.theirsRef} → ${mergeContext.oursRef}` + : ""; + console.log(`${c.dim} context: ${mergeContext.operation} in progress${refs} (target: ${mergeContext.targetSide})${c.reset}`); + } } // If no files specified, discover from git @@ -98,6 +152,53 @@ export async function cmdResolve( printLines: string[]; }; + // Extrait pour être réutilisé par la pass 2 (accuracy lot D — regenerate + // tier) : après une régénération réussie/échouée, les stats/résolutions du + // fichier changent et la ligne affichée doit refléter le nouvel état plutôt + // que le résultat figé de la pass 1. + function buildFileLines( + file: string, + result: MergeResult, + validationWarning: string | null, + skipWrite: boolean, + ): string[] { + const printLines: string[] = []; + if (isCIMode) return printLines; + if (result.stats.totalConflicts === 0) { + printLines.push(`${c.dim} ○ ${file} — no conflicts${c.reset}`); + return printLines; + } + + const icon = result.stats.remaining === 0 ? "✓" : "◐"; + const color = result.stats.remaining === 0 ? c.green : c.yellow; + + printLines.push( + `${color} ${icon} ${file} — ${result.stats.autoResolved}/${result.stats.totalConflicts} resolved${c.reset}`, + ); + + if (validationWarning) { + const warnColor = skipWrite ? c.red : c.yellow; + printLines.push(`${warnColor} ⚠ validation: ${validationWarning}${c.reset}`); + } + + if (verbose) { + for (const res of result.resolutions) { + const status = res.autoResolved + ? `${c.green}auto${c.reset}` + : `${c.red}manual${c.reset}`; + printLines.push( + `${c.dim} L${res.hunk.startLine} [${res.hunk.type}] ${status} — ${res.hunk.explanation}${c.reset}`, + ); + printLines.push(`${c.dim} trace: ${res.hunk.trace.summary}${c.reset}`); + if (res.regenerationPlan) { + printLines.push(`${c.dim} regenerate: ${res.resolutionReason}${c.reset}`); + } + } + } + + return printLines; + } + const outcomes = await runPool(files, concurrency, async (file) => { const filePath = resolvePath(file); let content: string; @@ -118,6 +219,9 @@ export async function cmdResolve( ? await resolveAsync(content, file, { verbose: false, resolveWhitespace, + resolveGeneratedFiles, + mergeContext, + conventions, llmFallback: { ...buildResolveLlmOptions(llmCliConfig, llmFileConfig), endpoint: buildLlmEndpoint(llmCliConfig), @@ -126,6 +230,9 @@ export async function cmdResolve( : resolve(content, file, { verbose: false, resolveWhitespace, + resolveGeneratedFiles, + mergeContext, + conventions, }); // Écriture sur disque (sauf dry-run). Bloquée si des marqueurs résiduels @@ -154,39 +261,206 @@ export async function cmdResolve( } } - const printLines: string[] = []; - if (!isCIMode) { - if (result.stats.totalConflicts === 0) { - printLines.push(`${c.dim} \u25CB ${file} — no conflicts${c.reset}`); - } else { - const icon = result.stats.remaining === 0 ? "\u2713" : "\u25D0"; - const color = result.stats.remaining === 0 ? c.green : c.yellow; + const printLines = buildFileLines(file, result, validationWarning, skipWrite); - printLines.push( - `${color} ${icon} ${file} — ${result.stats.autoResolved}/${result.stats.totalConflicts} resolved${c.reset}`, - ); + return { file, result, printLines }; + }); - if (validationWarning) { - const warnColor = skipWrite ? c.red : c.yellow; - printLines.push(`${warnColor} ⚠ validation: ${validationWarning}${c.reset}`); + // ─── accuracy lot D — Pass 2 : tier de régénération (opt-in) ─── + // + // Ne tourne QUE si `--regenerate` ou `.gitwandrc` `regenerate: true` est + // actif, et seulement après que la pass 1 ci-dessus a produit `outcomes` + // en entier — c'est ce qui permet de connaître l'état des AUTRES fichiers + // du merge (sources de vérité) avant de décider qu'un plan est sûr à + // exécuter. Voir `regenerate-runner.ts` pour l'exécution elle-même. + const regenerateEnabled = + !resolveGeneratedFiles && (flags.regenerate === true || loadGitwandrcRegenerateFlag()); + if (regenerateEnabled) { + const repoRoot = findGitRoot(); + if (repoRoot !== null) { + // Fix round 1 (Important #1) — `outcomes` ne couvre QUE les fichiers + // que git a signalés en conflit (`getConflictedFiles()` / + // `git diff --diff-filter=U`). Une source de vérité qui a fusionné + // proprement (ex: `package.json` intact pendant que `package-lock.json` + // diverge) n'apparaît JAMAIS dans `outcomes` — et `RegenerationContext. + // siblingFiles` documente pourtant la clé comme « chaque AUTRE fichier + // de ce merge », pas « chaque autre fichier CONFLICTÉ ». Ne pas la + // couvrir revient à la traiter comme "conflicted" par défaut dans + // `buildRegenerationPlan` (absente de la map ⇒ conflicted) — ce qui + // rend `runnable` inatteignable pour le cas le plus courant (lockfile + // seul en conflit) et rend yarn-berry totalement injoignable (son + // marqueur `.yarnrc.yml` n'est quasiment jamais lui-même conflicté). + const conflictedFileSet = new Set(outcomes.map((o) => o.file)); + const siblingFiles: RegenerationContext["siblingFiles"] = {}; + for (const outcome of outcomes) { + if (outcome.result === null) continue; + const { stats } = outcome.result; + siblingFiles[outcome.file] = { + state: + stats.totalConflicts === 0 + ? "clean" + : stats.remaining === 0 + ? "resolved" + : "conflicted", + }; + } + // Pré-seed chaque source de vérité des écosystèmes candidats qui n'a + // JAMAIS été signalée en conflit par git : par construction, "jamais + // vue en conflit" = "clean", exactement le signal attendu par le type. + // + // Fix (final review, Finding 2) — "jamais vue en conflit" ne veut PAS + // dire "clean" : un fichier peut n'avoir jamais été conflicté parce + // qu'il n'EXISTE tout simplement pas dans ce dépôt (ex: `.yarnrc.yml` + // sur un dépôt yarn CLASSIC, qui n'a jamais eu ce fichier). Confondre + // "pas conflicté" et "clean" faisait passer un tel repo pour + // `runnable: true` sur l'écosystème yarn-berry, contredisant la propre + // garde documentée du registre (`registry.ts` — `.yarnrc.yml` absent ⇒ + // non-runnable). On ne marque donc "clean" que si le fichier existe + // RÉELLEMENT sur disque en plus de n'être pas conflicté — sinon on le + // laisse absent de `siblingFiles`, que `buildRegenerationPlan` traite + // déjà comme "conflicted" (jamais runnable par défaut). + for (const outcome of outcomes) { + if (outcome.result === null) continue; + const hasRegenCandidate = outcome.result.resolutions.some((res) => res.regenerationPlan !== undefined); + if (!hasRegenCandidate) continue; + const ecosystem = findEcosystem(outcome.file); + if (!ecosystem) continue; + for (const sourcePath of ecosystem.sourcesOfTruth) { + if ( + !conflictedFileSet.has(sourcePath) && + !(sourcePath in siblingFiles) && + existsSync(resolvePath(sourcePath)) + ) { + siblingFiles[sourcePath] = { state: "clean" }; + } } + } + + for (const outcome of outcomes) { + if (outcome.result === null) continue; + const hasRegenCandidate = outcome.result.resolutions.some((res) => res.regenerationPlan !== undefined); + if (!hasRegenCandidate) continue; + + const ecosystem = findEcosystem(outcome.file); + if (!ecosystem) continue; // ne devrait jamais arriver — le plan pass-1 impliquait déjà un match + + // Ruling P-1b (brief) — on IGNORE le `runnable` attaché en pass 1 (il + // vaut toujours `false`, `regenerationContext` n'existait pas encore) + // et on re-dérive le plan avec la carte de siblings réelle. + const plan = buildRegenerationPlan(outcome.file, ecosystem, { siblingFiles }); + if (!plan.runnable) continue; - if (verbose) { - for (const res of result.resolutions) { - const status = res.autoResolved - ? `${c.green}auto${c.reset}` - : `${c.red}manual${c.reset}`; - printLines.push( - `${c.dim} L${res.hunk.startLine} [${res.hunk.type}] ${status} — ${res.hunk.explanation}${c.reset}`, + const resolvedSources: ResolvedSource[] = []; + let sourcesReady = true; + let unreadableSource: string | null = null; + for (const source of plan.sources) { + const siblingOutcome = outcomes.find((o) => o.file === source.path); + if (siblingOutcome?.result?.mergedContent != null) { + resolvedSources.push({ path: source.path, content: siblingOutcome.result.mergedContent }); + continue; + } + if (!siblingOutcome) { + // Jamais vu par la pass 1 ⇒ jamais conflicté ⇒ son contenu actuel + // sur disque EST déjà le contenu final (rien à fusionner) : on le + // lit directement plutôt que de le rechercher dans `outcomes`. + try { + const diskContent = await readFile(resolvePath(source.path), "utf-8"); + resolvedSources.push({ path: source.path, content: diskContent }); + continue; + } catch { + // Fichier introuvable — défensif, ne devrait pas arriver si + // `state === "clean"` a été dérivé de "jamais en conflit". + } + } + sourcesReady = false; + unreadableSource = source.path; + break; + } + if (!sourcesReady) { + // Final review Finding 2 (opportunistic ask) — ce cas était + // auparavant totalement silencieux, même sous `--regenerate` + // explicite. Un plan jugé runnable mais dont une source ne peut + // finalement pas être lue reste défensif (`plan.runnable` aurait dû + // le garantir) mais mérite au moins une ligne nommant le fichier. + if (!isCIMode) { + console.log( + `${c.dim} ⚠ ${outcome.file} — regeneration plan was runnable but source "${unreadableSource ?? "?"}" could not be read; skipped.${c.reset}`, ); - printLines.push(`${c.dim} trace: ${res.hunk.trace.summary}${c.reset}`); } + continue; + } + + const regenOutcome = await runRegeneration({ + repoRoot, + file: outcome.file, + ecosystem, + resolvedSources, + }); + + const hasResidualMarkers = + regenOutcome.kind === "success" && + regenOutcome.content !== null && + RESIDUAL_MARKER_RE.test(regenOutcome.content); + + let validationWarning: string | null = null; + let skipWrite = false; + + if (regenOutcome.kind === "success" && regenOutcome.content !== null && !hasResidualMarkers) { + if (!flags["dry-run"]) { + await writeFile(resolvePath(outcome.file), regenOutcome.content, "utf-8"); + } + const updatedResolutions = outcome.result.resolutions.map((res) => + res.regenerationPlan !== undefined + ? { + ...res, + autoResolved: true, + resolutionReason: `${res.resolutionReason} ${regenOutcome.reason}`, + } + : res, + ); + const newAutoResolved = updatedResolutions.filter((r) => r.autoResolved).length; + outcome.result = { + ...outcome.result, + mergedContent: regenOutcome.content, + resolutions: updatedResolutions, + stats: { + ...outcome.result.stats, + autoResolved: newAutoResolved, + remaining: outcome.result.stats.totalConflicts - newAutoResolved, + }, + }; + siblingFiles[outcome.file] = { + state: outcome.result.stats.remaining === 0 ? "resolved" : "conflicted", + }; + } else { + // Échec (toute nature confondue) OU succès mais contenu régénéré + // truffé de marqueurs résiduels : le fichier reste EXACTEMENT tel + // que la pass 1 l'a laissé sur disque — seule la raison affichée + // gagne le détail de l'échec. + if (hasResidualMarkers) { + validationWarning = "regenerated content still contains conflict markers — file NOT touched"; + skipWrite = true; + } + const detail = hasResidualMarkers + ? `${regenOutcome.reason} (marqueurs résiduels détectés — écriture annulée)` + : regenOutcome.reason; + const updatedResolutions = outcome.result.resolutions.map((res) => + res.regenerationPlan !== undefined + ? { ...res, resolutionReason: `${res.resolutionReason} ${detail}` } + : res, + ); + outcome.result = { ...outcome.result, resolutions: updatedResolutions }; + } + + outcome.printLines = buildFileLines(outcome.file, outcome.result, validationWarning, skipWrite); + if (verbose && !isCIMode) { + outcome.printLines.push( + `${c.dim} regenerate: ${regenOutcome.trace.ecosystem} · ${regenOutcome.trace.command} · ${(regenOutcome.trace.durationMs / 1000).toFixed(1)}s · ${regenOutcome.kind}${c.reset}`, + ); } } } - - return { file, result, printLines }; - }); + } // Flush ordonné (ordre de `files`, pas ordre de complétion). if (!isCIMode) { @@ -241,6 +515,28 @@ export async function cmdResolve( ); } + // accuracy lot D (task 3, checklist item 1) — offer the regenerate tier by + // default (not just under --verbose) whenever it's a live option: at least + // one declined resolution carries a `regenerationPlan` (an ecosystem + // matched), regardless of whether a measured convention exists — the + // per-file reason text (visible via --verbose) already carries the + // convention provenance when there is one. Suppressed when this very run + // already used --regenerate/.gitwandrc `regenerate: true`: re-offering a + // flag that was already applied (and, on failure, already tried) is not + // useful. `regenerationPlan` is still attached after a failed pass-2 + // attempt, so this check must also account for that by keying off + // `regenerateEnabled` from this same invocation. + const hasRegenerationOffer = + !regenerateEnabled && + outcomes.some((o) => + o.result?.resolutions.some((r) => r.regenerationPlan !== undefined && !r.autoResolved), + ); + if (hasRegenerationOffer) { + console.log( + `${c.dim}Some declined file(s) could be auto-resolved by regenerating their lockfile — re-run with --regenerate.${c.reset}`, + ); + } + // v3.4 — "recoverable-before-model" : de ce qui dépasse les passes triviales, // combien reste récupérable de façon déterministe avant d'atteindre le LLM. // N'affiche rien si tout était trivial (résidu vide — rien à mesurer). diff --git a/packages/cli/src/git.ts b/packages/cli/src/git.ts index 0aa82b38..a5e5c259 100644 --- a/packages/cli/src/git.ts +++ b/packages/cli/src/git.ts @@ -31,3 +31,96 @@ export function getConflictedFiles(): string[] { return []; } } + + +// ─── accuracy lot C — Détection du contexte de merge ────────────────────────────────── + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { MergeContext } from "@gitwand/core"; + +/** `git rev-parse --git-dir`, résolu en chemin absolu (couvre les worktrees, où `.git` est un fichier). */ +function gitDir(cwd: string): string | null { + try { + return execFileSync("git", ["rev-parse", "--absolute-git-dir"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + } catch { + return null; + } +} + +function revName(cwd: string, args: string[]): string | undefined { + try { + const out = execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + return out || undefined; + } catch { + return undefined; + } +} + +/** + * Détecte l'opération git en cours et construit le `MergeContext` correspondant. + * + * Convention des marqueurs git : « ours » est la branche DANS LAQUELLE on + * intègre pour merge, rebase (ours = la branche sur laquelle on rebase) et + * cherry-pick — donc `targetSide: "ours"` dans les trois cas. On le déclare + * ici, explicitement, pour que le moteur n'ait jamais à re-dériver l'inversion + * ours/theirs du rebase. + * + * Retourne `null` hors dépôt ou quand aucune opération n'est en cours — le + * moteur retombe alors sur son comportement sans contexte (proposer plutôt + * qu'appliquer sur les décisions dépendantes du contexte). + */ +export function detectMergeContext(cwd: string = process.cwd()): MergeContext | null { + const dir = gitDir(cwd); + if (!dir) return null; + + if (existsSync(join(dir, "MERGE_HEAD"))) { + return { + operation: "merge", + targetSide: "ours", + oursRef: revName(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]), + theirsRef: revName(cwd, ["name-rev", "--name-only", "--refs=refs/heads/*", "--refs=refs/remotes/*", "MERGE_HEAD"]), + }; + } + + if (existsSync(join(dir, "rebase-merge")) || existsSync(join(dir, "rebase-apply"))) { + // Pendant un rebase : ours = la branche sur laquelle on rejoue (la cible), + // theirs = le commit de l'utilisateur en cours de rejeu. + const rebaseDir = existsSync(join(dir, "rebase-merge")) ? "rebase-merge" : "rebase-apply"; + return { + operation: "rebase", + targetSide: "ours", + oursRef: revName(cwd, ["name-rev", "--name-only", "--refs=refs/heads/*", "--refs=refs/remotes/*", "HEAD"]), + theirsRef: readRefFile(join(dir, rebaseDir, "head-name")), + }; + } + + if (existsSync(join(dir, "CHERRY_PICK_HEAD"))) { + return { + operation: "cherry-pick", + targetSide: "ours", + oursRef: revName(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]), + }; + } + + if (existsSync(join(dir, "REVERT_HEAD"))) { + return { + operation: "revert", + targetSide: "ours", + oursRef: revName(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]), + }; + } + + return null; +} + +/** Lit un fichier de ref du rebase (`head-name` contient `refs/heads/`). */ +function readRefFile(path: string): string | undefined { + try { + const raw = readFileSync(path, "utf-8").trim(); + return raw.replace(/^refs\/heads\//, "") || undefined; + } catch { + return undefined; + } +} diff --git a/packages/cli/src/llm-config.ts b/packages/cli/src/llm-config.ts index 8e2b7a60..8e147bdd 100644 --- a/packages/cli/src/llm-config.ts +++ b/packages/cli/src/llm-config.ts @@ -66,6 +66,37 @@ export function loadGitwandrcLlmConfig(): GitWandrcConfig["llmFallback"] | null return null; } +/** + * Lit `.gitwandrc`/`.gitwandrc.json` à la racine du repo git courant et + * retourne sa valeur `resolveGeneratedFiles` (task 3 — Bug A fix + précédence + * lot D/F). + * + * Retourne `undefined` — jamais `false` par défaut — quand le champ n'est pas + * déclaré, dans un repo introuvable, ou si le fichier est absent/invalide : + * `undefined` est le signal "pas d'avis explicite" que core sait distinguer + * d'un `false` concret (seul un `false`/`true` explicite doit surclasser une + * convention `generatedFiles` mesurée — voir `resolver/index.ts`, précédence + * lot F). Même contrat tolérant que `loadGitwandrcLlmConfig` : ne throw jamais. + */ +export function loadGitwandrcResolveGeneratedFiles(): boolean | undefined { + const repoRoot = findGitRoot(); + if (repoRoot === null) return undefined; + + for (const filename of [".gitwandrc", ".gitwandrc.json"]) { + const path = join(repoRoot, filename); + let content: string; + try { + content = readFileSync(path, "utf-8"); + } catch { + continue; + } + const parsed = parseGitwandrc(content); + if (parsed === null) continue; + return parsed.resolveGeneratedFiles; + } + return undefined; +} + /** * Localise la racine du repo git courant via `git rev-parse --show-toplevel`. * Retourne `null` si on n'est pas dans un repo ou si git est introuvable — diff --git a/packages/cli/src/regenerate-runner.ts b/packages/cli/src/regenerate-runner.ts new file mode 100644 index 00000000..a27851fe --- /dev/null +++ b/packages/cli/src/regenerate-runner.ts @@ -0,0 +1,569 @@ +/** + * accuracy lot D — Exécuteur du tier de régénération, côté CLI. + * + * Le moteur (`@gitwand/core`) n'exécute jamais rien : il émet un + * `RegenerationPlan` (donnée pure). C'est ce module qui, quand ce plan est + * `runnable`, lance réellement la commande de l'écosystème (npm/pnpm/yarn + * berry/composer/cargo) — dans un `git worktree` jetable, jamais dans + * l'arbre de travail réel de l'utilisateur. + * + * Sandbox d'exécution (voir le brief de la tâche, § "Worktree sourcing") : + * 1. `git worktree add --detach HEAD` — HEAD est un point jetable, + * jamais la branche réelle de l'utilisateur. + * 2. superposer sur ce worktree chaque chemin déjà résolu (stage 0) de + * l'index de merge réel (`git checkout-index --all --force`, ciblé via + * `--work-tree`) — c'est ce qui rend visibles les fichiers qui n'existent + * QUE côté "theirs" (follow-up plan, résout la limitation identifiée par + * la revue finale du plan original — voir git blame pour l'historique). + * Note (revue finale, Important #4) : cette superposition ne change RIEN + * à l'état du lockfile candidat lui-même — il reste multi-stage (encore + * en conflit) dans l'index de merge réel, donc `checkout-index --all` le + * saute silencieusement, exactement comme avant ce fix ; seule la + * visibilité des fichiers theirs-only est réellement corrigée ici. + * 3. écraser dans ce worktree chaque source de vérité (`package.json`…) + * par son contenu déjà résolu en pass 1 (fourni par l'appelant — ce + * module ne re-résout rien). + * 4. lancer la commande du registre (flags de suppression de scripts déjà + * bakés dans `ecosystem.command.args` — jamais surchargeables ici). + * 5. sur succès : relire + valider le lockfile régénéré depuis le + * filesystem du worktree. + * 6. `finally` : toujours supprimer le worktree, succès ou échec. + * + * Chaque tentative est tracée intégralement (commande, durée, code de + * sortie) — cette provenance doit finir dans la raison de résolution + * affichée à l'utilisateur (voir `commands/resolve.ts`). + */ + +import { execFile, execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { readFileSync } from "node:fs"; +import { lookup as dnsLookup } from "node:dns/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { parse as parseToml } from "smol-toml"; +import { parse as parseYaml } from "yaml"; +import type { RegenEcosystem } from "@gitwand/core"; + +import { findGitRoot } from "./llm-config.js"; + +const execFileAsync = promisify(execFile); + +/** Hôte utilisé pour la sonde hors-ligne, par écosystème `network: "required"`. */ +const NETWORK_PROBE_HOSTS: Partial> = { + npm: "registry.npmjs.org", + pnpm: "registry.npmjs.org", + "yarn-berry": "registry.yarnpkg.com", + composer: "repo.packagist.org", +}; + +/** Budget de la sonde DNS hors-ligne — rapide, ne doit jamais bloquer longtemps. */ +const OFFLINE_PROBE_TIMEOUT_MS = 2_000; + +/** + * Fix round 1 (Important #2) — AGENTS.md : « Strip environment variables + * that carry secrets… Pass only the specific env vars the child process + * needs. » C'est la description d'une ALLOWLIST, pas d'une denylist — une + * denylist par motif de nom a toujours des trous (`*_PRIVATE_KEY`, + * `DATABASE_URL` avec un mot de passe embarqué, tout secret dont le nom ne + * matche aucun des motifs prévus…). On liste donc explicitement ce dont + * git/npm/pnpm/yarn/composer/cargo ont besoin pour tourner, plutôt que ce + * qu'on essaie de deviner comme "sensible". + * + * `GIT_*` est inclus en bloc (préfixe) : c'est de la plomberie git, jamais + * un secret, et le retirer casse `git worktree add` lui-même (régression + * découverte en test : un environnement qui injecte `safe.directory` via + * `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_N`/`GIT_CONFIG_VALUE_N` échoue si l'un + * des trois est retiré sans les deux autres — "fatal: unable to parse + * command-line config"). + */ +const ENV_ALLOWLIST_EXACT = new Set([ + // POSIX — nécessaires pour localiser les binaires, le HOME (~/.npmrc, + // ~/.cargo, ~/.composer…) et un shell/locale cohérents. + "PATH", + "HOME", + "TMPDIR", + "TMP", + "TEMP", + "LANG", + "LC_ALL", + "LC_CTYPE", + "USER", + "LOGNAME", + "SHELL", + // Windows — équivalents, seulement transmis s'ils sont effectivement définis. + "SystemRoot", + "SystemDrive", + "windir", + "ComSpec", + "PATHEXT", + "APPDATA", + "LOCALAPPDATA", + "ProgramData", + "ProgramFiles", + "ProgramFiles(x86)", + "ProgramW6432", + "ALLUSERSPROFILE", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "NUMBER_OF_PROCESSORS", + // Emplacements toolchain non-standard — n'ont d'effet que si l'utilisateur + // les a lui-même définis (rustup/cargo/pnpm/composer hors XDG par défaut). + "CARGO_HOME", + "RUSTUP_HOME", + "PNPM_HOME", + "COMPOSER_HOME", + "COMPOSER_CACHE_DIR", + "npm_config_cache", +]); + +/** Préfixes de noms de variables entièrement whitelistés (plomberie git). */ +const ENV_ALLOWLIST_PREFIXES = ["GIT_"]; + +/** + * Fix (final review, Finding 4) — `ENV_ALLOWLIST_PREFIXES` (`GIT_*`) était + * jusqu'ici utilisé par LA MÊME fonction (`buildSpawnEnv`) pour LES DEUX + * familles de spawn : la plomberie git (`git worktree add`/`remove`, où + * `GIT_*` est effectivement nécessaire — voir le commentaire ci-dessus) ET + * l'installeur de l'écosystème (npm/pnpm/yarn/composer/cargo), qui n'a + * besoin d'AUCUNE de ces variables. Des systèmes CI injectent couramment des + * identifiants via `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` + * (ex: `http.extraheader=Authorization: Basic `) ou + * `GIT_ASKPASS`/`GIT_SSH_COMMAND` — laisser ces variables atteindre le + * process spawné pour l'écosystème contredit la propre justification de + * l'allowlist ("aucun token ne peut fuiter par un nom de variable qu'une + * denylist aurait oublié") et AGENTS.md ("Pass only the specific env vars + * the child process needs"). + * + * Les 5 commandes du registre v1 sont toutes lockfile-only (jamais + * d'installation complète) : aucune n'a besoin de résoudre une dépendance + * `git+https://` via la config git héritée. On retire donc le préfixe + * `GIT_*` ENTIÈREMENT pour ce builder plutôt que de tenter une liste + * d'exclusions au sein du préfixe (plus simple à auditer, et le blast radius + * d'un manque futur — une dépendance git+https qui échouerait proprement — + * est bien moins grave qu'une fuite de credentials). + */ +function buildEcosystemSpawnEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value === undefined) continue; + if (!ENV_ALLOWLIST_EXACT.has(key)) continue; + env[key] = value; + } + return env; +} + +export type RegenerationOutcomeKind = + | "success" + | "missing-toolchain" + | "offline" + | "timeout" + | "spawn-failed" + | "validation-failed"; + +export interface RegenerationTrace { + ecosystem: RegenEcosystem["id"]; + bin: string; + args: string[]; + /** `bin` + `args` joints — pour affichage/log. */ + command: string; + durationMs: number; + /** `null` quand le process n'a jamais tourné (toolchain manquant, hors-ligne) ou a été tué (timeout). */ + exitCode: number | null; +} + +export interface RegenerationOutcome { + kind: RegenerationOutcomeKind; + /** Contenu régénéré et validé — présent uniquement quand `kind === "success"`. */ + content: string | null; + /** Raison lisible (français, cohérent avec les raisons de déclin du moteur). */ + reason: string; + trace: RegenerationTrace; +} + +export interface ResolvedSource { + /** Chemin repo-relatif (ex: "package.json"). */ + path: string; + /** Contenu déjà résolu (pass 1) à écrire dans le worktree jetable. */ + content: string; +} + +export interface RegenerationRunParams { + /** Racine du dépôt git réel — jamais écrite, seulement lue pour créer le worktree. */ + repoRoot: string; + /** Chemin repo-relatif du fichier généré à régénérer (ex: "package-lock.json"). */ + file: string; + ecosystem: RegenEcosystem; + resolvedSources: ResolvedSource[]; + /** Surcharge de `ecosystem.defaultTimeoutMs` (tests notamment). */ + timeoutMs?: number; + /** + * Alternate git index file to seed the disposable worktree from (via + * `GIT_INDEX_FILE`), instead of `repoRoot`'s own live index. Omitted in + * production (the real CLI always has a genuine in-progress merge whose + * live index is exactly what should seed the worktree) — supplied by the + * measurement harness, which has no real in-progress merge to read from. + */ + seedIndexFile?: string; +} + +function buildTrace( + ecosystem: RegenEcosystem["id"], + bin: string, + args: string[], + durationMs: number, + exitCode: number | null, +): RegenerationTrace { + return { ecosystem, bin, args, command: [bin, ...args].join(" "), durationMs, exitCode }; +} + +function formatDuration(durationMs: number): string { + return `${(durationMs / 1000).toFixed(1)}s`; +} + +/** `which`/`where` — sonde de présence du binaire, jamais d'exécution réelle. */ +export function isToolchainAvailable(bin: string): boolean { + const whichCmd = process.platform === "win32" ? "where" : "which"; + try { + execFileSync(whichCmd, [bin], { stdio: ["ignore", "pipe", "ignore"] }); + return true; + } catch { + return false; + } +} + +/** + * Sonde hors-ligne, rapide et sans dépendance : une résolution DNS du + * registre de l'écosystème, bornée dans le temps. Pas de vérité absolue + * (un DNS qui répond ne garantit pas que le registre soit joignable), mais + * suffisant pour éviter une tentative de régénération vouée à l'échec quand + * la machine n'a clairement aucune connectivité réseau — et bien plus + * rapide/robuste qu'attendre le timeout complet de la commande elle-même. + */ +export async function isOffline(ecosystemId: RegenEcosystem["id"]): Promise { + const host = NETWORK_PROBE_HOSTS[ecosystemId]; + if (!host) return false; // pas de sonde connue pour cet écosystème → on ne bloque pas + const probe = dnsLookup(host).then( + () => false, + () => true, + ); + const timeout = new Promise((resolve) => { + setTimeout(() => resolve(true), OFFLINE_PROBE_TIMEOUT_MS); + }); + return Promise.race([probe, timeout]); +} + +/** + * Validation du contenu régénéré — un parse réussi dans le format attendu + * par l'écosystème. Ce n'est PAS une validation sémantique (lot B) : aucun + * validateur réutilisable exporté par `@gitwand/core` ne couvre ces formats + * de lockfile (`validateMergedContent` n'est pas exporté publiquement) ; + * c'est le plancher documenté dans le brief de la tâche — un parse simple + * avec les mêmes libs que le moteur utilise en interne (`yaml`, `smol-toml`). + */ +export function validateRegeneratedContent( + ecosystemId: RegenEcosystem["id"], + content: string, +): { valid: true } | { valid: false; error: string } { + try { + switch (ecosystemId) { + case "npm": + case "composer": + JSON.parse(content); + return { valid: true }; + case "pnpm": + case "yarn-berry": + parseYaml(content); + return { valid: true }; + case "cargo": + parseToml(content); + return { valid: true }; + } + } catch (err) { + return { valid: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * Construit l'environnement des DEUX spawns de plomberie git (`git worktree + * add`/`remove`/`prune`) à partir d'une ALLOWLIST explicite + * (`ENV_ALLOWLIST_EXACT` + préfixe `GIT_*`), pas d'une denylist de motifs + * "sensibles" — voir le commentaire de l'allowlist pour le pourquoi. Rien + * d'autre du `process.env` de l'agent n'est transmis. + * + * Fix (final review, Finding 4) — ce builder (GIT_*-inclusif) ne doit PLUS + * servir pour le spawn de l'installeur de l'écosystème (npm/pnpm/yarn/ + * composer/cargo) : voir `buildEcosystemSpawnEnv` ci-dessus et son + * commentaire pour le pourquoi. Réservé à git désormais — d'où le renommage. + */ +function buildGitSpawnEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value === undefined) continue; + const allowed = + ENV_ALLOWLIST_EXACT.has(key) || ENV_ALLOWLIST_PREFIXES.some((prefix) => key.startsWith(prefix)); + if (!allowed) continue; + env[key] = value; + } + return env; +} + +/** + * Fix (follow-up plan, "merge-index seeding") — step 1 still worktrees at + * `HEAD` (a disposable, always-valid scaffold), but step 2 overlays every + * already-resolved (stage-0) path from the REAL merge index on top of it — + * this is what makes a `theirs`-only file (a new workspace member's + * `package.json`, say) visible to the installer. Paths still mid-conflict + * (multi-stage) are silently skipped by `checkout-index --all` — this + * INCLUDES the candidate lockfile itself, which stays at its `HEAD` + * (`ours'`) content from step 1, exactly as before this fix: this overlay + * does not, and was never claimed to, change the lockfile's own seed state. + * (Final review, Important #4 — an earlier revision of this comment claimed + * this overlay "stops the seed lockfile from being biased toward `ours'` + * incremental state"; that was never true. Only the theirs-only-file + * visibility half is real.) The caller overwrites the resolved sources of + * truth explicitly via `resolvedSources` right after this returns. + * + * `seedIndexFile`, when given, points `checkout-index` at an alternate index + * instead of `repoRoot`'s own live one — used by the measurement harness + * (`scripts/replay-regenerate.mjs`) to replay a *historical* merge, which has + * no real in-progress-merge index to read from. + * + * Final review, Important #2/#3: + * - never throw: `runRegeneration`'s documented contract is that it always + * resolves to a `RegenerationOutcome`, never an exception. A + * `checkout-index` failure here degrades to the HEAD-only scaffold from + * step 1 (no overlay applied) rather than propagating as an unhandled + * rejection — the pre-fix behavior, not a regression. + * - never leak an ambient `GIT_INDEX_FILE`: `buildGitSpawnEnv()` allowlists + * the whole `GIT_*` prefix, so an ambient `GIT_INDEX_FILE` already present + * in the process environment (git hooks, some mergetool flows) would + * otherwise silently override the "omit `seedIndexFile` → use `repoRoot`'s + * own live index" default this function documents. Explicitly deleted + * when `seedIndexFile` is not supplied. + */ +async function addWorktree( + repoRoot: string, + worktreeDir: string, + seedIndexFile?: string, +): Promise { + await execFileAsync("git", ["worktree", "add", "--detach", worktreeDir, "HEAD"], { + cwd: repoRoot, + env: buildGitSpawnEnv(), + }); + + const env = buildGitSpawnEnv(); + if (seedIndexFile) { + env.GIT_INDEX_FILE = seedIndexFile; + } else { + delete env.GIT_INDEX_FILE; + } + try { + await execFileAsync( + "git", + ["--work-tree", worktreeDir, "checkout-index", "--all", "--force"], + { cwd: repoRoot, env }, + ); + } catch { + // Never throw — see doc comment above. Degrading to the HEAD-only + // scaffold from step 1 (no overlay applied) is the pre-fix behavior, + // not a regression, just the failure floor this fix started from. + } +} + +async function removeWorktree(repoRoot: string, worktreeDir: string): Promise { + try { + await execFileAsync("git", ["worktree", "remove", "--force", worktreeDir], { + cwd: repoRoot, + env: buildGitSpawnEnv(), + }); + } catch { + // Best-effort fallback : le worktree n'est peut-être jamais devenu un + // vrai worktree git (échec avant/pendant `git worktree add`) — on + // s'assure quand même que rien ne reste sur disque. + await rm(worktreeDir, { recursive: true, force: true }).catch(() => {}); + await execFileAsync("git", ["worktree", "prune"], { cwd: repoRoot, env: buildGitSpawnEnv() }).catch(() => {}); + } +} + +/** + * Exécute le plan de régénération pour un fichier. Ne throw jamais — tout + * échec (toolchain absent, hors-ligne, timeout, code de sortie non nul, + * validation échouée) revient comme un `RegenerationOutcome` explicite, + * jamais une exception qui remonterait jusqu'à `cmdResolve`. + */ +export async function runRegeneration(params: RegenerationRunParams): Promise { + const { repoRoot, file, ecosystem, resolvedSources } = params; + const { bin, args } = ecosystem.command; + const timeoutMs = params.timeoutMs ?? ecosystem.defaultTimeoutMs; + + // 1. Toolchain probe — avant tout worktree, échec rapide et sans effet de bord. + if (!isToolchainAvailable(bin)) { + return { + kind: "missing-toolchain", + content: null, + reason: `tool "${bin}" not found in PATH — cannot regenerate "${file}" (${ecosystem.id}).`, + trace: buildTrace(ecosystem.id, bin, args, 0, null), + }; + } + + // 2. Hors-ligne — jamais de tentative partielle quand le réseau est requis. + if (ecosystem.network === "required" && (await isOffline(ecosystem.id))) { + return { + kind: "offline", + content: null, + reason: `no network connection detected — regenerating "${file}" (${ecosystem.id}) requires network access, declined.`, + trace: buildTrace(ecosystem.id, bin, args, 0, null), + }; + } + + const worktreeDir = join(tmpdir(), `gitwand-regen-${randomUUID()}`); + let worktreeCreated = false; + + try { + await addWorktree(repoRoot, worktreeDir, params.seedIndexFile); + worktreeCreated = true; + + // 3. Écrase les sources de vérité par leur contenu déjà résolu (pass 1) — + // jamais l'état conflictuel brut de l'index de merge. + for (const source of resolvedSources) { + const dest = join(worktreeDir, source.path); + await mkdir(dirname(dest), { recursive: true }); + await writeFile(dest, source.content, "utf-8"); + } + + // 4. Spawn — args array uniquement, jamais d'interpolation shell. + const start = Date.now(); + let stdout = ""; + let stderr = ""; + let exitCode: number | null = null; + let spawnError: unknown = null; + try { + const res = await execFileAsync(bin, args, { + cwd: worktreeDir, + env: buildEcosystemSpawnEnv(), + timeout: timeoutMs, + encoding: "utf-8", + maxBuffer: 32 * 1024 * 1024, + }); + stdout = res.stdout; + stderr = res.stderr; + exitCode = 0; + } catch (err) { + spawnError = err; + const e = err as NodeJS.ErrnoException & { + code?: number | string; + killed?: boolean; + stdout?: string; + stderr?: string; + }; + stdout = e.stdout ?? ""; + stderr = e.stderr ?? ""; + exitCode = typeof e.code === "number" ? e.code : null; + } + const durationMs = Date.now() - start; + const trace = buildTrace(ecosystem.id, bin, args, durationMs, exitCode); + + if (spawnError !== null) { + // Un process tué par le timeout n'a jamais de code de sortie propre ; + // la durée écoulée (proche du budget alloué) est le signal fiable. + const timedOut = durationMs >= timeoutMs; + if (timedOut) { + return { + kind: "timeout", + content: null, + reason: `regenerating "${file}" via "${trace.command}" was interrupted after ${formatDuration(durationMs)} (timeout ${formatDuration(timeoutMs)}) — conflict not resolved.`, + trace, + }; + } + const detail = stderr.trim().split("\n").slice(0, 3).join(" | "); + return { + kind: "spawn-failed", + content: null, + reason: `regenerating "${file}" via "${trace.command}" failed (code ${exitCode ?? "?"}) — conflict not resolved.${detail ? ` ${detail}` : ""}`, + trace, + }; + } + + // 5. Succès du process — relire + valider le lockfile régénéré. + const lockfilePath = join(worktreeDir, file); + let regenerated: string; + try { + regenerated = await readFile(lockfilePath, "utf-8"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + kind: "spawn-failed", + content: null, + reason: `regenerating "${file}" via "${trace.command}" (${formatDuration(durationMs)}) produced no readable file — conflict not resolved. ${msg}`, + trace, + }; + } + + const validation = validateRegeneratedContent(ecosystem.id, regenerated); + if (!validation.valid) { + return { + kind: "validation-failed", + content: null, + reason: `regenerating "${file}" via "${trace.command}" (${formatDuration(durationMs)}) produced invalid content — conflict not resolved. ${validation.error}`, + trace, + }; + } + + return { + kind: "success", + content: regenerated, + reason: `regenerated via ${trace.command} (${formatDuration(durationMs)}).`, + trace, + }; + } finally { + if (worktreeCreated) { + await removeWorktree(repoRoot, worktreeDir); + } else { + // `git worktree add` peut avoir échoué après avoir déjà créé le + // répertoire cible (rare mais possible) — nettoyage défensif. + await rm(worktreeDir, { recursive: true, force: true }).catch(() => {}); + } + } +} + +// ─── `.gitwandrc` `regenerate: true` — lecture CLI-only (§ ruling P-3) ──── +// +// Le moteur (`@gitwand/core`) n'exécute jamais rien : ce champ n'a donc pas +// sa place dans `GitWandrcConfig`/`parseGitwandrc` (core), qui reste +// entièrement dédié au COMPORTEMENT DE RÉSOLUTION. On mirror ici le même +// pattern de découverte de fichier que `loadGitwandrcLlmConfig` +// (llm-config.ts) sans passer par `parseGitwandrc`, qui ignorerait ce champ. + +/** + * Lit `.gitwandrc`/`.gitwandrc.json` à la racine du dépôt git courant et + * retourne `true` si `{ "regenerate": true }` y est déclaré. Tolérant : + * repo introuvable, fichier absent, ou JSON invalide → `false`, jamais de + * throw (même contrat que `loadGitwandrcLlmConfig`). + */ +export function loadGitwandrcRegenerateFlag(): boolean { + const repoRoot = findGitRoot(); + if (repoRoot === null) return false; + + for (const filename of [".gitwandrc", ".gitwandrc.json"]) { + const path = join(repoRoot, filename); + let content: string; + try { + content = readFileSync(path, "utf-8"); + } catch { + continue; + } + try { + const parsed: unknown = JSON.parse(content); + if (parsed && typeof parsed === "object" && "regenerate" in parsed) { + return (parsed as { regenerate?: unknown }).regenerate === true; + } + return false; + } catch { + continue; + } + } + return false; +} diff --git a/packages/core/src/__tests__/accuracy-lot1.test.ts b/packages/core/src/__tests__/accuracy-lot1.test.ts new file mode 100644 index 00000000..c79ee333 --- /dev/null +++ b/packages/core/src/__tests__/accuracy-lot1.test.ts @@ -0,0 +1,185 @@ +/** + * accuracy lot 1 — Lot 1 « accuracy » : tests des trois changements issus du benchmark + * (docs/superpowers/specs/2026-08-26-conflict-engine-accuracy.md). + * + * A — contrat du classifieur : un hunk `complex` résolu par un résolveur + * format-aware est reclassifié `format_semantic` (confiance + trace), + * plus jamais affiché « complex » mais appliqué en douce. + * B — invariants de format : une résolution qui produit un changelog à deux + * sections « Unreleased » ou un JSON à clé dupliquée est rétractée. + * D — fichiers générés : décliner par défaut avec un message actionnable ; + * l'ancien comportement reste disponible via `resolveGeneratedFiles`. + */ + +import { describe, expect, it } from "vitest"; +import { resolve } from "../index.js"; +import { checkFormatInvariants, findDuplicateJsonKeys } from "../resolver/validation.js"; + +const conflict = (ours: string[], base: string[], theirs: string[]) => + [ + "<<<<<<< ours", + ...ours, + "||||||| base", + ...base, + "=======", + ...theirs, + ">>>>>>> theirs", + ].join("\n"); + +// ─── A — contrat du classifieur ─────────────────────────────────────────────── + +describe("A — classifier contract (format_semantic)", () => { + it("reclassifies a complex hunk resolved by the JSON resolver, with confidence and trace", () => { + // Whole-document conflict: each side adds a different key — textual + // complex, semantically a clean key-merge for the JSON resolver (which + // needs each side to parse as a full JSON document). + const content = conflict( + ["{", ' "name": "app",', ' "alpha": 1', "}"], + ["{", ' "name": "app"', "}"], + ["{", ' "name": "app",', ' "beta": 2', "}"], + ); + + const result = resolve(content, "config.json"); + expect(result.mergedContent).not.toBeNull(); + expect(result.stats.byType.complex ?? 0).toBe(0); + expect(result.stats.byType.format_semantic).toBe(1); + + const hunk = result.hunks[0]; + expect(hunk.type).toBe("format_semantic"); + expect(hunk.confidence.label).toBe("high"); + expect(hunk.trace.selected).toBe("format_semantic"); + expect(hunk.trace.steps.at(-1)?.reason).toContain("semantic merge"); + }); + + it("never reports a fully-resolved file whose only hunk is still `complex`", () => { + const content = [ + "# Doc", + "", + conflict(["- ours line"], ["- base line"], ["- theirs line"]), + ].join("\n"); + const result = resolve(content, "notes.md"); + if (result.mergedContent !== null) { + expect(result.stats.byType.complex ?? 0).toBe(0); + } + }); + + it("respects the confidence threshold for format-aware resolutions (strict policy)", () => { + const content = conflict( + ["{", ' "name": "app",', ' "alpha": 1', "}"], + ["{", ' "name": "app"', "}"], + ["{", ' "name": "app",', ' "beta": 2', "}"], + ); + // strict → minConfidence certain : la résolution format-aware (high) est bloquée + const result = resolve(content, "config.json", { policy: "strict" }); + expect(result.mergedContent).toBeNull(); + expect(result.resolutions[0].autoResolved).toBe(false); + expect(result.resolutions[0].resolutionReason).toMatch(/policy|insufficient/); + }); +}); + +// ─── B — invariants de format ───────────────────────────────────────────────── + +describe("B — format invariants", () => { + it("finds duplicate JSON keys per object, not across objects", () => { + expect(findDuplicateJsonKeys('{"a":1,"a":2}')).toEqual(["a"]); + expect(findDuplicateJsonKeys('{"a":{"x":1},"b":{"x":1}}')).toEqual([]); + expect(findDuplicateJsonKeys('{"a":"a\\":1,\\"a","b":2}')).toEqual([]); + expect(findDuplicateJsonKeys('[{"k":1},{"k":2}]')).toEqual([]); + }); + + it("flags a changelog with two Unreleased sections", () => { + const md = "# Notes\n\n## [Unreleased](x)\n\nstuff\n\n## [Unreleased](y)\n\nmore"; + expect(checkFormatInvariants(md, "CHANGELOG.md")).toHaveLength(1); + // ...but only for changelog-shaped files + expect(checkFormatInvariants(md, "guide.md")).toHaveLength(0); + }); + + it("retracts a resolution that would produce a duplicate JSON key", () => { + // Both sides add the SAME key with different values → line union would + // keep both → invariant violation → retraction. + const content = [ + "{", + ' "name": "app",', + conflict([' "dep": "^12.0",'], [], [' "dep": "^13.0",']), + ' "zeta": 26', + "}", + ].join("\n"); + + const result = resolve(content, "composer.json"); + // Quoi que le moteur ait tenté, le fichier final ne doit jamais porter la clé dupliquée. + if (result.mergedContent !== null) { + expect(findDuplicateJsonKeys(result.mergedContent)).toEqual([]); + } else { + expect(result.stats.autoResolved).toBe(0); + } + }); + + it("retracts a changelog resolution that duplicates the Unreleased section", () => { + const content = [ + "# Release Notes", + "", + conflict( + ["## [Unreleased](compare/v13.25.0...13.x)"], + ["## [Unreleased](compare/v12.65.0...12.x)"], + ["## [Unreleased](compare/v12.66.0...12.x)", "", "## [v12.66.0](compare/...) - 2026-08-11", "", "* change A"], + ), + "", + "## [v13.25.0](compare/...) - 2026-08-11", + "", + "* change B", + ].join("\n"); + + const result = resolve(content, "CHANGELOG.md"); + if (result.mergedContent !== null) { + const unreleased = result.mergedContent.split("\n").filter((l) => /^##\s+\[?unreleased/i.test(l)); + expect(unreleased.length).toBeLessThanOrEqual(1); + } else { + expect(result.stats.autoResolved).toBe(0); + expect(result.validation.isValid === false || result.resolutions.every((r) => !r.autoResolved)).toBe(true); + } + }); +}); + +// ─── D — fichiers générés : décliner par défaut ─────────────────────────────── + +describe("D — generated files decline by default", () => { + const lockConflict = [ + "{", + ' "lockfileVersion": 3,', + conflict([' "pkg-a": "1.0.0",'], [], [' "pkg-b": "2.0.0",']), + ' "end": true', + "}", + ].join("\n"); + + it("declines on package-lock.json with an actionable reason", () => { + const result = resolve(lockConflict, "package-lock.json"); + expect(result.mergedContent).toBeNull(); + expect(result.stats.autoResolved).toBe(0); + const reason = result.resolutions[0].resolutionReason; + expect(reason).toMatch(/regenerat|install|build/i); + expect(reason).toContain("resolveGeneratedFiles"); + }); + + it("keeps the historical behaviour behind resolveGeneratedFiles: true", () => { + const result = resolve(lockConflict, "package-lock.json", { resolveGeneratedFiles: true }); + expect(result.stats.autoResolved).toBeGreaterThan(0); + }); + + it("still resolves the safe textual cases on generated files (one side untouched)", () => { + const content = [ + "{", + conflict([' "pkg-a": "1.0.1",'], [' "pkg-a": "1.0.0",'], [' "pkg-a": "1.0.0",']), + ' "end": true', + "}", + ].join("\n"); + const result = resolve(content, "package-lock.json"); + // one_side_change : prendre le côté modifié ne fabrique rien — autorisé. + expect(result.stats.autoResolved).toBe(1); + expect(result.hunks[0].type).toBe("one_side_change"); + }); + + it("classification still reports generated_file (tier: unresolved by default)", () => { + const result = resolve(lockConflict, "package-lock.json"); + expect(result.hunks[0].type === "generated_file" || result.stats.autoResolved === 0).toBe(true); + }); +}); diff --git a/packages/core/src/__tests__/conventions.test.ts b/packages/core/src/__tests__/conventions.test.ts new file mode 100644 index 00000000..31db800e --- /dev/null +++ b/packages/core/src/__tests__/conventions.test.ts @@ -0,0 +1,158 @@ +/** + * accuracy lot F — Conventions de dépôt : dérivation pure et consommation. + * + * Dérivation : verdicts uniquement au-dessus des planchers (≥5 échantillons, + * ≥80 % d'accord), preuve contradictoire → pas de verdict, provenance stampée. + * Consommation : `.gitwandrc`/appelant > convention > défaut, et toute + * résolution influencée porte la provenance dans sa raison. + */ + +import { describe, expect, it } from "vitest"; +import { deriveConventions, resolve, type ConventionObservation, type RepoConventions } from "../index.js"; + +const META = { mergesReplayed: 40, derivedAt: "2026-08-26T12:00:00Z", engineVersion: "3.8.0" }; + +const obs = ( + question: ConventionObservation["question"], + candidates: Record, + bucket?: string, +): ConventionObservation => ({ question, path: "x", candidates, ...(bucket ? { bucket } : {}) }); + +describe("deriveConventions — planchers de preuve", () => { + it("stamps evidence and derives nothing from nothing", () => { + const c = deriveConventions([], META); + expect(c.evidence).toEqual({ ...META, conflictedFiles: 0 }); + expect(c.generatedFiles).toBeUndefined(); + expect(c.changelog).toBeUndefined(); + }); + + it("no verdict below MIN_SAMPLES (4 unanimous samples are not enough)", () => { + const c = deriveConventions(Array(4).fill(obs("generatedFiles", { merge: false })), META); + expect(c.generatedFiles).toBeUndefined(); + }); + + it("verdict 'regenerate' when semantic merges never match what ships", () => { + const c = deriveConventions(Array(6).fill(obs("generatedFiles", { merge: false })), META); + expect(c.generatedFiles).toEqual({ verdict: "regenerate", samples: 6, agreement: 1 }); + }); + + it("verdict 'merge' when semantic merges match what ships", () => { + const c = deriveConventions( + [...Array(9).fill(obs("generatedFiles", { merge: true })), obs("generatedFiles", { merge: false })], + META, + ); + expect(c.generatedFiles?.verdict).toBe("merge"); + expect(c.generatedFiles?.agreement).toBeCloseTo(0.9); + }); + + it("contradictory evidence (50/50) yields NO verdict", () => { + const c = deriveConventions( + [...Array(5).fill(obs("generatedFiles", { merge: true })), ...Array(5).fill(obs("generatedFiles", { merge: false }))], + META, + ); + expect(c.generatedFiles).toBeUndefined(); + }); + + it("changelog: neither union nor target matching → 'tool-rebuilt'", () => { + const c = deriveConventions( + Array(7).fill(obs("changelog", { union: false, "target-structure": false })), + META, + ); + expect(c.changelog?.verdict).toBe("tool-rebuilt"); + }); + + it("changelog: union matches → 'union'", () => { + const c = deriveConventions(Array(6).fill(obs("changelog", { union: true, "target-structure": false })), META); + expect(c.changelog?.verdict).toBe("union"); + }); + + it("pathPolicies: both sides matching means the evidence is worthless", () => { + const c = deriveConventions( + Array(6).fill(obs("pathPolicy", { "prefer-ours": true, "prefer-theirs": true }, "**/*.md")), + META, + ); + expect(c.pathPolicies).toBeUndefined(); + }); + + it("pathPolicies: a clear one-sided family is reported", () => { + const c = deriveConventions( + Array(6).fill(obs("pathPolicy", { "prefer-ours": false, "prefer-theirs": true }, "**/*.snap")), + META, + ); + expect(c.pathPolicies).toEqual([{ glob: "**/*.snap", policy: "prefer-theirs", samples: 6, agreement: 1 }]); + }); +}); + +describe("conventions — consumption precedence and provenance", () => { + const lockConflict = [ + "{", + ' "lockfileVersion": 3,', + "<<<<<<< ours", + ' "pkg-a": "1.0.0",', + "||||||| base", + "=======", + ' "pkg-b": "2.0.0",', + ">>>>>>> theirs", + ' "end": true', + "}", + ].join("\n"); + + const mergeConv: RepoConventions = { + evidence: { mergesReplayed: 40, conflictedFiles: 12, derivedAt: "x", engineVersion: "3.8.0" }, + generatedFiles: { verdict: "merge", samples: 12, agreement: 0.92 }, + }; + + it("generatedFiles 'merge' convention enables auto-resolution, with provenance in the reason", () => { + const result = resolve(lockConflict, "package-lock.json", { conventions: mergeConv }); + expect(result.stats.autoResolved).toBeGreaterThan(0); + expect(result.resolutions[0].resolutionReason).toContain("convention measured on 12 merges"); + }); + + it("an explicit caller choice beats the convention (.gitwandrc precedence)", () => { + const result = resolve(lockConflict, "package-lock.json", { + conventions: mergeConv, + resolveGeneratedFiles: false, + }); + expect(result.stats.autoResolved).toBe(0); + expect(result.resolutions[0].resolutionReason).not.toContain("convention measured"); + }); + + it("'regenerate' convention keeps the decline and confirms it with provenance", () => { + const regen: RepoConventions = { + ...mergeConv, + generatedFiles: { verdict: "regenerate", samples: 9, agreement: 1 }, + }; + const result = resolve(lockConflict, "package-lock.json", { conventions: regen }); + expect(result.stats.autoResolved).toBe(0); + expect(result.resolutions[0].resolutionReason).toContain("regenerates its generated files"); + }); + + it("'tool-rebuilt' changelog convention declines the union with provenance", () => { + const changelog = [ + "# Changelog", + "", + "<<<<<<< ours", + "- feat A", + "=======", + "- feat B", + ">>>>>>> theirs", + ].join("\n"); + const conv: RepoConventions = { + evidence: { mergesReplayed: 30, conflictedFiles: 8, derivedAt: "x", engineVersion: "3.8.0" }, + changelog: { verdict: "tool-rebuilt", samples: 8, agreement: 0.94 }, + }; + const withConv = resolve(changelog, "CHANGELOG.md", { conventions: conv }); + expect(withConv.stats.autoResolved).toBe(0); + expect(withConv.resolutions[0].resolutionReason).toContain("release tooling"); + // ...et sans convention, l'union markdown fait son travail habituel. + const without = resolve(changelog, "CHANGELOG.md"); + expect(without.stats.autoResolved).toBe(1); + }); + + it("conventions never touch files they are not about", () => { + const ts = ["<<<<<<< ours", "const x = 1;", "||||||| base", "const x = 0;", "=======", "const x = 0;", ">>>>>>> theirs"].join("\n"); + const a = resolve(ts, "src/a.ts", { conventions: mergeConv }); + const b = resolve(ts, "src/a.ts"); + expect(a.mergedContent).toBe(b.mergedContent); + }); +}); diff --git a/packages/core/src/__tests__/corpus.ts b/packages/core/src/__tests__/corpus.ts index 12fde6c0..3f366fef 100644 --- a/packages/core/src/__tests__/corpus.ts +++ b/packages/core/src/__tests__/corpus.ts @@ -309,7 +309,7 @@ const F11: CorpusFixture = { `>>>>>>> theirs`, ].join("\n"), expectedType: "value_only_change", - expectedResolved: true, + expectedResolved: false, // accuracy lot 1 — fichier généré : décline par défaut (se régénère, ne se fusionne pas), }; const F12: CorpusFixture = { @@ -361,7 +361,7 @@ const F13: CorpusFixture = { `>>>>>>> theirs`, ].join("\n"), expectedType: "value_only_change", - expectedResolved: true, + expectedResolved: false, // accuracy lot 1 — fichier généré : décline par défaut (se régénère, ne se fusionne pas), options: { minConfidence: "medium" }, }; @@ -384,7 +384,7 @@ const F14: CorpusFixture = { ].join("\n"), // diff3 + les deux côtés changent + tokens non-volatils (clés) → complex → generated_file expectedType: "generated_file", - expectedResolved: true, + expectedResolved: false, // accuracy lot 1 — fichier généré : décline par défaut (se régénère, ne se fusionne pas), }; // ─── Format-aware — JSON sémantique ──────────────────────── @@ -1360,6 +1360,46 @@ const F46: CorpusFixture = { expectedResolved: false, }; +// ─── accuracy lot C — MergeContext (lot C) ─────────────────────────── + +const F47: CorpusFixture = { + id: "F47", + description: "accuracy lot C — value_only_change : identité de version en back-merge, la cible gagne (contexte fourni)", + filePath: "src/Application.php", + category: "semantic", + input: [ + `<<<<<<< ours`, + ` const VERSION = '13.x-dev';`, + `||||||| base`, + ` const VERSION = '12.53.0';`, + `=======`, + ` const VERSION = '12.54.1';`, + `>>>>>>> theirs`, + ].join("\n"), + expectedType: "value_only_change", + expectedResolved: true, + expectedOutput: ` const VERSION = '13.x-dev';`, + options: { mergeContext: { operation: "merge", targetSide: "ours", oursRef: "13.x", theirsRef: "12.x" } }, +}; + +const F48: CorpusFixture = { + id: "F48", + description: "accuracy lot C — value_only_change : même identité de version SANS contexte → proposé, jamais appliqué (l'ancien fallback politique était mesuré faux ~3 fois sur 4)", + filePath: "src/Application.php", + category: "semantic", + input: [ + `<<<<<<< ours`, + ` const VERSION = '13.x-dev';`, + `||||||| base`, + ` const VERSION = '12.53.0';`, + `=======`, + ` const VERSION = '12.54.1';`, + `>>>>>>> theirs`, + ].join("\n"), + expectedType: "value_only_change", + expectedResolved: false, +}; + // ─── Export ───────────────────────────────────────────────── export const CORPUS: CorpusFixture[] = [ @@ -1376,6 +1416,8 @@ export const CORPUS: CorpusFixture[] = [ // v2.5 — LLM fallback candidates (complex sans LLM, résolus avec LLM mocké) F36, F37, F38, F39, F40, F41, F42, F43, F44, F45, + // accuracy lot C — MergeContext + F47, F48, // v3.4 — token_level_merge F46, ]; diff --git a/packages/core/src/__tests__/golden-funnel.default.json b/packages/core/src/__tests__/golden-funnel.default.json index cc06b733..e769c285 100644 --- a/packages/core/src/__tests__/golden-funnel.default.json +++ b/packages/core/src/__tests__/golden-funnel.default.json @@ -1,10 +1,11 @@ { - "fixtures": 46, - "totalHunks": 46, - "autoResolved": 31, + "fixtures": 48, + "totalHunks": 48, + "autoResolved": 29, "byType": { - "complex": 20, + "complex": 14, "delete_no_change": 2, + "format_semantic": 6, "generated_file": 1, "insertion_at_boundary": 4, "non_overlapping": 4, @@ -12,15 +13,15 @@ "reorder_only": 1, "same_change": 3, "token_level_merge": 1, - "value_only_change": 4, + "value_only_change": 6, "whitespace_only": 1 }, "tiers": { - "trivial": 25, - "advancedDeterministic": 1, + "trivial": 26, + "advancedDeterministic": 7, "model": 0, - "unresolved": 20, - "residual": 21, - "aiReachable": 20 + "unresolved": 15, + "residual": 22, + "aiReachable": 15 } } diff --git a/packages/core/src/__tests__/golden-funnel.refactoring.json b/packages/core/src/__tests__/golden-funnel.refactoring.json index c10e7ca4..6af590db 100644 --- a/packages/core/src/__tests__/golden-funnel.refactoring.json +++ b/packages/core/src/__tests__/golden-funnel.refactoring.json @@ -1,10 +1,11 @@ { - "fixtures": 46, - "totalHunks": 46, - "autoResolved": 31, + "fixtures": 48, + "totalHunks": 48, + "autoResolved": 29, "byType": { - "complex": 19, + "complex": 14, "delete_no_change": 2, + "format_semantic": 5, "generated_file": 1, "insertion_at_boundary": 4, "non_overlapping": 4, @@ -13,15 +14,15 @@ "reorder_only": 1, "same_change": 3, "token_level_merge": 1, - "value_only_change": 4, + "value_only_change": 6, "whitespace_only": 1 }, "tiers": { - "trivial": 25, - "advancedDeterministic": 2, + "trivial": 26, + "advancedDeterministic": 7, "model": 0, - "unresolved": 19, - "residual": 21, - "aiReachable": 19 + "unresolved": 15, + "residual": 22, + "aiReachable": 15 } } diff --git a/packages/core/src/__tests__/merge-context.test.ts b/packages/core/src/__tests__/merge-context.test.ts new file mode 100644 index 00000000..1a5d520c --- /dev/null +++ b/packages/core/src/__tests__/merge-context.test.ts @@ -0,0 +1,101 @@ +/** + * accuracy lot C — Lot C : MergeContext. + * + * Le moteur reçoit (optionnellement) l'opération en cours et le côté cible. + * Règles testées : + * - scalaire de version modifié des deux côtés + contexte → la cible gagne, + * y compris quand « le semver le plus élevé » aurait choisi l'autre côté ; + * - même cas sans contexte, valeurs non ordonnables → proposé, pas appliqué + * (l'ancien fallback politique était mesuré faux ~3 fois sur 4) ; + * - paires semver ordonnables sans contexte → règle historique intacte ; + * - hashes/timestamps → comportement inchangé, contexte ou pas ; + * - le contexte n'influence pas les hunks qui ne le concernent pas. + */ + +import { describe, expect, it } from "vitest"; +import { resolve, type MergeContext } from "../index.js"; + +const conflict = (ours: string[], base: string[], theirs: string[]) => + ["<<<<<<< ours", ...ours, "||||||| base", ...base, "=======", ...theirs, ">>>>>>> theirs"].join("\n"); + +const backMerge: MergeContext = { + operation: "merge", + targetSide: "ours", + oursRef: "13.x", + theirsRef: "12.x", +}; + +describe("MergeContext — version scalars", () => { + // Le cas laravel : la cible porte '13.x-dev' (non semver), la source une + // version publiée. L'ancien moteur retombait sur prefer-theirs → importait + // la version de la source. Les humains gardent TOUJOURS la valeur de la cible. + const laravelShape = conflict( + [" const VERSION = '13.x-dev';"], + [" const VERSION = '12.53.0';"], + [" const VERSION = '12.54.1';"], + ); + + it("target wins on a back-merge, even against a 'newer' published version", () => { + const result = resolve(laravelShape, "src/Application.php", { mergeContext: backMerge }); + expect(result.stats.autoResolved).toBe(1); + expect(result.mergedContent).toContain("13.x-dev"); + expect(result.mergedContent).not.toContain("12.54.1"); + expect(result.resolutions[0].resolutionReason).toContain("target branch"); + }); + + it("targetSide is honoured literally (rebase declares its own inversion)", () => { + const rebaseCtx: MergeContext = { operation: "rebase", targetSide: "ours" }; + const result = resolve(laravelShape, "src/Application.php", { mergeContext: rebaseCtx }); + expect(result.mergedContent).toContain("13.x-dev"); + + const inverted: MergeContext = { operation: "merge", targetSide: "theirs" }; + const result2 = resolve(laravelShape, "src/Application.php", { mergeContext: inverted }); + expect(result2.mergedContent).toContain("12.54.1"); + }); + + it("without context, unorderable version pairs are proposed, never applied", () => { + const result = resolve(laravelShape, "src/Application.php"); + expect(result.stats.autoResolved).toBe(0); + expect(result.mergedContent).toBeNull(); + expect(result.resolutions[0].resolutionReason).toContain("decision"); + }); + + it("orderable semver pairs keep 'newest wins' even WITH context", () => { + // Mesuré sur benchmark/ : basculer aussi les paires ordonnables vers la + // cible faisait régresser prettier (45,0 → 39,0 %) — les humains prennent + // bien la dépendance la plus récente apportée par la branche source. La + // règle « la cible gagne » ne s'applique qu'aux paires NON ordonnables + // (l'identité de version du fichier : '13.x-dev', '2.9.0-dev'…). + const orderable = conflict( + [' "version": "1.2.3"'], + [' "version": "1.2.2"'], + [' "version": "1.2.9"'], + ); + const without = resolve(orderable, "app/config.json"); + expect(without.mergedContent).toContain("1.2.9"); + const withCtx = resolve(orderable, "app/config.json", { mergeContext: backMerge }); + expect(withCtx.mergedContent).toContain("1.2.9"); + }); +}); + +describe("MergeContext — untouched behaviours", () => { + it("hash-only value changes keep the policy fallback, context or not", () => { + const hashes = conflict( + [' "sha": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678"'], + [' "sha": "0000000000000000000000000000000000000000"'], + [' "sha": "9f8e7d6c5b4a39281706f5e4d3c2b1a098765432"'], + ); + const without = resolve(hashes, "meta.json"); + const withCtx = resolve(hashes, "meta.json", { mergeContext: backMerge }); + expect(without.mergedContent).toBe(withCtx.mergedContent); + expect(without.stats.autoResolved).toBe(1); + }); + + it("context does not change hunks it cannot influence (one_side_change)", () => { + const oneSide = conflict(["const x = 2;"], ["const x = 1;"], ["const x = 1;"]); + const without = resolve(oneSide, "src/a.ts"); + const withCtx = resolve(oneSide, "src/a.ts", { mergeContext: backMerge }); + expect(without.mergedContent).toBe(withCtx.mergedContent); + expect(without.hunks[0].type).toBe("one_side_change"); + }); +}); diff --git a/packages/core/src/__tests__/patterns/value-only-change.test.ts b/packages/core/src/__tests__/patterns/value-only-change.test.ts index 0aa4feaf..915a725d 100644 --- a/packages/core/src/__tests__/patterns/value-only-change.test.ts +++ b/packages/core/src/__tests__/patterns/value-only-change.test.ts @@ -10,6 +10,8 @@ import { describe, it, expect } from "vitest"; import { resolve } from "../../resolver.js"; +// accuracy lot 1 — ces cas exercent le pattern value_only_change sur des chemins de lockfile ; +// sous le nouveau défaut ces fichiers déclinent, donc opt-in resolveGeneratedFiles. // ─── Cas qui doivent matcher value_only_change ─────────────── @@ -43,12 +45,12 @@ describe("value_only_change : checksums différents (diff2)", () => { ].join("\n"); it("classifie en value_only_change", () => { - const result = resolve(input, "Cargo.lock"); + const result = resolve(input, "Cargo.lock", { resolveGeneratedFiles: true }); expect(result.hunks[0].type).toBe("value_only_change"); }); it("auto-résout", () => { - const result = resolve(input, "Cargo.lock"); + const result = resolve(input, "Cargo.lock", { resolveGeneratedFiles: true }); expect(result.stats.autoResolved).toBe(1); }); }); @@ -63,12 +65,12 @@ describe("value_only_change : integrity hash npm (diff2)", () => { ].join("\n"); it("classifie en value_only_change", () => { - const result = resolve(input, "package-lock.json"); + const result = resolve(input, "package-lock.json", { resolveGeneratedFiles: true }); expect(result.hunks[0].type).toBe("value_only_change"); }); it("auto-résout", () => { - const result = resolve(input, "package-lock.json"); + const result = resolve(input, "package-lock.json", { resolveGeneratedFiles: true }); expect(result.stats.autoResolved).toBe(1); }); }); @@ -85,12 +87,12 @@ describe("value_only_change : multiple lignes avec valeurs scalaires (diff2)", ( ].join("\n"); it("classifie en value_only_change", () => { - const result = resolve(input, "package-lock.json"); + const result = resolve(input, "package-lock.json", { resolveGeneratedFiles: true }); expect(result.hunks[0].type).toBe("value_only_change"); }); it("auto-résout", () => { - const result = resolve(input, "package-lock.json"); + const result = resolve(input, "package-lock.json", { resolveGeneratedFiles: true }); expect(result.stats.autoResolved).toBe(1); }); }); @@ -105,12 +107,12 @@ describe("value_only_change : hash de commit (diff2)", () => { ].join("\n"); it("classifie en value_only_change", () => { - const result = resolve(input, "Cargo.lock"); + const result = resolve(input, "Cargo.lock", { resolveGeneratedFiles: true }); expect(result.hunks[0].type).toBe("value_only_change"); }); it("auto-résout", () => { - const result = resolve(input, "Cargo.lock"); + const result = resolve(input, "Cargo.lock", { resolveGeneratedFiles: true }); expect(result.stats.autoResolved).toBe(1); }); }); diff --git a/packages/core/src/__tests__/regenerate-integration.test.ts b/packages/core/src/__tests__/regenerate-integration.test.ts new file mode 100644 index 00000000..d53fd6e7 --- /dev/null +++ b/packages/core/src/__tests__/regenerate-integration.test.ts @@ -0,0 +1,222 @@ +/** + * accuracy lot D — Intégration : `resolve()` attache un `RegenerationPlan` + * quand un fichier généré est décliné ET que son chemin matche un écosystème + * du registre `regenerate/registry.ts`. Trois sites de déclin peuvent + * attacher le plan (voir `attachRegenerationPlan` dans `resolver/index.ts`) : + * 1. le `generatedGate` (hunks non-`generated_file`, ex: `value_only_change`) ; + * 2. le seuil `minConfidence` poussé au-dessus de "high" sur un hunk + * `generated_file` (cas rare) ; + * 3. `assembleResolution`'s case "generated_file" — le cas MAJORITAIRE : + * un lockfile réellement en conflit (chevauchement sémantique, pas un + * pattern "safe") est reclassifié `generated_file` par + * `reclassifyIfGenerated` avant même que `resolveHunk` ne tourne, donc + * le `generatedGate` (qui exclut `hunk.type === "generated_file"`) ne le + * voit jamais ; le déclin arrive plus loin, dans `assembleResolution`. + * Spec finding #1 (0 % d'accord sur `generated_file`) porte sur ce cas. + * + * Règles testées : + * - package-lock.json décliné (value_only_change, site 1) + package.json + * clean dans regenerationContext → plan runnable, reason contient + * l'indice --regenerate ; + * - package.json conflicted → plan attaché mais runnable: false ; + * - resolveGeneratedFiles: true → aucun plan (l'opt-in textuel gagne) ; + * - fichier généré hors registre (.min.js) → aucun plan (juste le déclin) ; + * - yarn.lock : .yarnrc.yml absent/conflicted → runnable: false (Ruling P-3) ; + * - lockfile GENUINELY conflicting (site 3, assembleResolution) → plan + * attaché aussi, avec le même hint et la même sémantique runnable/not ; + * resolveGeneratedFiles: true continue de prendre "accepter theirs" sans + * jamais décliner sur ce chemin (donc jamais de plan). + */ + +import { describe, expect, it } from "vitest"; +import { resolve } from "../index.js"; +import type { RegenerationContext } from "../types.js"; + +const lockEntryDiff = `<<<<<<< HEAD + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/foo/-/foo-3.2.1.tgz", + "integrity": "sha512-abc123def456" +======= + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foo/-/foo-3.3.0.tgz", + "integrity": "sha512-xyz789ghi012" +>>>>>>> master`; + +// Structurally different (not value_only) so the hunk stays "complex" → +// reclassified "generated_file". Not in the v1 registry (.min.js), so no +// plan is attached regardless of which of the three sites declines it. +const minJsDiff = `<<<<<<< HEAD +!function(){var a=1;console.log(a);doStuff()}(); +======= +!function(){var b=2;alert(b);doOther();cleanup()}(); +>>>>>>> master`; + +// Genuinely overlapping package-lock.json entry: both sides diverge from +// each other in STRUCTURE (not just a scalar value), so the classifier calls +// it "complex" → reclassifyIfGenerated turns it into "generated_file" BEFORE +// resolveHunk runs. This is what a real lockfile conflict looks like — the +// majority case (spec finding #1), reached via assembleResolution's +// case "generated_file", not the generatedGate branch. +const overlappingLockJsonDiff = `<<<<<<< HEAD + "node_modules/foo": { + "version": "1.0.0", + "requires": { "bar": "^2.0" } + } +======= + "node_modules/foo": { + "version": "1.1.0", + "requires": { "bar": "^2.0", "baz": "^1.0" } + } +>>>>>>> master`; + +describe("regenerate tier — resolver integration", () => { + it("attaches a runnable plan when the source of truth is clean", () => { + const ctx: RegenerationContext = { siblingFiles: { "package.json": { state: "clean" } } }; + const result = resolve(lockEntryDiff, "package-lock.json", { regenerationContext: ctx }); + + expect(result.stats.autoResolved).toBe(0); + const resolution = result.resolutions[0]; + expect(resolution.regenerationPlan).toBeDefined(); + expect(resolution.regenerationPlan?.runnable).toBe(true); + expect(resolution.regenerationPlan?.ecosystem).toBe("npm"); + expect(resolution.resolutionReason).toContain("--regenerate"); + }); + + it("attaches a non-runnable plan when the source of truth is still conflicted", () => { + const ctx: RegenerationContext = { siblingFiles: { "package.json": { state: "conflicted" } } }; + const result = resolve(lockEntryDiff, "package-lock.json", { regenerationContext: ctx }); + + const resolution = result.resolutions[0]; + expect(resolution.regenerationPlan).toBeDefined(); + expect(resolution.regenerationPlan?.runnable).toBe(false); + expect(resolution.regenerationPlan?.sources).toContainEqual({ + path: "package.json", + state: "conflicted", + }); + }); + + it("attaches no plan at all when regenerationContext is absent (unknown = conflicted, still declined)", () => { + const result = resolve(lockEntryDiff, "package-lock.json"); + const resolution = result.resolutions[0]; + // A plan is still attached (the ecosystem matches) but it's not runnable — + // absence of context just means every source is treated as conflicted. + expect(resolution.regenerationPlan).toBeDefined(); + expect(resolution.regenerationPlan?.runnable).toBe(false); + }); + + it("attaches no plan when resolveGeneratedFiles: true (textual opt-in wins)", () => { + const ctx: RegenerationContext = { siblingFiles: { "package.json": { state: "clean" } } }; + const result = resolve(lockEntryDiff, "package-lock.json", { + resolveGeneratedFiles: true, + regenerationContext: ctx, + }); + + expect(result.stats.autoResolved).toBe(1); + expect(result.resolutions[0].regenerationPlan).toBeUndefined(); + }); + + it("attaches no plan for a generated file outside the v1 registry (.min.js)", () => { + const result = resolve(minJsDiff, "public/dist/app.min.js", { minConfidence: "medium" }); + const resolution = result.resolutions[0]; + expect(resolution.regenerationPlan).toBeUndefined(); + expect(resolution.resolutionReason).not.toContain("--regenerate"); + }); + + describe("yarn-berry vs classic (Ruling P-3)", () => { + const yarnDiff = `<<<<<<< HEAD + foo@^1.0.0: + version "1.0.0" +======= + foo@^1.0.0: + version "1.1.0" +>>>>>>> master`; + + it("is runnable when package.json AND .yarnrc.yml are both clean", () => { + const ctx: RegenerationContext = { + siblingFiles: { + "package.json": { state: "clean" }, + ".yarnrc.yml": { state: "clean" }, + }, + }; + const result = resolve(yarnDiff, "yarn.lock", { regenerationContext: ctx }); + const resolution = result.resolutions[0]; + expect(resolution.regenerationPlan?.ecosystem).toBe("yarn-berry"); + expect(resolution.regenerationPlan?.runnable).toBe(true); + }); + + it("is not runnable when .yarnrc.yml is missing from the context (classic yarn)", () => { + const ctx: RegenerationContext = { + siblingFiles: { "package.json": { state: "clean" } }, + }; + const result = resolve(yarnDiff, "yarn.lock", { regenerationContext: ctx }); + const resolution = result.resolutions[0]; + expect(resolution.regenerationPlan?.runnable).toBe(false); + expect(resolution.regenerationPlan?.sources).toContainEqual({ + path: ".yarnrc.yml", + state: "conflicted", + }); + }); + }); + + // Site 3 — assembleResolution's case "generated_file". The majority case: + // a genuinely overlapping lockfile diff (real semantic conflict, not a + // "safe" pattern), reclassified `generated_file` before resolveHunk runs, + // so the generatedGate branch (site 1) never sees it. + describe("genuinely-complex lockfile conflict (assembleResolution decline path)", () => { + it("classifies as generated_file (not caught by the generatedGate)", () => { + const result = resolve(overlappingLockJsonDiff, "package-lock.json", { minConfidence: "medium" }); + expect(result.hunks[0].type).toBe("generated_file"); + }); + + it("attaches a runnable plan when package.json is clean", () => { + const ctx: RegenerationContext = { siblingFiles: { "package.json": { state: "clean" } } }; + const result = resolve(overlappingLockJsonDiff, "package-lock.json", { + minConfidence: "medium", + regenerationContext: ctx, + }); + + expect(result.stats.autoResolved).toBe(0); + const resolution = result.resolutions[0]; + expect(resolution.regenerationPlan).toBeDefined(); + expect(resolution.regenerationPlan?.runnable).toBe(true); + expect(resolution.regenerationPlan?.ecosystem).toBe("npm"); + expect(resolution.resolutionReason).toContain("--regenerate"); + }); + + it("attaches a non-runnable plan when package.json is conflicted", () => { + const ctx: RegenerationContext = { siblingFiles: { "package.json": { state: "conflicted" } } }; + const result = resolve(overlappingLockJsonDiff, "package-lock.json", { + minConfidence: "medium", + regenerationContext: ctx, + }); + + const resolution = result.resolutions[0]; + expect(resolution.regenerationPlan).toBeDefined(); + expect(resolution.regenerationPlan?.runnable).toBe(false); + expect(resolution.regenerationPlan?.sources).toContainEqual({ + path: "package.json", + state: "conflicted", + }); + }); + + it("still attaches a (non-runnable) plan when regenerationContext is entirely absent", () => { + const result = resolve(overlappingLockJsonDiff, "package-lock.json", { minConfidence: "medium" }); + const resolution = result.resolutions[0]; + expect(resolution.regenerationPlan).toBeDefined(); + expect(resolution.regenerationPlan?.runnable).toBe(false); + }); + + it("attaches no plan when resolveGeneratedFiles: true (this path takes accept-theirs, never declines)", () => { + const ctx: RegenerationContext = { siblingFiles: { "package.json": { state: "clean" } } }; + const result = resolve(overlappingLockJsonDiff, "package-lock.json", { + minConfidence: "medium", + resolveGeneratedFiles: true, + regenerationContext: ctx, + }); + + expect(result.stats.autoResolved).toBe(1); + expect(result.resolutions[0].regenerationPlan).toBeUndefined(); + expect(result.resolutions[0].resolutionReason).not.toContain("--regenerate"); + }); + }); +}); diff --git a/packages/core/src/__tests__/regenerate/plan.test.ts b/packages/core/src/__tests__/regenerate/plan.test.ts new file mode 100644 index 00000000..93ec903b --- /dev/null +++ b/packages/core/src/__tests__/regenerate/plan.test.ts @@ -0,0 +1,142 @@ +/** + * accuracy lot D — `buildRegenerationPlan` : fonction pure, aucun I/O. + * + * Règles testées : + * - runnable uniquement quand toutes les sources sont clean/resolved ; + * - une source conflictuelle → runnable: false, nommée dans `sources` ; + * - une source absente du contexte → traitée comme conflictuelle (jamais + * "runnable par défaut") ; + * - contexte absent (`null`/`undefined`) → toutes les sources conflictuelles. + */ + +import { describe, expect, it } from "vitest"; +import { buildRegenerationPlan } from "../../regenerate/plan.js"; +import { findEcosystem } from "../../regenerate/registry.js"; +import type { RegenerationContext } from "../../types.js"; + +const npmEco = findEcosystem("package-lock.json")!; +const yarnBerryEco = findEcosystem("yarn.lock")!; + +describe("buildRegenerationPlan", () => { + it("is runnable when every source of truth is clean", () => { + const ctx: RegenerationContext = { + siblingFiles: { "package.json": { state: "clean" } }, + }; + const plan = buildRegenerationPlan("package-lock.json", npmEco, ctx); + expect(plan.runnable).toBe(true); + expect(plan.ecosystem).toBe("npm"); + expect(plan.file).toBe("package-lock.json"); + expect(plan.sources).toEqual([{ path: "package.json", state: "clean" }]); + }); + + it("is runnable when every source of truth is engine-resolved", () => { + const ctx: RegenerationContext = { + siblingFiles: { "package.json": { state: "resolved", confidence: 0.9 } }, + }; + const plan = buildRegenerationPlan("package-lock.json", npmEco, ctx); + expect(plan.runnable).toBe(true); + expect(plan.sources[0]).toEqual({ path: "package.json", state: "resolved", confidence: 0.9 }); + }); + + it("is not runnable when a source of truth is still conflicted", () => { + const ctx: RegenerationContext = { + siblingFiles: { "package.json": { state: "conflicted" } }, + }; + const plan = buildRegenerationPlan("package-lock.json", npmEco, ctx); + expect(plan.runnable).toBe(false); + expect(plan.sources).toContainEqual({ path: "package.json", state: "conflicted" }); + }); + + it("treats a source missing from siblingFiles as conflicted (not runnable by default)", () => { + const ctx: RegenerationContext = { siblingFiles: {} }; + const plan = buildRegenerationPlan("package-lock.json", npmEco, ctx); + expect(plan.runnable).toBe(false); + expect(plan.sources).toEqual([{ path: "package.json", state: "conflicted" }]); + }); + + it("treats a null/undefined context as every source conflicted", () => { + const planNull = buildRegenerationPlan("package-lock.json", npmEco, null); + expect(planNull.runnable).toBe(false); + expect(planNull.sources).toEqual([{ path: "package.json", state: "conflicted" }]); + + const planUndefined = buildRegenerationPlan("package-lock.json", npmEco, undefined); + expect(planUndefined.runnable).toBe(false); + }); + + // Ruling P-3 — yarn-berry vs classic: `.yarnrc.yml` is the berry marker. + describe("yarn-berry vs classic (Ruling P-3)", () => { + it("is runnable when both package.json and .yarnrc.yml are clean/resolved", () => { + const ctx: RegenerationContext = { + siblingFiles: { + "package.json": { state: "clean" }, + ".yarnrc.yml": { state: "clean" }, + }, + }; + const plan = buildRegenerationPlan("yarn.lock", yarnBerryEco, ctx); + expect(plan.runnable).toBe(true); + expect(plan.sources).toEqual( + expect.arrayContaining([ + { path: "package.json", state: "clean" }, + { path: ".yarnrc.yml", state: "clean" }, + ]), + ); + }); + + it("is not runnable when .yarnrc.yml is absent (classic yarn, no berry marker)", () => { + const ctx: RegenerationContext = { + siblingFiles: { "package.json": { state: "clean" } }, + }; + const plan = buildRegenerationPlan("yarn.lock", yarnBerryEco, ctx); + expect(plan.runnable).toBe(false); + expect(plan.sources).toContainEqual({ path: ".yarnrc.yml", state: "conflicted" }); + }); + + it("is not runnable when .yarnrc.yml is itself conflicted", () => { + const ctx: RegenerationContext = { + siblingFiles: { + "package.json": { state: "clean" }, + ".yarnrc.yml": { state: "conflicted" }, + }, + }; + const plan = buildRegenerationPlan("yarn.lock", yarnBerryEco, ctx); + expect(plan.runnable).toBe(false); + expect(plan.sources).toContainEqual({ path: ".yarnrc.yml", state: "conflicted" }); + }); + }); + + // Final review Finding 1 — a nested lockfile (e.g. `packages/x/package-lock.json`) + // matches the registry (intentional — see registry.test.ts) but nothing + // downstream is directory-aware: the CLI's regenerate-runner writes + // resolved sources at the worktree ROOT and reads the regenerated lockfile + // back from its nested path. Without this guard, a nested lockfile whose + // (root-relative) sourcesOfTruth all read "clean" comes back runnable, and + // executing that plan would silently regenerate the ROOT lockfile while + // reading back an untouched (still-"ours") nested one — a false success. + describe("nested (non-root) generated files (Finding 1, final review)", () => { + it("is not runnable even when every source of truth is clean", () => { + const ctx: RegenerationContext = { + siblingFiles: { "package.json": { state: "clean" } }, + }; + const plan = buildRegenerationPlan("packages/x/package-lock.json", npmEco, ctx); + expect(plan.runnable).toBe(false); + expect(plan.blockedReason).toBeDefined(); + expect(plan.blockedReason).toContain("packages/x/package-lock.json"); + }); + + it("carries no blockedReason for a root-level file (unaffected)", () => { + const ctx: RegenerationContext = { + siblingFiles: { "package.json": { state: "clean" } }, + }; + const plan = buildRegenerationPlan("package-lock.json", npmEco, ctx); + expect(plan.runnable).toBe(true); + expect(plan.blockedReason).toBeUndefined(); + }); + + it("still reports the (root-relative) source states even though it's blocked", () => { + const ctx: RegenerationContext = { siblingFiles: {} }; + const plan = buildRegenerationPlan("packages/x/package-lock.json", npmEco, ctx); + expect(plan.runnable).toBe(false); + expect(plan.sources).toEqual([{ path: "package.json", state: "conflicted" }]); + }); + }); +}); diff --git a/packages/core/src/__tests__/regenerate/registry.test.ts b/packages/core/src/__tests__/regenerate/registry.test.ts new file mode 100644 index 00000000..b7596bf1 --- /dev/null +++ b/packages/core/src/__tests__/regenerate/registry.test.ts @@ -0,0 +1,71 @@ +/** + * accuracy lot D — Registre des écosystèmes régénérables (v1). + * + * `findEcosystem` doit matcher chacun des 5 lockfiles v1 et ne rien + * retourner pour un fichier généré hors registre (`.min.js`). + */ + +import { describe, expect, it } from "vitest"; +import { findEcosystem, REGEN_ECOSYSTEMS } from "../../regenerate/registry.js"; + +describe("findEcosystem", () => { + it.each([ + ["package-lock.json", "npm"], + ["nested/dir/package-lock.json", "npm"], + ["pnpm-lock.yaml", "pnpm"], + ["yarn.lock", "yarn-berry"], + ["composer.lock", "composer"], + ["Cargo.lock", "cargo"], + ] as const)("matches %s → ecosystem %s", (path, ecosystemId) => { + const ecosystem = findEcosystem(path); + expect(ecosystem).toBeDefined(); + expect(ecosystem?.id).toBe(ecosystemId); + }); + + it("returns undefined for a non-registry generated file (.min.js)", () => { + expect(findEcosystem("public/dist/app.min.js")).toBeUndefined(); + }); + + it("returns undefined for an ordinary source file", () => { + expect(findEcosystem("src/index.ts")).toBeUndefined(); + }); + + describe("every v1 registry entry bakes in script-suppression", () => { + // Global constraint: no registry entry may omit script suppression. For + // npm/pnpm/composer that's an explicit flag on `command.args`. For + // yarn-berry and cargo, suppression is inherent to the command CHOICE + // itself (no flag exists to bolt onto a riskier command) — so instead of + // a boolean short-circuit, each ecosystem gets its own real assertion + // that fails if a future edit swaps in a script-running command. + it("npm carries --ignore-scripts", () => { + const eco = REGEN_ECOSYSTEMS.find((e) => e.id === "npm")!; + expect(eco.command.args).toContain("--ignore-scripts"); + }); + + it("pnpm carries --ignore-scripts", () => { + const eco = REGEN_ECOSYSTEMS.find((e) => e.id === "pnpm")!; + expect(eco.command.args).toContain("--ignore-scripts"); + }); + + it("composer carries --no-scripts", () => { + const eco = REGEN_ECOSYSTEMS.find((e) => e.id === "composer")!; + expect(eco.command.args).toContain("--no-scripts"); + }); + + it("yarn-berry carries --mode=update-lockfile (lockfile-only mode never runs install/lifecycle scripts)", () => { + const eco = REGEN_ECOSYSTEMS.find((e) => e.id === "yarn-berry")!; + expect(eco.command.args).toContain("--mode=update-lockfile"); + }); + + it("cargo is exactly generate-lockfile (resolves only, never invokes build.rs)", () => { + const eco = REGEN_ECOSYSTEMS.find((e) => e.id === "cargo")!; + expect(eco.command.args).toEqual(["generate-lockfile"]); + }); + }); + + it("v1 registry has exactly the 5 documented ecosystems", () => { + expect(REGEN_ECOSYSTEMS.map((e) => e.id).sort()).toEqual( + ["cargo", "composer", "npm", "pnpm", "yarn-berry"].sort(), + ); + }); +}); diff --git a/packages/core/src/__tests__/resolver.test.ts b/packages/core/src/__tests__/resolver.test.ts index 32f5ef07..5ae12776 100644 --- a/packages/core/src/__tests__/resolver.test.ts +++ b/packages/core/src/__tests__/resolver.test.ts @@ -611,7 +611,8 @@ describe("@gitwand/core resolve", () => { "name": "Foo" } }`; - const result = resolve(manifest, "build/manifest.json"); + // accuracy lot 1 — build/manifest.json est un chemin généré : opt-in requis + const result = resolve(manifest, "build/manifest.json", { resolveGeneratedFiles: true }); expect(result.hunks[0].type).toBe("value_only_change"); expect(result.hunks[0].confidence.label).toBe("high"); expect(result.stats.autoResolved).toBe(1); @@ -630,7 +631,8 @@ describe("@gitwand/core resolve", () => { "resolved": "https://registry.npmjs.org/foo/-/foo-3.3.0.tgz", "integrity": "sha512-xyz789ghi012" >>>>>>> master`; - const result = resolve(lockEntry, "package-lock.json"); + // accuracy lot 1 — lockfile : opt-in requis pour l'auto-résolution + const result = resolve(lockEntry, "package-lock.json", { resolveGeneratedFiles: true }); expect(result.hunks[0].type).toBe("value_only_change"); expect(result.stats.autoResolved).toBe(1); }); @@ -688,7 +690,13 @@ after`; >>>>>>> master`; const result = resolve(minJs, "public/dist/app.min.js", { minConfidence: "medium" }); expect(result.hunks[0].type).toBe("generated_file"); - expect(result.stats.autoResolved).toBe(1); + // accuracy lot 1 — classification conservée, application déclinée par défaut : + // un fichier généré se régénère, il ne se fusionne pas. + expect(result.stats.autoResolved).toBe(0); + expect(result.resolutions[0].resolutionReason).toContain("resolveGeneratedFiles"); + // L'ancien comportement reste disponible derrière l'opt-in. + const optIn = resolve(minJs, "public/dist/app.min.js", { minConfidence: "medium", resolveGeneratedFiles: true }); + expect(optIn.stats.autoResolved).toBe(1); }); it("reclassifies complex conflicts in package-lock.json as generated_file", () => { @@ -705,7 +713,7 @@ after`; >>>>>>> master`; const result = resolve(lockJson, "package-lock.json", { minConfidence: "medium" }); expect(result.hunks[0].type).toBe("generated_file"); - expect(result.stats.autoResolved).toBe(1); + expect(result.stats.autoResolved).toBe(0); // accuracy lot 1 — décliné par défaut }); it("reclassifies complex in build/manifest.json as generated_file", () => { @@ -723,7 +731,7 @@ after`; >>>>>>> master`; const result = resolve(manifest, "public/build/manifest.json", { minConfidence: "medium" }); expect(result.hunks[0].type).toBe("generated_file"); - expect(result.stats.autoResolved).toBe(1); + expect(result.stats.autoResolved).toBe(0); // accuracy lot 1 — décliné par défaut }); it("does NOT mark normal .ts files as generated", () => { diff --git a/packages/core/src/__tests__/resolvers/cargo.test.ts b/packages/core/src/__tests__/resolvers/cargo.test.ts index 33bb46e5..c37c5c7e 100644 --- a/packages/core/src/__tests__/resolvers/cargo.test.ts +++ b/packages/core/src/__tests__/resolvers/cargo.test.ts @@ -9,6 +9,9 @@ import { describe, it, expect } from "vitest"; import { resolve } from "../../resolver.js"; +// accuracy lot 1 — les lockfiles déclinent par défaut (fichiers générés) ; ces suites +// testent le résolveur sémantique lui-même, donc derrière l'opt-in resolveGeneratedFiles. + // ─── F25 — conflit [dependencies] ──────────────────────────── @@ -122,12 +125,12 @@ describe("F27 — Cargo.lock : merge de packages [[package]] (diff3)", () => { ].join("\n"); it("auto-résout via le resolver cargo", () => { - const result = resolve(lockConflict, "Cargo.lock"); + const result = resolve(lockConflict, "Cargo.lock", { resolveGeneratedFiles: true }); expect(result.stats.autoResolved).toBe(1); }); it("le résultat contient les deux nouveaux packages", () => { - const result = resolve(lockConflict, "Cargo.lock"); + const result = resolve(lockConflict, "Cargo.lock", { resolveGeneratedFiles: true }); const merged = result.mergedContent!; expect(merged).toContain("clap"); expect(merged).toContain("anyhow"); @@ -135,7 +138,7 @@ describe("F27 — Cargo.lock : merge de packages [[package]] (diff3)", () => { }); it("la raison mentionne Cargo.lock", () => { - const result = resolve(lockConflict, "Cargo.lock"); + const result = resolve(lockConflict, "Cargo.lock", { resolveGeneratedFiles: true }); expect(result.resolutions[0].resolutionReason).toMatch(/Cargo\.lock/i); }); }); @@ -169,7 +172,7 @@ describe("Cargo — détection du nom de fichier", () => { `version = "2.0.0"`, `>>>>>>> theirs`, ].join("\n"); - const result = resolve(input, "Cargo.lock"); + const result = resolve(input, "Cargo.lock", { resolveGeneratedFiles: true }); expect(result.resolutions[0].resolutionReason).toMatch(/\[cargo\]/i); }); }); diff --git a/packages/core/src/__tests__/resolvers/json-fragment.test.ts b/packages/core/src/__tests__/resolvers/json-fragment.test.ts new file mode 100644 index 00000000..6740798b --- /dev/null +++ b/packages/core/src/__tests__/resolvers/json-fragment.test.ts @@ -0,0 +1,134 @@ +/** + * accuracy lot E (lot E) — Fragments JSON fusionnés par clé. + * + * Les conflits réels de package.json / composer.json sont des fragments + * « "clé": valeur, » — le doc complet ne parse pas, et l'union ligne à ligne + * était mesurée juste 48–67 % du temps sur le corpus. Ici : 3-way par clé, + * arbitrage borné des contraintes de version, déclin sur tout le reste. + */ + +import { describe, expect, it } from "vitest"; +import { resolve } from "../../index.js"; +import { tryResolveJsonFragment, pickNewerRange } from "../../resolvers/json-fragment.js"; + +const conflict = (ours: string[], base: string[], theirs: string[]) => + ["<<<<<<< ours", ...ours, "||||||| base", ...base, "=======", ...theirs, ">>>>>>> theirs"].join("\n"); + +describe("pickNewerRange", () => { + it("compares same-operator ranges and picks the newer", () => { + expect(pickNewerRange('"^7.23.0"', '"^7.23.3"')).toBe('"^7.23.3"'); + expect(pickNewerRange('"~1.4.0"', '"~1.2.9"')).toBe('"~1.4.0"'); + expect(pickNewerRange('"2.0.0"', '"2.0.1"')).toBe('"2.0.1"'); + }); + it("declines mixed operators, wildcards and non-versions", () => { + expect(pickNewerRange('"^1.2.0"', '"~1.4.0"')).toBeNull(); + expect(pickNewerRange('"1.x"', '"1.2.0"')).toBeNull(); + expect(pickNewerRange('"workspace:*"', '"^3.3.8"')).toBeNull(); + }); +}); + +describe("json fragment merge (end-to-end through resolve)", () => { + it("takes the one-sided dependency bump — the vue @babel/parser shape", () => { + const content = [ + "{", + ' "dependencies": {', + conflict( + [' "@babel/parser": "^7.23.0",'], + [' "@babel/parser": "^7.23.0",'], + [' "@babel/parser": "^7.23.3",'], + ), + ' "source-map-js": "^1.0.2"', + " }", + "}", + ].join("\n"); + const result = resolve(content, "packages/compiler-core/package.json"); + expect(result.stats.autoResolved).toBe(1); + expect(result.mergedContent).toContain("^7.23.3"); + expect(result.mergedContent).not.toContain("^7.23.0"); + }); + + it("keeps both sides' distinct additions, alphabetically when both sides are sorted", () => { + const content = [ + "{", + ' "require": {', + conflict( + [' "aaa/pkg": "^1.0",', ' "mmm/pkg": "^2.0",'], + [], + [' "aaa/pkg": "^1.0",', ' "zzz/pkg": "^3.0",'], + ), + ' "php": "^8.2"', + " }", + "}", + ].join("\n"); + const result = resolve(content, "composer.json"); + expect(result.stats.autoResolved).toBe(1); + const merged = result.mergedContent!; + const iA = merged.indexOf("aaa/pkg"), iM = merged.indexOf("mmm/pkg"), iZ = merged.indexOf("zzz/pkg"); + expect(iA).toBeGreaterThan(-1); + expect(iM).toBeGreaterThan(iA); + expect(iZ).toBeGreaterThan(iM); + }); + + it("arbitrates a both-sides bump with the same operator to the newer range", () => { + const r = tryResolveJsonFragment( + [' "dep": "^1.0.0",'], + [' "dep": "^1.2.0",'], + [' "dep": "^1.4.1",'], + ); + expect(r.lines).toEqual([' "dep": "^1.4.1",']); + }); + + it("declines a real decision — same key, incomparable values (workspace:* migration)", () => { + const content = [ + "{", + ' "dependencies": {', + conflict( + [' "@vue/shared": "3.4.0-alpha.1",'], + [' "@vue/shared": "3.3.7",'], + [' "@vue/shared": "workspace:*",'], + ), + ' "end": "1"', + " }", + "}", + ].join("\n"); + const result = resolve(content, "package.json"); + expect(result.stats.autoResolved).toBe(0); + }); + + it("never produces a duplicate key from a two-sided constraint conflict", () => { + const content = [ + "{", + conflict( + [' "illuminate/reflection": "^12.0",'], + [], + [' "illuminate/reflection": "^13.0",'], + ), + ' "php": "^8.2"', + "}", + ].join("\n"); + const result = resolve(content, "composer.json"); + if (result.mergedContent !== null) { + const occurrences = result.mergedContent.split("illuminate/reflection").length - 1; + expect(occurrences).toBe(1); + expect(result.mergedContent).toContain("^13.0"); // même opérateur → la plus récente + } + }); + + it("declines fragments it does not fully understand (nested object lines)", () => { + const r = tryResolveJsonFragment( + [], + [' "scripts": {', ' "build": "tsc"', " },"], + [' "scripts": {', ' "build": "vite build"', " },"], + ); + expect(r.lines).toBeNull(); + }); + + it("handles deletion on one side, untouched on the other", () => { + const r = tryResolveJsonFragment( + [' "old-dep": "^1.0.0",', ' "kept": "^2.0.0",'], + [' "kept": "^2.0.0",'], + [' "old-dep": "^1.0.0",', ' "kept": "^2.1.0",'], + ); + expect(r.lines).toEqual([' "kept": "^2.1.0",']); + }); +}); diff --git a/packages/core/src/__tests__/resolvers/lockfile-npm.test.ts b/packages/core/src/__tests__/resolvers/lockfile-npm.test.ts index dc45e532..dd0dfd61 100644 --- a/packages/core/src/__tests__/resolvers/lockfile-npm.test.ts +++ b/packages/core/src/__tests__/resolvers/lockfile-npm.test.ts @@ -11,6 +11,9 @@ import { describe, it, expect } from "vitest"; import { resolve } from "../../resolver.js"; +// accuracy lot 1 — les lockfiles déclinent par défaut (fichiers générés) ; ces suites +// testent le résolveur sémantique lui-même, donc derrière l'opt-in resolveGeneratedFiles. + // ─── helpers ────────────────────────────────────────────────────────────────── @@ -59,18 +62,18 @@ describe("F1 — package-lock.json : package ajouté d'un seul côté (diff3)", ].join("\n"); it("auto-résout via le resolver lockfile-npm", () => { - const result = resolve(input, "package-lock.json"); + const result = resolve(input, "package-lock.json", { resolveGeneratedFiles: true }); expect(result.stats.autoResolved).toBe(1); }); it("le résultat contient le package ajouté", () => { - const result = resolve(input, "package-lock.json"); + const result = resolve(input, "package-lock.json", { resolveGeneratedFiles: true }); expect(result.mergedContent).toContain("lodash"); expect(result.mergedContent).toContain("react"); }); it("la raison mentionne [lockfile-npm]", () => { - const result = resolve(input, "package-lock.json"); + const result = resolve(input, "package-lock.json", { resolveGeneratedFiles: true }); expect(result.resolutions[0].resolutionReason).toMatch(/\[lockfile-npm\]/i); }); }); @@ -105,11 +108,11 @@ describe("F2 — package-lock.json : même package, version différente (diff3)" ].join("\n"); it("ne lève pas d'exception", () => { - expect(() => resolve(input, "package-lock.json")).not.toThrow(); + expect(() => resolve(input, "package-lock.json", { resolveGeneratedFiles: true })).not.toThrow(); }); it("la raison mentionne [lockfile-npm]", () => { - const result = resolve(input, "package-lock.json"); + const result = resolve(input, "package-lock.json", { resolveGeneratedFiles: true }); expect(result.resolutions[0].resolutionReason).toMatch(/\[lockfile-npm\]/i); }); }); @@ -134,11 +137,11 @@ describe("F3 — package-lock.json minimal : ne plante pas", () => { ].join("\n"); it("ne lève pas d'exception", () => { - expect(() => resolve(input, "package-lock.json")).not.toThrow(); + expect(() => resolve(input, "package-lock.json", { resolveGeneratedFiles: true })).not.toThrow(); }); it("produit un résultat avec au moins un hunk", () => { - const result = resolve(input, "package-lock.json"); + const result = resolve(input, "package-lock.json", { resolveGeneratedFiles: true }); expect(result.hunks.length).toBeGreaterThanOrEqual(1); }); }); @@ -173,19 +176,19 @@ describe("F4 — package-lock.json : packages différents ajoutés des deux côt ].join("\n"); it("auto-résout via le resolver lockfile-npm", () => { - const result = resolve(input, "package-lock.json"); + const result = resolve(input, "package-lock.json", { resolveGeneratedFiles: true }); expect(result.stats.autoResolved).toBe(1); }); it("le résultat contient les deux packages", () => { - const result = resolve(input, "package-lock.json"); + const result = resolve(input, "package-lock.json", { resolveGeneratedFiles: true }); expect(result.mergedContent).toContain("axios"); expect(result.mergedContent).toContain("date-fns"); expect(result.mergedContent).toContain("react"); }); it("la raison mentionne [lockfile-npm]", () => { - const result = resolve(input, "package-lock.json"); + const result = resolve(input, "package-lock.json", { resolveGeneratedFiles: true }); expect(result.resolutions[0].resolutionReason).toMatch(/\[lockfile-npm\]/i); }); }); @@ -213,12 +216,12 @@ describe("F5 — package-lock.json : détection du nom de fichier", () => { ].join("\n"); it("le nom package-lock.json active le bon resolver", () => { - const result = resolve(input, "package-lock.json"); + const result = resolve(input, "package-lock.json", { resolveGeneratedFiles: true }); expect(result.resolutions[0].resolutionReason).toMatch(/\[lockfile-npm\]/i); }); it("le nom dans un sous-dossier est aussi détecté", () => { - const result = resolve(input, "apps/frontend/package-lock.json"); + const result = resolve(input, "apps/frontend/package-lock.json", { resolveGeneratedFiles: true }); expect(result.resolutions[0].resolutionReason).toMatch(/\[lockfile-npm\]/i); }); }); diff --git a/packages/core/src/__tests__/resolvers/lockfile-pnpm.test.ts b/packages/core/src/__tests__/resolvers/lockfile-pnpm.test.ts index ece95740..0ee50b66 100644 --- a/packages/core/src/__tests__/resolvers/lockfile-pnpm.test.ts +++ b/packages/core/src/__tests__/resolvers/lockfile-pnpm.test.ts @@ -11,6 +11,9 @@ import { describe, it, expect } from "vitest"; import { resolve } from "../../resolver.js"; +// accuracy lot 1 — les lockfiles déclinent par défaut (fichiers générés) ; ces suites +// testent le résolveur sémantique lui-même, donc derrière l'opt-in resolveGeneratedFiles. + // ─── base lockfile ───────────────────────────────────────────────────────────── @@ -54,18 +57,18 @@ describe("F1 — pnpm-lock.yaml : package ajouté dans packages: d'un seul côt ].join("\n"); it("auto-résout via le resolver lockfile-pnpm", () => { - const result = resolve(input, "pnpm-lock.yaml"); + const result = resolve(input, "pnpm-lock.yaml", { resolveGeneratedFiles: true }); expect(result.stats.autoResolved).toBe(1); }); it("le résultat contient le package ajouté", () => { - const result = resolve(input, "pnpm-lock.yaml"); + const result = resolve(input, "pnpm-lock.yaml", { resolveGeneratedFiles: true }); expect(result.mergedContent).toContain("axios"); expect(result.mergedContent).toContain("vue"); }); it("la raison mentionne [lockfile-pnpm]", () => { - const result = resolve(input, "pnpm-lock.yaml"); + const result = resolve(input, "pnpm-lock.yaml", { resolveGeneratedFiles: true }); expect(result.resolutions[0].resolutionReason).toMatch(/\[lockfile-pnpm\]/i); }); }); @@ -87,11 +90,11 @@ describe("F2 — pnpm-lock.yaml : même package, version différente (diff3)", ( ].join("\n"); it("ne lève pas d'exception", () => { - expect(() => resolve(input, "pnpm-lock.yaml")).not.toThrow(); + expect(() => resolve(input, "pnpm-lock.yaml", { resolveGeneratedFiles: true })).not.toThrow(); }); it("la raison mentionne [lockfile-pnpm]", () => { - const result = resolve(input, "pnpm-lock.yaml"); + const result = resolve(input, "pnpm-lock.yaml", { resolveGeneratedFiles: true }); expect(result.resolutions[0].resolutionReason).toMatch(/\[lockfile-pnpm\]/i); }); }); @@ -112,11 +115,11 @@ describe("F3 — pnpm-lock.yaml minimal : ne plante pas", () => { ].join("\n"); it("ne lève pas d'exception", () => { - expect(() => resolve(input, "pnpm-lock.yaml")).not.toThrow(); + expect(() => resolve(input, "pnpm-lock.yaml", { resolveGeneratedFiles: true })).not.toThrow(); }); it("produit un résultat avec au moins un hunk", () => { - const result = resolve(input, "pnpm-lock.yaml"); + const result = resolve(input, "pnpm-lock.yaml", { resolveGeneratedFiles: true }); expect(result.hunks.length).toBeGreaterThanOrEqual(1); }); }); @@ -159,17 +162,17 @@ packages: ].join("\n"); it("auto-résout via le resolver lockfile-pnpm", () => { - const result = resolve(input, "pnpm-lock.yaml"); + const result = resolve(input, "pnpm-lock.yaml", { resolveGeneratedFiles: true }); expect(result.stats.autoResolved).toBe(1); }); it("le résultat contient la dépendance ajoutée dans importers", () => { - const result = resolve(input, "pnpm-lock.yaml"); + const result = resolve(input, "pnpm-lock.yaml", { resolveGeneratedFiles: true }); expect(result.mergedContent).toContain("axios"); }); it("la raison mentionne [lockfile-pnpm]", () => { - const result = resolve(input, "pnpm-lock.yaml"); + const result = resolve(input, "pnpm-lock.yaml", { resolveGeneratedFiles: true }); expect(result.resolutions[0].resolutionReason).toMatch(/\[lockfile-pnpm\]/i); }); }); @@ -193,12 +196,12 @@ describe("F5 — pnpm-lock.yaml : détection du nom de fichier", () => { ].join("\n"); it("le nom pnpm-lock.yaml active le bon resolver", () => { - const result = resolve(input, "pnpm-lock.yaml"); + const result = resolve(input, "pnpm-lock.yaml", { resolveGeneratedFiles: true }); expect(result.resolutions[0].resolutionReason).toMatch(/\[lockfile-pnpm\]/i); }); it("le nom dans un sous-dossier est aussi détecté", () => { - const result = resolve(input, "packages/core/pnpm-lock.yaml"); + const result = resolve(input, "packages/core/pnpm-lock.yaml", { resolveGeneratedFiles: true }); expect(result.resolutions[0].resolutionReason).toMatch(/\[lockfile-pnpm\]/i); }); }); diff --git a/packages/core/src/__tests__/resolvers/lockfile-yarn.test.ts b/packages/core/src/__tests__/resolvers/lockfile-yarn.test.ts index 5a347ffd..5bd9686c 100644 --- a/packages/core/src/__tests__/resolvers/lockfile-yarn.test.ts +++ b/packages/core/src/__tests__/resolvers/lockfile-yarn.test.ts @@ -11,6 +11,9 @@ import { describe, it, expect } from "vitest"; import { resolve } from "../../resolver.js"; +// accuracy lot 1 — les lockfiles déclinent par défaut (fichiers générés) ; ces suites +// testent le résolveur sémantique lui-même, donc derrière l'opt-in resolveGeneratedFiles. + // ─── base lockfile ───────────────────────────────────────────────────────────── @@ -49,19 +52,19 @@ axios@^1.0.0: ].join("\n"); it("auto-résout via le resolver lockfile-yarn", () => { - const result = resolve(input, "yarn.lock"); + const result = resolve(input, "yarn.lock", { resolveGeneratedFiles: true }); expect(result.stats.autoResolved).toBe(1); }); it("le résultat contient le bloc de package ajouté", () => { - const result = resolve(input, "yarn.lock"); + const result = resolve(input, "yarn.lock", { resolveGeneratedFiles: true }); expect(result.mergedContent).toContain("axios@^1.0.0:"); expect(result.mergedContent).toContain("react@^18.0.0:"); expect(result.mergedContent).toContain("vue@^3.0.0:"); }); it("la raison mentionne [lockfile-yarn]", () => { - const result = resolve(input, "yarn.lock"); + const result = resolve(input, "yarn.lock", { resolveGeneratedFiles: true }); expect(result.resolutions[0].resolutionReason).toMatch(/\[lockfile-yarn\]/i); }); }); @@ -83,11 +86,11 @@ describe("F2 — yarn.lock : même bloc, version différente → prefer theirs ( ].join("\n"); it("ne lève pas d'exception", () => { - expect(() => resolve(input, "yarn.lock")).not.toThrow(); + expect(() => resolve(input, "yarn.lock", { resolveGeneratedFiles: true })).not.toThrow(); }); it("la raison mentionne [lockfile-yarn]", () => { - const result = resolve(input, "yarn.lock"); + const result = resolve(input, "yarn.lock", { resolveGeneratedFiles: true }); expect(result.resolutions[0].resolutionReason).toMatch(/\[lockfile-yarn\]/i); }); }); @@ -108,11 +111,11 @@ describe("F3 — yarn.lock minimal : ne plante pas", () => { ].join("\n"); it("ne lève pas d'exception", () => { - expect(() => resolve(input, "yarn.lock")).not.toThrow(); + expect(() => resolve(input, "yarn.lock", { resolveGeneratedFiles: true })).not.toThrow(); }); it("produit un résultat avec au moins un hunk", () => { - const result = resolve(input, "yarn.lock"); + const result = resolve(input, "yarn.lock", { resolveGeneratedFiles: true }); expect(result.hunks.length).toBeGreaterThanOrEqual(1); }); }); @@ -145,12 +148,12 @@ date-fns@^3.0.0: ].join("\n"); it("auto-résout avec les deux packages", () => { - const result = resolve(input, "yarn.lock"); + const result = resolve(input, "yarn.lock", { resolveGeneratedFiles: true }); expect(result.stats.autoResolved).toBe(1); }); it("le résultat contient les deux packages ajoutés", () => { - const result = resolve(input, "yarn.lock"); + const result = resolve(input, "yarn.lock", { resolveGeneratedFiles: true }); expect(result.mergedContent).toContain("lodash@^4.0.0:"); expect(result.mergedContent).toContain("date-fns@^3.0.0:"); expect(result.mergedContent).toContain("react@^18.0.0:"); @@ -158,7 +161,7 @@ date-fns@^3.0.0: }); it("la raison mentionne [lockfile-yarn]", () => { - const result = resolve(input, "yarn.lock"); + const result = resolve(input, "yarn.lock", { resolveGeneratedFiles: true }); expect(result.resolutions[0].resolutionReason).toMatch(/\[lockfile-yarn\]/i); }); }); @@ -184,12 +187,12 @@ zod@^3.0.0: ].join("\n"); it("le nom yarn.lock active le bon resolver", () => { - const result = resolve(input, "yarn.lock"); + const result = resolve(input, "yarn.lock", { resolveGeneratedFiles: true }); expect(result.resolutions[0].resolutionReason).toMatch(/\[lockfile-yarn\]/i); }); it("le nom dans un sous-dossier est aussi détecté", () => { - const result = resolve(input, "apps/desktop/yarn.lock"); + const result = resolve(input, "apps/desktop/yarn.lock", { resolveGeneratedFiles: true }); expect(result.resolutions[0].resolutionReason).toMatch(/\[lockfile-yarn\]/i); }); }); diff --git a/packages/core/src/__tests__/stats/tiers.test.ts b/packages/core/src/__tests__/stats/tiers.test.ts index 96f1cdcc..22e5eb96 100644 --- a/packages/core/src/__tests__/stats/tiers.test.ts +++ b/packages/core/src/__tests__/stats/tiers.test.ts @@ -36,10 +36,11 @@ describe("summarizeTiers — mapping des tiers", () => { whitespace_only: 1, reorder_only: 1, insertion_at_boundary: 1, value_only_change: 1, generated_file: 1, })); - expect(s.byTier.trivial).toBe(12); + expect(s.byTier.trivial).toBe(11); expect(s.byTier.advancedDeterministic).toBe(0); expect(s.byTier.model).toBe(0); - expect(s.byTier.unresolved).toBe(0); + // accuracy lot 1 — generated_file décline par défaut (se régénère, ne se fusionne pas) + expect(s.byTier.unresolved).toBe(1); }); it("classe refactoring_aware_merge et token_level_merge dans 'advancedDeterministic'", () => { diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index be63be23..c1bbcbe5 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -244,6 +244,13 @@ export interface GitWandrcConfig { * S'ajoutent aux built-ins (lockfiles, bundles, `dist/`…) sans les remplacer. */ generatedFiles?: string[]; + /** + * accuracy lot 1 — Autoriser l'auto-résolution des fichiers générés (défaut: false). + * Par défaut le moteur décline : un fichier généré se régénère, il ne se + * fusionne pas. Convention de dépôt, donc configurée ici plutôt qu'en + * réglage d'application. + */ + resolveGeneratedFiles?: boolean; /** * v2.4 — Validation post-merge. * - `level: "balanced"` (défaut) : marqueurs résiduels + syntaxe + parse-tree @@ -413,6 +420,11 @@ export function parseGitwandrc(json: string): GitWandrcConfig | null { } } + // accuracy lot 1 — Auto-résolution des fichiers générés (opt-in booléen strict). + if (typeof parsed.resolveGeneratedFiles === "boolean") { + result.resolveGeneratedFiles = parsed.resolveGeneratedFiles; + } + // v2.4 — Validation post-merge. const validLevels: ValidationLevel[] = ["balanced", "strict", "off"]; if (parsed.validation && typeof parsed.validation === "object") { diff --git a/packages/core/src/conventions/derive.ts b/packages/core/src/conventions/derive.ts new file mode 100644 index 00000000..4bdff54d --- /dev/null +++ b/packages/core/src/conventions/derive.ts @@ -0,0 +1,130 @@ +/** + * GitWand — Dérivation des conventions (accuracy lot F). + * + * Fonction PURE : des observations en entrée (chaque fichier en conflit d'un + * merge historique, rejoué sous des règles candidates), des verdicts en sortie. + * Ni git, ni fs, ni horloge — le runner côté appelant fournit tout, y compris + * `derivedAt` et `engineVersion`, pour rester rejouable et testable. + */ + +import { + MAX_REFUTED, + MIN_AGREEMENT, + MIN_SAMPLES, + type ConventionObservation, + type ConventionVerdict, + type RepoConventions, +} from "./types.js"; + +interface Tally { + samples: number; + matches: Record; +} + +function tally(observations: ConventionObservation[], question: ConventionObservation["question"]): Tally { + const t: Tally = { samples: 0, matches: {} }; + for (const obs of observations) { + if (obs.question !== question) continue; + t.samples++; + for (const [candidate, matched] of Object.entries(obs.candidates)) { + t.matches[candidate] = (t.matches[candidate] ?? 0) + (matched ? 1 : 0); + } + } + return t; +} + +const rate = (t: Tally, candidate: string): number => + t.samples === 0 ? 0 : (t.matches[candidate] ?? 0) / t.samples; + +/** + * Dérive les verdicts. Chaque question a sa propre logique, mais toutes + * partagent les planchers : `samples >= MIN_SAMPLES`, et un verdict n'est + * émis que s'il est net (confirmé ≥ MIN_AGREEMENT, ou réfuté ≤ MAX_REFUTED + * quand le verdict est « l'inverse du candidat mesurable »). + */ +export function deriveConventions( + observations: ConventionObservation[], + meta: { mergesReplayed: number; derivedAt: string; engineVersion: string }, +): RepoConventions { + const conventions: RepoConventions = { + evidence: { + mergesReplayed: meta.mergesReplayed, + conflictedFiles: observations.length, + derivedAt: meta.derivedAt, + engineVersion: meta.engineVersion, + }, + }; + + // ── generatedFiles ───────────────────────────────────────────────────────── + // Un seul candidat mesurable : « merge » (la fusion sémantique correspond au + // commit). « regenerate » est son inverse — on ne peut pas produire la sortie + // d'un outil, mais on peut constater que la fusion ne la reproduit jamais. + { + const t = tally(observations, "generatedFiles"); + if (t.samples >= MIN_SAMPLES) { + const merge = rate(t, "merge"); + if (merge >= MIN_AGREEMENT) { + conventions.generatedFiles = { verdict: "merge", samples: t.samples, agreement: merge }; + } else if (merge <= MAX_REFUTED) { + conventions.generatedFiles = { verdict: "regenerate", samples: t.samples, agreement: 1 - merge }; + } + // Entre les deux : preuve contradictoire → pas de verdict. + } + } + + // ── changelog ────────────────────────────────────────────────────────────── + // Deux candidats mesurables : « union » (la fusion des sections correspond) + // et « target-structure » (le fichier livré est le côté cible tel quel). + // « tool-rebuilt » est le constat que NI l'un NI l'autre ne correspond. + { + const t = tally(observations, "changelog"); + if (t.samples >= MIN_SAMPLES) { + const union = rate(t, "union"); + const target = rate(t, "target-structure"); + let verdict: ConventionVerdict<"union" | "target-structure" | "tool-rebuilt"> | undefined; + if (union >= MIN_AGREEMENT) { + verdict = { verdict: "union", samples: t.samples, agreement: union }; + } else if (target >= MIN_AGREEMENT) { + verdict = { verdict: "target-structure", samples: t.samples, agreement: target }; + } else if (union <= MAX_REFUTED && target <= MAX_REFUTED) { + verdict = { verdict: "tool-rebuilt", samples: t.samples, agreement: 1 - Math.max(union, target) }; + } + if (verdict) conventions.changelog = verdict; + } + } + + // ── pathPolicies ─────────────────────────────────────────────────────────── + // Par famille de chemins (bucket), deux candidats : le fichier livré est le + // côté ours tel quel, ou le côté theirs tel quel. Dérivées et rapportées — + // jamais appliquées silencieusement (v1) : le CLI en fait une suggestion de + // `patternOverrides` que l'utilisateur promeut en `.gitwandrc` s'il veut. + { + const byBucket = new Map(); + for (const obs of observations) { + if (obs.question !== "pathPolicy" || !obs.bucket) continue; + const t = byBucket.get(obs.bucket) ?? { samples: 0, matches: {} }; + t.samples++; + for (const [candidate, matched] of Object.entries(obs.candidates)) { + t.matches[candidate] = (t.matches[candidate] ?? 0) + (matched ? 1 : 0); + } + byBucket.set(obs.bucket, t); + } + const policies: NonNullable = []; + for (const [bucket, t] of byBucket) { + if (t.samples < MIN_SAMPLES) continue; + const ours = rate(t, "prefer-ours"); + const theirs = rate(t, "prefer-theirs"); + // Un seul des deux peut être net — s'ils le sont tous les deux, les + // fichiers étaient identiques des deux côtés et la preuve ne vaut rien. + if (ours >= MIN_AGREEMENT && theirs < MIN_AGREEMENT) { + policies.push({ glob: bucket, policy: "prefer-ours", samples: t.samples, agreement: ours }); + } else if (theirs >= MIN_AGREEMENT && ours < MIN_AGREEMENT) { + policies.push({ glob: bucket, policy: "prefer-theirs", samples: t.samples, agreement: theirs }); + } + } + policies.sort((a, b) => b.samples - a.samples || b.agreement - a.agreement); + if (policies.length > 0) conventions.pathPolicies = policies.slice(0, 8); + } + + return conventions; +} diff --git a/packages/core/src/conventions/types.ts b/packages/core/src/conventions/types.ts new file mode 100644 index 00000000..a4c12300 --- /dev/null +++ b/packages/core/src/conventions/types.ts @@ -0,0 +1,83 @@ +/** + * GitWand — Conventions de dépôt (accuracy lot F) + * + * Une convention n'est pas une préférence déclarée : c'est une politique + * MESURÉE sur l'historique de merges du dépôt lui-même. La dérivation rejoue + * les merges passés sous des règles candidates et score laquelle correspond à + * ce que l'équipe a réellement commité. + * + * Deux garde-fous structurels : + * - un verdict n'existe qu'au-dessus d'un plancher de preuve (échantillons et + * taux d'accord) — en dessous, le champ est absent et le moteur garde ses + * défauts calibrés sur le corpus public ; + * - un `.gitwandrc` explicite gagne TOUJOURS sur une convention dérivée : une + * équipe qui déclare sa politique n'est jamais contredite par une inférence. + */ + +/** Verdict d'une question, avec sa preuve. */ +export interface ConventionVerdict { + verdict: V; + /** Nombre d'observations qui ont porté sur cette question. */ + samples: number; + /** Part des observations en accord avec le verdict (0–1). */ + agreement: number; +} + +export interface RepoConventions { + /** Traçabilité de la dérivation — les consommateurs DOIVENT l'afficher. */ + evidence: { + mergesReplayed: number; + conflictedFiles: number; + derivedAt: string; + engineVersion: string; + }; + /** + * Les fichiers générés de ce dépôt sont-ils re-générés après merge + * (`regenerate` — la sortie d'un outil, jamais fusionnée) ou réellement + * fusionnés (`merge` — l'auto-résolution correspond à ce qui est livré) ? + */ + generatedFiles?: ConventionVerdict<"regenerate" | "merge">; + /** + * Le changelog de ce dépôt : l'union des sections correspond-elle à ce qui + * est livré (`union`), la structure de la branche cible gagne-t-elle + * (`target-structure`), ou est-il reconstruit par l'outillage de release + * (`tool-rebuilt` — aucune fusion textuelle ne le reproduit) ? + */ + changelog?: ConventionVerdict<"union" | "target-structure" | "tool-rebuilt">; + /** + * Identité de version (champ `version`, `const VERSION`…) : la branche cible + * la garde-t-elle (`target-wins`) ? Non dérivée en v1 — champ réservé, le + * moteur applique la règle du lot C (cible) mesurée sur le corpus public. + */ + versionIdentity?: ConventionVerdict<"target-wins" | "newest-wins">; + /** + * Politiques par famille de chemins découvertes dans l'historique (top-N, + * plancher de preuve). v1 : dérivées et RAPPORTÉES (suggestion de + * `patternOverrides` pour `.gitwandrc`), jamais appliquées silencieusement. + */ + pathPolicies?: Array<{ + glob: string; + policy: "prefer-ours" | "prefer-theirs"; + samples: number; + agreement: number; + }>; +} + +/** + * Une observation = un fichier en conflit d'un merge historique, rejoué. + * `candidates` associe chaque règle candidate à « sa sortie correspond-elle + * octet à octet à ce que l'équipe a commité ? ». + */ +export interface ConventionObservation { + question: "generatedFiles" | "changelog" | "pathPolicy"; + path: string; + /** Pour pathPolicy : la famille de chemins (ex: "**\/*.md"). */ + bucket?: string; + candidates: Record; +} + +/** Planchers de preuve — en dessous, pas de verdict. */ +export const MIN_SAMPLES = 5; +export const MIN_AGREEMENT = 0.8; +/** Symétrique : un candidat est réfuté quand son accord tombe sous ce seuil. */ +export const MAX_REFUTED = 0.2; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e3d65642..7def6e28 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,6 +29,20 @@ export { } from "./structural/index.js"; export type { StructuralLoaderOptions, SupportedLanguage } from "./structural/index.js"; export { parseConflictMarkers, classifyConflict } from "./parser.js"; +// accuracy lot F — conventions de dépôt mesurées sur l'historique +export { deriveConventions } from "./conventions/derive.js"; +export { + MIN_AGREEMENT, + MIN_SAMPLES, + type ConventionObservation, + type RepoConventions, +} from "./conventions/types.js"; +export { isGeneratedFile, stripVolatileValues } from "./resolver/generated-detection.js"; +export { isChangelogFile } from "./resolver/validation.js"; + +// accuracy lot D — Regenerate tier: core emits a plan, never executes it +export { findEcosystem, REGEN_ECOSYSTEMS, type RegenEcosystem } from "./regenerate/registry.js"; +export { buildRegenerationPlan, type RegenerationPlan } from "./regenerate/plan.js"; export { mergeNonOverlapping, computeDiff, lcs } from "./diff.js"; // v2.1 — nouveaux backends diff exposés @@ -107,6 +121,9 @@ export type { ConfidenceScore, HunkResolution, GitWandOptions, + MergeContext, + // accuracy lot D — Regenerate tier + RegenerationContext, // Phase 7.1 DecisionTrace, TraceStep, diff --git a/packages/core/src/patterns/utils.ts b/packages/core/src/patterns/utils.ts index 2069fcda..edac0fcc 100644 --- a/packages/core/src/patterns/utils.ts +++ b/packages/core/src/patterns/utils.ts @@ -417,6 +417,45 @@ function compareSemver(a: [number, number, number, boolean], b: [number, number, * (semver, ou datetime ISO où l'ordre lexicographique est chronologique) — * pour les hashes et autres valeurs ambiguës on retombe sur la politique. */ +/** + * accuracy lot C — Y a-t-il, parmi les paires de tokens qui diffèrent, au moins une + * paire « de type version » qui n'est PAS ordonnable proprement ? + * + * C'est exactement le cas mesuré comme faux sur le corpus benchmark/ : deux + * côtés fixent un scalaire de version à des valeurs différentes dont l'une ne + * parse pas en semver (`'13.x-dev'`, `'2.0-beta'`, `dev-master`). L'ancien + * comportement retombait sur la politique (prefer-theirs) — un pari. Ces + * paires sont une décision : la branche cible gagne quand le contexte est + * connu, et on propose au lieu d'appliquer quand il ne l'est pas. + * + * Délibérément conservateur : un token n'est « versionish » que s'il ressemble + * réellement à une version (chiffres pointés, wildcard x/*, suffixe -dev/-beta…). + * Les hashes et timestamps ne matchent pas et gardent leur traitement existant. + */ +const RE_VERSIONISH_TOKEN = /^["']?v?\d+\.(\d+|[x*])(\.(\d+|[x*]))?([._-][0-9A-Za-z.]+)?["']?$/; + +export function hasUnorderableVersionPair( + oursLines: string[], + theirsLines: string[], +): boolean { + if (oursLines.length !== theirsLines.length) return false; + for (let i = 0; i < oursLines.length; i++) { + const oursTokens = tokenizeLineQuoteAware(oursLines[i]); + const theirsTokens = tokenizeLineQuoteAware(theirsLines[i]); + if (oursTokens.length !== theirsTokens.length) continue; + for (let j = 0; j < oursTokens.length; j++) { + const a = oursTokens[j]; + const b = theirsTokens[j]; + if (a === b) continue; + const bothSemver = parseSemver(a) !== null && parseSemver(b) !== null; + const bothDatetime = RE_DATETIME_TOKEN.test(a) && RE_DATETIME_TOKEN.test(b); + if (bothSemver || bothDatetime) continue; // ordonnable → pickNewerSemverSide gère + if (RE_VERSIONISH_TOKEN.test(a) || RE_VERSIONISH_TOKEN.test(b)) return true; + } + } + return false; +} + export function pickNewerSemverSide( oursLines: string[], theirsLines: string[], diff --git a/packages/core/src/regenerate/plan.ts b/packages/core/src/regenerate/plan.ts new file mode 100644 index 00000000..8ed9abeb --- /dev/null +++ b/packages/core/src/regenerate/plan.ts @@ -0,0 +1,85 @@ +/** + * accuracy lot D — Émission d'un plan de régénération (fonction pure, zéro I/O). + * + * Le moteur n'exécute jamais rien : `buildRegenerationPlan` se contente de + * lire l'état (déjà connu de l'appelant, voir `RegenerationContext` dans + * `types.ts`) des fichiers "sources de vérité" d'un écosystème régénérable, + * et de décider si régénérer serait sûr (`runnable`). L'exécution de la + * commande elle-même appartient toujours à l'appelant. + */ + +import type { RegenerationContext } from "../types.js"; +import type { RegenEcosystem } from "./registry.js"; + +/** Ce que le moteur émet à la place d'une résolution ; l'appelant décide de l'exécuter. */ +export interface RegenerationPlan { + file: string; + ecosystem: RegenEcosystem["id"]; + /** Chaque source de vérité et comment elle a été réglée (clean | resolved | conflicted). */ + sources: Array<{ path: string; state: "clean" | "resolved" | "conflicted"; confidence?: number }>; + /** Le plan n'est runnable que si aucune source n'est "conflicted" (absente = conflicted). */ + runnable: boolean; + /** + * Final-review Finding 1 — renseigné uniquement quand `runnable` est forcé à + * `false` pour une raison AUTRE que l'état d'une source (aujourd'hui : + * fichier généré niché dans un sous-répertoire). Absent dans tous les + * autres cas — ne pas s'y fier pour distinguer "runnable" de "non-runnable", + * seul `runnable` fait foi ; ce champ n'existe que pour donner une raison + * lisible quand il y en a une plus précise que "une source est conflictuelle". + */ + blockedReason?: string; +} + +/** + * Construit le plan de régénération pour `file` dans l'écosystème `ecosystem`, + * à partir de l'état des fichiers voisins fourni par l'appelant (`context`). + * + * Une source de vérité absente de `context.siblingFiles` est traitée comme + * "conflicted" (état inconnu = pas sûr de régénérer) — jamais runnable par défaut. + * + * Final-review Finding 1 — fichiers générés NICHÉS (non à la racine du dépôt). + * `findEcosystem`/`GENERATED_FILE_PATTERNS` matchent volontairement les + * lockfiles nichés (ex: `packages/x/package-lock.json` → npm, voir + * `registry.ts` et son test) mais rien en aval n'est conscient du répertoire : + * le runner CLI écrit chaque source de vérité résolue à la RACINE du worktree + * jetable, y lance l'installeur avec `cwd` = cette racine, puis relit le + * fichier régénéré à son chemin niché — jamais touché par un install lancé à + * la racine. Résultat possible sans cette garde : un plan jugé runnable qui + * régénère silencieusement le lockfile RACINE pendant que le lockfile niché + * (resté tel quel, encore "ours") est relu, valide car simplement périmé, et + * présenté comme un succès de régénération — exactement le mode d'échec + * (sortie fausse mais présentée comme fiable) que ce lot existe pour éliminer. + * Bloqué ici, une seule fois, pour TOUS les appelants (`resolver/index.ts` + * pass 1, la pass 2 du CLI, le reporting MCP, le harness de mesure) plutôt que + * dupliqué dans chacun — voir le brief de la fix wave finale. + */ +export function buildRegenerationPlan( + file: string, + ecosystem: RegenEcosystem, + context: RegenerationContext | null | undefined, +): RegenerationPlan { + const siblingFiles = context?.siblingFiles ?? {}; + + const sources = ecosystem.sourcesOfTruth.map((path) => { + const sibling = siblingFiles[path]; + if (!sibling) { + return { path, state: "conflicted" as const }; + } + return { path, state: sibling.state, confidence: sibling.confidence }; + }); + + const normalizedFile = file.replace(/\\/g, "/"); + if (normalizedFile.includes("/")) { + return { + file, + ecosystem: ecosystem.id, + sources, + runnable: false, + blockedReason: `regeneration is not supported for a generated file nested in a subdirectory ("${file}") — resolve it manually or re-run your installer from that directory.`, + }; + } + + const runnable = sources.every((source) => source.state === "clean" || source.state === "resolved"); + + return { file, ecosystem: ecosystem.id, sources, runnable }; +} diff --git a/packages/core/src/regenerate/registry.ts b/packages/core/src/regenerate/registry.ts new file mode 100644 index 00000000..afec1279 --- /dev/null +++ b/packages/core/src/regenerate/registry.ts @@ -0,0 +1,96 @@ +/** + * accuracy lot D — Registre des écosystèmes régénérables (v1). + * + * Le moteur n'exécute jamais de commande : il se contente de savoir, pour un + * chemin de fichier généré donné, QUELLE commande le régénérerait et QUELS + * fichiers doivent être propres au préalable (`sourcesOfTruth`). L'exécution + * elle-même appartient toujours à l'appelant (CLI aujourd'hui, desktop plus + * tard) — voir `plan.ts` et le brief de la tâche. + * + * v1 est délibérément restreint aux écosystèmes qui exposent un mode + * "lockfile-only" ET une façon de couper les scripts de cycle de vie : + * `go.sum`, `Gemfile.lock`, `poetry.lock` et la régénération de snapshots + * (`jest -u`) sont hors scope v1 (exécutent du code arbitraire du dépôt). + * + * Contrainte globale : les flags de suppression de scripts sont des + * CONSTANTES du registre, jamais quelque chose que l'appelant peut + * surcharger. Une entrée sans eux ne doit pas passer la revue de code. + */ + +/** accuracy lot D — un écosystème que le tier de régénération sait piloter. */ +export interface RegenEcosystem { + id: "npm" | "pnpm" | "yarn-berry" | "composer" | "cargo"; + /** Le fichier généré que possède cette entrée (matche GENERATED_FILE_PATTERNS). */ + lockfile: RegExp; + /** Fichiers qui doivent être propres (ou résolus par le moteur) avant régénération. */ + sourcesOfTruth: string[]; + /** Commande lockfile-only, scripts coupés. Jamais un install complet. */ + command: { bin: string; args: string[] }; + network: "required" | "offline-capable"; + defaultTimeoutMs: number; +} + +const DEFAULT_TIMEOUT_MS = 120_000; + +/** + * v1 registry — 5 entrées, une par écosystème supporté. Voir le brief de la + * tâche (§ "v1 registry") pour la justification de chaque commande exacte. + */ +export const REGEN_ECOSYSTEMS: readonly RegenEcosystem[] = [ + { + id: "npm", + lockfile: /package-lock\.json$/i, + sourcesOfTruth: ["package.json"], + command: { bin: "npm", args: ["install", "--package-lock-only", "--ignore-scripts"] }, + network: "required", + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + }, + { + id: "pnpm", + lockfile: /pnpm-lock\.yaml$/i, + sourcesOfTruth: ["package.json"], + command: { bin: "pnpm", args: ["install", "--lockfile-only", "--ignore-scripts"] }, + network: "required", + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + }, + { + id: "yarn-berry", + lockfile: /yarn\.lock$/i, + // Ruling P-3 (brief) — yarn.lock est matché sans distinction classic/berry + // par GENERATED_FILE_PATTERNS, mais v1 ne pilote QUE berry (`--mode=update-lockfile` + // n'existe pas en classic). `.yarnrc.yml` est le marqueur berry : en son + // absence (ou conflit), le plan est non-runnable — voir plan.ts. + sourcesOfTruth: ["package.json", ".yarnrc.yml"], + // `--mode=update-lockfile` ne fait jamais tourner d'install ni de scripts de + // cycle de vie (postinstall…) : il ne fait que mettre à jour le lockfile. + command: { bin: "yarn", args: ["install", "--mode=update-lockfile"] }, + network: "required", + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + }, + { + id: "composer", + lockfile: /composer\.lock$/i, + sourcesOfTruth: ["composer.json"], + command: { bin: "composer", args: ["update", "--lock", "--no-scripts", "--no-install"] }, + network: "required", + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + }, + { + id: "cargo", + lockfile: /Cargo\.lock$/i, + sourcesOfTruth: ["Cargo.toml"], + // `generate-lockfile` résout les dépendances, il ne construit jamais rien : + // aucun build.rs ni script de cycle de vie ne s'exécute. + command: { bin: "cargo", args: ["generate-lockfile"] }, + network: "offline-capable", + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + }, +]; + +/** + * Retourne l'entrée du registre dont `lockfile` matche `path`, ou `undefined` + * si aucune ne correspond (ex: `.min.js` — généré mais hors registre v1). + */ +export function findEcosystem(path: string): RegenEcosystem | undefined { + return REGEN_ECOSYSTEMS.find((eco) => eco.lockfile.test(path)); +} diff --git a/packages/core/src/resolver/assemble.ts b/packages/core/src/resolver/assemble.ts index 04a39f08..e14aea94 100644 --- a/packages/core/src/resolver/assemble.ts +++ b/packages/core/src/resolver/assemble.ts @@ -17,7 +17,7 @@ import type { MergePolicy, PolicyConfig } from "../config.js"; import { mergeNonOverlapping } from "../diff.js"; import { stripVolatileValues } from "./generated-detection.js"; import { getLastRefMergeResult } from "../patterns/refactoring-aware-merge.js"; -import { pickNewerSemverSide } from "../patterns/utils.js"; +import { pickNewerSemverSide, hasUnorderableVersionPair } from "../patterns/utils.js"; /** * Applique la stratégie textuelle correspondant au type de hunk. @@ -165,17 +165,45 @@ export function assembleResolution( reason: `value_only_change resolution disabled by the "${effectivePolicy}" policy.`, }; } - // Quand toutes les paires de tokens différents sont des semver - // comparables, le côté le plus élevé gagne — déterministe et conforme à - // l'intention « garder la version la plus récente », quel que soit le - // côté qui la porte. Sinon (hashes, timestamps) : côté-politique. const semverSide = pickNewerSemverSide(hunk.oursLines, hunk.theirsLines); + const versionish = hasUnorderableVersionPair(hunk.oursLines, hunk.theirsLines); + const ctx = options.mergeContext; + + // accuracy lot C — Un scalaire de version NON ordonnable fixé différemment des + // deux côtés ('13.x-dev' vs '12.54.1', '2.9.0-dev'…) est l'identité de + // version du fichier sur la branche cible : avec le contexte, la cible + // garde sa valeur. Mesuré sur benchmark/ : laravel 36,6 % → 81,5 % + // d'accord. Les paires ORDONNABLES (deps bumpées des deux côtés) gardent + // en revanche « la plus récente gagne » même avec contexte — la première + // version de cette règle les basculait aussi vers la cible, et l'accord + // régressait sur prettier/vue/express (les humains prennent bien la dep + // la plus récente apportée par la branche source). + if (ctx && versionish && semverSide === null) { + const side = ctx.targetSide; + const refs = ctx.oursRef && ctx.theirsRef ? ` (${ctx.theirsRef} → ${ctx.oursRef})` : ""; + return { + lines: side === "ours" ? [...hunk.oursLines] : [...hunk.theirsLines], + reason: `Version changed on both sides during a ${ctx.operation}${refs} — the target branch keeps its value. Resolution: take ${side}.`, + }; + } + + // Sans contexte : les paires semver/datetime ordonnables gardent la règle + // historique « la plus récente gagne » (déterministe et testée)… if (semverSide !== null) { return { lines: semverSide === "ours" ? [...hunk.oursLines] : [...hunk.theirsLines], reason: `Same structure, differing semver version(s). Resolution: take ${semverSide} (the higher version).`, }; } + // …mais une paire version NON ordonnable ('13.x-dev' vs '12.54.1') ne + // retombe plus sur la politique : mesurée fausse ~3 fois sur 4, c'est une + // proposition, pas une application. + if (versionish) { + return { + lines: null, + reason: "Version changed on both sides with non-comparable values — this is a merge decision, not volatility. The target branch wins when context is known (auto-detected by the CLI and desktop); here it isn't, so GitWand proposes instead of applying.", + }; + } const preferred = policyCfg.preferOurs ? hunk.oursLines : hunk.theirsLines; const side = policyCfg.preferOurs ? "ours" : "theirs"; return { @@ -193,21 +221,34 @@ export function assembleResolution( }; case "generated_file": { - // Smart resolution : si les deux côtés sont identiques après suppression - // des valeurs volatiles (hashes, timestamps), le conflit est cosmétique + // accuracy lot 1 — Par défaut, on DÉCLINE : la version commitée d'un fichier + // généré est la sortie d'un outil, pas la fusion de deux textes. + // Mesuré sur le corpus benchmark/ : « accepter theirs » divergeait de + // ce que les équipes livrent dans ~100 % des cas. Décliner avec un + // message actionnable vaut mieux qu'une fusion silencieusement fausse. const oursStripped = stripVolatileValues(hunk.oursLines); const theirsStripped = stripVolatileValues(hunk.theirsLines); + const cosmetic = oursStripped === theirsStripped; - if (oursStripped === theirsStripped) { + if (!options.resolveGeneratedFiles) { + return { + lines: null, + reason: cosmetic + ? "Generated file — only volatile differences (hashes/timestamps). Resolve the source file (e.g. package.json) then regenerate this one with its tool (install/build). Auto-resolution available via resolveGeneratedFiles: true." + : "Generated file — not merged, regenerated. Resolve the source file (e.g. package.json) then re-run the tool that produces this one (install/build). Auto-resolution (take theirs) available via resolveGeneratedFiles: true.", + }; + } + + // Opt-in resolveGeneratedFiles: true — comportement historique. + if (cosmetic) { return { lines: [...hunk.theirsLines], reason: "Generated file with identical structure (only volatile values differ). Resolution: take theirs. Suggestion: re-run the build or install.", }; } - return { lines: [...hunk.theirsLines], - reason: "Generated file: it will be rebuilt after the merge. Resolution: take theirs. Suggestion: re-run the build or install.", + reason: "Generated file: it will be rebuilt after the merge. Resolution: take theirs (opt-in resolveGeneratedFiles). Suggestion: re-run the build or install.", }; } diff --git a/packages/core/src/resolver/format-dispatch.ts b/packages/core/src/resolver/format-dispatch.ts index abd553fb..51719844 100644 --- a/packages/core/src/resolver/format-dispatch.ts +++ b/packages/core/src/resolver/format-dispatch.ts @@ -21,7 +21,7 @@ import { computeEffectivePolicy } from "./policy.js"; export type FormatDispatchResult = /** Le résolveur format-aware a produit une résolution. */ - | { status: "resolved"; lines: string[]; reason: string } + | { status: "resolved"; lines: string[]; reason: string; resolverUsed: string } /** Le résolveur a résolu mais la politique rejette le résultat (ex: imports/non_overlapping off). */ | { status: "rejected-policy"; reason: string } /** Aucun résolveur format-aware n'a traité ce hunk — continuer vers le moteur textuel. */ @@ -66,5 +66,5 @@ export function dispatchFormatAware( } } - return { status: "resolved", lines: formatResult.lines, reason: formatResult.reason }; + return { status: "resolved", lines: formatResult.lines, reason: formatResult.reason, resolverUsed: formatResult.resolverUsed }; } diff --git a/packages/core/src/resolver/generated-detection.ts b/packages/core/src/resolver/generated-detection.ts index 525cd951..0204809b 100644 --- a/packages/core/src/resolver/generated-detection.ts +++ b/packages/core/src/resolver/generated-detection.ts @@ -19,8 +19,8 @@ export const GENERATED_FILE_PATTERNS: Array<{ pattern: RegExp; label: string }> { pattern: /Gemfile\.lock$/i, label: "bundler lockfile" }, { pattern: /Cargo\.lock$/i, label: "cargo lockfile" }, { pattern: /\.min\.(js|css)$/i, label: "minified file" }, - { pattern: /\bdist\//, label: "fichier build dist/" }, - { pattern: /\bbuild\/manifest\.json$/i, label: "manifest de build" }, + { pattern: /\bdist\//, label: "dist/ build output" }, + { pattern: /\bbuild\/manifest\.json$/i, label: "build manifest" }, { pattern: /\.bundle\.(js|css)$/i, label: "bundle" }, { pattern: /mix-manifest\.json$/i, label: "Laravel Mix manifest" }, ]; @@ -107,14 +107,14 @@ export function reclassifyIfGenerated( baseAvailability: 0, }, boosters: [`Path matches the generated-file pattern: ${genInfo.label}`], - penalties: ["The content will be regenerated, so theirs is assumed to be the more recent"], + penalties: ["The committed content is a tool's output — a textual merge does not reproduce it"], }; return { ...hunk, type: "generated_file", confidence: generatedScore, - explanation: `Generated file (${genInfo.label}). It will be rebuilt after the merge. Proposed resolution: take theirs and re-run the build.`, + explanation: `Generated file (${genInfo.label}). This file is regenerated, not merged: resolve its source, then re-run the tool that produces it (install/build).`, // Update the trace to reflect the reclassification trace: { ...hunk.trace, diff --git a/packages/core/src/resolver/index.ts b/packages/core/src/resolver/index.ts index 367f394a..c0026703 100644 --- a/packages/core/src/resolver/index.ts +++ b/packages/core/src/resolver/index.ts @@ -15,6 +15,7 @@ */ import type { + ConfidenceScore, ConflictHunk, ConflictType, ExternalValidationResult, @@ -36,6 +37,9 @@ import { EMPTY_VALIDATION, validateMergedContent } from "./validation.js"; import { checkParseTreeValid, applyPostMergeRiskPenalty } from "./validate-parse-tree.js"; import { runStrictValidation } from "./validate-strict.js"; import { isGeneratedFile, reclassifyIfGenerated } from "./generated-detection.js"; +import { findEcosystem } from "../regenerate/registry.js"; +import { buildRegenerationPlan, type RegenerationPlan } from "../regenerate/plan.js"; +import { isChangelogFile } from "./validation.js"; import { CONFIDENCE_ORDER, DEFAULT_OPTIONS, @@ -57,31 +61,197 @@ import { runLlmFallbackPhase } from "./llm-pipeline.js"; * @param options - Options de configuration (complètes, déjà fusionnées avec les défauts) * @returns Les lignes résolues + la raison, ou `null` + raison de refus */ +/** + * accuracy lot 1 — Types de hunk qu'un pattern textuel peut résoudre sans risque même + * dans un fichier généré : ils ne fabriquent aucun contenu (ils prennent un + * côté existant ou constatent l'identité des deux). + */ +const SAFE_TEXTUAL_ON_GENERATED: ReadonlySet = new Set([ + "same_change", + "one_side_change", + "delete_no_change", + "whitespace_only", +]); + +/** + * accuracy lot 1 — Contrat du classifieur : un hunk `complex` résolu par un résolveur + * format-aware est reclassifié `format_semantic`, avec une confiance et une + * trace — plus jamais un hunk affiché « complex » mais appliqué en douce. + */ +function reclassifyFormatSemantic(hunk: ConflictHunk, resolverUsed: string): ConflictHunk { + // On ne remplace pas le score du classifieur, on l'augmente : les dimensions + // (baseAvailability, dataRisk…) et les boosters existants (zdiff3…) restent — + // la reclassification ajoute l'information « fusion sémantique validée », + // elle n'efface pas ce que la classification savait déjà. + const confidence: ConfidenceScore = { + score: Math.max(hunk.confidence.score, 78), + label: "high", + dimensions: { ...hunk.confidence.dimensions, typeClassification: 85 }, + boosters: [ + ...hunk.confidence.boosters, + `Format-aware resolver "${resolverUsed}": semantic merge validated for this format`, + ], + penalties: hunk.confidence.penalties, + }; + return { + ...hunk, + type: "format_semantic", + confidence, + explanation: `Hunk resolved semantically by the "${resolverUsed}" resolver (merged by the format's structure, not by lines).`, + trace: { + ...hunk.trace, + selected: "format_semantic", + summary: `Format-aware resolver "${resolverUsed}" — reclassified out of complex.`, + steps: [ + ...hunk.trace.steps, + { + type: "format_semantic" as ConflictType, + passed: true, + reason: `The "${resolverUsed}" resolver produced a semantic merge; the hunk is no longer "complex".`, + }, + ], + }, + }; +} + +/** + * accuracy lot 1 — Un hunk non-complex résolu par un résolveur format-aware garde son + * type (la classification textuelle reste vraie) mais sa confiance intègre la + * validation sémantique du résolveur : c'est elle qui justifie l'application, + * et elle doit être visible dans la trace au lieu d'un bypass silencieux. + */ +function boostFormatValidated(hunk: ConflictHunk, resolverUsed: string): ConflictHunk { + if (CONFIDENCE_ORDER[hunk.confidence.label] >= CONFIDENCE_ORDER.high) return hunk; + const confidence: ConfidenceScore = { + score: Math.max(hunk.confidence.score, 75), + label: "high", + dimensions: hunk.confidence.dimensions, + boosters: [ + ...hunk.confidence.boosters, + `Format-aware resolver "${resolverUsed}": semantically validated merge for this format`, + ], + penalties: hunk.confidence.penalties, + }; + return { ...hunk, confidence }; +} + +/** + * accuracy lot D — Si `filePath` matche un écosystème connu du registre + * (lockfiles npm/pnpm/yarn-berry/composer/cargo), calcule le plan de + * régénération et ajoute l'indice « --regenerate » à la raison de déclin. + * Sinon, retourne la raison telle quelle sans plan. Centralisé ici : les + * deux sites de déclin d'un fichier généré (`generatedGate`, + * `assembleResolution` case "generated_file") appellent ce même helper — + * `assembleResolution` reste un simple switch lignes-ou-null, il n'a pas + * connaissance du registre de régénération. + * + * (Un troisième site — le seuil `minConfidence` — a été envisagé puis + * retiré : `computeEffectiveMinConfidence` retourne toujours le PLUS + * PERMISSIF de la politique et de l'option, et aucun `MergePolicy` ne + * dépasse "high" ; comme `reclassifyIfGenerated` fixe la confiance d'un + * hunk `generated_file` à exactement "high", ce seuil ne peut jamais + * rejeter un tel hunk, quelle que soit l'API publique utilisée. Ce n'était + * pas un cas rare à couvrir par prudence : c'était du code mort.) + */ +function attachRegenerationPlan( + filePath: string, + options: Required, + reason: string, +): { reason: string; regenerationPlan?: RegenerationPlan } { + const ecosystem = findEcosystem(filePath); + if (!ecosystem) return { reason }; + const regenerationPlan = buildRegenerationPlan(filePath, ecosystem, options.regenerationContext); + return { reason: `${reason} Or re-run with --regenerate.`, regenerationPlan }; +} + function resolveHunk( hunk: ConflictHunk, filePath: string, options: Required, -): { lines: string[] | null; reason: string } { + genInfo: { generated: boolean; label: string }, +): { hunk: ConflictHunk; lines: string[] | null; reason: string; regenerationPlan?: RegenerationPlan } { // explainOnly : ne pas appliquer de résolution, juste tracer if (options.explainOnly) { return { + hunk, lines: null, reason: `Explain-only mode: no resolution applied (type: ${hunk.type}, confidence: ${hunk.confidence.label} [score: ${hunk.confidence.score}]).`, }; } - // Phase 7.3 — Dispatch format-aware (bypasse le seuil de confiance textuel - // car les résolveurs spécialisés font une validation sémantique). - const dispatch = dispatchFormatAware(hunk, filePath, options); - if (dispatch.status === "resolved") { - return { lines: dispatch.lines, reason: dispatch.reason }; + // accuracy lot 1 — Fichier généré : par défaut on ne fusionne pas, on régénère. + // Les résolveurs format-aware (lockfiles compris) ne sont même pas tentés ; + // seuls les patterns textuels qui ne fabriquent rien restent autorisés. + // accuracy lot F — convention mesurée : dans ce dépôt, le changelog est + // RECONSTRUIT par l'outillage de release, pas fusionné. Aucune fusion + // textuelle ne le reproduit (mesuré sur l'historique), donc on décline avec + // la provenance au lieu de produire une union plausible mais jamais livrée. + const changelogConv = options.conventions?.changelog; + if ( + changelogConv?.verdict === "tool-rebuilt" && + isChangelogFile(filePath) && + !SAFE_TEXTUAL_ON_GENERATED.has(hunk.type) + ) { + return { + hunk, + lines: null, + reason: `Changelog rebuilt by this repo's release tooling [convention measured on ${changelogConv.samples} merges, ${Math.round(changelogConv.agreement * 100)}%] — merge declined: resolve the source and re-run the release tool.`, + }; + } + + const generatedGate = genInfo.generated && !options.resolveGeneratedFiles; + if (generatedGate && hunk.type !== "generated_file" && !SAFE_TEXTUAL_ON_GENERATED.has(hunk.type)) { + // accuracy lot D — Si le chemin matche un écosystème connu (lockfiles + // npm/pnpm/yarn-berry/composer/cargo), on émet un plan de régénération en + // plus du déclin : le moteur ne l'exécute jamais, il indique juste ce qui + // le rendrait sûr (sources de vérité propres). L'appelant (CLI) décide. + const { reason, regenerationPlan } = attachRegenerationPlan( + filePath, + options, + `Generated file (${genInfo.label}) — not merged, regenerated. Resolve the source file then re-run the tool that produces this one (install/build). Auto-resolution available via resolveGeneratedFiles: true.`, + ); + return { hunk, lines: null, reason, regenerationPlan }; } - if (dispatch.status === "rejected-policy") { - return { lines: null, reason: dispatch.reason }; + + // Phase 7.3 — Dispatch format-aware. accuracy lot 1 : plus de bypass silencieux — + // un hunk complex résolu ici est reclassifié `format_semantic` (confiance + + // trace) puis soumis au même seuil de confiance que les patterns. + let dispatchNote = ""; + if (!generatedGate) { + const dispatch = dispatchFormatAware(hunk, filePath, options); + if (dispatch.status === "resolved") { + const effective = hunk.type === "complex" + ? reclassifyFormatSemantic(hunk, dispatch.resolverUsed) + : boostFormatValidated(hunk, dispatch.resolverUsed); + const { policy: fmtPolicy, cfg: fmtCfg } = computeEffectivePolicy(filePath, options); + const fmtMinConfidence = computeEffectiveMinConfidence(fmtCfg, options); + // Une fusion sémantique combine du contenu des deux côtés — même famille + // de risque que non_overlapping. Les politiques qui l'excluent (strict, + // prefer-safety) l'excluent donc aussi, comme pour le résolveur imports. + if (effective.type === "format_semantic" && !fmtCfg.allowNonOverlapping) { + return { + hunk: effective, + lines: null, + reason: `Semantic merge (${dispatch.resolverUsed}) disabled by the "${fmtPolicy}" policy — it combines content from both sides.`, + }; + } + if (CONFIDENCE_ORDER[effective.confidence.label] < CONFIDENCE_ORDER[fmtMinConfidence]) { + return { + hunk: effective, + lines: null, + reason: `Confidence ${effective.confidence.label} (score: ${effective.confidence.score}) is insufficient to apply the format-aware resolution (minimum required: ${fmtMinConfidence}, policy: ${fmtPolicy}).`, + }; + } + return { hunk: effective, lines: dispatch.lines, reason: dispatch.reason }; + } + if (dispatch.status === "rejected-policy") { + return { hunk, lines: null, reason: dispatch.reason }; + } + // dispatch.status === "not-applicable" → on continue vers le moteur textuel. + // `dispatch.note` porte la raison d'échec du résolveur spécialisé (pour + // annotation du refus final si le seuil de confiance bloque aussi). + dispatchNote = dispatch.note; } - // dispatch.status === "not-applicable" → on continue vers le moteur textuel. - // `dispatch.note` porte la raison d'échec du résolveur spécialisé (pour - // annotation du refus final si le seuil de confiance bloque aussi). // Phase 7.4 — Politique de merge effective pour ce fichier const { policy: effectivePolicy, cfg: policyCfg } = computeEffectivePolicy(filePath, options); @@ -90,12 +260,28 @@ function resolveHunk( // Vérifier le niveau de confiance minimum if (CONFIDENCE_ORDER[hunk.confidence.label] < CONFIDENCE_ORDER[effectiveMinConfidence]) { return { + hunk, lines: null, - reason: `Confidence ${hunk.confidence.label} (score: ${hunk.confidence.score}) is below the ${effectiveMinConfidence} required by the ${effectivePolicy} policy.${dispatch.note ? ` [${dispatch.note}]` : ""}`, + reason: `Confidence ${hunk.confidence.label} (score: ${hunk.confidence.score}) is below the ${effectiveMinConfidence} required by the ${effectivePolicy} policy.${dispatchNote ? ` [${dispatchNote}]` : ""}`, }; } - return assembleResolution(hunk, options, effectivePolicy, policyCfg); + const assembled = assembleResolution(hunk, options, effectivePolicy, policyCfg); + + // accuracy lot D — la majorité des lockfiles réellement en conflit (chevauchement + // sémantique, pas un pattern "safe") sont reclassifiés `generated_file` par + // `reclassifyIfGenerated` AVANT `resolveHunk` : le generatedGate ci-dessus ne les + // voit donc jamais (il exclut explicitement `hunk.type === "generated_file"`). Le + // déclin arrive ici, dans `assembleResolution`'s case "generated_file" — + // `assembled.lines === null` sauf opt-in `resolveGeneratedFiles: true` (auquel cas + // ce chemin prend "accepter theirs" et ne décline jamais). C'est le cas majoritaire + // visé par le spec finding #1 (0 % d'accord sur generated_file). + if (genInfo.generated && hunk.type === "generated_file" && assembled.lines === null) { + const { reason, regenerationPlan } = attachRegenerationPlan(filePath, options, assembled.reason); + return { hunk, lines: null, reason, regenerationPlan }; + } + + return { hunk, ...assembled }; } /** @@ -111,7 +297,17 @@ export function resolve( filePath: string, userOptions: GitWandOptions = {}, ): MergeResult { - const options = { ...DEFAULT_OPTIONS, ...userOptions }; + let options = { ...DEFAULT_OPTIONS, ...userOptions }; + + // accuracy lot F — précédence : option explicite > convention dérivée > défaut. + // Seule la convention generatedFiles pilote un interrupteur du moteur en v1 ; + // elle ne s'applique que si l'appelant n'a PAS exprimé de choix. + const generatedConv = options.conventions?.generatedFiles; + const generatedByConvention = + userOptions.resolveGeneratedFiles === undefined && generatedConv?.verdict === "merge"; + if (generatedByConvention) { + options = { ...options, resolveGeneratedFiles: true }; + } // v2.6 — RefMerge opt-in : activer le pattern avant classification, désactiver après const refEnabled = !!(options.refactoringAware?.enabled); @@ -147,9 +343,15 @@ export function resolve( // Si fichier auto-généré et hunk classifié "complex", reclassifier en "generated_file" hunk = reclassifyIfGenerated(hunk, genInfo); + const { + hunk: effectiveHunk, + lines: resolvedLines, + reason: resolutionReason, + regenerationPlan, + } = resolveHunk(hunk, filePath, options, genInfo); + hunk = effectiveHunk; hunks.push(hunk); - const { lines: resolvedLines, reason: resolutionReason } = resolveHunk(hunk, filePath, options); const autoResolved = resolvedLines !== null; // v1.4 — Incrémenter le compteur de hunks complexes non résolus pour fileFrequency @@ -157,7 +359,17 @@ export function resolve( priorComplexHunks++; } - resolutions.push({ hunk, resolvedLines, autoResolved, resolutionReason }); + // accuracy lot F — provenance : toute résolution (ou déclin) d'un fichier + // généré influencée par une convention mesurée le dit dans sa raison. + let finalReason = resolutionReason; + if (genInfo.generated && generatedConv) { + const prov = `[convention measured on ${generatedConv.samples} merges, ${Math.round(generatedConv.agreement * 100)}%: this repo ${generatedConv.verdict === "merge" ? "merges" : "regenerates"} its generated files]`; + if ((generatedByConvention && autoResolved) || (generatedConv.verdict === "regenerate" && !autoResolved)) { + finalReason = `${resolutionReason} ${prov}`; + } + } + + resolutions.push({ hunk, resolvedLines, autoResolved, resolutionReason: finalReason, regenerationPlan }); if (autoResolved) { outputLines.push(...resolvedLines); @@ -207,6 +419,33 @@ export function resolve( ? validateMergedContent(mergedContent, filePath) : EMPTY_VALIDATION; + // accuracy lot 1 — Une violation d'invariant de format (deux « Unreleased » dans un + // changelog, clé JSON dupliquée…) rétracte les résolutions automatiques du + // fichier, comme la validation parse-tree le fait déjà pour la syntaxe. + // Une résolution qui casse un invariant n'est pas appliquée, quel que soit + // le pattern qui l'a produite. + if (mergedContent !== null && validation.invariantErrors && validation.invariantErrors.length > 0) { + const why = validation.invariantErrors.join(" "); + const retractedResolutions = resolutions.map((r) => + r.autoResolved + ? { + ...r, + autoResolved: false, + resolvedLines: null, + resolutionReason: `Retracted: the merged content violates a format invariant. ${why}`, + } + : r, + ); + return { + filePath, + mergedContent: null, + hunks, + resolutions: retractedResolutions, + stats: { ...stats, autoResolved: 0, remaining: stats.totalConflicts }, + validation: { ...validation, isValid: false }, + }; + } + return { filePath, mergedContent, diff --git a/packages/core/src/resolver/policy.ts b/packages/core/src/resolver/policy.ts index b16917ac..3c5ba4bb 100644 --- a/packages/core/src/resolver/policy.ts +++ b/packages/core/src/resolver/policy.ts @@ -33,6 +33,14 @@ export const DEFAULT_OPTIONS: Required = { policy: DEFAULT_POLICY, patternOverrides: {}, generatedFiles: [], + // accuracy lot 1 — les fichiers générés déclinent par défaut (voir GitWandOptions) + resolveGeneratedFiles: false, + // accuracy lot C — contexte de merge inconnu par défaut ; fourni par les appelants + mergeContext: null, + // accuracy lot D — pas de contexte de régénération par défaut ; fourni par les appelants + regenerationContext: null, + // accuracy lot F — pas de conventions dérivées par défaut + conventions: null, // v2.2 — profils de format actifs par défaut disableFormatProfiles: false, // v2.4 — validation post-merge diff --git a/packages/core/src/resolver/validation.ts b/packages/core/src/resolver/validation.ts index 7ee05613..660d7dc6 100644 --- a/packages/core/src/resolver/validation.ts +++ b/packages/core/src/resolver/validation.ts @@ -63,6 +63,100 @@ function tryParse(content: string, format: StructuredFormat): string | null { } } +// ─── accuracy lot 1 — Invariants de format ────────────────────────────────────────────── +// +// La validation syntaxique ne suffit pas : un changelog avec deux sections +// « ## [Unreleased] » parse très bien, un package.json avec une clé dupliquée +// aussi (JSON.parse garde silencieusement la dernière). Ces invariants-là sont +// exactement ce qu'une fusion textuelle casse. Une violation entraîne la +// rétractation des résolutions du fichier (voir resolver/index.ts). + +/** + * Détecte les clés dupliquées dans un document JSON, objet par objet. + * Scanner tolérant : suit l'imbrication et l'état « dans une chaîne » + * (échappements compris) sans construire d'AST. `.json` strict uniquement — + * les commentaires JSONC feraient mentir le suivi de chaînes. + */ +export function findDuplicateJsonKeys(content: string): string[] { + const duplicates: string[] = []; + type Frame = { type: "obj" | "arr"; keys: Set; expectKey: boolean }; + const stack: Frame[] = []; + let i = 0; + const n = content.length; + + while (i < n) { + const ch = content[i]; + + if (ch === '"') { + // Lire la chaîne entière (échappements compris) + let j = i + 1; + let str = ""; + while (j < n) { + const c = content[j]; + if (c === "\\") { str += content[j + 1] ?? ""; j += 2; continue; } + if (c === '"') break; + str += c; + j += 1; + } + const top = stack[stack.length - 1]; + if (top?.type === "obj" && top.expectKey) { + if (top.keys.has(str) && !duplicates.includes(str)) duplicates.push(str); + top.keys.add(str); + top.expectKey = false; + } + i = j + 1; + continue; + } + + if (ch === "{") stack.push({ type: "obj", keys: new Set(), expectKey: true }); + else if (ch === "[") stack.push({ type: "arr", keys: new Set(), expectKey: false }); + else if (ch === "}" || ch === "]") stack.pop(); + else if (ch === ",") { + const top = stack[stack.length - 1]; + if (top?.type === "obj") top.expectKey = true; + } + i += 1; + } + return duplicates; +} + +/** Un fichier est « de type changelog » si son nom de base commence par changelog/history/releases et finit en .md. */ +export function isChangelogFile(filePath: string): boolean { + const base = filePath.split(/[\\/]/).pop() ?? ""; + return /^(changelog|history|releases|release-notes)\b.*\.(md|markdown)$/i.test(base); +} + +/** + * Vérifie les invariants du format au-delà de la syntaxe. + * Retourne la liste (possiblement vide) des violations, en clair. + */ +export function checkFormatInvariants(content: string, filePath: string): string[] { + const violations: string[] = []; + + if (isChangelogFile(filePath)) { + const lines = content.split("\n"); + const unreleased = lines.filter((l) => /^##\s+\[?unreleased/i.test(l.trim())); + if (unreleased.length > 1) { + violations.push(`Changelog: ${unreleased.length} "Unreleased" sections — a changelog has only one.`); + } + const headings = lines.map((l) => l.trim()).filter((l) => /^##\s+\[?v?\d/i.test(l)); + const seen = new Set(); + for (const h of headings) { + if (seen.has(h)) { violations.push(`Changelog: duplicated version section — "${h.slice(0, 80)}".`); break; } + seen.add(h); + } + } + + if (/\.json$/i.test(filePath)) { + const dup = findDuplicateJsonKeys(content); + if (dup.length > 0) { + violations.push(`JSON: duplicate key(s) in the same object — ${dup.slice(0, 5).map((k) => `"${k}"`).join(", ")}. JSON.parse would silently keep the last one.`); + } + } + + return violations; +} + /** * Valide le contenu fusionné pour détecter les problèmes résiduels. * @@ -94,7 +188,10 @@ export function validateMergedContent(content: string, filePath: string): Valida const format = detectFormat(filePath); const syntaxError = tryParse(content, format); - const isValid = !hasResidualMarkers && syntaxError === null; + // 3. accuracy lot 1 — Invariants de format (au-delà de la syntaxe) + const invariantErrors = checkFormatInvariants(content, filePath); + + const isValid = !hasResidualMarkers && syntaxError === null && invariantErrors.length === 0; // parseTreeValid est null ici car validateMergedContent est synchrone. // La validation parse-tree (tree-sitter, async) est effectuée séparément @@ -104,6 +201,7 @@ export function validateMergedContent(content: string, filePath: string): Valida residualMarkerLines, syntaxError, isValid, + invariantErrors, parseTreeValid: null, parseTreeErrors: 0, parseTreeErrorRanges: [], @@ -116,6 +214,7 @@ export const EMPTY_VALIDATION: ValidationResult = { residualMarkerLines: [], syntaxError: null, isValid: true, + invariantErrors: [], parseTreeValid: null, parseTreeErrors: 0, parseTreeErrorRanges: [], diff --git a/packages/core/src/resolvers/dispatcher.ts b/packages/core/src/resolvers/dispatcher.ts index 04b01c73..2df1e143 100644 --- a/packages/core/src/resolvers/dispatcher.ts +++ b/packages/core/src/resolvers/dispatcher.ts @@ -29,6 +29,7 @@ import type { ConflictHunk } from "../types.js"; import { tryResolveJsonConflict } from "./json.js"; +import { tryResolveJsonFragment } from "./json-fragment.js"; import { tryResolveMarkdownConflict } from "./markdown.js"; import { tryResolveYamlConflict } from "./yaml.js"; import { tryResolveImportConflict, isImportBlock } from "./imports.js"; @@ -296,6 +297,18 @@ export function tryFormatAwareResolve( }; } + // accuracy lot E (lot E) — le doc complet n'a pas parsé : les conflits réels de + // package.json / composer.json sont des FRAGMENTS (« "clé": valeur, »). + // Fusion 3-way par clé, mesurée bien plus juste que l'union ligne à ligne. + const frag = tryResolveJsonFragment(hunk.baseLines, hunk.oursLines, hunk.theirsLines); + if (frag.lines !== null) { + return { + lines: frag.lines, + reason: `[json] ${frag.reason}`, + resolverUsed: "json", + }; + } + return { lines: null, reason: `[json] ${result.reason}`, diff --git a/packages/core/src/resolvers/json-fragment.ts b/packages/core/src/resolvers/json-fragment.ts new file mode 100644 index 00000000..5ff5b3c5 --- /dev/null +++ b/packages/core/src/resolvers/json-fragment.ts @@ -0,0 +1,210 @@ +/** + * GitWand — Résolveur de FRAGMENTS JSON (accuracy lot E, lot E) + * + * `tryResolveJsonConflict` exige que chaque côté du hunk parse comme un + * document JSON complet. Or les conflits réels de `package.json` / + * `composer.json` sont presque toujours des fragments — quelques lignes + * `"clé": valeur,` au milieu d'un objet. Le moteur textuel les traite ligne à + * ligne (union non_overlapping, value_only…), ce qui est exactement la + * mauvaise granularité : mesuré sur benchmark/, non_overlapping n'est en + * accord avec le merge humain que 48–67 % du temps sur ces fichiers. + * + * Ici : 3-way par CLÉ. + * - ajoutée d'un côté → gardée ; supprimée d'un côté (intacte de l'autre) → supprimée ; + * - modifiée d'un côté → prise ; modifiée pareil des deux → prise ; + * - modifiée des deux côtés en valeurs DIFFÉRENTES → arbitrage borné : + * si les deux valeurs sont des contraintes de version au MÊME opérateur + * (`^7.23.0` vs `^7.23.3`), la plus récente gagne — c'est ce que les + * équipes livrent, mesuré sur le corpus (elles prennent la dépendance la + * plus récente apportée par l'autre branche). Sinon → null, fallback. + * + * Conservateur par construction : une ligne qui n'est pas exactement une + * entrée `"clé": ` (objet imbriqué multi-lignes, + * commentaire, ligne vide) → null, on ne devine pas. + */ + +// ─── Parsing d'un fragment ──────────────────────────────── + +export interface FragmentEntry { + key: string; + /** Texte source de la valeur (non re-sérialisé — le formatage d'origine est conservé). */ + valueText: string; + /** Ligne d'origine SANS sa virgule finale (indentation et espaces intacts). */ + rawNoComma: string; + /** La ligne d'origine portait-elle une virgule finale ? */ + hadComma: boolean; +} + +const RE_ENTRY = /^(\s*)"((?:[^"\\]|\\.)+)"(\s*):(\s*)(.+?)(,?)\s*$/; + +/** Parse les lignes d'un côté du hunk. `null` dès qu'une ligne n'est pas une entrée simple. */ +export function parseFragmentEntries(lines: string[]): FragmentEntry[] | null { + const entries: FragmentEntry[] = []; + const seen = new Set(); + for (const line of lines) { + if (line.trim() === "") return null; // ligne vide → hors périmètre (conservateur) + const m = line.match(RE_ENTRY); + if (!m) return null; + const [, indent, key, preColon, postColon, valueText, comma] = m; + // La valeur doit être un JSON mono-ligne valide (scalaire, tableau ou objet inline). + try { + JSON.parse(valueText); + } catch { + return null; + } + if (seen.has(key)) return null; // clé dupliquée dans un même côté → on ne devine pas + seen.add(key); + entries.push({ + key, + valueText, + rawNoComma: `${indent}"${key}"${preColon}:${postColon}${valueText}`, + hadComma: comma === ",", + }); + } + return entries.length > 0 ? entries : null; +} + +// ─── Arbitrage des contraintes de version ───────────────── + +const RE_RANGE = /^"([\^~]?)v?(\d+)\.(\d+)(?:\.(\d+))?"$/; + +/** + * Si `a` et `b` sont deux contraintes de version au même opérateur + * (`"^7.23.0"` vs `"^7.23.3"`), retourne la plus récente. Sinon `null`. + * Volontairement strict : opérateurs différents, wildcards, prérelease, + * plages composées → null. + */ +export function pickNewerRange(a: string, b: string): string | null { + const ma = a.match(RE_RANGE); + const mb = b.match(RE_RANGE); + if (!ma || !mb) return null; + if (ma[1] !== mb[1]) return null; // ^ vs ~ vs pin : intentions différentes → décision humaine + const va = [Number(ma[2]), Number(ma[3]), Number(ma[4] ?? 0)]; + const vb = [Number(mb[2]), Number(mb[3]), Number(mb[4] ?? 0)]; + for (let i = 0; i < 3; i++) { + if (va[i] > vb[i]) return a; + if (va[i] < vb[i]) return b; + } + return a; // égales +} + +// ─── Merge 3-way par clé ────────────────────────────────── + +export interface FragmentMergeResult { + lines: string[] | null; + reason: string; +} + +export function tryResolveJsonFragment( + baseLines: string[], + oursLines: string[], + theirsLines: string[], +): FragmentMergeResult { + const ours = parseFragmentEntries(oursLines); + const theirs = parseFragmentEntries(theirsLines); + if (!ours || !theirs) { + return { lines: null, reason: "JSON fragment: lines not recognized as simple \"key\": value entries." }; + } + // Base absente (diff2) → traitée comme vide : tout est « ajouté ». + const base = baseLines.length > 0 ? parseFragmentEntries(baseLines) : []; + if (base === null) { + return { lines: null, reason: "JSON fragment: base not recognized as a list of simple entries." }; + } + + const bMap = new Map(base.map((e) => [e.key, e])); + const oMap = new Map(ours.map((e) => [e.key, e])); + const tMap = new Map(theirs.map((e) => [e.key, e])); + + let merged = 0; + let arbitrated = 0; + + /** Décide l'entrée survivante pour une clé, ou "drop", ou null (conflit réel). */ + function decide(key: string): FragmentEntry | "drop" | null { + const b = bMap.get(key); + const o = oMap.get(key); + const t = tMap.get(key); + const eq = (x?: FragmentEntry, y?: FragmentEntry) => + !!x && !!y && JSON.stringify(JSON.parse(x.valueText)) === JSON.stringify(JSON.parse(y.valueText)); + + if (o && t) { + if (eq(o, t)) return o; // identiques (même modif ou intacts) + if (b && eq(b, o)) { merged++; return t; } // seul theirs a changé + if (b && eq(b, t)) { merged++; return o; } // seul ours a changé + // Modifiée/ajoutée des deux côtés avec des valeurs différentes. + const winner = pickNewerRange(o.valueText, t.valueText); + if (winner !== null) { + arbitrated++; + return winner === o.valueText ? o : t; + } + return null; + } + if (o && !t) { + if (!b) return o; // ajoutée par ours + if (eq(b, o)) return "drop"; // supprimée par theirs, intacte chez ours + return null; // modifiée par ours ET supprimée par theirs + } + if (!o && t) { + if (!b) return t; + if (eq(b, t)) return "drop"; + return null; + } + return "drop"; // supprimée des deux côtés + } + + // Ordre de sortie : la séquence de ours, puis insertion des clés propres à + // theirs juste après leur prédécesseur dans theirs (ou en tête / à la fin). + const outKeys: string[] = []; + const decided = new Map(); + const allKeys = new Set([...oMap.keys(), ...tMap.keys()]); + + for (const key of allKeys) { + const d = decide(key); + if (d === null) { + return { lines: null, reason: `JSON fragment: key "${key}" is changed on both sides with non-arbitrable values — human decision.` }; + } + if (d !== "drop") decided.set(key, d); + } + + for (const e of ours) if (decided.has(e.key)) outKeys.push(e.key); + const theirsKeys = theirs.map((e) => e.key); + for (let i = 0; i < theirsKeys.length; i++) { + const key = theirsKeys[i]; + if (!decided.has(key) || outKeys.includes(key)) continue; + // Prédécesseur (dans theirs) déjà placé → insérer juste après lui. + let anchor = -1; + for (let j = i - 1; j >= 0; j--) { + const at = outKeys.indexOf(theirsKeys[j]); + if (at !== -1) { anchor = at; break; } + } + outKeys.splice(anchor + 1, 0, key); + } + + // Les maps de dépendances sont triées alphabétiquement par convention (npm + // l'impose à l'install). Si les DEUX côtés étaient déjà triés, on trie la + // sortie — c'est ce que l'outillage de l'équipe aurait produit. Sinon on + // respecte l'ordre reconstruit ci-dessus. + const isSorted = (keys: string[]) => keys.every((k, i) => i === 0 || keys[i - 1].localeCompare(k) <= 0); + if (isSorted(ours.map((e) => e.key)) && isSorted(theirsKeys)) { + outKeys.sort((a, b) => a.localeCompare(b)); + } + + // Virgules : chaque ligne sauf la dernière en porte une ; la dernière suit la + // convention du fragment d'origine (dernière ligne de ours et theirs d'accord, + // sinon on décline plutôt que de risquer un JSON invalide). + const oursLast = ours[ours.length - 1].hadComma; + const theirsLast = theirs[theirs.length - 1].hadComma; + if (oursLast !== theirsLast) { + return { lines: null, reason: "JSON fragment: inconsistent trailing-comma convention between the two sides." }; + } + + const lines = outKeys.map((key, idx) => { + const e = decided.get(key)!; + const isLast = idx === outKeys.length - 1; + return e.rawNoComma + (isLast ? (oursLast ? "," : "") : ","); + }); + + return { + lines, + reason: `JSON fragment merged by key: ${outKeys.length} entr${outKeys.length === 1 ? "y" : "ies"}, ${merged} one-sided change(s) taken${arbitrated ? `, ${arbitrated} version constraint(s) arbitrated to the newer` : ""}.`, + }; +} diff --git a/packages/core/src/stats/tiers.ts b/packages/core/src/stats/tiers.ts index 8f74670a..7de927ea 100644 --- a/packages/core/src/stats/tiers.ts +++ b/packages/core/src/stats/tiers.ts @@ -48,9 +48,12 @@ const TIER_BY_TYPE: Record = { reorder_only: "trivial", insertion_at_boundary: "trivial", value_only_change: "trivial", - generated_file: "trivial", + // accuracy lot 1 — generated_file décline par défaut (le fichier se régénère, il ne se + // fusionne pas) : le compter « trivial » gonflerait la couverture mesurée. + generated_file: "unresolved", refactoring_aware_merge: "advancedDeterministic", token_level_merge: "advancedDeterministic", + format_semantic: "advancedDeterministic", llm_proposed: "model", complex: "unresolved", }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index fc418bab..5f2af981 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -31,6 +31,7 @@ export type ConflictType = | "token_level_merge" // v3.4 — fusion fine ligne/token, toujours proposée (jamais auto-appliquée) | "llm_proposed" // v2.5 — résolution proposée par LLM fallback (opt-in, priority 998) | "refactoring_aware_merge" // v2.6 — RefMerge : détection/inversion/rejeu de refactorings (expérimental, opt-in) + | "format_semantic" // accuracy lot 1 — hunk complex résolu par un résolveur format-aware (JSON/MD/YAML/Vue/CSS…), reclassifié pour que stats et trace disent la vérité | "complex"; // Conflit réel nécessitant intervention humaine /** Niveau de confiance discret (label seuil, utilisé dans les options) */ @@ -413,6 +414,13 @@ export interface HunkResolution { autoResolved: boolean; /** Raison lisible de la résolution (ou du refus de résolution) */ resolutionReason: string; + /** + * accuracy lot D — Présent uniquement quand le fichier a été décliné parce + * qu'auto-généré ET que son chemin matche un écosystème du registre + * `regenerate/registry.ts`. Absent quand `resolveGeneratedFiles: true` + * (l'opt-in textuel gagne, la résolution n'est alors jamais déclinée). + */ + regenerationPlan?: import("./regenerate/plan.js").RegenerationPlan; } // ─── Phase 7.2 — Validation post-merge ─────────────────── @@ -442,6 +450,12 @@ export interface ValidationResult { syntaxError: string | null; /** Le contenu fusionné est-il valide ? */ isValid: boolean; + /** + * accuracy lot 1 — Violations d'invariants de format (au-delà de la syntaxe). + * Ex : deux sections `## [Unreleased]` dans un changelog, clé dupliquée + * dans un objet JSON. Non vide → les résolutions du fichier sont rétractées. + */ + invariantErrors?: string[]; /** * v2.4 — Résultat de la validation parse-tree via tree-sitter. * - `true` : l'arbre syntaxique ne contient aucun nœud d'erreur @@ -495,6 +509,42 @@ export interface MergeStats { } /** Options de configuration pour le moteur de résolution */ +/** + * accuracy lot C — Contexte du merge en cours : la donnée que le moteur n'a jamais eue. + * Optionnel et purement déclaratif — les appelants le détectent (CLI/MCP lisent + * l'état `.git`, le desktop connaît son opération) ; le cœur reste une fonction + * pure qui l'echo dans ses traces. + */ +export interface MergeContext { + /** L'opération git qui a produit ces marqueurs. */ + operation: "merge" | "rebase" | "cherry-pick" | "revert"; + /** + * Quel côté des marqueurs est la branche DANS LAQUELLE on fusionne. + * Dans la convention git c'est "ours" pour merge, rebase (ours = la branche + * sur laquelle on rebase) ET cherry-pick — mais l'appelant le déclare + * explicitement pour que le moteur n'ait jamais à re-dériver l'inversion + * ours/theirs du rebase. + */ + targetSide: "ours" | "theirs"; + /** Noms de refs, pour les traces et explications uniquement — jamais parsés pour décider. */ + oursRef?: string; + theirsRef?: string; +} + +/** + * accuracy lot D — État des autres fichiers de ce merge, tel que connu par + * l'appelant. Un fichier régénérable (ex: `package-lock.json`) dépend d'une + * ou plusieurs "sources de vérité" (ex: `package.json`) ; le moteur ne peut + * pas voir ces fichiers-là lui-même (il reçoit le contenu conflictuel d'UN + * seul fichier à la fois et doit rester sans accès filesystem), donc + * l'appelant (CLI aujourd'hui, ayant déjà traité les autres fichiers du + * merge) le lui fournit explicitement. + */ +export interface RegenerationContext { + /** Clé = chemin repo-relative de CHAQUE AUTRE fichier de ce merge. */ + siblingFiles: Record; +} + export interface GitWandOptions { /** Résoudre les conflits whitespace-only (défaut: true) */ resolveWhitespace?: boolean; @@ -532,6 +582,37 @@ export interface GitWandOptions { * Exemple : `["src/**\/*.generated.ts", "*.pb.go", "api/openapi-client/**"]`. */ generatedFiles?: string[]; + /** + * accuracy lot 1 — Autoriser l'auto-résolution des fichiers générés (lockfiles, + * bundles, `dist/`…). Défaut : `false` — mesuré sur 1 662 merges réels, + * la version commitée de ces fichiers est la sortie d'un outil, pas la + * fusion de deux textes : l'auto-résolution divergeait de ce que les + * équipes livrent dans ~100 % des cas. Par défaut le moteur décline avec + * un message actionnable (« résous la source et régénère »). + */ + resolveGeneratedFiles?: boolean; + /** + * accuracy lot C — Contexte du merge en cours (opération + côté cible). `null`/absent : + * inconnu. Quand il est fourni, les décisions qui en dépendent (scalaires de + * version modifiés des deux côtés) deviennent déterministes : la branche + * cible gagne. Sans lui, ces cas sont proposés au lieu d'être appliqués. + */ + mergeContext?: MergeContext | null; + /** + * accuracy lot D — État des autres fichiers de ce merge (source de vérité + * d'un fichier régénérable, ex: package.json pour package-lock.json). + * Fourni par l'appelant (CLI aujourd'hui) qui a déjà résolu les autres + * fichiers du merge ; le moteur ne touche jamais au filesystem lui-même. + */ + regenerationContext?: RegenerationContext | null; + /** + * accuracy lot F — Conventions du dépôt, MESURÉES sur son propre historique + * de merges (voir `deriveConventions`). Précédence stricte : une option + * explicite (`.gitwandrc` ou appelant) gagne toujours sur une convention + * dérivée, qui gagne sur les défauts du moteur. Toute résolution influencée + * porte la provenance dans sa raison. + */ + conventions?: import("./conventions/types.js").RepoConventions | null; /** * v2.4 — Niveau de validation post-merge. * - `"balanced"` (défaut) : marqueurs résiduels + syntaxe JSON/YAML/TOML + parse-tree tree-sitter (async) diff --git a/packages/mcp/src/__tests__/regenerate-report.test.ts b/packages/mcp/src/__tests__/regenerate-report.test.ts new file mode 100644 index 00000000..2aee766e --- /dev/null +++ b/packages/mcp/src/__tests__/regenerate-report.test.ts @@ -0,0 +1,382 @@ +/** + * Task 3 (accuracy lot D) — `regenerate: true` reporting-only option on the + * 3 real `resolve()` MCP tool sites (`gitwand_status`, `gitwand_resolve_conflicts`, + * `gitwand_preview_merge`). + * + * Scope ruling under test: none of these tools ever executes regeneration — + * no process spawned, no git worktree created, no file touched beyond what + * the tool already does without the flag. Each test below asserts both the + * reported plan content AND that safety property explicitly (the + * safety-critical assertion per task-3-brief.md § "Tests"). + * + * Real temp git repos — no mocking of the git layer (AGENTS.md). The + * `package-lock.json` conflict doesn't need real npm output: `isGeneratedFile` + * matches by filename, not content (same technique as the CLI's + * `resolve-conventions.test.ts` / core's `conventions-derive.test.ts`). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// ESM module namespaces aren't configurable (vi.spyOn can't patch a named +// export of 'node:child_process' directly) — track every spawned binary via +// a hoisted pass-through mock instead: same real execution, just observed. +// `vi.hoisted` is required because `vi.mock` factories run before the rest +// of this file's imports. +const { spawnedBinaries } = vi.hoisted(() => ({ spawnedBinaries: [] as string[] })) +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + execFileSync: (...args: Parameters) => { + spawnedBinaries.push(String(args[0])) + return actual.execFileSync(...args) + }, + execSync: (...args: Parameters) => { + spawnedBinaries.push(String(args[0]).split(' ')[0]) + return actual.execSync(...args) + }, + } +}) + +import { handleToolCall } from '../tools/index.js' + +type ToolResult = Awaited> & { isError?: boolean } + +interface Repo { + cwd: string + cleanup: () => void +} + +function git(cwd: string, args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf-8', + env: { + ...process.env, + GIT_AUTHOR_NAME: 'Test', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'Test', + GIT_COMMITTER_EMAIL: 'test@example.com', + GIT_CONFIG_NOSYSTEM: '1', + }, + }).trim() +} + +function makeRepo(): Repo { + const cwd = mkdtempSync(join(tmpdir(), 'gitwand-mcp-regen-test-')) + git(cwd, ['init', '-b', 'main']) + git(cwd, ['config', 'user.email', 'test@example.com']) + git(cwd, ['config', 'user.name', 'Test']) + git(cwd, ['config', 'commit.gpgsign', 'false']) + return { cwd, cleanup: () => rmSync(cwd, { recursive: true, force: true }) } +} + +const LOCK = 'package-lock.json' + +function lockContent(shared: string): string { + return `{\n "name": "e2e",\n "lockfileVersion": 3,\n "shared": "${shared}"\n}\n` +} + +/** + * Repo with `package.json` (never conflicted) + `package-lock.json` + * (conflicted — the only diverging line changed on BOTH branches, so it + * classifies "complex" then reclassifies to "generated_file" by filename). + */ +function buildConflictedLockRepo(): Repo { + const repo = makeRepo() + const { cwd } = repo + writeFileSync(join(cwd, 'package.json'), '{"name":"e2e","version":"1.0.0"}\n', 'utf-8') + writeFileSync(join(cwd, LOCK), lockContent('base'), 'utf-8') + git(cwd, ['add', '-A']) + git(cwd, ['commit', '-m', 'init']) + + git(cwd, ['checkout', '-b', 'feature']) + writeFileSync(join(cwd, LOCK), lockContent('feature'), 'utf-8') + git(cwd, ['commit', '-a', '-m', 'feature: bump lock']) + + git(cwd, ['checkout', 'main']) + writeFileSync(join(cwd, LOCK), lockContent('main'), 'utf-8') + git(cwd, ['commit', '-a', '-m', 'main: bump lock']) + + try { + git(cwd, ['merge', 'feature']) + } catch { + // conflict expected + } + return repo +} + +/** + * Repo where BOTH `package.json` and `package-lock.json` conflict (unlike + * `buildConflictedLockRepo`, whose `package.json` never conflicts) — needed + * to prove the fix-round-1 regression: a narrowed `files:` param that omits + * an actually-conflicted `package.json` must NOT make the reported plan + * look runnable. + */ +function buildConflictedLockAndManifestRepo(): Repo { + const repo = makeRepo() + const { cwd } = repo + writeFileSync(join(cwd, 'package.json'), '{"name":"e2e","version":"1.0.0"}\n', 'utf-8') + writeFileSync(join(cwd, LOCK), lockContent('base'), 'utf-8') + git(cwd, ['add', '-A']) + git(cwd, ['commit', '-m', 'init']) + + git(cwd, ['checkout', '-b', 'feature']) + writeFileSync(join(cwd, 'package.json'), '{"name":"e2e","version":"1.1.0-feature"}\n', 'utf-8') + writeFileSync(join(cwd, LOCK), lockContent('feature'), 'utf-8') + git(cwd, ['commit', '-a', '-m', 'feature: bump version + lock']) + + git(cwd, ['checkout', 'main']) + writeFileSync(join(cwd, 'package.json'), '{"name":"e2e","version":"1.1.0-main"}\n', 'utf-8') + writeFileSync(join(cwd, LOCK), lockContent('main'), 'utf-8') + git(cwd, ['commit', '-a', '-m', 'main: bump version + lock']) + + try { + git(cwd, ['merge', 'feature']) + } catch { + // conflict expected on both files + } + return repo +} + +/** Binaries the regenerate-tier registry would spawn — must NEVER appear in any execFile/execSync call made by these 3 MCP tools. */ +const REGEN_BINARIES = ['npm', 'pnpm', 'yarn', 'composer', 'cargo'] + +function assertNothingExecuted(): void { + for (const bin of spawnedBinaries) { + expect(REGEN_BINARIES).not.toContain(bin) + } +} + +function worktreeCount(cwd: string): number { + return git(cwd, ['worktree', 'list']).split('\n').filter((l) => l.trim().length > 0).length +} + +describe('MCP regenerate:true — reporting only, never executes (task 3)', () => { + beforeEach(() => { + spawnedBinaries.length = 0 + }) + + it('gitwand_status: regenerate:true reports an accurate runnable plan, executes nothing', async () => { + const { cwd, cleanup } = buildConflictedLockRepo() + try { + const worktreesBefore = worktreeCount(cwd) + const lockBefore = readFileSync(join(cwd, LOCK), 'utf-8') + + const result: ToolResult = await handleToolCall('gitwand_status', { regenerate: true }, cwd) + + expect(result.isError).toBeFalsy() + const parsed = JSON.parse(result.content[0].text) + expect(Array.isArray(parsed.regenerationPlans)).toBe(true) + const plan = parsed.regenerationPlans.find((p: { file: string }) => p.file === LOCK) + expect(plan).toBeDefined() + expect(plan.ecosystem).toBe('npm') + // package.json was never conflicted → treated as "clean" → runnable. + expect(plan.runnable).toBe(true) + expect(plan.sources).toEqual([{ path: 'package.json', state: 'clean' }]) + + // Safety-critical: reporting-only means no process spawn, no worktree, + // no file mutation beyond what a plain `gitwand_status` call already does + // (which never writes files). + assertNothingExecuted() + expect(worktreeCount(cwd)).toBe(worktreesBefore) + expect(readFileSync(join(cwd, LOCK), 'utf-8')).toBe(lockBefore) + } finally { + cleanup() + } + }, 30_000) + + it('gitwand_status: without regenerate, response has no regenerationPlans key (backward compatible)', async () => { + const { cwd, cleanup } = buildConflictedLockRepo() + try { + const result: ToolResult = await handleToolCall('gitwand_status', {}, cwd) + const parsed = JSON.parse(result.content[0].text) + expect(parsed.regenerationPlans).toBeUndefined() + } finally { + cleanup() + } + }, 30_000) + + it('gitwand_resolve_conflicts: regenerate:true reports the plan without writing or executing anything', async () => { + const { cwd, cleanup } = buildConflictedLockRepo() + try { + const worktreesBefore = worktreeCount(cwd) + const lockBefore = readFileSync(join(cwd, LOCK), 'utf-8') + + const result: ToolResult = await handleToolCall( + 'gitwand_resolve_conflicts', + { dry_run: true, regenerate: true }, + cwd, + ) + + expect(result.isError).toBeFalsy() + const parsed = JSON.parse(result.content[0].text) + expect(Array.isArray(parsed.regenerationPlans)).toBe(true) + const plan = parsed.regenerationPlans.find((p: { file: string }) => p.file === LOCK) + expect(plan).toBeDefined() + expect(plan.runnable).toBe(true) + // Declined by default (no --resolve-generated equivalent, no + // conventions, no .gitwandrc) — nothing auto-resolved, so dry_run + // wouldn't have written it anyway, but this proves regenerate:true + // doesn't change that. + expect(parsed.summary.autoResolved).toBe(0) + + assertNothingExecuted() + expect(worktreeCount(cwd)).toBe(worktreesBefore) + expect(readFileSync(join(cwd, LOCK), 'utf-8')).toBe(lockBefore) + } finally { + cleanup() + } + }, 30_000) + + it('gitwand_preview_merge: regenerate:true reports the plan, stays side-effect-free', async () => { + const { cwd, cleanup } = buildConflictedLockRepo() + try { + const worktreesBefore = worktreeCount(cwd) + const lockBefore = readFileSync(join(cwd, LOCK), 'utf-8') + + const result: ToolResult = await handleToolCall( + 'gitwand_preview_merge', + { operation: 'merge', regenerate: true }, + cwd, + ) + + expect(result.isError).toBeFalsy() + const parsed = JSON.parse(result.content[0].text) + expect(Array.isArray(parsed.regenerationPlans)).toBe(true) + const plan = parsed.regenerationPlans.find((p: { file: string }) => p.file === LOCK) + expect(plan).toBeDefined() + expect(plan.runnable).toBe(true) + + assertNothingExecuted() + expect(worktreeCount(cwd)).toBe(worktreesBefore) + expect(readFileSync(join(cwd, LOCK), 'utf-8')).toBe(lockBefore) + } finally { + cleanup() + } + }, 30_000) + + it( + // Fix round 1 regression — mirrors Task 2's own CLI-side regression test + // for the same bug (`resolve.ts:292/316`). A caller-narrowed `files:` + // param must never make an actually-conflicted-elsewhere source of truth + // look "clean" just because THIS call didn't fetch it. + 'gitwand_resolve_conflicts: a narrowed files: param excluding a conflicted package.json must NOT report runnable:true', + async () => { + const { cwd, cleanup } = buildConflictedLockAndManifestRepo() + try { + // Precondition: package.json really is conflicted repo-wide (not just + // package-lock.json) — confirms this test actually exercises the gap. + const conflicted = git(cwd, ['diff', '--name-only', '--diff-filter=U']).trim().split('\n').sort() + expect(conflicted).toEqual(['package-lock.json', 'package.json'].sort()) + + const worktreesBefore = worktreeCount(cwd) + + // Narrowed on purpose: only package-lock.json, excluding the + // genuinely-conflicted package.json. + const result: ToolResult = await handleToolCall( + 'gitwand_resolve_conflicts', + { files: [LOCK], dry_run: true, regenerate: true }, + cwd, + ) + + expect(result.isError).toBeFalsy() + const parsed = JSON.parse(result.content[0].text) + const plan = parsed.regenerationPlans.find((p: { file: string }) => p.file === LOCK) + expect(plan).toBeDefined() + // The safety-critical assertion: package.json's real state (conflicted) + // is unknown to THIS narrowed call — the plan must NOT claim runnable. + expect(plan.runnable).toBe(false) + const source = plan.sources.find((s: { path: string }) => s.path === 'package.json') + expect(source?.state).toBe('conflicted') + + assertNothingExecuted() + expect(worktreeCount(cwd)).toBe(worktreesBefore) + } finally { + cleanup() + } + }, + 30_000, + ) + + it( + // Final review Finding 2 — "not conflicted" must not be conflated with + // "clean": a yarn-CLASSIC repo (has yarn.lock, no `.yarnrc.yml` at all — + // the berry marker `registry.ts` requires) has `.yarnrc.yml` trivially + // "not conflicted" simply because it never existed. Before the fix, that + // made the reported yarn-berry plan come back `runnable: true` for a repo + // the registry's own documented guard says must never be runnable. + 'gitwand_status: yarn-classic repo (no .yarnrc.yml) must NOT report runnable:true for the yarn-berry plan', + async () => { + const repo = makeRepo() + const { cwd, cleanup } = repo + try { + const YARN_LOCK = 'yarn.lock' + writeFileSync(join(cwd, 'package.json'), '{"name":"e2e","version":"1.0.0"}\n', 'utf-8') + writeFileSync(join(cwd, YARN_LOCK), lockContent('base'), 'utf-8') + git(cwd, ['add', '-A']) + git(cwd, ['commit', '-m', 'init']) + + git(cwd, ['checkout', '-b', 'feature']) + writeFileSync(join(cwd, YARN_LOCK), lockContent('feature'), 'utf-8') + git(cwd, ['commit', '-a', '-m', 'feature: bump lock']) + + git(cwd, ['checkout', 'main']) + writeFileSync(join(cwd, YARN_LOCK), lockContent('main'), 'utf-8') + git(cwd, ['commit', '-a', '-m', 'main: bump lock']) + + try { + git(cwd, ['merge', 'feature']) + } catch { + // conflict expected + } + + // Precondition: only yarn.lock is conflicted, and `.yarnrc.yml` + // genuinely does not exist anywhere in this repo (classic yarn). + const conflicted = git(cwd, ['diff', '--name-only', '--diff-filter=U']).trim().split('\n') + expect(conflicted).toEqual([YARN_LOCK]) + expect(existsSync(join(cwd, '.yarnrc.yml'))).toBe(false) + + const worktreesBefore = worktreeCount(cwd) + + const result: ToolResult = await handleToolCall('gitwand_status', { regenerate: true }, cwd) + + expect(result.isError).toBeFalsy() + const parsed = JSON.parse(result.content[0].text) + const plan = parsed.regenerationPlans.find((p: { file: string }) => p.file === YARN_LOCK) + expect(plan).toBeDefined() + expect(plan.ecosystem).toBe('yarn-berry') + // Safety-critical: absent berry marker must block runnable, not be + // silently defaulted to "clean" just because it was never conflicted. + expect(plan.runnable).toBe(false) + const marker = plan.sources.find((s: { path: string }) => s.path === '.yarnrc.yml') + expect(marker?.state).toBe('conflicted') + + assertNothingExecuted() + expect(worktreeCount(cwd)).toBe(worktreesBefore) + } finally { + cleanup() + } + }, + 30_000, + ) + + it('gitwand_preview_merge: rebase/cherry-pick operations never populate regenerationPlans (out of this task\'s scope)', async () => { + const { cwd, cleanup } = buildConflictedLockRepo() + try { + // No `onto` — expect a structured error, not a crash, and definitely no plan. + const result: ToolResult = await handleToolCall( + 'gitwand_preview_merge', + { operation: 'rebase', regenerate: true }, + cwd, + ) + expect(result.isError).toBe(true) + } finally { + cleanup() + } + }, 30_000) +}) diff --git a/packages/mcp/src/merge-context.ts b/packages/mcp/src/merge-context.ts new file mode 100644 index 00000000..99c4ba58 --- /dev/null +++ b/packages/mcp/src/merge-context.ts @@ -0,0 +1,71 @@ +/** + * accuracy lot C — Détection du contexte de merge pour les tools MCP. + * + * Volontairement dupliqué depuis `@gitwand/cli` (src/git.ts) plutôt + * qu'importé : le MCP ne dépend pas du CLI, et `@gitwand/core` reste sans + * dépendance Node (il tourne dans le navigateur). Les deux copies suivent la + * même convention — « ours » est la branche CIBLE pour merge, rebase ET + * cherry-pick, déclaré via `targetSide` pour que le moteur n'ait jamais à + * re-dériver l'inversion ours/theirs du rebase. + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { MergeContext } from "@gitwand/core"; + +function gitDir(cwd: string): string | null { + try { + return execFileSync("git", ["rev-parse", "--absolute-git-dir"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + } catch { + return null; + } +} + +function revName(cwd: string, args: string[]): string | undefined { + try { + const out = execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + return out || undefined; + } catch { + return undefined; + } +} + +function readRefFile(path: string): string | undefined { + try { + return readFileSync(path, "utf-8").trim().replace(/^refs\/heads\//, "") || undefined; + } catch { + return undefined; + } +} + +/** Détecte l'opération git en cours dans `cwd`. `null` = aucune / hors dépôt. */ +export function detectMergeContext(cwd: string): MergeContext | null { + const dir = gitDir(cwd); + if (!dir) return null; + + if (existsSync(join(dir, "MERGE_HEAD"))) { + return { + operation: "merge", + targetSide: "ours", + oursRef: revName(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]), + theirsRef: revName(cwd, ["name-rev", "--name-only", "--refs=refs/heads/*", "--refs=refs/remotes/*", "MERGE_HEAD"]), + }; + } + if (existsSync(join(dir, "rebase-merge")) || existsSync(join(dir, "rebase-apply"))) { + const rebaseDir = existsSync(join(dir, "rebase-merge")) ? "rebase-merge" : "rebase-apply"; + return { + operation: "rebase", + targetSide: "ours", + oursRef: revName(cwd, ["name-rev", "--name-only", "--refs=refs/heads/*", "--refs=refs/remotes/*", "HEAD"]), + theirsRef: readRefFile(join(dir, rebaseDir, "head-name")), + }; + } + if (existsSync(join(dir, "CHERRY_PICK_HEAD"))) { + return { operation: "cherry-pick", targetSide: "ours", oursRef: revName(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) }; + } + if (existsSync(join(dir, "REVERT_HEAD"))) { + return { operation: "revert", targetSide: "ours", oursRef: revName(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) }; + } + return null; +} diff --git a/packages/mcp/src/regenerate-report.ts b/packages/mcp/src/regenerate-report.ts new file mode 100644 index 00000000..151c66e7 --- /dev/null +++ b/packages/mcp/src/regenerate-report.ts @@ -0,0 +1,182 @@ +/** + * accuracy lot D (task 3) — MCP-local, REPORTING-ONLY regeneration helper. + * + * Scope ruling (task-3-brief.md § 4): no MCP tool ever executes regeneration. + * None of the 3 MCP tools that call `resolve()` (`gitwand_status`, + * `gitwand_resolve_conflicts`, `gitwand_preview_merge`) spawns a process or + * creates a git worktree, ever — that machinery (`regenerate-runner.ts`) + * lives ONLY in `@gitwand/cli`, which MCP must not depend on + * (`packages/mcp/CLAUDE.md`: thin wrapper around `@gitwand/core` only). + * + * This module re-derives an accurate `RegenerationPlan` — via the same pure + * core exports the CLI itself uses (`findEcosystem`/`buildRegenerationPlan`) + * — from the file states a tool call already knows about, so a caller passing + * `regenerate: true` gets a correct `runnable`/ecosystem verdict in the JSON + * response instead of the always-`runnable: false` plan pass 1 attaches + * on its own (regenerationContext is unknown to core at that point — see + * `resolver/index.ts`). It mirrors the CLI's pass 2 sibling-state logic + * (`commands/resolve.ts`) closely enough to be accurate, without any of the + * CLI's execution machinery. + * + * `loadPersistedConventions`/`loadGitwandrcResolveGeneratedFiles` below are + * intentionally duplicated from `@gitwand/cli` (`commands/conventions.ts` / + * `llm-config.ts`) rather than imported — same reason as `merge-context.ts`'s + * header comment: MCP must not depend on the CLI package. Both read from an + * arbitrary `cwd`, mirroring the `detectMergeContext(cwd)` pattern already + * used elsewhere in this package (MCP has no implicit `process.cwd()`). + */ + +import { readFileSync, existsSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { join, resolve as resolvePath } from "node:path"; +import { + findEcosystem, + buildRegenerationPlan, + parseGitwandrc, + type MergeResult, + type RegenerationContext, + type RegenerationPlan, + type RepoConventions, +} from "@gitwand/core"; + +function gitTry(cwd: string, args: string[]): string | null { + try { + const out = execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + return out || null; + } catch { + return null; + } +} + +/** + * `.git/gitwand/conventions.json` for the repo at `cwd`, tolerant — never + * throws. Absent/unreadable/invalid all mean "no conventions", exactly the + * `null` core expects on `options.conventions` by default. + */ +export function loadPersistedConventions(cwd: string): RepoConventions | null { + const gitDir = gitTry(cwd, ["rev-parse", "--absolute-git-dir"]); + if (!gitDir) return null; + const path = join(gitDir, "gitwand", "conventions.json"); + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf-8")) as RepoConventions; + } catch { + return null; + } +} + +/** + * `.gitwandrc`/`.gitwandrc.json` `resolveGeneratedFiles` for the repo at + * `cwd`. Returns `undefined` (never a concrete `false`) when unset, outside a + * repo, or the file is missing/invalid — the same "no explicit opinion" + * signal the CLI's `loadGitwandrcResolveGeneratedFiles` returns, letting a + * measured `generatedFiles` convention take over exactly as it does there. + */ +export function loadGitwandrcResolveGeneratedFiles(cwd: string): boolean | undefined { + const root = gitTry(cwd, ["rev-parse", "--show-toplevel"]); + if (!root) return undefined; + + for (const filename of [".gitwandrc", ".gitwandrc.json"]) { + let content: string; + try { + content = readFileSync(join(root, filename), "utf-8"); + } catch { + continue; + } + const parsed = parseGitwandrc(content); + if (parsed === null) continue; + return parsed.resolveGeneratedFiles; + } + return undefined; +} + +export interface RegenerationReportEntry { + file: string; + ecosystem: RegenerationPlan["ecosystem"]; + runnable: boolean; + sources: RegenerationPlan["sources"]; +} + +/** + * Re-derives an accurate `RegenerationPlan` for every declined + * `generated_file` resolution across `results` — each `{ file, result }` this + * SAME tool call already computed. Pure reporting: reads nothing beyond what + * `results`/`conflictedFiles` already carry, spawns nothing. + * + * `conflictedFiles` MUST be the repo's FULL conflicted-file set (e.g. + * `getConflictedFiles(cwd)` with no narrowing), not just the (possibly + * caller-narrowed) file list a tool call happened to process — this is the + * exact bug the CLI's Task 2 fix round already closed for `resolve.ts` + * (`resolve.ts:292/316`): a source-of-truth path absent from `results` is + * ONLY safe to default to "clean" when it is ALSO absent from the repo's + * full conflicted set. If it's absent from `results` (because a caller's + * `files:` param narrowed it out) but present in `conflictedFiles`, its real + * state is unknown to this call — it must NOT be reported as runnable. + * (Absent from `siblingFiles` entirely already means "conflicted" by + * `buildRegenerationPlan`'s own default — see `regenerate/plan.ts`.) + * + * This is a best-effort simplification appropriate for a reporting-only + * surface: `regenerate: true` documents plan AVAILABILITY, never a + * guarantee — actually applying it always goes through + * `gitwand resolve --regenerate` (the CLI), which verifies the real working + * tree before running anything. + * + * Final review Finding 2 — `cwd` is required so a sourceOfTruth that is + * neither conflicted (per `conflictedFiles`) NOR present here in `results` + * can be checked against the real filesystem before defaulting it to + * "clean". "Never conflicted" does NOT imply "clean" — it can just as well + * mean the file does not exist at all (e.g. `.yarnrc.yml` on a yarn-CLASSIC + * repo, which never had that file). Reporting such a file as "clean" made + * yarn-berry plans come back `runnable: true` for classic-yarn repos, + * directly contradicting the registry's own documented berry-marker guard + * (`registry.ts`). + */ +export function buildRegenerationReport( + results: Array<{ file: string; result: MergeResult }>, + conflictedFiles: string[], + cwd: string, +): RegenerationReportEntry[] { + const conflictedFileSet = new Set(conflictedFiles); + const siblingFiles: RegenerationContext["siblingFiles"] = {}; + for (const { file, result } of results) { + siblingFiles[file] = { + state: + result.stats.totalConflicts === 0 + ? "clean" + : result.stats.remaining === 0 + ? "resolved" + : "conflicted", + }; + } + + const report: RegenerationReportEntry[] = []; + const seen = new Set(); + for (const { file, result } of results) { + if (seen.has(file)) continue; + const hasRegenCandidate = result.resolutions.some((r) => r.regenerationPlan !== undefined); + if (!hasRegenCandidate) continue; + seen.add(file); + + const ecosystem = findEcosystem(file); + if (!ecosystem) continue; // should not happen — pass 1 already implied a match + + for (const source of ecosystem.sourcesOfTruth) { + if (source in siblingFiles) continue; + // Never conflicted anywhere in the repo AND actually present on disk + // ⇒ safe to treat as "clean". Conflicted in the repo but absent from + // THIS call's results (a narrowed `files:` param) ⇒ unknown to this + // call — leave it out of siblingFiles entirely, which + // `buildRegenerationPlan` itself already treats as "conflicted" (never + // silently runnable). Same for a source that's simply absent from disk + // (e.g. `.yarnrc.yml` on a yarn-classic repo) — "never conflicted" + // there means "never existed", not "clean". + if (!conflictedFileSet.has(source) && existsSync(resolvePath(cwd, source))) { + siblingFiles[source] = { state: "clean" }; + } + } + + const plan = buildRegenerationPlan(file, ecosystem, { siblingFiles }); + report.push({ file, ecosystem: plan.ecosystem, runnable: plan.runnable, sources: plan.sources }); + } + return report; +} diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index 6dacdadf..b49d1163 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -16,6 +16,17 @@ import { execSync, execFileSync } from "node:child_process"; import { resolve as resolvePath } from "node:path"; import { resolve, summarizeTiers, type MergeResult, type ConflictType } from "@gitwand/core"; import { resolveHunkToolDefinition, handleResolveHunk } from "./resolve_hunk.js"; +import { detectMergeContext } from "../merge-context.js"; +import { + buildRegenerationReport, + loadGitwandrcResolveGeneratedFiles, + loadPersistedConventions, + type RegenerationReportEntry, +} from "../regenerate-report.js"; + +/** Shared description suffix for the `regenerate` param on all 3 tools that expose it — see task-3-brief.md § 4 scope ruling. */ +const REGENERATE_PARAM_DESCRIPTION = + "If true, report GitWand's regenerate-tier plan (lockfile ecosystem + whether it's currently runnable) for any declined generated file (e.g. package-lock.json), reusing the same file states this call already computed. REPORTING ONLY — this never executes anything (no process spawned, no worktree created, no file written beyond what this tool already writes without the flag). To actually apply a plan, run `gitwand resolve --regenerate` (the CLI). Default: false."; // ─── Tool definitions ────────────────────────────────────── @@ -32,6 +43,10 @@ export function registerTools() { type: "string", description: "Working directory (repo root). Defaults to server cwd.", }, + regenerate: { + type: "boolean", + description: REGENERATE_PARAM_DESCRIPTION, + }, }, }, }, @@ -60,6 +75,10 @@ export function registerTools() { enum: ["prefer-ours", "prefer-theirs", "prefer-merge", "prefer-safety", "strict"], description: "Merge policy to use. Default: prefer-theirs.", }, + regenerate: { + type: "boolean", + description: REGENERATE_PARAM_DESCRIPTION, + }, }, }, }, @@ -87,6 +106,10 @@ export function registerTools() { type: "string", description: "Required when operation is 'cherry-pick': the commit to simulate cherry-picking onto HEAD.", }, + regenerate: { + type: "boolean", + description: `Only applies when operation is 'merge' (the default). ${REGENERATE_PARAM_DESCRIPTION}`, + }, }, }, }, @@ -436,7 +459,7 @@ export async function handleToolCall( switch (name) { case "gitwand_status": - return toolStatus(cwd); + return toolStatus(cwd, args); case "gitwand_resolve_conflicts": return toolResolve(cwd, args); case "gitwand_preview_merge": @@ -454,8 +477,9 @@ export async function handleToolCall( } } -async function toolStatus(cwd: string) { +async function toolStatus(cwd: string, args: Record = {}) { const files = getConflictedFiles(cwd); + const wantsRegenerationReport = args.regenerate === true; if (files.length === 0) { return { @@ -463,7 +487,16 @@ async function toolStatus(cwd: string) { }; } + // accuracy lot F/D (task 3) — same measured-convention/.gitwandrc precedence + // the CLI applies (`resolveGeneratedFiles.ts` Bug A/B fix): explicit + // `.gitwandrc` beats a measured convention, which beats core's own default. + // MCP has no `--resolve-generated` flag equivalent on this read-only tool, + // so there is no higher-precedence "explicit call arg" tier here. + const conventions = loadPersistedConventions(cwd); + const resolveGeneratedFiles = loadGitwandrcResolveGeneratedFiles(cwd); + const aggregateByType: Partial> = {}; + const resultsForReport: Array<{ file: string; result: MergeResult }> = []; const conflicts = files.map((file) => { const filePath = resolvePath(cwd, file); try { @@ -472,8 +505,13 @@ async function toolStatus(cwd: string) { // format-aware dispatch and the confidence gate, so every hunk comes back // unresolved and `stats.autoResolved` is always 0. This is a prediction on // in-memory content, nothing is written, so run the real resolution. - const result = resolve(content, file); + const result = resolve(content, file, { + mergeContext: detectMergeContext(cwd), + conventions, + resolveGeneratedFiles, + }); addByType(aggregateByType, result.stats.byType); + resultsForReport.push({ file, result }); return { path: file, totalConflicts: result.stats.totalConflicts, @@ -496,6 +534,13 @@ async function toolStatus(cwd: string) { // v3.4 — "recoverable-before-model" : of the residual past the trivial passes, // how much is still recoverable deterministically before the model is invoked. const tierSummary = summarizeTiers(aggregateByType as Record); + // accuracy lot D (task 3, § 4) — reporting-only; never executes anything. + // `files` IS the repo's full conflicted set here (toolStatus never narrows + // it), so it doubles as the `conflictedFiles` guard buildRegenerationReport + // needs against the fix-round-1 "narrowed files ⇒ falsely clean" bug. + const regenerationPlans = wantsRegenerationReport + ? buildRegenerationReport(resultsForReport, files, cwd) + : undefined; return { content: [{ @@ -507,6 +552,7 @@ async function toolStatus(cwd: string) { remaining: totalConflicts - totalResolvable, tierSummary, conflicts, + ...(regenerationPlans !== undefined ? { regenerationPlans } : {}), }, null, 2), }], }; @@ -516,6 +562,7 @@ async function toolResolve(cwd: string, args: Record) { let files = (args.files as string[]) ?? []; const dryRun = (args.dry_run as boolean) ?? false; const policy = args.policy as string | undefined; + const wantsRegenerationReport = args.regenerate === true; if (files.length === 0) { files = getConflictedFiles(cwd); @@ -527,15 +574,29 @@ async function toolResolve(cwd: string, args: Record) { }; } + // accuracy lot F/D (task 3) — same precedence as the CLI's Bug A/B fix: + // a measured `generatedFiles` convention only engages the textual "merge" + // path when there is no higher-precedence explicit opinion; `.gitwandrc` + // always wins over the convention. + const conventions = loadPersistedConventions(cwd); + const resolveGeneratedFiles = loadGitwandrcResolveGeneratedFiles(cwd); + const aggregateByType: Partial> = {}; + const resultsForReport: Array<{ file: string; result: MergeResult }> = []; const results = files.map((file) => { const filePath = resolvePath(cwd, file); try { const content = readFileSync(filePath, "utf-8"); const result = resolve(content, file, { ...(policy ? { policy: policy as any } : {}), + // accuracy lot C — l'opération en cours rend déterministes les décisions qui en + // dépendent (versions modifiées des deux côtés → la cible gagne). + mergeContext: detectMergeContext(cwd), + conventions, + resolveGeneratedFiles, }); addByType(aggregateByType, result.stats.byType); + resultsForReport.push({ file, result }); // Write resolved content unless dry-run if (!dryRun && result.stats.autoResolved > 0) { @@ -553,6 +614,17 @@ async function toolResolve(cwd: string, args: Record) { const totalResolved = results.reduce((s: number, r: Record) => s + ((r.autoResolved as number) ?? 0), 0); // v3.4 — "recoverable-before-model" tier summary, see summarizeTiers() in @gitwand/core. const tierSummary = summarizeTiers(aggregateByType as Record); + // accuracy lot D (task 3, § 4 — fix round 1) — reporting-only; never + // executes anything, regardless of `dryRun`. `files` may be a + // caller-NARROWED subset (`args.files`), unlike `toolStatus`/the merge + // branch of `toolPreview` — so it must NOT be reused as the + // `conflictedFiles` guard: a source-of-truth path excluded from a narrowed + // `files:` list would otherwise be misreported as "clean" even when it's + // genuinely conflicted elsewhere in the repo. Always re-fetch the repo's + // FULL conflicted set for that guard. + const regenerationPlans = wantsRegenerationReport + ? buildRegenerationReport(resultsForReport, getConflictedFiles(cwd), cwd) + : undefined; return { content: [{ @@ -568,6 +640,7 @@ async function toolResolve(cwd: string, args: Record) { tierSummary, }, files: results, + ...(regenerationPlans !== undefined ? { regenerationPlans } : {}), }, null, 2), }], }; @@ -585,6 +658,7 @@ async function toolPreview(cwd: string, args: Record) { // Default: merge — analyze conflicts already present in the working tree. const files = getConflictedFiles(cwd); + const wantsRegenerationReport = args.regenerate === true; if (files.length === 0) { return { @@ -592,6 +666,11 @@ async function toolPreview(cwd: string, args: Record) { }; } + // accuracy lot F/D (task 3) — same precedence as the CLI's Bug A/B fix. + const conventions = loadPersistedConventions(cwd); + const resolveGeneratedFiles = loadGitwandrcResolveGeneratedFiles(cwd); + + const resultsForReport: Array<{ file: string; result: MergeResult }> = []; const previews = files.map((file) => { const filePath = resolvePath(cwd, file); try { @@ -600,14 +679,29 @@ async function toolPreview(cwd: string, args: Record) { // format-aware dispatch and the confidence gate, so every hunk comes back // unresolved and `stats.autoResolved` is always 0. This is a prediction on // in-memory content, nothing is written, so run the real resolution. - const result = resolve(content, file); + const result = resolve(content, file, { + mergeContext: detectMergeContext(cwd), + conventions, + resolveGeneratedFiles, + }); + resultsForReport.push({ file, result }); return serializeResult(file, result); } catch (err: any) { return { path: file, error: err.message }; } }); - return previewResponse("merge", files.length, previews); + // accuracy lot D (task 3, § 4) — reporting-only; this whole tool is already + // side-effect-free ("Does NOT modify the working tree, index, or HEAD" per + // its own tool description), so `regenerate: true` here changes nothing + // beyond what's included in the JSON response. `files` IS the repo's full + // conflicted set here (no `files:` narrowing param on this tool), so it + // doubles as the `conflictedFiles` guard (fix round 1). + const regenerationPlans = wantsRegenerationReport + ? buildRegenerationReport(resultsForReport, files, cwd) + : undefined; + + return previewResponse("merge", files.length, previews, 0, regenerationPlans); } /** @@ -630,6 +724,10 @@ function previewResponse( fileCount: number, previews: Array>, addDeleteCount = 0, + // accuracy lot D (task 3, § 4) — only ever populated by the merge branch of + // `toolPreview` (the sole "real resolve() site" among the 3 preview + // operations, per the brief's scope ruling); `undefined` for rebase/cherry-pick. + regenerationPlans?: RegenerationReportEntry[], ) { const totalConflicts = previews.reduce((s: number, r) => s + ((r.totalConflicts as number) ?? 0), 0); const totalResolvable = previews.reduce((s: number, r) => s + ((r.autoResolved as number) ?? 0), 0); @@ -672,6 +770,7 @@ function previewResponse( : 100, }, files: previews, + ...(regenerationPlans !== undefined ? { regenerationPlans } : {}), }, null, 2), }], }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f65421ea..2dbd32fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,12 @@ importers: '@tauri-apps/cli': specifier: ^2.11.4 version: 2.11.4 + smol-toml: + specifier: ^1.8.0 + version: 1.8.0 + yaml: + specifier: ^2.9.0 + version: 2.9.0 apps/desktop: dependencies: @@ -115,7 +121,7 @@ importers: version: 25.5.0 '@vitejs/plugin-vue': specifier: ^5.2.0 - version: 5.2.4(vite@6.4.3(@types/node@25.5.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) + version: 5.2.4(vite@6.4.3(@types/node@25.5.0)(yaml@2.9.0))(vue@3.5.32(typescript@5.9.3)) concurrently: specifier: ^9.0.0 version: 9.2.1 @@ -130,10 +136,10 @@ importers: version: 5.9.3 vite: specifier: ^6.4.3 - version: 6.4.3(@types/node@25.5.0)(yaml@2.8.3) + version: 6.4.3(@types/node@25.5.0)(yaml@2.9.0) vitest: specifier: ^4.1.0 - version: 4.1.0(@types/node@25.5.0)(jsdom@25.0.1)(vite@6.4.3(@types/node@25.5.0)(yaml@2.8.3)) + version: 4.1.0(@types/node@25.5.0)(jsdom@25.0.1)(vite@6.4.3(@types/node@25.5.0)(yaml@2.9.0)) vue-tsc: specifier: ^2.1.0 version: 2.2.12(typescript@5.9.3) @@ -143,6 +149,12 @@ importers: '@gitwand/core': specifier: workspace:* version: link:../core + smol-toml: + specifier: ^1.6.1 + version: 1.6.1 + yaml: + specifier: ^2.8.3 + version: 2.8.3 devDependencies: '@types/node': specifier: ^25.5.0 @@ -196,7 +208,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.0(@types/node@25.5.0)(jsdom@25.0.1)(vite@7.3.5(@types/node@25.5.0)(yaml@2.8.3)) + version: 4.1.0(@types/node@25.5.0)(jsdom@25.0.1)(vite@7.3.5(@types/node@25.5.0)(yaml@2.9.0)) packages/vscode: dependencies: @@ -231,10 +243,10 @@ importers: devDependencies: vitepress: specifier: 2.0.0-alpha.17 - version: 2.0.0-alpha.17(@types/node@25.5.0)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.8.3) + version: 2.0.0-alpha.17(@types/node@25.5.0)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0) vitest: specifier: ^4.1.0 - version: 4.1.0(@types/node@25.5.0)(jsdom@25.0.1)(vite@7.3.5(@types/node@25.5.0)(yaml@2.8.3)) + version: 4.1.0(@types/node@25.5.0)(jsdom@25.0.1)(vite@7.3.5(@types/node@25.5.0)(yaml@2.9.0)) vue: specifier: ^3.5.0 version: 3.5.32(typescript@5.9.3) @@ -3167,6 +3179,10 @@ packages: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3646,6 +3662,11 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -4968,15 +4989,15 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@25.5.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))': + '@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@25.5.0)(yaml@2.9.0))(vue@3.5.32(typescript@5.9.3))': dependencies: - vite: 6.4.3(@types/node@25.5.0)(yaml@2.8.3) + vite: 6.4.3(@types/node@25.5.0)(yaml@2.9.0) vue: 3.5.32(typescript@5.9.3) - '@vitejs/plugin-vue@6.0.7(vite@7.3.5(@types/node@25.5.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.7(vite@7.3.5(@types/node@25.5.0)(yaml@2.9.0))(vue@3.5.32(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 7.3.5(@types/node@25.5.0)(yaml@2.8.3) + vite: 7.3.5(@types/node@25.5.0)(yaml@2.9.0) vue: 3.5.32(typescript@5.9.3) '@vitest/expect@4.1.0': @@ -4988,13 +5009,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.0(vite@6.4.3(@types/node@25.5.0)(yaml@2.8.3))': + '@vitest/mocker@4.1.0(vite@6.4.3(@types/node@25.5.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.0 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.3(@types/node@25.5.0)(yaml@2.8.3) + vite: 6.4.3(@types/node@25.5.0)(yaml@2.9.0) '@vitest/mocker@4.1.0(vite@7.3.5(@types/node@25.5.0)(yaml@2.8.3))': dependencies: @@ -5004,6 +5025,14 @@ snapshots: optionalDependencies: vite: 7.3.5(@types/node@25.5.0)(yaml@2.8.3) + '@vitest/mocker@4.1.0(vite@7.3.5(@types/node@25.5.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.5(@types/node@25.5.0)(yaml@2.9.0) + '@vitest/pretty-format@4.1.0': dependencies: tinyrainbow: 3.1.0 @@ -6740,6 +6769,8 @@ snapshots: smol-toml@1.6.1: {} + smol-toml@1.8.0: {} + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} @@ -6977,7 +7008,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@6.4.3(@types/node@25.5.0)(yaml@2.8.3): + vite@6.4.3(@types/node@25.5.0)(yaml@2.9.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -6988,7 +7019,7 @@ snapshots: optionalDependencies: '@types/node': 25.5.0 fsevents: 2.3.3 - yaml: 2.8.3 + yaml: 2.9.0 vite@7.3.5(@types/node@25.5.0)(yaml@2.8.3): dependencies: @@ -7003,7 +7034,20 @@ snapshots: fsevents: 2.3.3 yaml: 2.8.3 - vitepress@2.0.0-alpha.17(@types/node@25.5.0)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.8.3): + vite@7.3.5(@types/node@25.5.0)(yaml@2.9.0): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 + rollup: 4.62.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.5.0 + fsevents: 2.3.3 + yaml: 2.9.0 + + vitepress@2.0.0-alpha.17(@types/node@25.5.0)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0): dependencies: '@docsearch/css': 4.6.3 '@docsearch/js': 4.6.3 @@ -7013,7 +7057,7 @@ snapshots: '@shikijs/transformers': 3.23.0 '@shikijs/types': 3.23.0 '@types/markdown-it': 14.1.2 - '@vitejs/plugin-vue': 6.0.7(vite@7.3.5(@types/node@25.5.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) + '@vitejs/plugin-vue': 6.0.7(vite@7.3.5(@types/node@25.5.0)(yaml@2.9.0))(vue@3.5.32(typescript@5.9.3)) '@vue/devtools-api': 8.1.3 '@vue/shared': 3.5.32 '@vueuse/core': 14.3.0(vue@3.5.32(typescript@5.9.3)) @@ -7022,7 +7066,7 @@ snapshots: mark.js: 8.11.1 minisearch: 7.2.0 shiki: 3.23.0 - vite: 7.3.5(@types/node@25.5.0)(yaml@2.8.3) + vite: 7.3.5(@types/node@25.5.0)(yaml@2.9.0) vue: 3.5.32(typescript@5.9.3) optionalDependencies: postcss: 8.5.15 @@ -7051,10 +7095,10 @@ snapshots: - universal-cookie - yaml - vitest@4.1.0(@types/node@25.5.0)(jsdom@25.0.1)(vite@6.4.3(@types/node@25.5.0)(yaml@2.8.3)): + vitest@4.1.0(@types/node@25.5.0)(jsdom@25.0.1)(vite@6.4.3(@types/node@25.5.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.0 - '@vitest/mocker': 4.1.0(vite@6.4.3(@types/node@25.5.0)(yaml@2.8.3)) + '@vitest/mocker': 4.1.0(vite@6.4.3(@types/node@25.5.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.0 '@vitest/runner': 4.1.0 '@vitest/snapshot': 4.1.0 @@ -7071,7 +7115,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 6.4.3(@types/node@25.5.0)(yaml@2.8.3) + vite: 6.4.3(@types/node@25.5.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.5.0 @@ -7107,6 +7151,34 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.0(@types/node@25.5.0)(jsdom@25.0.1)(vite@7.3.5(@types/node@25.5.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.0 + '@vitest/mocker': 4.1.0(vite@7.3.5(@types/node@25.5.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.0 + '@vitest/runner': 4.1.0 + '@vitest/snapshot': 4.1.0 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 7.3.5(@types/node@25.5.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.5.0 + jsdom: 25.0.1 + transitivePeerDependencies: + - msw + vscode-uri@3.1.0: {} vue-tsc@2.2.12(typescript@5.9.3): @@ -7188,6 +7260,8 @@ snapshots: yaml@2.8.3: {} + yaml@2.9.0: {} + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/scripts/lib/merge-tree.mjs b/scripts/lib/merge-tree.mjs new file mode 100644 index 00000000..de1d40ee --- /dev/null +++ b/scripts/lib/merge-tree.mjs @@ -0,0 +1,68 @@ +/** + * mergeTree(repo, p1, p2) — thin wrapper around `git merge-tree --write-tree` + * (diff3 conflict style), extracted out of `scripts/replay-regenerate.mjs`'s + * stage-1 candidate discovery so it can be unit-tested against real hermetic + * git repos. Returns `null` on a clean merge (exit 0 — no lockfile conflict + * possible). On a conflicted merge (exit 1) returns `{ treeOid, files }`, + * where `files` is the RAW, unquoted list of conflicted paths. Any other + * outcome (a real git error — bad revision, corrupt repo, etc.) is rethrown; + * callers that want a "just skip this one and count it" policy (see + * `replay-regenerate.mjs`) should wrap the call themselves. + * + * Bug (found by independent review after the regenerate-tier sweep already + * shipped): the default (non-`-z`) `--name-only` output C-quotes any path + * containing a `"` character or a non-ASCII byte (see `git help + * merge-tree`'s "Conflicted file info" section — quoting follows + * `core.quotePath`'s rule, unconditionally for embedded `"`). Those quoted, + * escaped strings never match the RAW bytes `seedScratchIndex` + * (scripts/lib/seed-index.mjs) compares `skipPaths` against (it reads via + * `git ls-tree -z`, always unquoted) — so a C-quoted conflicted path would + * silently fail to be recognised as a skip path, leaking literal diff3 + * marker content into the scratch index — exactly the failure mode + * `skipPaths` exists to prevent, just one layer upstream. Fixed by using + * `-z` for the `merge-tree` invocation itself. + * + * `-z` output shape for a non-`--stdin` invocation — confirmed empirically + * against a real git 2.50 binary (see merge-tree.test.mjs); do NOT trust + * `git help merge-tree`'s prose alone for the exact delimiter shape, since it + * describes the general grammar but not this file's exact byte-for-byte + * token boundaries: + * + * \0\0\0...\0\0\0\0 + * + * i.e. the tree OID, then each conflicted path as its own NUL-terminated + * raw-byte token (no quoting), then ONE EXTRA NUL marking the start of the + * messages section (per `git help merge-tree`: "-z ... Also begin the + * messages section with a NUL character instead of a newline" — mirroring + * the blank-line separator in the non-`-z` format), then zero or more + * message records this function does not need and ignores. On a clean merge + * (exit 0) the output is just `\0` — no path list, no messages, + * since `--[no-]messages` defaults to omitting them when there is nothing to + * report. + */ +import { execFileSync } from "node:child_process"; + +export function mergeTree(repo, p1, p2) { + try { + execFileSync( + "git", + ["-C", repo, "-c", "merge.conflictstyle=diff3", "merge-tree", "-z", "--write-tree", "--name-only", p1, p2], + { encoding: "utf-8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }, + ); + return null; // exit 0 → clean merge, no lockfile conflict possible + } catch (err) { + if (err.status === 1 && typeof err.stdout === "string") { + const tokens = err.stdout.split("\0"); + const treeOid = tokens[0]; + // Paths run from index 1 up to (not including) the first empty-string + // token: that token is either the extra NUL marking the start of the + // messages section, or (if there happen to be zero conflicted paths) + // immediately follows the OID. Both cases are handled the same way. + let end = tokens.indexOf("", 1); + if (end === -1) end = tokens.length; // defensive: real -z output always has one + const files = tokens.slice(1, end); + return { treeOid, files }; + } + throw err; + } +} diff --git a/scripts/lib/merge-tree.test.mjs b/scripts/lib/merge-tree.test.mjs new file mode 100644 index 00000000..33fed2ee --- /dev/null +++ b/scripts/lib/merge-tree.test.mjs @@ -0,0 +1,225 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { mergeTree } from "./merge-tree.mjs"; +import { seedScratchIndex } from "./seed-index.mjs"; + +// Hermetic git env — same reason as seed-index.test.mjs: without this, the +// host machine's global/system git config can make a plumbing call hang or +// behave unpredictably. +const HERMETIC_GIT_ENV = { + ...process.env, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", +}; + +function git(repo, args, opts = {}) { + return execFileSync("git", ["-C", repo, ...args], { + encoding: "utf-8", + timeout: 10_000, + ...opts, + env: { ...HERMETIC_GIT_ENV, ...(opts.env ?? {}) }, + }); +} + +/** `git ls-files -s -z` against a scratch index, decoded to raw path names. */ +function lsFilesScratchNames(repo, indexPath) { + const out = git(repo, ["ls-files", "-s", "-z"], { env: { GIT_INDEX_FILE: indexPath } }); + return out + .split("\0") + .filter((e) => e.length > 0) + .map((e) => e.slice(e.indexOf("\t") + 1)); +} + +test("mergeTree returns null for a clean merge", () => { + const repo = mkdtempSync(join(tmpdir(), "gw-mergetree-clean-")); + try { + git(repo, ["init", "-q", "-b", "main"]); + git(repo, ["config", "user.email", "t@t.com"]); + git(repo, ["config", "user.name", "t"]); + writeFileSync(join(repo, "a.txt"), "a\n"); + writeFileSync(join(repo, "b.txt"), "b\n"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "base"]); + + git(repo, ["checkout", "-q", "-b", "theirs"]); + writeFileSync(join(repo, "a.txt"), "theirs a\n"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "theirs"]); + const theirsSha = git(repo, ["rev-parse", "HEAD"]).trim(); + + git(repo, ["checkout", "-q", "main"]); + writeFileSync(join(repo, "b.txt"), "main b\n"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "main"]); + const mainSha = git(repo, ["rev-parse", "HEAD"]).trim(); + + assert.equal(mergeTree(repo, mainSha, theirsSha), null); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +test("mergeTree returns the tree oid and raw conflicted paths for a multi-file conflict", () => { + const repo = mkdtempSync(join(tmpdir(), "gw-mergetree-multi-")); + try { + git(repo, ["init", "-q", "-b", "main"]); + git(repo, ["config", "user.email", "t@t.com"]); + git(repo, ["config", "user.name", "t"]); + writeFileSync(join(repo, "a.txt"), "a\n"); + writeFileSync(join(repo, "b.txt"), "b\n"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "base"]); + + git(repo, ["checkout", "-q", "-b", "theirs"]); + writeFileSync(join(repo, "a.txt"), "theirs a\n"); + writeFileSync(join(repo, "b.txt"), "theirs b\n"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "theirs"]); + const theirsSha = git(repo, ["rev-parse", "HEAD"]).trim(); + + git(repo, ["checkout", "-q", "main"]); + writeFileSync(join(repo, "a.txt"), "main a\n"); + writeFileSync(join(repo, "b.txt"), "main b\n"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "main"]); + const mainSha = git(repo, ["rev-parse", "HEAD"]).trim(); + + const result = mergeTree(repo, mainSha, theirsSha); + assert.ok(result, "expected a conflict result"); + assert.match(result.treeOid, /^[0-9a-f]{40}$/, "treeOid must be a real sha"); + assert.deepEqual([...result.files].sort(), ["a.txt", "b.txt"]); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +// Bug A — the actual regression. The default (non-`-z`) `--name-only` output +// C-quotes a path containing a literal `"` and a non-ASCII byte; the fixed +// `-z` invocation must return it RAW and unquoted so it matches the bytes +// `git ls-tree -z` (and thus `seedScratchIndex`'s skip-matching) produces. +test("mergeTree returns a path containing a quote and a non-ASCII byte RAW, not C-quoted", () => { + const repo = mkdtempSync(join(tmpdir(), "gw-mergetree-quoting-")); + try { + git(repo, ["init", "-q", "-b", "main"]); + git(repo, ["config", "user.email", "t@t.com"]); + git(repo, ["config", "user.name", "t"]); + mkdirSync(join(repo, "sub"), { recursive: true }); + const trickyName = 'café "quote".lock'; + writeFileSync(join(repo, "sub", trickyName), "base\n"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "base"]); + + git(repo, ["checkout", "-q", "-b", "theirs"]); + writeFileSync(join(repo, "sub", trickyName), "theirs change\n"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "theirs"]); + const theirsSha = git(repo, ["rev-parse", "HEAD"]).trim(); + + git(repo, ["checkout", "-q", "main"]); + writeFileSync(join(repo, "sub", trickyName), "main change\n"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "main"]); + const mainSha = git(repo, ["rev-parse", "HEAD"]).trim(); + + const result = mergeTree(repo, mainSha, theirsSha); + assert.ok(result, "expected a conflict result"); + const expected = `sub/${trickyName}`; + assert.deepEqual( + result.files, + [expected], + `expected the RAW unquoted path, got: ${JSON.stringify(result.files)}`, + ); + // Sanity: prove this path really would have been C-quoted by git's + // default (non-`-z`) output, so this test would have caught the + // original bug (a regression back to the non-`-z` invocation). Only the + // "Conflicted file info" section (the paragraph right after the tree + // oid) is quoted — the free-form "Informational messages" section that + // follows the blank-line separator is NOT quoted, so the check must be + // scoped to that first paragraph, not the whole output. + let nonZOutput; + try { + git(repo, ["-c", "merge.conflictstyle=diff3", "merge-tree", "--write-tree", "--name-only", mainSha, theirsSha]); + assert.fail("expected merge-tree to exit 1 on conflict"); + } catch (err) { + nonZOutput = err.stdout; + } + const [, conflictedFileInfo] = nonZOutput.split("\n\n")[0].split("\n"); + assert.notEqual( + conflictedFileInfo, + expected, + "sanity check: the default output's Conflicted file info section must be C-quoted, not the raw path — otherwise this test cannot prove the -z fix matters", + ); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +test("mergeTree rethrows on a genuine git error (not a recognised conflict outcome)", () => { + // A nonexistent repo path makes `git -C ...` fail with exit 128 + // ("fatal: cannot change to ...") before merge-tree itself ever runs — + // confirmed empirically to NOT collide with the exit-1 conflict path, + // unlike unresolvable revision names against a real repo (git's + // merge-tree also exits 1 for "not something we can merge", with empty + // stdout — a separate, pre-existing ambiguity this function does not try + // to disambiguate; this test targets the unambiguous case instead). + assert.throws(() => mergeTree(join(tmpdir(), "gw-mergetree-does-not-exist"), "HEAD", "HEAD")); +}); + +// End-to-end pipeline proof (the actual bug, not just mergeTree()'s return +// value in isolation): a C-quoted skip path must now be correctly matched +// and removed by seedScratchIndex, so no diff3 marker content leaks into the +// scratch index. This is the exact `mergeTree()` -> `conflictedPaths` -> +// `skipPaths` -> `seedScratchIndex` pipeline replay-regenerate.mjs runs. +test("end-to-end: a quoted/unicode conflicted path from mergeTree() is correctly skipped by seedScratchIndex", () => { + const repo = mkdtempSync(join(tmpdir(), "gw-mergetree-e2e-")); + try { + git(repo, ["init", "-q", "-b", "main"]); + git(repo, ["config", "user.email", "t@t.com"]); + git(repo, ["config", "user.name", "t"]); + mkdirSync(join(repo, "sub"), { recursive: true }); + const trickyName = 'café "quote".lock'; + writeFileSync(join(repo, "sub", trickyName), "base\n"); + writeFileSync(join(repo, "clean-only.txt"), "will only exist on theirs\n"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "base"]); + + git(repo, ["checkout", "-q", "-b", "theirs"]); + writeFileSync(join(repo, "sub", trickyName), "theirs change\n"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "theirs"]); + const theirsSha = git(repo, ["rev-parse", "HEAD"]).trim(); + + git(repo, ["checkout", "-q", "main"]); + writeFileSync(join(repo, "sub", trickyName), "main change\n"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "main"]); + const mainSha = git(repo, ["rev-parse", "HEAD"]).trim(); + + // Stage 1 (candidate discovery), for real, via the fixed mergeTree(). + const conflict = mergeTree(repo, mainSha, theirsSha); + assert.ok(conflict, "expected a conflict result"); + const trickyPath = `sub/${trickyName}`; + assert.ok( + conflict.files.includes(trickyPath), + `mergeTree() must report the raw tricky path as conflicted, got: ${JSON.stringify(conflict.files)}`, + ); + + // Stage 1's candidate.conflictedPaths becomes seedScratchIndex's + // skipPaths, exactly as replay-regenerate.mjs wires it. + const scratchIndex = join(repo, ".git", "scratch-e2e-index"); + seedScratchIndex(repo, conflict.treeOid, scratchIndex, conflict.files); + + const names = lsFilesScratchNames(repo, scratchIndex); + assert.ok( + !names.includes(trickyPath), + `the quoted/unicode conflicted path must be ABSENT from the scratch index (correctly skipped, no marker-content leak) — got: ${JSON.stringify(names)}`, + ); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); diff --git a/scripts/lib/regenerate-compare.mjs b/scripts/lib/regenerate-compare.mjs new file mode 100644 index 00000000..4a3f8195 --- /dev/null +++ b/scripts/lib/regenerate-compare.mjs @@ -0,0 +1,171 @@ +/** + * regenerate-compare.mjs — structural comparison for `scripts/replay-regenerate.mjs`. + * + * Byte-exact comparison of a regenerated lockfile against the one a team + * actually committed almost never holds (dependency resolvers vary resolved + * URLs, hashes, and ordering run-to-run even given the same inputs — see the + * task-4 brief's "Measurement" section). So "did regeneration reproduce the + * commit" is answered structurally instead: for each of the v1 registry's five + * lockfile formats, extract the set of resolved `name@version` package + * identities and compare those sets, ignoring integrity hashes, resolved + * URLs, timestamps and key ordering. + * + * Design choice (documented per the brief): format-aware parsing (`yaml`, + * `smol-toml`, `JSON.parse` — the same libraries `packages/cli`'s + * `regenerate-runner.ts` already uses for these exact formats) is the primary + * path, because "same dependency graph" is a stronger and more meaningful + * claim than "same text after stripping some volatile-looking substrings". + * `stripVolatileValues` (`@gitwand/core`, exported for this purpose per the + * brief) is kept as the FALLBACK when a lockfile fails to parse in its + * expected format (corrupt output, an unexpected variant) — a text-normalised + * compare is better than crashing the measurement run. + */ + +import { parse as parseYaml } from "yaml"; +import { parse as parseToml } from "smol-toml"; +import { stripVolatileValues } from "../../packages/core/dist/index.js"; + +/** + * npm package-lock.json — supports both the modern "packages" map + * (lockfileVersion 2/3, keyed by node_modules path) and the legacy nested + * "dependencies" tree (lockfileVersion 1). + */ +function extractNpmIdentities(content) { + const parsed = JSON.parse(content); + const identities = new Set(); + + if (parsed.packages && typeof parsed.packages === "object") { + for (const [pkgPath, meta] of Object.entries(parsed.packages)) { + if (pkgPath === "" || !meta || typeof meta.version !== "string") continue; + const idx = pkgPath.lastIndexOf("node_modules/"); + const name = idx === -1 ? pkgPath : pkgPath.slice(idx + "node_modules/".length); + identities.add(`${name}@${meta.version}`); + } + return identities; + } + + const walk = (deps) => { + if (!deps || typeof deps !== "object") return; + for (const [name, meta] of Object.entries(deps)) { + if (!meta || typeof meta.version !== "string") continue; + identities.add(`${name}@${meta.version}`); + if (meta.dependencies) walk(meta.dependencies); + } + }; + walk(parsed.dependencies); + return identities; +} + +/** composer.lock — "packages" + "packages-dev" arrays of {name, version}. */ +function extractComposerIdentities(content) { + const parsed = JSON.parse(content); + const identities = new Set(); + for (const key of ["packages", "packages-dev"]) { + for (const pkg of parsed[key] ?? []) { + if (pkg && typeof pkg.name === "string" && typeof pkg.version === "string") { + identities.add(`${pkg.name}@${pkg.version}`); + } + } + } + return identities; +} + +/** + * pnpm-lock.yaml — the top-level "packages" map's keys already embed + * `name@version` (e.g. `/lodash@4.17.21` or `lodash@4.17.21` depending on + * lockfileVersion); the "resolution"/"integrity" subfields are volatile and + * deliberately not part of the identity. + */ +function extractPnpmIdentities(content) { + const parsed = parseYaml(content); + const identities = new Set(); + const packages = parsed?.packages ?? {}; + for (const key of Object.keys(packages)) { + identities.add(key.replace(/^\//, "")); + } + return identities; +} + +/** + * yarn.lock (berry) — top-level keys are comma-separated locator lists + * (`"foo@npm:^1.0.0, foo@npm:^1.2.0":`); each block's `version` field is the + * resolved version. `__metadata` is not a package entry. + */ +function extractYarnIdentities(content) { + const parsed = parseYaml(content); + const identities = new Set(); + for (const [key, meta] of Object.entries(parsed ?? {})) { + if (key === "__metadata" || !meta || typeof meta.version !== "string") continue; + const firstLocator = key.split(",")[0].trim().replace(/^"|"$/g, ""); + const atNpm = firstLocator.lastIndexOf("@npm:"); + const name = atNpm === -1 ? firstLocator.replace(/@[^@]*$/, "") : firstLocator.slice(0, atNpm); + identities.add(`${name}@${meta.version}`); + } + return identities; +} + +/** Cargo.lock — array of `[[package]]` tables with name/version. */ +function extractCargoIdentities(content) { + const parsed = parseToml(content); + const identities = new Set(); + for (const pkg of parsed.package ?? []) { + if (pkg && typeof pkg.name === "string" && typeof pkg.version === "string") { + identities.add(`${pkg.name}@${pkg.version}`); + } + } + return identities; +} + +const EXTRACTORS = { + npm: extractNpmIdentities, + composer: extractComposerIdentities, + pnpm: extractPnpmIdentities, + "yarn-berry": extractYarnIdentities, + cargo: extractCargoIdentities, +}; + +/** + * Format-aware extraction of the `name@version` identity set for a lockfile. + * Returns `null` (not a thrown error) when `ecosystemId` is unknown, so + * callers can fall back cleanly. + */ +export function extractPackageIdentities(ecosystemId, content) { + const extractor = EXTRACTORS[ecosystemId]; + if (!extractor) return null; + return extractor(content); +} + +/** + * Structural comparison between an expected (actually-committed) lockfile and + * an actual (regenerated) one. See module doc for the two-tier strategy. + */ +export function structuralMatch(ecosystemId, expectedContent, actualContent) { + try { + const expected = extractPackageIdentities(ecosystemId, expectedContent); + const actual = extractPackageIdentities(ecosystemId, actualContent); + if (expected && actual) { + const onlyInExpected = [...expected].filter((id) => !actual.has(id)); + const onlyInActual = [...actual].filter((id) => !expected.has(id)); + const match = onlyInExpected.length === 0 && onlyInActual.length === 0; + return { + match, + comparable: true, + method: "structural", + expectedCount: expected.size, + actualCount: actual.size, + onlyInExpected, + onlyInActual, + }; + } + } catch { + // Fall through to the text fallback below — a parse failure (corrupt + // regenerated output, an unexpected format variant) must not crash the + // whole replay run. + } + + // Fallback: format-aware parsing didn't apply or failed — normalise both + // sides with stripVolatileValues and compare as text. + const a = stripVolatileValues(expectedContent.split(/\r?\n/)); + const b = stripVolatileValues(actualContent.split(/\r?\n/)); + return { match: a === b, comparable: true, method: "text-fallback" }; +} diff --git a/scripts/lib/regenerate-compare.test.mjs b/scripts/lib/regenerate-compare.test.mjs new file mode 100644 index 00000000..a44757d9 --- /dev/null +++ b/scripts/lib/regenerate-compare.test.mjs @@ -0,0 +1,293 @@ +/** + * Fixture-based tests for regenerate-compare.mjs — fast, no network, no real + * installs. Run with: node --test scripts/lib/regenerate-compare.test.mjs + * (see root package.json's "test:scripts-lib" script, which runs every + * scripts/lib/*.test.mjs file, this one included). + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { structuralMatch, extractPackageIdentities } from "./regenerate-compare.mjs"; + +// ─── npm (package-lock.json, lockfileVersion 3 "packages" map) ───────────── + +const npmA = JSON.stringify({ + name: "demo", + lockfileVersion: 3, + packages: { + "": { name: "demo", version: "1.0.0" }, + "node_modules/lodash": { + version: "4.17.21", + resolved: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + integrity: "sha512-abc123==", + }, + "node_modules/left-pad": { + version: "1.3.0", + resolved: "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + integrity: "sha512-def456==", + }, + }, +}); + +// Same dependency graph, different volatile fields (mirrors resolved-URL / +// integrity-hash drift a real re-resolve can produce even for an unchanged graph). +const npmAVolatileDrift = JSON.stringify({ + name: "demo", + lockfileVersion: 3, + packages: { + "": { name: "demo", version: "1.0.0" }, + "node_modules/lodash": { + version: "4.17.21", + resolved: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + integrity: "sha512-ZZZZZZ==", + }, + "node_modules/left-pad": { + version: "1.3.0", + resolved: "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + integrity: "sha512-YYYYYY==", + }, + }, +}); + +const npmB = JSON.stringify({ + name: "demo", + lockfileVersion: 3, + packages: { + "": { name: "demo", version: "1.0.0" }, + "node_modules/lodash": { + version: "4.17.20", // genuinely different resolved version + resolved: "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + integrity: "sha512-abc123==", + }, + "node_modules/left-pad": { + version: "1.3.0", + resolved: "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + integrity: "sha512-def456==", + }, + }, +}); + +test("npm: identical modulo volatile hashes/resolved URLs -> match", () => { + const result = structuralMatch("npm", npmA, npmAVolatileDrift); + assert.equal(result.match, true); + assert.equal(result.method, "structural"); +}); + +test("npm: genuinely different resolved version -> no match", () => { + const result = structuralMatch("npm", npmA, npmB); + assert.equal(result.match, false); + assert.deepEqual(result.onlyInExpected, ["lodash@4.17.21"]); + assert.deepEqual(result.onlyInActual, ["lodash@4.17.20"]); +}); + +test("npm: legacy lockfileVersion 1 nested 'dependencies' tree is supported", () => { + const legacy = JSON.stringify({ + name: "demo", + lockfileVersion: 1, + dependencies: { + lodash: { version: "4.17.21" }, + wrap: { version: "1.0.0", dependencies: { inner: { version: "2.0.0" } } }, + }, + }); + const ids = extractPackageIdentities("npm", legacy); + assert.ok(ids.has("lodash@4.17.21")); + assert.ok(ids.has("wrap@1.0.0")); + assert.ok(ids.has("inner@2.0.0")); +}); + +// ─── composer (composer.lock) ─────────────────────────────────────────────── + +const composerA = JSON.stringify({ + _readme: ["This file locks..."], + "content-hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + packages: [ + { name: "laravel/framework", version: "v10.0.0", dist: { reference: "abc111" } }, + { name: "symfony/console", version: "v6.3.0", dist: { reference: "abc222" } }, + ], + "packages-dev": [{ name: "phpunit/phpunit", version: "10.0.0", dist: { reference: "abc333" } }], +}); + +const composerAVolatileDrift = JSON.stringify({ + _readme: ["This file locks..."], + "content-hash": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", // volatile: recomputed hash + packages: [ + { name: "laravel/framework", version: "v10.0.0", dist: { reference: "def999" } }, // volatile: dist ref + { name: "symfony/console", version: "v6.3.0", dist: { reference: "def888" } }, + ], + "packages-dev": [{ name: "phpunit/phpunit", version: "10.0.0", dist: { reference: "def777" } }], +}); + +const composerB = JSON.stringify({ + _readme: ["This file locks..."], + "content-hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + packages: [ + { name: "laravel/framework", version: "v10.1.0", dist: { reference: "abc111" } }, // genuinely different version + { name: "symfony/console", version: "v6.3.0", dist: { reference: "abc222" } }, + ], + "packages-dev": [{ name: "phpunit/phpunit", version: "10.0.0", dist: { reference: "abc333" } }], +}); + +test("composer: identical modulo content-hash/dist.reference -> match", () => { + const result = structuralMatch("composer", composerA, composerAVolatileDrift); + assert.equal(result.match, true); +}); + +test("composer: genuinely different dependency graph -> no match", () => { + const result = structuralMatch("composer", composerA, composerB); + assert.equal(result.match, false); + assert.deepEqual(result.onlyInExpected, ["laravel/framework@v10.0.0"]); + assert.deepEqual(result.onlyInActual, ["laravel/framework@v10.1.0"]); +}); + +// ─── pnpm (pnpm-lock.yaml) ─────────────────────────────────────────────────── + +const pnpmA = `lockfileVersion: '9.0' +packages: + lodash@4.17.21: + resolution: {integrity: sha512-abc123==} + left-pad@1.3.0: + resolution: {integrity: sha512-def456==} +`; + +const pnpmAVolatileDrift = `lockfileVersion: '9.0' +packages: + lodash@4.17.21: + resolution: {integrity: sha512-ZZZZZZ==} + left-pad@1.3.0: + resolution: {integrity: sha512-YYYYYY==} +`; + +const pnpmB = `lockfileVersion: '9.0' +packages: + lodash@4.17.20: + resolution: {integrity: sha512-abc123==} + left-pad@1.3.0: + resolution: {integrity: sha512-def456==} +`; + +test("pnpm: identical modulo integrity hash -> match", () => { + const result = structuralMatch("pnpm", pnpmA, pnpmAVolatileDrift); + assert.equal(result.match, true); +}); + +test("pnpm: genuinely different resolved version -> no match", () => { + const result = structuralMatch("pnpm", pnpmA, pnpmB); + assert.equal(result.match, false); +}); + +// ─── yarn-berry (yarn.lock) ────────────────────────────────────────────────── + +const yarnA = `__metadata: + version: 6 + +"lodash@npm:^4.17.21": + version: 4.17.21 + resolution: "lodash@npm:4.17.21" + checksum: 10c0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + languageName: node + linkType: hard +`; + +const yarnAVolatileDrift = `__metadata: + version: 6 + +"lodash@npm:^4.17.21": + version: 4.17.21 + resolution: "lodash@npm:4.17.21" + checksum: 10c0/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + languageName: node + linkType: hard +`; + +const yarnB = `__metadata: + version: 6 + +"lodash@npm:^4.17.21": + version: 4.17.20 + resolution: "lodash@npm:4.17.20" + checksum: 10c0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + languageName: node + linkType: hard +`; + +test("yarn-berry: identical modulo checksum -> match", () => { + const result = structuralMatch("yarn-berry", yarnA, yarnAVolatileDrift); + assert.equal(result.match, true); +}); + +test("yarn-berry: genuinely different resolved version -> no match", () => { + const result = structuralMatch("yarn-berry", yarnA, yarnB); + assert.equal(result.match, false); +}); + +// ─── cargo (Cargo.lock) ─────────────────────────────────────────────────────── + +const cargoA = `# This file is automatically @generated by Cargo. +version = 4 + +[[package]] +name = "serde" +version = "1.0.190" +checksum = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +[[package]] +name = "libc" +version = "0.2.150" +checksum = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +`; + +const cargoAVolatileDrift = `# This file is automatically @generated by Cargo. +version = 4 + +[[package]] +name = "serde" +version = "1.0.190" +checksum = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + +[[package]] +name = "libc" +version = "0.2.150" +checksum = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" +`; + +const cargoB = `# This file is automatically @generated by Cargo. +version = 4 + +[[package]] +name = "serde" +version = "1.0.195" +checksum = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +[[package]] +name = "libc" +version = "0.2.150" +checksum = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +`; + +test("cargo: identical modulo checksum -> match", () => { + const result = structuralMatch("cargo", cargoA, cargoAVolatileDrift); + assert.equal(result.match, true); +}); + +test("cargo: genuinely different resolved version -> no match", () => { + const result = structuralMatch("cargo", cargoA, cargoB); + assert.equal(result.match, false); +}); + +// ─── fallback path (unparseable in the expected format) ───────────────────── + +test("fallback: unknown ecosystem id falls back to stripVolatileValues text compare", () => { + const a = 'hash: "sha512-abcdef1234567890abcdef1234567890abcdef12"'; + const b = 'hash: "sha512-zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"'; + const result = structuralMatch("unknown-ecosystem", a, b); + assert.equal(result.method, "text-fallback"); + assert.equal(result.match, true); // both strip to the same "" placeholder +}); + +test("fallback: malformed JSON for a known ecosystem still produces a verdict, not a throw", () => { + const broken = "<<<<<<< HEAD\nnot valid json\n=======\n>>>>>>> theirs\n"; + assert.doesNotThrow(() => structuralMatch("npm", broken, broken)); + const result = structuralMatch("npm", broken, broken); + assert.equal(result.method, "text-fallback"); + assert.equal(result.match, true); // identical text on both sides +}); diff --git a/scripts/lib/seed-index.mjs b/scripts/lib/seed-index.mjs new file mode 100644 index 00000000..0a3d7c9c --- /dev/null +++ b/scripts/lib/seed-index.mjs @@ -0,0 +1,135 @@ +/** + * Populates a SCRATCH git index file with the contents of `treeOid` (a tree + * object — typically the output of `git merge-tree --write-tree`), scoped to + * `repo`. Never touches `repo`'s own index: `GIT_INDEX_FILE` redirects git's + * plumbing to `indexPath` for this one call only. The caller later points + * `checkout-index --work-tree=` at the same `indexPath` (via + * `GIT_INDEX_FILE`) to materialize the tree's files into a disposable + * worktree — see `scripts/replay-regenerate.mjs` and + * `packages/cli/src/regenerate-runner.ts`'s `addWorktree`. + * + * Final review, Critical #1 — `git read-tree ` of a SINGLE tree + * necessarily puts every path in that tree at stage 0, including paths that + * were genuinely conflicted in the 3-way merge `merge-tree --write-tree` + * computed `treeOid` from. `merge-tree --write-tree`'s conflicted blobs hold + * literal diff3 conflict-marker text as their content — so without the + * `skipPaths` step below, `checkout-index --all` (which only ever skips + * paths NOT at stage 0) would happily write that marker-laden content into + * the worktree. Production never does this: a genuine in-progress merge's + * index holds conflicted paths at stages 1/2/3, and `checkout-index --all` + * silently skips anything not at stage 0. `skipPaths` (the set of paths this + * historical merge actually left conflicted, known at candidate-discovery + * time — see `replay-regenerate.mjs`) removes those paths from the scratch + * index after the `read-tree`, so the harness's scratch index behaves + * exactly like production's real multi-stage index: still-conflicted paths + * are absent, not materialized with marker content. + * + * Regenerate-sweep re-run #2 fix — `git update-index --force-remove`, even + * though it only ever edits an index file and never touches the filesystem, + * is still subject to git's `NEED_WORK_TREE` plumbing rule and fails with + * `fatal: this operation must be run in a work tree` against a BARE repo — + * exactly the shape `benchmark/run.mjs`'s `prepare()` clones the corpus into. + * Since a "runnable" regeneration candidate is BY DEFINITION one whose + * lockfile is still conflicted, `skipPaths` is non-empty on every real + * candidate, so this fired 100% of the time against the real corpus. Fixed + * by building the filtered tree via pure object-database plumbing instead — + * `git ls-tree -r` + `git mktree` (both never require a work tree, unlike + * `update-index`) to construct a tree object with the skipped paths already + * removed, then a single `git read-tree` of that tree. No work tree is + * needed anywhere in this function now. + * + * Second finding, caught only by testing against a REAL corpus repo + * (prettier/prettier) rather than trusting the plan's own description: an + * earlier version of this fix rebuilt the ENTIRE tree from a flat `git + * ls-tree -r` (thousands of entries even for one lockfile skip, since + * prettier's tree alone has 3000+ directories) and fed it straight to `git + * mktree`. Two problems, both only visible against a real tree: (1) `mktree` + * does not reconstruct nested subtrees from full recursive paths on its own — + * it rejects any entry whose name contains a slash with `fatal: path ... + * contains slash`; (2) without `-z`, both `ls-tree` and `mktree` use + * C-style quoting for filenames with special characters (spaces, quotes, + * unicode — common in any large real repo's test fixtures), and reassembling + * quoted names by hand (e.g. splitting a quoted, escaped path on `/`) breaks + * in ways that surface as `fatal: invalid quoting`. + * + * Fixed by doing dramatically less work, correctly: since `skipPaths` is + * always a small, known set of exact paths (the merge's own conflicted + * files), only the directories on the path from the root to each skipped + * file actually change — every sibling subtree keeps its ORIGINAL oid + * untouched. `removePathFromTree` walks that one chain per skip path with + * `git ls-tree -z ` (single level, NOT recursive) and rewrites just + * that level's entries via `git mktree -z`, propagating the new subtree oid + * up to its parent. `-z` (NUL-terminated, unquoted raw bytes) is used for + * BOTH commands throughout, which sidesteps the quoting class of bug + * entirely rather than trying to parse or re-emit quoted names correctly. + * + * Third finding, again only visible against the real corpus (not the unit + * fixtures, which are always full, non-partial clones): `benchmark/run.mjs`'s + * `prepare()` clones the corpus BLOBLESS (`--filter=blob:none`), so most blob + * objects at a given historical tree are not fetched locally yet. `git + * mktree` — unlike most git commands, which lazily fetch a missing object + * from the partial clone's promisor remote on demand — verifies up front + * that every object it is asked to reference already exists locally, and + * does NOT trigger that lazy fetch itself; it fails outright with `fatal: + * entry '' object is unavailable`. `--missing` disables that + * verification. It is safe here specifically because every sha `mktree` is + * asked to write was read moments earlier from a real `ls-tree` of the same + * repo's own object database — this function only ever removes an entry, it + * never invents or mutates a blob/tree sha, so there is nothing to validate. + */ +import { execFileSync } from "node:child_process"; + +export function seedScratchIndex(repo, treeOid, indexPath, skipPaths = []) { + let effectiveTreeOid = treeOid; + for (const path of skipPaths) { + effectiveTreeOid = removePathFromTree(repo, effectiveTreeOid, path.split("/")); + } + execFileSync("git", ["-C", repo, "read-tree", effectiveTreeOid], { + env: { ...process.env, GIT_INDEX_FILE: indexPath }, + }); +} + +/** + * Returns a NEW tree oid equal to `treeOid` with the single path named by + * `segments` removed, rewriting only the directories on that path — every + * sibling entry, and every subtree not on the chain, keeps its original oid. + * `-z` throughout (both `ls-tree` and `mktree`) works on raw, unquoted bytes, + * so filenames with spaces/quotes/unicode are handled correctly without any + * hand-rolled quoting logic. Neither command requires or touches a work tree. + */ +function removePathFromTree(repo, treeOid, segments) { + const [target, ...rest] = segments; + const output = execFileSync("git", ["-C", repo, "ls-tree", "-z", treeOid], { + encoding: "utf-8", + }); + const entries = output.split("\0").filter((entry) => entry.length > 0); + + let targetFound = false; + const outEntries = []; + for (const entry of entries) { + const tabIndex = entry.indexOf("\t"); + const meta = entry.slice(0, tabIndex); // " " + const name = entry.slice(tabIndex + 1); + if (name !== target) { + outEntries.push(entry); + continue; + } + targetFound = true; + if (rest.length === 0) { + continue; // this is the leaf to remove — drop it, do not re-emit + } + const [mode, type, sha] = meta.split(" "); + const newSubtreeOid = removePathFromTree(repo, sha, rest); + outEntries.push(`${mode} ${type} ${newSubtreeOid}\t${name}`); + } + + // Path segment absent at this level (already renamed/removed upstream, or + // a stale skipPath) — nothing to remove here; the tree is unchanged. + if (!targetFound) return treeOid; + + const input = outEntries.length > 0 ? outEntries.join("\0") + "\0" : ""; + return execFileSync("git", ["-C", repo, "mktree", "-z", "--missing"], { + input, + encoding: "utf-8", + }).trim(); +} diff --git a/scripts/lib/seed-index.test.mjs b/scripts/lib/seed-index.test.mjs new file mode 100644 index 00000000..21e804b5 --- /dev/null +++ b/scripts/lib/seed-index.test.mjs @@ -0,0 +1,395 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { seedScratchIndex } from "./seed-index.mjs"; + +// Hermetic git env — same reason as merge-context-detect.test.ts / +// regenerate-runner.test.ts: without this, the host machine's global/system +// git config (hooksPath, GPG signing, editor…) can make a plumbing call hang +// or behave unpredictably. +const HERMETIC_GIT_ENV = { + ...process.env, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", +}; + +function git(repo, args, opts = {}) { + return execFileSync("git", ["-C", repo, ...args], { + encoding: "utf-8", + timeout: 10_000, + ...opts, + env: { ...HERMETIC_GIT_ENV, ...(opts.env ?? {}) }, + }); +} + +/** `git ls-files -s` against a scratch index via `GIT_INDEX_FILE`. */ +function lsFilesScratch(repo, indexPath) { + return git(repo, ["ls-files", "-s"], { + env: { GIT_INDEX_FILE: indexPath }, + }); +} + +/** + * `git merge-tree --write-tree` exits 1 (not 0) whenever the merge produces + * a conflict — the tree oid is still the first line of stdout even then. + * Mirrors `replay-regenerate.mjs`'s own `mergeTree()` handling. + */ +function mergeTreeWriteTree(repo, p1, p2) { + try { + const out = git(repo, ["-c", "merge.conflictstyle=diff3", "merge-tree", "--write-tree", p1, p2]); + return out.trim().split("\n")[0]; + } catch (err) { + if (err.status === 1 && typeof err.stdout === "string") { + return err.stdout.trim().split("\n")[0]; + } + throw err; + } +} + +test("seedScratchIndex materializes a theirs-only file into a scratch index without touching the repo's real index", () => { + const repo = mkdtempSync(join(tmpdir(), "gw-seed-index-")); + try { + git(repo, ["init", "-q", "-b", "main"]); + git(repo, ["config", "user.email", "t@t.com"]); + git(repo, ["config", "user.name", "t"]); + writeFileSync(join(repo, "package.json"), '{"v":1}\n'); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "base"]); + + git(repo, ["checkout", "-q", "-b", "theirs"]); + writeFileSync(join(repo, "theirs-only.txt"), "only on theirs\n"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "theirs adds a file"]); + const theirsSha = git(repo, ["rev-parse", "HEAD"]).trim(); + + git(repo, ["checkout", "-q", "main"]); + writeFileSync(join(repo, "package.json"), '{"v":2}\n'); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "main bumps a value"]); + const mainSha = git(repo, ["rev-parse", "HEAD"]).trim(); + + const merged = git(repo, [ + "-c", "merge.conflictstyle=diff3", + "merge-tree", "--write-tree", mainSha, theirsSha, + ]).trim(); + const treeOid = merged.split("\n")[0]; + + const realIndexBefore = readFileSync(join(repo, ".git", "index")); + + const scratchIndex = join(repo, ".git", "scratch-test-index"); + seedScratchIndex(repo, treeOid, scratchIndex); + + assert.ok(existsSync(scratchIndex), "scratch index file must be created"); + // The repo's own index must be byte-for-byte untouched. + assert.deepEqual(readFileSync(join(repo, ".git", "index")), realIndexBefore); + + // Behavioral assertion must read back the SCRATCH INDEX itself (via + // `ls-files -s` with `GIT_INDEX_FILE` pointed at it) — not a property of + // the source tree (`git ls-tree `), which is true regardless of + // whether `seedScratchIndex` did anything at all. + const listing = lsFilesScratch(repo, scratchIndex); + assert.ok(listing.includes("theirs-only.txt"), "theirs-only.txt must be present in the scratch index"); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +test("seedScratchIndex(skipPaths) removes still-conflicted paths from the scratch index, matching production's multi-stage skip", () => { + const repo = mkdtempSync(join(tmpdir(), "gw-seed-index-skip-")); + try { + git(repo, ["init", "-q", "-b", "main"]); + git(repo, ["config", "user.email", "t@t.com"]); + git(repo, ["config", "user.name", "t"]); + writeFileSync(join(repo, "conflicted.txt"), "base\n"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "base"]); + + git(repo, ["checkout", "-q", "-b", "theirs"]); + writeFileSync(join(repo, "conflicted.txt"), "theirs change\n"); + writeFileSync(join(repo, "clean-only.txt"), "only on theirs, no conflict\n"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "theirs: conflicting change + a clean add"]); + const theirsSha = git(repo, ["rev-parse", "HEAD"]).trim(); + + git(repo, ["checkout", "-q", "main"]); + writeFileSync(join(repo, "conflicted.txt"), "main change\n"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "main: conflicting change"]); + const mainSha = git(repo, ["rev-parse", "HEAD"]).trim(); + + // `merge-tree --write-tree` produces a genuine conflict on + // conflicted.txt (diff3 marker content as the blob's literal text) and a + // clean merge for clean-only.txt (theirs-only, no conflict). It exits 1 + // (not 0) because of the conflict — see mergeTreeWriteTree()'s doc. + const treeOid = mergeTreeWriteTree(repo, mainSha, theirsSha); + + const scratchIndex = join(repo, ".git", "scratch-test-index-skip"); + seedScratchIndex(repo, treeOid, scratchIndex, ["conflicted.txt"]); + + const listing = lsFilesScratch(repo, scratchIndex); + assert.ok( + !listing.includes("conflicted.txt"), + `conflicted.txt must be ABSENT from the scratch index (skipped, like production's multi-stage skip) — got:\n${listing}`, + ); + assert.ok( + listing.includes("clean-only.txt"), + `clean-only.txt must be present at stage 0 in the scratch index — got:\n${listing}`, + ); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +// Regenerate-sweep re-run #2 — the bug this test exists to catch (and the +// prior two tests above never could): `git update-index --force-remove` +// fails with `fatal: this operation must be run in a work tree` against a +// BARE repo, even though it only edits an index file and never touches the +// filesystem. The real corpus (`benchmark/run.mjs`'s `prepare()`) clones +// bare + blobless, so this is the shape that actually matters in production +// use of this harness. Build commits in an ordinary non-bare repo (bare repos +// have no work tree to `git add`/`git commit` against), then `git clone +// --bare` it into a second temp path and exercise `seedScratchIndex` against +// THAT bare clone. +// +// The fixture ALSO puts one skipped and one kept file inside a nested +// subdirectory (`src/nested/...`) — a real, real-world repo (prettier) turned +// up a second bug the first version of this test's flat-only fixture missed +// entirely: `git mktree` (unlike `ls-tree -r`) does not reconstruct nested +// subtrees on its own and rejects any path containing a slash with `fatal: +// path ... contains slash`. A fixture with only root-level files can never +// exercise that failure mode. +test("seedScratchIndex(skipPaths) works against a BARE repo with nested paths (no work tree) — the real corpus's shape", () => { + const srcRepo = mkdtempSync(join(tmpdir(), "gw-seed-index-bare-src-")); + const bareRepo = mkdtempSync(join(tmpdir(), "gw-seed-index-bare-")); + try { + git(srcRepo, ["init", "-q", "-b", "main"]); + git(srcRepo, ["config", "user.email", "t@t.com"]); + git(srcRepo, ["config", "user.name", "t"]); + mkdirSync(join(srcRepo, "src", "nested"), { recursive: true }); + writeFileSync(join(srcRepo, "conflicted.txt"), "base\n"); + writeFileSync(join(srcRepo, "src", "nested", "conflicted-nested.txt"), "base nested\n"); + git(srcRepo, ["add", "-A"]); + git(srcRepo, ["commit", "-q", "-m", "base"]); + + git(srcRepo, ["checkout", "-q", "-b", "theirs"]); + writeFileSync(join(srcRepo, "conflicted.txt"), "theirs change\n"); + writeFileSync(join(srcRepo, "src", "nested", "conflicted-nested.txt"), "theirs nested change\n"); + writeFileSync(join(srcRepo, "clean-only.txt"), "only on theirs, no conflict\n"); + writeFileSync(join(srcRepo, "src", "nested", "clean-nested.txt"), "only on theirs, nested, no conflict\n"); + git(srcRepo, ["add", "-A"]); + git(srcRepo, ["commit", "-q", "-m", "theirs: conflicting changes (root + nested) + clean adds (root + nested)"]); + const theirsSha = git(srcRepo, ["rev-parse", "HEAD"]).trim(); + + git(srcRepo, ["checkout", "-q", "main"]); + writeFileSync(join(srcRepo, "conflicted.txt"), "main change\n"); + writeFileSync(join(srcRepo, "src", "nested", "conflicted-nested.txt"), "main nested change\n"); + git(srcRepo, ["add", "-A"]); + git(srcRepo, ["commit", "-q", "-m", "main: conflicting changes (root + nested)"]); + const mainSha = git(srcRepo, ["rev-parse", "HEAD"]).trim(); + + const treeOid = mergeTreeWriteTree(srcRepo, mainSha, theirsSha); + + // Re-create bareRepo as an actual bare clone of srcRepo (mkdtempSync + // already created bareRepo as an empty dir — `clone --bare` needs to + // create/populate its target, so remove it first and let clone recreate it). + rmSync(bareRepo, { recursive: true, force: true }); + git(srcRepo, ["clone", "-q", "--bare", srcRepo, bareRepo]); + assert.equal( + git(bareRepo, ["rev-parse", "--is-bare-repository"]).trim(), + "true", + "fixture must actually be bare, or this test proves nothing", + ); + + const scratchIndex = join(bareRepo, "scratch-test-index-bare-skip"); + // Must NOT throw `fatal: this operation must be run in a work tree` NOR + // `fatal: path ... contains slash`. + seedScratchIndex(bareRepo, treeOid, scratchIndex, ["conflicted.txt", "src/nested/conflicted-nested.txt"]); + + const listing = lsFilesScratch(bareRepo, scratchIndex); + assert.ok( + !listing.includes("conflicted.txt") || listing.includes("src/nested/conflicted-nested.txt") === false, + `sanity: listing must not be empty/garbage — got:\n${listing}`, + ); + assert.ok( + !listing.split("\n").some((l) => l.endsWith("\tconflicted.txt")), + `root-level conflicted.txt must be ABSENT from the scratch index built against a bare repo — got:\n${listing}`, + ); + assert.ok( + !listing.includes("src/nested/conflicted-nested.txt"), + `nested conflicted-nested.txt must be ABSENT from the scratch index built against a bare repo — got:\n${listing}`, + ); + assert.ok( + listing.includes("clean-only.txt"), + `root-level clean-only.txt must be present at stage 0 — got:\n${listing}`, + ); + assert.ok( + listing.includes("src/nested/clean-nested.txt"), + `nested clean-nested.txt must be present at stage 0, with its full nested path intact — got:\n${listing}`, + ); + } finally { + rmSync(srcRepo, { recursive: true, force: true }); + rmSync(bareRepo, { recursive: true, force: true }); + } +}); + +// Regenerate-sweep re-run #2, second finding — a real corpus repo +// (prettier/prettier) turned up a case no hand-built fixture had covered: +// git C-quotes filenames with special characters (spaces, double quotes, +// unicode) in the default (non-`-z`) output of both `ls-tree` and `mktree`. +// Reassembling a hand-parsed quoted name (e.g. splitting on "/" or matching +// it against a skip path) breaks and surfaces as `fatal: invalid quoting`. +// This fixture puts a filename containing a double quote and a space +// ALONGSIDE the skipped file at the very same tree level, so a regression +// back to non-`-z` parsing would corrupt or drop it. +test("seedScratchIndex(skipPaths) tolerates sibling filenames with quotes/spaces that git C-quotes by default", () => { + const repo = mkdtempSync(join(tmpdir(), "gw-seed-index-quoting-")); + try { + git(repo, ["init", "-q", "-b", "main"]); + git(repo, ["config", "user.email", "t@t.com"]); + git(repo, ["config", "user.name", "t"]); + const trickyName = 'weird "quoted" file with spaces.txt'; + writeFileSync(join(repo, "conflicted.txt"), "base\n"); + writeFileSync(join(repo, trickyName), "base tricky\n"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "base"]); + + git(repo, ["checkout", "-q", "-b", "theirs"]); + writeFileSync(join(repo, "conflicted.txt"), "theirs change\n"); + writeFileSync(join(repo, trickyName), "theirs tricky change\n"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "theirs: conflicting change + a clean edit of a tricky filename"]); + const theirsSha = git(repo, ["rev-parse", "HEAD"]).trim(); + + git(repo, ["checkout", "-q", "main"]); + writeFileSync(join(repo, "conflicted.txt"), "main change\n"); + git(repo, ["add", "-A"]); + git(repo, ["commit", "-q", "-m", "main: conflicting change"]); + const mainSha = git(repo, ["rev-parse", "HEAD"]).trim(); + + const treeOid = mergeTreeWriteTree(repo, mainSha, theirsSha); + + const scratchIndex = join(repo, ".git", "scratch-test-index-quoting"); + // Must NOT throw `fatal: invalid quoting`. + seedScratchIndex(repo, treeOid, scratchIndex, ["conflicted.txt"]); + + // `-z` (NUL-terminated) so `trickyName`'s embedded literal quote comes + // back as a raw byte instead of git's own C-quoted/escaped + // representation (which any name containing a literal `"` always gets, + // regardless of `core.quotepath` — that setting only affects non-ASCII, + // not embedded quote characters) — otherwise this assertion would need + // to hand-construct the escaped form itself. + const listingZ = git(repo, ["ls-files", "-s", "-z"], { + env: { GIT_INDEX_FILE: scratchIndex }, + }); + const names = listingZ + .split("\0") + .filter((e) => e.length > 0) + .map((e) => e.slice(e.indexOf("\t") + 1)); + assert.ok( + !names.includes("conflicted.txt"), + `conflicted.txt must be ABSENT from the scratch index — got:\n${JSON.stringify(names)}`, + ); + assert.ok( + names.includes(trickyName), + `sibling file with quotes/spaces must survive intact (not corrupted, not dropped) — got:\n${JSON.stringify(names)}`, + ); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +// Bug B regression — the blobless-clone `mktree --missing` fix (see the +// module doc's "Third finding") shipped without a regression test, on a +// claim that a hermetic fixture "cannot reproduce" a blobless clone since it +// is "never blobless". That claim is false: a genuinely blobless bare clone +// is reproducible locally, with no network, using exactly the technique +// below — confirmed empirically here (see also benchmark/README.md's "Full +// corpus sweep re-run #2" section, which documents an independent review +// reproducing this the same way). +// +// Build a normal (non-bare) origin repo with real commits, enable +// `uploadpack.allowFilter` on it (required for `--filter` to work at all +// against a local transport), then `git clone --bare --filter=blob:none +// file://` into a second temp path. That clone's object database +// genuinely has zero blob objects (confirmed below via `cat-file +// --batch-all-objects --batch-check`) — the exact shape `git mktree` +// (without `--missing`) fails against with `fatal: entry '' object +// is unavailable`. +test("seedScratchIndex(skipPaths) works against a genuinely blobless bare clone (mktree --missing)", () => { + const originRepo = mkdtempSync(join(tmpdir(), "gw-seed-index-blobless-origin-")); + const bareRepo = mkdtempSync(join(tmpdir(), "gw-seed-index-blobless-bare-")); + try { + git(originRepo, ["init", "-q", "-b", "main"]); + git(originRepo, ["config", "user.email", "t@t.com"]); + git(originRepo, ["config", "user.name", "t"]); + writeFileSync(join(originRepo, "conflicted.txt"), "base\n"); + writeFileSync(join(originRepo, "clean-only.txt"), "base clean\n"); + git(originRepo, ["add", "-A"]); + git(originRepo, ["commit", "-q", "-m", "base"]); + // Required for a blobless clone to work at all against a local + // (file://) transport — without this, the clone below fails outright. + git(originRepo, ["config", "uploadpack.allowFilter", "true"]); + + git(originRepo, ["checkout", "-q", "-b", "theirs"]); + writeFileSync(join(originRepo, "conflicted.txt"), "theirs change\n"); + git(originRepo, ["add", "-A"]); + git(originRepo, ["commit", "-q", "-m", "theirs: conflicting change"]); + const theirsSha = git(originRepo, ["rev-parse", "HEAD"]).trim(); + + git(originRepo, ["checkout", "-q", "main"]); + writeFileSync(join(originRepo, "conflicted.txt"), "main change\n"); + git(originRepo, ["add", "-A"]); + git(originRepo, ["commit", "-q", "-m", "main: conflicting change"]); + const mainSha = git(originRepo, ["rev-parse", "HEAD"]).trim(); + + // The real 3-way merge-tree result, computed BEFORE cloning (bare + // clones have no work tree, but merge-tree needs none either way — this + // just mirrors when replay-regenerate.mjs's candidate discovery runs it, + // against the full, non-blobless origin). + const treeOid = mergeTreeWriteTree(originRepo, mainSha, theirsSha); + + rmSync(bareRepo, { recursive: true, force: true }); + git(originRepo, ["clone", "-q", "--bare", "--filter=blob:none", `file://${originRepo}`, bareRepo]); + assert.equal( + git(bareRepo, ["rev-parse", "--is-bare-repository"]).trim(), + "true", + "fixture must actually be bare, or this test proves nothing", + ); + + // Confirm the clone is GENUINELY blobless (not just requested as such) — + // zero blob objects present locally, only the commits/trees that were + // fetched to satisfy the ref advertisement. + const batchCheck = git(bareRepo, ["cat-file", "--batch-all-objects", "--batch-check=%(objecttype)"]); + const objectTypeCounts = batchCheck + .trim() + .split("\n") + .filter(Boolean) + .reduce((counts, type) => ({ ...counts, [type]: (counts[type] ?? 0) + 1 }), {}); + assert.equal( + objectTypeCounts.blob ?? 0, + 0, + `fixture must be genuinely blobless (0 blob objects locally) — got: ${JSON.stringify(objectTypeCounts)}`, + ); + + const scratchIndex = join(bareRepo, "scratch-test-index-blobless"); + // Must NOT throw `fatal: entry '' object is unavailable`. + seedScratchIndex(bareRepo, treeOid, scratchIndex, ["conflicted.txt"]); + + const listing = lsFilesScratch(bareRepo, scratchIndex); + assert.ok( + !listing.split("\n").some((l) => l.endsWith("\tconflicted.txt")), + `conflicted.txt must be ABSENT from the scratch index — got:\n${listing}`, + ); + assert.ok( + listing.includes("clean-only.txt"), + `clean-only.txt must be present at stage 0 in the scratch index — got:\n${listing}`, + ); + } finally { + rmSync(originRepo, { recursive: true, force: true }); + rmSync(bareRepo, { recursive: true, force: true }); + } +}); diff --git a/scripts/replay-conflicts.mjs b/scripts/replay-conflicts.mjs index b1fd9f8f..fa4b613a 100644 --- a/scripts/replay-conflicts.mjs +++ b/scripts/replay-conflicts.mjs @@ -223,7 +223,13 @@ const disagreeExamples = []; const normalizeForCompare = (text) => text.replace(/\r\n/g, "\n").split("\n").map((l) => l.replace(/[ \t]+$/, "")).join("\n").replace(/\n+$/, ""); -const resolveOptions = WITH_REFACTORING ? { refactoringAware: { enabled: true } } : {}; +// v3.10 — dans un commit de merge rejoué, le premier parent EST la branche +// cible (celle où le merge a été commité). Le replay exerce donc la vraie +// règle contextuelle : les scalaires de version reviennent au côté cible. +const MERGE_CONTEXT = { operation: "merge", targetSide: "ours" }; +const resolveOptions = WITH_REFACTORING + ? { refactoringAware: { enabled: true }, mergeContext: MERGE_CONTEXT } + : { mergeContext: MERGE_CONTEXT }; // --lists accumulators let listComplexHunks = 0; diff --git a/scripts/replay-regenerate.mjs b/scripts/replay-regenerate.mjs new file mode 100644 index 00000000..f4861ca9 --- /dev/null +++ b/scripts/replay-regenerate.mjs @@ -0,0 +1,442 @@ +#!/usr/bin/env node +/** + * replay-regenerate.mjs — measure whether REAL regeneration (accuracy lot D, + * task 4) reproduces the lockfile a team actually committed, on historical + * merges from a real, already-cloned corpus repo. + * + * Why this is a separate script from scripts/replay-conflicts.mjs / + * benchmark/run.mjs: those replay merges purely with `git merge-tree + * --write-tree`, which never touches a working tree — there is no way to + * measure "does `npm install --package-lock-only` reproduce this lockfile" + * without an actual checkout and an actual install. See the task-4 brief + * (.superpowers/sdd/2026-08-26-regenerate-tier/task-4-brief.md), § "Measurement". + * + * Two-stage approach (same shape as replay-conflicts.mjs's mergeTree() reuse): + * 1. CHEAP — `git merge-tree --write-tree` (diff3) over up to --max-merges + * historical merges, to find CANDIDATES: merges whose conflict set + * includes a v1-registry lockfile (packages/core/src/regenerate/registry.ts). + * No checkout, no network beyond having the repo already cloned. + * 2. EXPENSIVE — for up to --max-real candidates PER ECOSYSTEM (the plan's + * own "≤20 merges per ecosystem" ceiling — default here matches it): + * a. resolve the ecosystem's sourcesOfTruth (package.json/composer.json) + * from the merge-tree result — either it merged clean, or + * @gitwand/core's resolve() settles it; a still-conflicted source + * makes the plan non-runnable (buildRegenerationPlan), same as the + * real CLI decides in commands/resolve.ts. + * b. build a `RegenerationPlan` and, if runnable, run the EXACT same + * executor the CLI uses in production — `runRegeneration` from + * packages/cli/dist/regenerate-runner.js (disposable git worktree, + * script-suppression flags baked into the registry command, wall-clock + * timeout, env allowlist). Reused rather than reimplemented so this + * measurement reflects the real execution path, not a stand-in. + * c. compare the regenerated lockfile against the ACTUAL committed one + * (`git show :`) via structuralMatch() + * (scripts/lib/regenerate-compare.mjs). + * + * Bounded, network-required (real `npm install`/`composer update` calls) → + * this script does NOT run in CI, same as replay-conflicts.mjs/benchmark/run.mjs + * — it is an operator-run tool. See benchmark/README.md for the method write-up + * and results. + * + * Design choice on WORKTREE SOURCE: `runRegeneration` always worktrees from + * `repoRoot`'s current HEAD (that is correct for the real CLI, where HEAD + * *is* the in-progress merge's target branch). To reproduce a specific + * historical merge here, this script points `repoRoot`'s HEAD at that merge's + * first parent (the "ours"/target side — matching the v3.10 merge-context + * convention used throughout the benchmark, see replay-conflicts.mjs) right + * before invoking it, and restores the original HEAD when done. + * + * Final review Finding 5 — `` MUST be a bare repository. An + * earlier revision of this comment claimed "can be bare or non-bare", which + * was wrong: `git update-ref HEAD ` follows the symref — on a non-bare + * repo with a branch checked out, it silently rewrites THAT branch's ref, + * not just a detached state. This script refuses to run against a non-bare + * repo (`git rev-parse --is-bare-repository`) before it ever moves HEAD. + * It also restores the operator's original HEAD from a `SIGINT`/`SIGTERM` + * handler, not just the happy-path tail of the script — a Ctrl-C mid-sweep + * (the natural way an operator aborts a real multi-minute install run) + * would otherwise leave HEAD reset to a historical commit while the + * index/worktree still hold newer state, an easy silent-loss trap for + * whatever the operator commits there next. + * + * Usage: + * node scripts/replay-regenerate.mjs [--max-merges N] \ + * [--max-real N] [--ecosystem npm,composer] [--timeout-ms N] [--json] + * + * must already be a local clone with the corpus commit reachable + * (bare+blobless, pinned to the corpus SHA, is the recommended shape — see + * benchmark/run.mjs's prepare() for the exact recipe; this script does not + * clone for you, same separation of concerns as replay-conflicts.mjs). + */ + +import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { rm } from "node:fs/promises"; +import { + resolve as gwResolve, + findEcosystem, + buildRegenerationPlan, +} from "../packages/core/dist/index.js"; +import { runRegeneration } from "../packages/cli/dist/regenerate-runner.js"; +import { structuralMatch } from "./lib/regenerate-compare.mjs"; +import { seedScratchIndex } from "./lib/seed-index.mjs"; +import { mergeTree as mergeTreeLib } from "./lib/merge-tree.mjs"; + +// ─── args ──────────────────────────────────────────────────────────────────── + +const args = process.argv.slice(2); +const repo = args.find((a) => !a.startsWith("--")); +if (!repo) { + console.error( + "usage: node scripts/replay-regenerate.mjs [--max-merges N] [--max-real N] [--ecosystem npm,composer] [--timeout-ms N] [--json]", + ); + process.exit(2); +} +const flagValue = (name) => { + const idx = args.indexOf(name); + return idx !== -1 ? args[idx + 1] : undefined; +}; +const MAX_MERGES = Number(flagValue("--max-merges") ?? 500); +// The plan's own bound (task-4 brief checklist): "≤ 20 merges per ecosystem". +const MAX_REAL_PER_ECOSYSTEM = Number(flagValue("--max-real") ?? 20); +const ECOSYSTEM_FILTER = flagValue("--ecosystem") ? new Set(flagValue("--ecosystem").split(",")) : null; +const TIMEOUT_MS_OVERRIDE = flagValue("--timeout-ms") ? Number(flagValue("--timeout-ms")) : undefined; +const AS_JSON = args.includes("--json"); +const MAX_EXAMPLES = 15; + +// ─── git helpers (mirrors replay-conflicts.mjs) ───────────────────────────── + +function git(cmd, opts = {}) { + return execFileSync("git", ["-C", repo, ...cmd], { + encoding: "utf-8", + maxBuffer: 64 * 1024 * 1024, + ...opts, + }); +} + +// ─── Finding 5 (final review) — bare-repo guard, before ANYTHING moves HEAD ─ +// +// `git update-ref HEAD ` follows the symref: on a non-bare repo with a +// branch checked out, it silently rewrites that branch's ref, not merely a +// detached-HEAD state. Refuse outright rather than risk a real branch. +function isBareRepo() { + try { + return git(["rev-parse", "--is-bare-repository"]).trim() === "true"; + } catch { + return false; + } +} + +if (!isBareRepo()) { + console.error( + `refusing to run: "${repo}" is not a bare repository.\n` + + `This script moves HEAD (git update-ref HEAD ) to replay each\n` + + `candidate merge. On a non-bare repo with a branch checked out, that silently\n` + + `rewrites the checked-out branch's ref, not just a detached state — this could\n` + + `reset a real branch to a historical commit.\n` + + `Re-clone as bare (+blobless, pinned to the corpus SHA — see benchmark/run.mjs's\n` + + `prepare() for the exact recipe) and re-run against that clone instead.`, + ); + process.exit(2); +} + +// ─── Finding 5 (final review) — restore the operator's HEAD on Ctrl-C too ─── +// +// Captured immediately (before stage 1/2 do any work) so a SIGINT/SIGTERM at +// ANY point — including mid-sweep, the natural way an operator aborts a real +// multi-minute install run — can always restore it, not just the happy-path +// tail of the script. +const originalHead = git(["rev-parse", "HEAD"]).trim(); +let exitingViaSignal = false; + +function restoreHeadAndExit(signal) { + if (exitingViaSignal) return; // a second signal while we're already cleaning up + exitingViaSignal = true; + try { + git(["update-ref", "HEAD", originalHead]); + console.error(`\n[replay-regenerate] ${signal} received — restored HEAD to ${originalHead.slice(0, 10)} before exiting.`); + } catch (err) { + console.error( + `\n[replay-regenerate] ${signal} received — FAILED to restore HEAD to ${originalHead.slice(0, 10)}: ` + + `${err instanceof Error ? err.message : String(err)}. Fix "${repo}"'s HEAD manually before reusing this clone.`, + ); + } + process.exit(130); +} +process.on("SIGINT", () => restoreHeadAndExit("SIGINT")); +process.on("SIGTERM", () => restoreHeadAndExit("SIGTERM")); + +let mergeTreeErrors = 0; + +/** merge-tree exits 1 on conflict — capture that case without throwing. Same + * pattern as replay-conflicts.mjs's mergeTree(): DO NOT throw on conflict. + * Delegates to scripts/lib/merge-tree.mjs (see its doc comment for the `-z` + * fix and the exact output shape it was verified against); any error that + * isn't a recognised conflict outcome is counted here rather than crashing + * the whole candidate-discovery sweep. */ +function mergeTree(p1, p2) { + try { + return mergeTreeLib(repo, p1, p2); + } catch { + mergeTreeErrors++; + return null; + } +} + +/** Repo-tree-relative file read via `git show :`; null if absent. */ +function readTreePath(treeOid, path) { + try { + return git(["show", `${treeOid}:${path}`], { stdio: ["ignore", "pipe", "ignore"] }); + } catch { + return null; + } +} + +// v3.10 convention (replay-conflicts.mjs) — the first parent of a replayed +// merge commit IS the target branch; version-identity scalars stay "ours". +const MERGE_CONTEXT = { operation: "merge", targetSide: "ours" }; +const resolveOptions = { mergeContext: MERGE_CONTEXT }; + +/** + * Settle one sourceOfTruth path's state from the merge-tree result — exactly + * the three states `RegenerationPlan.sources[].state` models: + * - "clean": no conflict markers in the merge-tree result for this path. + * - "resolved": conflicted, but @gitwand/core's resolve() fully settles it + * (mirrors commands/resolve.ts's `stats.remaining === 0` bar exactly). + * - "conflicted": still has unresolved conflicts, or the path is absent + * from the merge-tree result (renamed/deleted) — unknown state is unsafe. + */ +function resolveSource(treeOid, path) { + const content = readTreePath(treeOid, path); + if (content === null) return { state: "conflicted", content: null }; + if (!content.includes("<<<<<<<")) return { state: "clean", content }; + let result; + try { + result = gwResolve(content, path, resolveOptions); + } catch { + return { state: "conflicted", content: null }; + } + if (result.mergedContent !== null && result.stats.remaining === 0) { + return { state: "resolved", content: result.mergedContent }; + } + return { state: "conflicted", content: null }; +} + +// ─── stage 1: cheap candidate discovery ───────────────────────────────────── + +const merges = git(["rev-list", "--merges", `--max-count=${MAX_MERGES}`, "HEAD"]).split("\n").filter(Boolean); + +/** @type {Map>} */ +const candidatesByEcosystem = new Map(); +let mergesScanned = 0; + +for (const m of merges) { + mergesScanned++; + let parents; + try { + parents = git(["rev-list", "--parents", "-n", "1", m]).trim().split(" ").slice(1); + } catch { + continue; + } + if (parents.length !== 2) continue; // skip octopus merges + + const conflict = mergeTree(parents[0], parents[1]); + if (!conflict) continue; + + for (const path of new Set(conflict.files)) { + const ecosystem = findEcosystem(path); + if (!ecosystem) continue; + if (ECOSYSTEM_FILTER && !ECOSYSTEM_FILTER.has(ecosystem.id)) continue; + if (!candidatesByEcosystem.has(ecosystem.id)) candidatesByEcosystem.set(ecosystem.id, []); + candidatesByEcosystem.get(ecosystem.id).push({ + sha: m, + parents, + lockfilePath: path, + treeOid: conflict.treeOid, + // Final review, Critical #1 — retained so `seedScratchIndex` can strip + // these paths back out of the scratch index (see its doc comment): + // `merge-tree --write-tree`'s conflicted blobs hold literal diff3 + // marker text, and production never materializes that content because + // a real merge index keeps conflicted paths off stage 0 entirely. + conflictedPaths: conflict.files, + ecosystem, + }); + } +} + +// ─── stage 2: expensive real regeneration, bounded per ecosystem ─────────── +// (`originalHead` was already captured above, before stage 1, so the +// SIGINT/SIGTERM handler can restore it even if interrupted during stage 1.) + +const perEcosystem = {}; + +for (const [ecosystemId, allCandidates] of candidatesByEcosystem) { + const candidates = allCandidates.slice(0, MAX_REAL_PER_ECOSYSTEM); + const report = { + ecosystem: ecosystemId, + candidatesFound: allCandidates.length, + attempted: candidates.length, + outcomes: {}, // kind -> count + runnablePlans: 0, + ran: 0, // regeneration command actually exited 0 + comparable: 0, // both regenerated + actual committed content available + matched: 0, + examples: [], + }; + perEcosystem[ecosystemId] = report; + + const bump = (kind) => { + report.outcomes[kind] = (report.outcomes[kind] ?? 0) + 1; + }; + + for (const candidate of candidates) { + try { + const siblingFiles = {}; + const resolvedContents = {}; + for (const path of candidate.ecosystem.sourcesOfTruth) { + const r = resolveSource(candidate.treeOid, path); + siblingFiles[path] = { state: r.state }; + if (r.content !== null) resolvedContents[path] = r.content; + } + const plan = buildRegenerationPlan(candidate.lockfilePath, candidate.ecosystem, { siblingFiles }); + + if (!plan.runnable) { + bump("not-runnable"); + continue; + } + report.runnablePlans++; + + const resolvedSources = plan.sources.map((s) => ({ path: s.path, content: resolvedContents[s.path] })); + + // Point the corpus repo's HEAD at this merge's target side (first + // parent) so `runRegeneration`'s `git worktree add --detach HEAD` + // reproduces the right commit — see module doc. + git(["update-ref", "HEAD", candidate.parents[0]]); + + // Follow-up plan ("merge-index seeding"): seed the disposable worktree + // from the ACTUAL 3-way merge result — the tree `merge-tree + // --write-tree` already computed during candidate discovery + // (`candidate.treeOid`) — not just `candidate.parents[0]`'s bare HEAD. + // A scratch index is a throwaway file; it never touches this corpus + // repo's own index. + const seedIndexFile = join(tmpdir(), `gitwand-replay-index-${randomUUID()}`); + // Final review, Critical #1 — skip the paths that were genuinely + // conflicted in this historical merge, so the scratch index matches + // production's real multi-stage-skip behavior instead of materializing + // diff3 marker content for them. + seedScratchIndex(repo, candidate.treeOid, seedIndexFile, candidate.conflictedPaths); + + let regenOutcome; + try { + regenOutcome = await runRegeneration({ + repoRoot: repo, + file: candidate.lockfilePath, + ecosystem: candidate.ecosystem, + resolvedSources, + timeoutMs: TIMEOUT_MS_OVERRIDE, + seedIndexFile, + }); + } finally { + await rm(seedIndexFile, { force: true }); + } + + bump(regenOutcome.kind); + + if (regenOutcome.kind !== "success" || regenOutcome.content === null) { + if (report.examples.length < MAX_EXAMPLES) { + report.examples.push({ + merge: candidate.sha.slice(0, 10), + path: candidate.lockfilePath, + outcome: regenOutcome.kind, + reason: regenOutcome.reason, + }); + } + continue; + } + report.ran++; + + let actual; + try { + actual = git(["show", `${candidate.sha}:${candidate.lockfilePath}`], { + stdio: ["ignore", "pipe", "ignore"], + }); + } catch { + bump("actual-unavailable"); + continue; + } + + report.comparable++; + const cmp = structuralMatch(ecosystemId, actual, regenOutcome.content); + if (cmp.match) report.matched++; + if (report.examples.length < MAX_EXAMPLES) { + report.examples.push({ + merge: candidate.sha.slice(0, 10), + path: candidate.lockfilePath, + outcome: regenOutcome.kind, + match: cmp.match, + method: cmp.method, + durationMs: regenOutcome.trace.durationMs, + }); + } + } catch (err) { + // Offline/partial-clone/worktree failures must not crash the whole + // run — skip this one candidate and keep going (task-4 brief: "Offline + // is a first-class path"). + bump("error"); + if (report.examples.length < MAX_EXAMPLES) { + report.examples.push({ + merge: candidate.sha.slice(0, 10), + path: candidate.lockfilePath, + outcome: "error", + reason: err instanceof Error ? err.message : String(err), + }); + } + } + } + + report.agreementRate = report.comparable ? Number(((report.matched / report.comparable) * 100).toFixed(1)) : null; +} + +// Restore HEAD exactly as found — this script mutates a shared ref on the +// caller-supplied clone (like benchmark/run.mjs's prepare() does at the start +// of a run); leave it pointed where the caller expects afterwards. +try { + git(["update-ref", "HEAD", originalHead]); +} catch { + // best-effort +} + +// ─── report ────────────────────────────────────────────────────────────────── + +const report = { + repo, + mergesScanned, + mergeTreeErrors, + maxMerges: MAX_MERGES, + maxRealPerEcosystem: MAX_REAL_PER_ECOSYSTEM, + perEcosystem, +}; + +if (AS_JSON) { + console.log(JSON.stringify(report, null, 2)); +} else { + console.log(`\n═══ ${repo} — regenerate-tier replay ═══`); + console.log(`merges scanned (cheap stage): ${mergesScanned}${mergeTreeErrors ? ` (⚠ ${mergeTreeErrors} merge-tree errors)` : ""}`); + for (const eco of Object.values(perEcosystem)) { + console.log(`\n─── ${eco.ecosystem} ───`); + console.log(`candidates found: ${eco.candidatesFound} (attempted: ${eco.attempted}, cap ${MAX_REAL_PER_ECOSYSTEM}/ecosystem)`); + console.log(`runnable plans: ${eco.runnablePlans}`); + console.log(`ran successfully: ${eco.ran}`); + console.log(`comparable: ${eco.comparable}`); + console.log(`structural match: ${eco.matched} (${eco.agreementRate === null ? "n/a" : eco.agreementRate + "%"})`); + console.log(`outcomes: ${JSON.stringify(eco.outcomes)}`); + if (eco.examples.length) { + console.log(`examples:`); + for (const ex of eco.examples) { + console.log(` ${ex.merge} ${ex.path} ${ex.outcome}${"match" in ex ? ` match=${ex.match}` : ""}${ex.reason ? ` — ${ex.reason}` : ""}`); + } + } + } +} diff --git a/website/fix/package-lock-json-merge-conflict.md b/website/fix/package-lock-json-merge-conflict.md index d027966f..9a398ff1 100644 --- a/website/fix/package-lock-json-merge-conflict.md +++ b/website/fix/package-lock-json-merge-conflict.md @@ -116,13 +116,20 @@ package-lock.json merge=npm-lock **Batch dependency updates.** Most lockfile conflicts come from several bot PRs updating dependencies in parallel. Grouping them into one PR per week removes the overlap rather than resolving it. -## A structural resolution +## Why GitWand declines to merge lockfiles by default -The reason `--theirs` is a coin flip is that the tooling has thrown away the structure. A lockfile is not lines — it is a map of independent entries, which is exactly the shape a three-way merge handles perfectly. +The reason `--theirs` is a coin flip is that the tooling has thrown away the structure. A lockfile is not lines — it is a map of independent entries, which looks like exactly the shape a three-way merge should handle perfectly. -[GitWand](/) resolves lockfiles that way. It ships dedicated semantic resolvers for `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml` and `Cargo.lock`: each version is parsed into a map of package entries, merged key by key against the common ancestor, and re-serialised with the original formatting. Added on one side only → kept. Removed on one side, untouched on the other → removed. Changed on one side → taken. Changed on both to different versions → surfaced to you as a real conflict, with the package named, instead of buried in a thousand-line diff. +[GitWand](/) tried that: dedicated semantic resolvers for `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml` and `Cargo.lock`, each version parsed into a map of package entries and merged key by key against the common ancestor. Then it [measured the result against 1,662 real merges](https://github.com/devlint/GitWand/tree/main/benchmark) instead of assuming it was correct — and even a structurally-correct key-wise merge diverged from what the team actually committed in almost every case, because a lockfile also encodes a resolved dependency *graph*, not just a set of independent pins; merging two valid maps key by key can still produce a graph the installer would never have resolved to on its own. -The same [engine](/guide/conflict-resolution) handles the JSON, YAML, TypeScript import blocks and Vue SFCs around it, and runs as a [desktop app](/guide/desktop), a [CLI](/guide/cli) for hooks and CI, and an [MCP server](/guide/mcp) for coding agents. A reinstall is still recommended afterwards — a merged lockfile is consistent, not necessarily freshly resolved. +So by default, GitWand **declines** lockfile conflicts instead of guessing: it names the file, explains why, and tells you to resolve `package.json` (or `composer.json`, `Cargo.toml`…) first and re-run the installer — the workflow above. Only the changes that fabricate nothing still apply automatically: identical edits on both sides, a change on one side only, a deletion against an untouched side, whitespace-only differences. + +Two opt-ins exist for teams that want more automation, each measured rather than assumed safe: + +- **`gitwand resolve --regenerate`** (or `.gitwandrc`'s `regenerate: true`) actually re-runs the installer for you — in a disposable, sandboxed `git worktree`, never your real working tree, with no secrets forwarded to the child process. Measured accuracy on this tier so far is below the bar GitWand holds itself to for auto-applying anything (see the [benchmark README](https://github.com/devlint/GitWand/tree/main/benchmark) for the current numbers), so treat it as a fast first attempt to verify, not a silent auto-merge. +- **`resolveGeneratedFiles: true`** in `.gitwandrc` (or `--resolve-generated` on the CLI) restores the old key-wise semantic merge described above, for teams that have decided — as a repository convention — that they'd rather merge lockfiles than regenerate them. + +The same [engine](/guide/conflict-resolution) handles the JSON, YAML, TypeScript import blocks and Vue SFCs around it, and runs as a [desktop app](/guide/desktop), a [CLI](/guide/cli) for hooks and CI, and an [MCP server](/guide/mcp) for coding agents. ## FAQ diff --git a/website/guide/conflict-resolution.md b/website/guide/conflict-resolution.md index 7a83c7bf..95babb9a 100644 --- a/website/guide/conflict-resolution.md +++ b/website/guide/conflict-resolution.md @@ -87,7 +87,7 @@ When enabled, a hunk no deterministic pattern could resolve is sent to the confi ### `generated_file` -The file is auto-generated (lockfiles, minified bundles, build manifests). Detected by filename patterns. Resolution: prefer theirs (the file will be regenerated). +The file is auto-generated (lockfiles, minified bundles, build manifests). Detected by filename patterns. **Declined by default**: [measured on 1,662 real merges](https://github.com/devlint/GitWand/tree/main/benchmark), auto-merging a generated file diverged from what teams actually shipped in almost every case, so GitWand tells you to resolve the source file and re-run the installer/build instead of guessing. Only the patterns that fabricate nothing (`same_change`, `one_side_change`, `delete_no_change`, `whitespace_only`) still apply automatically on these files. Opt back into the old accept-theirs/semantic-merge behavior with `.gitwandrc`'s `resolveGeneratedFiles: true` or `gitwand resolve --resolve-generated` — see [Generated Files](/reference/config#generated-files) for the full option, including the CLI's opt-in `--regenerate` tier that actually re-runs the installer in a disposable worktree. **Detected patterns:** `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `composer.lock`, `Gemfile.lock`, `Cargo.lock`, `.min.js`, `.min.css`, `dist/`, `build/manifest.json`, `.bundle.js`, `.bundle.css` @@ -157,9 +157,11 @@ The default resolution strategy can be overridden per-project with a [`.gitwandr | `reorder_only` | Either side | Same content, different order | | `insertion_at_boundary` | Merge both | Independent additions around intact base | | `value_only_change` | Theirs | Incoming values are newer | -| `generated_file` | Theirs | Will be regenerated | +| `generated_file` | Declined by default* | Committed version is a tool's output, not a merge | | `complex` | No auto-resolution | Too risky | +\* Restore the old behavior with `.gitwandrc`'s `resolveGeneratedFiles: true` or `gitwand resolve --resolve-generated`. + ## Format-Aware Resolvers Beyond the generic text-based resolution, GitWand includes specialized resolvers for structured file formats: diff --git a/website/reference/cli-commands.md b/website/reference/cli-commands.md index da8f4a7b..13741188 100644 --- a/website/reference/cli-commands.md +++ b/website/reference/cli-commands.md @@ -28,6 +28,12 @@ gitwand resolve [files...] [options] | `--no-whitespace` | Skip whitespace-only conflicts | | `--ci` | CI mode: JSON output, exit code 1 if unresolved | | `--json` | Alias for `--ci` | +| `--resolve-generated` | Auto-resolve generated files (lockfiles, `dist/`) — declined by default: regenerate them instead | +| `--regenerate` | Re-run the ecosystem's generator (npm/pnpm/yarn-berry/composer/cargo) for declined lockfiles once their source of truth is clean/resolved (sandboxed git worktree, opt-in — see `.gitwandrc` `"regenerate": true`) | +| `--concurrency=N` | Parallel file workers (default 8, min 1) | +| `--llm-fallback` | Enable LLM fallback for unresolved conflicts (opt-in, experimental) | +| `--llm-provider=X` | LLM provider: `claude` (default) \| `openai` \| `ollama` | +| `--llm-model=X` | Model name (e.g. `claude-sonnet-4-6`, `gpt-4o-mini`, `llama3`) | ### Examples @@ -174,6 +180,33 @@ Reports the number of conflicted files, total conflicts, and how many are auto-r --- +## `gitwand conventions` + +Measures this repo's own merge conventions from its historical merges (which side wins version scalars, whether the team regenerates or merges lockfiles, how the changelog is maintained) and writes the verdicts to `.git/gitwand/conventions.json` — per clone, never committed, always beaten by an explicit `.gitwandrc`. + +### Options + +| Option | Description | +|--------|-------------| +| `--show` | Print the currently persisted conventions without re-measuring | +| `--clear` | Delete the persisted conventions file | +| `--max-merges=N` | Cap on historical merges replayed (default 200) | +| `--json` | Machine-readable output | + +### Example + +```bash +$ gitwand conventions + measured on 187 merges / 412 conflicted files (engine 3.8.0, 2026-08-27) + + generated files regenerate (11 samples, 91 %) + changelog tool-rebuilt (8 samples, 100 %) + +✓ written to .git/gitwand/conventions.json (per-clone, never committed; an explicit .gitwandrc always wins) +``` + +--- + ## `gitwand --help` Show usage information. diff --git a/website/reference/config.md b/website/reference/config.md index 098e14d5..d5269aa6 100644 --- a/website/reference/config.md +++ b/website/reference/config.md @@ -94,6 +94,133 @@ When multiple patterns match a file: 2. Falls back to the global `policy` 3. Falls back to `DEFAULT_POLICY` (`"prefer-theirs"`) +## Generated Files + +Lockfiles (`package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `Cargo.lock`…), +minified bundles and `dist/` outputs are detected as **generated files**. By +default GitWand declines to auto-resolve them and tells you why: the committed +version of a generated file is a tool's output, not a merge of two texts — +[measured on 1,662 real merges](https://github.com/devlint/GitWand/tree/main/benchmark), +auto-merging them diverged from what teams actually shipped in almost every +case. Resolve the source file (`package.json`, `composer.json`…), re-run the +installer or build, and the conflict disappears. + +Only the patterns that fabricate nothing still apply automatically on these +files: identical edits on both sides, a change on one side only, a deletion +against an untouched side, whitespace-only differences. + +To extend detection to your own generated paths: + +```json +{ + "generatedFiles": ["src/**/*.generated.ts", "*.pb.go", "api/openapi-client/**"] +} +``` + +To restore full auto-resolution (semantic lockfile merges, accept-theirs) — for +example if your team genuinely merges lockfiles rather than regenerating them: + +```json +{ + "resolveGeneratedFiles": true +} +``` + +The CLI equivalent is `gitwand resolve --resolve-generated`. This is a +repository convention, so it lives in `.gitwandrc` rather than in the app +settings. + +### Regenerate tier + +Merging a lockfile textually is wrong in almost every real case — the +committed version is a tool's output, not the union of two edits. Rather than +guess, GitWand can instead resolve the *source* file (`package.json`, +`composer.json`, `Cargo.toml`…) and re-run the ecosystem's own installer to +regenerate the lockfile, then take that as the resolution. + +This never happens automatically. It requires explicit opt-in, per invocation +or per repository: + +```bash +gitwand resolve --regenerate +``` + +```json +{ + "regenerate": true +} +``` + +When declined without the flag, `gitwand resolve` now suggests it by default +whenever a lockfile ecosystem is recognized: + +``` +Some declined file(s) could be auto-resolved by regenerating their lockfile — re-run with --regenerate. +``` + +**What runs.** A small, deliberately narrow registry of ecosystems that each +expose a lockfile-only, script-suppressed mode — never a full install: + +| Ecosystem | Command | +|---|---| +| npm | `npm install --package-lock-only --ignore-scripts` | +| pnpm | `pnpm install --lockfile-only --ignore-scripts` | +| Yarn (Berry only) | `yarn install --mode=update-lockfile` | +| Composer | `composer update --lock --no-scripts --no-install` | +| Cargo | `cargo generate-lockfile` | + +The command runs inside a disposable `git worktree` — never your real working +tree — populated only with the already-resolved source files, under a +wall-clock timeout (120s by default). The command and its duration are folded +into the resolution reason; the full trace (binary, arguments, duration, exit +code) is visible with `--verbose`. The script-suppression flags in the table +above are registry constants; nothing you configure can remove them. + +**What never runs.** No full `install` (dependencies aren't actually +downloaded beyond what resolving the lockfile requires), no lifecycle scripts +(`postinstall` and friends), and no attempt at all when the ecosystem needs +network access and the machine is offline — that case declines with the same +interim message as always, never a partial or guessed lockfile. Any failure +(missing toolchain, timeout, non-zero exit, invalid output) hands the conflict +back untouched, with the failure detail appended to the reason. + +**Interaction with measured conventions.** The `gitwand conventions` CLI command +can measure, from a repository's own merge history, whether its team actually +regenerates or textually merges its generated files. A measured `"regenerate"` +verdict is only ever a *hint* — the extra provenance text visible via +`gitwand resolve --verbose` and the default summary offer above — it never +runs the regenerate tier by itself. A measured `"merge"` verdict, by contrast, +can flip the textual path on (equivalent to `resolveGeneratedFiles: true`) when +nothing more specific overrides it. Precedence, most to least specific: + +1. `gitwand resolve --resolve-generated` / `--regenerate` (explicit, per invocation) +2. `.gitwandrc` `resolveGeneratedFiles` / `regenerate` (explicit, per repository) +3. A measured `generatedFiles` convention (`gitwand conventions`) +4. The engine's own default — decline, with an actionable message + +## Merge Context + +GitWand's engine accepts an optional **merge context** — which operation is in +progress (merge, rebase, cherry-pick, revert) and which side of the conflict +markers is the target branch. You normally never set this: the desktop app, the +CLI and the MCP server detect it from the repository's `.git` state. + +It changes one class of decision. A version scalar set differently on both +sides (`'13.x-dev'` vs `'12.54.1'`) is a real decision, not a volatile value — +and with context, the answer is deterministic: **the target branch keeps its +version identity**. Measured on laravel/framework's real merge history, this +took agreement with the humans' own resolutions from 36.6 % to 81.9 %. Without +context, GitWand proposes instead of applying. Ordinary dependency bumps +(orderable versions on both sides) keep the "newest wins" rule either way. + +API consumers can pass it explicitly: + +```ts +resolve(content, filePath, { + mergeContext: { operation: "merge", targetSide: "ours", oursRef: "13.x", theirsRef: "12.x" }, +}); +``` + ## Confidence Levels The `minConfidence` setting (set implicitly by each policy) controls the minimum confidence score required for auto-resolution: