From 3c2a4b4ec6cfcf9e6bdb4c2de982a338ddacdbcf Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Wed, 26 Aug 2026 13:07:16 +0000 Subject: [PATCH 01/37] feat(core): enforce the classifier contract, format invariants, and decline on generated files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lot 1 of docs/superpowers/specs/2026-08-26-conflict-engine-accuracy.md — the three changes that stop the engine being confidently wrong, measured against what teams actually committed (benchmark/). A — classifier contract. A complex hunk resolved by a format-aware resolver is reclassified `format_semantic` (new ConflictType), keeping the classifier's dimensions and boosters and adding the resolver's, then submitted to the same confidence threshold and policy gates as every pattern (semantic merges combine both sides — policies that exclude non_overlapping exclude them too, like the imports resolver already did). A non-complex hunk applied via a format resolver keeps its type but its confidence now records the semantic validation instead of silently bypassing the threshold. No file can come back fully resolved while reporting `complex` hunks — on laravel that was 116 of 325 files, applied with no confidence score and no trace. B — format invariants. validateMergedContent now checks what syntax cannot see: a changelog with two Unreleased sections or duplicated version headings, a JSON object with duplicate keys (scanner is string-safe and per-object; YAML dups were already caught by the yaml parser). Violations retract every auto-applied hunk in the file, same mechanism as the v2.4 parse-tree retraction. D (interim) — generated files decline by default. Their committed version is a tool's output, not a merge of two texts: "accept theirs" and semantic lockfile merges both diverged from what teams shipped in ~100% of measured cases. The engine now declines with an actionable reason (resolve the source, re-run the installer/build); only patterns that fabricate nothing (same_change, one_side_change, delete_no_change, whitespace_only) still apply. The historical behaviour sits behind a new option, resolveGeneratedFiles (default false), and generated_file moves from the "trivial" tier to "unresolved". Measured before → after on the pinned corpus (files resolved end-to-end, byte-identical to the human merge): laravel/framework 325 files 24.3% → 216 files 36.6% prettier/prettier 179 files 25.3% → 100 files 45.0% vuejs/core 226 files 92.5% → 220 files 95.0% expressjs/express 47 files 59.6% → 49 files 59.2% Coverage drops by design — those resolutions were ones the product's own contract said it should not have made. The remaining laravel disagreements are now Application.php version scalars and composer.json — exactly the merge- context problem (direction-aware value_only_change), which is lot C. Tests: 1078 passing. Lockfile/value_only suites exercise their resolvers behind the opt-in; corpus fixtures F11/F13/F14 now expect decline; the golden-funnel snapshot was regenerated on purpose (complex 20 → 14, format_semantic 6, autoResolved 31 → 28 on the fixture corpus). Not in this lot: desktop/CLI surfacing of resolveGeneratedFiles (settings + 5 locales), and website/reference/config.md documentation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LQoTe6RE4JpoQknCASS3hu --- .../core/src/__tests__/accuracy-lot1.test.ts | 185 ++++++++++++++++++ packages/core/src/__tests__/corpus.ts | 6 +- .../src/__tests__/golden-funnel.default.json | 15 +- .../__tests__/golden-funnel.refactoring.json | 15 +- .../patterns/value-only-change.test.ts | 18 +- packages/core/src/__tests__/resolver.test.ts | 18 +- .../src/__tests__/resolvers/cargo.test.ts | 11 +- .../__tests__/resolvers/lockfile-npm.test.ts | 27 +-- .../__tests__/resolvers/lockfile-pnpm.test.ts | 27 +-- .../__tests__/resolvers/lockfile-yarn.test.ts | 27 +-- .../core/src/__tests__/stats/tiers.test.ts | 5 +- packages/core/src/resolver/assemble.ts | 23 ++- packages/core/src/resolver/format-dispatch.ts | 4 +- .../core/src/resolver/generated-detection.ts | 4 +- packages/core/src/resolver/index.ts | 174 ++++++++++++++-- packages/core/src/resolver/policy.ts | 2 + packages/core/src/resolver/validation.ts | 101 +++++++++- packages/core/src/stats/tiers.ts | 5 +- packages/core/src/types.ts | 16 ++ 19 files changed, 586 insertions(+), 97 deletions(-) create mode 100644 packages/core/src/__tests__/accuracy-lot1.test.ts 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..bdf09364 --- /dev/null +++ b/packages/core/src/__tests__/accuracy-lot1.test.ts @@ -0,0 +1,185 @@ +/** + * v3.9 — 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("fusion sémantique"); + }); + + 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(/politique|insuffisante/); + }); +}); + +// ─── 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(/régénère|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__/corpus.ts b/packages/core/src/__tests__/corpus.ts index edffff88..d114f624 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, // v3.9 — 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, // v3.9 — 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, // v3.9 — fichier généré : décline par défaut (se régénère, ne se fusionne pas), }; // ─── Format-aware — JSON sémantique ──────────────────────── diff --git a/packages/core/src/__tests__/golden-funnel.default.json b/packages/core/src/__tests__/golden-funnel.default.json index cc06b733..c0829417 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, + "autoResolved": 28, "byType": { - "complex": 20, + "complex": 14, "delete_no_change": 2, + "format_semantic": 6, "generated_file": 1, "insertion_at_boundary": 4, "non_overlapping": 4, @@ -16,11 +17,11 @@ "whitespace_only": 1 }, "tiers": { - "trivial": 25, - "advancedDeterministic": 1, + "trivial": 24, + "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..57e20e01 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, + "autoResolved": 28, "byType": { - "complex": 19, + "complex": 14, "delete_no_change": 2, + "format_semantic": 5, "generated_file": 1, "insertion_at_boundary": 4, "non_overlapping": 4, @@ -17,11 +18,11 @@ "whitespace_only": 1 }, "tiers": { - "trivial": 25, - "advancedDeterministic": 2, + "trivial": 24, + "advancedDeterministic": 7, "model": 0, - "unresolved": 19, - "residual": 21, - "aiReachable": 19 + "unresolved": 15, + "residual": 22, + "aiReachable": 15 } } 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 35d5f452..7c2b872e 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"; +// v3.9 — 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__/resolver.test.ts b/packages/core/src/__tests__/resolver.test.ts index 7d82ebfa..00ba0574 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"); + // v3.9 — 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"); + // v3.9 — 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); + // v3.9 — 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); // v3.9 — 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); // v3.9 — 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..c893c3b3 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"; +// v3.9 — 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/lockfile-npm.test.ts b/packages/core/src/__tests__/resolvers/lockfile-npm.test.ts index dc45e532..3779a2c9 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"; +// v3.9 — 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..fdfed327 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"; +// v3.9 — 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..e7632121 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"; +// v3.9 — 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 3fc0aac4..5efd8406 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); + // v3.9 — 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/resolver/assemble.ts b/packages/core/src/resolver/assemble.ts index 4b8098fc..bfacec36 100644 --- a/packages/core/src/resolver/assemble.ts +++ b/packages/core/src/resolver/assemble.ts @@ -193,21 +193,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 + // v3.9 — 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 + ? "Fichier auto-généré — différences volatiles uniquement (hashes/timestamps). Résous le fichier source (ex: package.json) puis régénère celui-ci avec son outil (install/build). Auto-résolution disponible via resolveGeneratedFiles: true." + : "Fichier auto-généré — ne se fusionne pas, se régénère. Résous le fichier source (ex: package.json) puis relance l'outil qui produit celui-ci (install/build). Auto-résolution (accepter theirs) disponible via resolveGeneratedFiles: true.", + }; + } + + // Opt-in resolveGeneratedFiles: true — comportement historique. + if (cosmetic) { return { lines: [...hunk.theirsLines], reason: "Fichier auto-généré — contenu structurel identique (seules les valeurs volatiles diffèrent). Résolution : accepter theirs. Suggestion : relancer le build/install.", }; } - return { lines: [...hunk.theirsLines], - reason: "Fichier auto-généré — le fichier sera régénéré après merge. Résolution : accepter theirs. Suggestion : relancer le build/install.", + reason: "Fichier auto-généré — le fichier sera régénéré après merge. Résolution : accepter theirs (opt-in resolveGeneratedFiles). Suggestion : relancer le build/install.", }; } diff --git a/packages/core/src/resolver/format-dispatch.ts b/packages/core/src/resolver/format-dispatch.ts index 6d017c5a..c669fbea 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 37f84d23..5fdc0f4e 100644 --- a/packages/core/src/resolver/generated-detection.ts +++ b/packages/core/src/resolver/generated-detection.ts @@ -107,14 +107,14 @@ export function reclassifyIfGenerated( baseAvailability: 0, }, boosters: [`Chemin correspond au pattern de fichier auto-généré : ${genInfo.label}`], - penalties: ["Le contenu sera régénéré — theirs est supposé plus récent"], + penalties: ["Le contenu commité est la sortie d'un outil — une fusion textuelle ne le reproduit pas"], }; return { ...hunk, type: "generated_file", confidence: generatedScore, - explanation: `Fichier auto-généré (${genInfo.label}). Ce fichier sera régénéré après le merge. Résolution proposée : accepter theirs et relancer le build.`, + explanation: `Fichier auto-généré (${genInfo.label}). Ce fichier se régénère, il ne se fusionne pas : résous sa source puis relance l'outil qui le produit (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 3a6ea03a..79e9bee0 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, @@ -57,31 +58,146 @@ 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 */ +/** + * v3.9 — 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", +]); + +/** + * v3.9 — 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, + `Résolveur format-aware « ${resolverUsed} » : fusion sémantique validée pour ce format`, + ], + penalties: hunk.confidence.penalties, + }; + return { + ...hunk, + type: "format_semantic", + confidence, + explanation: `Hunk résolu sémantiquement par le résolveur « ${resolverUsed} » (fusion par structure du format, pas par lignes).`, + trace: { + ...hunk.trace, + selected: "format_semantic", + summary: `Résolveur format-aware « ${resolverUsed} » — reclassifié depuis complex.`, + steps: [ + ...hunk.trace.steps, + { + type: "format_semantic" as ConflictType, + passed: true, + reason: `Le résolveur « ${resolverUsed} » a produit une fusion sémantique ; le hunk n'est plus « complex ».`, + }, + ], + }, + }; +} + +/** + * v3.9 — 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, + `Résolveur format-aware « ${resolverUsed} » : fusion validée sémantiquement pour ce format`, + ], + penalties: hunk.confidence.penalties, + }; + return { ...hunk, confidence }; +} + 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 } { // explainOnly : ne pas appliquer de résolution, juste tracer if (options.explainOnly) { return { + hunk, lines: null, reason: `Mode explain-only : résolution non appliquée (type: ${hunk.type}, confiance: ${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 }; + // v3.9 — 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. + const generatedGate = genInfo.generated && !options.resolveGeneratedFiles; + if (generatedGate && hunk.type !== "generated_file" && !SAFE_TEXTUAL_ON_GENERATED.has(hunk.type)) { + return { + hunk, + lines: null, + reason: `Fichier auto-généré (${genInfo.label}) — ne se fusionne pas, se régénère. Résous le fichier source puis relance l'outil qui produit celui-ci (install/build). Auto-résolution disponible via resolveGeneratedFiles: true.`, + }; } - if (dispatch.status === "rejected-policy") { - return { lines: null, reason: dispatch.reason }; + + // Phase 7.3 — Dispatch format-aware. v3.9 : 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: `Fusion sémantique (${dispatch.resolverUsed}) désactivée par la politique "${fmtPolicy}" — elle combine du contenu des deux côtés.`, + }; + } + if (CONFIDENCE_ORDER[effective.confidence.label] < CONFIDENCE_ORDER[fmtMinConfidence]) { + return { + hunk: effective, + lines: null, + reason: `Confiance ${effective.confidence.label} (score: ${effective.confidence.score}) insuffisante pour appliquer la résolution format-aware (minimum requis : ${fmtMinConfidence}, politique : ${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 +206,14 @@ function resolveHunk( // Vérifier le niveau de confiance minimum if (CONFIDENCE_ORDER[hunk.confidence.label] < CONFIDENCE_ORDER[effectiveMinConfidence]) { return { + hunk, lines: null, - reason: `Confiance ${hunk.confidence.label} (score: ${hunk.confidence.score}) insuffisante (minimum requis : ${effectiveMinConfidence}, politique : ${effectivePolicy}).${dispatch.note ? ` [${dispatch.note}]` : ""}`, + reason: `Confiance ${hunk.confidence.label} (score: ${hunk.confidence.score}) insuffisante (minimum requis : ${effectiveMinConfidence}, politique : ${effectivePolicy}).${dispatchNote ? ` [${dispatchNote}]` : ""}`, }; } - return assembleResolution(hunk, options, effectivePolicy, policyCfg); + const assembled = assembleResolution(hunk, options, effectivePolicy, policyCfg); + return { hunk, ...assembled }; } /** @@ -147,9 +265,10 @@ 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 } = 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 @@ -207,6 +326,33 @@ export function resolve( ? validateMergedContent(mergedContent, filePath) : EMPTY_VALIDATION; + // v3.9 — 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: `Rétracté : le contenu fusionné viole un invariant du format. ${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 83c6f414..9c2ec0fb 100644 --- a/packages/core/src/resolver/policy.ts +++ b/packages/core/src/resolver/policy.ts @@ -33,6 +33,8 @@ export const DEFAULT_OPTIONS: Required = { policy: DEFAULT_POLICY, patternOverrides: {}, generatedFiles: [], + // v3.9 — les fichiers générés déclinent par défaut (voir GitWandOptions) + resolveGeneratedFiles: false, // 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..b88d7d82 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 { } } +// ─── v3.9 — 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. */ +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} sections « Unreleased » — un changelog n'en a qu'une.`); + } + 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 : section de version dupliquée — « ${h.slice(0, 80)} ».`); break; } + seen.add(h); + } + } + + if (/\.json$/i.test(filePath)) { + const dup = findDuplicateJsonKeys(content); + if (dup.length > 0) { + violations.push(`JSON : clé(s) dupliquée(s) dans un même objet — ${dup.slice(0, 5).map((k) => `« ${k} »`).join(", ")}. JSON.parse garderait silencieusement la dernière.`); + } + } + + 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. v3.9 — 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/stats/tiers.ts b/packages/core/src/stats/tiers.ts index f885e14c..cd6a9437 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", + // v3.9 — 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 e7f7120b..ec49d09c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -31,6 +31,7 @@ export type ConflictType = | "token_level_merge" // v2.7 — 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" // v3.9 — 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) */ @@ -442,6 +443,12 @@ export interface ValidationResult { syntaxError: string | null; /** Le contenu fusionné est-il valide ? */ isValid: boolean; + /** + * v3.9 — 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 @@ -532,6 +539,15 @@ export interface GitWandOptions { * Exemple : `["src/**\/*.generated.ts", "*.pb.go", "api/openapi-client/**"]`. */ generatedFiles?: string[]; + /** + * v3.9 — 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; /** * v2.4 — Niveau de validation post-merge. * - `"balanced"` (défaut) : marqueurs résiduels + syntaxe JSON/YAML/TOML + parse-tree tree-sitter (async) From 80ec0aeaee96359f55380dd717c1ab1cb07fd470 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Wed, 26 Aug 2026 13:23:25 +0000 Subject: [PATCH 02/37] feat: expose resolveGeneratedFiles through .gitwandrc and the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine-level default (generated files decline) needs a switch at the right scope. Whether a team regenerates or merges its lockfiles is a repository convention, not an app preference — so the opt-in lives in .gitwandrc (resolveGeneratedFiles: true, parsed strictly as a boolean), flows to the desktop through the existing rc loading in useGitWand, and gets a CLI flag (--resolve-generated) for hooks and CI. No SettingsPanel toggle on purpose. reference/config.md gains a Generated Files section: what is detected, why the default declines (with the benchmark link), which fabricate-nothing patterns still apply, and both opt-in forms. cli: 34 tests green; core tsc + desktop vue-tsc clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LQoTe6RE4JpoQknCASS3hu --- apps/desktop/src/composables/useGitWand.ts | 3 ++ packages/cli/src/cli.ts | 1 + packages/cli/src/commands/resolve.ts | 5 +++ packages/core/src/config.ts | 12 ++++++++ website/reference/config.md | 36 ++++++++++++++++++++++ 5 files changed, 57 insertions(+) diff --git a/apps/desktop/src/composables/useGitWand.ts b/apps/desktop/src/composables/useGitWand.ts index d4f96a4a..33ad9d3a 100644 --- a/apps/desktop/src/composables/useGitWand.ts +++ b/apps/desktop/src/composables/useGitWand.ts @@ -426,6 +426,9 @@ export function useGitWand() { policy: cfg.policy, patternOverrides: cfg.patterns, generatedFiles: cfg.generatedFiles, + // v3.9 — 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 diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6aa7b49c..6c165c6e 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -37,6 +37,7 @@ function printHelp(): void { 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(` --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)`); diff --git a/packages/cli/src/commands/resolve.ts b/packages/cli/src/commands/resolve.ts index 42001c85..411d7ac1 100644 --- a/packages/cli/src/commands/resolve.ts +++ b/packages/cli/src/commands/resolve.ts @@ -36,6 +36,9 @@ 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); + // v3.9 — les fichiers générés déclinent par défaut ; ce flag rétablit + // l'auto-résolution (équivalent CLI de resolveGeneratedFiles: true). + const resolveGeneratedFiles = flags["resolve-generated"] === true; const concurrency = parseConcurrency(flags.concurrency); const llmFallbackEnabled = flags["llm-fallback"] === true; @@ -118,6 +121,7 @@ export async function cmdResolve( ? await resolveAsync(content, file, { verbose: false, resolveWhitespace, + resolveGeneratedFiles, llmFallback: { ...buildResolveLlmOptions(llmCliConfig, llmFileConfig), endpoint: buildLlmEndpoint(llmCliConfig), @@ -126,6 +130,7 @@ export async function cmdResolve( : resolve(content, file, { verbose: false, resolveWhitespace, + resolveGeneratedFiles, }); // Écriture sur disque (sauf dry-run). Bloquée si des marqueurs résiduels diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index be63be23..6e427d86 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[]; + /** + * v3.9 — 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 { } } + // v3.9 — 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/website/reference/config.md b/website/reference/config.md index 098e14d5..c972d843 100644 --- a/website/reference/config.md +++ b/website/reference/config.md @@ -94,6 +94,42 @@ 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. + ## Confidence Levels The `minConfidence` setting (set implicitly by each policy) controls the minimum confidence score required for auto-resolution: From a0f11068c1186df9649c20d374879afb67887b72 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Wed, 26 Aug 2026 13:24:54 +0000 Subject: [PATCH 03/37] plan: merge context (accuracy lot C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The largest measured source of disagreement left after lot 1 is value_only_change guessing 'newer semver wins' on back-merges (laravel: ~112 wrong resolutions; the human answer is always the target branch's value). The engine can't know that without knowing what merge it is in — this plan adds an optional MergeContext to GitWandOptions, detection helpers on the callers' side (CLI/MCP read .git state, the desktop already knows its operation), the target-wins rule for version-like scalars, and demotes the context-less version guess from auto-apply to propose. Task 5 is the gate: the benchmark re-run must show agreement improving on at least two repos before the desktop wiring ships. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LQoTe6RE4JpoQknCASS3hu --- .../plans/2026-08-26-merge-context.md | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-26-merge-context.md 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..c5c12af7 --- /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 +- [ ] `types.ts`: add `MergeContext`, add `mergeContext?: MergeContext` to `GitWandOptions`; `DEFAULT_OPTIONS.mergeContext: undefined` (typed `MergeContext | undefined`; keep `Required` compiling). +- [ ] Thread `options.mergeContext` into `resolveHunk` / `assembleResolution` (already receive full options — verify, no signature change expected). +- [ ] 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` +- [ ] 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. +- [ ] Context present + version-like → resolve to `targetSide`, confidence `high`, trace step naming the operation and refs. +- [ ] 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`). +- [ ] 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). +- [ ] Unit tests with `TempRepo`: mid-merge, mid-rebase, mid-cherry-pick, clean repo → null. +- [ ] CLI `resolve` / `preview`: call it, pass it, print one line in verbose mode ("context: merging feature/x into main"). +- [ ] 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 +- [ ] `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 +- [ ] `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. +- [ ] 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. From 2392a13142bdecb02eafec7975e566a41031937d Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Wed, 26 Aug 2026 13:55:21 +0000 Subject: [PATCH 04/37] =?UTF-8?q?feat(core,cli,mcp,desktop):=20merge=20con?= =?UTF-8?q?text=20=E2=80=94=20the=20engine=20finally=20knows=20what=20merg?= =?UTF-8?q?e=20it=20is=20in=20(lot=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New optional GitWandOptions.mergeContext ({ operation, targetSide, oursRef?, theirsRef? }), plain data, detected by the callers and echoed in traces — the core stays a pure function. The rule, refined once by the benchmark itself: - An UNORDERABLE version pair ('13.x-dev' vs '12.54.1' — the file's version identity) resolves to the target side when context is present, and is proposed instead of applied when it is not. The old path fell back to prefer-theirs: a coin flip measured wrong ~3 times out of 4. - ORDERABLE semver pairs keep "newest wins" even with context. The first version of this rule sent them to the target too, and agreement regressed on prettier (45.0 → 39.0), vue and express — teams do take the newer dependency brought by the source branch. The gate in the plan (task 5) caught it before anything shipped. Measured, same pinned corpus, files byte-identical to the human merge: laravel/framework 36.6% → 81.9% (the Application.php class of failures) prettier/prettier 45.0% → 45.0% expressjs/express 59.2% → 59.2% vuejs/core 95.0% → 90.0%* * denominator artefact, not a regression: a per-file flip scan found ZERO files where the previous engine agreed and this one doesn't. Fixing the version hunk pulls previously-excluded files into the comparable set, where other hunks disagree — all 11 in one merge, dominated by a workspace:* migration done by hand during the merge. Detection: detectMergeContext() reads .git state (MERGE_HEAD, rebase-merge/ rebase-apply + head-name, CHERRY_PICK_HEAD, REVERT_HEAD), covers linked worktrees, returns null when nothing is in progress. Implemented in the CLI (tested on real temp repos: merge, rebase, cherry-pick, worktree, clean, non- repo) and duplicated in the MCP on purpose — mcp must not depend on cli, and core stays Node-free. targetSide is declared by the caller in all cases, so the engine never re-derives the rebase ours/theirs inversion; rebase-merge/ head-name is the branch being REBASED (theirs), which the desktop mapping now gets right against its own misleading field name. Wired: CLI resolve (flag-free, plus a verbose context line), the three MCP resolve/preview sites, and the desktop batch loader via the existing git_repo_state command. Tests: core 1084, cli 40, mcp 12, desktop 1058 across 4 shards — all green. benchmark/README gains the three-state impact table and the refinement story; plan tasks 1–5 checked, task 6 (corpus fixtures, golden funnel, site docs, changelog) remains. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LQoTe6RE4JpoQknCASS3hu --- apps/desktop/src/composables/useGitWand.ts | 35 +++++- benchmark/README.md | 27 +++++ .../plans/2026-08-26-merge-context.md | 26 ++--- .../__tests__/merge-context-detect.test.ts | 102 ++++++++++++++++++ packages/cli/src/commands/resolve.ts | 14 ++- packages/cli/src/git.ts | 93 ++++++++++++++++ .../core/src/__tests__/merge-context.test.ts | 101 +++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/src/patterns/utils.ts | 39 +++++++ packages/core/src/resolver/assemble.ts | 38 ++++++- packages/core/src/resolver/policy.ts | 2 + packages/core/src/types.ts | 29 +++++ packages/mcp/src/merge-context.ts | 71 ++++++++++++ packages/mcp/src/tools/index.ts | 8 +- scripts/replay-conflicts.mjs | 8 +- 15 files changed, 570 insertions(+), 24 deletions(-) create mode 100644 packages/cli/src/__tests__/merge-context-detect.test.ts create mode 100644 packages/core/src/__tests__/merge-context.test.ts create mode 100644 packages/mcp/src/merge-context.ts diff --git a/apps/desktop/src/composables/useGitWand.ts b/apps/desktop/src/composables/useGitWand.ts index 33ad9d3a..50a9dfe5 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"; @@ -483,12 +484,42 @@ export function useGitWand() { // Non-fatal : visible dans le toast d'erreur, mais on continue. error.value = msg; } + // v3.10 — 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..7b4f74a4 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -203,6 +203,33 @@ 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. +## 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 | +|---|---:|---:|---:| +| `laravel/framework` | 24.3 % | 36.6 % | **81.9 %** | +| `prettier/prettier` | 25.3 % | 45.0 % | 45.0 % | +| `vuejs/core` | 92.5 % | 95.0 % | 90.0 %* | +| `expressjs/express` | 59.6 % | 59.2 % | 59.2 % | + +\* 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. + ## Results `results/` holds one JSON file per measured GitWand version, plus the corpus pin diff --git a/docs/superpowers/plans/2026-08-26-merge-context.md b/docs/superpowers/plans/2026-08-26-merge-context.md index c5c12af7..037bacb5 100644 --- a/docs/superpowers/plans/2026-08-26-merge-context.md +++ b/docs/superpowers/plans/2026-08-26-merge-context.md @@ -50,30 +50,30 @@ Detection lives with the callers, not the core: the CLI and MCP read `.git` stat ## Tasks ### 1 — Core: the type and the plumbing -- [ ] `types.ts`: add `MergeContext`, add `mergeContext?: MergeContext` to `GitWandOptions`; `DEFAULT_OPTIONS.mergeContext: undefined` (typed `MergeContext | undefined`; keep `Required` compiling). -- [ ] Thread `options.mergeContext` into `resolveHunk` / `assembleResolution` (already receive full options — verify, no signature change expected). -- [ ] Unit: `resolve()` with and without context returns identical results on a corpus fixture that context should NOT influence. +- [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` -- [ ] 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. -- [ ] Context present + version-like → resolve to `targetSide`, confidence `high`, trace step naming the operation and refs. -- [ ] 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`). -- [ ] 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. +- [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). -- [ ] Unit tests with `TempRepo`: mid-merge, mid-rebase, mid-cherry-pick, clean repo → null. -- [ ] CLI `resolve` / `preview`: call it, pass it, print one line in verbose mode ("context: merging feature/x into main"). -- [ ] 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. +- [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 -- [ ] `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`. +- [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 -- [ ] `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. -- [ ] 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`. +- [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 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..d366eb26 --- /dev/null +++ b/packages/cli/src/__tests__/merge-context-detect.test.ts @@ -0,0 +1,102 @@ +/** + * v3.10 — 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"; + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }); +} + +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"]); +} + +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 }); }); + +describe("detectMergeContext", () => { + it("returns null on a clean repo", () => { + initRepo(repo); + commitFile(repo, "a.txt", "x\n", "init"); + expect(detectMergeContext(repo)).toBeNull(); + }); + + it("returns null outside a git repo", () => { + expect(detectMergeContext(repo)).toBeNull(); + }); + + it("detects a merge in progress, ours = the checked-out target", () => { + 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", () => { + 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", () => { + 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)", () => { + 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/commands/resolve.ts b/packages/cli/src/commands/resolve.ts index 411d7ac1..3c95cd89 100644 --- a/packages/cli/src/commands/resolve.ts +++ b/packages/cli/src/commands/resolve.ts @@ -22,7 +22,7 @@ import { resolve as resolvePath } from "node:path"; import { resolve, resolveAsync, summarizeTiers, type MergeResult, type ConflictType } 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"; @@ -39,6 +39,10 @@ export async function cmdResolve( // v3.9 — les fichiers générés déclinent par défaut ; ce flag rétablit // l'auto-résolution (équivalent CLI de resolveGeneratedFiles: true). const resolveGeneratedFiles = flags["resolve-generated"] === true; + // v3.10 — 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; @@ -70,6 +74,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 @@ -122,6 +132,7 @@ export async function cmdResolve( verbose: false, resolveWhitespace, resolveGeneratedFiles, + mergeContext, llmFallback: { ...buildResolveLlmOptions(llmCliConfig, llmFileConfig), endpoint: buildLlmEndpoint(llmCliConfig), @@ -131,6 +142,7 @@ export async function cmdResolve( verbose: false, resolveWhitespace, resolveGeneratedFiles, + mergeContext, }); // Écriture sur disque (sauf dry-run). Bloquée si des marqueurs résiduels diff --git a/packages/cli/src/git.ts b/packages/cli/src/git.ts index 0aa82b38..702bed71 100644 --- a/packages/cli/src/git.ts +++ b/packages/cli/src/git.ts @@ -31,3 +31,96 @@ export function getConflictedFiles(): string[] { return []; } } + + +// ─── v3.10 — 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/core/src/__tests__/merge-context.test.ts b/packages/core/src/__tests__/merge-context.test.ts new file mode 100644 index 00000000..d29c2c08 --- /dev/null +++ b/packages/core/src/__tests__/merge-context.test.ts @@ -0,0 +1,101 @@ +/** + * v3.10 — 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("branche cible"); + }); + + 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("décision"); + }); + + 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/index.ts b/packages/core/src/index.ts index 2eb077f0..bde5fccc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -107,6 +107,7 @@ export type { ConfidenceScore, HunkResolution, GitWandOptions, + MergeContext, // Phase 7.1 DecisionTrace, TraceStep, diff --git a/packages/core/src/patterns/utils.ts b/packages/core/src/patterns/utils.ts index 944888ed..41cb6616 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. */ +/** + * v3.10 — 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/resolver/assemble.ts b/packages/core/src/resolver/assemble.ts index bfacec36..307b4727 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: `Résolution value_only_change désactivée par la politique "${effectivePolicy}".`, }; } - // 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; + + // v3.10 — 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 modifiée des deux côtés pendant un ${ctx.operation}${refs} — la branche cible garde sa valeur. Résolution : accepter ${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: `Même structure, version(s) semver différente(s). Résolution : accepter ${semverSide} (version la plus élevée).`, }; } + // …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 modifiée des deux côtés avec des valeurs non comparables — c'est une décision de merge, pas une volatilité. La branche cible gagne quand le contexte est connu (détecté automatiquement par le CLI et le desktop) ; ici il ne l'est pas, donc GitWand propose au lieu d'appliquer.", + }; + } const preferred = policyCfg.preferOurs ? hunk.oursLines : hunk.theirsLines; const side = policyCfg.preferOurs ? "ours" : "theirs"; return { diff --git a/packages/core/src/resolver/policy.ts b/packages/core/src/resolver/policy.ts index 9c2ec0fb..cd9023c5 100644 --- a/packages/core/src/resolver/policy.ts +++ b/packages/core/src/resolver/policy.ts @@ -35,6 +35,8 @@ export const DEFAULT_OPTIONS: Required = { generatedFiles: [], // v3.9 — les fichiers générés déclinent par défaut (voir GitWandOptions) resolveGeneratedFiles: false, + // v3.10 — contexte de merge inconnu par défaut ; fourni par les appelants + mergeContext: null, // v2.2 — profils de format actifs par défaut disableFormatProfiles: false, // v2.4 — validation post-merge diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ec49d09c..14fa083a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -502,6 +502,28 @@ export interface MergeStats { } /** Options de configuration pour le moteur de résolution */ +/** + * v3.10 — 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; +} + export interface GitWandOptions { /** Résoudre les conflits whitespace-only (défaut: true) */ resolveWhitespace?: boolean; @@ -548,6 +570,13 @@ export interface GitWandOptions { * un message actionnable (« résous la source et régénère »). */ resolveGeneratedFiles?: boolean; + /** + * v3.10 — 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; /** * 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/merge-context.ts b/packages/mcp/src/merge-context.ts new file mode 100644 index 00000000..1406ccc7 --- /dev/null +++ b/packages/mcp/src/merge-context.ts @@ -0,0 +1,71 @@ +/** + * v3.10 — 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/tools/index.ts b/packages/mcp/src/tools/index.ts index 4abddab7..df4b8fe5 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -16,6 +16,7 @@ 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"; // ─── Tool definitions ────────────────────────────────────── @@ -472,7 +473,7 @@ 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) }); addByType(aggregateByType, result.stats.byType); return { path: file, @@ -534,6 +535,9 @@ async function toolResolve(cwd: string, args: Record) { const content = readFileSync(filePath, "utf-8"); const result = resolve(content, file, { ...(policy ? { policy: policy as any } : {}), + // v3.10 — 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), }); addByType(aggregateByType, result.stats.byType); @@ -600,7 +604,7 @@ 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) }); return serializeResult(file, result); } catch (err: any) { return { path: file, error: err.message }; 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; From 8f55a05878b09aa807afb2d3c2e30b7a2bad59a4 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Wed, 26 Aug 2026 13:57:27 +0000 Subject: [PATCH 05/37] docs+tests: close out merge context (lot C task 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corpus gains two context-dependent fixtures — F47, the laravel back-merge shape resolved to the target when context is present, and F48, the same conflict without context, pinned as proposed-never-applied. Golden funnel regenerated for the two new fixtures (46 → 48, autoResolved 28 → 29). reference/config.md documents mergeContext (auto-detected by all three frontends; API consumers can pass it), and the CHANGELOG's Unreleased section tells the whole accuracy arc: format_semantic, invariants, generated files declining, merge context, and the benchmark that drove — and once corrected — those rules. Core suite: 1086 green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LQoTe6RE4JpoQknCASS3hu --- CHANGELOG.md | 12 ++++++ packages/core/src/__tests__/corpus.ts | 42 +++++++++++++++++++ .../src/__tests__/golden-funnel.default.json | 10 ++--- .../__tests__/golden-funnel.refactoring.json | 10 ++--- website/reference/config.md | 23 ++++++++++ 5 files changed, 87 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19626906..359b75fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **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. +- **`resolveGeneratedFiles` option** (`.gitwandrc`, and `--resolve-generated` on the CLI). + +### Changed + +- **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. + ## [3.8.0] - 2026-08-24 ### Added diff --git a/packages/core/src/__tests__/corpus.ts b/packages/core/src/__tests__/corpus.ts index d114f624..19537784 100644 --- a/packages/core/src/__tests__/corpus.ts +++ b/packages/core/src/__tests__/corpus.ts @@ -1360,6 +1360,46 @@ const F46: CorpusFixture = { expectedResolved: false, }; +// ─── v3.10 — MergeContext (lot C) ─────────────────────────── + +const F47: CorpusFixture = { + id: "F47", + description: "v3.10 — 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: "v3.10 — 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, + // v3.10 — MergeContext + F47, F48, // v2.7 — 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 c0829417..e769c285 100644 --- a/packages/core/src/__tests__/golden-funnel.default.json +++ b/packages/core/src/__tests__/golden-funnel.default.json @@ -1,7 +1,7 @@ { - "fixtures": 46, - "totalHunks": 46, - "autoResolved": 28, + "fixtures": 48, + "totalHunks": 48, + "autoResolved": 29, "byType": { "complex": 14, "delete_no_change": 2, @@ -13,11 +13,11 @@ "reorder_only": 1, "same_change": 3, "token_level_merge": 1, - "value_only_change": 4, + "value_only_change": 6, "whitespace_only": 1 }, "tiers": { - "trivial": 24, + "trivial": 26, "advancedDeterministic": 7, "model": 0, "unresolved": 15, diff --git a/packages/core/src/__tests__/golden-funnel.refactoring.json b/packages/core/src/__tests__/golden-funnel.refactoring.json index 57e20e01..6af590db 100644 --- a/packages/core/src/__tests__/golden-funnel.refactoring.json +++ b/packages/core/src/__tests__/golden-funnel.refactoring.json @@ -1,7 +1,7 @@ { - "fixtures": 46, - "totalHunks": 46, - "autoResolved": 28, + "fixtures": 48, + "totalHunks": 48, + "autoResolved": 29, "byType": { "complex": 14, "delete_no_change": 2, @@ -14,11 +14,11 @@ "reorder_only": 1, "same_change": 3, "token_level_merge": 1, - "value_only_change": 4, + "value_only_change": 6, "whitespace_only": 1 }, "tiers": { - "trivial": 24, + "trivial": 26, "advancedDeterministic": 7, "model": 0, "unresolved": 15, diff --git a/website/reference/config.md b/website/reference/config.md index c972d843..c616ac31 100644 --- a/website/reference/config.md +++ b/website/reference/config.md @@ -130,6 +130,29 @@ The CLI equivalent is `gitwand resolve --resolve-generated`. This is a repository convention, so it lives in `.gitwandrc` rather than in the app settings. +## 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: From 45c78278dbc040224a97998ce753a9f61592e725 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Wed, 26 Aug 2026 14:06:52 +0000 Subject: [PATCH 06/37] feat(core): key-wise merge for JSON manifest fragments (lot E) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real package.json / composer.json conflicts are fragments — a few '"key": value,' lines mid-object — so the full-document JSON resolver never fired on them and the textual engine merged them line by line: exactly the wrong granularity, measured at 48-67% agreement. New resolvers/json-fragment.ts, hooked as the fallback of the dispatcher's JSON branch: parse each side as simple entries (anything else — nested multi-line values, blank lines, duplicate keys — declines rather than guesses), then three-way merge by key. One-sided changes and deletions resolve; both-sides-different gets a single bounded arbitration: two version constraints on the SAME operator ('^7.23.0' vs '^7.23.3') resolve to the newer — what teams ship, per the corpus. Operator changes and workspace:* migrations are human decisions and fall through. Output preserves each winning line's original formatting, keeps the fragment's trailing-comma convention (declines when the two sides disagree on it), and sorts keys only when both sides were already sorted — npm's own convention. First change that raises BOTH metrics at once, on all four repos: agreement files resolved end-to-end laravel/framework 81.9% → 83.3% 216 → 245 prettier/prettier 45.0% → 49.6% 100 → 117 expressjs/express 59.2% → 61.3% 49 → 62 vuejs/core 90.0% → 90.1% 220 → 222 Core suite: 1095 green (9 new tests, including the vue @babel/parser shape, the workspace:* decline, and the no-duplicate-key guarantee that lot 1's invariant would otherwise retract). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LQoTe6RE4JpoQknCASS3hu --- CHANGELOG.md | 1 + benchmark/README.md | 19 +- .../__tests__/resolvers/json-fragment.test.ts | 134 +++++++++++ packages/core/src/resolvers/dispatcher.ts | 13 ++ packages/core/src/resolvers/json-fragment.ts | 210 ++++++++++++++++++ 5 files changed, 371 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/__tests__/resolvers/json-fragment.test.ts create mode 100644 packages/core/src/resolvers/json-fragment.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 359b75fd..35d80663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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 diff --git a/benchmark/README.md b/benchmark/README.md index 7b4f74a4..763929db 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -208,12 +208,19 @@ settled, there will be a figure worth putting on a landing page. 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 | -|---|---:|---:|---:| -| `laravel/framework` | 24.3 % | 36.6 % | **81.9 %** | -| `prettier/prettier` | 25.3 % | 45.0 % | 45.0 % | -| `vuejs/core` | 92.5 % | 95.0 % | 90.0 %* | -| `expressjs/express` | 59.6 % | 59.2 % | 59.2 % | +| 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 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..26aac463 --- /dev/null +++ b/packages/core/src/__tests__/resolvers/json-fragment.test.ts @@ -0,0 +1,134 @@ +/** + * v3.11 (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/resolvers/dispatcher.ts b/packages/core/src/resolvers/dispatcher.ts index d11304b9..5ae6931f 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( }; } + // v3.11 (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..7bb67113 --- /dev/null +++ b/packages/core/src/resolvers/json-fragment.ts @@ -0,0 +1,210 @@ +/** + * GitWand — Résolveur de FRAGMENTS JSON (v3.11, 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: "Fragment JSON : lignes non reconnues comme entrées « \"clé\": valeur » simples." }; + } + // Base absente (diff2) → traitée comme vide : tout est « ajouté ». + const base = baseLines.length > 0 ? parseFragmentEntries(baseLines) : []; + if (base === null) { + return { lines: null, reason: "Fragment JSON : base non reconnue comme liste d'entrées simples." }; + } + + 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: `Fragment JSON : la clé « ${key} » est modifiée des deux côtés avec des valeurs non arbitrables — décision humaine.` }; + } + 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: "Fragment JSON : convention de virgule finale incohérente entre les deux côtés." }; + } + + 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: `Fragment JSON fusionné par clé : ${outKeys.length} entrée(s), ${merged} modification(s) unilatérale(s) prise(s)${arbitrated ? `, ${arbitrated} contrainte(s) de version arbitrée(s) vers la plus récente` : ""}.`, + }; +} From e5947b5210e4749fa800ecacd8c1085ed865acd9 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Wed, 26 Aug 2026 14:16:44 +0000 Subject: [PATCH 07/37] roadmap: make Engine Accuracy a first-class release; free the version numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The accuracy work squatted v3.9/v3.10/v3.11 in code comments while the ROADMAP reserves those for Live Repo, preview-to-apply and Stacked Branches. Comments now say 'accuracy lot 1/C/E' (27 files, mechanical), and the ROADMAP gains a 'Next release — Engine Accuracy' section: what shipped on the branch, the measured table, and the follow-ups (lot F conventions-from-history, lot G CI gate, lot D full regeneration, corpus re-pin) each flagged as wanting its own plan. Two cross-references added where the roadmap already converged on this work without knowing it: v3.10's confidence threshold (only meaningful now that format resolvers carry a real confidence) and v4.0's feedback loop (lot F is its active, measured form — they should share one store). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LQoTe6RE4JpoQknCASS3hu --- ROADMAP.md | 35 +++++++++++++++++-- apps/desktop/src/composables/useGitWand.ts | 4 +-- .../__tests__/merge-context-detect.test.ts | 2 +- packages/cli/src/commands/resolve.ts | 4 +-- packages/cli/src/git.ts | 2 +- .../core/src/__tests__/accuracy-lot1.test.ts | 2 +- packages/core/src/__tests__/corpus.ts | 14 ++++---- .../core/src/__tests__/merge-context.test.ts | 2 +- .../patterns/value-only-change.test.ts | 2 +- packages/core/src/__tests__/resolver.test.ts | 10 +++--- .../src/__tests__/resolvers/cargo.test.ts | 2 +- .../__tests__/resolvers/json-fragment.test.ts | 2 +- .../__tests__/resolvers/lockfile-npm.test.ts | 2 +- .../__tests__/resolvers/lockfile-pnpm.test.ts | 2 +- .../__tests__/resolvers/lockfile-yarn.test.ts | 2 +- .../core/src/__tests__/stats/tiers.test.ts | 2 +- packages/core/src/config.ts | 4 +-- packages/core/src/patterns/utils.ts | 2 +- packages/core/src/resolver/assemble.ts | 4 +-- packages/core/src/resolver/index.ts | 12 +++---- packages/core/src/resolver/policy.ts | 4 +-- packages/core/src/resolver/validation.ts | 4 +-- packages/core/src/resolvers/dispatcher.ts | 2 +- packages/core/src/resolvers/json-fragment.ts | 2 +- packages/core/src/stats/tiers.ts | 2 +- packages/core/src/types.ts | 10 +++--- packages/mcp/src/merge-context.ts | 2 +- packages/mcp/src/tools/index.ts | 2 +- 28 files changed, 84 insertions(+), 55 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1b497e56..08681322 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**: 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 50a9dfe5..bb4d57d0 100644 --- a/apps/desktop/src/composables/useGitWand.ts +++ b/apps/desktop/src/composables/useGitWand.ts @@ -427,7 +427,7 @@ export function useGitWand() { policy: cfg.policy, patternOverrides: cfg.patterns, generatedFiles: cfg.generatedFiles, - // v3.9 — opt-in repo-level : ré-autorise l'auto-résolution des + // 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, }; @@ -484,7 +484,7 @@ export function useGitWand() { // Non-fatal : visible dans le toast d'erreur, mais on continue. error.value = msg; } - // v3.10 — Contexte de merge : l'app sait quelle opération est en cours + // 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 diff --git a/packages/cli/src/__tests__/merge-context-detect.test.ts b/packages/cli/src/__tests__/merge-context-detect.test.ts index d366eb26..16ddc51d 100644 --- a/packages/cli/src/__tests__/merge-context-detect.test.ts +++ b/packages/cli/src/__tests__/merge-context-detect.test.ts @@ -1,5 +1,5 @@ /** - * v3.10 — detectMergeContext : détection de l'opération git en cours depuis + * 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). */ diff --git a/packages/cli/src/commands/resolve.ts b/packages/cli/src/commands/resolve.ts index 3c95cd89..dd39378f 100644 --- a/packages/cli/src/commands/resolve.ts +++ b/packages/cli/src/commands/resolve.ts @@ -36,10 +36,10 @@ 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); - // v3.9 — les fichiers générés déclinent par défaut ; ce flag rétablit + // 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). const resolveGeneratedFiles = flags["resolve-generated"] === true; - // v3.10 — contexte de merge : détecté depuis l'état .git ; null hors opération. + // 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(); diff --git a/packages/cli/src/git.ts b/packages/cli/src/git.ts index 702bed71..a5e5c259 100644 --- a/packages/cli/src/git.ts +++ b/packages/cli/src/git.ts @@ -33,7 +33,7 @@ export function getConflictedFiles(): string[] { } -// ─── v3.10 — Détection du contexte de merge ────────────────────────────────── +// ─── accuracy lot C — Détection du contexte de merge ────────────────────────────────── import { execFileSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; diff --git a/packages/core/src/__tests__/accuracy-lot1.test.ts b/packages/core/src/__tests__/accuracy-lot1.test.ts index bdf09364..349bd6b7 100644 --- a/packages/core/src/__tests__/accuracy-lot1.test.ts +++ b/packages/core/src/__tests__/accuracy-lot1.test.ts @@ -1,5 +1,5 @@ /** - * v3.9 — Lot 1 « accuracy » : tests des trois changements issus du benchmark + * 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 diff --git a/packages/core/src/__tests__/corpus.ts b/packages/core/src/__tests__/corpus.ts index 19537784..23ff7e2b 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: false, // v3.9 — fichier généré : décline par défaut (se régénère, ne se fusionne pas), + 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: false, // v3.9 — fichier généré : décline par défaut (se régénère, ne se fusionne pas), + 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: false, // v3.9 — fichier généré : décline par défaut (se régénère, ne se fusionne pas), + 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,11 +1360,11 @@ const F46: CorpusFixture = { expectedResolved: false, }; -// ─── v3.10 — MergeContext (lot C) ─────────────────────────── +// ─── accuracy lot C — MergeContext (lot C) ─────────────────────────── const F47: CorpusFixture = { id: "F47", - description: "v3.10 — value_only_change : identité de version en back-merge, la cible gagne (contexte fourni)", + 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: [ @@ -1384,7 +1384,7 @@ const F47: CorpusFixture = { const F48: CorpusFixture = { id: "F48", - description: "v3.10 — value_only_change : même identité de version SANS contexte → proposé, jamais appliqué (l'ancien fallback politique était mesuré faux ~3 fois sur 4)", + 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: [ @@ -1416,7 +1416,7 @@ 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, - // v3.10 — MergeContext + // accuracy lot C — MergeContext F47, F48, // v2.7 — token_level_merge F46, diff --git a/packages/core/src/__tests__/merge-context.test.ts b/packages/core/src/__tests__/merge-context.test.ts index d29c2c08..384d07f0 100644 --- a/packages/core/src/__tests__/merge-context.test.ts +++ b/packages/core/src/__tests__/merge-context.test.ts @@ -1,5 +1,5 @@ /** - * v3.10 — Lot C : MergeContext. + * 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 : 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 7c2b872e..3668cfdc 100644 --- a/packages/core/src/__tests__/patterns/value-only-change.test.ts +++ b/packages/core/src/__tests__/patterns/value-only-change.test.ts @@ -10,7 +10,7 @@ import { describe, it, expect } from "vitest"; import { resolve } from "../../resolver.js"; -// v3.9 — ces cas exercent le pattern value_only_change sur des chemins de lockfile ; +// 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 ─────────────── diff --git a/packages/core/src/__tests__/resolver.test.ts b/packages/core/src/__tests__/resolver.test.ts index 00ba0574..6470aa10 100644 --- a/packages/core/src/__tests__/resolver.test.ts +++ b/packages/core/src/__tests__/resolver.test.ts @@ -611,7 +611,7 @@ describe("@gitwand/core resolve", () => { "name": "Foo" } }`; - // v3.9 — build/manifest.json est un chemin généré : opt-in requis + // 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"); @@ -631,7 +631,7 @@ describe("@gitwand/core resolve", () => { "resolved": "https://registry.npmjs.org/foo/-/foo-3.3.0.tgz", "integrity": "sha512-xyz789ghi012" >>>>>>> master`; - // v3.9 — lockfile : opt-in requis pour l'auto-résolution + // 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); @@ -690,7 +690,7 @@ after`; >>>>>>> master`; const result = resolve(minJs, "public/dist/app.min.js", { minConfidence: "medium" }); expect(result.hunks[0].type).toBe("generated_file"); - // v3.9 — classification conservée, application déclinée par défaut : + // 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"); @@ -713,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(0); // v3.9 — décliné par défaut + expect(result.stats.autoResolved).toBe(0); // accuracy lot 1 — décliné par défaut }); it("reclassifies complex in build/manifest.json as generated_file", () => { @@ -731,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(0); // v3.9 — décliné par défaut + 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 c893c3b3..c37c5c7e 100644 --- a/packages/core/src/__tests__/resolvers/cargo.test.ts +++ b/packages/core/src/__tests__/resolvers/cargo.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect } from "vitest"; import { resolve } from "../../resolver.js"; -// v3.9 — les lockfiles déclinent par défaut (fichiers générés) ; ces suites +// 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. diff --git a/packages/core/src/__tests__/resolvers/json-fragment.test.ts b/packages/core/src/__tests__/resolvers/json-fragment.test.ts index 26aac463..6740798b 100644 --- a/packages/core/src/__tests__/resolvers/json-fragment.test.ts +++ b/packages/core/src/__tests__/resolvers/json-fragment.test.ts @@ -1,5 +1,5 @@ /** - * v3.11 (lot E) — Fragments JSON fusionnés par clé. + * 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 diff --git a/packages/core/src/__tests__/resolvers/lockfile-npm.test.ts b/packages/core/src/__tests__/resolvers/lockfile-npm.test.ts index 3779a2c9..dd0dfd61 100644 --- a/packages/core/src/__tests__/resolvers/lockfile-npm.test.ts +++ b/packages/core/src/__tests__/resolvers/lockfile-npm.test.ts @@ -11,7 +11,7 @@ import { describe, it, expect } from "vitest"; import { resolve } from "../../resolver.js"; -// v3.9 — les lockfiles déclinent par défaut (fichiers générés) ; ces suites +// 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. diff --git a/packages/core/src/__tests__/resolvers/lockfile-pnpm.test.ts b/packages/core/src/__tests__/resolvers/lockfile-pnpm.test.ts index fdfed327..0ee50b66 100644 --- a/packages/core/src/__tests__/resolvers/lockfile-pnpm.test.ts +++ b/packages/core/src/__tests__/resolvers/lockfile-pnpm.test.ts @@ -11,7 +11,7 @@ import { describe, it, expect } from "vitest"; import { resolve } from "../../resolver.js"; -// v3.9 — les lockfiles déclinent par défaut (fichiers générés) ; ces suites +// 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. diff --git a/packages/core/src/__tests__/resolvers/lockfile-yarn.test.ts b/packages/core/src/__tests__/resolvers/lockfile-yarn.test.ts index e7632121..5bd9686c 100644 --- a/packages/core/src/__tests__/resolvers/lockfile-yarn.test.ts +++ b/packages/core/src/__tests__/resolvers/lockfile-yarn.test.ts @@ -11,7 +11,7 @@ import { describe, it, expect } from "vitest"; import { resolve } from "../../resolver.js"; -// v3.9 — les lockfiles déclinent par défaut (fichiers générés) ; ces suites +// 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. diff --git a/packages/core/src/__tests__/stats/tiers.test.ts b/packages/core/src/__tests__/stats/tiers.test.ts index 5efd8406..4e662c8a 100644 --- a/packages/core/src/__tests__/stats/tiers.test.ts +++ b/packages/core/src/__tests__/stats/tiers.test.ts @@ -39,7 +39,7 @@ describe("summarizeTiers — mapping des tiers", () => { expect(s.byTier.trivial).toBe(11); expect(s.byTier.advancedDeterministic).toBe(0); expect(s.byTier.model).toBe(0); - // v3.9 — generated_file décline par défaut (se régénère, ne se fusionne pas) + // 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); }); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 6e427d86..c1bbcbe5 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -245,7 +245,7 @@ export interface GitWandrcConfig { */ generatedFiles?: string[]; /** - * v3.9 — Autoriser l'auto-résolution des fichiers générés (défaut: false). + * 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. @@ -420,7 +420,7 @@ export function parseGitwandrc(json: string): GitWandrcConfig | null { } } - // v3.9 — Auto-résolution des fichiers générés (opt-in booléen strict). + // 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; } diff --git a/packages/core/src/patterns/utils.ts b/packages/core/src/patterns/utils.ts index 41cb6616..10858262 100644 --- a/packages/core/src/patterns/utils.ts +++ b/packages/core/src/patterns/utils.ts @@ -418,7 +418,7 @@ function compareSemver(a: [number, number, number, boolean], b: [number, number, * pour les hashes et autres valeurs ambiguës on retombe sur la politique. */ /** - * v3.10 — Y a-t-il, parmi les paires de tokens qui diffèrent, au moins une + * 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 diff --git a/packages/core/src/resolver/assemble.ts b/packages/core/src/resolver/assemble.ts index 307b4727..ef8ce7a9 100644 --- a/packages/core/src/resolver/assemble.ts +++ b/packages/core/src/resolver/assemble.ts @@ -169,7 +169,7 @@ export function assembleResolution( const versionish = hasUnorderableVersionPair(hunk.oursLines, hunk.theirsLines); const ctx = options.mergeContext; - // v3.10 — Un scalaire de version NON ordonnable fixé différemment des + // 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 % @@ -221,7 +221,7 @@ export function assembleResolution( }; case "generated_file": { - // v3.9 — Par défaut, on DÉCLINE : la version commitée d'un fichier + // 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 diff --git a/packages/core/src/resolver/index.ts b/packages/core/src/resolver/index.ts index 79e9bee0..7b4981b8 100644 --- a/packages/core/src/resolver/index.ts +++ b/packages/core/src/resolver/index.ts @@ -59,7 +59,7 @@ import { runLlmFallbackPhase } from "./llm-pipeline.js"; * @returns Les lignes résolues + la raison, ou `null` + raison de refus */ /** - * v3.9 — Types de hunk qu'un pattern textuel peut résoudre sans risque même + * 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). */ @@ -71,7 +71,7 @@ const SAFE_TEXTUAL_ON_GENERATED: ReadonlySet = new Set([ ]); /** - * v3.9 — Contrat du classifieur : un hunk `complex` résolu par un résolveur + * 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. */ @@ -112,7 +112,7 @@ function reclassifyFormatSemantic(hunk: ConflictHunk, resolverUsed: string): Con } /** - * v3.9 — Un hunk non-complex résolu par un résolveur format-aware garde son + * 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. @@ -147,7 +147,7 @@ function resolveHunk( }; } - // v3.9 — Fichier généré : par défaut on ne fusionne pas, on régénère. + // 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. const generatedGate = genInfo.generated && !options.resolveGeneratedFiles; @@ -159,7 +159,7 @@ function resolveHunk( }; } - // Phase 7.3 — Dispatch format-aware. v3.9 : plus de bypass silencieux — + // 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 = ""; @@ -326,7 +326,7 @@ export function resolve( ? validateMergedContent(mergedContent, filePath) : EMPTY_VALIDATION; - // v3.9 — Une violation d'invariant de format (deux « Unreleased » dans un + // 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 diff --git a/packages/core/src/resolver/policy.ts b/packages/core/src/resolver/policy.ts index cd9023c5..2ab8fa1d 100644 --- a/packages/core/src/resolver/policy.ts +++ b/packages/core/src/resolver/policy.ts @@ -33,9 +33,9 @@ export const DEFAULT_OPTIONS: Required = { policy: DEFAULT_POLICY, patternOverrides: {}, generatedFiles: [], - // v3.9 — les fichiers générés déclinent par défaut (voir GitWandOptions) + // accuracy lot 1 — les fichiers générés déclinent par défaut (voir GitWandOptions) resolveGeneratedFiles: false, - // v3.10 — contexte de merge inconnu par défaut ; fourni par les appelants + // accuracy lot C — contexte de merge inconnu par défaut ; fourni par les appelants mergeContext: null, // v2.2 — profils de format actifs par défaut disableFormatProfiles: false, diff --git a/packages/core/src/resolver/validation.ts b/packages/core/src/resolver/validation.ts index b88d7d82..59980978 100644 --- a/packages/core/src/resolver/validation.ts +++ b/packages/core/src/resolver/validation.ts @@ -63,7 +63,7 @@ function tryParse(content: string, format: StructuredFormat): string | null { } } -// ─── v3.9 — Invariants de format ────────────────────────────────────────────── +// ─── 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 @@ -188,7 +188,7 @@ export function validateMergedContent(content: string, filePath: string): Valida const format = detectFormat(filePath); const syntaxError = tryParse(content, format); - // 3. v3.9 — Invariants de format (au-delà de la syntaxe) + // 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; diff --git a/packages/core/src/resolvers/dispatcher.ts b/packages/core/src/resolvers/dispatcher.ts index 5ae6931f..5bdfb037 100644 --- a/packages/core/src/resolvers/dispatcher.ts +++ b/packages/core/src/resolvers/dispatcher.ts @@ -297,7 +297,7 @@ export function tryFormatAwareResolve( }; } - // v3.11 (lot E) — le doc complet n'a pas parsé : les conflits réels de + // 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); diff --git a/packages/core/src/resolvers/json-fragment.ts b/packages/core/src/resolvers/json-fragment.ts index 7bb67113..63ac61a7 100644 --- a/packages/core/src/resolvers/json-fragment.ts +++ b/packages/core/src/resolvers/json-fragment.ts @@ -1,5 +1,5 @@ /** - * GitWand — Résolveur de FRAGMENTS JSON (v3.11, lot E) + * 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` / diff --git a/packages/core/src/stats/tiers.ts b/packages/core/src/stats/tiers.ts index cd6a9437..15b28673 100644 --- a/packages/core/src/stats/tiers.ts +++ b/packages/core/src/stats/tiers.ts @@ -48,7 +48,7 @@ const TIER_BY_TYPE: Record = { reorder_only: "trivial", insertion_at_boundary: "trivial", value_only_change: "trivial", - // v3.9 — generated_file décline par défaut (le fichier se régénère, il ne se + // 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", diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 14fa083a..af480dce 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -31,7 +31,7 @@ export type ConflictType = | "token_level_merge" // v2.7 — 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" // v3.9 — 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é + | "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) */ @@ -444,7 +444,7 @@ export interface ValidationResult { /** Le contenu fusionné est-il valide ? */ isValid: boolean; /** - * v3.9 — Violations d'invariants de format (au-delà de la syntaxe). + * 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. */ @@ -503,7 +503,7 @@ export interface MergeStats { /** Options de configuration pour le moteur de résolution */ /** - * v3.10 — Contexte du merge en cours : la donnée que le moteur n'a jamais eue. + * 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. @@ -562,7 +562,7 @@ export interface GitWandOptions { */ generatedFiles?: string[]; /** - * v3.9 — Autoriser l'auto-résolution des fichiers générés (lockfiles, + * 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 @@ -571,7 +571,7 @@ export interface GitWandOptions { */ resolveGeneratedFiles?: boolean; /** - * v3.10 — Contexte du merge en cours (opération + côté cible). `null`/absent : + * 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. diff --git a/packages/mcp/src/merge-context.ts b/packages/mcp/src/merge-context.ts index 1406ccc7..99c4ba58 100644 --- a/packages/mcp/src/merge-context.ts +++ b/packages/mcp/src/merge-context.ts @@ -1,5 +1,5 @@ /** - * v3.10 — Détection du contexte de merge pour les tools MCP. + * 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 diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index df4b8fe5..3bd0ac60 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -535,7 +535,7 @@ async function toolResolve(cwd: string, args: Record) { const content = readFileSync(filePath, "utf-8"); const result = resolve(content, file, { ...(policy ? { policy: policy as any } : {}), - // v3.10 — l'opération en cours rend déterministes les décisions qui en + // 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), }); From c25fd104a71bdc31d069417918ae92b29d5352e1 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Wed, 26 Aug 2026 14:17:29 +0000 Subject: [PATCH 08/37] fix(cli): make the merge-context detection tests hermetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase test hung to vitest's 5s timeout on a real macOS setup while passing in a clean VM — the temp repos were inheriting the host's global git config, so a global core.hooksPath (husky), a GPG key waiting for a passphrase, or a configured editor could stall 'git rebase' silently. Test git calls now run with GIT_CONFIG_GLOBAL/SYSTEM pointed at /dev/null, prompts and editors disabled, a local hooksPath override, and a hard 10s execFileSync timeout so any future hang fails loud instead of timing out quietly. Production detectMergeContext is untouched — it only runs read-only commands and should respect the user's real config. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LQoTe6RE4JpoQknCASS3hu --- .../__tests__/merge-context-detect.test.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/__tests__/merge-context-detect.test.ts b/packages/cli/src/__tests__/merge-context-detect.test.ts index 16ddc51d..ba6642a8 100644 --- a/packages/cli/src/__tests__/merge-context-detect.test.ts +++ b/packages/cli/src/__tests__/merge-context-detect.test.ts @@ -12,8 +12,31 @@ 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"] }); + 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 { @@ -21,6 +44,7 @@ function initRepo(cwd: string): void { 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 { From cc20576dd1c61b963dba2364a7f0b7c12a1e7f0f Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Wed, 26 Aug 2026 14:18:46 +0000 Subject: [PATCH 09/37] =?UTF-8?q?plan:=20repo=20conventions=20(accuracy=20?= =?UTF-8?q?lot=20F)=20=E2=80=94=20measure=20the=20team's=20policies=20from?= =?UTF-8?q?=20its=20own=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine's rules are calibrated on a public corpus, and lot C proved conventions differ per repo. This plan derives them per repository by replaying its own merge history under candidate rules and scoring against what the team actually committed — verdicts gated on sample floors, stored per-clone in .git/gitwand/, always losing to an explicit .gitwandrc, and carrying provenance into every trace they influence. Task 5 is the gate again: split-half validation on the benchmark corpus (derive on the first half of merges, measure on the second) before any desktop surface ships. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LQoTe6RE4JpoQknCASS3hu --- .../plans/2026-08-26-repo-conventions.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-26-repo-conventions.md 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..936ba979 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-repo-conventions.md @@ -0,0 +1,71 @@ +# 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 +- [ ] `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). +- [ ] `packages/core/src/conventions/derive.ts` — pure `deriveConventions(observations: ConventionObservation[]): RepoConventions`, with the sample/agreement floors and per-question scoring. No git, no fs. +- [ ] Unit tests: floors respected (4 samples → no verdict), conflicting evidence → no verdict, agreement math, engineVersion stamped. + +### 2 — Core: conventions as an input +- [ ] `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). +- [ ] Trace provenance: every influenced resolution's reason names the convention, its sample count and agreement. +- [ ] Unit tests per question + a precedence test (.gitwandrc beats conventions). + +### 3 — The derivation runner (caller side) +- [ ] `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. +- [ ] `gitwand conventions` CLI command: derive (`--max-merges`, `--json`), show current verdicts with evidence, `--clear`. Verbose prints the per-question table. +- [ ] Tests: temp repo with a fabricated history (team regenerates lockfiles in 6 merges → verdict; 4 merges → no verdict), worktree case, cap respected. + +### 4 — Desktop +- [ ] 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) +- [ ] 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. +- [ ] 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. From 2acb06e18b701ddcaf4463abc3ff42fb6c768740 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Wed, 26 Aug 2026 14:37:40 +0000 Subject: [PATCH 10/37] feat(core,cli): repo conventions measured from history (accuracy lot F, core+CLI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A convention is no longer an assumption: `gitwand conventions` replays the repo's own merge history (git merge-tree, read-only, capped at 200 merges), scores candidate rules against what the team actually committed, and emits verdicts only above evidence floors (>=5 samples, >=80% agreement). Results live in .git/gitwand/conventions.json — per clone, never committed — and an explicit .gitwandrc ALWAYS beats a derived convention. Core: conventions/{types,derive}.ts (pure — observations in, verdicts out, no git/fs/clock), GitWandOptions.conventions, and three consumptions: generatedFiles verdict "merge" re-enables auto-resolution when the caller expressed no choice; "regenerate" keeps the decline; changelog "tool-rebuilt" declines markdown unions on changelog files. Every influenced resolution carries provenance in its reason ("convention mesurée sur 16 merges, 100%"). pathPolicies are derived and REPORTED as a suggested .gitwandrc patternOverrides snippet, never silently applied (v1). CLI: conventions-runner inside commands/conventions.ts (derive / --show / --clear / --max-merges / --json), git >= 2.38 guard with a clear error (merge-tree --write-tree), hermetic-git tests on fabricated histories that skip cleanly on older git (the dev VM runs 2.34; validated for real on 2.43 in a container: regenerate/tool-rebuilt/prefer-theirs all measured at 100%). Task-5 gate verdict — recorded in benchmark/README and the plan: split-half on the corpus is FLAT everywhere (zero regressions, zero gains) because every derived verdict confirms the engine defaults... which were calibrated on this very corpus. Circularity, not absence of value: the layer pays off as provenance, and on repos that diverge from defaults (pinned by unit tests). Desktop surface deferred per the gate until the corpus re-pin includes divergent-convention repos. Tests: core 1109 (14 new), cli 40 + 4 gated (44 on modern git) — green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LQoTe6RE4JpoQknCASS3hu --- benchmark/README.md | 25 ++ .../plans/2026-08-26-repo-conventions.md | 30 ++- .../src/__tests__/conventions-derive.test.ts | 127 +++++++++ packages/cli/src/cli.ts | 4 + packages/cli/src/commands/conventions.ts | 254 ++++++++++++++++++ .../core/src/__tests__/conventions.test.ts | 158 +++++++++++ packages/core/src/conventions/derive.ts | 130 +++++++++ packages/core/src/conventions/types.ts | 83 ++++++ packages/core/src/index.ts | 10 + packages/core/src/resolver/index.ts | 42 ++- packages/core/src/resolver/policy.ts | 2 + packages/core/src/resolver/validation.ts | 2 +- packages/core/src/types.ts | 8 + 13 files changed, 860 insertions(+), 15 deletions(-) create mode 100644 packages/cli/src/__tests__/conventions-derive.test.ts create mode 100644 packages/cli/src/commands/conventions.ts create mode 100644 packages/core/src/__tests__/conventions.test.ts create mode 100644 packages/core/src/conventions/derive.ts create mode 100644 packages/core/src/conventions/types.ts diff --git a/benchmark/README.md b/benchmark/README.md index 763929db..a531d939 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -237,6 +237,31 @@ 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). + ## Results `results/` holds one JSON file per measured GitWand version, plus the corpus pin diff --git a/docs/superpowers/plans/2026-08-26-repo-conventions.md b/docs/superpowers/plans/2026-08-26-repo-conventions.md index 936ba979..05c3ee3e 100644 --- a/docs/superpowers/plans/2026-08-26-repo-conventions.md +++ b/docs/superpowers/plans/2026-08-26-repo-conventions.md @@ -41,29 +41,35 @@ Derivation is a **replay**: for each historical merge with conflicts, re-run the ## Tasks ### 1 — Core: the observation → verdict engine -- [ ] `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). -- [ ] `packages/core/src/conventions/derive.ts` — pure `deriveConventions(observations: ConventionObservation[]): RepoConventions`, with the sample/agreement floors and per-question scoring. No git, no fs. -- [ ] Unit tests: floors respected (4 samples → no verdict), conflicting evidence → no verdict, agreement math, engineVersion stamped. +- [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 -- [ ] `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). -- [ ] Trace provenance: every influenced resolution's reason names the convention, its sample count and agreement. -- [ ] Unit tests per question + a precedence test (.gitwandrc beats conventions). +- [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) -- [ ] `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. -- [ ] `gitwand conventions` CLI command: derive (`--max-merges`, `--json`), show current verdicts with evidence, `--clear`. Verbose prints the per-question table. -- [ ] Tests: temp repo with a fabricated history (team regenerates lockfiles in 6 merges → verdict; 4 merges → no verdict), worktree case, cap respected. +- [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 +### 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) -- [ ] 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. -- [ ] Record the split-half results in `benchmark/README.md`. +- [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. 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..2f5a3019 --- /dev/null +++ b/packages/cli/src/__tests__/conventions-derive.test.ts @@ -0,0 +1,127 @@ +/** + * 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 }); }); + +describe.skipIf(!MODERN_GIT)("deriveFromHistory", () => { + it("measures the simulated team's conventions from six real merges", () => { + 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)", () => { + 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", () => { + 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", () => { + 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/cli.ts b/packages/cli/src/cli.ts index 6c165c6e..d7ce32b1 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,6 +32,7 @@ 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}`); @@ -111,6 +113,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..e4630f36 --- /dev/null +++ b/packages/cli/src/commands/conventions.ts @@ -0,0 +1,254 @@ +/** + * `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"); +} + +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) { + if (!existsSync(path)) { + console.log(asJson ? "null" : `${c.dim}no derived conventions — run \`gitwand conventions\` to measure them${c.reset}`); + return; + } + const conv = JSON.parse(readFileSync(path, "utf-8")) as RepoConventions; + 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/core/src/__tests__/conventions.test.ts b/packages/core/src/__tests__/conventions.test.ts new file mode 100644 index 00000000..75790380 --- /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 mesurée sur 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 mesurée"); + }); + + 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("régénère ses fichiers générés"); + }); + + 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("outillage de release"); + // ...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/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 bde5fccc..2d15c344 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,6 +29,16 @@ 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 } from "./resolver/generated-detection.js"; +export { isChangelogFile } from "./resolver/validation.js"; export { mergeNonOverlapping, computeDiff, lcs } from "./diff.js"; // v2.1 — nouveaux backends diff exposés diff --git a/packages/core/src/resolver/index.ts b/packages/core/src/resolver/index.ts index 7b4981b8..f2b912e7 100644 --- a/packages/core/src/resolver/index.ts +++ b/packages/core/src/resolver/index.ts @@ -37,6 +37,7 @@ 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 { isChangelogFile } from "./validation.js"; import { CONFIDENCE_ORDER, DEFAULT_OPTIONS, @@ -150,6 +151,23 @@ function resolveHunk( // 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 reconstruit par l'outillage de release dans ce dépôt [convention mesurée sur ${changelogConv.samples} merges, ${Math.round(changelogConv.agreement * 100)} %] — fusion déclinée : résous la source et relance l'outil de release.`, + }; + } + const generatedGate = genInfo.generated && !options.resolveGeneratedFiles; if (generatedGate && hunk.type !== "generated_file" && !SAFE_TEXTUAL_ON_GENERATED.has(hunk.type)) { return { @@ -229,7 +247,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); @@ -276,7 +304,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 mesurée sur ${generatedConv.samples} merges, ${Math.round(generatedConv.agreement * 100)} % : ce dépôt ${generatedConv.verdict === "merge" ? "fusionne" : "régénère"} ses fichiers générés]`; + if ((generatedByConvention && autoResolved) || (generatedConv.verdict === "regenerate" && !autoResolved)) { + finalReason = `${resolutionReason} ${prov}`; + } + } + + resolutions.push({ hunk, resolvedLines, autoResolved, resolutionReason: finalReason }); if (autoResolved) { outputLines.push(...resolvedLines); diff --git a/packages/core/src/resolver/policy.ts b/packages/core/src/resolver/policy.ts index 2ab8fa1d..6d4ed922 100644 --- a/packages/core/src/resolver/policy.ts +++ b/packages/core/src/resolver/policy.ts @@ -37,6 +37,8 @@ export const DEFAULT_OPTIONS: Required = { resolveGeneratedFiles: false, // accuracy lot C — contexte de merge inconnu par défaut ; fourni par les appelants mergeContext: 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 59980978..39fa2483 100644 --- a/packages/core/src/resolver/validation.ts +++ b/packages/core/src/resolver/validation.ts @@ -121,7 +121,7 @@ export function findDuplicateJsonKeys(content: string): string[] { } /** Un fichier est « de type changelog » si son nom de base commence par changelog/history/releases et finit en .md. */ -function isChangelogFile(filePath: string): boolean { +export function isChangelogFile(filePath: string): boolean { const base = filePath.split(/[\\/]/).pop() ?? ""; return /^(changelog|history|releases|release-notes)\b.*\.(md|markdown)$/i.test(base); } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index af480dce..42ed8bba 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -577,6 +577,14 @@ export interface GitWandOptions { * cible gagne. Sans lui, ces cas sont proposés au lieu d'être appliqués. */ mergeContext?: MergeContext | 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) From 2f745a79f909cd04cedc4de0bb23ec89303a6f5a Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Wed, 26 Aug 2026 14:43:34 +0000 Subject: [PATCH 11/37] fix(cli): give the git-integration tests a real timeout budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase-detection and conventions-derivation tests spawn 30-40 git subprocesses each. On macOS every exec goes through XProtect/Gatekeeper (~100-300ms per process), so vitest's default 5s test timeout is structurally too short there — the suites run in under a second on Linux and timed out on a real Mac. The hermetic-env fix from c25fd10 was necessary (global config must never leak into temp repos) but not sufficient. Each integration test now carries an explicit 30s budget, distinct from the hard 10s per-git-call timeout that still catches genuine hangs loudly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LQoTe6RE4JpoQknCASS3hu --- .../src/__tests__/conventions-derive.test.ts | 14 ++++++++++---- .../src/__tests__/merge-context-detect.test.ts | 18 ++++++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/__tests__/conventions-derive.test.ts b/packages/cli/src/__tests__/conventions-derive.test.ts index 2f5a3019..12d6fc67 100644 --- a/packages/cli/src/__tests__/conventions-derive.test.ts +++ b/packages/cli/src/__tests__/conventions-derive.test.ts @@ -88,8 +88,14 @@ 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("measures the simulated team's conventions from six real merges", IT_TIMEOUT, () => { buildHistory(repo, 6); const { conventions } = deriveFromHistory(repo, 200); @@ -102,7 +108,7 @@ describe.skipIf(!MODERN_GIT)("deriveFromHistory", () => { ]); }); - it("emits no verdict below the evidence floor (4 merges)", () => { + it("emits no verdict below the evidence floor (4 merges)", IT_TIMEOUT, () => { buildHistory(repo, 4); const { conventions } = deriveFromHistory(repo, 200); expect(conventions.generatedFiles).toBeUndefined(); @@ -110,14 +116,14 @@ describe.skipIf(!MODERN_GIT)("deriveFromHistory", () => { expect(conventions.pathPolicies).toBeUndefined(); }); - it("respects the merge cap", () => { + 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("conventionsPath resolves inside .git, worktree-safe", IT_TIMEOUT, () => { buildHistory(repo, 1); const p = conventionsPath(repo); expect(p).toContain(".git"); diff --git a/packages/cli/src/__tests__/merge-context-detect.test.ts b/packages/cli/src/__tests__/merge-context-detect.test.ts index ba6642a8..846c4527 100644 --- a/packages/cli/src/__tests__/merge-context-detect.test.ts +++ b/packages/cli/src/__tests__/merge-context-detect.test.ts @@ -67,18 +67,24 @@ 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("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("returns null outside a git repo", IT_TIMEOUT, () => { expect(detectMergeContext(repo)).toBeNull(); }); - it("detects a merge in progress, ours = the checked-out target", () => { + 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); @@ -88,7 +94,7 @@ describe("detectMergeContext", () => { expect(ctx?.theirsRef).toContain("feature"); }); - it("detects a rebase in progress, ours = the branch rebased onto", () => { + 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 */ } @@ -100,7 +106,7 @@ describe("detectMergeContext", () => { expect(ctx?.theirsRef).toContain("feature"); }); - it("detects a cherry-pick in progress", () => { + 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 */ } @@ -110,7 +116,7 @@ describe("detectMergeContext", () => { expect(ctx?.oursRef).toBe("main"); }); - it("works from a linked worktree (.git is a file)", () => { + 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"]); From 5652582e06b7d9214700447223471bc59805f5eb Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Wed, 26 Aug 2026 15:03:05 +0000 Subject: [PATCH 12/37] benchmark: corpus v2 (measured selection) + CI agreement gate (lot G) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corpus v2 — selection is now itself measured. Candidates were probed with rev-list --merges plus a merge-tree conflict-rate sample over their 60 most recent merges: kubernetes, rails and godot rejected at 0/60 (merge queues); symfony (back-merge culture, composer.json in half its conflicted merges), git/git (integration branches, maintainer-resolved conflicts — the best human ground truth there is) and bootstrap (adversarial _variables.scss family) come in. cargo/django dropped for having nothing to replay, and vue dropped DESPITE being the 92-95% showcase — keeping it would have been flattering rather than informative. Current-engine baseline on v2, committed as the CI reference (results/v3.8.0-corpus2-baseline.json): 1927 merges replayed, 634 conflicted, 5675 hunks — 59.2% of end-to-end-resolved files byte-identical to the human merge (391/660), per-repo spread 17.5-65.4%. Lot G — the gate itself: - compare.mjs: fails when corpus agreement drops >1.5 pts, any repo drops >5 pts, or end-to-end coverage collapses >25% (a deliberate decline policy must update the baseline in the same PR, reasoning in the commit message). Agreement is the protected metric; coverage may fall on purpose — that asymmetry is the lesson of lots 1/C/E encoded as thresholds. - .github/workflows/benchmark-gate.yml: runs on PRs touching the engine, clones cached keyed on the corpus hash (only the first run after a re-pin pays), explicit git>=2.38 check so an old git fails loudly instead of measuring zero conflicts silently. Lot F gate re-run on the v2 additions: still flat (halves too thin to clear the evidence floors) — the desktop deferral stands, recorded in the README. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LQoTe6RE4JpoQknCASS3hu --- .github/workflows/benchmark-gate.yml | 80 ++++++++ benchmark/README.md | 44 +++-- benchmark/compare.mjs | 78 ++++++++ benchmark/corpus.json | 54 +++--- .../results/v3.8.0-corpus2-baseline.json | 180 ++++++++++++++++++ 5 files changed, 398 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/benchmark-gate.yml create mode 100644 benchmark/compare.mjs create mode 100644 benchmark/results/v3.8.0-corpus2-baseline.json 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/benchmark/README.md b/benchmark/README.md index a531d939..6ec2d0d5 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 @@ -262,6 +278,12 @@ histories; demonstrating them on real public repos needs corpus candidates 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). + ## Results `results/` holds one JSON file per measured GitWand version, plus the corpus pin 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..ac03713e --- /dev/null +++ b/benchmark/results/v3.8.0-corpus2-baseline.json @@ -0,0 +1,180 @@ +{ + "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 + }, + "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 + }, + "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 + }, + "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 + }, + "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 + }, + "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 + }, + "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 + }, + "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 + }, + "autoResolvedShare": 40.31 + } + ] +} From a457ac9ddf854157023a0c56a02311ac4dad94c6 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Wed, 26 Aug 2026 15:10:11 +0000 Subject: [PATCH 13/37] benchmark: regenerate the v2 baseline with per-repo agreement detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first committed baseline was produced by a stale run.mjs in the measuring container that predated the per-repo agreement block, so compare.mjs could only gate on the aggregate — per-repo regressions passed silently. Regenerated with the current runner (same corpus, same totals: 59.24%, 391/660); the gate now verifiably reports all eight repos and fails on a simulated -6pt per-repo drop. Per-repo v2 baseline: laravel 83.3, symfony 73.7, express 61.7, git/git 61.1, prettier 49.6, tauri 37.9, hugo 30.2, bootstrap 25.7. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LQoTe6RE4JpoQknCASS3hu --- .../results/v3.8.0-corpus2-baseline.json | 859 ++++++++++++++++++ 1 file changed, 859 insertions(+) diff --git a/benchmark/results/v3.8.0-corpus2-baseline.json b/benchmark/results/v3.8.0-corpus2-baseline.json index ac03713e..1f91a824 100644 --- a/benchmark/results/v3.8.0-corpus2-baseline.json +++ b/benchmark/results/v3.8.0-corpus2-baseline.json @@ -62,6 +62,139 @@ "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 }, { @@ -78,6 +211,139 @@ "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 }, { @@ -94,6 +360,139 @@ "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 }, { @@ -110,6 +509,139 @@ "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 }, { @@ -126,6 +658,124 @@ "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 }, { @@ -142,6 +792,39 @@ "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 }, { @@ -158,6 +841,49 @@ "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 }, { @@ -174,6 +900,139 @@ "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 } ] From ffd11f3446e616276ab882912203c749f51cdf07 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Wed, 26 Aug 2026 15:32:51 +0000 Subject: [PATCH 14/37] docs: implementation plan for the regenerate tier (accuracy lot D, full) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan only — registry design (5 script-suppressed ecosystems), plan-in-core / execution-in-callers split, consent + sandbox + offline model, dedicated measurement harness (merge-tree replay cannot score this lot), desktop surface gated on measured results. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LQoTe6RE4JpoQknCASS3hu --- ROADMAP.md | 2 +- .../plans/2026-08-26-regenerate-tier.md | 83 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-08-26-regenerate-tier.md diff --git a/ROADMAP.md b/ROADMAP.md index 08681322..dea73f76 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -40,7 +40,7 @@ _The engine's claims are now measured instead of asserted, and three measured fa - **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**: 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. +- **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. 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. From 996760369cfa4be14fe37dd7b4bcba4c8b607568 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Thu, 27 Aug 2026 14:55:13 +0200 Subject: [PATCH 15/37] =?UTF-8?q?feat(core):=20regenerate=20tier=20plumbin?= =?UTF-8?q?g=20=E2=80=94=20plan=20emission=20for=20generated=20files=20(ac?= =?UTF-8?q?curacy=20lot=20D,=20task=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core stays Node-free: the generatedGate decline now attaches a machine-usable RegenerationPlan (npm/pnpm/yarn-berry/composer/cargo) whenever the declined lockfile matches the v1 registry, sourced from a caller-supplied regenerationContext. Core never spawns anything; it only emits the plan and appends a --regenerate hint to the existing French decline reason. --- .../__tests__/regenerate-integration.test.ts | 125 ++++++++++++++++++ .../src/__tests__/regenerate/plan.test.ts | 106 +++++++++++++++ .../src/__tests__/regenerate/registry.test.ts | 53 ++++++++ packages/core/src/index.ts | 6 + packages/core/src/regenerate/plan.ts | 49 +++++++ packages/core/src/regenerate/registry.ts | 96 ++++++++++++++ packages/core/src/resolver/index.ts | 25 +++- packages/core/src/resolver/policy.ts | 2 + packages/core/src/types.ts | 28 ++++ 9 files changed, 486 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/__tests__/regenerate-integration.test.ts create mode 100644 packages/core/src/__tests__/regenerate/plan.test.ts create mode 100644 packages/core/src/__tests__/regenerate/registry.test.ts create mode 100644 packages/core/src/regenerate/plan.ts create mode 100644 packages/core/src/regenerate/registry.ts 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..166aba3c --- /dev/null +++ b/packages/core/src/__tests__/regenerate-integration.test.ts @@ -0,0 +1,125 @@ +/** + * accuracy lot D — Intégration : `resolve()` attache un `RegenerationPlan` + * quand un fichier généré est décliné (generatedGate) ET que son chemin + * matche un écosystème du registre `regenerate/registry.ts`. + * + * Règles testées : + * - package-lock.json décliné + 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). + */ + +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" → declined by the generatedGate. +const minJsDiff = `<<<<<<< HEAD +!function(){var a=1;console.log(a);doStuff()}(); +======= +!function(){var b=2;alert(b);doOther();cleanup()}(); +>>>>>>> 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", + }); + }); + }); +}); 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..7aa4cb0d --- /dev/null +++ b/packages/core/src/__tests__/regenerate/plan.test.ts @@ -0,0 +1,106 @@ +/** + * 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" }); + }); + }); +}); 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..eb4ad372 --- /dev/null +++ b/packages/core/src/__tests__/regenerate/registry.test.ts @@ -0,0 +1,53 @@ +/** + * 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(); + }); + + it("every v1 registry entry bakes in script-suppression or is inherently script-free", () => { + // Global constraint: no registry entry may omit script suppression — either + // an explicit flag (--ignore-scripts / --no-scripts) or a command that never + // executes lifecycle scripts by construction (documented per entry below). + for (const eco of REGEN_ECOSYSTEMS) { + const args = eco.command.args.join(" "); + const scriptSuppressed = + args.includes("--ignore-scripts") || + args.includes("--no-scripts") || + eco.id === "yarn-berry" || // update-lockfile mode never runs installs/lifecycle scripts + eco.id === "cargo"; // generate-lockfile only resolves, never builds/runs build.rs + expect(scriptSuppressed).toBe(true); + } + }); + + 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/index.ts b/packages/core/src/index.ts index 2d15c344..008d0232 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -39,6 +39,10 @@ export { } from "./conventions/types.js"; export { isGeneratedFile } 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 @@ -118,6 +122,8 @@ export type { HunkResolution, GitWandOptions, MergeContext, + // accuracy lot D — Regenerate tier + RegenerationContext, // Phase 7.1 DecisionTrace, TraceStep, diff --git a/packages/core/src/regenerate/plan.ts b/packages/core/src/regenerate/plan.ts new file mode 100644 index 00000000..11ee60a9 --- /dev/null +++ b/packages/core/src/regenerate/plan.ts @@ -0,0 +1,49 @@ +/** + * 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; +} + +/** + * 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. + */ +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 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/index.ts b/packages/core/src/resolver/index.ts index f2b912e7..7ca1a066 100644 --- a/packages/core/src/resolver/index.ts +++ b/packages/core/src/resolver/index.ts @@ -37,6 +37,8 @@ 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, @@ -138,7 +140,7 @@ function resolveHunk( filePath: string, options: Required, genInfo: { generated: boolean; label: string }, -): { hunk: ConflictHunk; lines: string[] | null; reason: string } { +): { hunk: ConflictHunk; lines: string[] | null; reason: string; regenerationPlan?: RegenerationPlan } { // explainOnly : ne pas appliquer de résolution, juste tracer if (options.explainOnly) { return { @@ -170,10 +172,20 @@ function resolveHunk( 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 ecosystem = findEcosystem(filePath); + const regenerationPlan = ecosystem + ? buildRegenerationPlan(filePath, ecosystem, options.regenerationContext) + : undefined; + const regenerateHint = ecosystem ? " Ou relance avec --regenerate." : ""; return { hunk, lines: null, - reason: `Fichier auto-généré (${genInfo.label}) — ne se fusionne pas, se régénère. Résous le fichier source puis relance l'outil qui produit celui-ci (install/build). Auto-résolution disponible via resolveGeneratedFiles: true.`, + reason: `Fichier auto-généré (${genInfo.label}) — ne se fusionne pas, se régénère. Résous le fichier source puis relance l'outil qui produit celui-ci (install/build). Auto-résolution disponible via resolveGeneratedFiles: true.${regenerateHint}`, + regenerationPlan, }; } @@ -293,7 +305,12 @@ 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 } = resolveHunk(hunk, filePath, options, genInfo); + const { + hunk: effectiveHunk, + lines: resolvedLines, + reason: resolutionReason, + regenerationPlan, + } = resolveHunk(hunk, filePath, options, genInfo); hunk = effectiveHunk; hunks.push(hunk); @@ -314,7 +331,7 @@ export function resolve( } } - resolutions.push({ hunk, resolvedLines, autoResolved, resolutionReason: finalReason }); + resolutions.push({ hunk, resolvedLines, autoResolved, resolutionReason: finalReason, regenerationPlan }); if (autoResolved) { outputLines.push(...resolvedLines); diff --git a/packages/core/src/resolver/policy.ts b/packages/core/src/resolver/policy.ts index 6d4ed922..85b43d62 100644 --- a/packages/core/src/resolver/policy.ts +++ b/packages/core/src/resolver/policy.ts @@ -37,6 +37,8 @@ export const DEFAULT_OPTIONS: Required = { 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 diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 42ed8bba..b73959c0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -414,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 ─────────────────── @@ -524,6 +531,20 @@ export interface MergeContext { 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; @@ -577,6 +598,13 @@ export interface GitWandOptions { * 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 From a33aafcff85ba7652c22b791fa7363e2aa8452f7 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Thu, 27 Aug 2026 15:00:01 +0200 Subject: [PATCH 16/37] fix(core): attach regeneration plan at all three generated-file decline sites (lot D, fix round 1) reclassifyIfGenerated turns a genuinely-complex hunk on a generated path into generated_file BEFORE resolveHunk runs, so the generatedGate branch (which explicitly skips hunk.type === "generated_file") never sees the majority case: a real lockfile conflict. That decline happened in assembleResolution's generated_file case with no plan attached (spec finding #1's 0%-agreement case). Factored plan-attachment into attachRegenerationPlan() and call it at generatedGate, the minConfidence decline, and right after assembleResolution returns lines: null for a generated_file hunk. assemble.ts stays untouched. --- .../__tests__/regenerate-integration.test.ts | 109 +++++++++++++++++- packages/core/src/resolver/index.ts | 66 ++++++++--- 2 files changed, 153 insertions(+), 22 deletions(-) diff --git a/packages/core/src/__tests__/regenerate-integration.test.ts b/packages/core/src/__tests__/regenerate-integration.test.ts index 166aba3c..d53fd6e7 100644 --- a/packages/core/src/__tests__/regenerate-integration.test.ts +++ b/packages/core/src/__tests__/regenerate-integration.test.ts @@ -1,15 +1,31 @@ /** * accuracy lot D — Intégration : `resolve()` attache un `RegenerationPlan` - * quand un fichier généré est décliné (generatedGate) ET que son chemin - * matche un écosystème du registre `regenerate/registry.ts`. + * 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é + package.json clean dans regenerationContext - * → plan runnable, reason contient l'indice --regenerate ; + * - 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). + * - 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"; @@ -27,13 +43,32 @@ const lockEntryDiff = `<<<<<<< HEAD >>>>>>> master`; // Structurally different (not value_only) so the hunk stays "complex" → -// reclassified "generated_file" → declined by the generatedGate. +// 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" } } }; @@ -122,4 +157,66 @@ describe("regenerate tier — resolver integration", () => { }); }); }); + + // 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/resolver/index.ts b/packages/core/src/resolver/index.ts index 7ca1a066..4cef8296 100644 --- a/packages/core/src/resolver/index.ts +++ b/packages/core/src/resolver/index.ts @@ -135,6 +135,27 @@ function boostFormatValidated(hunk: ConflictHunk, resolverUsed: string): Conflic 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 + * trois sites de déclin d'un fichier généré (generatedGate, seuil de + * confiance, `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. + */ +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} Ou relance avec --regenerate.`, regenerationPlan }; +} + function resolveHunk( hunk: ConflictHunk, filePath: string, @@ -176,17 +197,12 @@ function resolveHunk( // 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 ecosystem = findEcosystem(filePath); - const regenerationPlan = ecosystem - ? buildRegenerationPlan(filePath, ecosystem, options.regenerationContext) - : undefined; - const regenerateHint = ecosystem ? " Ou relance avec --regenerate." : ""; - return { - hunk, - lines: null, - reason: `Fichier auto-généré (${genInfo.label}) — ne se fusionne pas, se régénère. Résous le fichier source puis relance l'outil qui produit celui-ci (install/build). Auto-résolution disponible via resolveGeneratedFiles: true.${regenerateHint}`, - regenerationPlan, - }; + const { reason, regenerationPlan } = attachRegenerationPlan( + filePath, + options, + `Fichier auto-généré (${genInfo.label}) — ne se fusionne pas, se régénère. Résous le fichier source puis relance l'outil qui produit celui-ci (install/build). Auto-résolution disponible via resolveGeneratedFiles: true.`, + ); + return { hunk, lines: null, reason, regenerationPlan }; } // Phase 7.3 — Dispatch format-aware. accuracy lot 1 : plus de bypass silencieux — @@ -235,14 +251,32 @@ function resolveHunk( // Vérifier le niveau de confiance minimum if (CONFIDENCE_ORDER[hunk.confidence.label] < CONFIDENCE_ORDER[effectiveMinConfidence]) { - return { - hunk, - lines: null, - reason: `Confiance ${hunk.confidence.label} (score: ${hunk.confidence.score}) insuffisante (minimum requis : ${effectiveMinConfidence}, politique : ${effectivePolicy}).${dispatchNote ? ` [${dispatchNote}]` : ""}`, - }; + const baseReason = `Confiance ${hunk.confidence.label} (score: ${hunk.confidence.score}) insuffisante (minimum requis : ${effectiveMinConfidence}, politique : ${effectivePolicy}).${dispatchNote ? ` [${dispatchNote}]` : ""}`; + // accuracy lot D — cas rare : un appelant a poussé minConfidence au-dessus + // de "high" (le score fixe de generated_file, voir reclassifyIfGenerated), + // ce qui fait échouer ce hunk générateur au seuil au lieu du generatedGate. + if (genInfo.generated && hunk.type === "generated_file") { + const { reason, regenerationPlan } = attachRegenerationPlan(filePath, options, baseReason); + return { hunk, lines: null, reason, regenerationPlan }; + } + return { hunk, lines: null, reason: baseReason }; } 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 }; } From 54d3ddda4f52eb171d032f1872c2e97180831ca5 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Thu, 27 Aug 2026 15:09:42 +0200 Subject: [PATCH 17/37] fix(core): remove dead minConfidence site, harden script-suppression test (lot D, fix round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Site 2 (regeneration-plan attachment on the minConfidence decline for generated_file hunks) was unreachable: computeEffectiveMinConfidence always returns the more permissive of policy/option, no MergePolicy preset exceeds "high", and reclassifyIfGenerated fixes generated_file confidence at exactly "high" — so the confidence-threshold branch can never reject that hunk type through any public API. Removed the branch and its comment; two real sites remain (generatedGate, post-assembleResolution). Also replaced the registry test's per-ecosystem script-suppression check, which short-circuited to true for yarn-berry/cargo (never actually checking their args), with a real assertion per ecosystem: explicit flag for npm/pnpm/composer, exact --mode=update-lockfile for yarn-berry, and an exact ["generate-lockfile"] args array for cargo — so a future edit that dropped the safe command choice would fail the test. --- .../src/__tests__/regenerate/registry.test.ts | 44 +++++++++++++------ packages/core/src/resolver/index.ts | 30 +++++++------ 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/packages/core/src/__tests__/regenerate/registry.test.ts b/packages/core/src/__tests__/regenerate/registry.test.ts index eb4ad372..b7596bf1 100644 --- a/packages/core/src/__tests__/regenerate/registry.test.ts +++ b/packages/core/src/__tests__/regenerate/registry.test.ts @@ -30,19 +30,37 @@ describe("findEcosystem", () => { expect(findEcosystem("src/index.ts")).toBeUndefined(); }); - it("every v1 registry entry bakes in script-suppression or is inherently script-free", () => { - // Global constraint: no registry entry may omit script suppression — either - // an explicit flag (--ignore-scripts / --no-scripts) or a command that never - // executes lifecycle scripts by construction (documented per entry below). - for (const eco of REGEN_ECOSYSTEMS) { - const args = eco.command.args.join(" "); - const scriptSuppressed = - args.includes("--ignore-scripts") || - args.includes("--no-scripts") || - eco.id === "yarn-berry" || // update-lockfile mode never runs installs/lifecycle scripts - eco.id === "cargo"; // generate-lockfile only resolves, never builds/runs build.rs - expect(scriptSuppressed).toBe(true); - } + 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", () => { diff --git a/packages/core/src/resolver/index.ts b/packages/core/src/resolver/index.ts index 4cef8296..99aee00b 100644 --- a/packages/core/src/resolver/index.ts +++ b/packages/core/src/resolver/index.ts @@ -140,10 +140,18 @@ function boostFormatValidated(hunk: ConflictHunk, resolverUsed: string): Conflic * (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 - * trois sites de déclin d'un fichier généré (generatedGate, seuil de - * confiance, `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. + * 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, @@ -251,15 +259,11 @@ function resolveHunk( // Vérifier le niveau de confiance minimum if (CONFIDENCE_ORDER[hunk.confidence.label] < CONFIDENCE_ORDER[effectiveMinConfidence]) { - const baseReason = `Confiance ${hunk.confidence.label} (score: ${hunk.confidence.score}) insuffisante (minimum requis : ${effectiveMinConfidence}, politique : ${effectivePolicy}).${dispatchNote ? ` [${dispatchNote}]` : ""}`; - // accuracy lot D — cas rare : un appelant a poussé minConfidence au-dessus - // de "high" (le score fixe de generated_file, voir reclassifyIfGenerated), - // ce qui fait échouer ce hunk générateur au seuil au lieu du generatedGate. - if (genInfo.generated && hunk.type === "generated_file") { - const { reason, regenerationPlan } = attachRegenerationPlan(filePath, options, baseReason); - return { hunk, lines: null, reason, regenerationPlan }; - } - return { hunk, lines: null, reason: baseReason }; + return { + hunk, + lines: null, + reason: `Confiance ${hunk.confidence.label} (score: ${hunk.confidence.score}) insuffisante (minimum requis : ${effectiveMinConfidence}, politique : ${effectivePolicy}).${dispatchNote ? ` [${dispatchNote}]` : ""}`, + }; } const assembled = assembleResolution(hunk, options, effectivePolicy, policyCfg); From e59c9265faad70d0c63e7599ae6bf278705fda1c Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Thu, 27 Aug 2026 15:34:48 +0200 Subject: [PATCH 18/37] feat(cli): regenerate-tier executor (accuracy lot D, task 2) Core (lot D task 1) only emits a RegenerationPlan for declined generated files; this adds the CLI-side executor. `gitwand resolve --regenerate` (or `.gitwandrc` `regenerate: true`) runs a pass 2 after the normal resolution pool: it re-derives each plan with the real sibling-file state (pass 1's plans are always non-runnable placeholders) and, when runnable, regenerates the lockfile in a disposable `git worktree` seeded with the resolved sources of truth - never the user's real working tree. - regenerate-runner.ts: toolchain probe, offline probe (DNS, no new dep), worktree sandboxing, timeout + full trace, output validation (JSON for npm/composer, YAML for pnpm/yarn-berry, TOML for cargo), guaranteed worktree cleanup. - resolve.ts: pass 2 wiring, per-file verbose regenerate trace line, failure leaves the file exactly as pass 1 left it plus the reason. - Fixed a real env-stripping bug found via testing: the secret-scrubbing regex was deleting GIT_CONFIG_KEY_N (legitimate git plumbing) while keeping GIT_CONFIG_COUNT, breaking `git worktree add` in environments that inject config via env vars. --- packages/cli/package.json | 4 +- .../src/__tests__/regenerate-runner.test.ts | 403 +++++++++++++++++ packages/cli/src/cli.ts | 1 + packages/cli/src/commands/resolve.ts | 215 +++++++-- packages/cli/src/regenerate-runner.ts | 407 ++++++++++++++++++ pnpm-lock.yaml | 10 +- 6 files changed, 1010 insertions(+), 30 deletions(-) create mode 100644 packages/cli/src/__tests__/regenerate-runner.test.ts create mode 100644 packages/cli/src/regenerate-runner.ts 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__/regenerate-runner.test.ts b/packages/cli/src/__tests__/regenerate-runner.test.ts new file mode 100644 index 00000000..326644e0 --- /dev/null +++ b/packages/cli/src/__tests__/regenerate-runner.test.ts @@ -0,0 +1,403 @@ +/** + * 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("régénéré 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("introuvable dans le 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("contenu invalide"); + 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("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/cli.ts b/packages/cli/src/cli.ts index d7ce32b1..e5b38cee 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -40,6 +40,7 @@ function printHelp(): void { 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)`); diff --git a/packages/cli/src/commands/resolve.ts b/packages/cli/src/commands/resolve.ts index dd39378f..5052d174 100644 --- a/packages/cli/src/commands/resolve.ts +++ b/packages/cli/src/commands/resolve.ts @@ -19,7 +19,16 @@ import { readFile, writeFile } from "node:fs/promises"; 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, detectMergeContext } from "../git.js"; @@ -27,7 +36,17 @@ 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 } from "../llm-config.js"; +import { + runRegeneration, + loadGitwandrcRegenerateFlag, + type ResolvedSource, +} from "../regenerate-runner.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[], @@ -111,6 +130,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; @@ -171,39 +237,134 @@ 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 }; + }); + + // ─── 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) { + 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", + }; + } - if (validationWarning) { - const warnColor = skipWrite ? c.red : c.yellow; - printLines.push(`${warnColor} ⚠ validation: ${validationWarning}${c.reset}`); + 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; + + const resolvedSources: ResolvedSource[] = []; + let sourcesReady = true; + for (const source of plan.sources) { + const siblingOutcome = outcomes.find((o) => o.file === source.path); + if (!siblingOutcome?.result || siblingOutcome.result.mergedContent === null) { + sourcesReady = false; + break; + } + resolvedSources.push({ path: source.path, content: siblingOutcome.result.mergedContent }); } + if (!sourcesReady) continue; // défensif : `plan.runnable` aurait dû le garantir + + 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 (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 (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) { diff --git a/packages/cli/src/regenerate-runner.ts b/packages/cli/src/regenerate-runner.ts new file mode 100644 index 00000000..5baf4885 --- /dev/null +++ b/packages/cli/src/regenerate-runner.ts @@ -0,0 +1,407 @@ +/** + * 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. é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). + * 3. lancer la commande du registre (flags de suppression de scripts déjà + * bakés dans `ecosystem.command.args` — jamais surchargeables ici). + * 4. sur succès : relire + valider le lockfile régénéré depuis le + * filesystem du worktree. + * 5. `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; + +/** + * Motifs de noms de variables d'environnement considérées comme sensibles. + * Volontairement délimité par des frontières `_`/début/fin de nom — une + * regex `key`/`auth` nue matche aussi des variables de PLOMBERIE git tout à + * fait légitimes (`GIT_CONFIG_KEY_0`, `GIT_CONFIG_COUNT`…, injectées par + * certains environnements CI/sandbox pour porter `safe.directory`) et les + * retirer casse `git worktree add` (`GIT_CONFIG_COUNT` sans son `KEY_N` + * correspondant → "fatal: unable to parse command-line config"). On ne + * strippe donc que les segments de nom qui ressemblent vraiment à un secret. + */ +const SECRET_ENV_PATTERN = + /(?:^|_)(?:API_?KEYS?|ACCESS_KEYS?|SECRET_KEYS?|SECRETS?|TOKENS?|PASSWORD|PASSWD|CREDENTIALS?)(?:_|$)/i; + +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; +} + +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) }; + } +} + +/** + * Clone `process.env` en retirant toute variable dont le NOM ressemble à un + * secret (token/clé/mot de passe/identifiant…) — l'outillage régénéré + * (npm/pnpm/yarn/composer/cargo) n'a besoin de rien de tel pour un + * `install --lockfile-only` script-suppressed ; ne jamais transmettre plus + * que le strict nécessaire à un process spawné (règle AGENTS.md). + */ +function buildSpawnEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value === undefined) continue; + if (SECRET_ENV_PATTERN.test(key)) continue; + env[key] = value; + } + return env; +} + +async function addWorktree(repoRoot: string, worktreeDir: string): Promise { + await execFileAsync("git", ["worktree", "add", "--detach", worktreeDir, "HEAD"], { + cwd: repoRoot, + env: buildSpawnEnv(), + }); +} + +async function removeWorktree(repoRoot: string, worktreeDir: string): Promise { + try { + await execFileAsync("git", ["worktree", "remove", "--force", worktreeDir], { + cwd: repoRoot, + env: buildSpawnEnv(), + }); + } 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: buildSpawnEnv() }).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: `outil « ${bin} » introuvable dans le PATH — régénération de "${file}" (${ecosystem.id}) impossible.`, + 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: `pas de connexion réseau détectée — régénération de "${file}" (${ecosystem.id}) nécessite un accès réseau, déclinée.`, + trace: buildTrace(ecosystem.id, bin, args, 0, null), + }; + } + + const worktreeDir = join(tmpdir(), `gitwand-regen-${randomUUID()}`); + let worktreeCreated = false; + + try { + await addWorktree(repoRoot, worktreeDir); + 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: buildSpawnEnv(), + 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: `régénération de "${file}" via "${trace.command}" interrompue après ${formatDuration(durationMs)} (timeout ${formatDuration(timeoutMs)}) — conflit non résolu.`, + trace, + }; + } + const detail = stderr.trim().split("\n").slice(0, 3).join(" | "); + return { + kind: "spawn-failed", + content: null, + reason: `régénération de "${file}" via "${trace.command}" a échoué (code ${exitCode ?? "?"}) — conflit non résolu.${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: `régénération de "${file}" via "${trace.command}" (${formatDuration(durationMs)}) n'a produit aucun fichier lisible — conflit non résolu. ${msg}`, + trace, + }; + } + + const validation = validateRegeneratedContent(ecosystem.id, regenerated); + if (!validation.valid) { + return { + kind: "validation-failed", + content: null, + reason: `régénération de "${file}" via "${trace.command}" (${formatDuration(durationMs)}) a produit un contenu invalide — conflit non résolu. ${validation.error}`, + trace, + }; + } + + return { + kind: "success", + content: regenerated, + reason: `régénéré 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/pnpm-lock.yaml b/pnpm-lock.yaml index 796f7039..6a1073c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,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 @@ -6849,8 +6855,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinyrainbow@3.1.0: {} From 30a8cd6453b1dfe674f59c00400eba86678fb94e Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Thu, 27 Aug 2026 15:50:44 +0200 Subject: [PATCH 19/37] fix(cli): sibling-map clean-file gap + env allowlist (regenerate tier, fix round 1) Review Important #1: pass 2's siblingFiles map only covered files git flagged as conflicted, so a source of truth that merged cleanly (the common case: package.json untouched, package-lock.json diverges) never got a "clean" entry and buildRegenerationPlan treated it as conflicted, making runnable permanently unreachable for that shape (and yarn-berry entirely, since .yarnrc.yml is essentially never itself conflicted). Pre-seed every candidate ecosystem's sourcesOfTruth not in the conflicted set as clean, and read its on-disk content directly when pass 1 never touched it. Review Important #2: replaced the env denylist (regex on "sensitive" substrings) with an explicit allowlist, matching AGENTS.md's actual wording ("pass only the specific env vars the child process needs"). Review Important #3: added an end-to-end regression test that drives cmdResolve() itself (not just regenerate-runner.ts) against a repo shaped exactly like Important #1 - verified it fails without the resolve.ts fix and passes with it restored. --- .../src/__tests__/resolve-regenerate.test.ts | 136 ++++++++++++++++++ packages/cli/src/commands/resolve.ts | 50 ++++++- packages/cli/src/regenerate-runner.ts | 83 ++++++++--- 3 files changed, 249 insertions(+), 20 deletions(-) create mode 100644 packages/cli/src/__tests__/resolve-regenerate.test.ts 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/commands/resolve.ts b/packages/cli/src/commands/resolve.ts index 5052d174..7225844c 100644 --- a/packages/cli/src/commands/resolve.ts +++ b/packages/cli/src/commands/resolve.ts @@ -254,6 +254,19 @@ export async function cmdResolve( 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; @@ -267,6 +280,21 @@ export async function cmdResolve( : "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. + 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)) { + siblingFiles[sourcePath] = { state: "clean" }; + } + } + } for (const outcome of outcomes) { if (outcome.result === null) continue; @@ -286,11 +314,25 @@ export async function cmdResolve( let sourcesReady = true; for (const source of plan.sources) { const siblingOutcome = outcomes.find((o) => o.file === source.path); - if (!siblingOutcome?.result || siblingOutcome.result.mergedContent === null) { - sourcesReady = false; - break; + 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". + } } - resolvedSources.push({ path: source.path, content: siblingOutcome.result.mergedContent }); + sourcesReady = false; + break; } if (!sourcesReady) continue; // défensif : `plan.runnable` aurait dû le garantir diff --git a/packages/cli/src/regenerate-runner.ts b/packages/cli/src/regenerate-runner.ts index 5baf4885..42007752 100644 --- a/packages/cli/src/regenerate-runner.ts +++ b/packages/cli/src/regenerate-runner.ts @@ -52,17 +52,65 @@ const NETWORK_PROBE_HOSTS: Partial> = { const OFFLINE_PROBE_TIMEOUT_MS = 2_000; /** - * Motifs de noms de variables d'environnement considérées comme sensibles. - * Volontairement délimité par des frontières `_`/début/fin de nom — une - * regex `key`/`auth` nue matche aussi des variables de PLOMBERIE git tout à - * fait légitimes (`GIT_CONFIG_KEY_0`, `GIT_CONFIG_COUNT`…, injectées par - * certains environnements CI/sandbox pour porter `safe.directory`) et les - * retirer casse `git worktree add` (`GIT_CONFIG_COUNT` sans son `KEY_N` - * correspondant → "fatal: unable to parse command-line config"). On ne - * strippe donc que les segments de nom qui ressemblent vraiment à un secret. + * 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 SECRET_ENV_PATTERN = - /(?:^|_)(?:API_?KEYS?|ACCESS_KEYS?|SECRET_KEYS?|SECRETS?|TOKENS?|PASSWORD|PASSWD|CREDENTIALS?)(?:_|$)/i; +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_"]; export type RegenerationOutcomeKind = | "success" @@ -188,17 +236,20 @@ export function validateRegeneratedContent( } /** - * Clone `process.env` en retirant toute variable dont le NOM ressemble à un - * secret (token/clé/mot de passe/identifiant…) — l'outillage régénéré - * (npm/pnpm/yarn/composer/cargo) n'a besoin de rien de tel pour un - * `install --lockfile-only` script-suppressed ; ne jamais transmettre plus - * que le strict nécessaire à un process spawné (règle AGENTS.md). + * Construit l'environnement des process spawnés (git worktree + installeur) + * à partir d'une ALLOWLIST explicite (`ENV_ALLOWLIST_EXACT`/`_PREFIXES`), 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 : + * aucun token/clé/identifiant ne peut fuiter par un nom de variable qu'une + * denylist aurait simplement oublié de couvrir. */ function buildSpawnEnv(): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(process.env)) { if (value === undefined) continue; - if (SECRET_ENV_PATTERN.test(key)) continue; + const allowed = + ENV_ALLOWLIST_EXACT.has(key) || ENV_ALLOWLIST_PREFIXES.some((prefix) => key.startsWith(prefix)); + if (!allowed) continue; env[key] = value; } return env; From a5dc39208a8687400ff2750524227ea71beab1b5 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Thu, 27 Aug 2026 16:11:22 +0200 Subject: [PATCH 20/37] feat(cli,mcp): conventions/.gitwandrc wiring + MCP regenerate reporting (accuracy lot D, task 3) Fixes two upstream bugs that silently blocked core's lot F convention precedence from the CLI: resolveGeneratedFiles always reached resolve() as a concrete false instead of undefined, and .git/gitwand/conventions.json was never loaded into options.conventions at all. Adds .gitwandrc resolveGeneratedFiles precedence over measured conventions, a default (non-verbose) --regenerate offer in the resolve summary, and reporting-only regenerate:true support on the 3 MCP resolve() tools (never executes, mirrors the CLI's precedence via a duplicated MCP-local helper). Extends website/reference/config.md's Generated Files section with the regenerate tier's consent model, sandbox, and convention interplay. --- .../src/__tests__/resolve-conventions.test.ts | 262 ++++++++++++++++++ packages/cli/src/commands/conventions.ts | 31 ++- packages/cli/src/commands/resolve.ts | 49 +++- packages/cli/src/llm-config.ts | 31 +++ .../src/__tests__/regenerate-report.test.ts | 244 ++++++++++++++++ packages/mcp/src/regenerate-report.ts | 149 ++++++++++ packages/mcp/src/tools/index.ts | 88 +++++- website/reference/config.md | 68 +++++ 8 files changed, 913 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/__tests__/resolve-conventions.test.ts create mode 100644 packages/mcp/src/__tests__/regenerate-report.test.ts create mode 100644 packages/mcp/src/regenerate-report.ts 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..c7d21905 --- /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 mesurée"); + }, + ); + + 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/commands/conventions.ts b/packages/cli/src/commands/conventions.ts index e4630f36..d1716045 100644 --- a/packages/cli/src/commands/conventions.ts +++ b/packages/cli/src/commands/conventions.ts @@ -179,6 +179,33 @@ export function conventionsPath(cwd: string): string { 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`; @@ -223,11 +250,11 @@ export async function cmdConventions(flags: Record): P } if (flags.show === true) { - if (!existsSync(path)) { + 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; } - const conv = JSON.parse(readFileSync(path, "utf-8")) as RepoConventions; if (asJson) console.log(JSON.stringify(conv, null, 2)); else { printBanner(); printVerdicts(conv); } return; diff --git a/packages/cli/src/commands/resolve.ts b/packages/cli/src/commands/resolve.ts index 7225844c..0f0b8844 100644 --- a/packages/cli/src/commands/resolve.ts +++ b/packages/cli/src/commands/resolve.ts @@ -36,12 +36,18 @@ 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, findGitRoot } 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 @@ -57,7 +63,22 @@ export async function cmdResolve( 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). - const resolveGeneratedFiles = flags["resolve-generated"] === 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). @@ -199,6 +220,7 @@ export async function cmdResolve( resolveWhitespace, resolveGeneratedFiles, mergeContext, + conventions, llmFallback: { ...buildResolveLlmOptions(llmCliConfig, llmFileConfig), endpoint: buildLlmEndpoint(llmCliConfig), @@ -209,6 +231,7 @@ export async function cmdResolve( resolveWhitespace, resolveGeneratedFiles, mergeContext, + conventions, }); // Écriture sur disque (sauf dry-run). Bloquée si des marqueurs résiduels @@ -461,6 +484,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}`, + ); + } + // v2.7 — "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/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/mcp/src/__tests__/regenerate-report.test.ts b/packages/mcp/src/__tests__/regenerate-report.test.ts new file mode 100644 index 00000000..0197083d --- /dev/null +++ b/packages/mcp/src/__tests__/regenerate-report.test.ts @@ -0,0 +1,244 @@ +/** + * 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 } 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 +} + +/** 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() + } + }) + + 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() + } + }) + + 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() + } + }) + + 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() + } + }) + + 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() + } + }) +}) diff --git a/packages/mcp/src/regenerate-report.ts b/packages/mcp/src/regenerate-report.ts new file mode 100644 index 00000000..7bdf4ca4 --- /dev/null +++ b/packages/mcp/src/regenerate-report.ts @@ -0,0 +1,149 @@ +/** + * 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 } 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` already carries, spawns nothing. + * + * A source-of-truth path never seen in conflict by THIS call is treated as + * "clean" — the exact same convention the CLI's pass 2 uses for the common + * case of a lockfile conflicting alone while its source merged cleanly (see + * `resolve.ts`'s siblingFiles pre-seed comment). 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. + */ +export function buildRegenerationReport( + results: Array<{ file: string; result: MergeResult }>, +): RegenerationReportEntry[] { + 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)) 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 3bd0ac60..105a5dc9 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -17,6 +17,16 @@ 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 ────────────────────────────────────── @@ -33,6 +43,10 @@ export function registerTools() { type: "string", description: "Working directory (repo root). Defaults to server cwd.", }, + regenerate: { + type: "boolean", + description: REGENERATE_PARAM_DESCRIPTION, + }, }, }, }, @@ -61,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, + }, }, }, }, @@ -88,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}`, + }, }, }, }, @@ -437,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": @@ -455,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 { @@ -464,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 { @@ -473,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, { mergeContext: detectMergeContext(cwd) }); + 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, @@ -497,6 +534,8 @@ async function toolStatus(cwd: string) { // v2.7 — "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. + const regenerationPlans = wantsRegenerationReport ? buildRegenerationReport(resultsForReport) : undefined; return { content: [{ @@ -508,6 +547,7 @@ async function toolStatus(cwd: string) { remaining: totalConflicts - totalResolvable, tierSummary, conflicts, + ...(regenerationPlans !== undefined ? { regenerationPlans } : {}), }, null, 2), }], }; @@ -517,6 +557,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); @@ -528,7 +569,15 @@ 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 { @@ -538,8 +587,11 @@ async function toolResolve(cwd: string, args: Record) { // 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) { @@ -557,6 +609,9 @@ async function toolResolve(cwd: string, args: Record) { const totalResolved = results.reduce((s: number, r: Record) => s + ((r.autoResolved as number) ?? 0), 0); // v2.7 — "recoverable-before-model" tier summary, see summarizeTiers() in @gitwand/core. const tierSummary = summarizeTiers(aggregateByType as Record); + // accuracy lot D (task 3, § 4) — reporting-only; never executes anything, + // regardless of `dryRun`. + const regenerationPlans = wantsRegenerationReport ? buildRegenerationReport(resultsForReport) : undefined; return { content: [{ @@ -572,6 +627,7 @@ async function toolResolve(cwd: string, args: Record) { tierSummary, }, files: results, + ...(regenerationPlans !== undefined ? { regenerationPlans } : {}), }, null, 2), }], }; @@ -589,6 +645,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 { @@ -596,6 +653,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 { @@ -604,14 +666,25 @@ 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, { mergeContext: detectMergeContext(cwd) }); + 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. + const regenerationPlans = wantsRegenerationReport ? buildRegenerationReport(resultsForReport) : undefined; + + return previewResponse("merge", files.length, previews, 0, regenerationPlans); } /** @@ -634,6 +707,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); @@ -676,6 +753,7 @@ function previewResponse( : 100, }, files: previews, + ...(regenerationPlans !== undefined ? { regenerationPlans } : {}), }, null, 2), }], }; diff --git a/website/reference/config.md b/website/reference/config.md index c616ac31..d5269aa6 100644 --- a/website/reference/config.md +++ b/website/reference/config.md @@ -130,6 +130,74 @@ 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 From e15fdd962ffa873a938bc89838e79532da7053f1 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Thu, 27 Aug 2026 16:19:47 +0200 Subject: [PATCH 21/37] fix(mcp): guard regenerate-report sibling-map against narrowed files: param (task 3, fix round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildRegenerationReport() defaulted any sourceOfTruth path absent from the current call's own results to "clean", conflating "not seen by this call" with "not actually conflicted" — the same bug Task 2's fix round closed for the CLI's resolve.ts. Exploitable via gitwand_resolve_conflicts' files: param: a narrowed list excluding an actually-conflicted package.json could report a plan as runnable:true. Now threads the repo's full conflicted-file set through as a second parameter and only defaults to "clean" when a path is absent from both the call's own results AND that full set, mirroring resolve.ts's guard exactly. --- .../src/__tests__/regenerate-report.test.ts | 75 +++++++++++++++++++ packages/mcp/src/regenerate-report.ts | 37 ++++++--- packages/mcp/src/tools/index.ts | 27 +++++-- 3 files changed, 123 insertions(+), 16 deletions(-) diff --git a/packages/mcp/src/__tests__/regenerate-report.test.ts b/packages/mcp/src/__tests__/regenerate-report.test.ts index 0197083d..b07ca94e 100644 --- a/packages/mcp/src/__tests__/regenerate-report.test.ts +++ b/packages/mcp/src/__tests__/regenerate-report.test.ts @@ -110,6 +110,39 @@ function buildConflictedLockRepo(): Repo { 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'] @@ -227,6 +260,48 @@ describe('MCP regenerate:true — reporting only, never executes (task 3)', () = } }) + 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() + } + }, + ) + it('gitwand_preview_merge: rebase/cherry-pick operations never populate regenerationPlans (out of this task\'s scope)', async () => { const { cwd, cleanup } = buildConflictedLockRepo() try { diff --git a/packages/mcp/src/regenerate-report.ts b/packages/mcp/src/regenerate-report.ts index 7bdf4ca4..992ce0c4 100644 --- a/packages/mcp/src/regenerate-report.ts +++ b/packages/mcp/src/regenerate-report.ts @@ -101,20 +101,31 @@ export interface RegenerationReportEntry { * 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` already carries, spawns nothing. + * `results`/`conflictedFiles` already carry, spawns nothing. * - * A source-of-truth path never seen in conflict by THIS call is treated as - * "clean" — the exact same convention the CLI's pass 2 uses for the common - * case of a lockfile conflicting alone while its source merged cleanly (see - * `resolve.ts`'s siblingFiles pre-seed comment). 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. + * `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. */ export function buildRegenerationReport( results: Array<{ file: string; result: MergeResult }>, + conflictedFiles: string[], ): RegenerationReportEntry[] { + const conflictedFileSet = new Set(conflictedFiles); const siblingFiles: RegenerationContext["siblingFiles"] = {}; for (const { file, result } of results) { siblingFiles[file] = { @@ -139,7 +150,13 @@ export function buildRegenerationReport( if (!ecosystem) continue; // should not happen — pass 1 already implied a match for (const source of ecosystem.sourcesOfTruth) { - if (!(source in siblingFiles)) siblingFiles[source] = { state: "clean" }; + if (source in siblingFiles) continue; + // Never conflicted anywhere in the repo ⇒ 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). + if (!conflictedFileSet.has(source)) siblingFiles[source] = { state: "clean" }; } const plan = buildRegenerationPlan(file, ecosystem, { siblingFiles }); diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index 105a5dc9..59793a71 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -535,7 +535,10 @@ async function toolStatus(cwd: string, args: Record = {}) { // 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. - const regenerationPlans = wantsRegenerationReport ? buildRegenerationReport(resultsForReport) : undefined; + // `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) : undefined; return { content: [{ @@ -609,9 +612,17 @@ async function toolResolve(cwd: string, args: Record) { const totalResolved = results.reduce((s: number, r: Record) => s + ((r.autoResolved as number) ?? 0), 0); // v2.7 — "recoverable-before-model" tier summary, see summarizeTiers() in @gitwand/core. const tierSummary = summarizeTiers(aggregateByType as Record); - // accuracy lot D (task 3, § 4) — reporting-only; never executes anything, - // regardless of `dryRun`. - const regenerationPlans = wantsRegenerationReport ? buildRegenerationReport(resultsForReport) : undefined; + // 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)) + : undefined; return { content: [{ @@ -681,8 +692,12 @@ async function toolPreview(cwd: string, args: Record) { // 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. - const regenerationPlans = wantsRegenerationReport ? buildRegenerationReport(resultsForReport) : undefined; + // 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) + : undefined; return previewResponse("merge", files.length, previews, 0, regenerationPlans); } From 6addbdc93f1dca50ba582ca62809aa4f13bfb938 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Thu, 27 Aug 2026 16:49:52 +0200 Subject: [PATCH 22/37] feat(scripts): regenerate-tier measurement harness (accuracy lot D, task 4) scripts/replay-regenerate.mjs replays historical merges from a real, already-cloned corpus repo: cheap merge-tree candidate discovery, then real checkout + real npm/pnpm/yarn-berry/composer/cargo regeneration via the CLI's own runRegeneration() executor, structurally compared (scripts/lib/ regenerate-compare.mjs) against the lockfile the team actually committed. Fixture tests (node --test scripts/lib/regenerate-compare.test.mjs, also `pnpm run test:regenerate-compare`) cover the comparison/scoring logic for all five registry formats with no network. stripVolatileValues is now exported from @gitwand/core for the fallback path. Pilot run (bounded, ~5 real attempts/ecosystem, per Ruling P-9): laravel/ framework's composer leg is infeasible (it never commits composer.lock, confirmed via full history; symfony/symfony has the same gap, so corpus v2 has no measurable composer repo). prettier/prettier turned out to be yarn-berry, not npm as assumed; piloted that instead: 66.7% structural agreement (n=3), below the 80% target. benchmark/README.md documents the method, the real numbers, and the resulting gate verdict: keep CLI opt-in only, do not build the desktop surface (task 5) on this evidence. --- benchmark/README.md | 143 ++++++++++ package.json | 5 +- packages/core/src/index.ts | 2 +- pnpm-lock.yaml | 106 +++++-- scripts/lib/regenerate-compare.mjs | 171 ++++++++++++ scripts/lib/regenerate-compare.test.mjs | 292 ++++++++++++++++++++ scripts/replay-regenerate.mjs | 352 ++++++++++++++++++++++++ 7 files changed, 1050 insertions(+), 21 deletions(-) create mode 100644 scripts/lib/regenerate-compare.mjs create mode 100644 scripts/lib/regenerate-compare.test.mjs create mode 100644 scripts/replay-regenerate.mjs diff --git a/benchmark/README.md b/benchmark/README.md index 6ec2d0d5..30cd4fca 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -284,6 +284,149 @@ 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:regenerate-compare` from the repo root) — 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) — SMALL SAMPLE, read the caveat before the numbers + +Per the task-4 plan, a full ≤ 20-merges-per-ecosystem sweep was explicitly +**not** run — this is a bounded pilot (≤ 5 real attempts per ecosystem) meant +to decide whether a full run and the desktop surface (task 5) are worth +building at all. Both named corpus v2 repos (`benchmark/corpus.json`, cloned +bare+blobless, pinned to their corpus SHA, same recipe as `run.mjs`'s +`prepare()`) were used, with one correction and one hard blocker discovered +along the way: + +- **`laravel/framework` (composer) — INFEASIBLE, not just slow.** `git log + --all -- composer.lock` returns **zero commits, ever**, in the entire + history. `laravel/framework` is a Composer *library* package, and library + packages deliberately do not commit a lockfile (only applications do) — this + is architectural, not an environment or toolchain problem. The same check + against `symfony/symfony` (the corpus's other PHP repo) confirms it has no + `composer.lock` either. **Corpus v2 currently has no repository that can + measure the composer leg of this gate at all** — a future re-pin needs an + application-shaped PHP repo (the way `prettier/prettier`/`vuejs/core` are + application-shaped for npm-family ecosystems). +- **`prettier/prettier` — the brief's "npm ecosystem" label was wrong.** + `git ls-tree` shows no `package-lock.json` anywhere in the repo, ever; the + repo has a root `.yarnrc.yml` with `yarnPath: .yarn/releases/yarn-4.18.0.cjs` + and a root `yarn.lock` — it is a **yarn-berry** repo. The pilot used the + correctly-identified ecosystem for the same named repo rather than + fabricating an npm measurement that has no basis in this repo's history. + (Confirmed the delegation works in this environment: only yarn classic + 1.22.x was installed via `npm install -g yarn`, and running `yarn + --version` inside a checkout of the repo correctly reports `4.18.0` — + yarn's `yarnPath` respawn works even from a classic binary.) + +Result, `prettier/prettier`, yarn-berry, 237 merges scanned, `--max-real 5`: + +| Metric | Value | +|---|---:| +| Candidate merges found (conflicting `yarn.lock`) | 85 | +| Attempted (the pilot's own cap) | 5 | +| Runnable plans (source resolvable) | 3 | +| Ran successfully (real `yarn install --mode=update-lockfile`, no toolchain/timeout/spawn failure) | 3 | +| Comparable (regenerated + actual committed content both available) | 3 | +| Structurally matched | 2 | +| **Agreement rate** | **66.7 % (2/3)** | + +The two non-runnable candidates declined because `@gitwand/core`'s `resolve()` +could not fully settle `package.json` on its own (genuine overlapping edits, +correctly not auto-resolved) — exactly the behaviour the real CLI would show +for those same two merges. + +### The gate verdict + +**n = 3.** That is not a corpus, it is barely a sample, and it is the honest +result of following Ruling P-9's bound (≤ 5 real attempts per ecosystem) against +a repo where two of five candidates were correctly declined before reaching +comparison. The measured rate, 66.7 %, is **below the ≥ 80 % target**, and one +of the two named corpus repos (`laravel/framework`) could not be measured on +the composer leg **at all** — not "below target", but no data. + +Per the plan's own instruction for this outcome: **keep CLI opt-in only** +(already true — `--regenerate`/`.gitwandrc` `regenerate: true` already gate +every regeneration behind explicit consent, since tasks 1–3), **document +findings, stop here.** The desktop surface (task 5) and any default-on +regeneration behaviour are **not** justified by this evidence. This is a +pilot-scale, single-ecosystem, n = 3 result — it does not prove regeneration is +unreliable at 66.7 % either; it proves the question isn't answered yet. Before +revisiting: (a) re-pin the corpus with at least one application-shaped PHP repo +so the composer leg is measurable, (b) run the plan's full ≤ 20-merges-per-ecosystem +sweep across npm, pnpm, yarn-berry, composer and cargo, and (c) characterise +the one observed mismatch (which package(s) diverged, and why) rather than +treating a single data point as noise. + ## Results `results/` holds one JSON file per measured GitWand version, plus the corpus pin diff --git a/package.json b/package.json index b94c615f..16f61890 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,13 @@ "scripts": { "build": "pnpm -r run build", "test": "pnpm -r run test", + "test:regenerate-compare": "node --test scripts/lib/regenerate-compare.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/core/src/index.ts b/packages/core/src/index.ts index 008d0232..adcc11c6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -37,7 +37,7 @@ export { type ConventionObservation, type RepoConventions, } from "./conventions/types.js"; -export { isGeneratedFile } from "./resolver/generated-detection.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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a1073c1..7cd9f02d 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) @@ -202,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: @@ -233,7 +239,7 @@ 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) vue: specifier: ^3.5.0 version: 3.5.32(typescript@5.9.3) @@ -3166,6 +3172,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'} @@ -3645,6 +3655,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'} @@ -4967,15 +4982,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': @@ -4987,13 +5002,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: @@ -5003,6 +5018,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 @@ -6739,6 +6762,8 @@ snapshots: smol-toml@1.6.1: {} + smol-toml@1.8.0: {} + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} @@ -6976,7 +7001,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) @@ -6987,7 +7012,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: @@ -7002,7 +7027,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 @@ -7012,7 +7050,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)) @@ -7021,7 +7059,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 @@ -7050,10 +7088,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 @@ -7070,7 +7108,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 @@ -7106,6 +7144,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): @@ -7187,6 +7253,8 @@ snapshots: yaml@2.8.3: {} + yaml@2.9.0: {} + yargs-parser@21.1.1: {} yargs@17.7.2: 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..f3ff28a4 --- /dev/null +++ b/scripts/lib/regenerate-compare.test.mjs @@ -0,0 +1,292 @@ +/** + * 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:regenerate-compare" script). + */ + +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/replay-regenerate.mjs b/scripts/replay-regenerate.mjs new file mode 100644 index 00000000..3c0c8353 --- /dev/null +++ b/scripts/replay-regenerate.mjs @@ -0,0 +1,352 @@ +#!/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. `repoRoot` + * can be bare or non-bare — `git worktree add` and `update-ref` both work + * against a bare repository. + * + * 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 { + 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"; + +// ─── 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, + }); +} + +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. */ +function mergeTree(p1, p2) { + try { + git(["-c", "merge.conflictstyle=diff3", "merge-tree", "--write-tree", "--name-only", p1, p2], { + 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 [head] = err.stdout.split("\n\n"); + const lines = head.split("\n").filter(Boolean); + return { treeOid: lines[0], files: lines.slice(1) }; + } + 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, + ecosystem, + }); + } +} + +// ─── stage 2: expensive real regeneration, bounded per ecosystem ─────────── + +const originalHead = git(["rev-parse", "HEAD"]).trim(); + +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]]); + + const regenOutcome = await runRegeneration({ + repoRoot: repo, + file: candidate.lockfilePath, + ecosystem: candidate.ecosystem, + resolvedSources, + timeoutMs: TIMEOUT_MS_OVERRIDE, + }); + + 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}` : ""}`); + } + } + } +} From 8c62a7c4949e5d4cf52c07f69052d48b62ee2839 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Thu, 27 Aug 2026 17:20:17 +0200 Subject: [PATCH 23/37] fix: final-review fix wave for the regenerate tier (accuracy lot D) Closes the 5 Important findings from the whole-branch final review: 1. Nested (non-root) lockfiles could report a false "regenerated" success (silent take-ours wearing regeneration's provenance) because the CLI's worktree runner writes/reads sources of truth at the worktree root, not the lockfile's own directory. Fixed once in core's buildRegenerationPlan (blocks any non-root file, inherited by CLI, MCP reporting, and the measurement harness alike) rather than duplicated in three callers. 2. "Not conflicted" was conflated with "clean" in both the CLI's and MCP's sibling-map seeding, letting yarn-classic repos (no .yarnrc.yml) report runnable:true for yarn-berry, contradicting the registry's own documented berry-marker guard. Both now require the file to actually exist on disk. 3. Documented (not re-architected, per the review's own scope note) that regenerate-runner.ts seeds its worktree from HEAD (ours-only) rather than the in-progress merge index, and named it as hypothesis (d) for the 66.7%/n=3 pilot result in benchmark/README.md. 4. Split the env allowlist: the GIT_* prefix (needed for git worktree plumbing) no longer reaches the spawned ecosystem installer, closing a path for CI-injected credentials (GIT_CONFIG_*/GIT_ASKPASS/ GIT_SSH_COMMAND) to leak into npm/pnpm/yarn/composer/cargo's environment. 5. scripts/replay-regenerate.mjs now refuses to run against a non-bare repo (git update-ref HEAD follows the symref) and restores the operator's original HEAD from a SIGINT/SIGTERM handler, not just the happy path. Also includes the review's opportunistic ask: a one-line notice when a runnable plan's source turns out to be unreadable (previously silent). disabled --- benchmark/README.md | 14 +- .../regenerate-runner-env-split.test.ts | 146 +++++++++++++++++ .../resolve-regenerate-nested.test.ts | 149 ++++++++++++++++++ .../resolve-regenerate-yarn-classic.test.ts | 118 ++++++++++++++ packages/cli/src/commands/resolve.ts | 35 +++- packages/cli/src/regenerate-runner.ts | 79 ++++++++-- .../src/__tests__/regenerate/plan.test.ts | 36 +++++ packages/core/src/regenerate/plan.ts | 36 +++++ .../src/__tests__/regenerate-report.test.ts | 63 +++++++- packages/mcp/src/regenerate-report.ts | 30 +++- packages/mcp/src/tools/index.ts | 8 +- scripts/replay-regenerate.mjs | 73 ++++++++- 12 files changed, 756 insertions(+), 31 deletions(-) create mode 100644 packages/cli/src/__tests__/regenerate-runner-env-split.test.ts create mode 100644 packages/cli/src/__tests__/resolve-regenerate-nested.test.ts create mode 100644 packages/cli/src/__tests__/resolve-regenerate-yarn-classic.test.ts diff --git a/benchmark/README.md b/benchmark/README.md index 30cd4fca..41a32385 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -423,9 +423,19 @@ pilot-scale, single-ecosystem, n = 3 result — it does not prove regeneration i unreliable at 66.7 % either; it proves the question isn't answered yet. Before revisiting: (a) re-pin the corpus with at least one application-shaped PHP repo so the composer leg is measurable, (b) run the plan's full ≤ 20-merges-per-ecosystem -sweep across npm, pnpm, yarn-berry, composer and cargo, and (c) characterise +sweep across npm, pnpm, yarn-berry, composer and cargo, (c) characterise the one observed mismatch (which package(s) diverged, and why) rather than -treating a single data point as noise. +treating a single data point as noise, and (d) rule out a documented, known +limitation before blaming the registry commands themselves: `regenerate-runner.ts` +seeds its disposable worktree from `HEAD` (ours-only), not the in-progress merge +index that the plan's own architecture text describes. Files that exist only on +`theirs`' side are invisible to the installer, and seeding from `ours`' lockfile +biases the regeneration toward an incremental update rather than a fresh +resolution — a plausible contributor to the 66.7 % this pilot measured. The full +fix (seed from the merge's stage-2/3 state instead) needs its own real +measurement to confirm it actually moves the number before it's worth building — +see `regenerate-runner.ts`'s module header for the detailed writeup of this +limitation. ## Results 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__/resolve-regenerate-nested.test.ts b/packages/cli/src/__tests__/resolve-regenerate-nested.test.ts new file mode 100644 index 00000000..703ee4b4 --- /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("régénéré 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..7167813b --- /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("régénéré via"); + + const lockContent = readFileSync(join(repo, YARN_LOCK), "utf-8"); + expect(lockContent).toContain("<<<<<<<"); + }, + ); +}); diff --git a/packages/cli/src/commands/resolve.ts b/packages/cli/src/commands/resolve.ts index 0f0b8844..119931dc 100644 --- a/packages/cli/src/commands/resolve.ts +++ b/packages/cli/src/commands/resolve.ts @@ -18,6 +18,7 @@ */ import { readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; import { resolve as resolvePath } from "node:path"; import { resolve, @@ -306,6 +307,18 @@ export async function cmdResolve( // 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); @@ -313,7 +326,11 @@ export async function cmdResolve( const ecosystem = findEcosystem(outcome.file); if (!ecosystem) continue; for (const sourcePath of ecosystem.sourcesOfTruth) { - if (!conflictedFileSet.has(sourcePath) && !(sourcePath in siblingFiles)) { + if ( + !conflictedFileSet.has(sourcePath) && + !(sourcePath in siblingFiles) && + existsSync(resolvePath(sourcePath)) + ) { siblingFiles[sourcePath] = { state: "clean" }; } } @@ -335,6 +352,7 @@ export async function cmdResolve( 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) { @@ -355,9 +373,22 @@ export async function cmdResolve( } } sourcesReady = false; + unreadableSource = source.path; break; } - if (!sourcesReady) continue; // défensif : `plan.runnable` aurait dû le garantir + 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}`, + ); + } + continue; + } const regenOutcome = await runRegeneration({ repoRoot, diff --git a/packages/cli/src/regenerate-runner.ts b/packages/cli/src/regenerate-runner.ts index 42007752..9be65555 100644 --- a/packages/cli/src/regenerate-runner.ts +++ b/packages/cli/src/regenerate-runner.ts @@ -22,6 +22,26 @@ * 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`). + * + * LIMITATION CONNUE (final review, Finding 3 — documentation seulement, pas + * de re-architecture dans cette fix wave) : le worktree jetable est semé + * depuis `HEAD` (étape 1 ci-dessus) — c'est-à-dire l'état "ours" seul — et + * non depuis l'INDEX DE MERGE en cours (stages 1/2/3), pourtant ce que le + * texte d'architecture du plan décrit ("populated from the in-progress merge + * index"). C'était une décision délibérée prise pendant le dispatch de la + * tâche 2 (raisonnement : ce qui comptait était la jetabilité du worktree, + * et HEAD est jetable) — mais elle a un coût réel que la revue finale du + * plan a identifié à juste titre : les fichiers qui n'existent QUE côté + * "theirs" sont invisibles pour l'installeur, et semer depuis le lockfile de + * "ours" biaise la régénération vers une mise à jour incrémentale plutôt + * qu'une résolution fraîche à partir de zéro. C'est un contributeur + * plausible au faible taux d'accord mesuré par le pilote réel de la tâche 4 + * (66,7 %, n = 3 — voir `benchmark/README.md`, "The gate verdict", hypothèse + * (d)). Le correctif complet (semer depuis l'état stage-2/3 du merge) + * nécessite sa propre mesure réelle pour valider qu'il change effectivement + * ce chiffre — hors scope de cette fix wave (pas de second tour de revue + * après celle-ci) ; ledgeré ici comme limitation connue plutôt que laissé + * silencieux. */ import { execFile, execFileSync } from "node:child_process"; @@ -112,6 +132,39 @@ const ENV_ALLOWLIST_EXACT = new Set([ /** 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" @@ -236,14 +289,18 @@ export function validateRegeneratedContent( } /** - * Construit l'environnement des process spawnés (git worktree + installeur) - * à partir d'une ALLOWLIST explicite (`ENV_ALLOWLIST_EXACT`/`_PREFIXES`), 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 : - * aucun token/clé/identifiant ne peut fuiter par un nom de variable qu'une - * denylist aurait simplement oublié de couvrir. + * 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 buildSpawnEnv(): NodeJS.ProcessEnv { +function buildGitSpawnEnv(): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(process.env)) { if (value === undefined) continue; @@ -258,7 +315,7 @@ function buildSpawnEnv(): NodeJS.ProcessEnv { async function addWorktree(repoRoot: string, worktreeDir: string): Promise { await execFileAsync("git", ["worktree", "add", "--detach", worktreeDir, "HEAD"], { cwd: repoRoot, - env: buildSpawnEnv(), + env: buildGitSpawnEnv(), }); } @@ -266,14 +323,14 @@ async function removeWorktree(repoRoot: string, worktreeDir: string): Promise {}); - await execFileAsync("git", ["worktree", "prune"], { cwd: repoRoot, env: buildSpawnEnv() }).catch(() => {}); + await execFileAsync("git", ["worktree", "prune"], { cwd: repoRoot, env: buildGitSpawnEnv() }).catch(() => {}); } } @@ -332,7 +389,7 @@ export async function runRegeneration(params: RegenerationRunParams): Promise { 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/regenerate/plan.ts b/packages/core/src/regenerate/plan.ts index 11ee60a9..94e46878 100644 --- a/packages/core/src/regenerate/plan.ts +++ b/packages/core/src/regenerate/plan.ts @@ -19,6 +19,15 @@ export interface RegenerationPlan { 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; } /** @@ -27,6 +36,22 @@ export interface RegenerationPlan { * * 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, @@ -43,6 +68,17 @@ export function buildRegenerationPlan( 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: `régénération non supportée pour un fichier généré niché dans un sous-répertoire ("${file}") — résous-le manuellement ou relance ton installeur depuis ce répertoire.`, + }; + } + const runnable = sources.every((source) => source.state === "clean" || source.state === "resolved"); return { file, ecosystem: ecosystem.id, sources, runnable }; diff --git a/packages/mcp/src/__tests__/regenerate-report.test.ts b/packages/mcp/src/__tests__/regenerate-report.test.ts index b07ca94e..fc1878ee 100644 --- a/packages/mcp/src/__tests__/regenerate-report.test.ts +++ b/packages/mcp/src/__tests__/regenerate-report.test.ts @@ -17,7 +17,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { execFileSync } from 'node:child_process' -import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -302,6 +302,67 @@ describe('MCP regenerate:true — reporting only, never executes (task 3)', () = }, ) + 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() + } + }, + ) + it('gitwand_preview_merge: rebase/cherry-pick operations never populate regenerationPlans (out of this task\'s scope)', async () => { const { cwd, cleanup } = buildConflictedLockRepo() try { diff --git a/packages/mcp/src/regenerate-report.ts b/packages/mcp/src/regenerate-report.ts index 992ce0c4..151c66e7 100644 --- a/packages/mcp/src/regenerate-report.ts +++ b/packages/mcp/src/regenerate-report.ts @@ -28,7 +28,7 @@ import { readFileSync, existsSync } from "node:fs"; import { execFileSync } from "node:child_process"; -import { join } from "node:path"; +import { join, resolve as resolvePath } from "node:path"; import { findEcosystem, buildRegenerationPlan, @@ -120,10 +120,21 @@ export interface RegenerationReportEntry { * 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"] = {}; @@ -151,12 +162,17 @@ export function buildRegenerationReport( for (const source of ecosystem.sourcesOfTruth) { if (source in siblingFiles) continue; - // Never conflicted anywhere in the repo ⇒ 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). - if (!conflictedFileSet.has(source)) siblingFiles[source] = { state: "clean" }; + // 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 }); diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index 59793a71..50f9e6ea 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -538,7 +538,9 @@ async function toolStatus(cwd: string, args: Record = {}) { // `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) : undefined; + const regenerationPlans = wantsRegenerationReport + ? buildRegenerationReport(resultsForReport, files, cwd) + : undefined; return { content: [{ @@ -621,7 +623,7 @@ async function toolResolve(cwd: string, args: Record) { // 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)) + ? buildRegenerationReport(resultsForReport, getConflictedFiles(cwd), cwd) : undefined; return { @@ -696,7 +698,7 @@ async function toolPreview(cwd: string, args: Record) { // 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) + ? buildRegenerationReport(resultsForReport, files, cwd) : undefined; return previewResponse("merge", files.length, previews, 0, regenerationPlans); diff --git a/scripts/replay-regenerate.mjs b/scripts/replay-regenerate.mjs index 3c0c8353..853826a9 100644 --- a/scripts/replay-regenerate.mjs +++ b/scripts/replay-regenerate.mjs @@ -44,9 +44,20 @@ * 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. `repoRoot` - * can be bare or non-bare — `git worktree add` and `update-ref` both work - * against a bare repository. + * 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] \ @@ -99,6 +110,58 @@ function git(cmd, 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 @@ -196,8 +259,8 @@ for (const m of merges) { } // ─── stage 2: expensive real regeneration, bounded per ecosystem ─────────── - -const originalHead = git(["rev-parse", "HEAD"]).trim(); +// (`originalHead` was already captured above, before stage 1, so the +// SIGINT/SIGTERM handler can restore it even if interrupted during stage 1.) const perEcosystem = {}; From bde4df38056dbaff5013fbb6bc07caffb62667b2 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Thu, 27 Aug 2026 17:34:24 +0200 Subject: [PATCH 24/37] test(mcp): add explicit 30s timeout to regenerate-report's real-git-repo tests Discovered during the pre-finish full-suite run: these tests spawn real git subprocesses and were relying on vitest's 5s default, which flakes under the full monorepo suite's concurrent load. Matches the 30s timeout convention already used by every other real-git-repo test in this plan. --- packages/mcp/src/__tests__/regenerate-report.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/mcp/src/__tests__/regenerate-report.test.ts b/packages/mcp/src/__tests__/regenerate-report.test.ts index fc1878ee..2aee766e 100644 --- a/packages/mcp/src/__tests__/regenerate-report.test.ts +++ b/packages/mcp/src/__tests__/regenerate-report.test.ts @@ -188,7 +188,7 @@ describe('MCP regenerate:true — reporting only, never executes (task 3)', () = } finally { cleanup() } - }) + }, 30_000) it('gitwand_status: without regenerate, response has no regenerationPlans key (backward compatible)', async () => { const { cwd, cleanup } = buildConflictedLockRepo() @@ -199,7 +199,7 @@ describe('MCP regenerate:true — reporting only, never executes (task 3)', () = } finally { cleanup() } - }) + }, 30_000) it('gitwand_resolve_conflicts: regenerate:true reports the plan without writing or executing anything', async () => { const { cwd, cleanup } = buildConflictedLockRepo() @@ -231,7 +231,7 @@ describe('MCP regenerate:true — reporting only, never executes (task 3)', () = } finally { cleanup() } - }) + }, 30_000) it('gitwand_preview_merge: regenerate:true reports the plan, stays side-effect-free', async () => { const { cwd, cleanup } = buildConflictedLockRepo() @@ -258,7 +258,7 @@ describe('MCP regenerate:true — reporting only, never executes (task 3)', () = } finally { cleanup() } - }) + }, 30_000) it( // Fix round 1 regression — mirrors Task 2's own CLI-side regression test @@ -300,6 +300,7 @@ describe('MCP regenerate:true — reporting only, never executes (task 3)', () = cleanup() } }, + 30_000, ) it( @@ -361,6 +362,7 @@ describe('MCP regenerate:true — reporting only, never executes (task 3)', () = cleanup() } }, + 30_000, ) it('gitwand_preview_merge: rebase/cherry-pick operations never populate regenerationPlans (out of this task\'s scope)', async () => { @@ -376,5 +378,5 @@ describe('MCP regenerate:true — reporting only, never executes (task 3)', () = } finally { cleanup() } - }) + }, 30_000) }) From d181fdfb5d849124e25640be3d1be25b03d3dc12 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Fri, 28 Aug 2026 09:34:13 +0200 Subject: [PATCH 25/37] docs: implementation plan for the regenerate tier follow-up (merge-index seeding, full sweep, docs gap) --- .../2026-08-27-regenerate-tier-followup.md | 655 ++++++++++++++++++ 1 file changed, 655 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-27-regenerate-tier-followup.md 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" +``` From 224a2bbaabb7e828171d6a677fe1966f0936ce58 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Fri, 28 Aug 2026 09:35:19 +0200 Subject: [PATCH 26/37] docs(website): document --regenerate/--resolve-generated and gitwand conventions in cli-commands.md --- website/reference/cli-commands.md | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) 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. From 687b318ca349b0c775047749373d84eb66afb09c Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Fri, 28 Aug 2026 09:50:38 +0200 Subject: [PATCH 27/37] fix(cli): seed the disposable regeneration worktree from the real merge index, not ours-only HEAD --- .../src/__tests__/regenerate-runner.test.ts | 54 ++++++++++++++ packages/cli/src/regenerate-runner.ts | 73 ++++++++++++------- 2 files changed, 101 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/__tests__/regenerate-runner.test.ts b/packages/cli/src/__tests__/regenerate-runner.test.ts index 326644e0..b7c28130 100644 --- a/packages/cli/src/__tests__/regenerate-runner.test.ts +++ b/packages/cli/src/__tests__/regenerate-runner.test.ts @@ -320,6 +320,60 @@ describe("runRegeneration — failure paths", () => { }); }); +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("validateRegeneratedContent", () => { it("accepts valid JSON for npm/composer", () => { expect(validateRegeneratedContent("npm", '{"a":1}').valid).toBe(true); diff --git a/packages/cli/src/regenerate-runner.ts b/packages/cli/src/regenerate-runner.ts index 9be65555..22294bcd 100644 --- a/packages/cli/src/regenerate-runner.ts +++ b/packages/cli/src/regenerate-runner.ts @@ -10,38 +10,23 @@ * 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. écraser dans ce worktree chaque source de vérité (`package.json`…) + * 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). - * 3. lancer la commande du registre (flags de suppression de scripts déjà + * 4. lancer la commande du registre (flags de suppression de scripts déjà * bakés dans `ecosystem.command.args` — jamais surchargeables ici). - * 4. sur succès : relire + valider le lockfile régénéré depuis le + * 5. sur succès : relire + valider le lockfile régénéré depuis le * filesystem du worktree. - * 5. `finally` : toujours supprimer le worktree, succès ou échec. + * 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`). - * - * LIMITATION CONNUE (final review, Finding 3 — documentation seulement, pas - * de re-architecture dans cette fix wave) : le worktree jetable est semé - * depuis `HEAD` (étape 1 ci-dessus) — c'est-à-dire l'état "ours" seul — et - * non depuis l'INDEX DE MERGE en cours (stages 1/2/3), pourtant ce que le - * texte d'architecture du plan décrit ("populated from the in-progress merge - * index"). C'était une décision délibérée prise pendant le dispatch de la - * tâche 2 (raisonnement : ce qui comptait était la jetabilité du worktree, - * et HEAD est jetable) — mais elle a un coût réel que la revue finale du - * plan a identifié à juste titre : les fichiers qui n'existent QUE côté - * "theirs" sont invisibles pour l'installeur, et semer depuis le lockfile de - * "ours" biaise la régénération vers une mise à jour incrémentale plutôt - * qu'une résolution fraîche à partir de zéro. C'est un contributeur - * plausible au faible taux d'accord mesuré par le pilote réel de la tâche 4 - * (66,7 %, n = 3 — voir `benchmark/README.md`, "The gate verdict", hypothèse - * (d)). Le correctif complet (semer depuis l'état stage-2/3 du merge) - * nécessite sa propre mesure réelle pour valider qu'il change effectivement - * ce chiffre — hors scope de cette fix wave (pas de second tour de revue - * après celle-ci) ; ledgeré ici comme limitation connue plutôt que laissé - * silencieux. */ import { execFile, execFileSync } from "node:child_process"; @@ -209,6 +194,14 @@ export interface RegenerationRunParams { 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( @@ -312,11 +305,39 @@ function buildGitSpawnEnv(): NodeJS.ProcessEnv { return env; } -async function addWorktree(repoRoot: string, worktreeDir: string): Promise { +/** + * 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 }, + ); } async function removeWorktree(repoRoot: string, worktreeDir: string): Promise { @@ -369,7 +390,7 @@ export async function runRegeneration(params: RegenerationRunParams): Promise Date: Fri, 28 Aug 2026 10:00:33 +0200 Subject: [PATCH 28/37] feat(scripts): seed the measurement harness's worktree from the real merge-tree result replay-regenerate.mjs replays historical merges with no live in-progress-merge index to read from, so it now builds a scratch index from the merge-tree it already computed during candidate discovery and passes it as runRegeneration's seedIndexFile, matching the disposable-worktree seeding fix already shipped in the CLI's production path. --- scripts/lib/seed-index.mjs | 17 ++++++++++ scripts/lib/seed-index.test.mjs | 55 +++++++++++++++++++++++++++++++++ scripts/replay-regenerate.mjs | 34 +++++++++++++++----- 3 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 scripts/lib/seed-index.mjs create mode 100644 scripts/lib/seed-index.test.mjs diff --git a/scripts/lib/seed-index.mjs b/scripts/lib/seed-index.mjs new file mode 100644 index 00000000..560b8567 --- /dev/null +++ b/scripts/lib/seed-index.mjs @@ -0,0 +1,17 @@ +/** + * 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 }, + }); +} diff --git a/scripts/lib/seed-index.test.mjs b/scripts/lib/seed-index.test.mjs new file mode 100644 index 00000000..33001567 --- /dev/null +++ b/scripts/lib/seed-index.test.mjs @@ -0,0 +1,55 @@ +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 }); + } +}); diff --git a/scripts/replay-regenerate.mjs b/scripts/replay-regenerate.mjs index 853826a9..4dfccbbd 100644 --- a/scripts/replay-regenerate.mjs +++ b/scripts/replay-regenerate.mjs @@ -70,6 +70,10 @@ */ 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, @@ -77,6 +81,7 @@ import { } 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"; // ─── args ──────────────────────────────────────────────────────────────────── @@ -307,13 +312,28 @@ for (const [ecosystemId, allCandidates] of candidatesByEcosystem) { // 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, - }); + // 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 }); + } bump(regenOutcome.kind); From b62d03c643ace00f09778b3ea07e8fb197c0aec2 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Fri, 28 Aug 2026 10:26:07 +0200 Subject: [PATCH 29/37] benchmark: full corpus sweep for the regenerate-tier gate, post merge-index-seeding fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real, full-scale replay-regenerate.mjs run (--max-real 20) against all four in-scope corpus v2 repos (prettier/prettier, tauri-apps/tauri, expressjs/express, twbs/bootstrap), after tasks 2-3's merge-index-seeding fix landed. Result: n=1 comparable (1/1 = 100.0%), a smaller comparable sample than the pilot's n=3 (66.7%) it was meant to supersede — reported as genuinely inconclusive, not "met". CLI-opt-in-only status quo stands; the desktop surface remains unjustified by this evidence. --- benchmark/README.md | 191 ++++++++++++++++++++++++++------------------ 1 file changed, 115 insertions(+), 76 deletions(-) diff --git a/benchmark/README.md b/benchmark/README.md index 41a32385..af9833ee 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -357,85 +357,124 @@ 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) — SMALL SAMPLE, read the caveat before the numbers - -Per the task-4 plan, a full ≤ 20-merges-per-ecosystem sweep was explicitly -**not** run — this is a bounded pilot (≤ 5 real attempts per ecosystem) meant -to decide whether a full run and the desktop surface (task 5) are worth -building at all. Both named corpus v2 repos (`benchmark/corpus.json`, cloned -bare+blobless, pinned to their corpus SHA, same recipe as `run.mjs`'s -`prepare()`) were used, with one correction and one hard blocker discovered -along the way: - -- **`laravel/framework` (composer) — INFEASIBLE, not just slow.** `git log - --all -- composer.lock` returns **zero commits, ever**, in the entire - history. `laravel/framework` is a Composer *library* package, and library - packages deliberately do not commit a lockfile (only applications do) — this - is architectural, not an environment or toolchain problem. The same check - against `symfony/symfony` (the corpus's other PHP repo) confirms it has no - `composer.lock` either. **Corpus v2 currently has no repository that can - measure the composer leg of this gate at all** — a future re-pin needs an - application-shaped PHP repo (the way `prettier/prettier`/`vuejs/core` are - application-shaped for npm-family ecosystems). -- **`prettier/prettier` — the brief's "npm ecosystem" label was wrong.** - `git ls-tree` shows no `package-lock.json` anywhere in the repo, ever; the - repo has a root `.yarnrc.yml` with `yarnPath: .yarn/releases/yarn-4.18.0.cjs` - and a root `yarn.lock` — it is a **yarn-berry** repo. The pilot used the - correctly-identified ecosystem for the same named repo rather than - fabricating an npm measurement that has no basis in this repo's history. - (Confirmed the delegation works in this environment: only yarn classic - 1.22.x was installed via `npm install -g yarn`, and running `yarn - --version` inside a checkout of the repo correctly reports `4.18.0` — - yarn's `yarnPath` respawn works even from a classic binary.) - -Result, `prettier/prettier`, yarn-berry, 237 merges scanned, `--max-real 5`: - -| Metric | Value | -|---|---:| -| Candidate merges found (conflicting `yarn.lock`) | 85 | -| Attempted (the pilot's own cap) | 5 | -| Runnable plans (source resolvable) | 3 | -| Ran successfully (real `yarn install --mode=update-lockfile`, no toolchain/timeout/spawn failure) | 3 | -| Comparable (regenerated + actual committed content both available) | 3 | -| Structurally matched | 2 | -| **Agreement rate** | **66.7 % (2/3)** | - -The two non-runnable candidates declined because `@gitwand/core`'s `resolve()` -could not fully settle `package.json` on its own (genuine overlapping edits, -correctly not auto-resolved) — exactly the behaviour the real CLI would show -for those same two merges. +### 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 seed from +the real 3-way merge result instead of `HEAD` alone), 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 + +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; **all 32 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 -**n = 3.** That is not a corpus, it is barely a sample, and it is the honest -result of following Ruling P-9's bound (≤ 5 real attempts per ecosystem) against -a repo where two of five candidates were correctly declined before reaching -comparison. The measured rate, 66.7 %, is **below the ≥ 80 % target**, and one -of the two named corpus repos (`laravel/framework`) could not be measured on -the composer leg **at all** — not "below target", but no data. - -Per the plan's own instruction for this outcome: **keep CLI opt-in only** -(already true — `--regenerate`/`.gitwandrc` `regenerate: true` already gate -every regeneration behind explicit consent, since tasks 1–3), **document -findings, stop here.** The desktop surface (task 5) and any default-on -regeneration behaviour are **not** justified by this evidence. This is a -pilot-scale, single-ecosystem, n = 3 result — it does not prove regeneration is -unreliable at 66.7 % either; it proves the question isn't answered yet. Before -revisiting: (a) re-pin the corpus with at least one application-shaped PHP repo -so the composer leg is measurable, (b) run the plan's full ≤ 20-merges-per-ecosystem -sweep across npm, pnpm, yarn-berry, composer and cargo, (c) characterise -the one observed mismatch (which package(s) diverged, and why) rather than -treating a single data point as noise, and (d) rule out a documented, known -limitation before blaming the registry commands themselves: `regenerate-runner.ts` -seeds its disposable worktree from `HEAD` (ours-only), not the in-progress merge -index that the plan's own architecture text describes. Files that exist only on -`theirs`' side are invisible to the installer, and seeding from `ours`' lockfile -biases the regeneration toward an incremental update rather than a fresh -resolution — a plausible contributor to the 66.7 % this pilot measured. The full -fix (seed from the merge's stage-2/3 state instead) needs its own real -measurement to confirm it actually moves the number before it's worth building — -see `regenerate-runner.ts`'s module header for the detailed writeup of this -limitation. +**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.** The fix from tasks 2–3 was +exercised (`replay-regenerate.mjs` now seeds its scratch index from the real +merge-tree result, per its module header), but the bottleneck this sweep hit +is a *different* failure surface than the one hypothesis (d) targeted: 11 of +13 runnable `prettier/prettier` candidates failed at the `yarn install +--mode=update-lockfile` step itself — before ever reaching the comparison the +seeding fix was meant to improve. Manually reproducing `yarn install +--mode=update-lockfile` against one of the same merges' unmodified checkout +(no `resolvedSources` overlay applied) succeeds cleanly in this environment, +so the toolchain itself is not broken; the failures are specific to the +regenerated worktree state for those particular candidates and were not +further root-caused here (out of scope for an operator-run measurement task). +Whether this `spawn-failed` surface is itself a side effect of seeding from a +more realistic (and more heterogeneous) merge-index state — as opposed to the +old `HEAD`-only worktree, which by construction produced closer-to-trivial +installs — is a plausible hypothesis, not a confirmed finding. + +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 new dominant bottleneck, +`spawn-failed` on 11 of 13 runnable `prettier/prettier` candidates, needs its +own root-cause pass (capture full `yarn` stdout/stderr per failure, not just +the truncated 3-line `reason` string) before any further accuracy conclusion +is possible; (c) `tauri-apps/tauri`'s 100 % `not-runnable` rate across both +its ecosystems (32/32) 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) once (b) is understood, a +re-run with a materially larger comparable sample (not just a larger attempted +count) is needed before the ≥ 80 % target can be honestly called met or missed. ## Results From 43be17edee3d019581df1ebaa21787a0a072f584 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Fri, 28 Aug 2026 10:50:45 +0200 Subject: [PATCH 30/37] fix: harden regenerate-tier merge-index seeding after whole-branch review The measurement harness's scratch index (git read-tree of a single merge-tree result) put every path at stage 0, including genuinely conflicted ones whose blob content is literal diff3 markers - a worktree state production can never reach, since a real merge index keeps conflicted paths off stage 0 entirely. seedScratchIndex now takes a skipPaths param to force-remove those paths from the scratch index after read-tree, and replay-regenerate.mjs retains each candidate's conflicted path list from discovery to pass through. Also: addWorktree's checkout-index overlay no longer throws (preserves runRegeneration's never-throw contract) and no longer leaks an ambient GIT_INDEX_FILE; corrected an overclaim in the fix's own doc comments and in benchmark/README.md about what the overlay actually changes; fixed a 32/32 arithmetic error in the sweep write-up; marked the existing sweep numbers invalidated pending a re-run against the fixed harness; rewrote seed-index's existing test (it wasn't actually exercising seedScratchIndex) and added coverage for the skip-paths behavior and for the CLI's seedIndexFile interface end to end. disabled --- benchmark/README.md | 55 +++++++--- package.json | 2 +- .../src/__tests__/regenerate-runner.test.ts | 101 ++++++++++++++++++ packages/cli/src/regenerate-runner.ts | 55 ++++++++-- scripts/lib/seed-index.mjs | 23 +++- scripts/lib/seed-index.test.mjs | 96 ++++++++++++++++- scripts/replay-regenerate.mjs | 12 ++- 7 files changed, 315 insertions(+), 29 deletions(-) diff --git a/benchmark/README.md b/benchmark/README.md index af9833ee..8f68ee7b 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -327,9 +327,11 @@ 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:regenerate-compare` from the repo root) — 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). +`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 @@ -360,8 +362,13 @@ 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 seed from -the real 3-way merge result instead of `HEAD` alone), a bounded pilot +`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 @@ -371,6 +378,26 @@ the real, full-scale numbers gathered after the fix. ### Full corpus sweep (2026-08-28) — post merge-index-seeding fix +> **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 @@ -417,7 +444,9 @@ Detail per repo, exactly as measured, no rounding or omission: - **`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; **all 32 attempted candidates across both ecosystems came back + 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. @@ -469,12 +498,14 @@ explicitly out of scope for this plan; (b) the new dominant bottleneck, own root-cause pass (capture full `yarn` stdout/stderr per failure, not just the truncated 3-line `reason` string) before any further accuracy conclusion is possible; (c) `tauri-apps/tauri`'s 100 % `not-runnable` rate across both -its ecosystems (32/32) 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) once (b) is understood, a -re-run with a materially larger comparable sample (not just a larger attempted -count) is needed before the ≥ 80 % target can be honestly called met or missed. +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) once +(b) is understood, a re-run with a materially larger comparable sample (not +just a larger attempted count) is needed before the ≥ 80 % target can be +honestly called met or missed. ## Results diff --git a/package.json b/package.json index 16f61890..f4e54e78 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "scripts": { "build": "pnpm -r run build", "test": "pnpm -r run test", - "test:regenerate-compare": "node --test scripts/lib/regenerate-compare.test.mjs", + "test:scripts-lib": "node --test scripts/lib/*.test.mjs", "clean": "pnpm -r run clean", "postinstall": "node scripts/fix-spawn-helper.mjs" }, diff --git a/packages/cli/src/__tests__/regenerate-runner.test.ts b/packages/cli/src/__tests__/regenerate-runner.test.ts index b7c28130..c6f385b3 100644 --- a/packages/cli/src/__tests__/regenerate-runner.test.ts +++ b/packages/cli/src/__tests__/regenerate-runner.test.ts @@ -374,6 +374,107 @@ describe("runRegeneration — worktree reflects the real merge index", () => { }); }); +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); diff --git a/packages/cli/src/regenerate-runner.ts b/packages/cli/src/regenerate-runner.ts index 22294bcd..d1079188 100644 --- a/packages/cli/src/regenerate-runner.ts +++ b/packages/cli/src/regenerate-runner.ts @@ -15,6 +15,11 @@ * `--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). @@ -310,16 +315,34 @@ function buildGitSpawnEnv(): NodeJS.ProcessEnv { * `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. + * `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, @@ -332,12 +355,22 @@ async function addWorktree( }); const env = buildGitSpawnEnv(); - if (seedIndexFile) env.GIT_INDEX_FILE = seedIndexFile; - await execFileAsync( - "git", - ["--work-tree", worktreeDir, "checkout-index", "--all", "--force"], - { cwd: repoRoot, env }, - ); + 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 { diff --git a/scripts/lib/seed-index.mjs b/scripts/lib/seed-index.mjs index 560b8567..77b8257f 100644 --- a/scripts/lib/seed-index.mjs +++ b/scripts/lib/seed-index.mjs @@ -7,11 +7,32 @@ * `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. */ import { execFileSync } from "node:child_process"; -export function seedScratchIndex(repo, treeOid, indexPath) { +export function seedScratchIndex(repo, treeOid, indexPath, skipPaths = []) { execFileSync("git", ["-C", repo, "read-tree", treeOid], { env: { ...process.env, GIT_INDEX_FILE: indexPath }, }); + if (skipPaths.length > 0) { + execFileSync("git", ["-C", repo, "update-index", "--force-remove", "--", ...skipPaths], { + env: { ...process.env, GIT_INDEX_FILE: indexPath }, + }); + } } diff --git a/scripts/lib/seed-index.test.mjs b/scripts/lib/seed-index.test.mjs index 33001567..33b74189 100644 --- a/scripts/lib/seed-index.test.mjs +++ b/scripts/lib/seed-index.test.mjs @@ -6,8 +6,48 @@ 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", ...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", () => { @@ -47,8 +87,58 @@ test("seedScratchIndex materializes a theirs-only file into a scratch index with // 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"); + // 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 }); } diff --git a/scripts/replay-regenerate.mjs b/scripts/replay-regenerate.mjs index 4dfccbbd..99dfb2af 100644 --- a/scripts/replay-regenerate.mjs +++ b/scripts/replay-regenerate.mjs @@ -258,6 +258,12 @@ for (const m of merges) { 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, }); } @@ -319,7 +325,11 @@ for (const [ecosystemId, allCandidates] of candidatesByEcosystem) { // 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); + // 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 { From a09e5b182d3a66c9f9192192ae0ac7305f27ed79 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Fri, 28 Aug 2026 11:02:23 +0200 Subject: [PATCH 31/37] docs(benchmark): resolve the gate-verdict section's remaining contradiction with the invalidation notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate verdict section still floated a "more realistic merge-index state" hypothesis for the spawn-failed bottleneck, and asked for further root-causing — both superseded by the invalidation notice's own root-cause (the harness's marker-corruption bug, now fixed). Also fixes a stale package.json script-name reference left over from the test:regenerate-compare -> test:scripts-lib rename. --- benchmark/README.md | 45 ++++++++++++------------- scripts/lib/regenerate-compare.test.mjs | 3 +- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/benchmark/README.md b/benchmark/README.md index 8f68ee7b..847e16ed 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -474,37 +474,34 @@ evidence — n = 1 justifies nothing either way. Do not read this section as one data point. On hypothesis (d) specifically (does merge-index seeding move the number): -**this sweep cannot confirm or refute it.** The fix from tasks 2–3 was -exercised (`replay-regenerate.mjs` now seeds its scratch index from the real -merge-tree result, per its module header), but the bottleneck this sweep hit -is a *different* failure surface than the one hypothesis (d) targeted: 11 of -13 runnable `prettier/prettier` candidates failed at the `yarn install ---mode=update-lockfile` step itself — before ever reaching the comparison the -seeding fix was meant to improve. Manually reproducing `yarn install ---mode=update-lockfile` against one of the same merges' unmodified checkout -(no `resolvedSources` overlay applied) succeeds cleanly in this environment, -so the toolchain itself is not broken; the failures are specific to the -regenerated worktree state for those particular candidates and were not -further root-caused here (out of scope for an operator-run measurement task). -Whether this `spawn-failed` surface is itself a side effect of seeding from a -more realistic (and more heterogeneous) merge-index state — as opposed to the -old `HEAD`-only worktree, which by construction produced closer-to-trivial -installs — is a plausible hypothesis, not a confirmed finding. +**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 new dominant bottleneck, -`spawn-failed` on 11 of 13 runnable `prettier/prettier` candidates, needs its -own root-cause pass (capture full `yarn` stdout/stderr per failure, not just -the truncated 3-line `reason` string) before any further accuracy conclusion -is possible; (c) `tauri-apps/tauri`'s 100 % `not-runnable` rate across both +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) once -(b) is understood, a re-run with a materially larger comparable sample (not -just a larger attempted count) is needed before the ≥ 80 % target can be +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. ## Results diff --git a/scripts/lib/regenerate-compare.test.mjs b/scripts/lib/regenerate-compare.test.mjs index f3ff28a4..a44757d9 100644 --- a/scripts/lib/regenerate-compare.test.mjs +++ b/scripts/lib/regenerate-compare.test.mjs @@ -1,7 +1,8 @@ /** * 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:regenerate-compare" script). + * (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"; From 9613b5cd3921f2853f2351f9a41be623c720e917 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Fri, 28 Aug 2026 12:14:39 +0200 Subject: [PATCH 32/37] benchmark: real re-run of the regenerate-tier sweep against the fixed harness The seed-index skipPaths fix (43be17e) is real but incomplete: git update-index --force-remove still requires a work tree even when only editing an index file, and the corpus caches are intentionally bare, so every runnable candidate now fails before ever reaching the installer. This fresh, real re-run produced 0 comparable results (vs the invalidated sweep's n=1 and the original pilot's n=3), documented honestly with the root cause and the concrete next fix needed. --- benchmark/README.md | 116 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) diff --git a/benchmark/README.md b/benchmark/README.md index 847e16ed..a98b4a61 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -376,7 +376,7 @@ why the number might be low. That pilot's full write-up (including the 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 +### Full corpus sweep (2026-08-28) — post merge-index-seeding fix, superseded by the corrected re-run below > **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 @@ -451,7 +451,7 @@ Detail per repo, exactly as measured, no rounding or omission: `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 +### 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 @@ -504,6 +504,118 @@ 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 + +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. + ## Results `results/` holds one JSON file per measured GitWand version, plus the corpus pin From 099f53586d320c67e219a83eae0b81c9de0d734d Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Fri, 28 Aug 2026 12:59:58 +0200 Subject: [PATCH 33/37] fix(benchmark): rebuild regenerate-tier scratch index via ls-tree/mktree plumbing git update-index --force-remove still requires a work tree even when only editing an index file, so seedScratchIndex failed 100% of the time against the corpus's bare clones. Rebuilt it to remove skipped paths via targeted git ls-tree -z / mktree -z --missing calls that walk only the directory chain of each skip path, which needs no work tree, tolerates C-quoted filenames, and doesn't require objects to be fetched in a blobless clone. Re-ran the real regenerate-tier sweep against the fixed harness: n = 13 comparable, 5 matched, 38.5% agreement on prettier/prettier's yarn-berry candidates, well below the 80% target. Documented in benchmark/README.md alongside the two prior, invalidated sweep attempts. disabled --- benchmark/README.md | 145 ++++++++++++++++++++++++++++- scripts/lib/seed-index.mjs | 107 ++++++++++++++++++++- scripts/lib/seed-index.test.mjs | 160 +++++++++++++++++++++++++++++++- 3 files changed, 405 insertions(+), 7 deletions(-) diff --git a/benchmark/README.md b/benchmark/README.md index a98b4a61..e35ac9b1 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -616,7 +616,150 @@ 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. -## Results +### 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 + 3288-directory-per-candidate rebuild 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. + +All three 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). #3 is a partial-clone +promisor-fetch behavior that a from-scratch `mkdtemp` fixture cannot reproduce +(it is never blobless); it was caught and fixed by direct testing against the +real cache, which is exactly why this task's protocol required a real-bare-repo +sanity check before the full sweep — one is documented below. +`node --test scripts/lib/*.test.mjs` passes, 17/17, including the two new +regression tests. + +**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: it is more +than 4× the comparable sample size of either predecessor (pilot n = 3, +invalidated sweep n = 1), it is the first sweep where a materially larger +*comparable* population was actually produced (not just a larger *attempted* +count), and every one of the 13 runnable candidates ran to completion, so +there is no remaining pool of not-yet-measured runnable candidates hiding a +different answer. **Verdict: target NOT met**, plainly, not "inconclusive." + +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): **this sweep finally answers it, and the answer is not the one +tasks 2–3's fix was hoping for.** The scratch-index seeding bug that blocked +every candidate in re-run #1 is fixed, candidates now reach `yarn install` and +run to completion, and the result is 38.5 % agreement — well below both the +pilot's 66.7 % (n = 3) and the ≥ 80 % target. Seeding the disposable worktree +from the real 3-way merge result (rather than `HEAD` alone) does not, by +itself, get this feature to a publishable number. `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/scripts/lib/seed-index.mjs b/scripts/lib/seed-index.mjs index 77b8257f..0a3d7c9c 100644 --- a/scripts/lib/seed-index.mjs +++ b/scripts/lib/seed-index.mjs @@ -23,16 +23,113 @@ * 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 = []) { - execFileSync("git", ["-C", repo, "read-tree", treeOid], { + 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 }, }); - if (skipPaths.length > 0) { - execFileSync("git", ["-C", repo, "update-index", "--force-remove", "--", ...skipPaths], { - 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 index 33b74189..05e3b20b 100644 --- a/scripts/lib/seed-index.test.mjs +++ b/scripts/lib/seed-index.test.mjs @@ -1,7 +1,7 @@ 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 { 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"; @@ -143,3 +143,161 @@ test("seedScratchIndex(skipPaths) removes still-conflicted paths from the scratc 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 }); + } +}); From 01251ce2e0cd5a2768cdf88ef4f79c51ae0161de Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Fri, 28 Aug 2026 13:21:24 +0200 Subject: [PATCH 34/37] docs(benchmark): fix honesty gaps in the conclusive sweep's write-up Independent review of the third sweep attempt confirmed the 38.5% (5/13) result itself is sound, but found the write-up overreached in two places and understated the fix's remaining coverage gaps: - The gate verdict implied "every runnable candidate ran to completion" meant the full 85-candidate population was covered; it didn't disclose that --max-real 20 only attempted the 20 most recent (recency-biased) candidates, leaving 65 unclassified. - The hypothesis-(d) paragraph read a causal "seeding made it worse" comparison out of the pilot's 66.7% (n=3) vs this sweep's 38.5% (n=13) despite there being no matched-pair comparison and heavily overlapping confidence intervals - replaced with the narrower, actually-supported claim. - Corrected a false "cannot be reproduced without network" claim about the blobless-clone bug (independently disproven) and disclosed two residual gaps found by the same review: the blobless dimension still has no regression test, and a C-quoted skip path from merge-tree's default output would silently fail to be stripped (zero blast radius on this sweep's 237 scanned merges, confirmed by the reviewer, but a live latent bug for future runs). - Fixed a stale historical-section pointer chain and an inaccurate illustrative directory count. --- benchmark/README.md | 94 ++++++++++++++++++++++++++++++++------------- 1 file changed, 67 insertions(+), 27 deletions(-) diff --git a/benchmark/README.md b/benchmark/README.md index e35ac9b1..efbf4987 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -376,7 +376,7 @@ why the number might be low. That pilot's full write-up (including the 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 the corrected re-run below +### 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 @@ -504,7 +504,7 @@ 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 +### 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 @@ -631,9 +631,9 @@ two more real bugs no hand-built fixture had ever exercised, in order: 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 - 3288-directory-per-candidate rebuild into effectively zero-to-a-few `mktree` - calls per candidate, since `prettier/prettier`'s skip paths are always at - the tree root. + 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: @@ -651,15 +651,35 @@ two more real bugs no hand-built fixture had ever exercised, in order: the same repository's own object database — nothing is invented, so there is nothing to validate. -All three are now covered by `scripts/lib/seed-index.test.mjs`: a bare-repo +#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). #3 is a partial-clone -promisor-fetch behavior that a from-scratch `mkdtemp` fixture cannot reproduce -(it is never blobless); it was caught and fixed by direct testing against the -real cache, which is exactly why this task's protocol required a real-bare-repo -sanity check before the full sweep — one is documented below. -`node --test scripts/lib/*.test.mjs` passes, 17/17, including the two new -regression tests. +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 @@ -727,13 +747,25 @@ lockfile the `prettier` team actually committed. **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: it is more -than 4× the comparable sample size of either predecessor (pilot n = 3, -invalidated sweep n = 1), it is the first sweep where a materially larger -*comparable* population was actually produced (not just a larger *attempted* -count), and every one of the 13 runnable candidates ran to completion, so -there is no remaining pool of not-yet-measured runnable candidates hiding a -different answer. **Verdict: target NOT met**, plainly, not "inconclusive." +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 @@ -753,13 +785,21 @@ 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): **this sweep finally answers it, and the answer is not the one -tasks 2–3's fix was hoping for.** The scratch-index seeding bug that blocked -every candidate in re-run #1 is fixed, candidates now reach `yarn install` and -run to completion, and the result is 38.5 % agreement — well below both the -pilot's 66.7 % (n = 3) and the ≥ 80 % target. Seeding the disposable worktree -from the real 3-way merge result (rather than `HEAD` alone) does not, by -itself, get this feature to a publishable number. +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 From e2973c8f23dfd0bedc0b40b41062fec5f08aca67 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Fri, 28 Aug 2026 15:08:21 +0200 Subject: [PATCH 35/37] fix(benchmark): C-quoted merge-tree skip paths + blobless mktree regression test mergeTree() in replay-regenerate.mjs used git merge-tree's default (non-z) output, which C-quotes any conflicted path containing a quote character or non-ASCII byte. Those quoted, escaped strings never matched the raw bytes seedScratchIndex compares skipPaths against (read via git ls-tree -z), so a C-quoted conflicted path would silently fail to be skipped, leaking diff3 marker content into the scratch index. Extracted mergeTree into scripts/lib/merge-tree.mjs and switched it to -z, parsing the verified real output shape rather than guessing from documentation. Also added the regression test the blobless-clone mktree --missing fix (scripts/lib/seed-index.mjs) shipped without: a hermetic blobless bare clone built via uploadpack.allowFilter + clone --bare --filter=blob:none, no network required, disproving the prior claim that this case "cannot be reproduced" hermetically. Zero impact on the already-published regenerate-tier sweep numbers; this is pure robustness/coverage work for future runs. --- scripts/lib/merge-tree.mjs | 68 ++++++++++ scripts/lib/merge-tree.test.mjs | 225 ++++++++++++++++++++++++++++++++ scripts/lib/seed-index.test.mjs | 92 +++++++++++++ scripts/replay-regenerate.mjs | 19 ++- 4 files changed, 393 insertions(+), 11 deletions(-) create mode 100644 scripts/lib/merge-tree.mjs create mode 100644 scripts/lib/merge-tree.test.mjs 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/seed-index.test.mjs b/scripts/lib/seed-index.test.mjs index 05e3b20b..21e804b5 100644 --- a/scripts/lib/seed-index.test.mjs +++ b/scripts/lib/seed-index.test.mjs @@ -301,3 +301,95 @@ test("seedScratchIndex(skipPaths) tolerates sibling filenames with quotes/spaces 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-regenerate.mjs b/scripts/replay-regenerate.mjs index 99dfb2af..f4861ca9 100644 --- a/scripts/replay-regenerate.mjs +++ b/scripts/replay-regenerate.mjs @@ -82,6 +82,7 @@ import { 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 ──────────────────────────────────────────────────────────────────── @@ -170,19 +171,15 @@ 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. */ + * 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 { - git(["-c", "merge.conflictstyle=diff3", "merge-tree", "--write-tree", "--name-only", p1, p2], { - 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 [head] = err.stdout.split("\n\n"); - const lines = head.split("\n").filter(Boolean); - return { treeOid: lines[0], files: lines.slice(1) }; - } + return mergeTreeLib(repo, p1, p2); + } catch { mergeTreeErrors++; return null; } From 11e465f3713b78320a18672fd1374e485a862082 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Fri, 28 Aug 2026 17:59:24 +0200 Subject: [PATCH 36/37] docs: fix generated-file/lockfile claims left stale by the accuracy work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README.md, the conflict-resolution guide and the package-lock.json fix page all still described generated_file as auto-applying (accept-theirs or a semantic key-wise merge) by default. It now declines by default, measured on 1,662 real merges to diverge from what teams actually shipped in almost every case — these three pages were the only user-facing docs this branch left saying otherwise. disabled --- README.md | 6 +++++- website/fix/package-lock-json-merge-conflict.md | 15 +++++++++++---- website/guide/conflict-resolution.md | 6 ++++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d6267e24..5d1b1eee 100644 --- a/README.md +++ b/README.md @@ -163,9 +163,11 @@ GitWand uses a **pattern registry** — the classifier evaluates patterns in pri | **reorder_only** | Same lines, different order — pure permutation | High | | **insertion_at_boundary** | Pure insertions on both sides, base intact | High | | **value_only_change** | Scalar value update (version number, constant) | Medium | -| **generated_file** | File matches a known generated-file path pattern | High | +| **generated_file** | File matches a known generated-file path pattern | Declined by default* | | **complex** | Overlapping edits — never auto-resolved | — | +\* Generated files (lockfiles, minified bundles, `dist/` outputs) are declined by default — [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. 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`. + ### Composite confidence score Every resolution carries a `ConfidenceScore` object rather than a simple label: @@ -184,6 +186,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/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: From 35383d6d0fc0ddf0e51595b1f83f1842b9b6da2a Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Mon, 31 Aug 2026 09:39:11 +0200 Subject: [PATCH 37/37] chore: gitignore the Impeccable local session-lease cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .impeccable/hook.cache.json showed up as an untracked file with no repo content (empty session cache) — ignore the whole dir so it stops surfacing. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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/