Skip to content

fix(core): stop keeping both sides of an import or Dockerfile conflict - #278

Merged
devlint merged 6 commits into
mainfrom
fix/core-import-kinds-dockerfile-from
Nov 3, 2026
Merged

devlint merged 6 commits into
mainfrom
fix/core-import-kinds-dockerfile-from

Conversation

@devlint

@devlint devlint commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

Two engine bugs found while checking the examples of the trivial-conflicts taxonomy article, plus a third one in the same code path.

Imports: same module in two forms kept both lines

<<<<<<< ours
import React from "react";
import { useAuth } from "./auth";
||||||| base
import React from "react";
=======
import React from "react";
import useAuth from "./auth";
>>>>>>> theirs

The imports resolver correctly found the conflict (incompatible kinds (named vs default)), but a null from a format-aware resolver only meant "not applicable", so the hunk went on to the textual engine. The textual engine classified it insertion_at_boundary and applied it, keeping both lines: useAuth was declared twice.

Fix: ImportMergeResult and FormatResolveResult get a conflict flag, set on the resolver's semantic-conflict returns (and not on "the block is not imports" or "line cannot be parsed", which still fall through). dispatchFormatAware returns a new conflict status, and resolveHunk reclassifies the hunk complex (confidence and explanation from the complex pattern, the resolver's reason added to the trace) instead of running the textual engine.

Dockerfile: stages paired by image, or by a random key

getStageKey was alias ?? image ?? random. So:

  • FROM node:20-alpine vs FROM node:22-alpine (no alias) did not pair → both stages kept → two FROM lines.
  • A hunk with no FROM (e.g. ENV PORT=3000 vs ENV PORT=4000) got a random key on each side → nothing paired → both ENV PORT lines kept. This is the most common shape and was not in the original report.

Fix: stages are paired by alias when they have one, otherwise by position among unaliased stages. If the two sides do not have the same number of unaliased stages (stage added on one side, alias added on one side only), the resolver declines and the textual engine takes over.

Tests

  • 6 Dockerfile regression tests (FROM-only hunk, FROM + instructions, two unaliased stages, hunk with no FROM, alias on one side only, different stage counts).
  • 5 imports regression tests (both directions, classified complex, reason, a non-import block still falls through).
  • Core suite: 78 files, 1305 tests passing (golden funnel unchanged).

Follow-up

The blog article website/blog/trivial-conflicts-taxonomy.md describes the import case as a known gap "on the list to fix". Update it when this ships.

Review fixes

A review found hunks that main auto-resolved correctly but this PR turned complex or merged badly. Fixed in this PR, with regression tests:

  • Imports: conflict: true now needs a shared local name ({ useAuth } vs useAuth, { x as useAuth } vs useAuth) or a require (binding not parsed). import React + import { useState } from the same module are both kept again. A one-sided form change ({ x } → x, other side kept the base import) is not a conflict. When both sides modify the same base import, the flag is dropped: the textual engine cannot build a union there, and the token-level proposal is offered again.
  • Dockerfile: mergeStage now uses the matching base stage, so an instruction changed on one side only keeps that change. Before, it was replaced by the other side's base value, which position pairing made common. The resolver now declines when ours or theirs repeats an instruction key (several RUN, COPY to the same destination, ENV PATH): the textual engine handles those. It used to lose or duplicate one. It also declines on lines that are not instructions (heredoc bodies, \\ continuations started before the hunk). MAINTAINER is a known instruction, and \\ followed by spaces or \\r continues the line.

Core suite: 1322 tests passing.

Known gaps, unchanged from main, follow-up: one-sided deletions inside a Dockerfile stage come back; COMMENT lines share one key. In imports, oursBySource keeps only the last import per module, so { a, X } + type { T } vs import X still keeps both.

Imports: when both sides import the same module in incompatible forms
(named vs default), the imports resolver declined, and the textual engine
then applied the hunk as insertion_at_boundary, keeping both lines. The
resolver now flags such declines as a genuine conflict, and the
orchestrator reclassifies the hunk complex instead of falling through.

Dockerfile: stages were paired by alias, else by image, else by a random
key. A FROM changed on both sides without an alias therefore kept both
stages, and a hunk with no FROM matched nothing, duplicating e.g. an ENV
changed on both sides. Unaliased stages are now paired by position, and
the resolver declines when the two sides have different counts of them.
The same-module/different-form check fired when the other side still had
the base import untouched (`{ x }` → `x` on one side only). Since #278
stops sending import conflicts to the textual engine, such hunks became
complex instead of resolving as before.
…ockerfile stages against base

Review of #278 found hunks that main auto-resolves but the PR turned complex:
- default and named imports of the same module added on each side
  (`import React` + `import { useState }`) are valid together; only a
  shared local name, or a require whose binding is not parsed, is a conflict;
- when both sides modify the same base import, the textual engine cannot build
  a union, so the conflict flag is dropped there (restores whitespace_only and
  the token-level proposal).

Dockerfile: mergeStage ignored base and preferred theirs, so with stages now
paired by position a change made on ours only (image bump, ENV) was silently
replaced. The matching base stage now settles instructions changed on one side.
…instruction lines

mergeStage indexes theirs by kind:key, so a second RUN (or COPY to the same
destination) replaced the first, and the base rule then emitted only the last
one: `RUN a` was lost when theirs added `RUN b`. A repeated key on theirs now
falls back to the textual engine, which merges it correctly as on main. So do
hunks containing lines that are not instructions (a \\ continuation started
before the hunk, a heredoc body), whose keys are meaningless.
With a repeated key on ours (two RUN, two ENV PATH, two EXPOSE), the same
theirs instruction was matched twice: `RUN npm run build` came out twice, or
a value was lost (already the case on main for ENV/EXPOSE/LABEL). Such hunks
now go to the textual engine.

Also: MAINTAINER is a known instruction, and a `\\` followed by spaces or
`\\r` (CRLF) continues the line, so these no longer trip the non-instruction
guard.
@devlint
devlint merged commit 6877cd5 into main Nov 3, 2026
10 checks passed
@devlint
devlint deleted the fix/core-import-kinds-dockerfile-from branch November 3, 2026 08:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant