@aefree/pi-plastic 0.8.3 → 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", 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
 
@@ -95,6 +96,7 @@ export function parseStatusXml(bytes: Uint8Array): { output: string; items: XmlS
95
96
  if (change.tagName !== "Change") invalid();
96
97
  const row = children(change, fields);
97
98
  // All recognized fields are scalar in the verified configuration.
99
+ // TypeVerbose/MergesInfo are display metadata, never merge-readiness evidence.
98
100
  for (const value of row.values()) leaf(value);
99
101
  const statusCode = leaf(row.get("Type"));
100
102
  if (!/^[A-Z][A-Z0-9_]*$/.test(statusCode)) invalid();
@@ -104,15 +106,28 @@ export function parseStatusXml(bytes: Uint8Array): { output: string; items: XmlS
104
106
  const oldPath = leaf(row.get("OldPath"));
105
107
  const revisionType = leaf(row.get("RevisionType"));
106
108
  if (!/^en[A-Za-z0-9]+$/.test(revisionType)) invalid();
107
- const supportedType = revisionType === "enDirectory" || revisionType === "enTextFile";
109
+ const supportedType = revisionType === "enDirectory" || revisionType === "enTextFile" || revisionType === "enBinaryFile";
108
110
  if (supportedStatus && (kind === "moved") !== Boolean(oldPath)) invalid();
109
- if (oldPath && identity(oldPath) === path) invalid();
110
- if (seen.has(path)) invalid();
111
- 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
+ }
112
127
  // Validate the entire document before reporting unsupported profiles: a later unsafe
113
128
  // identity/structure must not be masked by an earlier unadmitted record.
114
129
  if (!supportedStatus || !supportedType) { unsupported = true; continue; }
115
- 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 } : {}) });
116
131
  }
117
132
  if (unsupported) throw new StatusXmlError("unsupported_source");
118
133
  const summary: MachineReadableStatusSummary = { totalPending: items.length, added: 0, changed: 0, moved: 0, deleted: 0, private: 0, other: 0, tracked: 0 };
@@ -21,7 +21,8 @@ export function parseSwitchTarget(value: string) {
21
21
  }
