@aefree/pi-plastic 0.8.4 → 0.8.5

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.
@@ -14,6 +14,8 @@ export type PendingItem = {
14
14
  revisionId?: string;
15
15
  sourceWorkspacePath?: string;
16
16
  baseRepository?: string;
17
+ /** Observation-only fraction in [0, 1]; producer precision is transport-specific. */
18
+ similarity?: number;
17
19
  };
18
20
 
19
21
  export type PendingItemSummary = {
@@ -35,6 +37,8 @@ export type MachineReadableStatusItem = {
35
37
  isDirectory: boolean;
36
38
  revisionId?: string;
37
39
  sourcePath?: string;
40
+ /** Observation-only fraction in [0, 1], never rename authorization. */
41
+ similarity?: number;
38
42
  };
39
43
 
40
44
  export type MachineReadableStatusSummary = Omit<PendingItemSummary, "privatePaths">;
@@ -185,11 +189,29 @@ export const parseMachineReadablePendingItems = (output: string, cwd: string): P
185
189
  export const hasStatusPathDecodingLoss = (path: string, platform: NodeJS.Platform = process.platform): boolean =>
186
190
  path.includes("\uFFFD") || (platform === "win32" && path.includes("?"));
187
191
 
192
+ // Observation-only lexical identities. Never use these diagnostics as mutation admission.
193
+ export const statusObservationPathKey = (value: string): string | null => {
194
+ if (!value || value.length > 4096 || /[\u0000-\u001f\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(value)) return null;
195
+ const path = value.replace(/\\/g, "/");
196
+ if (!/^(?:\/|[A-Za-z]:\/)/.test(path) || path.split("/").some(part => part === "." || part === "..")) return null;
197
+ const windows = /^[A-Za-z]:\//.test(path) || path.startsWith("//");
198
+ const tail = path.slice(path.startsWith("//") ? 2 : windows ? 3 : 1);
199
+ if (tail.includes("//") || (windows && (/[<>:"|?*]/.test(tail) || tail.split("/").some(part => /[. ]$/.test(part))))) return null;
200
+ if (path.startsWith("//") && !/^\/\/[^/]+\/[^/]+\//.test(path)) return null;
201
+ const key = windows ? path.toLowerCase() : path;
202
+ return key.length > (windows ? 3 : 1) ? key.replace(/\/$/, "") : key;
203
+ };
204
+ export const statusObservationPathRoot = (value: string): string => {
205
+ const path = value.replace(/\\/g, "/").toLowerCase();
206
+ return path.match(/^[a-z]:/)?.[0] ?? (path.startsWith("//") ? path.split("/").slice(0, 4).join("/") : "/");
207
+ };
208
+
188
209
  // Diagnostics deliberately leave the legacy selection parser unchanged for mutation/diff consumers.
189
210
  export const diagnoseMachineReadablePendingItems = (output: string, cwd: string, platform: NodeJS.Platform = process.platform) =>
190
211
  {
191
212
  const diagnostics = { valid: 0, blank: 0, header: 0, unsupported: 0, malformed: 0, ambiguousLegacyMove: 0 };
192
213
  const pendingItems: PendingItem[] = [];
214
+ const seen = new Set<string>();
193
215
  for (const rawLine of output.split(/\r?\n/))
194
216
  {
195
217
  const line = rawLine.trim();
@@ -198,7 +220,32 @@ export const diagnoseMachineReadablePendingItems = (output: string, cwd: string,
198
220
  if (fields[0] === "STATUS" && fields.length === 4 && /^\d+$/.test(fields[1]) && fields[2] && fields[3]) { diagnostics.header++; continue; }
199
221
  if (/^STATUS \d+ \S+ \S+$/.test(line)) { diagnostics.header++; continue; }
200
222
  if (fields[0] === "STATUS" || line.startsWith("STATUS ")) { diagnostics.malformed++; continue; }
223
+ // Inferred moves are observed separately; legacy mutation/diff selection is unchanged.
224
+ if (fields[0] === "LM") {
225
+ const [, percent, source, destination, directory, revision, metadata] = fields;
226
+ const sourceKey = statusObservationPathKey(source ?? "");
227
+ const destinationKey = statusObservationPathKey(destination ?? "");
228
+ const validPercent = (percent?.length ?? 0) <= 64 && /^(?:100(?:\.0+)?|[0-9]{1,2}(?:\.[0-9]+)?)%$/.test(percent ?? "");
229
+ if (fields.length !== 7 || !validPercent || !sourceKey || !destinationKey || sourceKey === destinationKey ||
230
+ statusObservationPathRoot(source) !== statusObservationPathRoot(destination) ||
231
+ seen.has(sourceKey) || seen.has(destinationKey) || (directory !== "True" && directory !== "False") ||
232
+ !/^[1-9][0-9]{0,19}$/.test(revision ?? "") || metadata !== "NO_MERGES" ||
233
+ [source, destination].some(path => hasStatusPathDecodingLoss(path, platform))) {
234
+ diagnostics.malformed++; continue;
235
+ }
236
+ seen.add(sourceKey); seen.add(destinationKey);
237
+ pendingItems.push({ statusCode: "LM", kind: "moved", workspacePath: destination,
238
+ normalizedPath: toNormalizedAbsolutePath(destination, cwd), comparisonKey: toPathComparisonKey(destination, cwd),
239
+ isDirectory: directory === "True", revisionId: revision, sourceWorkspacePath: source, similarity: Number(percent.slice(0, -1)) / 100 });
240
+ diagnostics.valid++; continue;
241
+ }
201
242
  const items = parseMachineReadablePendingItems(line, cwd);
243
+ for (const item of items) {
244
+ for (const path of [item.workspacePath, item.sourceWorkspacePath].filter((path): path is string => path !== undefined)) {
245
+ const key = statusObservationPathKey(path);
246
+ if (key) { if (seen.has(key)) diagnostics.malformed++; seen.add(key); }
247
+ }
248
+ }
202
249
  pendingItems.push(...items);
203
250
  if (items.length) {
204
251
  diagnostics.valid++;
@@ -239,6 +286,7 @@ export const toMachineReadableStatusItems = (pendingItems: PendingItem[]): Machi
239
286
  isDirectory: item.isDirectory,
240
287
  ...(item.revisionId ? { revisionId: item.revisionId } : {}),
241
288
  ...(item.sourceWorkspacePath ? { sourcePath: item.sourceWorkspacePath } : {}),
289
+ ...(item.similarity !== undefined ? { similarity: item.similarity } : {}),
242
290
  }));
243
291
 
244
292
  export const toMachineReadableStatusSummary = (pendingItems: PendingItem[], cwd: string): MachineReadableStatusSummary =>
@@ -13,7 +13,7 @@ export type MergeReceiptData = {
13
13
  capability: { state: "not-checked" | "advertised" | "missing" | "unavailable"; missingTokens: string[] };
14
14
  attempt: MergeAttempt;
15
15
  capture: { help: MergeCapture | null; merge: MergeCapture | null };
16
- parse: { records: number; malformed: boolean; unknownOperations: number; changesetsObserved: number; conflictsObserved: number; warningsObserved: number } | null;
16
+ parse: { records: number; malformed: boolean; unknownOperations: number; changesetsObserved: number; conflictsObserved: number; warningsObserved: number; unknownOperationClasses: string[]; resolvedConflictsVerified: number } | null;
17
17
  observedChangesets: ServerChangeset[];
18
18
  conflictPaths: string[];
19
19
  counts: { changesetsReturned: number; changesetsOmitted: number; conflictsReturned: number; conflictsOmitted: number };
@@ -1,5 +1,5 @@
1
1
  import { DOMParser, type Element } from "@xmldom/xmldom";
2
- import type { MachineReadableStatusItem, MachineReadableStatusSummary } from "./pending";
2
+ import { statusObservationPathKey, statusObservationPathRoot, type MachineReadableStatusItem, type MachineReadableStatusSummary } from "./pending";
3
3
 
4
4
  import { STATUS_XML_LIMITS } from "../execution/status-xml-limits";
5
5
  export { STATUS_XML_LIMITS } from "../execution/status-xml-limits";
@@ -13,11 +13,12 @@ export class StatusXmlError extends Error {
13
13
  }
14
14
  function invalid(): never { throw new StatusXmlError(); }
15
15
  function limit(): never { throw new StatusXmlError("output_overflow"); }
16
- const kinds = { CH: "changed", CO: "changed", CP: "added", RP: "changed", AD: "added", DE: "deleted", LD: "deleted", MV: "moved", PR: "private" } as const;
16
+ const kinds = { CH: "changed", CO: "changed", CP: "added", RP: "changed", AD: "added", DE: "deleted", LD: "deleted", MV: "moved", LM: "moved", PR: "private" } as const;
17
17
  const fields = new Set(["Type", "TypeVerbose", "Path", "OldPath", "PrintableMovedPath", "MergesInfo", "SimilarityPerUnit", "Similarity", "Size", "PrintableSize", "PrintableLastModified", "RevisionType", "LastModified"]);
18
18
  const isAbsolute = (path: string): boolean => path.startsWith("/") || /^[A-Za-z]:\\/.test(path) || /^\\\\[^\\]+\\[^\\]+\\/.test(path);
19
19
  const identity = (value: string): string => {
20
20
  if (!value || value.length > STATUS_XML_LIMITS.path || !isAbsolute(value) || /[\u0000-\u001f\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(value) || (process.platform === "win32" && value.includes("?"))) invalid();
21
+ if (!statusObservationPathKey(value)) invalid();
21
22
  return value;
22
23
  };
23
24
 
@@ -107,13 +108,26 @@ export function parseStatusXml(bytes: Uint8Array): { output: string; items: XmlS
107
108
  if (!/^en[A-Za-z0-9]+$/.test(revisionType)) invalid();
108
109
  const supportedType = revisionType === "enDirectory" || revisionType === "enTextFile" || revisionType === "enBinaryFile";
109
110
  if (supportedStatus && (kind === "moved") !== Boolean(oldPath)) invalid();
110
- if (oldPath && identity(oldPath) === path) invalid();
111
- if (seen.has(path)) invalid();
112
- seen.add(path);
111
+ if (oldPath) identity(oldPath);
112
+ const keys = [path, ...(oldPath ? [oldPath] : [])].map(value => statusObservationPathKey(value)!);
113
+ if (new Set(keys).size !== keys.length || keys.some(key => seen.has(key))) invalid();
114
+ for (const key of keys) seen.add(key);
115
+ let similarity: number | undefined;
116
+ if (statusCode === "LM") {
117
+ if (statusObservationPathRoot(path) !== statusObservationPathRoot(oldPath)) invalid();
118
+ const perUnit = leaf(row.get("SimilarityPerUnit"));
119
+ if (perUnit.length > 64 || !/^(?:0(?:\.[0-9]+)?|1(?:\.0+)?)$/.test(perUnit)) invalid();
120
+ similarity = Number(perUnit);
121
+ // Rounded display is neither the identity nor an exact equality check on the fraction.
122
+ if (row.has("Similarity")) {
123
+ const display = leaf(row.get("Similarity"));
124
+ if (display.length > 64 || !/^(?:100(?:\.0+)?|[0-9]{1,2}(?:\.[0-9]+)?)%$/.test(display)) invalid();
125
+ }
126
+ }
113
127
  // Validate the entire document before reporting unsupported profiles: a later unsafe
114
128
  // identity/structure must not be masked by an earlier unadmitted record.
115
129
  if (!supportedStatus || !supportedType) { unsupported = true; continue; }
116
- items.push({ statusCode: statusCode as keyof typeof kinds, kind: kind!, path, isDirectory: revisionType === "enDirectory", ...(oldPath ? { sourcePath: oldPath } : {}) });
130
+ items.push({ statusCode: statusCode as keyof typeof kinds, kind: kind!, path, isDirectory: revisionType === "enDirectory", ...(oldPath ? { sourcePath: oldPath } : {}), ...(similarity !== undefined ? { similarity } : {}) });
117
131
  }
118
132
  if (unsupported) throw new StatusXmlError("unsupported_source");
119
133
  const summary: MachineReadableStatusSummary = { totalPending: items.length, added: 0, changed: 0, moved: 0, deleted: 0, private: 0, other: 0, tracked: 0 };
@@ -1,3 +1,6 @@
1
+ import { win32 } from "node:path";
2
+ import { admitMergedStandardStatus, correlateMergedCheckin } from "../domain/merged-workspace";
3
+ import { parseSwitchLoadedBranch } from "../domain/switch-contract";
1
4
  import { randomBytes } from "node:crypto";
2
5
  import { buildFallbackScopePaths } from "../domain/paths";
3
6
  import { summarizePendingItems, resolveCheckinPaths, filterPendingItemsByScope, selectPrivatePathsForAutoAdd, type PendingItem } from "../domain/pending";
@@ -7,6 +10,34 @@ const pendingArgv = ["status", "--machinereadable", "--includeRevId", "--fieldse
7
10
  const summary = (items: PendingItem[], cwd: string): CheckinSummary => { const { privatePaths: _, ...s } = summarizePendingItems(items, cwd); return s; };
8
11
  const normal = (o: CheckinCommandObservation) => o.capture.complete && !o.failed && o.attempt.state === "started" && o.attempt.terminal === "observed" && o.attempt.exitCode !== null && !o.attempt.aborted && !o.attempt.timedOut;
9
12
  const success = (o: CheckinCommandObservation) => normal(o) && o.attempt.exitCode === 0 && o.capture.stderrBytes === 0;
13
+ // Inspect section/row markers, never words embedded in a branch or filename.
14
+ // Default status can omit unchanged checkouts, so a valid header-only body is
15
+ // allowed; unfamiliar sections/metadata still cannot establish ordinary state.
16
+ function ordinaryStatusBodyAdmitted(text: string): boolean {
17
+ const shapes = {
18
+ Changed: { header: /^ Status +Size +Last Modified +Path *$/, label: "(?:Changed|Checked-out \\((?:changed|unchanged)\\))" },
19
+ Added: { header: /^ Status +Size +Last Modified +Path *$/, label: "(?:Added|Private)" },
20
+ Deleted: { header: /^ Status +Size +Path *$/, label: "(?:Deleted|Locally deleted|Removed)" },
21
+ Moved: { header: /^ Status +Size +Similarity +Path *$/, label: "(?:Moved|Moved locally)" },
22
+ };
23
+ let section: keyof typeof shapes | null = null, needsHeader = false;
24
+ const seen = new Set<string>();
25
+ for (const line of text.split(/\r?\n/).slice(1)) {
26
+ if (!line.trim()) continue;
27
+ if (line.length > 4096 || /[\u0000-\u001f\u007f-\u009f]/.test(line)) return false;
28
+ if (Object.hasOwn(shapes, line)) {
29
+ if (needsHeader || seen.has(line)) return false;
30
+ seen.add(line); section = line as keyof typeof shapes; needsHeader = true; continue;
31
+ }
32
+ if (!section) return false;
33
+ const shape = shapes[section];
34
+ if (needsHeader) { if (!shape.header.test(line)) return false; needsHeader = false; continue; }
35
+ // A status-label annotation is metadata, unlike a parenthesis in the
36
+ // later path cell. Unknown annotations (including Merge from) fail.
37
+ if (new RegExp(`^ ${shape.label} +\\(`).test(line) || !new RegExp(`^ ${shape.label} {2,}.+$`).test(line)) return false;
38
+ }
39
+ return !needsHeader;
40
+ }
10
41
  function validateRequest(input: unknown): asserts input is CheckinRequest {
11
42
  if (!input || typeof input !== "object" || Array.isArray(input)) throw Error("Invalid bounded checkin input.");
12
43
  const r = input as Record<string, unknown>;
@@ -66,6 +97,21 @@ export async function assembleCheckinReceipt(input: unknown): Promise<CheckinRec
66
97
  const before = await read("pending-before");
67
98
  if (!before) return finish("unsupported", "pending_source_unadmitted", "pending", "Pending selection could not be admitted; no mutation was dispatched.");
68
99
  data.pendingBefore = summary(before.items, cwd);
100
+ const merged = before.merged;
101
+ let loaded: ReturnType<typeof parseSwitchLoadedBranch> = null;
102
+ // CO/-1/NO_MERGES also occurs during an unresolved merge. Read the
103
+ // native link state before any checkin, including standalone/preflight.
104
+ if (merged || before.items.some(i => i.statusCode === "CO")) {
105
+ const { o, s } = await record("merge-readiness", ["status"]);
106
+ loaded = success(o) ? parseSwitchLoadedBranch(o.stdout ?? "") : null;
107
+ const source = /^ Merge from cs:[1-9][0-9]{0,19} at (.+)\r?$/m.exec(o.stdout ?? "")?.[1];
108
+ const ready = loaded && loaded.repository === before.repository && loaded.server === before.server && (merged
109
+ ? !!source && admitMergedStandardStatus(o.stdout!, merged, source, cwd)
110
+ : ordinaryStatusBodyAdmitted(o.stdout ?? ""));
111
+ if (!ready) return finish("unsupported", "merge_readiness_unadmitted", "pending", "Merge source/resolution metadata could not be admitted; no checkin was dispatched.");
112
+ s.evidence = "admitted";
113
+ }
114
+ if (merged && input.includePrivate) return finish("unsupported", "merged_private_scope", "pending", "Merged checkin does not admit private inclusion; leave unrelated private work excluded.");
69
115
  let includedAbsolute: string[] = [], scoped = before.items, apply = input.applyChanged ?? false;
70
116
  if (input.paths?.length) {
71
117
  const r = resolveCheckinPaths(input.paths, before.items, cwd);
@@ -74,20 +120,33 @@ export async function assembleCheckinReceipt(input: unknown): Promise<CheckinRec
74
120
  scoped = filterPendingItemsByScope(before.items, includedAbsolute);
75
121
  if (!data.includedPaths.length) return finish("failed", "NO_PENDING_PATHS", "pending", "None of the provided checkin paths have admitted pending changes.");
76
122
  }
123
+ if (merged) {
124
+ data.excludedPaths.push(...before.items.filter(i => i.kind === "private").map(i => ({ path: i.workspacePath, reason: "Unrelated private work excluded from merged checkin (includePrivate=false)." })));
125
+ scoped = scoped.filter(i => i.kind !== "private");
126
+ if (merged.privateRows.length) {
127
+ // Do not rely on workspace-wide --all selection in the presence
128
+ // of private work. These directories are proven disjoint above.
129
+ data.includedPaths = merged.rows.map(r => win32.relative(cwd, r.path));
130
+ }
131
+ // All merged controlled rows must remain selected. Partial merge
132
+ // checkins and directory scopes overlapping private rows are unproven.
133
+ if (scoped.length !== merged.rows.length) return finish("unsupported", "merged_partial_scope", "pending", "All correlated merged controlled paths must be selected.");
134
+ }
77
135
  const nonce = randomBytes(16).toString("hex"), seps = { start: `__PI_CI_START_${nonce}__`, end: `__PI_CI_END_${nonce}__`, field: `__PI_CI_FIELD_${nonce}__` };
78
136
  const command = (paths: string[], force: boolean) => ["checkin", `-c=${input.message}`, ...(input.includeAll ? ["--all"] : []), ...(force ? ["--applychanged"] : []), ...(input.includePrivate ? ["--private"] : []), ...paths, "--machinereadable", `--startlineseparator=${seps.start}`, `--endlineseparator=${seps.end}`, `--fieldseparator=${seps.field}`];
79
137
  const initial = command(data.includedPaths, apply); data.command = ["cm", ...initial]; data.wouldRun = true;
80
138
  if (input.preflight) return finish("preflight");
81
139
  const attempt = async (name: "initial-checkin" | "private-retry" | "fallback-checkin", argv: string[]) => {
82
140
  const { o, s } = await record(name, argv), p = parseCheckinEvidence(o.stdout ?? "", seps); s.records = p.records;
83
- const admitted = p.admitted && p.changesets.every(c => c.repository === before.repository);
141
+ const mergedEvents = !merged || correlateMergedCheckin(p.events, merged, cwd);
142
+ const admitted = p.admitted && mergedEvents && p.changesets.every(c => c.repository === before.repository && c.server === before.server && (!loaded || c.branch === loaded.branch));
84
143
  if (admitted && normal(o)) {
85
144
  s.evidence = "admitted";
86
145
  data.itemEvents.push(...p.events); data.observedChangesets.push(...p.changesets);
87
146
  }
88
147
  if (success(o) && admitted && p.changesets.length === 1) { created = p.changesets[0]; s.changeset = created; s.effect = "changeset-created"; }
89
148
  // Only genuine complete native failure without a created record may drive the existing recovery policy.
90
- const retryEligible = normal(o) && admitted && o.attempt.exitCode !== 0 && p.changesets.length === 0;
149
+ const retryEligible = !merged && normal(o) && admitted && o.attempt.exitCode !== 0 && p.changesets.length === 0;
91
150
  return { completed: !!s.changeset, retryEligible, message: retryEligible ? o.stderr ?? "" : "" };
92
151
  };
93
152
  stage = "checkin";
@@ -17,10 +17,23 @@ export type DiffRequest = { workdir?:string;format?:"text"|"json";maxChars?:numb
17
17
  {mode:"workspace";paths:string[];allPending?:never;includePrivate?:never;maxFiles?:number} |
18
18
  {mode:"workspace";allPending:true;paths?:never;includePrivate?:boolean;maxFiles?:number}
19
19
  );
20
+ const baseMessages = {
21
+ "fileinfo-command": "Loaded-base fileinfo observation failed. No baseline was selected; inspect command/capture state before another attempt.",
22
+ "fileinfo-profile": "Loaded-base fileinfo XML is unsupported or malformed. Retain sanitized producer evidence; no baseline was guessed.",
23
+ "fileinfo-path": "Loaded-base fileinfo path does not match the requested workspace path. No baseline was selected.",
24
+ "fileinfo-type": "Loaded-base fileinfo item type is unsupported. No baseline was selected.",
25
+ "deletion-state": "Loaded deletion metadata conflicts with pending status. No empty baseline was substituted.",
26
+ "ls-command": "Loaded-base ls observation failed. No baseline was selected; inspect command/capture state before another attempt.",
27
+ "ls-profile": "Loaded-base ls XML is unsupported or malformed. Retain sanitized producer evidence; no baseline was guessed.",
28
+ "ls-path": "Loaded-base tree path does not match the inspected path. No baseline was selected.",
29
+ "revision-mismatch": "Loaded-base changeset/hash observations disagree. No baseline was selected; workspace changes may have displaced the observation.",
30
+ "ownership-mismatch": "Loaded-base repository/server identity is inconsistent. Alias equivalence is unverified. No baseline was selected.",
31
+ } as const;
32
+ type BaseReason = keyof typeof baseMessages;
20
33
  export class ConsolidatedDiffError extends Error {
21
- constructor(readonly code:string,readonly stage:string) { super("The requested diff could not be verified."); }
34
+ constructor(readonly code:string,readonly stage:string,readonly reason?:BaseReason) { super("The requested diff could not be verified."); }
22
35
  }
23
- const fail=(code:string,stage:string):never=>{throw new ConsolidatedDiffError(code,stage);};
36
+ const fail=(code:string,stage:string,reason?:BaseReason):never=>{throw new ConsolidatedDiffError(code,stage,reason);};
24
37
  const aborted=()=>{if(getActiveAbortSignal()?.aborted)fail("aborted","comparison");};
25
38
  const safe=(s:unknown,max:number):s is string=>typeof s==="string"&&s.length>0&&s.length<=max&&s.trim()===s&&!/[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(s);
26
39
  export function validateDiffRequest(input:unknown):asserts input is DiffRequest {
@@ -76,21 +89,32 @@ function comparison(observation:DiffRevisionsObservation,left:DiffSide,right:Dif
76
89
  const side=(origin:DiffSide["origin"],bytes:Buffer,path:string|null,selector:string|null,identity:LoadedIdentity|null=null):DiffSide=>({origin,path,selector,identity,bytes:bytes.length,binary:isBinaryContent(bytes)});
77
90
  async function pending(cwd:string):Promise<DiffPendingSnapshot>{try{return parseDiffPending(await xmlCommand(["status","--xml","--encoding=utf-8","--fullpaths","--iscochanged"],cwd,"status"));}catch(e){if(e instanceof ConsolidatedDiffError)throw e;return fail("malformed_output","status");}}
78
91
  async function base(path:string,cwd:string,snapshot:DiffPendingSnapshot,item:DiffPendingItem|null):Promise<{selector:string;identity:LoadedIdentity;hash:string;algorithm:"md5"|"sha1"}>{
92
+ let phase:"fileinfo-profile"|"ls-profile"="fileinfo-profile";
79
93
  try {
80
94
  const info=parseLoadedFileInfo(await xmlCommand(["fileinfo",path,"--fields=ClientPath,ServerPath,RevisionChangeset,Hash,RepSpec,Status,Type","--xml","--encoding=utf-8"],cwd,"base"));
81
- if(key(info.clientPath)!==key(path)||!["txt","bin","file"].includes(info.type.toLowerCase()))return fail("base_unavailable","base");
95
+ if(key(info.clientPath)!==key(path))return fail("base_unavailable","base","fileinfo-path");
96
+ if(!["txt","bin","file"].includes(info.type.toLowerCase()))return fail("base_unavailable","base","fileinfo-type");
82
97
  // DE removes live-tree membership/RepSpec. Deliberately inspect the
83
98
  // frozen loaded root tree, never retry a failed live-tree lookup or
84
99
  // guess a parent owning repository. Require loaded changeset/hash match.
85
100
  const deleted=item?.code==="DE";
86
- if(deleted&&info.status!=="deleted")return fail("base_changed","base");
101
+ if(deleted&&info.status!=="deleted")return fail("base_changed","base","deletion-state");
87
102
  const query=deleted?["ls",info.serverPath,"--tree=cs:"+snapshot.changeset+"@"+snapshot.repository+"@"+snapshot.server,"--xml","--encoding=utf-8"]:["ls",path,"--xml","--encoding=utf-8"];
103
+ phase="ls-profile";
88
104
  const row=parseLoadedLs(await xmlCommand(query,cwd,"base"));
89
- if((deleted?row.path!==info.serverPath:key(row.path)!==key(path))||row.changeset!==info.changeset||row.hash!==info.hash||!deleted&&info.repSpec!==row.repository+"@"+row.server)return fail("base_changed","base");
105
+ if(deleted?row.path!==info.serverPath:key(row.path)!==key(path))return fail("base_changed","base","ls-path");
106
+ if(row.changeset!==info.changeset||row.hash!==info.hash)return fail("base_changed","base","revision-mismatch");
107
+ if(!deleted&&info.repSpec!==row.repository+"@"+row.server)return fail("base_changed","base","ownership-mismatch");
90
108
  const selector="revid:"+row.revisionId+"@rep:"+row.repository+"@repserver:"+row.server;
91
109
  if(!validDiffRevisionsSelector(selector))return fail("invalid_selector","base");
92
110
  return {selector,identity:{revisionId:row.revisionId,changeset:row.changeset,repository:row.repository,server:row.server},hash:row.hash,algorithm:row.algorithm};
93
- }catch(e){if(e instanceof ConsolidatedDiffError)throw e;return fail("base_unavailable","base");}
111
+ }catch(e){
112
+ if(e instanceof ConsolidatedDiffError){
113
+ if(e.code==="command_failed")return fail(e.code,e.stage,phase==="fileinfo-profile"?"fileinfo-command":"ls-command");
114
+ throw e;
115
+ }
116
+ return fail("base_unavailable","base",phase);
117
+ }
94
118
  }
95
119
  async function fileComparison(args:DiffRequest&{mode:"file"},cwd:string,root:string,snapshot:DiffPendingSnapshot|null,item:DiffPendingItem|null):Promise<Comparison>{
96
120
  const path=resolve(cwd,args.path);if(!contains(root,path))return fail("outside_workspace","selection");
@@ -111,7 +135,7 @@ async function fileComparison(args:DiffRequest&{mode:"file"},cwd:string,root:str
111
135
  } finally {await fs.rm(temp,{recursive:true,force:true}).catch(()=>fail("cleanup_failed","cleanup"));}
112
136
  }
113
137
  export type DiffOutcome={path:string;sourcePath:string|null;kind:string;status:"compared";comparison:Comparison}|{path:string;sourcePath:string|null;kind:string;status:"skipped-directory"}|{path:string;sourcePath:string|null;kind:string;status:"unavailable";error:{code:string;stage:string;message:string}};
114
- export function diffError(e:unknown){const r=e as {code?:unknown;stage?:unknown};const codes=new Set(["invalid_request","invalid_selector","workspace_unavailable","outside_workspace","base_unavailable","base_changed","malformed_output","command_failed","aborted","cleanup_failed","materialization_failed","materialization_limit","invalid_utf8","capture_incomplete","output_overflow","missing_file","unsupported_file","file_limit","file_changed","read_failed"]);return {code:typeof r?.code==="string"&&codes.has(r.code)?r.code:"comparison_failed",stage:typeof r?.stage==="string"&&["input","selection","status","base","left","right","comparison","cleanup","producer"].includes(r.stage)?r.stage:"comparison",message:"The requested diff could not be verified."};}
138
+ export function diffError(e:unknown){const r=e as {code?:unknown;stage?:unknown};const codes=new Set(["invalid_request","invalid_selector","workspace_unavailable","outside_workspace","base_unavailable","base_changed","malformed_output","command_failed","aborted","cleanup_failed","materialization_failed","materialization_limit","invalid_utf8","capture_incomplete","output_overflow","missing_file","unsupported_file","file_limit","file_changed","read_failed"]);return {code:typeof r?.code==="string"&&codes.has(r.code)?r.code:"comparison_failed",stage:typeof r?.stage==="string"&&["input","selection","status","base","left","right","comparison","cleanup","producer"].includes(r.stage)?r.stage:"comparison",message:e instanceof ConsolidatedDiffError&&e.reason?baseMessages[e.reason]:r?.code==="materialization_limit"?"Historical file exceeds the 4 MiB materialization limit. maxChars controls output only; no empty baseline was substituted.":"The requested diff could not be verified."};}
115
139
  export async function assembleConsolidatedDiff(input:unknown){
116
140
  validateDiffRequest(input);const args=input;aborted();
117
141
  const header={schemaVersion:1 as const,action:"diff" as const,mode:args.mode,provenance:{source:"plastic" as const,producer:"@aefree/pi-plastic" as const,contentTrust:"external" as const}};
@@ -39,7 +39,7 @@ export function patchFailure(d: PatchData, code: Extract<PatchReceipt,{ok:false}
39
39
  return {...header(d),ok:false,outcome:["started","unknown"].includes(d.attempt.state)&&!d.artifact?"uncertain":"failed",error:{code,message}};
40
40
  }
41
41
  export async function assemblePatchReceipt(args: unknown): Promise<PatchReceipt> {
42
- const d=emptyPatchData();let stage: string|null=null;let entered=false;let failure: {code:Extract<PatchReceipt,{ok:false}>["error"]["code"];message:string}|null=null;
42
+ const d=emptyPatchData();let stage: string|null=null;let setupStage:"backend"|"selector"|"output"="backend";let entered=false;let failure: {code:Extract<PatchReceipt,{ok:false}>["error"]["code"];message:string}|null=null;
43
43
  try {
44
44
  if (!args||typeof args!=="object"||Array.isArray(args)||Object.keys(args).some(k=>!["source","destination","output","toolPath","clean","integration","workdir","preflight","format"].includes(k)))
45
45
  return patchFailure(d,"invalid_request","Use declared bounded patch controls only.");
@@ -49,11 +49,13 @@ export async function assemblePatchReceipt(args: unknown): Promise<PatchReceipt>
49
49
  d.requested=r;d.publication=r.output===null?"not-requested":"not-published";
50
50
  const toolPath=resolvePatchToolPath(r.toolPath??undefined);
51
51
  if (!validPatchText(toolPath)) throw new Error("Invalid patch backend.");
52
+ setupStage="selector";
52
53
  const branch=await resolvePatchBranchSpecs({source:r.source,...(r.destination===null?{}:{destination:r.destination})},r.workdir);
53
54
  const v={source:branch.source,destination:branch.destination??null,toolPath,output:r.output===null?null:isAbsolute(r.output)?r.output:resolve(r.workdir,r.output)};
54
55
  if ([v.source,v.destination,v.toolPath,v.output].some(s=>s!==null&&(s.length>8192||/[\u0000-\u001f\u007f-\u009f]/.test(s)))) throw new Error("Resolved patch controls exceed limits.");
55
56
  d.resolved=v;d.intendedArgv=patchReceiptArgv(r,v);
56
57
  if (r.preflight) return {...header(d),ok:true,outcome:"preflight"};
58
+ setupStage="output";
57
59
  if (v.output!==null) await ensurePatchOutputDoesNotExist(v.output);
58
60
  stage=await fs.mkdtemp(join(v.output===null?tmpdir():resolve(v.output,".."),".pi-plastic-patch-"));
59
61
  const staged=join(stage,"patch-output");
@@ -77,7 +79,12 @@ export async function assemblePatchReceipt(args: unknown): Promise<PatchReceipt>
77
79
  }
78
80
  } catch {
79
81
  if (entered&&d.capture===null) {d.attempt={...emptyWorkspaceMergeAttempt(),state:"unknown"};d.effect="unverified";}
80
- failure={code:entered?"producer_failed":"setup_failed",message:entered?"Patch observation failed; earlier observed effects remain recorded, no automatic retry.":"Patch setup unavailable. Verify exact selector repository, backend and new output parent; no command was dispatched."};
82
+ const setupMessages={
83
+ backend:"Patch backend unavailable. Set PI_PLASTIC_PATCH_EXECUTABLE to a verified non-GUI GNU-compatible executable, or pass toolPath. No command was dispatched.",
84
+ selector:"Patch selector repository could not be verified. Use an exact qualified br:/branch@repository@server selector. No command was dispatched.",
85
+ output:"Patch output is unavailable or already exists. Use a new filename in an existing writable parent directory. No command was dispatched.",
86
+ };
87
+ failure={code:entered?"producer_failed":"setup_failed",message:entered?"Patch observation failed; earlier observed effects remain recorded, no automatic retry.":setupMessages[setupStage]};
81
88
  } finally {
82
89
  if (stage!==null) {
83
90
  if (entered&&(d.attempt.state==="unknown"||d.attempt.state==="started")&&(d.attempt.terminal!=="observed"||d.attempt.aborted||d.attempt.timedOut)) {d.cleanup="retained";d.retainedStagingDirectory=stage;}
@@ -1,10 +1,12 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { lstat, open, realpath } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { randomBytes, createHash } from "node:crypto";
2
4
  import { assertAbsoluteResolutionPath, inspectResolutionFile } from "../domain/server-merge-resolutions";
3
5
  import { getActiveAbortSignal } from "../execution/context";
4
6
  import { captureServerMergeCommand } from "../execution/server-merge-command";
5
7
  import { tool } from "../tool-definition";
6
8
  import { outputFormatArg, formatServerMergeResult } from "../presentation/results";
7
- import { parseServerMergeOutput } from "../domain/merge-output";
9
+ import { isSafeServerMergePath, parseServerMergeOutput } from "../domain/merge-output";
8
10
  import { emptyMergeAttempt, splitServerBranch, type MergeReceipt, type MergeReceiptData, type ServerMergeRequest } from "../domain/server-merge-contract";
9
11
  export { splitServerBranch };
10
12
  export type { MergeReceipt } from "../domain/server-merge-contract";
@@ -14,7 +16,7 @@ export const SERVER_MERGE_OUTPUT_LIMIT = 32768, SERVER_MERGE_HELP_TIMEOUT_MS = 3
14
16
  export const serverMergeControlPattern = /[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
15
17
  export const serverMergeCapabilityTokens = ["--to", "--merge", "--nointeractiveresolution", "--machinereadable", "--startlineseparator", "--endlineseparator", "--fieldseparator"];
16
18
  export function assertSafeServerMergeValue(name: string, value: string): string {
17
- if (typeof value !== "string" || !value.trim() || value.length > 4096 || serverMergeControlPattern.test(value)) throw new ServerMergeInputError(`${name} must be non-empty, bounded and free of control/malformed characters.`);
19
+ if (typeof value !== "string" || !value.trim() || value.length > 4096 || (name === "message" ? /[\u0000-\u0009\u000b\u000c\u000e-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(value) : serverMergeControlPattern.test(value))) throw new ServerMergeInputError(`${name} must be non-empty, bounded and free of unsupported controls or malformed Unicode; only comments may contain CR/LF.`);
18
20
  return value.trim();
19
21
  }
20
22
  export function parseQualifiedServerBranch(name: string, value: string): QualifiedServerBranch {
@@ -40,6 +42,41 @@ export function validateServerMergeRequest(input: unknown): asserts input is Ser
40
42
  if (s.repository !== t.repository || s.server !== t.server) throw new ServerMergeInputError("source and target must identify the same exact repository and server.");
41
43
  if (s.branch === t.branch) throw new ServerMergeInputError("source and target must identify different branches.");
42
44
  }
45
+ // Only the observed resultFile-only candidate closeout uses this bounded snapshot.
46
+ // It verifies caller inputs, not the bytes stored by the server; callers still own
47
+ // immutability during dispatch and point-in-time races remain possible.
48
+ type ResolutionSnapshot = { digest: string; paths: string[] };
49
+ async function snapshotResolutionInputs(path: string): Promise<ResolutionSnapshot> {
50
+ const bounded = async (path: string, cap: number): Promise<Buffer> => {
51
+ const st = await lstat(path);
52
+ const lexical = resolve(path), physical = await realpath(path);
53
+ const key = (s: string) => process.platform === "win32" ? s.toLowerCase() : s;
54
+ if (!st.isFile() || st.isSymbolicLink() || key(lexical) !== key(physical) || st.size > cap) throw Error("invalid");
55
+ const handle = await open(path, "r");
56
+ try {
57
+ const before = await handle.stat();
58
+ if (!before.isFile() || before.dev !== st.dev || before.ino !== st.ino || before.size !== st.size || before.mtimeMs !== st.mtimeMs || before.ctimeMs !== st.ctimeMs) throw Error("unstable");
59
+ const bytes = Buffer.alloc(cap + 1); let length = 0;
60
+ while (length < bytes.length) { const r = await handle.read(bytes, length, bytes.length - length, null); if (!r.bytesRead) break; length += r.bytesRead; }
61
+ const after = await handle.stat(), final = await lstat(path);
62
+ if (length > cap || length !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs || final.isSymbolicLink() || final.ino !== before.ino || final.dev !== before.dev || final.size !== before.size || final.mtimeMs !== before.mtimeMs || final.ctimeMs !== before.ctimeMs) throw Error("unstable");
63
+ return bytes.subarray(0, length);
64
+ } finally { await handle.close(); }
65
+ };
66
+ const bytes = await bounded(path, 1024 * 1024);
67
+ const json = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
68
+ if (!Array.isArray(json.resolutions) || json.resolutions.length > 500) throw Error("invalid");
69
+ const hash = createHash("sha256").update(bytes), paths: string[] = [], keys = new Set<string>();
70
+ let remaining = 4 * 1024 * 1024;
71
+ for (const entry of json.resolutions) {
72
+ if (typeof entry.path !== "string" || !isSafeServerMergePath(entry.path) || keys.has(entry.path.toLowerCase()) || typeof entry.resultFile !== "string" || Object.keys(entry).some(k => !["path", "resultFile"].includes(k))) throw Error("invalid");
73
+ assertAbsoluteResolutionPath(entry.resultFile);
74
+ keys.add(entry.path.toLowerCase()); paths.push(entry.path);
75
+ const result = await bounded(entry.resultFile, remaining); remaining -= result.length;
76
+ hash.update(createHash("sha256").update(result).digest());
77
+ }
78
+ return { digest: hash.digest("hex"), paths };
79
+ }
43
80
  export async function assembleServerMergeReceipt(input: unknown): Promise<MergeReceipt> {
44
81
  const data: MergeReceiptData = { mode: "apply", resolutionsFile: null, unlistedConflictPolicy: null, requestedIdentity: null, command: null, capability: { state: "not-checked", missingTokens: [] }, attempt: emptyMergeAttempt(), capture: { help: null, merge: null }, parse: null, observedChangesets: [], conflictPaths: [], counts: { changesetsReturned: 0, changesetsOmitted: 0, conflictsReturned: 0, conflictsOmitted: 0 }, createdChangeset: null, effect: "not-attempted", serverAliasEquivalence: "unverified", mergeLinkIdentity: "unverified", xlinkEffects: "unverified", wouldRun: false, remoteAnalysis: "not-performed" };
45
82
  const completeness: MergeReceipt["completeness"] = { capture: "unknown", projection: true };
@@ -54,6 +91,7 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
54
91
  }
55
92
  return dto;
56
93
  };
94
+ let resolutionSnapshot: ResolutionSnapshot | null = null;
57
95
  let stage: "input" | "help" | "merge" = "input";
58
96
  try {
59
97
  validateServerMergeRequest(input);
@@ -64,6 +102,10 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
64
102
  try { data.resolutionsFile = await inspectResolutionFile(input.fileConflictsResolutionsFile); }
65
103
  catch (error) { throw new ServerMergeInputError(error instanceof Error && /^(Invalid resolution entry|Resolutions|Resolution)/.test(error.message) ? error.message : "Resolutions file or a result file is unavailable or invalid."); }
66
104
  }
105
+ if (data.resolutionsFile && data.resolutionsFile.resultFiles === data.resolutionsFile.entries && !data.unlistedConflictPolicy) {
106
+ try { resolutionSnapshot = await snapshotResolutionInputs(data.resolutionsFile.path); }
107
+ catch { throw new ServerMergeInputError("Resolution result inputs must be stable regular nonsymlink files with canonical unique conflict paths and at most 4 MiB aggregate result bytes."); }
108
+ }
67
109
  const seps = createServerMergeSeparators();
68
110
  const command = ["merge", source.raw, `--to=${target.raw}`, ...(data.mode === "apply" ? ["--merge", `-c=${assertSafeServerMergeValue("message", input.message!)}`] : []), ...(data.resolutionsFile ? [`--fileconflictsresolutionsfile=${data.resolutionsFile.path}`] : []), ...(data.unlistedConflictPolicy ? [data.unlistedConflictPolicy === "source" ? "--keepsource" : "--keepdestination"] : []), "--nointeractiveresolution", "--machinereadable", `--startlineseparator=${seps.start}`, `--endlineseparator=${seps.end}`, `--fieldseparator=${seps.field}`];
69
111
  data.requestedIdentity = { source: source.raw, target: target.raw, branch: target.branch, repository: target.repository, server: target.server }; data.command = ["cm", ...command]; data.wouldRun = true;
@@ -81,7 +123,7 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
81
123
  if (data.resolutionsFile) {
82
124
  try {
83
125
  const current = await inspectResolutionFile(data.resolutionsFile.path);
84
- if (current.sha256 !== data.resolutionsFile.sha256) throw new Error("changed");
126
+ if (current.sha256 !== data.resolutionsFile.sha256 || resolutionSnapshot && (await snapshotResolutionInputs(data.resolutionsFile.path)).digest !== resolutionSnapshot.digest) throw new Error("changed");
85
127
  } catch { return finish("failed", "input", "invalid_request", "Resolution inputs changed or became unavailable before merge dispatch."); }
86
128
  }
87
129
  stage = "merge";
@@ -89,16 +131,24 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
89
131
  completeness.capture = obs.capture.complete ? "complete" : "incomplete";
90
132
  if (obs.attempt.state === "not-attempted" || obs.attempt.state === "not-started") return finish("failed", "merge", obs.attempt.aborted ? "aborted" : "launch_failed");
91
133
  const parsed = parseServerMergeOutput(obs.stdout ?? "", seps, data.mode);
92
- const paths = parsed.records.filter(r => r.operation === "FILE_CONFLICT" && r.fields.length === 6 && r.fields[1]!.length <= 4096 && !serverMergeControlPattern.test(r.fields[1]!)).map(r => r.fields[1]!);
93
- data.parse = { records: parsed.records.length, malformed: parsed.malformed, unknownOperations: parsed.unknownOperations.length, changesetsObserved: parsed.changesets.length, conflictsObserved: paths.length, warningsObserved: parsed.warningsObserved };
134
+ const paths = parsed.records.filter(r => r.operation === "FILE_CONFLICT" && r.fields.length === 6 && isSafeServerMergePath(r.fields[1]!)).map(r => r.fields[1]!);
135
+ data.parse = { records: parsed.records.length, malformed: parsed.malformed, unknownOperations: parsed.unknownOperations.length, changesetsObserved: parsed.changesets.length, conflictsObserved: paths.length, warningsObserved: parsed.warningsObserved, unknownOperationClasses: parsed.unknownOperations, resolvedConflictsVerified: 0 };
94
136
  // One aggregate evidence budget, not one budget per collection.
95
137
  data.observedChangesets = parsed.changesets.slice(0, 100); data.conflictPaths = paths.slice(0, 100 - data.observedChangesets.length);
96
138
  data.counts = { changesetsReturned: data.observedChangesets.length, changesetsOmitted: parsed.changesets.length - data.observedChangesets.length, conflictsReturned: data.conflictPaths.length, conflictsOmitted: paths.length - data.conflictPaths.length };
97
139
  if (data.counts.changesetsOmitted || data.counts.conflictsOmitted) completeness.projection = false;
98
- const matching = parsed.changesets.filter(c => c.branch === target.branch && c.repository === target.repository && c.mount === "/");
140
+ const matching = parsed.changesets.filter(c => c.branch === target.branch && c.repository === target.repository && c.server === target.server && c.mount === "/");
141
+ let resolvedCandidates = false;
142
+ if (resolutionSnapshot && parsed.hasConflict && data.resolutionsFile) {
143
+ try {
144
+ const current = await snapshotResolutionInputs(data.resolutionsFile.path);
145
+ resolvedCandidates = current.digest === resolutionSnapshot.digest && paths.length === resolutionSnapshot.paths.length && new Set(paths).size === paths.length && paths.every(p => resolutionSnapshot!.paths.includes(p));
146
+ } catch { /* Changed/unavailable inputs retain effect uncertainty. */ }
147
+ }
148
+ if (resolvedCandidates) data.parse.resolvedConflictsVerified = paths.length;
99
149
  // Emitted server aliases are kept verbatim. Matching uses dispatch context,
100
150
  // not an invented equivalence between requested and emitted server names.
101
- const ambiguous = !obs.capture.complete || obs.failed || obs.attempt.aborted || obs.attempt.timedOut || obs.attempt.terminal !== "observed" || obs.attempt.exitCode === null || parsed.malformed || parsed.unknownOperations.length > 0 || parsed.changesets.length > 1 || parsed.changesets.length > 0 && matching.length !== 1 || parsed.hasConflict && parsed.changesets.length > 0 || parsed.isAlreadyConnected && (parsed.changesets.length > 0 || parsed.hasConflict);
151
+ const ambiguous = !obs.capture.complete || obs.failed || obs.attempt.aborted || obs.attempt.timedOut || obs.attempt.terminal !== "observed" || obs.attempt.exitCode === null || parsed.malformed || parsed.unknownOperations.length > 0 || parsed.changesets.length > 1 || parsed.changesets.length > 0 && matching.length !== 1 || parsed.hasConflict && parsed.changesets.length > 0 && !resolvedCandidates || parsed.isAlreadyConnected && (parsed.changesets.length > 0 || parsed.hasConflict);
102
152
  if (data.mode === "analyze") {
103
153
  if (!ambiguous && obs.attempt.exitCode === 0 && !obs.capture.stderrBytes && !parsed.changesets.length && parsed.records.length > 0) {
104
154
  data.remoteAnalysis = "performed";
@@ -107,7 +157,7 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
107
157
  const reason = parsed.malformed ? "Malformed server analysis framing or fields." : parsed.unknownOperations.length ? "Unsupported server analysis operation or subtype." : "Server conflict analysis could not be verified.";
108
158
  return finish("uncertain", "merge", "uncertain", reason + " No applying command was requested.");
109
159
  }
110
- if (!ambiguous && obs.attempt.exitCode === 0 && !obs.capture.stderrBytes && matching.length === 1 && !parsed.hasConflict && !parsed.isAlreadyConnected) { data.createdChangeset = matching[0]!; return finish("completed", "merge"); }
160
+ if (!ambiguous && obs.attempt.exitCode === 0 && !obs.capture.stderrBytes && matching.length === 1 && (!parsed.hasConflict || resolvedCandidates) && !parsed.isAlreadyConnected) { data.createdChangeset = matching[0]!; return finish("completed", "merge"); }
111
161
  if (!ambiguous && obs.attempt.exitCode === 0 && !obs.capture.stderrBytes && parsed.isAlreadyConnected && !parsed.changesets.length && !parsed.hasConflict) return finish("no-op", "merge");
112
162
  if (!ambiguous && obs.attempt.exitCode !== 0 && parsed.hasConflict && !parsed.changesets.length) return finish("conflict", "merge", "conflict");
113
163
  return finish("uncertain", "merge", "uncertain");
@@ -121,11 +171,12 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
121
171
  export async function presentServerMergeReceipt(dto: MergeReceipt, format: "text" | "json" = "text"): Promise<string> {
122
172
  const title = dto.outcome === "no-op" ? "No-op" : dto.outcome[0]!.toUpperCase() + dto.outcome.slice(1);
123
173
  const text = dto.outcome === "preflight" ? `## Server Merge Preflight\n\n- Would run: yes\n- Remote analysis: not performed\n- Command argv: ${JSON.stringify(dto.data.command)}` : dto.outcome === "analyzed" ? `## Server Merge Analysis\n\n- Read-only: no applying merge requested\n- File conflicts observed: ${dto.data.parse?.conflictsObserved ?? "unknown"}\n${dto.data.conflictPaths.map(path => "- " + path).join("\n")}\n- Conflicts omitted: ${dto.data.counts.conflictsOmitted}\n- Discard CHG warnings observed: ${dto.data.parse?.warningsObserved ?? "unknown"} (content equality unverified)\n- Point-in-time evidence only; no contributor content retrieved.` : `## Server Merge ${title}\n\n${dto.data.createdChangeset ? "- Created target changeset: cs:" + dto.data.createdChangeset.id + "\n" : ""}- Effect: ${dto.data.effect}\n- Server alias equivalence, merge-link identity and Xlink effects: unverified\n${dto.ok ? "" : "- " + dto.error.message + "\n- Do not retry automatically; inspect server state if an attempt may have started."}`;
124
- const rendered = await formatServerMergeResult(format, dto.outcome, text, dto.data);
174
+ const diagnostics = `\n- Unsupported operation classes: ${dto.data.parse?.unknownOperationClasses.join(", ") || "none"}\n- Conflict candidates with verified result inputs: ${dto.data.parse?.resolvedConflictsVerified ?? 0} (not independent server-byte verification)`;
175
+ const rendered = await formatServerMergeResult(format, dto.outcome, text + diagnostics, dto.data);
125
176
  return rendered.length <= 24000 ? rendered : JSON.stringify({ action: dto.action, outcome: dto.outcome, ok: dto.ok, note: "Presentation omitted; use the registered tool structuredContent for the bounded receipt." });
126
177
  }
127
178
  export const mergeBranches = tool({
128
179
  description: "Inspect server merge conflicts with mode=analyze, then apply reviewed per-file JSON resolutions without a workspace or UI. Server aliases, merge links and Xlink effects remain unverified.",
129
- args: { source: tool.schema.string().min(1).describe("Fully qualified source branch; branch/repository may not contain @; full server tail retained."), target: tool.schema.string().min(1).describe("Fully qualified distinct target in the same exact requested repository/server."), message: tool.schema.string().min(1).optional().describe("Required for apply; omit in analyze mode. Bounded nonempty comment, no editor fallback."), mode: tool.schema.enum(["apply", "analyze"]).optional().describe("Default apply creates a merge changeset. Analyze performs read-only remote conflict discovery without --merge; not command-only preflight."), fileConflictsResolutionsFile: tool.schema.string().min(1).optional().describe("Apply only: absolute existing UTF-8 JSON file with resolutions entries (exact conflict path plus keep or absolute readable resultFile). Inputs must remain immutable until completion."), unlistedConflictPolicy: tool.schema.enum(["source", "destination"]).optional().describe("Apply only: explicitly keep this contributor for conflicts absent from the JSON. Omit to retain noninteractive default behavior; never selected automatically."), preflight: tool.schema.boolean().optional().describe("Render intent only, zero commands; does not analyze remote conflicts."), format: outputFormatArg },
180
+ args: { source: tool.schema.string().min(1).describe("Fully qualified source branch; branch/repository may not contain @; full server tail retained."), target: tool.schema.string().min(1).describe("Fully qualified distinct target in the same exact requested repository/server."), message: tool.schema.string().min(1).optional().describe("Required for apply; omit in analyze mode. Bounded nonempty comment; CR/LF allowed, other controls rejected. No editor fallback."), mode: tool.schema.enum(["apply", "analyze"]).optional().describe("Default apply creates a merge changeset. Analyze performs read-only remote conflict discovery without --merge; not command-only preflight."), fileConflictsResolutionsFile: tool.schema.string().min(1).optional().describe("Apply only: absolute existing UTF-8 JSON file with resolutions entries (exact conflict path plus keep or absolute readable resultFile). Inputs must remain immutable until completion."), unlistedConflictPolicy: tool.schema.enum(["source", "destination"]).optional().describe("Apply only: explicitly keep this contributor for conflicts absent from the JSON. Omit to retain noninteractive default behavior; never selected automatically."), preflight: tool.schema.boolean().optional().describe("Render intent only, zero commands; does not analyze remote conflicts."), format: outputFormatArg },
130
181
  async execute(args) { const dto = await assembleServerMergeReceipt(args); if (!dto.ok && dto.error.stage === "input") throw new Error(dto.error.message); return presentServerMergeReceipt(dto, args.format); },
131
182
  });
@@ -10,7 +10,7 @@ const cs = object({ id: Type.String({ pattern: "^(0|[1-9][0-9]{0,19})$" }), bran
10
10
  const summary = object({ totalPending: uint(499), tracked: uint(499), private: uint(499), added: uint(499), changed: uint(499), moved: uint(499), deleted: uint(499), other: uint(499) });
11
11
  const attempt = object({ state: enumOf(["not-attempted", "not-started", "started", "unknown"]), terminal: enumOf(["not-observed", "observed"]), exitCode: Type.Union([uint(2147483647), Type.Null()]), aborted: Type.Boolean(), timedOut: Type.Boolean() });
12
12
  const capture = object({ stdoutBytes: uint(), stderrBytes: uint(), stdoutRetainedBytes: uint(65536), stderrRetainedBytes: uint(16384), truncated: Type.Boolean(), complete: Type.Boolean(), validUtf8: Type.Boolean() });
13
- const step = object({ name: enumOf(["pending-before", "initial-checkin", "private-add", "private-retry", "pending-recovery", "fallback-checkin", "pending-after", "merge-diagnostic"]), operation: enumOf(["status", "checkin", "add"]), attempt, capture, effect: enumOf(["not-attempted", "uncertain", "command-completed", "changeset-created", "read-observed"]), evidence: enumOf(["unadmitted", "admitted"]), records: uint(501), summary: nullable(summary), changeset: nullable(cs) });
13
+ const step = object({ name: enumOf(["pending-before", "initial-checkin", "private-add", "private-retry", "pending-recovery", "fallback-checkin", "pending-after", "merge-diagnostic", "merge-readiness"]), operation: enumOf(["status", "checkin", "add"]), attempt, capture, effect: enumOf(["not-attempted", "uncertain", "command-completed", "changeset-created", "read-observed"]), evidence: enumOf(["unadmitted", "admitted"]), records: uint(501), summary: nullable(summary), changeset: nullable(cs) });
14
14
  const paths = Type.Array(str(), { maxItems: 100 });
15
15
  const reasons = Type.Array(object({ path: str(), reason: str(256) }), { maxItems: 100 });
16
16
  const data = { sourceAdmission: Type.Literal("windows-cm11.0.16.10371-observed"), requestedPaths: paths, includedPaths: paths, fallbackPaths: paths, excludedPaths: reasons, privateAddPaths: paths, blockedPrivatePaths: reasons, itemEvents: Type.Array(Type.Union([object({ operation: enumOf(["CO", "AD", "DE", "CP", "RP"]), path: str() }), object({ operation: Type.Literal("MV"), sourcePath: str(), path: str() })]), { maxItems: 100 }), observedChangesets: Type.Array(cs, { maxItems: 100 }), command: nullable(Type.Array(str(8192), { maxItems: 120 })), steps: Type.Array(step, { maxItems: 8 }), autoEnabledApplyChanged: Type.Boolean(), usedFallbackRetry: Type.Boolean(), usedPrivateAutoAddRecovery: Type.Boolean(), pendingBefore: nullable(summary), pendingAfter: nullable(summary), wouldRun: Type.Boolean(), omittedReferences: uint(10000), scopeExhaustion: Type.Literal("unverified"), branchHead: Type.Literal("unverified"), serverAliasEquivalence: Type.Literal("unverified"), xlinkEffects: Type.Literal("unverified"), mergeLinkIdentity: Type.Literal("unverified") };
@@ -38,6 +38,7 @@ export function validateCheckinOutput(value: unknown): value is CheckinReceipt {
38
38
  for (const s of d.steps) {
39
39
  const a = s.attempt, c = s.capture;
40
40
  if (a.exitCode !== null && a.terminal !== "observed" || c.stdoutRetainedBytes > c.stdoutBytes || c.stderrRetainedBytes > c.stderrBytes || c.complete && (!c.validUtf8 || c.truncated || a.terminal !== "observed" || a.aborted || a.timedOut)) return false;
41
+ if (s.name === "merge-readiness" && (s.operation !== "status" || s.effect !== "not-attempted" || s.summary || s.evidence === "admitted" && (!normal(s) || a.exitCode !== 0 || c.stderrBytes))) return false;
41
42
  if (s.summary && (!summaryValid(s.summary) || s.operation !== "status" || s.effect !== "read-observed")) return false;
42
43
  if (s.operation === "status" && !["not-attempted", "read-observed"].includes(s.effect)) return false;
43
44
  if (s.operation !== "status" && s.summary) return false;
@@ -47,11 +48,13 @@ export function validateCheckinOutput(value: unknown): value is CheckinReceipt {
47
48
  if (s.changeset && s.effect !== "changeset-created") return false;
48
49
  if (s.operation !== "status" && s.effect === "not-attempted" && !["not-attempted", "not-started"].includes(a.state)) return false;
49
50
  }
51
+ const readiness = d.steps.filter(s => s.name === "merge-readiness");
52
+ if (readiness.length > 1 || readiness.length && (d.steps[0]?.name !== "pending-before" || d.steps[1] !== readiness[0] || (dto.ok || d.steps.length > 2) && readiness[0].evidence !== "admitted")) return false;
50
53
  const mutations = d.steps.filter(s => s.operation !== "status"), created = mutations.filter(s => s.effect === "changeset-created");
51
54
  const expected = created.length ? "changeset-created" : mutations.some(s => s.effect === "uncertain") ? "uncertain" : mutations.some(s => s.effect === "command-completed") ? "command-completed" : "not-attempted";
52
55
  if (d.effect !== expected || created.length > 1 || !!d.createdChangeset !== !!created.length || d.createdChangeset && !same(d.createdChangeset, created[0].changeset)) return false;
53
56
  if (dto.outcome === "completed" && (!d.createdChangeset || !d.pendingBefore || !d.wouldRun)) return false;
54
- if (dto.outcome === "preflight" && (mutations.length || !d.pendingBefore || !d.wouldRun || d.steps.length !== 1)) return false;
57
+ if (dto.outcome === "preflight" && (mutations.length || !d.pendingBefore || !d.wouldRun || d.steps.some(s => !["pending-before", "merge-readiness"].includes(s.name)))) return false;
55
58
  if ((dto.outcome === "failed" || dto.outcome === "unsupported") && mutations.length) return false;
56
59
  if (d.usedFallbackRetry !== d.steps.some(s => s.name === "fallback-checkin")) return false;
57
60
  if (d.usedPrivateAutoAddRecovery !== d.steps.some(s => s.name === "private-add" && s.effect === "command-completed")) return false;
@@ -67,7 +67,7 @@ export function validateCloseoutOutput(value:unknown):value is CloseoutReceipt {
67
67
  if(s.kind==="switch"){if(s.name!=="switch"||!validateSwitchOutput(s.result)||s.result.data.requestedTarget!==d.targetBranch||s.result.data.pendingPolicy!=="cancel"||s.result.data.preflight||s.result.data.workdir!==resolve(r.workdir))return false;}
68
68
  else if(s.kind==="update"){if(s.name!=="update"||!validateUpdateOutput(s.result)||s.result.data.workingDirectory!==r.workdir)return false;}
69
69
  else if(s.kind==="merge"){if(s.name!=="merge"||!validateWorkspaceMergeOutput(s.result)||s.result.action!=="merge"||s.result.data.requestedSource!==d.sourceBranch||s.result.data.strategy!==r.strategy||s.result.data.workingDirectory!==r.workdir||s.result.data.previewRequested!==false||s.result.data.cherrypicking||s.result.data.forced)return false;}
70
- else{if(!["checkin-preflight","checkin"].includes(s.name)||!validateCheckinOutput(s.result)||s.name==="checkin-preflight"&&s.result.ok&&s.result.outcome!=="preflight"||s.name==="checkin"&&s.result.ok&&s.result.outcome!=="completed")return false;const cmd=s.result.data.command,expected=["cm","checkin",`-c=${d.checkinMessage}`,"--all",...(r.includePrivate?["--private"]:[])];if(cmd&&(!same(cmd.slice(0,expected.length),expected)||cmd[expected.length]!=="--machinereadable"))return false;}
70
+ else{if(!["checkin-preflight","checkin"].includes(s.name)||!validateCheckinOutput(s.result)||s.name==="checkin-preflight"&&s.result.ok&&s.result.outcome!=="preflight"||s.name==="checkin"&&s.result.ok&&s.result.outcome!=="completed")return false;const cmd=s.result.data.command,expected=["cm","checkin",`-c=${d.checkinMessage}`,"--all",...(r.includePrivate?["--private"]:[]),...s.result.data.includedPaths];if(cmd&&(!same(cmd.slice(0,expected.length),expected)||cmd[expected.length]!=="--machinereadable"))return false;}
71
71
  if(index<stages.length-1&&!s.result.ok)return false;
72
72
  }
73
73
  }