@gitkraken/conflict-tools 0.2.4-beta.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -6
- package/README.md +262 -55
- package/dist/errors/conflict.d.ts +1 -1
- package/dist/git/port-dispatch.js +2 -3
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/ports/git.d.ts +4 -0
- package/dist/ports/parser.d.ts +27 -6
- package/dist/ports/validator.d.ts +29 -0
- package/dist/ports/validator.js +1 -0
- package/dist/reference-integrity/detect.d.ts +39 -4
- package/dist/reference-integrity/detect.js +2 -2
- package/dist/reference-integrity/snapshot.d.ts +18 -5
- package/dist/reference-integrity/snapshot.js +1 -1
- package/dist/resolution/detect-code-loss.d.ts +23 -0
- package/dist/resolution/detect-code-loss.js +1 -0
- package/dist/resolver/deps.d.ts +1 -1
- package/dist/resolver/fix-loop.d.ts +67 -0
- package/dist/resolver/fix-loop.js +2 -0
- package/dist/resolver/model-budget.d.ts +26 -0
- package/dist/resolver/model-budget.js +1 -0
- package/dist/resolver/prompt.d.ts +8 -2
- package/dist/resolver/prompt.js +5 -3
- package/dist/resolver/refine-pass.d.ts +8 -0
- package/dist/resolver/refine-pass.js +1 -1
- package/dist/resolver/resolve-conflict.d.ts +7 -1
- package/dist/resolver/resolve-conflict.js +1 -1
- package/dist/resolver/resolve-conflicts.js +1 -1
- package/dist/resolver/summarize-operation.js +1 -1
- package/dist/resolver/validate.js +1 -1
- package/dist/types/events.d.ts +30 -3
- package/dist/types/findings.d.ts +25 -15
- package/dist/types/resolution.d.ts +11 -0
- package/dist/types/step.d.ts +27 -0
- package/dist/types/summary.d.ts +17 -10
- package/dist/validation/findings.d.ts +9 -0
- package/dist/validation/findings.js +1 -0
- package/dist/validation/markers.d.ts +11 -0
- package/dist/{ports/verify.js → validation/markers.js} +1 -1
- package/package.json +1 -1
- package/dist/ports/verify.d.ts +0 -30
package/CHANGELOG.md
CHANGED
|
@@ -7,22 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-09-09
|
|
11
|
+
|
|
10
12
|
### Added
|
|
11
13
|
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
+
- `StepConfig.maxDetectionScopeFiles` — override the reference-integrity scope cap (default 400). Beyond the cap the scope is `truncated`, which holds the verdict off `clean`; raising it lets a large merge stay on the `--fix` auto-apply path. Threaded through `DetectDeps`/`SnapshotDeps` as `maxScopeFiles`.
|
|
15
|
+
- `StepConfig.maxModelCalls` — an optional global model-call budget for the whole operation. Enforced per model call (the refine pass checks it before every step), not merely per pass. When set and the budget is spent, the opt-in fix phase stops attempting further fixes and leaves the remainder in the findings (so the verdict still reflects the true post-fix state), emitting a new `fix:budget-exhausted` progress event (`{ spent, limit }`). When unset, the fix phase is not budget-limited.
|
|
16
|
+
- `fix:failed` progress event (`{ filePath?, reason }`) — emitted when the opt-in fix phase throws a non-abort error, so a consumer can observe the graceful degradation rather than only seeing the checks flip to `errored`. `filePath` is set when one file failed and the phase carried on without it; absent when the whole phase failed.
|
|
17
|
+
- `ParserPort.capabilities` — a backend declares what it can resolve (currently `crossFileResolution`). A backend that cannot resolve references across files reports every import as unresolved on both parents *and* the result, so no cross-file break can ever surface; detection now reports `partial-no-cross-file-resolution` for that instead of letting the empty result pass as a clean cross-file check. Omitted means the conservative reading (no cross-file resolution).
|
|
18
|
+
- `ParsedFile.supported` — separates "the backend has no grammar for this file type" (a coverage gap, skip it) from "the backend handles this type but the content did not parse" (a finding, report it in `syntaxErrors`). `language: 'unsupported'` previously meant both, so a merged result the parser choked on contributed zero findings and still read as clean.
|
|
19
|
+
- Cross-file fixes in the fix phase (opt-in `StepConfig.fix`). After per-file convergence, a project-tier broken reference (an import/cross-file use whose target stopped resolving) is routed to the refine of its **use-site** file: the finding becomes a fix instruction, and the whole set is re-detected, up to `crossFileMaxIterations` rounds (default 2). The refine is seeded with the **defining file's current definitions** (names, kind, and line — a token-cheap list, never the file's text) so it can recognize a renamed, moved, or re-signatured symbol behind the broken use from the use site alone, even though the refine tool cannot read other files. Each cross-file fix is a single refine pass plus a mandatory per-file re-validation; the final whole-set detection also re-checks per-file for files a cross-file fix touched. **Routing:** a use-site file is routed when it has a write channel — an AI resolution, or an AI-unanimous whole-side take (`take-ours`/`take-theirs`), which relabels to `ai` when the fix edit lands so the edited content is actually written. A deliberate rule/default take (`deliberateTake`) and an emergency `fallback` take are **not** routed — the discard was intentional or a last resort — and an untouched auto-merged file (no resolution to carry the edit) is reported but not auto-fixed yet (needs a patches channel). The definition side, if broken, arrives as its own finding on its own file. Report-only (fix off) is unchanged.
|
|
20
|
+
- `Finding.definitionFile` — for a cross-file `broken-reference`, the file the parser reported as the pre-merge target of the broken use. Present only when the backend fills reference `target`s; absent otherwise (the fix phase then routes the use-site file without the defining-file hint). The cross-file refine reads it to seed the refine with that file's current definitions.
|
|
21
|
+
- `Resolution.deliberateTake` — marks a `take-ours`/`take-theirs` produced by a rule or the default strategy (a deliberate whole-side choice) rather than by an AI resolution that happened to pick one side unanimously. Lets the fix phase route AI-unanimous takes for cross-file fixing while leaving deliberate discards — and emergency `fallback` takes — untouched.
|
|
22
|
+
- `Resolution.fallback` — marks the emergency whole-side take the resolver applies when every other path failed, so an accidental wholesale loss is identified by an explicit flag rather than by `confidence === 0` (a legal model output). It drives the `dropped-side` finding and keeps the emergency take out of cross-file fix routing.
|
|
23
|
+
- Per-file fix loop (opt-in via `StepConfig.fix`, default `false`). When enabled, an AI-resolved (`strategy: 'ai'`) text file that carries error-severity per-file findings is routed back through refine: the findings become fix instructions, the file is re-analyzed after each edit, and the loop repeats until the file is clean, a round makes no progress, or `fixLoopMaxIterations` (default 3) is reached. Whatever it cannot fix stays in the findings, so the verdict reflects the post-fix state. The no-markers invariant holds (a fix that reintroduces markers is discarded). New progress events `fix:started` / `fix:completed`. Report-only (fix off) is unchanged. Deferred for the opt-in fix path: rebase per-step-ref/transient discipline, parser/validator call caching, and a full finding-lifecycle model (double-counting is handled here by fingerprint dedup when folding findings into the report). `fix:completed` is emitted even for a file with no findings to fix (`iterations: 0`, `remainingFindings: 0`), so "the fix loop evaluated this file and it was clean" stays distinguishable from "the fix loop never reached this file". Without `refs` the per-file stage still runs on what needs no parent content (syntax errors, validator findings); only cross-file routing, which is driven by the whole-set before/after report, is skipped.
|
|
24
|
+
- Reference-integrity detection. `resolveConflicts` now attaches a `StepResult.detection` report listing broken references (symbols that resolved in a pre-merge parent but no longer resolve in the merged result, with provenance), duplicate definitions, and syntax errors in the resolved files. Detection is opt-in via a new `ParserPort` (`ResolverDeps.parser`) that supplies parsing/symbol resolution per surface (tree-sitter, language server); when it is absent, unavailable, or a file's language is unsupported, the report says so rather than implying a clean result. Detection itself is report-only; fixing is opt-in via the fix phase (above) and off by default. `ParserPort.analyze` is called serially — never for the two parents and the result concurrently — so a single-instance backend (a language server) is never asked to hold three overlays of the same paths at once. `DetectionReport.scope` reports `inScope`, `readable` (parents actually loaded), `incomingDiscovered`, and `truncated`; when nothing in scope could be read, `referenceIntegrity` reports `skipped-unreadable-source` rather than empty findings that would read as clean.
|
|
25
|
+
- Auto-merged scope for reference-integrity detection: the detector discovers the merge's incoming/auto-merged set itself (new optional `changedFiles` git-port op; `git diff --name-only base..theirs`, base from `refs.base` or `mergeBase`) and includes it in scope, so dangling references in cleanly-merged files are caught. Scope is capped and reported via `DetectionReport.scope`; an optional `ResolutionContext.touchedFiles` overrides discovery. Adds a `dropped-side` finding when the resolver's emergency fallback took one side wholesale and discarded a substantial diff from the other; a deliberate rule/default take is not flagged.
|
|
14
26
|
- `ResolutionContext.detectionRefs` — optional parent tips for reference-integrity detection's before/after comparison, separate from `refs` (which drives the resolver's three-way diff). Detection uses `detectionRefs ?? refs`. Needed when the resolver's refs are the in-flight per-step state (e.g. a rebase) while detection needs the two stable pre-merge branch tips; in a merge they coincide and it can be left unset.
|
|
15
27
|
- Binding-aware broken-reference detection. `ParsedFile` gains an optional `bindings` (names bound at any scope, module-level and nested), and reference-integrity now treats a name as "resolvable before the merge" if a parent resolved it as a reference **or** defined/bound it. This catches a reference broken by removing a nested binding (e.g. a `const` used by a closure elsewhere in the same file): the dangling use was never a resolved reference — because it was locally bound — so the before/after reference diff alone could not see the regression. Backends that only surface top-level `definitions` can omit `bindings`; they simply do not gain the nested-binding case.
|
|
16
|
-
- Operation-level assessment: `buildOperationDigest`/`summarizeOperation` now fold `StepResult.detection` into the rollup. A file with
|
|
28
|
+
- Operation-level assessment: `buildOperationDigest`/`summarizeOperation` now fold `StepResult.detection` into the rollup. A file with any finding is added to `needsReview` (`reason: 'finding'`) regardless of confidence — even a file with no resolution of its own; an error finding (broken-reference, syntax-error, dropped-side) drives `verdict: 'blocked'`, while a warning finding (duplicate-definition, consumer-validator warnings) drives `needs-review`; `OperationDigest.detectionComplete` reports whether every detection check ran (so empty findings on a skipped check are not read as clean); and a derived `OperationDigest.verdict` (`'clean' | 'needs-review' | 'blocked'`) gives consumers an honest gate signal. `ReviewItem` gains `reason` and optional `findingCategories`, and its `confidence` is now optional. A check counts as complete when it ran, including when some scope files were an unsupported language (they carry no references to break); it is incomplete when the check could not run at all — no parser, no refs, an unreadable parent snapshot, a thrown parser — or when the scope was not fully covered (`scope.truncated`, or an incoming set the git port could not enumerate).
|
|
29
|
+
- `detectFallbackCodeLoss(conflict, strategy)` and the `CodeLossDetection` type (carrying `discardedSide`), exported from the package root.
|
|
30
|
+
- `StepConfig.allowLossyFallback?: boolean` escape hatch.
|
|
31
|
+
- `ConflictError` code `CODE_LOSS_RISK`.
|
|
17
32
|
|
|
18
33
|
### Changed
|
|
19
34
|
|
|
20
|
-
- **Breaking
|
|
21
|
-
-
|
|
35
|
+
- **Breaking for `fallbackStrategy` users:** a wholesale `take-ours`/`take-theirs` fallback (taken when the AI resolver fails) is no longer applied to text conflicts. The file is recorded as a `CODE_LOSS_RISK` error with a `resolution:failed` event instead of completing silently, so the consumer stops for manual resolution. The fallback is applied with `git checkout --ours/--theirs`, which replaces the whole file with one stage and discards the other side plus anything git auto-merged outside the conflict markers; on rebase "ours" is everything integrated so far, so a blind take-theirs could wipe it (AGP-430). Only a delete-modify conflict resolved toward the side that keeps content still applies the fallback. Set `StepConfig.allowLossyFallback: true` to opt back into the previous behavior. Deliberate `rules` (e.g. `take-theirs` for lock files) are unaffected.
|
|
36
|
+
- Reference-integrity findings are now tiered by locality: an intra-file break — a local definition/binding the merge dropped while a use of it remains in the same file (e.g. a `const` used by a closure below) — is a `tier: 'file'` finding; a broken cross-file reference (an import whose target stopped resolving) stays `tier: 'project'`. Classification uses the reference's `target` file when the parser supplies it, else whether the name was a local definition/binding in the parent. This sets up the per-file vs cross-file fix loops.
|
|
37
|
+
- **Breaking**: consumer validation moved to a `Validator` port — `validate(sources: SourceOverlay): Promise<ValidatorFinding[]>` (replaces the per-file `ResolutionVerifier.verify`). It runs post-refine, non-blocking, over an overlay; its findings fold into the operation's detection findings and verdict. The overlay is currently always a single file — `SourceOverlay` is the parameter type so a whole-set call can be added without a further breaking change, but no such call site exists yet. `Resolution.findings` replaces `Resolution.issues`. `Finding.category` (and `ValidatorFinding.category`) is now `FindingCategory | (string & {})` — open to consumer categories while keeping the package's own values discoverable and autocompleting, matching the existing `UnmergedReason` precedent. Only validator findings are persisted on `Resolution.findings`; parser findings come solely from `detect()`, the authoritative whole-set pass.
|
|
38
|
+
- **Breaking**: the resolver's in-loop blocking check is now **residual conflict markers only** (built-in, language-agnostic). Consumer validation (the `Validator` port, above) no longer gates the resolve loop or triggers re-resolution — it runs once **after** refine, **non-blocking**, and its findings are folded into the report (advisory). This stops the resolver from burning its step budget on non-marker validation (which was exhausting into a whole-file `take-theirs` fallback), and puts consumer validation in its intended post-refine, report-only role. `ResolverLoopResult` no longer carries `issues`.
|
|
39
|
+
|
|
40
|
+
### Removed
|
|
41
|
+
|
|
42
|
+
- **Breaking**: `ResolutionVerifier` — replaced by the `Validator` port (see Changed). `defaultVerifier` and `VerificationResult` are no longer exported either; `checkResidualMarkers` stays internal as the built-in marker gate.
|
|
43
|
+
- **Breaking**: the `Issue` type is no longer exported from the package root. It only ever described the internal marker-gate result, which is not part of the public surface now that `VerificationResult` is unexported.
|
|
44
|
+
- **Breaking**: the `verification:issues` progress event. Consumer validation no longer runs inside the resolve loop, so the event had no emitter; validator output arrives as `Finding`s on the detection report, and a throwing validator is reported via `validation:failed`.
|
|
22
45
|
|
|
23
46
|
### Fixed
|
|
24
47
|
|
|
25
|
-
-
|
|
48
|
+
- A non-abort error in the refine pass no longer destroys the pre-refine resolution. Previously the refine `catch` rethrew, bubbling to `resolveOne` and degrading a good resolve-stage result into a take-side fallback (confidence 0) — the same class of failure as a throwing validator, one stage later. The pre-refine resolution is now kept, its confidence capped (the enhancement did not complete), and the error reported via `refine:failed`. A user abort still cancels (rethrown past the fallback).
|
|
49
|
+
- A throwing consumer validator no longer destroys a good resolution. The post-refine `validator.validate()` call is now guarded: a validator error is reported via a new `validation:failed` progress event and the resolution is kept intact, instead of bubbling to `resolveOne` and degrading a successful AI resolve into a whole-file take-side fallback (confidence 0) — the exact dropped-side loss the tool guards against. The validator stays advisory.
|
|
50
|
+
- A user abort mid-resolve now cancels the operation instead of degrading into a take-side fallback. `resolveOne` rethrows when `signal.aborted` (covering aborts raised from the resolve loop, refine, or the validator), bypassing the emergency fallback rather than turning a cancellation into a whole-file take-one-side resolution.
|
|
51
|
+
- The refine pass no longer floods its bounded step budget when the model spirals. After a run of consecutive `edit_file_lines` apply errors (a model repeatedly proposing ops that never apply), the pass bails early — keeping whatever edits landed — and emits a new `refine:bailed` progress event, instead of executing failing tool calls until the step budget is exhausted.
|
|
26
52
|
|
|
27
53
|
## [0.2.3] - 2026-07-06
|
|
28
54
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @gitkraken/conflict-tools
|
|
2
2
|
|
|
3
|
-
AI-powered git conflict resolution. Parses conflict markers (including delete-modify conflicts), resolves them with an agentic AI loop that has access to git context (blame, grep, diff, log, show, file content), and
|
|
3
|
+
AI-powered git conflict resolution. Parses conflict markers (including delete-modify conflicts), resolves them with an agentic AI loop that has access to git context (blame, grep, diff, log, show, file content), refines the result with targeted intra-file edits, then runs deterministic reference-integrity detection over the whole touched set and produces an honest verdict. Results are written back to the working tree by the consumer.
|
|
4
4
|
|
|
5
5
|
## What it does
|
|
6
6
|
|
|
@@ -8,9 +8,9 @@ Three entry points, each serving a different consumer need:
|
|
|
8
8
|
|
|
9
9
|
- `extractConflict()` — parses conflict markers from a single file and returns a structured representation. Handles both text conflicts (with markers) and delete-modify conflicts (one side deleted, the other modified). No AI, no resolution.
|
|
10
10
|
|
|
11
|
-
- `resolveConflict()` — takes one `Conflict` and an optional `ResolutionContext`, runs the AI tool-use loop, returns a `Resolution` with resolved content, confidence, and metrics. The single-file workhorse.
|
|
11
|
+
- `resolveConflict()` — takes one `Conflict` and an optional `ResolutionContext`, runs the AI tool-use loop and per-file refine, returns a `Resolution` with resolved content, confidence, and metrics. The single-file workhorse.
|
|
12
12
|
|
|
13
|
-
- `resolveConflicts() + applyResolutions()` — batch-resolve every currently unmerged file in the working tree, then write and stage results. Supports pattern-based routing: skip files, apply take-ours/take-theirs, or route to AI per glob pattern. The library does NOT orchestrate `git rebase`/`merge`/`cherry-pick` — the consumer drives the operation lifecycle.
|
|
13
|
+
- `resolveConflicts() + applyResolutions()` — batch-resolve every currently unmerged file in the working tree, run set-level detection, then write and stage results. Supports pattern-based routing: skip files, apply take-ours/take-theirs, or route to AI per glob pattern. The library does NOT orchestrate `git rebase`/`merge`/`cherry-pick` — the consumer drives the operation lifecycle.
|
|
14
14
|
|
|
15
15
|
The library never calls `git` or an AI provider directly. Consumers supply adapters via ports.
|
|
16
16
|
|
|
@@ -20,6 +20,16 @@ The library never calls `git` or an AI provider directly. Consumers supply adapt
|
|
|
20
20
|
pnpm add @gitkraken/conflict-tools
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
## Pipeline
|
|
24
|
+
|
|
25
|
+
An operation runs in three stages. **Report-only is the default**, and the stage shape is identical whether or not the opt-in fix phase is enabled — the fix phase only adds edit attempts, it does not change the flow.
|
|
26
|
+
|
|
27
|
+
1. **Resolve** — the agentic AI tool-use loop replaces conflict markers, self-assessing a confidence per marker. The *only* blocking gate is residual conflict markers (checked built-in, language-agnostic): a candidate that still contains markers is rejected and retried. No other check gates this stage — parser, validator, and detection are all downstream and advisory.
|
|
28
|
+
2. **Refine** (per file) — when the resolver returns `followUpInstructions` (targeted edits it could not express as a whole-chunk decision), a second agentic loop applies them via the `edit_file_lines` tool. A refine that reintroduces markers is discarded; a refined file's confidence is capped at 0.9.
|
|
29
|
+
3. **Detection** (set level) — after every file is resolved, deterministic reference-integrity detection runs over the whole touched set: per-file structural checks (syntax errors, duplicate definitions, intra-file broken references) on the resolved *and* the auto-merged/incoming files, plus cross-file broken-reference checks. Report-only — it never mutates state or fails the operation. It produces the `DetectionReport` attached to `StepResult.detection`, which drives the verdict.
|
|
30
|
+
|
|
31
|
+
The opt-in **fix phase** (`config.fix: true`) sits between refine and the final detection report: it re-detects, routes the error-severity findings back through refine (per-file convergence, then cross-file routing), and only then emits the final report. What it cannot fix stays in the findings, so the verdict always reflects the post-fix state. See [Fix phase](#fix-phase-opt-in).
|
|
32
|
+
|
|
23
33
|
## Ports
|
|
24
34
|
|
|
25
35
|
### `ConflictGitPort`
|
|
@@ -42,6 +52,7 @@ High-level ops the library dispatches through:
|
|
|
42
52
|
| `showFile(ref, path, opts?)` | Read file content at a specific ref. Supports `startLine`/`endLine` |
|
|
43
53
|
| `unmergedEntries()` | List files git considers unresolved |
|
|
44
54
|
| `mergeBase(a, b)` | Common ancestor between two refs |
|
|
55
|
+
| `changedFiles(from, to)` | Paths changed between two refs (name-only). Used to discover the merge's incoming/auto-merged set for detection |
|
|
45
56
|
| `blame(path, opts?)` | Line-level authorship. Supports `startLine`/`endLine` (maps to `git blame -L`) |
|
|
46
57
|
| `grep(pattern, opts?)` | Content search across the tree. Supports `maxResults` |
|
|
47
58
|
| `diff(from, to, opts?)` | Unified diff between refs, optionally scoped to a path |
|
|
@@ -73,29 +84,68 @@ const model: ConflictModelPort = {
|
|
|
73
84
|
|
|
74
85
|
The shape of `tools`, `toolCalls`, and `toolResults` follows abstract types (`ToolDefinition`, `ToolCall`, `ToolResult`) so consumers can adapt any SDK. The library does not require streaming or any provider-specific feature.
|
|
75
86
|
|
|
76
|
-
### `
|
|
87
|
+
### `ParserPort` (optional — enables detection)
|
|
88
|
+
|
|
89
|
+
A **facts-in** port. Given a content overlay, it returns per-file parse facts — definitions, references (each with a `resolved` flag), optional bindings, and syntax errors. The *package* derives findings from those facts; the port itself never classifies anything as a conflict or a break.
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
import type { ParserPort, SourceOverlay, ParsedFile } from '@gitkraken/conflict-tools';
|
|
93
|
+
|
|
94
|
+
const parser: ParserPort = {
|
|
95
|
+
async analyze(sources: SourceOverlay): Promise<ReadonlyMap<string, ParsedFile>> {
|
|
96
|
+
// tree-sitter (merge-mate) or a language server (GitLens/GKD), overlaying `sources`.
|
|
97
|
+
// Return one ParsedFile per input path.
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Four properties define the contract:
|
|
103
|
+
|
|
104
|
+
- **Content-in overlay, no FS access.** `analyze(sources)` sees exactly the `SourceOverlay` contents (a `ReadonlyMap<path, content>`) and never reads the filesystem itself. This is what lets the package hand it pre-merge parent snapshots *and* the post-merge result and diff the two. A tree-sitter backend sees only these files; a language server may resolve against its workspace with these contents overlaid on top.
|
|
105
|
+
- **Language-agnostic.** A file type the backend has no grammar for comes back `supported: false` with empty facts; the detector skips it (and reports `partial-unsupported-language`) rather than emitting false findings. A file it *does* handle but cannot parse is `supported: true` with the failure in `syntaxErrors` — that is a finding, not a coverage gap, and conflating the two would let a broken merge result read as clean.
|
|
106
|
+
- **Precision-declaring.** A tree-sitter backend and a language-server backend differ in how completely they resolve references, and the port asks them to say so via `capabilities`. A backend without `crossFileResolution` reports every import as unresolved on both parents *and* the result, so no cross-file break can ever surface — detection reports `partial-no-cross-file-resolution` instead of letting that empty result pass as a clean cross-file check.
|
|
107
|
+
- **Never called concurrently.** The package awaits each `analyze` before issuing the next, so a single-instance backend is never asked to hold two versions of the same path at once. Implementations need no internal locking.
|
|
108
|
+
|
|
109
|
+
`ParsedFile`:
|
|
110
|
+
|
|
111
|
+
| Field | Meaning |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `supported` | `false` when the backend has no grammar for this file type at all — the detector skips it |
|
|
114
|
+
| `language` | Backend's language id; meaningful only when `supported` |
|
|
115
|
+
| `definitions` | `SymbolDefinition[]` — top-level defs (`name`, `kind?`, 1-indexed `line`) |
|
|
116
|
+
| `references` | `SymbolReference[]` — `name`, `line`, `column?`, `resolved` (did it resolve within the overlay), `target?` |
|
|
117
|
+
| `bindings?` | Optional names bound at *any* scope (module-level and nested). Populating it lets detection catch a reference broken by removing a nested binding. A backend that only surfaces top-level `definitions` may omit it |
|
|
118
|
+
| `syntaxErrors` | `SyntaxIssue[]` — `line`, `message` |
|
|
119
|
+
|
|
120
|
+
Pass via `deps.parser`. When absent, detection is skipped and the report says so (`skipped-no-parser`) — never a silent clean.
|
|
121
|
+
|
|
122
|
+
### `Validator` (optional)
|
|
123
|
+
|
|
124
|
+
The consumer's validation port. Plug in any checks — syntax, real import resolution, types, lint, project rules. It runs **post-refine over an overlay** and returns `ValidatorFinding[]`.
|
|
77
125
|
|
|
78
|
-
|
|
126
|
+
The overlay is currently **always a single file**: the package validates one file at a time, right after that file's refine. `SourceOverlay` is the parameter type so a whole-set call can be added later without a breaking change, but no such call site exists yet — do not write a validator that only works across files.
|
|
79
127
|
|
|
80
128
|
```typescript
|
|
81
|
-
import {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return
|
|
92
|
-
? { valid: true }
|
|
93
|
-
: { valid: false, feedback: errors.join('\n') };
|
|
129
|
+
import type { Validator, ValidatorFinding } from '@gitkraken/conflict-tools';
|
|
130
|
+
|
|
131
|
+
const validator: Validator = {
|
|
132
|
+
async validate(sources) {
|
|
133
|
+
const findings: ValidatorFinding[] = [];
|
|
134
|
+
for (const [file, content] of sources) {
|
|
135
|
+
for (const err of await typecheck(file, content)) {
|
|
136
|
+
findings.push({ category: 'type-error', severity: 'error', file, line: err.line, message: err.message });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return findings;
|
|
94
140
|
},
|
|
95
141
|
};
|
|
96
142
|
```
|
|
97
143
|
|
|
98
|
-
|
|
144
|
+
**Advisory, never blocking.** Its findings fold into the operation's detection findings and the verdict; a validator finding never triggers a re-resolve, and a *throwing* validator never destroys the resolution — it is kept intact and the failure is reported. On the resolve/report path that report is a `validation:failed` event; inside the opt-in fix phase a validator throw folds into the phase's per-file isolation and surfaces as `fix:failed` (like any per-file throw there), so telemetry that must catch every validator failure should watch both. `ValidatorFinding.category` is an open string (e.g. `'type-error'`, `'lint:no-console'`) so consumer categories flow through alongside the package's own `FindingCategory` values.
|
|
145
|
+
|
|
146
|
+
The one blocking gate — **residual conflict markers** — is *not* this port's job. It is checked built-in and language-agnostic inside the resolve loop. (This was the old `defaultVerifier`; it is now internal. `defaultVerifier`, `ResolutionVerifier`, and `VerificationResult` are no longer exported.)
|
|
147
|
+
|
|
148
|
+
Pass via `deps.validator` to `resolveConflict` or `resolveConflicts`.
|
|
99
149
|
|
|
100
150
|
## Entry points
|
|
101
151
|
|
|
@@ -130,19 +180,25 @@ import { resolveConflict } from '@gitkraken/conflict-tools';
|
|
|
130
180
|
const resolution = await resolveConflict(conflict, context, {
|
|
131
181
|
model,
|
|
132
182
|
git,
|
|
183
|
+
validator, // optional — advisory per-file checks
|
|
133
184
|
config: { maxSteps: 15, temperature: 0 },
|
|
134
185
|
onProgress: (event) => console.log(event),
|
|
135
186
|
});
|
|
136
187
|
|
|
137
188
|
// resolution.content — resolved file content (markers replaced)
|
|
138
189
|
// resolution.strategy — 'ai' | 'take-ours' | 'take-theirs' | 'deleted' | 'skipped'
|
|
139
|
-
// resolution.confidence — 0..1, from the AI's own self-assessment
|
|
190
|
+
// resolution.confidence — 0..1, from the AI's own self-assessment (capped at 0.9 if refined)
|
|
140
191
|
// resolution.description — one-or-two sentence rationale, from the AI
|
|
192
|
+
// resolution.findings? — advisory per-file findings from the validator port (post-refine)
|
|
141
193
|
// resolution.chunks? — per-marker decisions (for observability and validation)
|
|
194
|
+
// resolution.edits? — IntraFileEditOp[] applied during refine (absent if refine did not run)
|
|
142
195
|
// resolution.metrics? — { inputTokens, outputTokens, stepCount, toolCallCount, ... }
|
|
196
|
+
// resolution.rawSignals? — policy-free measurements for the consumer's own assessment
|
|
143
197
|
```
|
|
144
198
|
|
|
145
|
-
`
|
|
199
|
+
`resolveConflict` runs stages 1–2 (resolve + refine) for a single file. Set-level detection (stage 3) is only run by the batch entry point, since it needs the whole touched set; use `detect()` directly if you want detection for a single-file flow.
|
|
200
|
+
|
|
201
|
+
`ResolutionContext` carries optional hints the AI can use, plus the refs detection compares against:
|
|
146
202
|
|
|
147
203
|
```typescript
|
|
148
204
|
interface ResolutionContext {
|
|
@@ -150,21 +206,25 @@ interface ResolutionContext {
|
|
|
150
206
|
prDescription?: string;
|
|
151
207
|
previousResolutions?: Resolution[];
|
|
152
208
|
fileNeighbors?: string[];
|
|
209
|
+
/** Auto-merged files beyond the conflict set. Usually unset — detection discovers this from refs. */
|
|
210
|
+
touchedFiles?: string[];
|
|
153
211
|
refs?: { ours: string; theirs: string; base?: string };
|
|
212
|
+
/** Parent tips for detection's before/after comparison, when they differ from `refs`. */
|
|
213
|
+
detectionRefs?: { ours: string; theirs: string; base?: string };
|
|
154
214
|
threeWayDiff?: { oursDiff: string; theirsDiff: string };
|
|
215
|
+
/** Whole-file free-text guidance, rendered as a top-level <user-guidance> block. */
|
|
216
|
+
userGuidance?: string;
|
|
155
217
|
metadata?: Record<string, unknown>;
|
|
156
218
|
}
|
|
157
219
|
```
|
|
158
220
|
|
|
159
221
|
When `refs` is provided, the library computes `git diff base..ours -- file` and `git diff base..theirs -- file` automatically and includes both in the prompt. This typically lets the AI resolve without any tool calls. Consumers that already have these diffs cached can pass `threeWayDiff` directly to skip the computation.
|
|
160
222
|
|
|
161
|
-
|
|
223
|
+
`refs` drives the resolver's three-way diff; `detectionRefs` (falling back to `refs`) drives detection's before/after comparison. They differ in a rebase — where `refs` is the in-flight per-step state while detection needs the two stable pre-merge branch tips — and coincide in a plain merge, so `detectionRefs` is usually left unset.
|
|
162
224
|
|
|
163
|
-
|
|
225
|
+
#### Refine edits
|
|
164
226
|
|
|
165
|
-
`ResolverConfig.refineMaxSteps
|
|
166
|
-
|
|
167
|
-
`Resolution.edits?: IntraFileEditOp[]` carries the list of edit operations that were successfully applied during Phase 2. Absent when Phase 2 did not run or all edits were discarded.
|
|
227
|
+
The refine pass (stage 2) applies edits the resolver could not express as whole-chunk decisions — for example, re-applying a partial change from the incoming branch. `ResolverConfig.refineMaxSteps` (default 5) bounds its model turns. `Resolution.edits?: IntraFileEditOp[]` lists the operations that survived (absent when refine did not run or all edits were discarded).
|
|
168
228
|
|
|
169
229
|
`IntraFileEditOp` is a discriminated union on `kind`:
|
|
170
230
|
|
|
@@ -176,12 +236,14 @@ After Phase 1 resolves conflict markers, the AI may return `followUpInstructions
|
|
|
176
236
|
### `resolveConflicts(deps, context?) + applyResolutions(resolutions, deps)` — batch
|
|
177
237
|
|
|
178
238
|
```typescript
|
|
179
|
-
import { resolveConflicts, applyResolutions } from '@gitkraken/conflict-tools';
|
|
239
|
+
import { resolveConflicts, applyResolutions, summarizeOperation } from '@gitkraken/conflict-tools';
|
|
180
240
|
|
|
181
241
|
const step = await resolveConflicts(
|
|
182
242
|
{
|
|
183
243
|
model,
|
|
184
244
|
git,
|
|
245
|
+
parser, // optional — supply to enable set-level detection (stage 3)
|
|
246
|
+
validator, // optional — advisory per-file checks
|
|
185
247
|
config: {
|
|
186
248
|
rules: [
|
|
187
249
|
{ match: ['*.lock', 'pnpm-lock.yaml'], strategy: 'take-theirs' },
|
|
@@ -200,8 +262,15 @@ const step = await resolveConflicts(
|
|
|
200
262
|
// step.resolutions[] — successful resolutions
|
|
201
263
|
// step.errors[] — { filePath, error } for files that failed (no fallback or AI exhausted)
|
|
202
264
|
// step.skipped?[] — { filePath, reason } for files that were excluded or had no markers
|
|
265
|
+
// step.detection — DetectionReport over the whole touched set; ALWAYS present. Read `checks`
|
|
266
|
+
// (or digest.detectionComplete) to tell whether detection actually ran —
|
|
267
|
+
// without a parser it is a present, empty report with `skipped-no-parser`.
|
|
203
268
|
|
|
204
|
-
|
|
269
|
+
// Gate on the verdict, not on raw confidence — see below.
|
|
270
|
+
const { digest } = await summarizeOperation(step);
|
|
271
|
+
if (digest.verdict === 'clean') {
|
|
272
|
+
await applyResolutions(step.resolutions, { git });
|
|
273
|
+
}
|
|
205
274
|
// Resolutions are written via writeFile, checkoutFile, or removeFile and staged.
|
|
206
275
|
```
|
|
207
276
|
|
|
@@ -209,7 +278,9 @@ await applyResolutions(step.resolutions, { git });
|
|
|
209
278
|
|
|
210
279
|
- `rules` — array of `FileRule` objects. Each rule has a `match` (glob pattern or array of patterns) and a `strategy`. First matching rule wins. Patterns without `/` match on basename; patterns with `/` match on full path.
|
|
211
280
|
- `defaultStrategy` — strategy for files not matching any rule. Defaults to `'ai'`.
|
|
212
|
-
- `
|
|
281
|
+
- `fix`, `fixLoopMaxIterations`, `crossFileMaxIterations`, `maxModelCalls` — the fix phase and its bounds. See [Fix phase](#fix-phase-opt-in). All four are batch-only because the fix phase is what enforces them.
|
|
282
|
+
- `fallbackStrategy` — when AI fails for a file, fall back to `'take-ours'` or `'take-theirs'` instead of recording an error. **Guarded against code loss, and the guard stops every text conflict.** The fallback is applied with `git checkout --ours/--theirs`, which replaces the whole file with one stage, so it discards the other side plus anything git auto-merged outside the conflict markers. On rebase "ours" is everything integrated so far, so a blind take-theirs can wipe it. The file is recorded as a `CODE_LOSS_RISK` error with a `resolution:failed` event instead of completing silently. The one case that still applies is a delete-modify conflict resolved toward the side that keeps content. Set `allowLossyFallback` to apply the fallback regardless.
|
|
283
|
+
- `allowLossyFallback` — opt back into the pre-guard behavior: apply `fallbackStrategy` wholesale even when it would discard integrated work. Off by default.
|
|
213
284
|
|
|
214
285
|
Available strategies for rules and `defaultStrategy`:
|
|
215
286
|
|
|
@@ -221,7 +292,122 @@ Available strategies for rules and `defaultStrategy`:
|
|
|
221
292
|
| `'deleted'` | Remove the file entirely |
|
|
222
293
|
| `'skip'` | Skip the file (no resolution produced) |
|
|
223
294
|
|
|
224
|
-
The function inspects the working tree once (via `unmergedEntries`), processes each conflict sequentially, and returns. It does not call `git rebase --continue` or any other operation command — that is the consumer's job.
|
|
295
|
+
The function inspects the working tree once (via `unmergedEntries`), processes each conflict sequentially, runs detection over the touched set, and returns. It does not call `git rebase --continue` or any other operation command — that is the consumer's job.
|
|
296
|
+
|
|
297
|
+
## Detection & verdict
|
|
298
|
+
|
|
299
|
+
### `detect(resolutions, context, deps)`
|
|
300
|
+
|
|
301
|
+
Deterministic, report-only post-resolution detection. It never mutates state or fails the operation. `resolveConflicts` calls it for you and attaches the result to `StepResult.detection`; call it directly for a single-file or custom flow.
|
|
302
|
+
|
|
303
|
+
```typescript
|
|
304
|
+
import { detect } from '@gitkraken/conflict-tools';
|
|
305
|
+
|
|
306
|
+
const report = await detect(step.resolutions, { refs }, { git, parser });
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
`DetectDeps` is `{ git; parser?; signal?; excludePaths? }`. `excludePaths` (a `ReadonlySet<string>`) keeps paths out of scope — pass the files you skipped or failed to resolve, since those still carry conflict markers on disk and the parser would report the marker text as syntax errors. It flags:
|
|
310
|
+
|
|
311
|
+
- references that resolved in a pre-merge parent (ours/theirs) but no longer resolve in the merged result (`broken-reference`, with `provenance`),
|
|
312
|
+
- duplicate definitions and syntax errors across the whole analyzed scope — the resolved files *and* the auto-merged/incoming ones (`duplicate-definition`, `syntax-error`). Duplicates are diffed against the parents, so a legal same-name declaration that already existed (a TS overload, `interface`/`namespace` merging) is not reported; only a duplicate the merge introduced is,
|
|
313
|
+
- the resolver's emergency fallback taking one side wholesale and discarding a substantial diff (`dropped-side`).
|
|
314
|
+
|
|
315
|
+
The scope includes the resolved files *and* the merge's auto-merged/incoming set (discovered from `refs` via `changedFiles`/`mergeBase`, or overridden by `context.touchedFiles`), so a dangling reference in a cleanly-merged file is still caught.
|
|
316
|
+
|
|
317
|
+
`DetectionReport`:
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
interface DetectionReport {
|
|
321
|
+
findings: Finding[];
|
|
322
|
+
checks: { referenceIntegrity: CheckStatus; structural: CheckStatus };
|
|
323
|
+
/** Honest coverage — present only when the reference check ran. */
|
|
324
|
+
scope?: { inScope: number; readable: number; truncated: boolean; incomingDiscovered: boolean };
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
`Finding`:
|
|
329
|
+
|
|
330
|
+
| Field | Meaning |
|
|
331
|
+
|---|---|
|
|
332
|
+
| `category` | Open string. The package emits `FindingCategory`; a consumer validator may add its own |
|
|
333
|
+
| `severity` | `'error'` \| `'warning'` |
|
|
334
|
+
| `tier` | `'file'` (intra-file) or `'project'` (cross-file) — the fix phase routes each tier differently |
|
|
335
|
+
| `location` | `{ file; line; symbol? }` |
|
|
336
|
+
| `provenance?` | For `broken-reference`: the pre-merge side (`'ours'`/`'theirs'`) that still resolved it, and where |
|
|
337
|
+
| `message` | Human-readable description |
|
|
338
|
+
|
|
339
|
+
`FindingCategory` (the well-known values the package itself emits):
|
|
340
|
+
|
|
341
|
+
| Category | Meaning |
|
|
342
|
+
|---|---|
|
|
343
|
+
| `broken-reference` | A symbol resolved in a pre-merge parent but no longer resolves in the merged result |
|
|
344
|
+
| `duplicate-definition` | The same symbol is defined twice in a resolved file (advisory `warning` — the detector cannot tell a genuine redefinition from legal overloads / declaration merging) |
|
|
345
|
+
| `syntax-error` | The parser reported a syntax error in a resolved file |
|
|
346
|
+
| `dropped-side` | The resolver's emergency fallback took one side wholesale, discarding a substantial diff (a deliberate rule/default take is not flagged) |
|
|
347
|
+
|
|
348
|
+
`CheckStatus` tells "ran, clean" apart from "did not run" — so an empty `findings` array is never mistaken for a clean result:
|
|
349
|
+
|
|
350
|
+
| Status | Meaning |
|
|
351
|
+
|---|---|
|
|
352
|
+
| `ran` | The check ran to completion |
|
|
353
|
+
| `skipped-no-parser` | No `ParserPort` supplied |
|
|
354
|
+
| `skipped-no-refs` | No refs, so the before/after comparison was impossible (structural checks still ran) |
|
|
355
|
+
| `skipped-unreadable-source` | Refs were present but the parent snapshots came back empty (unreadable source) |
|
|
356
|
+
| `partial-unsupported-language` | The check ran, but some files in scope were a file type the backend has no grammar for |
|
|
357
|
+
| `partial-no-cross-file-resolution` | The backend declared it does not resolve references across files, so cross-file breaks were structurally out of reach |
|
|
358
|
+
| `errored` | The parser was present but threw |
|
|
359
|
+
|
|
360
|
+
### `summarizeOperation` / `buildOperationDigest`
|
|
361
|
+
|
|
362
|
+
`buildOperationDigest(result, reviewThreshold?)` is a pure, model-free rollup of a `StepResult` that folds in `detection`. `summarizeOperation(result, opts?)` returns `{ digest, prose?, usage? }` — always the digest, plus a prose narrative when `opts.model` is supplied.
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
import { buildOperationDigest } from '@gitkraken/conflict-tools';
|
|
366
|
+
|
|
367
|
+
const digest = buildOperationDigest(step);
|
|
368
|
+
// digest.verdict — 'clean' | 'needs-review' | 'blocked'
|
|
369
|
+
// digest.detectionComplete — did every detection check run to completion
|
|
370
|
+
// digest.findingCount — total detection findings
|
|
371
|
+
// digest.needsReview — ReviewItem[] (findings first, then lowest confidence first)
|
|
372
|
+
// digest.minConfidence — lowest file confidence, or null
|
|
373
|
+
// digest.byStrategy — resolution count per strategy
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
`OperationVerdict`:
|
|
377
|
+
|
|
378
|
+
- `blocked` — at least one error-severity finding exists (the build is known-broken).
|
|
379
|
+
- `needs-review` — confidence is low, detection did not fully run, a resolution errored, or a warning finding exists.
|
|
380
|
+
- `clean` — none of the above.
|
|
381
|
+
|
|
382
|
+
**Gate auto-apply on the verdict, not on confidence.** A refined or fixed file has its confidence capped at 0.9 — so a "confidence must be 1.0" gate would reject exactly the files the tool improved. `verdict === 'clean'` is the honest signal. And an *absent* verdict must NOT be read as clean: if you never ran detection (no parser) or never summarized, `detectionComplete` is `false` and empty findings mean "we didn't look," not "nothing's wrong." Only `verdict === 'clean'` is clean.
|
|
383
|
+
|
|
384
|
+
A `ReviewItem` distinguishes a low-confidence flag from a finding: `reason: 'low-confidence' | 'finding' | 'unresolved'` (`'unresolved'` marks an unmerged entry the tool left untouched — a no-markers skip like a binary add/add — which keeps the operation off `clean`), with `confidence?` (absent for a file flagged purely by a finding or as unresolved, e.g. an auto-merged file with no resolution of its own) and `findingCategories?` (present when `reason` is `'finding'`).
|
|
385
|
+
|
|
386
|
+
## Fix phase (opt-in)
|
|
387
|
+
|
|
388
|
+
By default the pipeline is **report-only**: detection findings land in `StepResult.detection` and the verdict, and the consumer decides what to do. Set `config.fix: true` to have the library attempt to fix error-severity findings before emitting the final report. The pipeline shape is identical in both modes — the fix phase only inserts edit attempts between refine and the final detection.
|
|
389
|
+
|
|
390
|
+
When enabled:
|
|
391
|
+
|
|
392
|
+
- **Per-file convergence** — an AI-resolved (`strategy: 'ai'`) text file carrying error-severity per-file findings is routed back through refine; the file is re-analyzed after each edit and the loop repeats until it is clean, a round makes no progress, or `fixLoopMaxIterations` (default 3) is reached.
|
|
393
|
+
- **Cross-file routing** — a `tier: 'project'` broken reference (an import/cross-file use whose target stopped resolving) is routed to the refine of its *use-site* file, and the whole set is re-detected, up to `crossFileMaxIterations` (default 2) rounds. The refine is seeded with the defining file's current definitions (names, kind, line — a token-cheap list, not its text) so it can spot a renamed or moved symbol behind the broken use, even though the refine tool cannot read other files. A file is routed when it has a write channel: an AI resolution, or an AI-unanimous whole-side take (relabelled to `ai` when the fix edit lands). A deliberate rule/default take and an emergency fallback take are not routed, and a break landing on an untouched auto-merged file (no resolution to carry the edit) is reported but not auto-fixed.
|
|
394
|
+
|
|
395
|
+
Whatever the fix phase cannot fix stays in the findings, so the verdict always reflects the post-fix state. Two invariants hold throughout: a fix that reintroduces conflict markers is discarded rather than written, and a round that leaves *more* error findings than it started with is discarded too — the better content is kept, so the phase can never ship a file worse than the one it was handed.
|
|
396
|
+
|
|
397
|
+
Failures are isolated per file: a parser, validator, or model throw on one file leaves that file at its pre-fix resolution, emits `fix:failed` with its `filePath`, and the phase continues with the rest. Only a user abort propagates.
|
|
398
|
+
|
|
399
|
+
Without `ResolutionContext.refs` (or `detectionRefs`) the per-file stage still runs on what needs no parent content — syntax errors and validator findings — while cross-file routing is skipped, since it is driven by the whole-set before/after report.
|
|
400
|
+
|
|
401
|
+
`StepConfig` fix-phase fields:
|
|
402
|
+
|
|
403
|
+
| Field | Default | Effect |
|
|
404
|
+
|---|---|---|
|
|
405
|
+
| `fix` | `false` | Opt in to the fix phase. Off → report-only |
|
|
406
|
+
| `fixLoopMaxIterations` | `3` | Max per-file fix iterations (refine → re-detect the file) |
|
|
407
|
+
| `crossFileMaxIterations` | `2` | Max cross-file fix rounds (route project-level breaks → re-detect the set) |
|
|
408
|
+
| `maxModelCalls` | unset | Global model-call budget for the whole operation. When set and the budget is spent, the fix phase stops attempting further fixes and leaves the remainder in the findings, emitting `fix:budget-exhausted`. When unset, the fix phase is not budget-limited |
|
|
409
|
+
|
|
410
|
+
The fix phase is **fault-isolated** at two levels. A throw on one file (parser, validator, or model) leaves that file at its pre-fix resolution, emits `fix:failed` with its `filePath`, and the phase moves on — detection still runs over the set. A throw that escapes the whole phase does not reject the operation either: the resolutions computed so far (possibly partially fixed, since the phase mutates them as it goes) are kept, both detection checks are reported as `errored`, and `fix:failed` is emitted without a `filePath`. A user abort always propagates and cancels the operation.
|
|
225
411
|
|
|
226
412
|
## Consumer examples
|
|
227
413
|
|
|
@@ -241,8 +427,9 @@ if (conflict) {
|
|
|
241
427
|
### GitLens / GKD — batch "resolve all" (user-driven)
|
|
242
428
|
|
|
243
429
|
```typescript
|
|
244
|
-
const step = await resolveConflicts({ git, model, onProgress: webview.post });
|
|
245
|
-
const
|
|
430
|
+
const step = await resolveConflicts({ git, model, parser, onProgress: webview.post }, { refs });
|
|
431
|
+
const { digest } = await summarizeOperation(step);
|
|
432
|
+
const approved = await reviewDialog(step.resolutions, step.detection, digest.verdict);
|
|
246
433
|
await applyResolutions(approved, { git });
|
|
247
434
|
// User clicks "continue rebase" themselves.
|
|
248
435
|
```
|
|
@@ -256,13 +443,18 @@ for (const commit of commits) {
|
|
|
256
443
|
if (result.exitCode === 0) continue;
|
|
257
444
|
|
|
258
445
|
const step = await resolveConflicts({
|
|
259
|
-
git, model,
|
|
446
|
+
git, model, parser,
|
|
260
447
|
config: {
|
|
261
448
|
rules: [{ match: '*.lock', strategy: 'take-theirs' }],
|
|
262
449
|
fallbackStrategy: 'take-ours',
|
|
450
|
+
fix: true, // attempt to fix detected breaks before reporting
|
|
263
451
|
},
|
|
264
452
|
onProgress: emit,
|
|
265
|
-
});
|
|
453
|
+
}, { refs });
|
|
454
|
+
|
|
455
|
+
const { digest } = await summarizeOperation(step);
|
|
456
|
+
if (digest.verdict === 'blocked') { await escalateToHuman(step); break; }
|
|
457
|
+
|
|
266
458
|
await applyResolutions(step.resolutions, { git });
|
|
267
459
|
await git.exec(['rebase', '--continue']);
|
|
268
460
|
}
|
|
@@ -282,27 +474,34 @@ score(resolution, goldenFile);
|
|
|
282
474
|
|
|
283
475
|
| Event | When |
|
|
284
476
|
|---|---|
|
|
285
|
-
| `conflict:found` | A conflict file has been parsed |
|
|
477
|
+
| `conflict:found` | A conflict file has been parsed (`conflictType`, `markerCount`) |
|
|
286
478
|
| `conflict:excluded` | A file was skipped by a rule with `strategy: 'skip'` |
|
|
287
|
-
| `conflict:skipped` | A file had no conflict markers and no delete-modify reason |
|
|
288
|
-
| `resolution:applied` | A resolution was produced for a file |
|
|
289
|
-
| `resolution:fallback` | AI failed; fallback strategy applied |
|
|
290
|
-
| `resolution:failed` | AI failed and no fallback configured |
|
|
291
|
-
| `resolver:tool-call` | The AI invoked a tool
|
|
292
|
-
| `resolver:step-usage` | Token usage for one model call |
|
|
293
|
-
| `resolver:completed` | The resolver finished a single file |
|
|
294
|
-
| `resolver:response` |
|
|
295
|
-
| `resolver:tool-result` | A
|
|
296
|
-
| `refine:started` |
|
|
297
|
-
| `refine:skipped` |
|
|
298
|
-
| `refine:tool-call` |
|
|
299
|
-
| `refine:tool-result` |
|
|
300
|
-
| `refine:final-text` |
|
|
301
|
-
| `refine:completed` |
|
|
302
|
-
| `refine:failed` |
|
|
303
|
-
| `
|
|
304
|
-
|
|
305
|
-
|
|
479
|
+
| `conflict:skipped` | A file had no conflict markers and no delete-modify reason (`reason`, `entryReason?`) |
|
|
480
|
+
| `resolution:applied` | A resolution was produced for a file (`strategy`, `confidence?`, `description?`) |
|
|
481
|
+
| `resolution:fallback` | AI failed; fallback strategy applied (`error`, `cause?`) |
|
|
482
|
+
| `resolution:failed` | AI failed and no fallback configured (`error`) |
|
|
483
|
+
| `resolver:tool-call` | The AI invoked a tool during resolve (`tool`, `args`, `stepNumber`, `reason?`) |
|
|
484
|
+
| `resolver:step-usage` | Token usage for one resolve model call (`stepNumber`, `usage`) |
|
|
485
|
+
| `resolver:completed` | The resolver finished a single file (`stepCount`) |
|
|
486
|
+
| `resolver:response` | The resolve stage produced its final structured response (`chunks`, `confidence`, `description`, `strategy?`, `followUpInstructions?`) |
|
|
487
|
+
| `resolver:tool-result` | A resolve-stage tool returned content (`tool`, `stepNumber`, `content`) |
|
|
488
|
+
| `refine:started` | The refine pass has begun |
|
|
489
|
+
| `refine:skipped` | Refine was not run (`reason`, e.g. no follow-ups) |
|
|
490
|
+
| `refine:tool-call` | Refine model invoked `edit_file_lines` (`stepNumber`, `ops`) |
|
|
491
|
+
| `refine:tool-result` | A refine tool returned feedback (`stepNumber`, `appliedCount`, `isError`, `feedback`) |
|
|
492
|
+
| `refine:final-text` | Refine model emitted its final description (`text`) |
|
|
493
|
+
| `refine:completed` | Refine finished successfully (`appliedCount`) |
|
|
494
|
+
| `refine:failed` | Refine threw before completing (`reason`) |
|
|
495
|
+
| `refine:bailed` | Refine bailed early after consecutive apply errors, keeping edits that landed (`reason`) |
|
|
496
|
+
| `refine:discarded` | A refine/fix edit batch was rejected — it reintroduced conflict markers, or (in the fix phase) it introduced an error the file did not have (`reason` says which) |
|
|
497
|
+
| `refine:touched-user-region` | Refine modified a user-resolved marker region (`markerIndexes`) |
|
|
498
|
+
| `fix:started` | The fix loop began fixing a file (`findingCount`) |
|
|
499
|
+
| `fix:completed` | The fix loop finished evaluating a file (`iterations`, `remainingFindings`) — emitted even for a clean file (iterations 0, remainingFindings 0). `iterations: 0` means "evaluated, spent no model call" |
|
|
500
|
+
| `fix:failed` | A non-abort error in the opt-in fix phase (`filePath?`, `reason`). With `filePath`: that one file threw and was left at its pre-fix resolution while the phase carried on — detection still runs. Without `filePath`: the whole phase threw before it could report, so both checks are forced to `errored` |
|
|
501
|
+
| `fix:budget-exhausted` | The global `maxModelCalls` budget was spent (`spent`, `limit`); the fix phase stopped and left the remainder in findings |
|
|
502
|
+
| `validation:failed` | The consumer validator threw; reported and swallowed, resolution kept (`reason`) |
|
|
503
|
+
|
|
504
|
+
Note: `refine:tool-result.appliedCount` reports edits applied to an in-progress buffer per turn. The final disposition is signaled by `refine:completed` / `refine:discarded` / `refine:failed` — only edits that survive residual-marker validation appear in `Resolution.edits`.
|
|
306
505
|
|
|
307
506
|
Operation-level events (rebase started, step completed, etc.) are the consumer's responsibility — the library has no opinion about the surrounding workflow.
|
|
308
507
|
|
|
@@ -311,6 +510,7 @@ Operation-level events (rebase started, step completed, etc.) are the consumer's
|
|
|
311
510
|
| Error | Codes | When |
|
|
312
511
|
|---|---|---|
|
|
313
512
|
| `ConflictError` | `PARSE_FAILED` | Conflict extraction failed |
|
|
513
|
+
| `ConflictError` | `CODE_LOSS_RISK` | A wholesale `fallbackStrategy` would have discarded integrated work; the fallback was not applied |
|
|
314
514
|
| `AIError` | `VALIDATION_EXHAUSTED`, `INCOMPLETE_RESOLUTION` | Model could not produce a valid resolution after retries |
|
|
315
515
|
| `ConflictGitPortMissingOpError` | — | A required git op is missing and no `exec` fallback was provided |
|
|
316
516
|
|
|
@@ -328,7 +528,14 @@ try {
|
|
|
328
528
|
}
|
|
329
529
|
```
|
|
330
530
|
|
|
331
|
-
In the batch entry point, errors
|
|
531
|
+
In the batch entry point, errors do not throw; the loop continues with the next file. Where they land depends on the fallback:
|
|
532
|
+
|
|
533
|
+
- No `fallbackStrategy`: the error goes to `step.errors`.
|
|
534
|
+
- `fallbackStrategy` set, text conflict: the code-loss guard stops it, so a `CODE_LOSS_RISK` error goes to `step.errors` with a `resolution:failed` event. This is the common path, not the exception.
|
|
535
|
+
- `fallbackStrategy` set, delete-modify resolved toward the side that keeps content: downgraded to a `resolution:fallback` event, with the resolution in `step.resolutions`.
|
|
536
|
+
- `allowLossyFallback: true`: the guard is off, so every fallback takes the `resolution:fallback` path.
|
|
537
|
+
|
|
538
|
+
The advisory stages (refine, validator, detection, fix phase) are fault-isolated — a non-abort throw there is reported and swallowed rather than destroying a good resolution. A user abort (`signal.aborted`) always propagates and cancels the operation.
|
|
332
539
|
|
|
333
540
|
## Semver policy
|
|
334
541
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//#region src/errors/conflict.d.ts
|
|
2
|
-
type ConflictErrorCode = 'PARSE_FAILED';
|
|
2
|
+
type ConflictErrorCode = 'PARSE_FAILED' | 'CODE_LOSS_RISK';
|
|
3
3
|
declare class ConflictError extends Error {
|
|
4
4
|
readonly code: ConflictErrorCode;
|
|
5
5
|
constructor(code: ConflictErrorCode, message: string, options?: {
|
|
@@ -1,3 +1,2 @@
|
|
|
1
|
-
import{ConflictGitPortMissingOpError as e}from"../ports/git.js";import{sliceLines as t,truncateLines as n,truncateMatches as r}from"./truncation.js";const i={DD:`both-deleted`,AU:`added-by-us`,UD:`deleted-by-them`,UA:`added-by-them`,DU:`deleted-by-us`,AA:`both-added`,UU:`both-modified`};function a(e){return i[e]??e}function o(e){return`Search capped at ${e} matches. There may be more — narrow with a more specific pattern or path.`}function s(t,n){throw new e(t,n)}function c(e,t){return e!=null&&t!=null?`startLine=${e}, endLine=${t}`:e==null?`endLine=${t}`:`startLine=${e}`}async function l(e,t,n){return e.readFile?e.readFile(t,n):s(`readFile`)}async function u(e,r,i,a){let o;if(e.showFile)o=await e.showFile(r,i,a);else if(e.exec){let n=t(await e.exec([`show`,`${r}:${i}`],{signal:a?.signal}),a?.startLine,a?.endLine);if(!n.ok)return n.reason===`invalid-range`?`[Invalid range: ${c(n.startLine,n.endLine)} (startLine must be <= endLine).]`:`[Range out of bounds: file has ${n.total} lines, requested ${c(n.startLine,n.endLine)}.]`;o=n.content}else return s(`showFile`);return n(o,1e3,`Output capped at 1000 lines. Use startLine/endLine to read other regions of the file.`)}async function d(e,t){if(e.unmergedEntries)return e.unmergedEntries(t);if(e.exec){let n=await e.exec([`status`,`--porcelain=v2`],{signal:t?.signal}),r=[];for(let e of n.split(`
|
|
2
|
-
`)){if(!e.startsWith(`u `))continue;let t=e.split(` `),n=t[1]??``,i=t.slice(10).join(` `);r.push({path:i,reason:a(n)})}return r}return s(`unmergedEntries`)}async function f(e,t,n,r){return e.mergeBase?e.mergeBase(t,n,r):e.exec?(await e.exec([`merge-base`,`--`,t,n],{signal:r?.signal})).trim():s(`mergeBase`)}async function p(e,t,r){let i=r?.startLine,a=r?.endLine,o=Number.isFinite(i),l=Number.isFinite(a);if(o&&l&&i>a)return`[Invalid range: ${c(i,a)} (startLine must be <= endLine).]`;let u;if(e.blame)u=await e.blame(t,r);else if(e.exec){let n=[`blame`,`--porcelain`],s=o?Math.max(1,i):void 0,c=l?Math.max(1,a):void 0;s!=null&&c!=null?n.push(`-L`,`${s},${c}`):s==null?c!=null&&n.push(`-L`,`1,${c}`):n.push(`-L`,`${s},`),r?.ref&&n.push(r.ref),n.push(`--`,t),u=await e.exec(n,{signal:r?.signal})}else return s(`blame`);return n(u,1e3,`Output capped at 1000 lines. Use startLine/endLine to inspect other regions.`)}async function m(e,t,n){let i=n?.maxResults,a=i!=null&&Number.isFinite(i)?Math.min(Math.max(1,i),100):100,c;if(e.grep)c=await e.grep(t,n);else if(e.exec){let r=[`grep`,`-n`,`--`,t];n?.ref&&r.push(n.ref),c=await e.exec(r,{signal:n?.signal})}else return s(`grep`);return r(c,a,o(a))}async function h(e,t,n,r){if(e.diff)return e.diff(t,n,r);if(e.exec){let i=[`diff`,t,n];return r?.path&&i.push(`--`,r.path),e.exec(i,{signal:r?.signal})}return s(`diff`)}async function g(e,t,n,r){return e.changedFiles?e.changedFiles(t,n,r):e.exec?(await e.exec([
|
|
3
|
-
`).map(e=>e.trim()).filter(e=>e.length>0):s(`changedFiles`)}async function _(e,t){let r=t?.maxCount,i=r!=null&&Number.isFinite(r)?Math.min(Math.max(1,r),100):100,a;if(e.log)a=await e.log({...t,maxCount:i});else if(e.exec){let n=[`log`,`--oneline`,`-n`,String(i)];t?.ref&&n.push(t.ref),t?.path&&n.push(`--`,t.path),a=await e.exec(n,{signal:t?.signal})}else return s(`log`);return n(a,100,`Output capped at 100 commits. Use path to scope to a file or refine the ref range.`)}async function v(e,t,r){let i;if(e.show)i=await e.show(t,r);else if(e.exec){let n=[`show`,t];r?.path&&n.push(`--`,r.path),i=await e.exec(n,{signal:r?.signal})}else return s(`show`);return n(i,500,`Output capped at 500 lines. Use show_file_at_ref with startLine/endLine to read specific regions.`)}async function y(e,t,n,r){return e.writeFile?e.writeFile(t,n,r):s(`writeFile`)}async function b(e,t,n){if(e.stageFiles)return e.stageFiles(t,n);if(e.exec){await e.exec([`add`,`--`,...t],{signal:n?.signal});return}return s(`stageFiles`)}async function x(e,t,n,r){if(e.checkoutFile)return e.checkoutFile(t,n,r);if(e.exec){await e.exec([`checkout`,`--${n}`,`--`,t],{signal:r?.signal});return}return s(`checkoutFile`)}async function S(e,t,n){if(e.removeFile)return e.removeFile(t,n);if(e.exec){await e.exec([`rm`,`--`,t],{signal:n?.signal});return}return s(`removeFile`)}export{p as blame,g as changedFiles,x as checkoutFile,h as diff,m as grep,_ as log,f as mergeBase,l as readFile,S as removeFile,v as show,u as showFile,b as stageFiles,d as unmergedEntries,y as writeFile};
|
|
1
|
+
import{ConflictGitPortMissingOpError as e}from"../ports/git.js";import{sliceLines as t,truncateLines as n,truncateMatches as r}from"./truncation.js";const i={DD:`both-deleted`,AU:`added-by-us`,UD:`deleted-by-them`,UA:`added-by-them`,DU:`deleted-by-us`,AA:`both-added`,UU:`both-modified`};function a(e){return i[e]??e}function o(e){return`Search capped at ${e} matches. There may be more — narrow with a more specific pattern or path.`}function s(t,n){throw new e(t,n)}function c(e,t){return e!=null&&t!=null?`startLine=${e}, endLine=${t}`:e==null?`endLine=${t}`:`startLine=${e}`}async function l(e,t,n){return e.readFile?e.readFile(t,n):s(`readFile`)}async function u(e,r,i,a){let o;if(e.showFile)o=await e.showFile(r,i,a);else if(e.exec){let n=t(await e.exec([`show`,`${r}:${i}`],{signal:a?.signal}),a?.startLine,a?.endLine);if(!n.ok)return n.reason===`invalid-range`?`[Invalid range: ${c(n.startLine,n.endLine)} (startLine must be <= endLine).]`:`[Range out of bounds: file has ${n.total} lines, requested ${c(n.startLine,n.endLine)}.]`;o=n.content}else return s(`showFile`);return a?.fullContent?o:n(o,1e3,`Output capped at 1000 lines. Use startLine/endLine to read other regions of the file.`)}async function d(e,t){if(e.unmergedEntries)return e.unmergedEntries(t);if(e.exec){let n=await e.exec([`status`,`--porcelain=v2`],{signal:t?.signal}),r=[];for(let e of n.split(`
|
|
2
|
+
`)){if(!e.startsWith(`u `))continue;let t=e.split(` `),n=t[1]??``,i=t.slice(10).join(` `);r.push({path:i,reason:a(n)})}return r}return s(`unmergedEntries`)}async function f(e,t,n,r){return e.mergeBase?e.mergeBase(t,n,r):e.exec?(await e.exec([`merge-base`,`--`,t,n],{signal:r?.signal})).trim():s(`mergeBase`)}async function p(e,t,r){let i=r?.startLine,a=r?.endLine,o=Number.isFinite(i),l=Number.isFinite(a);if(o&&l&&i>a)return`[Invalid range: ${c(i,a)} (startLine must be <= endLine).]`;let u;if(e.blame)u=await e.blame(t,r);else if(e.exec){let n=[`blame`,`--porcelain`],s=o?Math.max(1,i):void 0,c=l?Math.max(1,a):void 0;s!=null&&c!=null?n.push(`-L`,`${s},${c}`):s==null?c!=null&&n.push(`-L`,`1,${c}`):n.push(`-L`,`${s},`),r?.ref&&n.push(r.ref),n.push(`--`,t),u=await e.exec(n,{signal:r?.signal})}else return s(`blame`);return n(u,1e3,`Output capped at 1000 lines. Use startLine/endLine to inspect other regions.`)}async function m(e,t,n){let i=n?.maxResults,a=i!=null&&Number.isFinite(i)?Math.min(Math.max(1,i),100):100,c;if(e.grep)c=await e.grep(t,n);else if(e.exec){let r=[`grep`,`-n`,`--`,t];n?.ref&&r.push(n.ref),c=await e.exec(r,{signal:n?.signal})}else return s(`grep`);return r(c,a,o(a))}async function h(e,t,n,r){if(e.diff)return e.diff(t,n,r);if(e.exec){let i=[`diff`,t,n];return r?.path&&i.push(`--`,r.path),e.exec(i,{signal:r?.signal})}return s(`diff`)}async function g(e,t,n,r){return e.changedFiles?e.changedFiles(t,n,r):e.exec?(await e.exec([`-c`,`core.quotepath=false`,`diff`,`--name-only`,`-z`,`--end-of-options`,t,n],{signal:r?.signal})).split(`\0`).filter(e=>e.length>0):s(`changedFiles`)}async function _(e,t){let r=t?.maxCount,i=r!=null&&Number.isFinite(r)?Math.min(Math.max(1,r),100):100,a;if(e.log)a=await e.log({...t,maxCount:i});else if(e.exec){let n=[`log`,`--oneline`,`-n`,String(i)];t?.ref&&n.push(t.ref),t?.path&&n.push(`--`,t.path),a=await e.exec(n,{signal:t?.signal})}else return s(`log`);return n(a,100,`Output capped at 100 commits. Use path to scope to a file or refine the ref range.`)}async function v(e,t,r){let i;if(e.show)i=await e.show(t,r);else if(e.exec){let n=[`show`,t];r?.path&&n.push(`--`,r.path),i=await e.exec(n,{signal:r?.signal})}else return s(`show`);return n(i,500,`Output capped at 500 lines. Use show_file_at_ref with startLine/endLine to read specific regions.`)}async function y(e,t,n,r){return e.writeFile?e.writeFile(t,n,r):s(`writeFile`)}async function b(e,t,n){if(e.stageFiles)return e.stageFiles(t,n);if(e.exec){await e.exec([`add`,`--`,...t],{signal:n?.signal});return}return s(`stageFiles`)}async function x(e,t,n,r){if(e.checkoutFile)return e.checkoutFile(t,n,r);if(e.exec){await e.exec([`checkout`,`--${n}`,`--`,t],{signal:r?.signal});return}return s(`checkoutFile`)}async function S(e,t,n){if(e.removeFile)return e.removeFile(t,n);if(e.exec){await e.exec([`rm`,`--`,t],{signal:n?.signal});return}return s(`removeFile`)}export{p as blame,g as changedFiles,x as checkoutFile,h as diff,m as grep,_ as log,f as mergeBase,l as readFile,S as removeFile,v as show,u as showFile,b as stageFiles,d as unmergedEntries,y as writeFile};
|