22
22
  export function parseSwitchLoadedBranch(text: string): SwitchBranchObservation | null {
23
23
  const lines = text.split(/\r?\n/);
24
- const grammar = /^(\/[^@]+)@([^@]+)@([^\s]+) \(cs:(?:0|[1-9][0-9]{0,19}) - head\)$/u;
24
+ // A numeric head is an annotation, not the loaded changeset or branch owner.
25
+ const grammar = /^(\/[^@]+)@([^@]+)@([^\s]+) \(cs:(?:0|[1-9][0-9]{0,19}) - head(?::(?:0|[1-9][0-9]{0,19}))?\)$/u;
25
26
  const match = grammar.exec(lines[0] ?? "");
26
27
  if (!match || lines.slice(1).some(line => grammar.test(line)) || !hierarchy(match[1]) || !identity(match[2]) || !identity(match[3]) || !safeSwitchValue(`${match[1]}@${match[2]}@${match[3]}`) || !parseSwitchTarget(`${match[1]}@${match[2]}@${match[3]}`)) return null;
27
28
  return { branch: match[1], repository: match[2], server: match[3], basis: "standard-status", scope: "loaded-workspace" };
@@ -1,3 +1,4 @@
1
+ import { parseMergedApply, type MergedDeclaration } from "./merged-workspace";
1
2
  import type { WorkspaceMergeAttempt, WorkspaceMergeCapture } from "../execution/workspace-merge-command";
2
3
  export type { WorkspaceMergeAttempt, WorkspaceMergeCapture } from "../execution/workspace-merge-command";
3
4
  export type MergeAction = "merge" | "finalize-merge";
@@ -6,9 +7,9 @@ export const safeMergeText = (v: unknown): v is string => typeof v === "string"
6
7
  export type MergeStage = { attempt: WorkspaceMergeAttempt; capture: WorkspaceMergeCapture | null; admission: "not-observed" | "unsupported" | "admitted" };
7
8
  export type WorkspaceMergeData = {
8
9
  intendedArgv: readonly string[]; workingDirectory: string | null; requestedSource: string | null; strategy: MergeStrategy | null; cherrypicking: boolean | null; forced: boolean | null; previewRequested: boolean | null; outputFormatRequested: "text" | "json" | null;
9
- apply: MergeStage; shortStatus: MergeStage; fullStatus: MergeStage; pendingReadMode: "short" | "copied-machine";
10
+ apply: MergeStage; shortStatus: MergeStage; fullStatus: MergeStage; pendingReadMode: "short" | "copied-machine" | "merged-machine";
10
11
  commandCompleted: boolean; observedWorkspaceIdentity: null; observedSourceIdentity: null; observedFinalizedMetadata: null; observedPreservation: null; observedPendingItems: null;
11
- fileConflictCount: number | null; protocol: "not-observed" | "already-connected" | "file-conflict" | "apply-add" | "apply-add-copied" | "unsupported";
12
+ fileConflictCount: number | null; protocol: "not-observed" | "already-connected" | "file-conflict" | "apply-add" | "apply-add-copied" | "merged-candidate" | "unsupported";
12
13
  checkinReadiness: "unknown" | "blocked" | "supported-no-unresolved-signals"; verification: "unverified"; effect: "not-attempted" | "not-proven" | "uncertain";
13
14
  };
14
15
  type Header = { schemaVersion: 1; action: MergeAction; provenance: { source: "plastic"; producer: "@aefree/pi-plastic"; contentTrust: "external" }; completeness: { capture: "complete" | "incomplete" | "unknown"; projection: true }; data: WorkspaceMergeData };
@@ -19,7 +20,9 @@ export function buildWorkspaceMergeArgv(source: string, strategy: MergeStrategy,
19
20
  if(cherry)result.push("--cherrypicking");if(forced)result.push("--forced");return result;
20
21
  }
21
22
  /** Narrow observed CM profile. Unknown records/text are not missing-conflict proof. */
22
- export function admitWorkspaceMergeOutput(text: string, token: string): { protocol: WorkspaceMergeData["protocol"]; fileConflictCount: number | null; copiedPaths: string[]; addedPaths: string[] } {
23
+ export function admitWorkspaceMergeOutput(text: string, token: string): { protocol: WorkspaceMergeData["protocol"]; fileConflictCount: number | null; copiedPaths: string[]; addedPaths: string[]; declarations?: MergedDeclaration[] } {
24
+ const declarations = parseMergedApply(text, token);
25
+ if (declarations?.some(d => d.status !== "CP")) return { protocol: "merged-candidate", fileConflictCount: declarations.filter(d => d.status === "CO").length, copiedPaths: [], addedPaths: [], declarations };
23
26
  const start=token+"S__",end=token+"E__",field=token+"F__";let rest=text,count=0,conflicts=0,connected=0,adds=0; const copiedPaths:string[]=[], addedPaths:string[]=[];
24
27
  while(rest.trim()!==""){
25
28
  if(++count>256)return {protocol:"unsupported",fileConflictCount:null,copiedPaths:[],addedPaths:[]};const s=rest.indexOf(start),e=rest.indexOf(end,s+start.length);
@@ -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";
@@ -3,6 +3,7 @@ import { parseSwitchTarget, parseSwitchLoadedBranch, compareSwitchTarget, parseS
3
3
  import { checkinSafeComment, checkinSafeValue } from "../domain/checkin-contract";
4
4
  import { admitCleanStandardStatus } from "../domain/workspace-merge-contract";
5
5
  import { parseCopiedPending } from "../domain/copied-merge";
6
+ import { parseMergedPending, admitMergedStandardStatus, type MergedPending } from "../domain/merged-workspace";
6
7
  import { cmWhereEquals } from "../domain/branches";
7
8
  import { buildCloseoutParentArgv, CLOSEOUT_STAGE_NAMES, closeoutChildEffect, closeoutCaptures, type CloseoutData, type CloseoutReceipt, type CloseoutRequest, type CloseoutStageName, type CloseoutRead, type CloseoutStage } from "../domain/closeout-contract";
8
9
  import { captureWorkspaceMergeCommand, emptyWorkspaceMergeAttempt } from "../execution/workspace-merge-command";
@@ -29,7 +30,7 @@ export async function assembleCloseoutReceipt(input:unknown):Promise<CloseoutRec
29
30
  };
30
31
  const push=(s:CloseoutStage)=>{if(data.stages.length>=16)throw Error();data.stages.push(s);data.unattemptedStages=data.unattemptedStages.filter(n=>n!==s.name);};
31
32
  const read=async(name:CloseoutStageName,argv:string[])=>{stage=name;const s:CloseoutRead={argv:[...argv],attempt:{...emptyWorkspaceMergeAttempt(),state:"unknown"},capture:null,admission:"unsupported",branch:null,parentPath:null,emptyParentResult:false,summary:null};push({name,kind:"read",result:s});const o=await captureWorkspaceMergeCommand([...argv],data.requested!.workdir);s.attempt=o.attempt;s.capture=o.capture;const success=o.capture.complete&&!o.failed&&o.attempt.state==="started"&&o.attempt.terminal==="observed"&&o.attempt.exitCode===0&&!o.attempt.aborted&&!o.attempt.timedOut&&o.stderr===""&&o.stdout!==null;return {s,o,success};};
32
- const loaded=async(name:CloseoutStageName,verify=false)=>{const {s,o,success}=await read(name,["status"]);if(success){s.branch=parseSwitchLoadedBranch(o.stdout!);if(s.branch)s.admission="admitted";}if(!s.branch)return false;if(verify){data.targetVerification=compareSwitchTarget(s.branch,data.targetBranch!);if(data.targetVerification==="unverified")return false;}return true;};
33
+ const loaded=async(name:CloseoutStageName,verify=false,mergedPending?:MergedPending|null)=>{const {s,o,success}=await read(name,["status"]);if(success){s.branch=parseSwitchLoadedBranch(o.stdout!);if(s.branch&&(!mergedPending||admitMergedStandardStatus(o.stdout!,mergedPending,data.sourceBranch!,data.requested!.workdir)))s.admission="admitted";else s.branch=null;}if(!s.branch)return false;if(verify){data.targetVerification=compareSwitchTarget(s.branch,data.targetBranch!);if(data.targetVerification==="unverified")return false;}return true;};
33
34
  const pending=async(name:"pending-before"|"pending-final")=>{const {s,o,success}=await read(name,[...switchPendingArgv]);if(success){const p=parseSwitchPending(o.stdout!,data.requested!.workdir);if(p.summary){s.admission="admitted";s.summary=p.summary;}}return s.summary;};
34
35
  try{
35
36
  const submitted=input&&typeof input==="object"&&!Array.isArray(input)?Object.freeze({...input}):input;admitRequest(submitted);const cwd=submitted.workdir??process.cwd();if(!safeSwitchValue(cwd)||!safeSwitchValue(resolve(cwd)))return finish("failed","invalid_request","Closeout needs a bounded workdir.");data.requested={source:submitted.source??null,target:submitted.target??null,message:submitted.message??null,cardRef:submitted.cardRef??null,strategy:submitted.strategy??"auto",updateTarget:submitted.updateTarget??true,includePrivate:submitted.includePrivate??false,preflight:submitted.preflight??false,outputFormat:submitted.format??"text",workdir:cwd};
@@ -60,9 +61,13 @@ export async function assembleCloseoutReceipt(input:unknown):Promise<CloseoutRec
60
61
  if(data.requested.updateTarget){stage="update";childEntered=true;const updated=await assembleUpdateReceipt({workdir:cwd});push({name:"update",kind:"update",result:updated});childEntered=false;if(!updated.ok)return finish("failed","child_failed");}
61
62
  if(!await loaded("loaded-before-merge",true))return finish("failed","target_unverified");
62
63
  stage="merge";childEntered=true;const merged=await assembleWorkspaceMergeReceipt("merge",{source:data.sourceBranch!,strategy:data.requested.strategy,workdir:cwd});push({name:"merge",kind:"merge",result:merged});childEntered=false;if(!merged.ok)return finish("failed","child_failed");
63
- const copiedReady=merged.data.protocol==="apply-add-copied";
64
- const ready=await read("readiness",copiedReady?[...switchPendingArgv]:["status"]);if(!ready.success||ready.o.stderr!==""||!(copiedReady?!!parseCopiedPending(ready.o.stdout!,cwd):admitCleanStandardStatus(ready.o.stdout!)))return finish("failed","observation_failed","Independent post-merge readiness is outside the admitted profile; no checkin permitted. Prior effects remain possible.");ready.s.admission="admitted";
65
- if(!await loaded("loaded-before-checkin",true))return finish("failed","target_unverified");
64
+ const copiedReady=merged.data.protocol==="apply-add-copied",mergedReady=merged.data.protocol==="merged-candidate";
65
+ const ready=await read("readiness",copiedReady||mergedReady?[...switchPendingArgv]:["status"]);
66
+ const mergedPending=mergedReady&&ready.success?parseMergedPending(ready.o.stdout!,cwd):null;
67
+ if(!ready.success||ready.o.stderr!==""||!(mergedReady?!!mergedPending:copiedReady?!!parseCopiedPending(ready.o.stdout!,cwd):admitCleanStandardStatus(ready.o.stdout!)))return finish("failed","observation_failed","Independent post-merge readiness is outside the admitted profile; no checkin permitted. Prior effects remain possible.");ready.s.admission="admitted";
68
+ // The independent machine snapshot is not readiness proof by itself: the next
69
+ // full status must still correlate its source link, namespace and resolved rows.
70
+ if(!await loaded("loaded-before-checkin",true,mergedPending))return finish("failed","target_unverified");
66
71
  const checkinArgs={message:data.checkinMessage,includeAll:true,includePrivate:data.requested.includePrivate,workdir:cwd};
67
72
  stage="checkin-preflight";childEntered=true;const preview=await assembleCheckinReceipt({...checkinArgs,preflight:true});push({name:"checkin-preflight",kind:"checkin",result:preview});childEntered=false;if(!preview.ok)return finish("failed","child_failed");
68
73
  stage="checkin";childEntered=true;const checked=await assembleCheckinReceipt(checkinArgs);push({name:"checkin",kind:"checkin",result:checked});childEntered=false;data.createdChangeset=checked.data.createdChangeset;if(!checked.ok)return finish("failed","child_failed");
@@ -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;}