Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- **Two imports of the same module are no longer both kept (#278).** When one side added `import { useAuth } from "./auth"` and the other `import useAuth from "./auth"`, the imports resolver declined, and the hunk went on to the line patterns, which applied it as `insertion_at_boundary` with both lines: a duplicate binding the compiler rejects. When both sides add an import of the same module that declares the same name, the hunk is now classified `complex` and left to you; two forms that bind different names (`import React` and `import { useState }`) are still both kept.
- **The Dockerfile resolver no longer duplicates a stage or an instruction (#278).** Stages without an `AS` alias were matched by their image, so a `FROM` changed on both sides (`node:20` against `node:22`) kept both lines and created a second stage; a hunk with no `FROM` at all matched nothing, so an `ENV PORT` changed on both sides came out twice. Unaliased stages are now matched by position. When the two sides do not have the same number of them, the hunk goes to the textual engine. Within a stage, an instruction changed on one side only now keeps that change instead of the other side's base value.

## [3.12.4] - 2026-10-19

### Added
Expand Down
180 changes: 180 additions & 0 deletions packages/core/src/__tests__/resolvers/dockerfile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,183 @@ describe("Dockerfile — préservation avec commentaires", () => {
expect(result.mergedContent).toContain("NODE_ENV=production");
});
});

// ─── Appariement des stages sans alias ───────────────────────────────────────
// Régression : les stages étaient appariés par `alias ?? image` (et par une clé
// aléatoire sans FROM). Sans alias, deux FROM différents ne s'appariaient pas et
// les deux stages étaient gardés ; sans FROM dans le hunk, rien ne s'appariait.

function dockerHunk(ours: string[], base: string[], theirs: string[], after: string[] = []): string {
return [
`<<<<<<< ours`, ...ours,
`||||||| base`, ...base,
`=======`, ...theirs,
`>>>>>>> theirs`, ...after,
].join("\n");
}

describe("Dockerfile — stages sans alias appariés par position", () => {
it("hunk limité au FROM : une seule ligne FROM (prefer theirs)", () => {
const input = dockerHunk([`FROM node:20-alpine`], [`FROM node:18-alpine`], [`FROM node:22-alpine`], [`RUN npm ci`]);
const result = resolve(input, "Dockerfile");
expect(result.stats.autoResolved).toBe(1);
expect(result.mergedContent?.match(/^FROM /gm)).toHaveLength(1);
expect(result.mergedContent).toContain("FROM node:22-alpine");
expect(result.mergedContent).not.toContain("node:20-alpine");
});

it("FROM + instructions : un seul stage, instructions des deux côtés", () => {
const input = dockerHunk(
[`FROM node:20-alpine`, `ENV A=1`],
[`FROM node:18-alpine`],
[`FROM node:22-alpine`, `ENV B=2`],
);
const result = resolve(input, "Dockerfile");
expect(result.stats.autoResolved).toBe(1);
expect(result.mergedContent?.match(/^FROM /gm)).toHaveLength(1);
expect(result.mergedContent).toContain("ENV A=1");
expect(result.mergedContent).toContain("ENV B=2");
});

it("deux stages sans alias : appariés un à un", () => {
const input = dockerHunk(
[`FROM golang:1.21`, `RUN go build`, `FROM alpine:3.18`],
[`FROM golang:1.20`, `RUN go build`, `FROM alpine:3.17`],
[`FROM golang:1.22`, `RUN go build`, `FROM alpine:3.19`],
);
const result = resolve(input, "Dockerfile");
expect(result.stats.autoResolved).toBe(1);
expect(result.mergedContent?.match(/^FROM /gm)).toHaveLength(2);
expect(result.mergedContent).toContain("FROM golang:1.22");
expect(result.mergedContent).toContain("FROM alpine:3.19");
expect(result.mergedContent?.match(/RUN go build/g)).toHaveLength(1);
});

it("hunk sans FROM : la même instruction n'est pas dupliquée", () => {
const input = [`FROM node:20`, dockerHunk([`ENV PORT=3000`], [`ENV PORT=8080`], [`ENV PORT=4000`], [`RUN npm ci`])].join("\n");
const result = resolve(input, "Dockerfile");
expect(result.stats.autoResolved).toBe(1);
expect(result.mergedContent?.match(/ENV PORT=/g)).toHaveLength(1);
expect(result.mergedContent).toContain("ENV PORT=4000");
});

it("alias d'un côté seulement : pas de résolution automatique", () => {
const input = dockerHunk([`FROM node:20 AS build`], [`FROM node:18`], [`FROM node:22`]);
const result = resolve(input, "Dockerfile");
expect(result.stats.autoResolved).toBe(0);
});

it("nombre de stages sans alias différent : pas de résolution automatique", () => {
const input = dockerHunk(
[`FROM node:20`, `RUN npm ci`, `FROM nginx:1.25`],
[`FROM node:18`, `RUN npm ci`],
[`FROM node:22`, `RUN npm ci`],
);
const result = resolve(input, "Dockerfile");
expect(result.stats.autoResolved).toBe(0);
});
});

// ─── Changement d'un seul côté : jamais effacé par « prefer theirs » ─────────
// Avec l'appariement par position, une modification faite seulement côté ours
// (bump d'image, ENV) était remplacée par la valeur de base venue de theirs.

describe("Dockerfile — la base départage les instructions", () => {
it("FROM changé côté ours seulement, ENV changé côté theirs : les deux gardés", () => {
const input = dockerHunk([`FROM node:20`, `ENV A=1`], [`FROM node:18`, `ENV A=1`], [`FROM node:18`, `ENV A=2`]);
const result = resolve(input, "Dockerfile");
expect(result.stats.autoResolved).toBe(1);
expect(result.mergedContent).toContain("FROM node:20");
expect(result.mergedContent).toContain("ENV A=2");
expect(result.mergedContent?.match(/^FROM /gm)).toHaveLength(1);
});

it("hunk sans FROM : ENV changé de chaque côté sur des clés différentes", () => {
const input = [`FROM node:20`, dockerHunk([`ENV PORT=3000`, `ENV B=1`], [`ENV PORT=8080`, `ENV B=1`], [`ENV PORT=8080`, `ENV B=2`])].join("\n");
const result = resolve(input, "Dockerfile");
expect(result.stats.autoResolved).toBe(1);
expect(result.mergedContent).toContain("ENV PORT=3000");
expect(result.mergedContent).toContain("ENV B=2");
expect(result.mergedContent).not.toContain("PORT=8080");
});

it("second stage bumpé côté ours seulement : bump gardé", () => {
const input = dockerHunk(
[`FROM golang:1.21`, `RUN go build`, `FROM alpine:3.19`],
[`FROM golang:1.21`, `RUN go build`, `FROM alpine:3.18`],
[`FROM debian:12`, `RUN go build`, `FROM alpine:3.18`],
);
const result = resolve(input, "Dockerfile");
expect(result.stats.autoResolved).toBe(1);
expect(result.mergedContent).toContain("FROM debian:12");
expect(result.mergedContent).toContain("FROM alpine:3.19");
expect(result.mergedContent).not.toContain("alpine:3.18");
});
});

// ─── Clé répétée côté theirs : jamais d'instruction perdue ───────────────────
describe("Dockerfile — instructions de même clé côté theirs", () => {
it("theirs ajoute un second RUN : RUN a n'est pas perdu", () => {
const input = dockerHunk(
[`FROM node:20 AS app`, `ENV A=2`, `RUN a`],
[`FROM node:20 AS app`, `ENV A=1`, `RUN a`],
[`FROM node:20 AS app`, `ENV A=1`, `RUN a`, `RUN b`],
);
const result = resolve(input, "Dockerfile");
expect(result.stats.autoResolved).toBe(1);
expect(result.mergedContent).toContain("RUN a");
expect(result.mergedContent).toContain("RUN b");
expect(result.mergedContent).toContain("ENV A=2");
});

it("theirs ajoute un COPY : le premier COPY n'est pas perdu (hunk sans FROM)", () => {
const input = [`FROM node:20`, dockerHunk([`ENV A=2`, `COPY a .`], [`ENV A=1`, `COPY a .`], [`ENV A=1`, `COPY a .`, `COPY b .`])].join("\n");
const result = resolve(input, "Dockerfile");
if (result.mergedContent) {
expect(result.mergedContent).toContain("COPY a .");
expect(result.mergedContent).toContain("COPY b .");
}
});

it("hunk au milieu d'une continuation `\\` : pas de fusion Dockerfile", () => {
const input = [`FROM node:20`, `RUN apt-get install \\`, dockerHunk([` pkg-a \\`, ` pkg-b`], [` pkg-a`], [` pkg-a \\`, ` pkg-c`])].join("\n");
const result = resolve(input, "Dockerfile");
expect(result.resolutions[0].resolutionReason ?? "").not.toMatch(/merged by stage/);
expect(result.mergedContent ?? "").not.toMatch(/pkg-c\n\s*pkg-c/);
});
});

