@aefree/pi-plastic 0.7.6 → 0.8.1
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 +43 -0
- package/README.md +48 -174
- package/docs/2026-10-06-development.md +101 -0
- package/docs/2026-10-06-diffs-and-patches.md +136 -0
- package/docs/2026-10-06-discovery.md +135 -0
- package/docs/2026-10-06-integrations.md +49 -0
- package/docs/2026-10-06-merging.md +153 -0
- package/docs/2026-10-06-safety-and-receipts.md +95 -0
- package/docs/2026-10-06-setup.md +101 -0
- package/docs/2026-10-06-shelvesets-and-reviews.md +113 -0
- package/docs/2026-10-06-status.md +130 -0
- package/docs/2026-10-06-tools-and-loading.md +64 -0
- package/docs/2026-10-06-workspace-operations.md +142 -0
- package/docs/README.md +65 -0
- package/extensions/bash-cm-diff-guard.ts +1 -1
- package/index.ts +4 -687
- package/package.json +10 -4
- package/skills/using-plastic/SKILL.md +11 -5
- package/skills/using-plastic/references/branch-management.md +5 -1
- package/skills/using-plastic/references/changeset-operations.md +9 -8
- package/skills/using-plastic/references/code-review-creation.md +25 -18
- package/skills/using-plastic/references/integration.md +9 -5
- package/skills/using-plastic/references/quick-reference.md +4 -4
- package/skills/using-plastic/references/reviewing-changes.md +10 -10
- package/skills/using-plastic/references/shelving.md +26 -11
- package/skills/using-plastic/references/troubleshooting.md +4 -4
- package/src/diff/local-snapshot.ts +74 -0
- package/src/diff/text.ts +187 -0
- package/src/domain/add-contract.ts +24 -0
- package/src/domain/branch-create-contract.ts +38 -0
- package/src/domain/branch-delete-contract.ts +15 -0
- package/src/domain/branches.ts +263 -0
- package/src/domain/checkin-contract.ts +70 -0
- package/src/domain/closeout-contract.ts +30 -0
- package/src/domain/copied-merge.ts +42 -0
- package/src/domain/diff-base-xml.ts +65 -0
- package/src/domain/diff-pending-xml.ts +34 -0
- package/src/domain/merge-output.ts +327 -0
- package/src/domain/paths.ts +253 -0
- package/src/domain/pending.ts +499 -0
- package/src/domain/removal-contract.ts +25 -0
- package/src/domain/revisions.ts +301 -0
- package/src/domain/server-merge-contract.ts +45 -0
- package/src/domain/server-merge-resolutions.ts +61 -0
- package/src/domain/status-xml.ts +122 -0
- package/src/domain/switch-contract.ts +60 -0
- package/src/domain/switch-policy.ts +95 -0
- package/src/domain/undo-contract.ts +24 -0
- package/src/domain/update-contract.ts +16 -0
- package/src/domain/workspace-merge-contract.ts +41 -0
- package/src/execution/add-command.ts +118 -0
- package/src/execution/branch-create-command.ts +118 -0
- package/src/execution/branch-list-command.ts +75 -0
- package/src/execution/checkin-command.ts +116 -0
- package/src/execution/cli-version.ts +31 -0
- package/src/execution/cm.ts +59 -0
- package/src/execution/code-review-find-command.ts +75 -0
- package/src/execution/context.ts +20 -0
- package/src/execution/diff-revisions-command.ts +74 -0
- package/src/execution/process.ts +260 -0
- package/src/execution/removal-command.ts +118 -0
- package/src/execution/server-merge-command.ts +92 -0
- package/src/execution/shelveset-list-command.ts +75 -0
- package/src/execution/status-command.ts +29 -0
- package/src/execution/status-xml-command.ts +72 -0
- package/src/execution/status-xml-limits.ts +1 -0
- package/src/execution/switch-command.ts +118 -0
- package/src/execution/test-seams.ts +10 -0
- package/src/execution/undo-command.ts +118 -0
- package/src/execution/update-command.ts +118 -0
- package/src/execution/workspace-list-command.ts +75 -0
- package/src/execution/workspace-merge-command.ts +118 -0
- package/src/operations/add-receipt.ts +37 -0
- package/src/operations/arguments.ts +3 -0
- package/src/operations/branch-create.ts +74 -0
- package/src/operations/branch-delete-receipt.ts +25 -0
- package/src/operations/branch-list.ts +94 -0
- package/src/operations/branch-reads.ts +94 -0
- package/src/operations/branches.ts +111 -0
- package/src/operations/checkin-receipt.ts +140 -0
- package/src/operations/checkin.ts +102 -0
- package/src/operations/closeout-receipt.ts +74 -0
- package/src/operations/closeout.ts +19 -0
- package/src/operations/code-review-find.ts +97 -0
- package/src/operations/consolidated-diff.ts +144 -0
- package/src/operations/diff-revisions.ts +157 -0
- package/src/operations/diff.ts +53 -0
- package/src/operations/merge.ts +26 -0
- package/src/operations/object-delete-receipt.ts +21 -0
- package/src/operations/object-write-receipt.ts +105 -0
- package/src/operations/patch-receipt.ts +96 -0
- package/src/operations/patch-support.ts +375 -0
- package/src/operations/patch.ts +25 -0
- package/src/operations/removal-receipt.ts +42 -0
- package/src/operations/reviews.ts +90 -0
- package/src/operations/server-merge.ts +130 -0
- package/src/operations/shelveset-list.ts +90 -0
- package/src/operations/shelvesets.ts +90 -0
- package/src/operations/status.ts +129 -0
- package/src/operations/switch-receipt.ts +85 -0
- package/src/operations/switch.ts +25 -0
- package/src/operations/undo-receipt.ts +37 -0
- package/src/operations/update-receipt.ts +35 -0
- package/src/operations/workspace-list.ts +92 -0
- package/src/operations/workspace-merge-receipt.ts +50 -0
- package/src/operations/workspace.ts +120 -0
- package/src/pi/add-output.ts +52 -0
- package/src/pi/arguments.ts +298 -0
- package/src/pi/branch-create-output.ts +83 -0
- package/src/pi/branch-delete-output.ts +46 -0
- package/src/pi/branch-list-output.ts +59 -0
- package/src/pi/branch-output.ts +63 -0
- package/src/pi/capability-diagnostics.ts +23 -0
- package/src/pi/checkin-output.ts +68 -0
- package/src/pi/closeout-output.ts +87 -0
- package/src/pi/code-review-find-output.ts +50 -0
- package/src/pi/diff-output.ts +66 -0
- package/src/pi/diff-revisions-output.ts +55 -0
- package/src/pi/object-delete-output.ts +15 -0
- package/src/pi/object-write-output.ts +54 -0
- package/src/pi/patch-output.ts +69 -0
- package/src/pi/register.ts +188 -0
- package/src/pi/removal-output.ts +59 -0
- package/src/pi/schemas.ts +146 -0
- package/src/pi/server-merge-output.ts +83 -0
- package/src/pi/shelveset-list-output.ts +50 -0
- package/src/pi/status-output.ts +204 -0
- package/src/pi/switch-output.ts +80 -0
- package/src/pi/tool-registry.ts +72 -0
- package/src/pi/undo-output.ts +52 -0
- package/src/pi/update-output.ts +48 -0
- package/src/pi/workspace-list-output.ts +53 -0
- package/src/pi/workspace-merge-output.ts +46 -0
- package/src/plastic-core.ts +14 -5775
- package/src/plastic-renderers.ts +102 -22
- package/src/plastic-tool-loading.ts +7 -12
- package/src/presentation/add-results.ts +5 -0
- package/src/presentation/branch-create.ts +7 -0
- package/src/presentation/branch-delete-results.ts +7 -0
- package/src/presentation/checkin-results.ts +14 -0
- package/src/presentation/closeout-results.ts +2 -0
- package/src/presentation/diff-results.ts +237 -0
- package/src/presentation/removal-results.ts +7 -0
- package/src/presentation/results.ts +84 -0
- package/src/presentation/switch-results.ts +7 -0
- package/src/presentation/undo-results.ts +5 -0
- package/src/presentation/update-results.ts +5 -0
- package/src/presentation/workspace-merge-results.ts +6 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { cmWhereEquals, cmWhereLike } from "../domain/branches";
|
|
2
|
+
import { toStructuredResult } from "../presentation/results";
|
|
3
|
+
import { runCm, normalizeFindOutputLines } from "../execution/cm";
|
|
4
|
+
import { spawnAndCollect, getCmExecutable } from "../execution/process";
|
|
5
|
+
import { commandExecutionStorage, getActiveAbortSignal } from "../execution/context";
|
|
6
|
+
import { runCodeReviewFindIdsCommand, CodeReviewFindCommandError, CODE_REVIEW_FIND_CAPTURE_LIMITS } from "../execution/code-review-find-command";
|
|
7
|
+
|
|
8
|
+
export type CodeReviewFindArgs = { status?: string; assignee?: string; owner?: string; target?: string; targetType?: "branch" | "changeset"; titleLike?: string; orderBy?: "date" | "modifieddate" | "status"; descending?: boolean; limit?: number; format?: string; dateFormat?: string; source?: "native" | "ids"; output?: "text" | "json"; maxItems?: number; workdir?: string };
|
|
9
|
+
export type CodeReviewFindQuery = { status: string | null; assignee: string | null; owner: string | null; target: string | null; targetType: "branch" | "changeset" | null; titleLike: string | null; orderBy: "date" | "modifieddate" | "status" | null; descending: boolean | null; limit: number | null };
|
|
10
|
+
export class CodeReviewFindError extends Error {
|
|
11
|
+
constructor(readonly code: "invalid_identity" | "malformed_output" | "unsupported_query" | "invalid_producer_data" | "output_overflow") { super(`Code review find read failed (${code}); no reusable rows are available.`); }
|
|
12
|
+
}
|
|
13
|
+
export const CODE_REVIEW_FIND_MAX_BYTES = 131072;
|
|
14
|
+
export const validCodeReviewQueryString = (s: string) => s.length <= 4096 && !/[\u0000-\u001f\u007f-\u009f\uD800-\uDFFF]/u.test(s);
|
|
15
|
+
export const validCodeReviewTemplate = (s: string) => s.length <= 4096 && !/[\u0000\uD800-\uDFFF]/u.test(s);
|
|
16
|
+
// Decimal strings retain exact integers beyond JavaScript's safe-number range.
|
|
17
|
+
export const validCodeReviewId = (s: string) => s.length <= 20 && /^(?:0|[1-9][0-9]*)$/.test(s);
|
|
18
|
+
export function codeReviewFindRequest(args: CodeReviewFindArgs) {
|
|
19
|
+
for (const value of [args.status, args.assignee, args.owner, args.target, args.titleLike])
|
|
20
|
+
if (value !== undefined && (typeof value !== "string" || !validCodeReviewQueryString(value))) throw new CodeReviewFindError("invalid_identity");
|
|
21
|
+
for (const value of [args.format, args.dateFormat])
|
|
22
|
+
if (value !== undefined && (typeof value !== "string" || !validCodeReviewTemplate(value))) throw new CodeReviewFindError("invalid_producer_data");
|
|
23
|
+
if ((args.source !== undefined && args.source !== "native" && args.source !== "ids")
|
|
24
|
+
|| (args.targetType !== undefined && args.targetType !== "branch" && args.targetType !== "changeset")
|
|
25
|
+
|| (args.orderBy !== undefined && !["date", "modifieddate", "status"].includes(args.orderBy))
|
|
26
|
+
|| (args.descending !== undefined && typeof args.descending !== "boolean")
|
|
27
|
+
|| (args.output !== undefined && args.output !== "text" && args.output !== "json")
|
|
28
|
+
|| (args.limit !== undefined && (!Number.isSafeInteger(args.limit) || args.limit < 1))
|
|
29
|
+
|| (args.maxItems !== undefined && (!Number.isSafeInteger(args.maxItems) || args.maxItems < 1 || args.maxItems > 500))) throw new CodeReviewFindError("invalid_producer_data");
|
|
30
|
+
const source = args.source ?? "native";
|
|
31
|
+
if (source === "ids" && (args.format || args.dateFormat)) throw new CodeReviewFindError("unsupported_query");
|
|
32
|
+
return { source, maxItems: args.maxItems ?? 100, query: { status: args.status ?? null, assignee: args.assignee ?? null, owner: args.owner ?? null, target: args.target ?? null, targetType: args.targetType ?? null, titleLike: args.titleLike ?? null, orderBy: args.orderBy ?? null, descending: args.descending ?? null, limit: args.limit ?? null } };
|
|
33
|
+
}
|
|
34
|
+
export function codeReviewFindCommand(args: CodeReviewFindArgs): string[] {
|
|
35
|
+
const { source, query } = codeReviewFindRequest(args);
|
|
36
|
+
const where: string[] = [];
|
|
37
|
+
for (const field of ["status", "assignee", "owner", "target", "targetType"] as const)
|
|
38
|
+
if (query[field]) where.push(cmWhereEquals(field === "targetType" ? "targettype" : field, query[field]));
|
|
39
|
+
if (query.titleLike) where.push(cmWhereLike("title", query.titleLike));
|
|
40
|
+
return ["find", "review", ...(where.length ? [`where ${where.join(" and ")}`] : []),
|
|
41
|
+
...(query.orderBy ? [`order by ${query.orderBy}${query.descending ? " desc" : " asc"}`] : []),
|
|
42
|
+
...(query.limit ? [`limit ${query.limit}`] : []),
|
|
43
|
+
...(source === "native" && args.format ? [`--format=${args.format}`] : []),
|
|
44
|
+
...(source === "native" && args.dateFormat ? [`--dateformat=${args.dateFormat}`] : []),
|
|
45
|
+
"--nototal", ...(source === "ids" ? ["--format={id}", "--encoding=utf-8"] : [])];
|
|
46
|
+
}
|
|
47
|
+
export function parseCodeReviewFindIds(bytes: Uint8Array): { ids: string[]; duplicateRecords: number } {
|
|
48
|
+
if (bytes.byteLength > CODE_REVIEW_FIND_CAPTURE_LIMITS.stdoutBytes) throw new CodeReviewFindCommandError("capture_incomplete");
|
|
49
|
+
if (bytes.some(byte => byte > 127)) throw new CodeReviewFindError("invalid_identity");
|
|
50
|
+
if (!bytes.length) return { ids: [], duplicateRecords: 0 };
|
|
51
|
+
const ids = Buffer.from(bytes).toString("ascii").split(/\r?\n/);
|
|
52
|
+
if (ids.at(-1) === "") ids.pop();
|
|
53
|
+
if (ids.length > CODE_REVIEW_FIND_CAPTURE_LIMITS.records) throw new CodeReviewFindCommandError("capture_incomplete");
|
|
54
|
+
if (ids.some(id => !validCodeReviewId(id))) throw new CodeReviewFindError("invalid_identity");
|
|
55
|
+
return { ids, duplicateRecords: ids.length - new Set(ids).size };
|
|
56
|
+
}
|
|
57
|
+
export type CodeReviewFindObservation = ReturnType<typeof codeReviewFindRequest> & ({ source: "native"; command: string[]; rawResult: string } | { source: "ids"; ids: string[]; duplicateRecords: number });
|
|
58
|
+
export async function assembleCodeReviewFindObservation(args: CodeReviewFindArgs, typedNative = false): Promise<CodeReviewFindObservation> {
|
|
59
|
+
const request = codeReviewFindRequest(args), command = codeReviewFindCommand(args);
|
|
60
|
+
if (request.source === "ids") {
|
|
61
|
+
const bytes = await runCodeReviewFindIdsCommand(command, args.workdir);
|
|
62
|
+
if (getActiveAbortSignal()?.aborted) throw new CodeReviewFindCommandError("aborted");
|
|
63
|
+
const parsed = parseCodeReviewFindIds(bytes);
|
|
64
|
+
if (request.query.limit !== null && parsed.ids.length > request.query.limit) throw new CodeReviewFindError("invalid_producer_data");
|
|
65
|
+
if (getActiveAbortSignal()?.aborted) throw new CodeReviewFindCommandError("aborted");
|
|
66
|
+
return { ...request, source: "ids", ...parsed };
|
|
67
|
+
}
|
|
68
|
+
if (!typedNative) return { ...request, source: "native", command, rawResult: await runCm(command, args.workdir) };
|
|
69
|
+
const signal = getActiveAbortSignal();
|
|
70
|
+
if (signal?.aborted) throw new CodeReviewFindCommandError("aborted");
|
|
71
|
+
let result;
|
|
72
|
+
try { result = await spawnAndCollect(getCmExecutable(), command, args.workdir ?? process.cwd(), undefined, signal, commandExecutionStorage.getStore()); }
|
|
73
|
+
catch { throw new CodeReviewFindCommandError(signal?.aborted ? "aborted" : "command_failed"); }
|
|
74
|
+
if (result.aborted || signal?.aborted) throw new CodeReviewFindCommandError("aborted");
|
|
75
|
+
if (result.timedOut || result.stdoutTruncated || result.stderrTruncated) throw new CodeReviewFindCommandError("capture_incomplete");
|
|
76
|
+
if (result.exitCode !== 0) throw new CodeReviewFindCommandError("command_failed");
|
|
77
|
+
return { ...request, source: "native", command, rawResult: [result.stdout, result.stderr].filter(Boolean).join("\n").trim() || "(no output)" };
|
|
78
|
+
}
|
|
79
|
+
export function codeReviewFindPayload(observation: CodeReviewFindObservation) {
|
|
80
|
+
const common = { schemaVersion: 1, action: "code-review-find", provenance: { source: "plastic", producer: "@aefree/pi-plastic", contentTrust: "external" }, ok: true } as const;
|
|
81
|
+
const data = { scope: "workspace_repository", qualifierVerified: false, query: observation.query, limits: { maxItems: observation.maxItems, idDigits: 20, queryCodeUnits: 4096, compactUtf8Bytes: CODE_REVIEW_FIND_MAX_BYTES } } as const;
|
|
82
|
+
const payload = observation.source === "native"
|
|
83
|
+
? { ...common, completeness: { read: "unknown", capture: "unknown", projection: false }, data: { ...data, mode: "native", basis: "find_review_native", rows: null, counts: null, countBasis: "unavailable" } }
|
|
84
|
+
: { ...common, completeness: { read: "complete", capture: "complete", projection: observation.ids.length <= observation.maxItems }, data: { ...data, mode: "ids", basis: "find_review_id_utf8", diagnostics: { duplicateRecords: observation.duplicateRecords }, rows: observation.ids.slice(0, observation.maxItems).map(id => ({ id })), countBasis: "observed_query_rows", counts: { observed: observation.ids.length, returned: Math.min(observation.ids.length, observation.maxItems), omitted: Math.max(0, observation.ids.length - observation.maxItems), excluded: 0 } } };
|
|
85
|
+
if (Buffer.byteLength(JSON.stringify(payload), "utf8") > CODE_REVIEW_FIND_MAX_BYTES) throw new CodeReviewFindError("output_overflow");
|
|
86
|
+
return payload;
|
|
87
|
+
}
|
|
88
|
+
export async function presentCodeReviewFindObservation(observation: CodeReviewFindObservation, args: CodeReviewFindArgs): Promise<string> {
|
|
89
|
+
if (observation.source === "native") return toStructuredResult("code-review-find", args.output ?? "text", observation.rawResult,
|
|
90
|
+
{ command: ["cm", ...observation.command], rawOutput: observation.rawResult, resultCount: normalizeFindOutputLines(observation.rawResult).length }, args.workdir);
|
|
91
|
+
if (args.output === "json") return JSON.stringify(codeReviewFindPayload(observation));
|
|
92
|
+
const ids = observation.ids.slice(0, observation.maxItems), omitted = observation.ids.length - ids.length;
|
|
93
|
+
return [...(ids.length ? ids : ["(no observed query rows)"]), ...(omitted ? [`(${omitted} observed query rows omitted from presentation.)`] : [])].join("\n");
|
|
94
|
+
}
|
|
95
|
+
export async function executeCodeReviewFind(args: CodeReviewFindArgs): Promise<string> {
|
|
96
|
+
return presentCodeReviewFindObservation(await assembleCodeReviewFindObservation(args), args);
|
|
97
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { createAsciiTempDirectory, isBinaryContent } from "../diff/text";
|
|
5
|
+
import { snapshotLocalFile } from "../diff/local-snapshot";
|
|
6
|
+
import { parseLoadedFileInfo, parseLoadedLs } from "../domain/diff-base-xml";
|
|
7
|
+
import { parseDiffPending, type DiffPendingItem, type DiffPendingSnapshot } from "../domain/diff-pending-xml";
|
|
8
|
+
import { resolveDiffFileRevision } from "../domain/revisions";
|
|
9
|
+
import { getCmExecutable } from "../execution/process";
|
|
10
|
+
import { getActiveAbortSignal } from "../execution/context";
|
|
11
|
+
import { runDiffRevisionsCommand } from "../execution/diff-revisions-command";
|
|
12
|
+
import { assembleDiffRevisionsObservation, compareDiffBytes, diffRevisionsPayload, materializeDiffRevision, validDiffRevisionsSelector, type DiffRevisionsObservation } from "./diff-revisions";
|
|
13
|
+
|
|
14
|
+
export type DiffRequest = { workdir?:string;format?:"text"|"json";maxChars?:number } & (
|
|
15
|
+
{mode:"revisions";leftRevision:string;rightRevision:string} |
|
|
16
|
+
{mode:"file";path:string;revision?:string} |
|
|
17
|
+
{mode:"workspace";paths:string[];allPending?:never;includePrivate?:never;maxFiles?:number} |
|
|
18
|
+
{mode:"workspace";allPending:true;paths?:never;includePrivate?:boolean;maxFiles?:number}
|
|
19
|
+
);
|
|
20
|
+
export class ConsolidatedDiffError extends Error {
|
|
21
|
+
constructor(readonly code:string,readonly stage:string) { super("The requested diff could not be verified."); }
|
|
22
|
+
}
|
|
23
|
+
const fail=(code:string,stage:string):never=>{throw new ConsolidatedDiffError(code,stage);};
|
|
24
|
+
const aborted=()=>{if(getActiveAbortSignal()?.aborted)fail("aborted","comparison");};
|
|
25
|
+
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
|
+
export function validateDiffRequest(input:unknown):asserts input is DiffRequest {
|
|
27
|
+
if(!input||typeof input!=="object"||Array.isArray(input))fail("invalid_request","input");
|
|
28
|
+
const r=input as Record<string,unknown>,allowed=new Set(["mode","workdir","format","maxChars"]);
|
|
29
|
+
if(r.mode==="revisions") {allowed.add("leftRevision");allowed.add("rightRevision");if(!validDiffRevisionsSelector(r.leftRevision)||!validDiffRevisionsSelector(r.rightRevision))fail("invalid_selector","input");}
|
|
30
|
+
else if(r.mode==="file") {allowed.add("path");allowed.add("revision");if(!safe(r.path,1024)||r.revision!==undefined&&!safe(r.revision,4096))fail("invalid_request","input");}
|
|
31
|
+
else if(r.mode==="workspace") {
|
|
32
|
+
allowed.add("maxFiles");
|
|
33
|
+
if(r.paths!==undefined){allowed.add("paths");if(!Array.isArray(r.paths)||!r.paths.length||r.paths.length>20||r.paths.some(p=>!safe(p,1024)))fail("invalid_request","input");}
|
|
34
|
+
else {allowed.add("allPending");allowed.add("includePrivate");if(r.allPending!==true||r.includePrivate!==undefined&&typeof r.includePrivate!=="boolean")fail("invalid_request","input");}
|
|
35
|
+
if(r.maxFiles!==undefined&&(!Number.isInteger(r.maxFiles)||Number(r.maxFiles)<1||Number(r.maxFiles)>20))fail("invalid_request","input");
|
|
36
|
+
} else fail("invalid_request","input");
|
|
37
|
+
if(Object.keys(r).some(k=>!allowed.has(k))||r.format!==undefined&&r.format!=="text"&&r.format!=="json"||r.workdir!==undefined&&!safe(r.workdir,4096))fail("invalid_request","input");
|
|
38
|
+
if(r.maxChars!==undefined&&(!Number.isInteger(r.maxChars)||Number(r.maxChars)<500||Number(r.maxChars)>(r.mode==="workspace"?8000:20000)))fail("invalid_request","input");
|
|
39
|
+
}
|
|
40
|
+
const key=(p:string)=>{const normalized=resolve(p);return process.platform==="win32"?normalized.toLowerCase():normalized;};
|
|
41
|
+
const contains=(root:string,p:string)=>{const r=relative(root,p);return r!==".."&&!r.startsWith(".."+sep)&&!isAbsolute(r);};
|
|
42
|
+
async function inspectPath(path:string,code:string,stage:string) {
|
|
43
|
+
try { return await fs.lstat(path); }
|
|
44
|
+
catch (error) {
|
|
45
|
+
const errno=(error as NodeJS.ErrnoException)?.code;
|
|
46
|
+
if(errno==="ENOENT"||errno==="ENOTDIR")return null;
|
|
47
|
+
return fail(code,stage);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function workspaceRoot(cwd:string):Promise<string>{
|
|
51
|
+
let p=await fs.realpath(cwd).catch(()=>fail("workspace_unavailable","selection"));
|
|
52
|
+
for(let i=0;i<128;i++){
|
|
53
|
+
const marker=await inspectPath(join(p,".plastic"),"workspace_unavailable","selection");
|
|
54
|
+
if(marker){
|
|
55
|
+
if(!marker.isDirectory()||marker.isSymbolicLink())return fail("workspace_unavailable","selection");
|
|
56
|
+
const config=await inspectPath(join(p,".plastic","plastic.workspace"),"workspace_unavailable","selection");
|
|
57
|
+
if(config){
|
|
58
|
+
if(!config.isFile()||config.isSymbolicLink())return fail("workspace_unavailable","selection");
|
|
59
|
+
return p;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const parent=dirname(p);if(parent===p)break;p=parent;
|
|
63
|
+
}
|
|
64
|
+
return fail("workspace_unavailable","selection");
|
|
65
|
+
}
|
|
66
|
+
async function xmlCommand(args:string[],cwd:string,stage:string):Promise<Buffer>{
|
|
67
|
+
aborted();try{return (await runDiffRevisionsCommand(getCmExecutable(),args,cwd,[0])).stdout;}catch{return fail(getActiveAbortSignal()?.aborted?"aborted":"command_failed",stage);}
|
|
68
|
+
}
|
|
69
|
+
export type LoadedIdentity={revisionId:string;changeset:string;repository:string;server:string};
|
|
70
|
+
export type DiffSide={origin:"historical"|"local-snapshot"|"synthetic-empty";selector:string|null;path:string|null;identity:LoadedIdentity|null;bytes:number;binary:boolean};
|
|
71
|
+
export type Comparison={atomicSnapshot:false;kind:string;pendingKind:string|null;left:DiffSide;right:DiffSide;status:"changed"|"unchanged"|"binary-different"|"added-empty";changed:boolean;binary:boolean;comparisonBasis:"byte_equality"|"diff_u";hunkCount:number;capture:{diffStdoutBytes:number|null;diffExitCode:number|null};excerpt:ReturnType<typeof diffRevisionsPayload>["data"]["excerpt"]};
|
|
72
|
+
function comparison(observation:DiffRevisionsObservation,left:DiffSide,right:DiffSide,kind:string,pendingKind:string|null):Comparison {
|
|
73
|
+
const d=diffRevisionsPayload(observation).data;
|
|
74
|
+
return {atomicSnapshot:false,kind,pendingKind,left,right,status:!d.changed&&pendingKind==="added"?"added-empty":d.status,changed:d.changed,binary:d.binary,comparisonBasis:d.comparisonBasis,hunkCount:d.hunkCount,capture:d.capture,excerpt:d.excerpt};
|
|
75
|
+
}
|
|
76
|
+
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
|
+
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
|
+
async function base(path:string,cwd:string,snapshot:DiffPendingSnapshot,item:DiffPendingItem|null):Promise<{selector:string;identity:LoadedIdentity;hash:string;algorithm:"md5"|"sha1"}>{
|
|
79
|
+
try {
|
|
80
|
+
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");
|
|
82
|
+
// DE removes live-tree membership/RepSpec. Deliberately inspect the
|
|
83
|
+
// frozen loaded root tree, never retry a failed live-tree lookup or
|
|
84
|
+
// guess a parent owning repository. Require loaded changeset/hash match.
|
|
85
|
+
const deleted=item?.code==="DE";
|
|
86
|
+
if(deleted&&info.status!=="deleted")return fail("base_changed","base");
|
|
87
|
+
const query=deleted?["ls",info.serverPath,"--tree=cs:"+snapshot.changeset+"@"+snapshot.repository+"@"+snapshot.server,"--xml","--encoding=utf-8"]:["ls",path,"--xml","--encoding=utf-8"];
|
|
88
|
+
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");
|
|
90
|
+
const selector="revid:"+row.revisionId+"@rep:"+row.repository+"@repserver:"+row.server;
|
|
91
|
+
if(!validDiffRevisionsSelector(selector))return fail("invalid_selector","base");
|
|
92
|
+
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");}
|
|
94
|
+
}
|
|
95
|
+
async function fileComparison(args:DiffRequest&{mode:"file"},cwd:string,root:string,snapshot:DiffPendingSnapshot|null,item:DiffPendingItem|null):Promise<Comparison>{
|
|
96
|
+
const path=resolve(cwd,args.path);if(!contains(root,path))return fail("outside_workspace","selection");
|
|
97
|
+
let selector:string|null=null,identity:LoadedIdentity|null=null,expected:{hash:string;algorithm:"md5"|"sha1"}|null=null;
|
|
98
|
+
if(args.revision!==undefined){try{selector=resolveDiffFileRevision(args.path,args.revision).resolved;}catch{return fail("invalid_selector","input");}if(!validDiffRevisionsSelector(selector))return fail("invalid_selector","input");}
|
|
99
|
+
else if(item?.kind!=="added"&&item?.kind!=="private") {const lookup=item?.code==="LM"?item.sourcePath!:path;if(!contains(root,lookup))return fail("outside_workspace","selection");const b=await base(lookup,cwd,snapshot!,item);selector=b.selector;identity=b.identity;expected=b;}
|
|
100
|
+
const temp=await createAsciiTempDirectory("pi-plastic-file-diff-");
|
|
101
|
+
try {
|
|
102
|
+
const l=selector?await materializeDiffRevision(selector,join(temp,"base"),"left",cwd):Buffer.alloc(0);
|
|
103
|
+
if(expected&&createHash(expected.algorithm).update(l).digest("base64")!==expected.hash)return fail("base_changed","base");
|
|
104
|
+
const deleted=!args.revision&&item?.kind==="deleted";
|
|
105
|
+
if(deleted&&await inspectPath(path,"read_failed","right"))return fail("file_changed","right");
|
|
106
|
+
const r=deleted?Buffer.alloc(0):(await snapshotLocalFile(path,root,getActiveAbortSignal())).bytes;
|
|
107
|
+
const left=side(selector?"historical":"synthetic-empty",l,null,selector,identity),right=side(deleted?"synthetic-empty":"local-snapshot",r,path,null);
|
|
108
|
+
const raw=await compareDiffBytes(l,r,selector??args.path+" (empty before add)",args.path+(deleted?" (empty after delete)":" (local snapshot)"));
|
|
109
|
+
const obs:DiffRevisionsObservation={...raw,maxChars:args.maxChars??8000,left:{selector:selector??"synthetic empty",kind:"global-revision",resolvedIdentity:null,bytes:l.length,binary:left.binary},right:{selector:args.path,kind:"file-qualified",resolvedIdentity:null,bytes:r.length,binary:right.binary}};
|
|
110
|
+
return comparison(obs,left,right,args.revision!==undefined?"explicit-revision-to-local":"loaded-base-to-local",args.revision!==undefined?null:item?.kind??null);
|
|
111
|
+
} finally {await fs.rm(temp,{recursive:true,force:true}).catch(()=>fail("cleanup_failed","cleanup"));}
|
|
112
|
+
}
|
|
113
|
+
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."};}
|
|
115
|
+
export async function assembleConsolidatedDiff(input:unknown){
|
|
116
|
+
validateDiffRequest(input);const args=input;aborted();
|
|
117
|
+
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}};
|
|
118
|
+
if(args.mode==="revisions") {
|
|
119
|
+
const obs=await assembleDiffRevisionsObservation(args),d=diffRevisionsPayload(obs);
|
|
120
|
+
const left:DiffSide={origin:"historical",selector:args.leftRevision,path:null,identity:null,bytes:obs.left.bytes,binary:obs.left.binary};
|
|
121
|
+
const right:DiffSide={origin:"historical",selector:args.rightRevision,path:null,identity:null,bytes:obs.right.bytes,binary:obs.right.binary};
|
|
122
|
+
return {...header,mode:"revisions" as const,ok:true,completeness:d.completeness,data:comparison(obs,left,right,"revision-to-revision",null)};
|
|
123
|
+
}
|
|
124
|
+
const cwd=await fs.realpath(args.workdir??process.cwd()).catch(()=>fail("workspace_unavailable","selection")),root=await workspaceRoot(cwd);
|
|
125
|
+
if(args.mode==="file") {
|
|
126
|
+
const snapshot=args.revision!==undefined?null:await pending(cwd),item=snapshot?.items.find(i=>key(i.path)===key(resolve(cwd,args.path)))??null;
|
|
127
|
+
const data=await fileComparison(args,cwd,root,snapshot,item);
|
|
128
|
+
return {...header,mode:"file" as const,ok:true,completeness:{read:"complete",capture:"complete",projection:!data.excerpt?.truncated},data};
|
|
129
|
+
}
|
|
130
|
+
const snapshot=await pending(cwd),requestedPaths=args.paths??null,scopes=requestedPaths?.map(p=>resolve(cwd,p))??[];
|
|
131
|
+
if(scopes.some(p=>!contains(root,p)))return fail("outside_workspace","selection");
|
|
132
|
+
if(scopes.some(p=>key(p)===key(root)))return fail("invalid_request","selection");
|
|
133
|
+
const matches=(p:string,i:DiffPendingItem)=>contains(p,i.path)||i.sourcePath!==null&&contains(p,i.sourcePath);
|
|
134
|
+
const eligible=snapshot.items.filter(i=>scopes.length?scopes.some(p=>matches(p,i)):i.kind!=="private"||"includePrivate"in args&&args.includePrivate);
|
|
135
|
+
const maxFiles=args.maxFiles??(scopes.length?20:3),selected=eligible.slice(0,maxFiles),outcomes:DiffOutcome[]=[];
|
|
136
|
+
let attempted=0,completed=0,failed=0,skipped=0;
|
|
137
|
+
for(const i of selected){if(getActiveAbortSignal()?.aborted)break;if(i.directory){outcomes.push({path:i.path,sourcePath:i.sourcePath,kind:i.kind,status:"skipped-directory"});skipped++;continue;}
|
|
138
|
+
attempted++;try{const c=await fileComparison({mode:"file",path:i.path,workdir:cwd,maxChars:args.maxChars??3000},cwd,root,snapshot,i);outcomes.push({path:i.path,sourcePath:i.sourcePath,kind:i.kind,status:"compared",comparison:c});completed++;}catch(e){outcomes.push({path:i.path,sourcePath:i.sourcePath,kind:i.kind,status:"unavailable",error:diffError(e)});failed++;}}
|
|
139
|
+
const unattempted=selected.length-outcomes.length,ok=!failed&&!unattempted;
|
|
140
|
+
const data={scope:scopes.length?"selected":"all-pending",requestedPaths,includePrivate:"includePrivate"in args?args.includePrivate??false:false,excludedPrivate:scopes.length?0:snapshot.items.filter(i=>i.kind==="private"&&!("includePrivate"in args&&args.includePrivate)).length,counts:{parsed:snapshot.items.length,eligible:eligible.length,selected:selected.length,attempted,completed,failed,skipped,unattempted,limited:eligible.length-selected.length,returned:outcomes.length,omitted:0},unmatched:requestedPaths?requestedPaths.filter((p,index)=>!snapshot.items.some(i=>matches(scopes[index],i))):[],outcomes};
|
|
141
|
+
const payload={...header,mode:"workspace" as const,ok,completeness:{read:ok&&!data.counts.limited&&!data.unmatched.length&&!data.counts.skipped?"complete":"incomplete",capture:ok?"complete":"unknown",projection:outcomes.every(o=>o.status!=="compared"||!o.comparison.excerpt?.truncated)&&!data.counts.limited},data,...(!ok?{error:{code:unattempted?"aborted":"partial_comparison",stage:"comparison",message:"Some requested comparisons could not be verified."}}:{})};
|
|
142
|
+
while(Buffer.byteLength(JSON.stringify(payload),"utf8")>131072&&data.outcomes.length){data.outcomes.pop();data.counts.returned--;data.counts.omitted++;payload.completeness.projection=false;}
|
|
143
|
+
if(Buffer.byteLength(JSON.stringify(payload),"utf8")>131072)return fail("output_overflow","producer");return payload;
|
|
144
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { TextDecoder } from "node:util";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createAsciiTempDirectory, safeTempExtension, isBinaryContent, stableDiffHeaders, type TextDiffResult } from "../diff/text";
|
|
5
|
+
import { DIFF_OUTPUT_MAX_CHARS } from "../diff/text";
|
|
6
|
+
import { normalizeDiffResponseMaxChars, formatTextDiff } from "../presentation/diff-results";
|
|
7
|
+
import { isValidDiffRevisionSpec } from "../domain/revisions";
|
|
8
|
+
import { getActiveAbortSignal } from "../execution/context";
|
|
9
|
+
import { getCmExecutable, getDiffExecutable } from "../execution/process";
|
|
10
|
+
import { runDiffRevisionsCommand, DiffRevisionsCommandError } from "../execution/diff-revisions-command";
|
|
11
|
+
|
|
12
|
+
export const DIFF_REVISIONS_FILE_BYTES = 4 * 1024 * 1024;
|
|
13
|
+
export const DIFF_REVISIONS_MAX_BYTES = 131072;
|
|
14
|
+
export type DiffRevisionsArgs = { leftRevision: string; rightRevision: string; maxChars?: number; format?: "text" | "json"; workdir?: string };
|
|
15
|
+
export type DiffRevisionsStage = "input" | "left" | "right" | "comparison" | "cleanup" | "producer";
|
|
16
|
+
export type DiffRevisionsErrorCode = "invalid_selector" | "invalid_producer_data" | "materialization_failed" | "materialization_limit" | "cleanup_failed" | "invalid_utf8" | "malformed_output" | "command_failed" | "aborted" | "capture_incomplete" | "output_overflow";
|
|
17
|
+
export class DiffRevisionsError extends Error {
|
|
18
|
+
constructor(readonly code: DiffRevisionsErrorCode, readonly stage: DiffRevisionsStage) { super("Historical revision comparison failed; no verified comparison is available."); }
|
|
19
|
+
}
|
|
20
|
+
const abort = (stage: DiffRevisionsStage) => { if (getActiveAbortSignal()?.aborted) throw new DiffRevisionsError("aborted", stage); };
|
|
21
|
+
export const validDiffRevisionsSelector = (s: unknown): s is string => {
|
|
22
|
+
if (typeof s !== "string" || !s.length || s.length > 4096 || s.trim() !== s
|
|
23
|
+
|| /[;\u0000-\u001f\u007f-\u009f\uD800-\uDFFF]/u.test(s) || s.startsWith("-")) return false;
|
|
24
|
+
const parts = s.split("#");
|
|
25
|
+
if (parts.length > 2) return false;
|
|
26
|
+
const validGlobal = (value: string) => /^revid:(?:0|[1-9][0-9]*)(?:@rep:[^@]+@repserver:\S+)?$/i.test(value) || /^rev:[^\s#]+$/i.test(value);
|
|
27
|
+
if (parts.length === 1) return validGlobal(s);
|
|
28
|
+
const [path, selector] = parts;
|
|
29
|
+
if (!path || path.trim() !== path || path.startsWith("-") || /^itemid:/i.test(path) && !/^itemid:(?:0|[1-9][0-9]*)$/i.test(path)) return false;
|
|
30
|
+
return /^cs:\d+$/i.test(selector) || /^br:\/.+$/i.test(selector) || /^lb:[^\s#]+$/i.test(selector) || validGlobal(selector);
|
|
31
|
+
};
|
|
32
|
+
export function diffRevisionsRequest(args: DiffRevisionsArgs) {
|
|
33
|
+
if (!validDiffRevisionsSelector(args.leftRevision) || !validDiffRevisionsSelector(args.rightRevision)) throw new DiffRevisionsError("invalid_selector", "input");
|
|
34
|
+
if (args.format !== undefined && args.format !== "text" && args.format !== "json") throw new DiffRevisionsError("invalid_producer_data", "input");
|
|
35
|
+
return { leftRevision: args.leftRevision, rightRevision: args.rightRevision, maxChars: normalizeDiffResponseMaxChars(args.maxChars) };
|
|
36
|
+
}
|
|
37
|
+
export type RevisionSide = { selector: string; kind: "file-qualified" | "global-revision"; resolvedIdentity: null; bytes: number; binary: boolean };
|
|
38
|
+
export type DiffRevisionsObservation = { left: RevisionSide; right: RevisionSide; maxChars: number; binary: boolean; changed: boolean; normalized: string; hunkCount: number; diffStdoutBytes: number | null; diffExitCode: number | null; legacyResult: TextDiffResult };
|
|
39
|
+
|
|
40
|
+
// Fully validate unified syntax/counts, not just an exit code or a plausible header.
|
|
41
|
+
// Backend-provided header identities are never published as resolved revisions.
|
|
42
|
+
export function validateRevisionUnifiedDiff(raw: string): number {
|
|
43
|
+
if (raw.includes("\0")) throw new DiffRevisionsError("malformed_output", "comparison");
|
|
44
|
+
const lines = raw.split(/\r?\n/); if (lines.at(-1) === "") lines.pop();
|
|
45
|
+
if (!lines[0]?.startsWith("--- ") || lines[0].length <= 4 || !lines[1]?.startsWith("+++ ") || lines[1].length <= 4) throw new DiffRevisionsError("malformed_output", "comparison");
|
|
46
|
+
let left = 0, right = 0, hunks = 0, changes = 0, markerAllowed = false;
|
|
47
|
+
for (const line of lines.slice(2)) {
|
|
48
|
+
const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$/.exec(line);
|
|
49
|
+
if (header) {
|
|
50
|
+
if (left || right) throw new DiffRevisionsError("malformed_output", "comparison");
|
|
51
|
+
const values = [header[1], header[2] ?? "1", header[3], header[4] ?? "1"].map(Number);
|
|
52
|
+
if (values.some(n => !Number.isSafeInteger(n) || n > DIFF_REVISIONS_FILE_BYTES + 1)) throw new DiffRevisionsError("malformed_output", "comparison");
|
|
53
|
+
left = values[1]; right = values[3]; if (!left && !right) throw new DiffRevisionsError("malformed_output", "comparison");
|
|
54
|
+
hunks++; markerAllowed = false; continue;
|
|
55
|
+
}
|
|
56
|
+
if (line === "\" && markerAllowed) { markerAllowed = false; continue; }
|
|
57
|
+
if (!hunks || (!left && !right)) throw new DiffRevisionsError("malformed_output", "comparison");
|
|
58
|
+
if (line.startsWith(" ")) { left--; right--; }
|
|
59
|
+
else if (line.startsWith("-")) { left--; changes++; }
|
|
60
|
+
else if (line.startsWith("+")) { right--; changes++; }
|
|
61
|
+
else throw new DiffRevisionsError("malformed_output", "comparison");
|
|
62
|
+
if (left < 0 || right < 0) throw new DiffRevisionsError("malformed_output", "comparison");
|
|
63
|
+
markerAllowed = true;
|
|
64
|
+
}
|
|
65
|
+
if (!hunks || !changes || left || right) throw new DiffRevisionsError("malformed_output", "comparison");
|
|
66
|
+
return hunks;
|
|
67
|
+
}
|
|
68
|
+
export async function materializeDiffRevision(selector: string, path: string, stage: "left" | "right", cwd: string): Promise<Buffer> {
|
|
69
|
+
abort(stage);
|
|
70
|
+
await runDiffRevisionsCommand(getCmExecutable(), ["cat", selector, "--file=" + path], cwd, [0])
|
|
71
|
+
.catch(error => { throw error instanceof DiffRevisionsCommandError ? new DiffRevisionsError(error.code, stage) : new DiffRevisionsError("materialization_failed", stage); });
|
|
72
|
+
abort(stage);
|
|
73
|
+
try {
|
|
74
|
+
// Reject symlink/directory output; read through the same descriptor whose size was checked.
|
|
75
|
+
const stat = await fs.lstat(path);
|
|
76
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new DiffRevisionsError("materialization_failed", stage);
|
|
77
|
+
const file = await fs.open(path, "r");
|
|
78
|
+
try {
|
|
79
|
+
const size = (await file.stat()).size;
|
|
80
|
+
if (size > DIFF_REVISIONS_FILE_BYTES) throw new DiffRevisionsError("materialization_limit", stage);
|
|
81
|
+
const bytes = Buffer.alloc(size + 1); // one extra byte detects growth after the size check
|
|
82
|
+
let length = 0;
|
|
83
|
+
while (length < bytes.length) { abort(stage); const result = await file.read(bytes, length, bytes.length - length, length); if (!result.bytesRead) break; length += result.bytesRead; }
|
|
84
|
+
if (length !== size || (await file.stat()).size !== size) throw new DiffRevisionsError("materialization_failed", stage);
|
|
85
|
+
return bytes.subarray(0, size);
|
|
86
|
+
} finally { await file.close(); }
|
|
87
|
+
} catch (error) { throw error instanceof DiffRevisionsError ? error : new DiffRevisionsError("materialization_failed", stage); }
|
|
88
|
+
}
|
|
89
|
+
export async function assembleDiffRevisionsObservation(args: DiffRevisionsArgs): Promise<DiffRevisionsObservation> {
|
|
90
|
+
const request = diffRevisionsRequest(args); abort("input");
|
|
91
|
+
let root: string;
|
|
92
|
+
try { root = await createAsciiTempDirectory("pi-plastic-revisions-"); } catch { throw new DiffRevisionsError("materialization_failed", "left"); }
|
|
93
|
+
try {
|
|
94
|
+
const leftPath = join(root, "left" + safeTempExtension(request.leftRevision));
|
|
95
|
+
const rightPath = join(root, "right" + safeTempExtension(request.rightRevision));
|
|
96
|
+
const leftBytes = await materializeDiffRevision(request.leftRevision, leftPath, "left", args.workdir ?? process.cwd());
|
|
97
|
+
const rightBytes = await materializeDiffRevision(request.rightRevision, rightPath, "right", args.workdir ?? process.cwd());
|
|
98
|
+
const side = (selector: string, bytes: Buffer): RevisionSide => ({ selector, kind: selector.includes("#") ? "file-qualified" : "global-revision", resolvedIdentity: null, bytes: bytes.length, binary: isBinaryContent(bytes) });
|
|
99
|
+
return { left: side(request.leftRevision,leftBytes), right: side(request.rightRevision,rightBytes), maxChars:request.maxChars, ...await compareDiffBytes(leftBytes,rightBytes,request.leftRevision.replace("#","@"),request.rightRevision.replace("#","@")) };
|
|
100
|
+
} finally { await fs.rm(root, { recursive: true, force: true }).catch(() => { throw new DiffRevisionsError("cleanup_failed", "cleanup"); }); }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function compareDiffBytes(leftBytes: Buffer, rightBytes: Buffer, leftLabel: string, rightLabel: string): Promise<Omit<DiffRevisionsObservation,"left"|"right"|"maxChars">> {
|
|
104
|
+
abort("comparison");
|
|
105
|
+
const binary = isBinaryContent(leftBytes) || isBinaryContent(rightBytes);
|
|
106
|
+
if (binary) return { binary: true, changed: !leftBytes.equals(rightBytes), normalized: "", hunkCount: 0, diffStdoutBytes: null, diffExitCode: null, legacyResult: { backend: "diff", binary: true, changed: !leftBytes.equals(rightBytes), output: "", truncated: false, totalChars: 0 } };
|
|
107
|
+
// Match the old portable helper: ASCII-safe immutable backend operands and no logical labels in argv.
|
|
108
|
+
const operands = await createAsciiTempDirectory("pi-plastic-diff-");
|
|
109
|
+
try {
|
|
110
|
+
const l = join(operands, "left" + safeTempExtension(leftLabel)), r = join(operands, "right" + safeTempExtension(rightLabel));
|
|
111
|
+
await Promise.all([fs.writeFile(l, leftBytes, { flag: "wx" }), fs.writeFile(r, rightBytes, { flag: "wx" })]);
|
|
112
|
+
abort("comparison");
|
|
113
|
+
const capture = await runDiffRevisionsCommand(getDiffExecutable(), ["-u", l, r], operands, [0, 1])
|
|
114
|
+
.catch(error => { throw new DiffRevisionsError(error instanceof DiffRevisionsCommandError ? error.code : "command_failed", "comparison"); });
|
|
115
|
+
let raw: string;
|
|
116
|
+
try { raw = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(capture.stdout); }
|
|
117
|
+
catch { throw new DiffRevisionsError("invalid_utf8", "comparison"); }
|
|
118
|
+
if (capture.exitCode === 0 && raw !== "") throw new DiffRevisionsError("malformed_output", "comparison");
|
|
119
|
+
const hunks = capture.exitCode === 1 ? validateRevisionUnifiedDiff(raw) : 0;
|
|
120
|
+
const normalized = stableDiffHeaders(raw, leftLabel, rightLabel);
|
|
121
|
+
const legacyOutput = stableDiffHeaders(raw.slice(0, DIFF_OUTPUT_MAX_CHARS), leftLabel, rightLabel);
|
|
122
|
+
const truncated = raw.length > DIFF_OUTPUT_MAX_CHARS;
|
|
123
|
+
const legacyResult: TextDiffResult = { backend: "diff", changed: capture.exitCode === 1, binary: false, output: truncated ? legacyOutput + "\n\n[Diff output truncated at " + DIFF_OUTPUT_MAX_CHARS + " characters; inspect a narrower file or generate a review patch for the complete change.]" : normalized, truncated, totalChars: truncated ? raw.length : normalized.length };
|
|
124
|
+
abort("comparison");
|
|
125
|
+
return { binary: false, changed: capture.exitCode === 1, normalized, hunkCount: hunks, diffStdoutBytes: capture.stdout.length, diffExitCode: capture.exitCode, legacyResult };
|
|
126
|
+
} finally { await fs.rm(operands, { recursive: true, force: true }).catch(() => { throw new DiffRevisionsError("cleanup_failed", "cleanup"); }); }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type RevisionExcerpt = { countBasis: "normalized_diff_utf16"; text: string; observedChars: number; sourceChars: number; returnedChars: number; omittedChars: number; markerChars: number; truncated: boolean };
|
|
130
|
+
export const DIFF_REVISIONS_EXCERPT_MARKER = "\n\n[Diff excerpt truncated; not a complete/applyable patch.]";
|
|
131
|
+
const prefix = (s: string, count: number) => { let end = Math.min(count, s.length); if (end > 0 && end < s.length && /[\uD800-\uDBFF]/.test(s[end - 1])) end--; return s.slice(0, end); };
|
|
132
|
+
export function diffRevisionsPayload(observation: DiffRevisionsObservation) {
|
|
133
|
+
const excerpt = (count: number): RevisionExcerpt => {
|
|
134
|
+
const selected = prefix(observation.normalized, count), omitted = observation.normalized.length - selected.length;
|
|
135
|
+
const marker = omitted ? DIFF_REVISIONS_EXCERPT_MARKER : "";
|
|
136
|
+
return { countBasis: "normalized_diff_utf16", text: selected + marker, observedChars: observation.normalized.length, sourceChars: selected.length, returnedChars: selected.length + marker.length, omittedChars: omitted, markerChars: marker.length, truncated: omitted > 0 };
|
|
137
|
+
};
|
|
138
|
+
const markerReserve = DIFF_REVISIONS_EXCERPT_MARKER.length;
|
|
139
|
+
const selected = observation.normalized.length > observation.maxChars ? Math.max(0, observation.maxChars - markerReserve) : observation.normalized.length;
|
|
140
|
+
const data = { ...({ scope: "explicit_selector_pair", qualifierVerified: false, resolvedIdentities: null, comparisonKind: "revision-to-revision", left: observation.left, right: observation.right, status: observation.changed ? observation.binary ? "binary-different" : "changed" : "unchanged", changed: observation.changed, binary: observation.binary, comparisonBasis: observation.binary ? "byte_equality" : "diff_u", hunkCount: observation.hunkCount, capture: { diffStdoutBytes: observation.diffStdoutBytes, diffExitCode: observation.diffExitCode }, limits: { maxChars: observation.maxChars, selectorCodeUnits: 4096, materializedBytesPerSide: DIFF_REVISIONS_FILE_BYTES, compactUtf8Bytes: DIFF_REVISIONS_MAX_BYTES }, excerpt: observation.binary ? null : excerpt(selected) } as const) };
|
|
141
|
+
const completeness = { read: "complete" as const, capture: "complete" as const, projection: !data.excerpt?.truncated };
|
|
142
|
+
const payload = { schemaVersion: 1, action: "diff-revisions", provenance: { source: "plastic", producer: "@aefree/pi-plastic", contentTrust: "external" }, ok: true, completeness, data } as const;
|
|
143
|
+
if (Buffer.byteLength(JSON.stringify(payload), "utf8") > DIFF_REVISIONS_MAX_BYTES && !observation.binary) {
|
|
144
|
+
let low = 0, high = selected;
|
|
145
|
+
while (low < high) { const mid = Math.ceil((low + high) / 2); data.excerpt = excerpt(mid); completeness.projection = !data.excerpt.truncated; if (Buffer.byteLength(JSON.stringify(payload), "utf8") <= DIFF_REVISIONS_MAX_BYTES) low = mid; else high = mid - 1; }
|
|
146
|
+
data.excerpt = excerpt(low);
|
|
147
|
+
completeness.projection = !data.excerpt.truncated;
|
|
148
|
+
}
|
|
149
|
+
if (Buffer.byteLength(JSON.stringify(payload), "utf8") > DIFF_REVISIONS_MAX_BYTES) throw new DiffRevisionsError("output_overflow", "producer");
|
|
150
|
+
return payload;
|
|
151
|
+
}
|
|
152
|
+
export async function presentDiffRevisions(observation: DiffRevisionsObservation, args: DiffRevisionsArgs): Promise<string> {
|
|
153
|
+
return formatTextDiff("diffRevisions", args.format, "revision-to-revision", observation.legacyResult, args.workdir, {}, args.maxChars);
|
|
154
|
+
}
|
|
155
|
+
export async function executeDiffRevisions(args: DiffRevisionsArgs): Promise<string> {
|
|
156
|
+
return presentDiffRevisions(await assembleDiffRevisionsObservation(args), args);
|
|
157
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { DIFF_OUTPUT_MAX_CHARS, isBinaryContent, stableDiffHeaders, boundDiffOutput, runPortableTextDiff, safeTempExtension, isAsciiPath, createAsciiTempDirectory, materializeRevision, isNoDataError, withWorkspaceBaseUnavailableDiagnostic, TextDiffResult } from "../diff/text";
|
|
2
|
+
import { DIFF_RESPONSE_DEFAULT_MAX_CHARS, DIFF_RESPONSE_MAX_CHARS, DIFF_RESPONSE_MIN_CHARS, DIFF_RESPONSE_TOTAL_MAX_CHARS, normalizeDiffResponseMaxChars, boundTextDiffResult, formatTextDiff, WORKSPACE_DIFF_PATH_MAX_CHARS, WORKSPACE_DIFF_MAX_PATHS, WORKSPACE_DIFF_MAX_FILES, WORKSPACE_DIFF_DEFAULT_MAX_FILES, WORKSPACE_DIFF_MIN_CHARS, WORKSPACE_DIFF_PER_FILE_MAX_CHARS, WORKSPACE_DIFF_DEFAULT_MAX_CHARS, boundWorkspaceValue, WORKSPACE_DIFF_CONTENT_MAX_CHARS, WORKSPACE_DIFF_DISPLAY_PATH_MAX_CHARS, boundWorkspaceDiffResult, WORKSPACE_DIFF_ERROR_MAX_CHARS, workspacePathPreview, WORKSPACE_DIFF_TOTAL_MAX_CHARS, formatWorkspaceDiffResult } from "../presentation/diff-results";
|
|
3
|
+
import { resolveDiffFileRevision, isUnscopedDiffRevisionSpec, extractBranchSelectorFromRevision, extractBranchNameFromSelector, PendingBaseIdentityResolver, isRevisionNotFoundError, createPendingBaseIdentityResolver } from "../domain/revisions";
|
|
4
|
+
import { listRecentBranchNames } from "../domain/branches";
|
|
5
|
+
import { tool } from "../tool-definition";
|
|
6
|
+
import { workdirArg } from "./arguments";
|
|
7
|
+
import { outputFormatArg } from "../presentation/results";
|
|
8
|
+
import { join, isAbsolute } from "path";
|
|
9
|
+
import { promises as fs } from "node:fs";
|
|
10
|
+
import { PendingItem, getMachineReadablePendingItems, filterPendingItemsByScope } from "../domain/pending";
|
|
11
|
+
import { toNormalizedAbsolutePath, toCommandPath, toPathComparisonKeyFromAbsolutePath } from "../domain/paths";
|
|
12
|
+
import { assembleConsolidatedDiff } from "./consolidated-diff";
|
|
13
|
+
|
|
14
|
+
export const __plasticDiffInternals = {
|
|
15
|
+
DIFF_OUTPUT_MAX_CHARS,
|
|
16
|
+
DIFF_RESPONSE_DEFAULT_MAX_CHARS,
|
|
17
|
+
DIFF_RESPONSE_MAX_CHARS,
|
|
18
|
+
DIFF_RESPONSE_MIN_CHARS,
|
|
19
|
+
DIFF_RESPONSE_TOTAL_MAX_CHARS,
|
|
20
|
+
normalizeDiffResponseMaxChars,
|
|
21
|
+
isBinaryContent,
|
|
22
|
+
stableDiffHeaders,
|
|
23
|
+
boundDiffOutput,
|
|
24
|
+
runPortableTextDiff,
|
|
25
|
+
safeTempExtension,
|
|
26
|
+
isAsciiPath,
|
|
27
|
+
createAsciiTempDirectory,
|
|
28
|
+
materializeRevision,
|
|
29
|
+
isNoDataError,
|
|
30
|
+
withWorkspaceBaseUnavailableDiagnostic,
|
|
31
|
+
boundTextDiffResult,
|
|
32
|
+
resolveDiffFileRevision,
|
|
33
|
+
isUnscopedDiffRevisionSpec,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const diff = tool({
|
|
37
|
+
description: "Bounded non-GUI comparison. Require mode=file (one local file versus loaded base or revision), revisions (two historical selectors), or workspace (explicit pending paths or allPending=true). No implicit whole-workspace review.",
|
|
38
|
+
args: {
|
|
39
|
+
mode: tool.schema.enum(["file","revisions","workspace"]),
|
|
40
|
+
path: tool.schema.string().optional(),
|
|
41
|
+
revision: tool.schema.string().optional(),
|
|
42
|
+
leftRevision: tool.schema.string().optional(),
|
|
43
|
+
rightRevision: tool.schema.string().optional(),
|
|
44
|
+
paths: tool.schema.array(tool.schema.string()).optional(),
|
|
45
|
+
allPending: tool.schema.boolean().optional(),
|
|
46
|
+
includePrivate: tool.schema.boolean().optional(),
|
|
47
|
+
maxFiles: tool.schema.number().optional(),
|
|
48
|
+
maxChars: tool.schema.number().optional(),
|
|
49
|
+
format: outputFormatArg,
|
|
50
|
+
workdir: workdirArg,
|
|
51
|
+
},
|
|
52
|
+
execute: async args => assembleConsolidatedDiff(args),
|
|
53
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { tool } from "../tool-definition";
|
|
2
|
+
import { outputFormatArg } from "../presentation/results";
|
|
3
|
+
import { workdirArg } from "./arguments";
|
|
4
|
+
import { assembleWorkspaceMergeReceipt } from "./workspace-merge-receipt";
|
|
5
|
+
import { presentWorkspaceMergeReceipt } from "../presentation/workspace-merge-results";
|
|
6
|
+
export type MergeConflictStrategy = "auto" | "source" | "destination";
|
|
7
|
+
export const merge = tool({
|
|
8
|
+
description:"Request one non-interactive workspace merge with an explicit strategy; command completion is not conflict resolution or checkin readiness.",
|
|
9
|
+
args:{
|
|
10
|
+
source:tool.schema.string().min(1).describe("Exact branch/changeset/label/shelveset source operand; not CLI options."),
|
|
11
|
+
strategy:tool.schema.enum(["auto","source","destination"]).optional().describe("Default auto/try; source/destination explicitly force contributor preference."),
|
|
12
|
+
cherrypicking:tool.schema.boolean().optional().describe("Request cherry-picking mode."),
|
|
13
|
+
forced:tool.schema.boolean().optional().describe("Request connected-branch-check override where CM supports it."),
|
|
14
|
+
preflight:tool.schema.boolean().optional().describe("Zero-command plan only, not conflict/readiness analysis."),
|
|
15
|
+
format:outputFormatArg,workdir:workdirArg},
|
|
16
|
+
async execute(args){const dto=await assembleWorkspaceMergeReceipt("merge",args);if(!dto.ok)throw new Error(presentWorkspaceMergeReceipt({...dto,data:{...dto.data,outputFormatRequested:"text"}}));return presentWorkspaceMergeReceipt(dto);}
|
|
17
|
+
});
|
|
18
|
+
export const finalizeMerge = tool({
|
|
19
|
+
description:"Request non-interactive merge finalization with an explicit contributor policy; not a guarantee of finalized metadata or preserved manual edits.",
|
|
20
|
+
args:{
|
|
21
|
+
source:tool.schema.string().min(1).describe("Exact original merge source operand; not CLI options."),
|
|
22
|
+
strategy:tool.schema.enum(["source","destination"]).optional().describe("Default destination; contributor preference is not a manual-edit preservation guarantee."),
|
|
23
|
+
preflight:tool.schema.boolean().optional().describe("Zero-command finalization plan only, not readiness analysis."),
|
|
24
|
+
format:outputFormatArg,workdir:workdirArg},
|
|
25
|
+
async execute(args){const dto=await assembleWorkspaceMergeReceipt("finalize-merge",args);if(!dto.ok)throw new Error(presentWorkspaceMergeReceipt({...dto,data:{...dto.data,outputFormatRequested:"text"}}));return presentWorkspaceMergeReceipt(dto);}
|
|
26
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {captureWorkspaceMergeCommand,emptyWorkspaceMergeAttempt} from "../execution/workspace-merge-command";
|
|
2
|
+
import {safeBranchDeleteText} from "../domain/branch-delete-contract";
|
|
3
|
+
export const validObjectDeleteText=safeBranchDeleteText;
|
|
4
|
+
export type ObjectDeleteAction="shelveset-delete"|"code-review-delete";
|
|
5
|
+
export type ObjectDeleteRequest={operands:string[];repository:string|null;workdir:string;preflight:boolean;format:"text"|"json"};
|
|
6
|
+
export type ObjectDeleteData={requested:ObjectDeleteRequest|null;intendedArgv:string[];attempt:ReturnType<typeof emptyWorkspaceMergeAttempt>;capture:Awaited<ReturnType<typeof captureWorkspaceMergeCommand>>["capture"]|null;observedDeletedObjects:null;observedRepositoryIdentity:null;scope:"requested-only";rollback:"not-proven";effect:"not-attempted"|"not-proven"|"uncertain"};
|
|
7
|
+
export type ObjectDeleteReceipt={schemaVersion:1;action:ObjectDeleteAction;provenance:{source:"plastic";producer:"@aefree/pi-plastic";contentTrust:"external"};completeness:{capture:"complete"|"incomplete"|"unknown";projection:true};data:ObjectDeleteData}&({ok:true;outcome:"preflight"|"command-completed"}|{ok:false;outcome:"failed"|"uncertain";error:{code:"invalid_request"|"launch_failed"|"aborted"|"uncertain"|"producer_failed";message:string}});
|
|
8
|
+
export const objectDeletePrefix=(a:ObjectDeleteAction)=>a==="shelveset-delete"?["shelveset","delete"]:["codereview","-d"];
|
|
9
|
+
export const objectDeleteArgv=(a:ObjectDeleteAction,r:ObjectDeleteRequest)=>[...objectDeletePrefix(a),...r.operands,...(r.repository===null?[]:[`--repository=${r.repository}`])];
|
|
10
|
+
export function validObjectDeleteOperands(a:ObjectDeleteAction,v:unknown):v is string[]{if(!Array.isArray(v)||v.length<1||v.length>(a==="shelveset-delete"?1:256))return false;let bytes=0;for(let i=0;i<v.length;i++){const s=v[i];if(!safeBranchDeleteText(s)||s.trimStart().startsWith("-"))return false;bytes+=Buffer.byteLength(s,"utf8");if(bytes>32768)return false;}return true;}
|
|
11
|
+
export function emptyObjectDeleteData(action:ObjectDeleteAction):ObjectDeleteData{return {requested:null,intendedArgv:objectDeletePrefix(action),attempt:emptyWorkspaceMergeAttempt(),capture:null,observedDeletedObjects:null,observedRepositoryIdentity:null,scope:"requested-only",rollback:"not-proven",effect:"not-attempted"};}
|
|
12
|
+
function header(action:ObjectDeleteAction,data:ObjectDeleteData){return {schemaVersion:1 as const,action,provenance:{source:"plastic" as const,producer:"@aefree/pi-plastic" as const,contentTrust:"external" as const},completeness:{capture:data.capture?data.capture.complete?"complete" as const:"incomplete" as const:"unknown" as const,projection:true as const},data};}
|
|
13
|
+
export function objectDeleteFailure(action:ObjectDeleteAction,data:ObjectDeleteData,code:Extract<ObjectDeleteReceipt,{ok:false}>["error"]["code"],message:string):ObjectDeleteReceipt{return {...header(action,data),ok:false,outcome:data.effect==="uncertain"?"uncertain":"failed",error:{code,message}};}
|
|
14
|
+
export async function assembleObjectDeleteReceipt(action:ObjectDeleteAction,args:unknown):Promise<ObjectDeleteReceipt>{const data=emptyObjectDeleteData(action);let entered=false;try{
|
|
15
|
+
if(!args||typeof args!=="object"||Array.isArray(args)||Object.keys(args).some(k=>!(action==="shelveset-delete"?["shelveset","preflight","format","workdir"]:["ids","repository","preflight","format","workdir"]).includes(k)))return objectDeleteFailure(action,data,"invalid_request","Use only declared bounded object-delete controls.");
|
|
16
|
+
const i={...args} as Record<string,unknown>,operands=action==="shelveset-delete"?[i.shelveset]:Array.isArray(i.ids)&&i.ids.length<=256?Array.from(i.ids):i.ids,cwd=i.workdir===undefined?process.cwd():i.workdir,repository=i.repository===undefined?null:i.repository,preview=i.preflight===undefined?false:i.preflight,format=i.format===undefined?"text":i.format;
|
|
17
|
+
if(!validObjectDeleteOperands(action,operands)||!safeBranchDeleteText(cwd)||repository!==null&&!safeBranchDeleteText(repository)||Object.hasOwn(i,"repository")&&i.repository===null||typeof preview!=="boolean"||!["text","json"].includes(format as string))return objectDeleteFailure(action,data,"invalid_request","Use bounded exact operands, repository text, strict preview boolean and text/json. Options/stdin are rejected.");
|
|
18
|
+
const r:ObjectDeleteRequest={operands:[...operands],repository:repository as string|null,workdir:cwd,preflight:preview,format:format as "text"|"json"};if(Buffer.byteLength(JSON.stringify(r),"utf8")>32768)return objectDeleteFailure(action,data,"invalid_request","Request exceeds aggregate byte budget.");data.requested=r;data.intendedArgv=objectDeleteArgv(action,r);
|
|
19
|
+
if(preview)return {...header(action,data),ok:true,outcome:"preflight"};entered=true;const o=await captureWorkspaceMergeCommand([...data.intendedArgv],cwd);data.attempt=o.attempt;data.capture=o.capture;if(o.capture.complete&&!o.failed&&o.attempt.state==="started"&&o.attempt.exitCode===0){data.effect="not-proven";return {...header(action,data),ok:true,outcome:"command-completed"};}data.effect=["started","unknown"].includes(data.attempt.state)?"uncertain":"not-attempted";return objectDeleteFailure(action,data,o.attempt.aborted?"aborted":data.effect==="uncertain"?"uncertain":"launch_failed","Object deletion completion is not proven; inspect earlier possible effects before deciding whether to retry.");
|
|
20
|
+
}catch{if(entered){data.attempt={...emptyWorkspaceMergeAttempt(),state:"unknown"};data.capture=null;data.effect="uncertain";}return objectDeleteFailure(action,data,"producer_failed","Object deletion observation failed; no rollback or safe retry is proven.");}}
|
|
21
|
+
export function presentObjectDeleteReceipt(dto:ObjectDeleteReceipt):string{if(dto.data.requested?.format==="json")return JSON.stringify(dto);if(!dto.ok)return `${dto.action} ${dto.outcome}: ${dto.error.message} Effects: ${dto.data.effect}.`;if(dto.outcome==="preflight")return `${dto.action} command-only preview (not executed): ${JSON.stringify(dto.data.intendedArgv)}. No existence/readiness analysis.`;return `${dto.action} command completed for ${dto.data.requested!.operands.length} requested operands. Deleted identities, repository scope and rollback remain unverified.`;}
|