@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.
- package/CHANGELOG.md +23 -0
- package/docs/2026-10-06-merging.md +12 -11
- package/docs/2026-10-06-status.md +3 -2
- package/docs/2026-10-06-workspace-operations.md +2 -2
- package/docs/2026-10-21-observed-workflow-profiles.md +54 -0
- package/docs/README.md +1 -0
- package/package.json +4 -4
- package/src/domain/branch-create-contract.ts +2 -1
- package/src/domain/checkin-contract.ts +10 -3
- package/src/domain/copied-merge.ts +1 -1
- package/src/domain/merge-output.ts +22 -7
- package/src/domain/merged-workspace.ts +121 -0
- package/src/domain/pending.ts +48 -0
- package/src/domain/server-merge-contract.ts +1 -1
- package/src/domain/status-xml.ts +22 -7
- package/src/domain/switch-contract.ts +2 -1
- package/src/domain/workspace-merge-contract.ts +6 -3
- package/src/operations/checkin-receipt.ts +61 -2
- package/src/operations/closeout-receipt.ts +9 -4
- package/src/operations/consolidated-diff.ts +31 -7
- package/src/operations/patch-receipt.ts +9 -2
- package/src/operations/server-merge.ts +62 -11
- package/src/operations/workspace-merge-receipt.ts +9 -6
- package/src/pi/checkin-output.ts +6 -3
- package/src/pi/closeout-output.ts +2 -2
- package/src/pi/diff-output.ts +2 -2
- package/src/pi/server-merge-output.ts +11 -3
- package/src/pi/status-output.ts +38 -4
- package/src/pi/workspace-merge-output.ts +6 -3
package/src/domain/pending.ts
CHANGED
|
@@ -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 };
|
package/src/domain/status-xml.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DOMParser, type Element } from "@xmldom/xmldom";
|
|
2
|
-
import type
|
|
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
|
|
110
|
-
|
|
111
|
-
seen.
|
|
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
|
-
|
|
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
|
|
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"]);
|
|
65
|
-
|
|
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)
|
|
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(
|
|
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){
|
|
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
|
-
|
|
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;}
|