describe("Dockerfile — instructions de même clé côté ours", () => {
it("ours ajoute un RUN que theirs a aussi : jamais en double", () => {
const input = dockerHunk(
[`FROM node:20 AS app`, `ENV A=2`, `RUN npm ci`, `RUN npm run build`],
[`FROM node:20 AS app`, `ENV A=1`, `RUN npm ci`],
[`FROM node:20 AS app`, `ENV A=1`, `RUN npm run build`],
);
const result = resolve(input, "Dockerfile");
expect(result.mergedContent?.match(/RUN npm run build/g) ?? []).not.toHaveLength(2);
});

it("ENV répété côté ours : aucune valeur perdue en silence", () => {
const input = dockerHunk(
[`FROM node:20 AS app`, `ENV PATH=p1`, `ENV PATH=p2`],
[`FROM node:20 AS app`, `ENV PATH=p1`],
[`FROM node:20 AS app`, `ENV PATH=p3`],
);
const result = resolve(input, "Dockerfile");
expect(result.resolutions[0].resolutionReason ?? "").not.toMatch(/merged by stage/);
});

it("MAINTAINER est une instruction : la fusion par stage reste possible", () => {
const input = dockerHunk(
[`FROM node:20 AS app`, `MAINTAINER x`, `ENV A=2`],
[`FROM node:20 AS app`, `MAINTAINER x`, `ENV A=1`],
[`FROM node:20 AS app`, `MAINTAINER x`, `ENV A=1`, `EXPOSE 80`],
);
const result = resolve(input, "Dockerfile");
expect(result.stats.autoResolved).toBe(1);
expect(result.mergedContent).toContain("ENV A=2");
expect(result.mergedContent).toContain("EXPOSE 80");
expect(result.mergedContent?.match(/MAINTAINER x/g)).toHaveLength(1);
});
});
165 changes: 165 additions & 0 deletions packages/core/src/__tests__/resolvers/imports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,168 @@ describe("F5 — imports : deux modules différents ajoutés (diff2)", () => {
expect(result.mergedContent).toContain("useRouter");
});
});

// ─── Même module, formes incompatibles : jamais de repli textuel ─────────────
// Régression : le résolveur imports refusait (null), le moteur textuel prenait
// le relais et classait le hunk insertion_at_boundary — les deux lignes étaient
// gardées et `useAuth` se retrouvait déclaré deux fois.

describe("imports — même module sous deux formes (named vs default)", () => {
const input = [
`<<<<<<< ours`,
`import React from "react";`,
`import { useAuth } from "./auth";`,
`||||||| base`,
`import React from "react";`,
`=======`,
`import React from "react";`,
`import useAuth from "./auth";`,
`>>>>>>> theirs`,
`export const x = 1;`,
].join("\n");

it("n'est pas auto-résolu", () => {
const result = resolve(input, "app.ts");
expect(result.stats.autoResolved).toBe(0);
});

it("est classé complex", () => {
const result = resolve(input, "app.ts");
expect(result.hunks[0].type).toBe("complex");
});

it("la raison cite le conflit d'import", () => {
const result = resolve(input, "app.ts");
expect(result.resolutions[0].resolutionReason).toMatch(/\[imports\].*'\.\/auth'/);
});

it("même chose dans l'autre sens (default côté ours)", () => {
const swapped = input
.replace(`import { useAuth } from "./auth";`, "__TMP__")
.replace(`import useAuth from "./auth";`, `import { useAuth } from "./auth";`)
.replace("__TMP__", `import useAuth from "./auth";`);
const result = resolve(swapped, "app.ts");
expect(result.stats.autoResolved).toBe(0);
expect(result.hunks[0].type).toBe("complex");
});

it("un bloc non-import laisse toujours la main au moteur textuel", () => {
const mixed = [
`<<<<<<< ours`,
`import React from "react";`,
`const a = 1;`,
`||||||| base`,
`import React from "react";`,
`=======`,
`import React from "react";`,
`const b = 2;`,
`>>>>>>> theirs`,
].join("\n");
const result = resolve(mixed, "app.ts");
expect(result.hunks[0].type).toBe("insertion_at_boundary");
});
});

// ─── Changement de forme d'un seul côté : pas un conflit ─────────────────────
// Régression : un côté passe `{ x }` → `x` et l'autre garde l'import de base.
// Le contrôle « même module, autre forme » se déclenchait quand même, et depuis
// que ce conflit n'est plus repassé au moteur textuel, le hunk devenait complex.

