@gitkraken/conflict-tools 0.4.0 → 0.5.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 CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-09-29
11
+
12
+ ### Added
13
+
14
+ - `StepResult.errors` entries carry a typed `reason` next to the original `error`, as `SkippedEntry` does for skips (here a closed union rather than a plain string), so a consumer no longer needs `instanceof` and `.code` to tell failures apart. The values come from the places that actually fail: `'read-failed'` (the file could not be read), `'parse-failed'` (`ConflictError` `PARSE_FAILED`), `'model-error'` (the model call itself threw, which is where a provider error or a timeout lands), `'validation-exhausted'` (`AIError` `VALIDATION_EXHAUSTED`), `'code-loss-risk'` (`ConflictError` `CODE_LOSS_RISK`), `'aborted'` (the signal was aborted while the file was being read) and `'unknown'`. On `'code-loss-risk'` the new `causeReason` says why the resolver failed before the fallback was withheld. `error` is unchanged. The entry type is exported as `FailedEntry` and the union as `FailureReason`. **Breaking** for code that builds a `StepResult` by hand: `reason` is required, so code that builds a `StepResult` with non-empty `errors` by hand, a test mock for example, has to set it.
15
+
16
+ ### Changed
17
+
18
+ - The `CODE_LOSS_RISK` message now ends with the message of the resolver failure that triggered the fallback (`Resolver failure: …`). Before, that cause was reachable only through `error.cause`, so a consumer that showed the message saw only that the fallback was withheld, never why the resolver had failed.
19
+
20
+ ### Removed
21
+
22
+ - **Breaking**: `'INCOMPLETE_RESOLUTION'` from `AIErrorCode`. Nothing ever threw it, so a consumer branching on it was handling a case that could not happen.
23
+
10
24
  ## [0.4.0] - 2026-09-22
11
25
 
12
26
  ### Added
package/README.md CHANGED
@@ -270,7 +270,7 @@ const step = await resolveConflicts(
270
270
  );
271
271
 
272
272
  // step.resolutions[] — successful resolutions
273
- // step.errors[] — { filePath, error } for files that failed (no fallback or AI exhausted)
273
+ // step.errors[] — { filePath, error, reason, causeReason? } for files that failed (see Error handling)
274
274
  // step.skipped?[] — { filePath, reason, entryReason?, stages? } for files that were excluded,
275
275
  // had no markers, or were left to a human (see the skip reasons below)
276
276
  // step.detection — DetectionReport over the whole touched set; ALWAYS present. Read `checks`
@@ -553,7 +553,7 @@ Operation-level events (rebase started, step completed, etc.) are the consumer's
553
553
  |---|---|---|
554
554
  | `ConflictError` | `PARSE_FAILED` | Conflict extraction failed |
555
555
  | `ConflictError` | `CODE_LOSS_RISK` | A wholesale `fallbackStrategy` would have discarded integrated work; the fallback was not applied |
556
- | `AIError` | `VALIDATION_EXHAUSTED`, `INCOMPLETE_RESOLUTION` | Model could not produce a valid resolution after retries |
556
+ | `AIError` | `VALIDATION_EXHAUSTED` | Model could not produce a valid resolution after retries |
557
557
  | `ConflictGitPortMissingOpError` | — | A required git op is missing and no `exec` fallback was provided |
558
558
 
559
559
  ```typescript
@@ -577,6 +577,18 @@ In the batch entry point, errors do not throw; the loop continues with the next
577
577
  - `fallbackStrategy` set, delete-modify resolved toward the side that keeps content: downgraded to a `resolution:fallback` event, with the resolution in `step.resolutions`.
578
578
  - `allowLossyFallback: true`: the guard is off, so every fallback takes the `resolution:fallback` path.
579
579
 
580
+ Each `step.errors` entry carries a typed `reason` next to the original `error`, so a consumer does not need `instanceof` checks to tell failures apart:
581
+
582
+ | `reason` | Meaning |
583
+ |---|---|
584
+ | `read-failed` | The file could not be read from the working tree |
585
+ | `parse-failed` | The conflict markers could not be parsed (`PARSE_FAILED`) |
586
+ | `model-error` | The model call threw: a provider error, a timeout, a rejected request |
587
+ | `validation-exhausted` | No model answer passed validation within `maxSteps` (`VALIDATION_EXHAUSTED`) |
588
+ | `code-loss-risk` | The fallback was withheld (`CODE_LOSS_RISK`). `causeReason` holds why the resolver failed, the error's `cause` holds that error, and its message ends with the cause's message |
589
+ | `aborted` | The signal was aborted while the file was being read |
590
+ | `unknown` | Any other error |
591
+
580
592
  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.
581
593
 
582
594
  ## Semver policy
@@ -1,5 +1,5 @@
1
1
  //#region src/errors/ai.d.ts
2
- type AIErrorCode = 'VALIDATION_EXHAUSTED' | 'INCOMPLETE_RESOLUTION';
2
+ type AIErrorCode = 'VALIDATION_EXHAUSTED';
3
3
  declare class AIError extends Error {
4
4
  readonly code: AIErrorCode;
5
5
  constructor(code: AIErrorCode, message: string, options?: {
package/dist/index.d.ts CHANGED
@@ -18,8 +18,8 @@ import { takeOurs, takeTheirs } from "./resolution/take-strategy.js";
18
18
  import { applyResolutions } from "./resolver/apply-resolutions.js";
19
19
  import { ConflictProgressEvent } from "./types/events.js";
20
20
  import { resolveConflict } from "./resolver/resolve-conflict.js";
21
- import { FileRule, SkippedEntry, StepConfig, StepResult } from "./types/step.js";
21
+ import { FailedEntry, FailureReason, FileRule, SkippedEntry, StepConfig, StepResult } from "./types/step.js";
22
22
  import { resolveConflicts } from "./resolver/resolve-conflicts.js";
23
23
  import { OperationDigest, OperationSummary, OperationVerdict, ReviewItem } from "./types/summary.js";
24
24
  import { SummarizeOperationOptions, buildOperationDigest, summarizeOperation } from "./resolver/summarize-operation.js";
25
- export { AIError, type AIErrorCode, type BlameOptions, type CheckStatus, type CodeLossDetection, type Conflict, type ConflictDiffOptions, ConflictError, type ConflictErrorCode, type ConflictGitOps, type ConflictGitPort, ConflictGitPortMissingOpError, type ConflictMarker, type ConflictMarkerUserInput, type ConflictModelMessage, type ConflictModelParams, type ConflictModelPort, type ConflictModelResult, type ConflictProgressEvent, type ConflictType, type DetectDeps, type DetectionReport, type DiffStats, type FileRule, type Finding, type FindingCategory, type FindingTier, type GrepOptions, type IntraFileEditOp, type LogOptions, type OpOptions, type OperationDigest, type OperationSummary, type OperationVerdict, type ParsedFile, type ParserCapabilities, type ParserPort, type Resolution, type ResolutionContext, type ResolutionMetrics, type ResolutionRawSignals, type ResolutionRefs, type ResolutionStrategy, type ResolvedChunk, type ResolverConfig, type ReviewItem, type ShowFileOptions, type ShowOptions, type SkippedEntry, type SourceOverlay, type StepConfig, type StepResult, type SummarizeOperationOptions, type SymbolDefinition, type SymbolReference, type SyntaxIssue, type ThreeWayDiff, type ToolCall, type ToolDefinition, type ToolResult, type UnmergedEntry, type UnmergedReason, type UnmergedStages, type Validator, type ValidatorFinding, applyResolutions, buildOperationDigest, checkoutFile, createResolution, detect, detectFallbackCodeLoss, extractConflict, removeFile, resolveConflict, resolveConflicts, show, summarizeOperation, takeOurs, takeTheirs };
25
+ export { AIError, type AIErrorCode, type BlameOptions, type CheckStatus, type CodeLossDetection, type Conflict, type ConflictDiffOptions, ConflictError, type ConflictErrorCode, type ConflictGitOps, type ConflictGitPort, ConflictGitPortMissingOpError, type ConflictMarker, type ConflictMarkerUserInput, type ConflictModelMessage, type ConflictModelParams, type ConflictModelPort, type ConflictModelResult, type ConflictProgressEvent, type ConflictType, type DetectDeps, type DetectionReport, type DiffStats, type FailedEntry, type FailureReason, type FileRule, type Finding, type FindingCategory, type FindingTier, type GrepOptions, type IntraFileEditOp, type LogOptions, type OpOptions, type OperationDigest, type OperationSummary, type OperationVerdict, type ParsedFile, type ParserCapabilities, type ParserPort, type Resolution, type ResolutionContext, type ResolutionMetrics, type ResolutionRawSignals, type ResolutionRefs, type ResolutionStrategy, type ResolvedChunk, type ResolverConfig, type ReviewItem, type ShowFileOptions, type ShowOptions, type SkippedEntry, type SourceOverlay, type StepConfig, type StepResult, type SummarizeOperationOptions, type SymbolDefinition, type SymbolReference, type SyntaxIssue, type ThreeWayDiff, type ToolCall, type ToolDefinition, type ToolResult, type UnmergedEntry, type UnmergedReason, type UnmergedStages, type Validator, type ValidatorFinding, applyResolutions, buildOperationDigest, checkoutFile, createResolution, detect, detectFallbackCodeLoss, extractConflict, removeFile, resolveConflict, resolveConflicts, show, summarizeOperation, takeOurs, takeTheirs };
@@ -1 +1 @@
1
- import{AIError as e}from"../errors/ai.js";import{ConflictError as t}from"../errors/conflict.js";import{mergeAttributes as n,readFile as r,unmergedEntries as i,unmergedStages as a}from"../git/port-dispatch.js";import{extractConflict as o,extractConflictWithSource as s}from"../extraction/extract.js";import{detect as c}from"../reference-integrity/detect.js";import{createResolution as l}from"../resolution/create-resolution.js";import{detectFallbackCodeLoss as u}from"../resolution/detect-code-loss.js";import{takeOurs as d,takeTheirs as f}from"../resolution/take-strategy.js";import{resolveConflict as p}from"./resolve-conflict.js";import{runFixPhase as m}from"./fix-loop.js";import{classifyUnmergedEntries as h,loneSideStageMode as g}from"./mechanical.js";import{createModelBudget as _}from"./model-budget.js";import{chooseSurvivingName as v}from"./rename-choice.js";import{findRenameRenameGroup as y}from"./rename-group.js";import b from"picomatch";function x(e){let t=e.map(e=>{let t=(Array.isArray(e.match)?e.match:[e.match]).map(e=>b(e,{basename:!e.includes(`/`)}));return{isMatch:e=>t.some(t=>t(e)),strategy:e.strategy}});return e=>{for(let{isMatch:n,strategy:r}of t)if(n(e))return r}}async function S(e,t){let r=e.config?.rules??[],a=e.config?.defaultStrategy??`ai`,o=x(r),c=await T(await i(e.git,{signal:e.signal}),e),u=h(c),p=_(e.config?.maxModelCalls),m=p.wrap(e.model),v={...t??{}},b=[],S=[],A=[],N=new Set,P,F=()=>(P??=n(e.git,c.map(e=>e.path),{signal:e.signal}).catch(()=>new Map),P),I=y(c),L=I?[I.basePath,I.oursPath,I.theirsPath]:[],R=I!==null&&L.every(e=>(o(e)??a)===`ai`),z=new Set;if(I&&R&&!e.signal?.aborted&&await D(I,e)){await O(I,c,v,{...e,model:m},{resolutions:b,errors:S,skipped:A});for(let e of L)z.add(e)}for(let t of c){if(e.signal?.aborted)break;if(z.has(t.path))continue;let n=o(t.path)??a;if(n===`skip`){A.push({filePath:t.path,reason:`excluded`}),e.onProgress?.({type:`conflict:excluded`,filePath:t.path});continue}let r=u.get(t.path);if(r?.kind===`removed`){w({filePath:t.path,content:``,strategy:`deleted`,confidence:1,description:`Undecided rename: both sides removed ${t.path}; the surviving name is unresolved.`},b,e);continue}if(n===`ai`&&r?.kind===`mechanical`){w({filePath:t.path,content:``,strategy:`mechanical`,confidence:1,description:`Git settled this path itself; its working-tree content is staged unchanged.`},b,e),t.stages!=null&&g(t.stages)===`120000`&&N.add(t.path);continue}if(n===`ai`&&r?.kind===`skip`){A.push({filePath:t.path,reason:r.reason,entryReason:t.reason,stages:t.stages}),e.onProgress?.({type:`conflict:skipped`,filePath:t.path,reason:r.reason,entryReason:t.reason});continue}let i,c;try{({conflict:i,rawContent:c}=await s(t.path,{git:e.git,signal:e.signal},t.reason))}catch(e){S.push({filePath:t.path,error:M(e)});continue}if(!i){if(n===`take-ours`||n===`take-theirs`||n===`deleted`){let r=C(n,t.stages)?`deleted`:n;w({filePath:t.path,content:``,strategy:r,confidence:1,description:r===n?`No conflict markers; applied the configured '${n}' strategy directly.`:`No conflict markers, and the index holds no side to take; removed instead.`,...r===`deleted`?{}:{deliberateTake:!0}},b,e);continue}let r=await j(t,c,F)?`unmergeable`:`no-markers`;A.push({filePath:t.path,reason:r,entryReason:t.reason,stages:t.stages}),e.onProgress?.({type:`conflict:skipped`,filePath:t.path,reason:r,entryReason:t.reason});continue}e.onProgress?.({type:`conflict:found`,filePath:i.filePath,conflictType:i.type,markerCount:i.markers.length});let p=null;p=n===`take-ours`?{...d(i),deliberateTake:!0}:n===`take-theirs`?{...f(i),deliberateTake:!0}:n===`deleted`?l(i,[],{confidence:1,fileStrategy:`deleted`}):await k(i,v,{...e,model:m},S),p&&(v.previousResolutions=[...v.previousResolutions??[],p],w(p,b,e))}return{resolutions:b,errors:S,skipped:A,detection:await E(b,v,A,S,N,p,{...e,model:m})}}function C(e,t){return t?e===`take-ours`?t.stage2==null:e===`take-theirs`?t.stage3==null:!1:!1}function w(e,t,n){t.push(e),n.onProgress?.({type:`resolution:applied`,filePath:e.filePath,strategy:e.strategy,confidence:e.confidence,description:e.description})}async function T(e,t){if(!t.git.exec||!e.some(e=>e.stages===void 0))return e;let n=await a(t.git,{signal:t.signal});return e.map(e=>{if(e.stages)return e;let t=n.get(e.path);return t?{...e,stages:t}:e})}async function E(e,t,n,r,i,a,o){let s=new Set([...n.map(e=>e.filePath),...r.map(e=>e.filePath),...e.flatMap(e=>e.retiredPaths??[]),...i]),l={git:o.git,parser:o.parser,signal:o.signal,excludePaths:s,maxScopeFiles:o.config?.maxDetectionScopeFiles},u;if(o.signal?.aborted)u={findings:[],checks:{referenceIntegrity:`errored`,structural:`errored`}};else if(o.config?.fix&&o.parser)try{u=await m(e,t,{model:o.model,git:o.git,parser:o.parser,validator:o.validator,config:o.config,onProgress:o.onProgress,signal:o.signal,budget:a,excludePaths:s})}catch(n){if(o.signal?.aborted)throw n;o.onProgress?.({type:`fix:failed`,reason:M(n).message}),u=await c(e,t,l),u={...u,checks:{referenceIntegrity:`errored`,structural:`errored`}}}else u=await c(e,t,l);let d=e.flatMap(e=>e.findings??[]);return{...u,findings:N([...u.findings,...d])}}async function D(e,t){try{return await r(t.git,e.oursPath,{signal:t.signal})===await r(t.git,e.theirsPath,{signal:t.signal})}catch(e){if(t.signal?.aborted)throw M(e);return!1}}async function O(e,t,n,r,i){let a=e=>{for(let n of e){let e=t.find(e=>e.path===n);i.skipped.push({filePath:n,reason:`rename-choice`,entryReason:e?.reason,stages:e?.stages}),r.onProgress?.({type:`conflict:skipped`,filePath:n,reason:`rename-choice`,entryReason:e?.reason})}},s;try{s=await v(e,n,{model:r.model,config:r.config,signal:r.signal})}catch(t){if(r.signal?.aborted)throw M(t);a([e.basePath,e.oursPath,e.theirsPath]);return}let c=s.path,l=[c===e.oursPath?e.theirsPath:e.oursPath,e.basePath],u=t.find(e=>e.path===c),d;try{d=await o(c,{git:r.git,signal:r.signal},u?.reason)}catch(e){if(r.signal?.aborted)throw M(e);i.errors.push({filePath:c,error:M(e)}),a(l);return}let f;if(!d)f={filePath:c,content:``,strategy:`mechanical`,confidence:s.confidence,description:s.description??`Both sides renamed ${e.basePath}; kept ${c}. Git settled the content itself, which is staged unchanged.`,retiredPaths:l};else{r.onProgress?.({type:`conflict:found`,filePath:d.filePath,conflictType:d.type,markerCount:d.markers.length});let e=await k(d,n,r,i.errors);if(!e){a(l);return}let t=[e.note,s.description].filter(e=>!!e).join(` `);f={...e,confidence:Math.min(e.confidence,s.confidence),retiredPaths:l,...t?{note:t}:{}}}i.resolutions.push(f),n.previousResolutions=[...n.previousResolutions??[],f],r.onProgress?.({type:`resolution:applied`,filePath:f.filePath,strategy:f.strategy,confidence:f.confidence,description:f.description})}async function k(n,r,i,a){try{return await p(n,r,{model:i.model,git:i.git,config:i.config,onProgress:i.onProgress,signal:i.signal,validator:i.validator})}catch(r){let o=M(r);if(i.signal?.aborted)throw o;let s=i.config?.fallbackStrategy;if(!s)return a.push({filePath:n.filePath,error:o}),i.onProgress?.({type:`resolution:failed`,filePath:n.filePath,error:o}),null;let c=i.config?.allowLossyFallback?null:u(n,s);if(c){let e=new t(`CODE_LOSS_RISK`,`AI resolution failed and the '${s}' fallback would replace ${n.filePath} wholesale, discarding the ${c.discardedSide} side and anything git auto-merged outside the conflict markers. Stopping for manual resolution.`,{cause:o});return a.push({filePath:n.filePath,error:e}),i.onProgress?.({type:`resolution:failed`,filePath:n.filePath,error:e}),null}return i.onProgress?.({type:`resolution:fallback`,filePath:n.filePath,error:o,cause:o instanceof e?o.code:void 0}),{...s===`take-ours`?d(n):f(n),confidence:0,fallback:!0}}}const A=new Set([`unspecified`,`set`,`text`]);async function j(e,t,n){if(e.stages?.stage2==null||e.stages?.stage3==null)return!1;if(t.includes(`\0`))return!0;let r=(await n()).get(e.path);return r!==void 0&&!A.has(r)}function M(e){return e instanceof Error?e:Error(String(e))}function N(e){let t=new Set,n=[];for(let r of e){let{category:e,location:i}=r,a=`${e}\u0000${i.file}\u0000${i.line}\u0000${i.symbol??``}`;t.has(a)||(t.add(a),n.push(r))}return n}export{S as resolveConflicts};
1
+ import{AIError as e}from"../errors/ai.js";import{ConflictError as t}from"../errors/conflict.js";import{mergeAttributes as n,readFile as r,unmergedEntries as i,unmergedStages as a}from"../git/port-dispatch.js";import{extractConflict as o,extractConflictWithSource as s}from"../extraction/extract.js";import{detect as c}from"../reference-integrity/detect.js";import{createResolution as l}from"../resolution/create-resolution.js";import{detectFallbackCodeLoss as u}from"../resolution/detect-code-loss.js";import{takeOurs as d,takeTheirs as f}from"../resolution/take-strategy.js";import{resolveConflict as p}from"./resolve-conflict.js";import{runFixPhase as m}from"./fix-loop.js";import{classifyUnmergedEntries as h,loneSideStageMode as g}from"./mechanical.js";import{createModelBudget as _}from"./model-budget.js";import{chooseSurvivingName as v}from"./rename-choice.js";import{findRenameRenameGroup as y}from"./rename-group.js";import b from"picomatch";function x(e){let t=e.map(e=>{let t=(Array.isArray(e.match)?e.match:[e.match]).map(e=>b(e,{basename:!e.includes(`/`)}));return{isMatch:e=>t.some(t=>t(e)),strategy:e.strategy}});return e=>{for(let{isMatch:n,strategy:r}of t)if(n(e))return r}}async function S(e,t){let r=e.config?.rules??[],a=e.config?.defaultStrategy??`ai`,o=x(r),c=await T(await i(e.git,{signal:e.signal}),e),u=h(c),p=_(e.config?.maxModelCalls),m=p.wrap(e.model),v={...t??{}},b=[],S=[],A=[],P=new Set,F,I=()=>(F??=n(e.git,c.map(e=>e.path),{signal:e.signal}).catch(()=>new Map),F),L=y(c),R=L?[L.basePath,L.oursPath,L.theirsPath]:[],z=L!==null&&R.every(e=>(o(e)??a)===`ai`),B=new Set;if(L&&z&&!e.signal?.aborted&&await D(L,e)){await O(L,c,v,{...e,model:m},{resolutions:b,errors:S,skipped:A});for(let e of R)B.add(e)}for(let t of c){if(e.signal?.aborted)break;if(B.has(t.path))continue;let n=o(t.path)??a;if(n===`skip`){A.push({filePath:t.path,reason:`excluded`}),e.onProgress?.({type:`conflict:excluded`,filePath:t.path});continue}let r=u.get(t.path);if(r?.kind===`removed`){w({filePath:t.path,content:``,strategy:`deleted`,confidence:1,description:`Undecided rename: both sides removed ${t.path}; the surviving name is unresolved.`},b,e);continue}if(n===`ai`&&r?.kind===`mechanical`){w({filePath:t.path,content:``,strategy:`mechanical`,confidence:1,description:`Git settled this path itself; its working-tree content is staged unchanged.`},b,e),t.stages!=null&&g(t.stages)===`120000`&&P.add(t.path);continue}if(n===`ai`&&r?.kind===`skip`){A.push({filePath:t.path,reason:r.reason,entryReason:t.reason,stages:t.stages}),e.onProgress?.({type:`conflict:skipped`,filePath:t.path,reason:r.reason,entryReason:t.reason});continue}let i,c;try{({conflict:i,rawContent:c}=await s(t.path,{git:e.git,signal:e.signal},t.reason))}catch(n){let r=N(n);S.push({filePath:t.path,error:r,reason:M(r,e.signal)});continue}if(!i){if(n===`take-ours`||n===`take-theirs`||n===`deleted`){let r=C(n,t.stages)?`deleted`:n;w({filePath:t.path,content:``,strategy:r,confidence:1,description:r===n?`No conflict markers; applied the configured '${n}' strategy directly.`:`No conflict markers, and the index holds no side to take; removed instead.`,...r===`deleted`?{}:{deliberateTake:!0}},b,e);continue}let r=await j(t,c,I)?`unmergeable`:`no-markers`;A.push({filePath:t.path,reason:r,entryReason:t.reason,stages:t.stages}),e.onProgress?.({type:`conflict:skipped`,filePath:t.path,reason:r,entryReason:t.reason});continue}e.onProgress?.({type:`conflict:found`,filePath:i.filePath,conflictType:i.type,markerCount:i.markers.length});let p=null;p=n===`take-ours`?{...d(i),deliberateTake:!0}:n===`take-theirs`?{...f(i),deliberateTake:!0}:n===`deleted`?l(i,[],{confidence:1,fileStrategy:`deleted`}):await k(i,v,{...e,model:m},S),p&&(v.previousResolutions=[...v.previousResolutions??[],p],w(p,b,e))}return{resolutions:b,errors:S,skipped:A,detection:await E(b,v,A,S,P,p,{...e,model:m})}}function C(e,t){return t?e===`take-ours`?t.stage2==null:e===`take-theirs`?t.stage3==null:!1:!1}function w(e,t,n){t.push(e),n.onProgress?.({type:`resolution:applied`,filePath:e.filePath,strategy:e.strategy,confidence:e.confidence,description:e.description})}async function T(e,t){if(!t.git.exec||!e.some(e=>e.stages===void 0))return e;let n=await a(t.git,{signal:t.signal});return e.map(e=>{if(e.stages)return e;let t=n.get(e.path);return t?{...e,stages:t}:e})}async function E(e,t,n,r,i,a,o){let s=new Set([...n.map(e=>e.filePath),...r.map(e=>e.filePath),...e.flatMap(e=>e.retiredPaths??[]),...i]),l={git:o.git,parser:o.parser,signal:o.signal,excludePaths:s,maxScopeFiles:o.config?.maxDetectionScopeFiles},u;if(o.signal?.aborted)u={findings:[],checks:{referenceIntegrity:`errored`,structural:`errored`}};else if(o.config?.fix&&o.parser)try{u=await m(e,t,{model:o.model,git:o.git,parser:o.parser,validator:o.validator,config:o.config,onProgress:o.onProgress,signal:o.signal,budget:a,excludePaths:s})}catch(n){if(o.signal?.aborted)throw n;o.onProgress?.({type:`fix:failed`,reason:N(n).message}),u=await c(e,t,l),u={...u,checks:{referenceIntegrity:`errored`,structural:`errored`}}}else u=await c(e,t,l);let d=e.flatMap(e=>e.findings??[]);return{...u,findings:P([...u.findings,...d])}}async function D(e,t){try{return await r(t.git,e.oursPath,{signal:t.signal})===await r(t.git,e.theirsPath,{signal:t.signal})}catch(e){if(t.signal?.aborted)throw N(e);return!1}}async function O(e,t,n,r,i){let a=e=>{for(let n of e){let e=t.find(e=>e.path===n);i.skipped.push({filePath:n,reason:`rename-choice`,entryReason:e?.reason,stages:e?.stages}),r.onProgress?.({type:`conflict:skipped`,filePath:n,reason:`rename-choice`,entryReason:e?.reason})}},s;try{s=await v(e,n,{model:r.model,config:r.config,signal:r.signal})}catch(t){if(r.signal?.aborted)throw N(t);a([e.basePath,e.oursPath,e.theirsPath]);return}let c=s.path,l=[c===e.oursPath?e.theirsPath:e.oursPath,e.basePath],u=t.find(e=>e.path===c),d;try{d=await o(c,{git:r.git,signal:r.signal},u?.reason)}catch(e){if(r.signal?.aborted)throw N(e);let t=N(e);i.errors.push({filePath:c,error:t,reason:M(t,r.signal)}),a(l);return}let f;if(!d)f={filePath:c,content:``,strategy:`mechanical`,confidence:s.confidence,description:s.description??`Both sides renamed ${e.basePath}; kept ${c}. Git settled the content itself, which is staged unchanged.`,retiredPaths:l};else{r.onProgress?.({type:`conflict:found`,filePath:d.filePath,conflictType:d.type,markerCount:d.markers.length});let e=await k(d,n,r,i.errors);if(!e){a(l);return}let t=[e.note,s.description].filter(e=>!!e).join(` `);f={...e,confidence:Math.min(e.confidence,s.confidence),retiredPaths:l,...t?{note:t}:{}}}i.resolutions.push(f),n.previousResolutions=[...n.previousResolutions??[],f],r.onProgress?.({type:`resolution:applied`,filePath:f.filePath,strategy:f.strategy,confidence:f.confidence,description:f.description})}async function k(n,r,i,a){let o,s={generate:async e=>{try{return await i.model.generate(e)}catch(e){throw o=e,e}}};try{return await p(n,r,{model:s,git:i.git,config:i.config,onProgress:i.onProgress,signal:i.signal,validator:i.validator})}catch(r){let s=N(r);if(i.signal?.aborted)throw s;let c=r===o?`model-error`:s instanceof e&&s.code===`VALIDATION_EXHAUSTED`?`validation-exhausted`:`unknown`,l=i.config?.fallbackStrategy;if(!l)return a.push({filePath:n.filePath,error:s,reason:c}),i.onProgress?.({type:`resolution:failed`,filePath:n.filePath,error:s}),null;let p=i.config?.allowLossyFallback?null:u(n,l);if(p){let e=new t(`CODE_LOSS_RISK`,`AI resolution failed and the '${l}' fallback would replace ${n.filePath} wholesale, discarding the ${p.discardedSide} side and anything git auto-merged outside the conflict markers. Stopping for manual resolution. Resolver failure: ${s.message}`,{cause:s});return a.push({filePath:n.filePath,error:e,reason:`code-loss-risk`,causeReason:c}),i.onProgress?.({type:`resolution:failed`,filePath:n.filePath,error:e}),null}return i.onProgress?.({type:`resolution:fallback`,filePath:n.filePath,error:s,cause:s instanceof e?s.code:void 0}),{...l===`take-ours`?d(n):f(n),confidence:0,fallback:!0}}}const A=new Set([`unspecified`,`set`,`text`]);async function j(e,t,n){if(e.stages?.stage2==null||e.stages?.stage3==null)return!1;if(t.includes(`\0`))return!0;let r=(await n()).get(e.path);return r!==void 0&&!A.has(r)}function M(e,n){return n?.aborted?`aborted`:e instanceof t&&e.code===`PARSE_FAILED`?`parse-failed`:`read-failed`}function N(e){return e instanceof Error?e:Error(String(e))}function P(e){let t=new Set,n=[];for(let r of e){let{category:e,location:i}=r,a=`${e}\u0000${i.file}\u0000${i.line}\u0000${i.symbol??``}`;t.has(a)||(t.add(a),n.push(r))}return n}export{S as resolveConflicts};
@@ -21,12 +21,27 @@ interface SkippedEntry {
21
21
  entryReason?: string;
22
22
  stages?: UnmergedStages;
23
23
  }
24
+ /** Why the resolver produced nothing for a file it tried to resolve. `error` is the original error, unchanged.
25
+ * - `'read-failed'` — the file could not be read from the working tree.
26
+ * - `'parse-failed'` — the conflict markers could not be parsed (`ConflictError` `PARSE_FAILED`).
27
+ * - `'model-error'` — the model call itself threw: a provider error, a timeout, a rejected request.
28
+ * - `'validation-exhausted'` — the model produced no valid answer within `maxSteps`, whether its answers
29
+ * failed validation or it spent the steps on tool calls (`AIError` `VALIDATION_EXHAUSTED`).
30
+ * - `'code-loss-risk'` — the resolver failed and the configured `fallbackStrategy` was withheld because it
31
+ * would discard work (`ConflictError` `CODE_LOSS_RISK`); `causeReason` says why the resolver failed.
32
+ * - `'aborted'` — the operation's signal was aborted while the file was being read.
33
+ * - `'unknown'` — any other error. */
34
+ type FailureReason = 'read-failed' | 'parse-failed' | 'model-error' | 'validation-exhausted' | 'code-loss-risk' | 'aborted' | 'unknown';
35
+ interface FailedEntry {
36
+ filePath: string;
37
+ error: Error;
38
+ reason: FailureReason;
39
+ /** Only on `'code-loss-risk'`: the reason the resolver failed before the fallback was withheld. */
40
+ causeReason?: FailureReason;
41
+ }
24
42
  interface StepResult {
25
43
  resolutions: Resolution[];
26
- errors: {
27
- filePath: string;
28
- error: Error;
29
- }[];
44
+ errors: FailedEntry[];
30
45
  skipped?: SkippedEntry[];
31
46
  detection?: DetectionReport;
32
47
  }
@@ -37,6 +52,10 @@ interface FileRule {
37
52
  interface StepConfig extends ResolverConfig {
38
53
  defaultStrategy?: 'ai' | 'take-ours' | 'take-theirs' | 'skip' | 'deleted';
39
54
  rules?: FileRule[];
55
+ /** Side to take wholesale when AI resolution fails for a file. The take replaces the whole file, so it is
56
+ * withheld whenever it would discard work: a text conflict with markers, and a delete-modify taken
57
+ * toward the deleting side, always end as a `CODE_LOSS_RISK` error in `StepResult.errors` unless
58
+ * `allowLossyFallback` is set. Only a delete-modify taken toward the side that keeps content applies. */
40
59
  fallbackStrategy?: 'take-ours' | 'take-theirs';
41
60
  /** Escape hatch: apply the wholesale `fallbackStrategy` even when it would discard already-integrated
42
61
  * work. Off by default so a lossy fallback stops for manual resolution instead of completing silently. */
@@ -67,4 +86,4 @@ interface StepConfig extends ResolverConfig {
67
86
  maxModelCalls?: number;
68
87
  }
69
88
  //#endregion
70
- export { FileRule, SkippedEntry, StepConfig, StepResult };
89
+ export { FailedEntry, FailureReason, FileRule, SkippedEntry, StepConfig, StepResult };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitkraken/conflict-tools",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "AI-powered git conflict resolution.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Axosoft, LLC dba GitKraken",