describe("imports — forme changée d'un seul côté", () => {
it("theirs change la forme, ours modifie un autre import : résolu", () => {
const input = [
`<<<<<<< ours`, `import A2 from "a";`, `import { x } from "b";`,
`||||||| base`, `import A from "a";`, `import { x } from "b";`,
`=======`, `import A from "a";`, `import x from "b";`,
`>>>>>>> theirs`, `export const y = 1;`,
].join("\n");
const result = resolve(input, "app.ts");
expect(result.stats.autoResolved).toBe(1);
expect(result.mergedContent).toContain(`import A2 from "a";`);
expect(result.mergedContent).toContain(`import x from "b";`);
expect(result.mergedContent).not.toContain(`import { x } from "b";`);
});

it("ours change la forme, theirs ajoute un import : résolu", () => {
const input = [
`<<<<<<< ours`, `import x from "b";`,
`||||||| base`, `import { x } from "b";`,
`=======`, `import { x } from "b";`, `import C from "c";`,
`>>>>>>> theirs`, `export const y = 1;`,
].join("\n");
const result = resolve(input, "app.ts");
expect(result.stats.autoResolved).toBe(1);
expect(result.mergedContent).toContain(`import x from "b";`);
expect(result.mergedContent).toContain(`import C from "c";`);
expect(result.mergedContent).not.toContain(`import { x } from "b";`);
});

it("les deux côtés changent la forme différemment : toujours complex", () => {
const input = [
`<<<<<<< ours`, `import * as x from "b";`,
`||||||| base`, `import { x } from "b";`,
`=======`, `import x from "b";`,
`>>>>>>> theirs`, `export const y = 1;`,
].join("\n");
const result = resolve(input, "app.ts");
expect(result.stats.autoResolved).toBe(0);
expect(result.hunks[0].type).toBe("complex");
});
});

// ─── Formes différentes sans nom commun : pas un conflit ─────────────────────
// `import React from "react"` et `import { useState } from "react"` coexistent.
// Seule une collision de nom local (ou une liaison illisible, `require`) est un
// vrai conflit ; sinon le moteur textuel garde les deux lignes, comme sur main.

describe("imports — même module, formes différentes, noms distincts", () => {
const hunk = (ours: string[], base: string[], theirs: string[]) => [
`<<<<<<< ours`, ...ours, `||||||| base`, ...base, `=======`, ...theirs, `>>>>>>> theirs`, `export const z = 1;`,
].join("\n");

it("default d'un côté, named de l'autre : les deux gardés", () => {
const result = resolve(hunk(
[`import a from "a";`, `import React from "react";`],
[`import a from "a";`],
[`import a from "a";`, `import { useState } from "react";`],
), "app.ts");
expect(result.stats.autoResolved).toBe(1);
expect(result.mergedContent).toContain(`import React from "react";`);
expect(result.mergedContent).toContain(`import { useState } from "react";`);
});

it("named modifié d'un côté, default ajouté de l'autre : les deux gardés", () => {
const result = resolve(hunk(
[`import { a, b } from "m";`],
[`import { a } from "m";`],
[`import { a } from "m";`, `import M from "m";`],
), "app.ts");
expect(result.stats.autoResolved).toBe(1);
expect(result.mergedContent).toContain(`import { a, b } from "m";`);
expect(result.mergedContent).toContain(`import M from "m";`);
});

it("alias en collision (`{ x as useAuth }` vs `useAuth`) : complex", () => {
const result = resolve(hunk(
[`import { x as useAuth } from "./auth";`], [], [`import useAuth from "./auth";`],
), "app.ts");
expect(result.stats.autoResolved).toBe(0);
expect(result.hunks[0].type).toBe("complex");
});

it("require contre import du même module : complex (liaison du require non lue)", () => {
const result = resolve(hunk(
[`const useAuth = require("./auth");`], [], [`import useAuth from "./auth";`],
), "app.ts");
expect(result.stats.autoResolved).toBe(0);
expect(result.hunks[0].type).toBe("complex");
});

it("même import de base modifié des deux côtés : le moteur textuel garde la main", () => {
const result = resolve(hunk(
[`import B from "./a";`], [`import A from "./a";`], [`import A from "./a.js";`],
), "app.ts");
expect(result.hunks[0].type).not.toBe("complex");
expect(result.mergedContent ?? "").not.toMatch(/import B from "\.\/a";\nimport A/);
});
});
6 changes: 6 additions & 0 deletions packages/core/src/resolver/format-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export type FormatDispatchResult =
| { 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 }
/** Le résolveur a établi un vrai conflit sémantique — ne pas passer au moteur textuel. */
| { status: "conflict"; reason: string; resolverUsed: string }
/** Aucun résolveur format-aware n'a traité ce hunk — continuer vers le moteur textuel. */
| { status: "not-applicable"; note: string };

Expand All @@ -43,6 +45,10 @@ export function dispatchFormatAware(
return { status: "not-applicable", note: "" };
}

if (formatResult.lines === null && formatResult.conflict) {
return { status: "conflict", reason: formatResult.reason, resolverUsed: formatResult.resolverUsed };
}

if (formatResult.lines === null) {
// Le résolveur spécialisé a échoué — pas de résolution, mais on garde
// le reason pour l'annoter dans le refus final du moteur textuel.
Expand Down
Loading
Loading