@aefree/pi-plastic 0.7.6 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +48 -174
  3. package/docs/2026-10-06-development.md +101 -0
  4. package/docs/2026-10-06-diffs-and-patches.md +136 -0
  5. package/docs/2026-10-06-discovery.md +135 -0
  6. package/docs/2026-10-06-integrations.md +49 -0
  7. package/docs/2026-10-06-merging.md +153 -0
  8. package/docs/2026-10-06-safety-and-receipts.md +95 -0
  9. package/docs/2026-10-06-setup.md +101 -0
  10. package/docs/2026-10-06-shelvesets-and-reviews.md +113 -0
  11. package/docs/2026-10-06-status.md +116 -0
  12. package/docs/2026-10-06-tools-and-loading.md +64 -0
  13. package/docs/2026-10-06-workspace-operations.md +142 -0
  14. package/docs/README.md +65 -0
  15. package/extensions/bash-cm-diff-guard.ts +1 -1
  16. package/index.ts +4 -687
  17. package/package.json +10 -4
  18. package/skills/using-plastic/SKILL.md +11 -5
  19. package/skills/using-plastic/references/branch-management.md +5 -1
  20. package/skills/using-plastic/references/changeset-operations.md +9 -8
  21. package/skills/using-plastic/references/code-review-creation.md +25 -18
  22. package/skills/using-plastic/references/integration.md +9 -5
  23. package/skills/using-plastic/references/quick-reference.md +4 -4
  24. package/skills/using-plastic/references/reviewing-changes.md +10 -10
  25. package/skills/using-plastic/references/shelving.md +26 -11
  26. package/skills/using-plastic/references/troubleshooting.md +4 -4
  27. package/src/diff/local-snapshot.ts +74 -0
  28. package/src/diff/text.ts +187 -0
  29. package/src/domain/add-contract.ts +24 -0
  30. package/src/domain/branch-create-contract.ts +38 -0
  31. package/src/domain/branch-delete-contract.ts +15 -0
  32. package/src/domain/branches.ts +263 -0
  33. package/src/domain/checkin-contract.ts +70 -0
  34. package/src/domain/closeout-contract.ts +30 -0
  35. package/src/domain/copied-merge.ts +42 -0
  36. package/src/domain/diff-base-xml.ts +65 -0
  37. package/src/domain/diff-pending-xml.ts +34 -0
  38. package/src/domain/merge-output.ts +327 -0
  39. package/src/domain/paths.ts +253 -0
  40. package/src/domain/pending.ts +499 -0
  41. package/src/domain/removal-contract.ts +25 -0
  42. package/src/domain/revisions.ts +301 -0
  43. package/src/domain/server-merge-contract.ts +45 -0
  44. package/src/domain/server-merge-resolutions.ts +61 -0
  45. package/src/domain/status-xml.ts +122 -0
  46. package/src/domain/switch-contract.ts +60 -0
  47. package/src/domain/switch-policy.ts +95 -0
  48. package/src/domain/undo-contract.ts +24 -0
  49. package/src/domain/update-contract.ts +16 -0
  50. package/src/domain/workspace-merge-contract.ts +41 -0
  51. package/src/execution/add-command.ts +118 -0
  52. package/src/execution/branch-create-command.ts +118 -0
  53. package/src/execution/branch-list-command.ts +75 -0
  54. package/src/execution/checkin-command.ts +116 -0
  55. package/src/execution/cli-version.ts +31 -0
  56. package/src/execution/cm.ts +59 -0
  57. package/src/execution/code-review-find-command.ts +75 -0
  58. package/src/execution/context.ts +20 -0
  59. package/src/execution/diff-revisions-command.ts +74 -0
  60. package/src/execution/process.ts +260 -0
  61. package/src/execution/removal-command.ts +118 -0
  62. package/src/execution/server-merge-command.ts +92 -0
  63. package/src/execution/shelveset-list-command.ts +75 -0
  64. package/src/execution/status-command.ts +29 -0
  65. package/src/execution/status-xml-command.ts +72 -0
  66. package/src/execution/status-xml-limits.ts +1 -0
  67. package/src/execution/switch-command.ts +118 -0
  68. package/src/execution/test-seams.ts +10 -0
  69. package/src/execution/undo-command.ts +118 -0
  70. package/src/execution/update-command.ts +118 -0
  71. package/src/execution/workspace-list-command.ts +75 -0
  72. package/src/execution/workspace-merge-command.ts +118 -0
  73. package/src/operations/add-receipt.ts +37 -0
  74. package/src/operations/arguments.ts +3 -0
  75. package/src/operations/branch-create.ts +74 -0
  76. package/src/operations/branch-delete-receipt.ts +25 -0
  77. package/src/operations/branch-list.ts +94 -0
  78. package/src/operations/branch-reads.ts +94 -0
  79. package/src/operations/branches.ts +111 -0
  80. package/src/operations/checkin-receipt.ts +140 -0
  81. package/src/operations/checkin.ts +102 -0
  82. package/src/operations/closeout-receipt.ts +74 -0
  83. package/src/operations/closeout.ts +19 -0
  84. package/src/operations/code-review-find.ts +97 -0
  85. package/src/operations/consolidated-diff.ts +144 -0
  86. package/src/operations/diff-revisions.ts +157 -0
  87. package/src/operations/diff.ts +53 -0
  88. package/src/operations/merge.ts +26 -0
  89. package/src/operations/object-delete-receipt.ts +21 -0
  90. package/src/operations/object-write-receipt.ts +105 -0
  91. package/src/operations/patch-receipt.ts +96 -0
  92. package/src/operations/patch-support.ts +375 -0
  93. package/src/operations/patch.ts +25 -0
  94. package/src/operations/removal-receipt.ts +42 -0
  95. package/src/operations/reviews.ts +90 -0
  96. package/src/operations/server-merge.ts +130 -0
  97. package/src/operations/shelveset-list.ts +90 -0
  98. package/src/operations/shelvesets.ts +90 -0
  99. package/src/operations/status.ts +129 -0
  100. package/src/operations/switch-receipt.ts +85 -0
  101. package/src/operations/switch.ts +25 -0
  102. package/src/operations/undo-receipt.ts +37 -0
  103. package/src/operations/update-receipt.ts +35 -0
  104. package/src/operations/workspace-list.ts +92 -0
  105. package/src/operations/workspace-merge-receipt.ts +50 -0
  106. package/src/operations/workspace.ts +120 -0
  107. package/src/pi/add-output.ts +52 -0
  108. package/src/pi/arguments.ts +298 -0
  109. package/src/pi/branch-create-output.ts +83 -0
  110. package/src/pi/branch-delete-output.ts +46 -0
  111. package/src/pi/branch-list-output.ts +59 -0
  112. package/src/pi/branch-output.ts +63 -0
  113. package/src/pi/capability-diagnostics.ts +23 -0
  114. package/src/pi/checkin-output.ts +68 -0
  115. package/src/pi/closeout-output.ts +87 -0
  116. package/src/pi/code-review-find-output.ts +50 -0
  117. package/src/pi/diff-output.ts +66 -0
  118. package/src/pi/diff-revisions-output.ts +55 -0
  119. package/src/pi/object-delete-output.ts +15 -0
  120. package/src/pi/object-write-output.ts +54 -0
  121. package/src/pi/patch-output.ts +69 -0
  122. package/src/pi/register.ts +188 -0
  123. package/src/pi/removal-output.ts +59 -0
  124. package/src/pi/schemas.ts +146 -0
  125. package/src/pi/server-merge-output.ts +83 -0
  126. package/src/pi/shelveset-list-output.ts +50 -0
  127. package/src/pi/status-output.ts +149 -0
  128. package/src/pi/switch-output.ts +80 -0
  129. package/src/pi/tool-registry.ts +72 -0
  130. package/src/pi/undo-output.ts +52 -0
  131. package/src/pi/update-output.ts +48 -0
  132. package/src/pi/workspace-list-output.ts +53 -0
  133. package/src/pi/workspace-merge-output.ts +46 -0
  134. package/src/plastic-core.ts +14 -5775
  135. package/src/plastic-renderers.ts +102 -22
  136. package/src/plastic-tool-loading.ts +7 -12
  137. package/src/presentation/add-results.ts +5 -0
  138. package/src/presentation/branch-create.ts +7 -0
  139. package/src/presentation/branch-delete-results.ts +7 -0
  140. package/src/presentation/checkin-results.ts +14 -0
  141. package/src/presentation/closeout-results.ts +2 -0
  142. package/src/presentation/diff-results.ts +237 -0
  143. package/src/presentation/removal-results.ts +7 -0
  144. package/src/presentation/results.ts +84 -0
  145. package/src/presentation/switch-results.ts +7 -0
  146. package/src/presentation/undo-results.ts +5 -0
  147. package/src/presentation/update-results.ts +5 -0
  148. package/src/presentation/workspace-merge-results.ts +6 -0
@@ -0,0 +1,499 @@
1
+ import { dirname } from "path";
2
+ import { runCmRaw, normalizeFindOutputLines } from "../execution/cm";
3
+ import { toNormalizedAbsolutePath, toPathComparisonKeyFromAbsolutePath, toPathComparisonKey, toCommandPath, isWithinPathScope, dedupeAndMinimizeAbsolutePaths } from "./paths";
4
+
5
+ export type PendingItemKind = "added" | "changed" | "moved" | "deleted" | "private" | "other";
6
+
7
+ export type PendingItem = {
8
+ statusCode: string;
9
+ workspacePath: string;
10
+ normalizedPath: string;
11
+ comparisonKey: string;
12
+ isDirectory: boolean;
13
+ kind: PendingItemKind;
14
+ revisionId?: string;
15
+ sourceWorkspacePath?: string;
16
+ baseRepository?: string;
17
+ };
18
+
19
+ export type PendingItemSummary = {
20
+ totalPending: number;
21
+ added: number;
22
+ changed: number;
23
+ moved: number;
24
+ deleted: number;
25
+ private: number;
26
+ other: number;
27
+ tracked: number;
28
+ privatePaths: string[];
29
+ };
30
+
31
+ export type MachineReadableStatusItem = {
32
+ statusCode: string;
33
+ kind: PendingItemKind;
34
+ path: string;
35
+ isDirectory: boolean;
36
+ revisionId?: string;
37
+ sourcePath?: string;
38
+ };
39
+
40
+ export type MachineReadableStatusSummary = Omit<PendingItemSummary, "privatePaths">;
41
+
42
+ export const MACHINE_READABLE_STATUS_DEFAULT_MAX_ITEMS = 100;
43
+ export const MACHINE_READABLE_STATUS_MAX_ITEMS = 500;
44
+
45
+ export type PrivateAutoAddSelection = {
46
+ candidatePaths: string[];
47
+ blockedPaths: Array<{ path: string; reason: string }>;
48
+ };
49
+
50
+ export type ResolvedCheckinPaths = {
51
+ requestedPaths: string[];
52
+ includedPaths: string[];
53
+ includedAbsolutePaths: string[];
54
+ fallbackPaths: string[];
55
+ fallbackAbsolutePaths: string[];
56
+ rewrittenPaths: string[];
57
+ excludedPaths: Array<{ path: string; reason: string }>;
58
+ shouldApplyChanged: boolean;
59
+ rewriteReason?: string;
60
+ matchedPendingCount: number;
61
+ };
62
+
63
+ export const inferPendingItemKind = (statusCode: string): PendingItemKind =>
64
+ {
65
+ const normalizedStatus = statusCode.toUpperCase();
66
+
67
+ if (normalizedStatus.includes("MV"))
68
+ {
69
+ return "moved";
70
+ }
71
+
72
+ if (normalizedStatus.includes("LD") || normalizedStatus.includes("RD") || normalizedStatus.includes("DE") || normalizedStatus.includes("RM"))
73
+ {
74
+ return "deleted";
75
+ }
76
+
77
+ if (normalizedStatus.includes("AD"))
78
+ {
79
+ return "added";
80
+ }
81
+
82
+ if (normalizedStatus.includes("PR"))
83
+ {
84
+ return "private";
85
+ }
86
+
87
+ if (normalizedStatus.includes("CH") || normalizedStatus.includes("CO") || normalizedStatus.includes("RP"))
88
+ {
89
+ return "changed";
90
+ }
91
+
92
+ return "other";
93
+ };
94
+
95
+ // Unit separator is accepted by Plastic and cannot occur in supported workspace paths.
96
+ export const STATUS_FIELD_SEPARATOR = "\x1f";
97
+
98
+ export const parseMachineReadablePendingItems = (output: string, cwd: string): PendingItem[] =>
99
+ {
100
+ const lines = normalizeFindOutputLines(output);
101
+ // Legacy records have no reliable way to distinguish the source and destination
102
+ // fields of a move, so they remain supported for normal records only.
103
+ const legacyStatusLinePattern = /^([A-Z+]+)\s+(.+)\s+(True|False)(?:\s+(.*))?$/;
104
+ const pendingItems: PendingItem[] = [];
105
+ const addItem = (statusCode: string, workspacePath: string, isDirectory: boolean, revisionId?: string, sourceWorkspacePath?: string): void =>
106
+ {
107
+ const normalizedPath = toNormalizedAbsolutePath(workspacePath, cwd);
108
+ pendingItems.push({
109
+ statusCode,
110
+ workspacePath,
111
+ normalizedPath,
112
+ comparisonKey: toPathComparisonKeyFromAbsolutePath(normalizedPath),
113
+ isDirectory,
114
+ kind: inferPendingItemKind(statusCode),
115
+ ...(revisionId ? { revisionId } : {}),
116
+ ...(sourceWorkspacePath ? { sourceWorkspacePath } : {}),
117
+ });
118
+ };
119
+
120
+ for (const line of lines)
121
+ {
122
+ if (line.startsWith("STATUS "))
123
+ {
124
+ continue;
125
+ }
126
+
127
+ if (line.includes(STATUS_FIELD_SEPARATOR))
128
+ {
129
+ const fields = line.split(STATUS_FIELD_SEPARATOR);
130
+ const statusCode = fields[0];
131
+ if (!/^[A-Z+]+$/.test(statusCode))
132
+ {
133
+ continue;
134
+ }
135
+
136
+ const kind = inferPendingItemKind(statusCode);
137
+ if (kind === "moved")
138
+ {
139
+ // MV: status, percent, source, destination, directory, revision, metadata.
140
+ const [,, sourceWorkspacePath, workspacePath, directoryField, revisionField] = fields;
141
+ if (!sourceWorkspacePath || !workspacePath || (directoryField !== "True" && directoryField !== "False"))
142
+ {
143
+ continue;
144
+ }
145
+ addItem(statusCode, workspacePath, directoryField === "True", /^\d+$/.test(revisionField ?? "") ? revisionField : undefined, sourceWorkspacePath);
146
+ continue;
147
+ }
148
+
149
+ // Ordinary records: status, path, directory, revision, metadata.
150
+ // Only moved records include a similarity-percent field.
151
+ const [, workspacePath, directoryField, revisionField] = fields;
152
+ if (!workspacePath || (directoryField !== "True" && directoryField !== "False"))
153
+ {
154
+ continue;
155
+ }
156
+ addItem(statusCode, workspacePath, directoryField === "True", /^\d+$/.test(revisionField ?? "") ? revisionField : undefined);
157
+ continue;
158
+ }
159
+
160
+ const match = line.match(legacyStatusLinePattern);
161
+ if (!match)
162
+ {
163
+ continue;
164
+ }
165
+
166
+ const statusCode = match[1];
167
+ // A whitespace-delimited MV record cannot safely reveal which portion is
168
+ // its destination. Skipping it prevents checkin/diff from targeting a
169
+ // wrong workspace path; package-owned calls always request a separator.
170
+ if (inferPendingItemKind(statusCode) === "moved")
171
+ {
172
+ continue;
173
+ }
174
+ const workspacePath = match[2];
175
+ const isDirectory = match[3] === "True";
176
+ const revisionId = (match[4] ?? "").match(/^\s*(\d+)\b/)?.[1];
177
+ addItem(statusCode, workspacePath, isDirectory, revisionId);
178
+ }
179
+
180
+ return pendingItems;
181
+ };
182
+
183
+ // Windows cannot have literal '?' filenames; local cm machine output uses it
184
+ // for characters lost before capture. POSIX '?' filenames remain supported.
185
+ export const hasStatusPathDecodingLoss = (path: string, platform: NodeJS.Platform = process.platform): boolean =>
186
+ path.includes("\uFFFD") || (platform === "win32" && path.includes("?"));
187
+
188
+ // Diagnostics deliberately leave the legacy selection parser unchanged for mutation/diff consumers.
189
+ export const diagnoseMachineReadablePendingItems = (output: string, cwd: string, platform: NodeJS.Platform = process.platform) =>
190
+ {
191
+ const diagnostics = { valid: 0, blank: 0, header: 0, unsupported: 0, malformed: 0, ambiguousLegacyMove: 0 };
192
+ const pendingItems: PendingItem[] = [];
193
+ for (const rawLine of output.split(/\r?\n/))
194
+ {
195
+ const line = rawLine.trim();
196
+ if (!line) { diagnostics.blank++; continue; }
197
+ const fields = line.split(STATUS_FIELD_SEPARATOR);
198
+ if (fields[0] === "STATUS" && fields.length === 4 && /^\d+$/.test(fields[1]) && fields[2] && fields[3]) { diagnostics.header++; continue; }
199
+ if (/^STATUS \d+ \S+ \S+$/.test(line)) { diagnostics.header++; continue; }
200
+ if (fields[0] === "STATUS" || line.startsWith("STATUS ")) { diagnostics.malformed++; continue; }
201
+ const items = parseMachineReadablePendingItems(line, cwd);
202
+ pendingItems.push(...items);
203
+ if (items.length) {
204
+ diagnostics.valid++;
205
+ // Selection remains permissive, but unfamiliar codes must never imply a complete read.
206
+ if (!/^(AD|CH|CO|RP|MV|LD|RD|DE|RM|PR)(\+(AD|CH|CO|RP|MV|LD|RD|DE|RM|PR))*$/.test(items[0].statusCode)) diagnostics.unsupported++;
207
+ // Legacy selection accepts a numeric prefix (e.g. 41 from 41.5).
208
+ // Status must validate the whole revision token before exposing a reusable identity.
209
+ const revisionField = fields.length > 1
210
+ ? fields[items[0].kind === "moved" ? 5 : 3]
211
+ : line.match(/^([A-Z+]+)\s+(.+)\s+(True|False)(?:\s+(.*))?$/)?.[4]?.trim().split(/\s+/)[0];
212
+ if (revisionField !== undefined && revisionField !== "" && revisionField !== "-1" && !/^\d+$/.test(revisionField)) {
213
+ diagnostics.malformed++;
214
+ for (const item of items) delete item.revisionId;
215
+ }
216
+ // Capture cannot distinguish decoding loss from a literal replacement character.
217
+ if (items.some(item => hasStatusPathDecodingLoss(item.workspacePath, platform) || (item.sourceWorkspacePath !== undefined && hasStatusPathDecodingLoss(item.sourceWorkspacePath, platform)))) diagnostics.malformed++;
218
+ continue;
219
+ }
220
+ if (/^MV\b/.test(line) && !line.includes(STATUS_FIELD_SEPARATOR)) diagnostics.ambiguousLegacyMove++;
221
+ else if (line.includes(STATUS_FIELD_SEPARATOR) || /^[A-Z+]+\s/.test(line)) diagnostics.malformed++;
222
+ else diagnostics.unsupported++;
223
+ }
224
+ return { pendingItems, diagnostics };
225
+ };
226
+
227
+ export const getMachineReadablePendingItems = async (workdir?: string): Promise<PendingItem[]> =>
228
+ {
229
+ const cwd = workdir ?? process.cwd();
230
+ const output = await runCmRaw(["status", "--machinereadable", "--includeRevId", `--fieldseparator=${STATUS_FIELD_SEPARATOR}`], workdir);
231
+ return parseMachineReadablePendingItems(output, cwd);
232
+ };
233
+
234
+ export const toMachineReadableStatusItems = (pendingItems: PendingItem[]): MachineReadableStatusItem[] =>
235
+ pendingItems.map((item) => ({
236
+ statusCode: item.statusCode,
237
+ kind: item.kind,
238
+ path: item.workspacePath,
239
+ isDirectory: item.isDirectory,
240
+ ...(item.revisionId ? { revisionId: item.revisionId } : {}),
241
+ ...(item.sourceWorkspacePath ? { sourcePath: item.sourceWorkspacePath } : {}),
242
+ }));
243
+
244
+ export const toMachineReadableStatusSummary = (pendingItems: PendingItem[], cwd: string): MachineReadableStatusSummary =>
245
+ {
246
+ const { privatePaths: _privatePaths, ...summary } = summarizePendingItems(pendingItems, cwd);
247
+ return summary;
248
+ };
249
+
250
+ export const summarizePendingItems = (pendingItems: PendingItem[], cwd?: string): PendingItemSummary =>
251
+ {
252
+ const summary: PendingItemSummary = {
253
+ totalPending: pendingItems.length,
254
+ added: 0,
255
+ changed: 0,
256
+ moved: 0,
257
+ deleted: 0,
258
+ private: 0,
259
+ other: 0,
260
+ tracked: 0,
261
+ privatePaths: [],
262
+ };
263
+
264
+ for (const item of pendingItems)
265
+ {
266
+ switch (item.kind)
267
+ {
268
+ case "added":
269
+ summary.added += 1;
270
+ break;
271
+ case "changed":
272
+ summary.changed += 1;
273
+ break;
274
+ case "moved":
275
+ summary.moved += 1;
276
+ break;
277
+ case "deleted":
278
+ summary.deleted += 1;
279
+ break;
280
+ case "private":
281
+ summary.private += 1;
282
+ summary.privatePaths.push(cwd ? toCommandPath(item.normalizedPath, cwd) : item.workspacePath);
283
+ break;
284
+ default:
285
+ summary.other += 1;
286
+ break;
287
+ }
288
+ }
289
+
290
+ summary.tracked = summary.totalPending - summary.private;
291
+ return summary;
292
+ };
293
+
294
+ export const formatPendingPathPreview = (pendingItems: PendingItem[], cwd: string, limit = 5): string =>
295
+ {
296
+ if (pendingItems.length === 0)
297
+ {
298
+ return "(none)";
299
+ }
300
+
301
+ const preview = pendingItems
302
+ .slice(0, limit)
303
+ .map((item) => toCommandPath(item.normalizedPath, cwd));
304
+
305
+ return pendingItems.length > limit ? `${preview.join(", ")}, ...` : preview.join(", ");
306
+ };
307
+
308
+ export const SENSITIVE_PRIVATE_PATH_PATTERNS: Array<{ label: string; pattern: RegExp }> = [
309
+ { label: "dotenv", pattern: /(^|\/)\.env(\.|$)/i },
310
+ { label: "private-key", pattern: /\.(pem|key|pfx|p12|jks|keystore|ppk)$/i },
311
+ { label: "ssh-key", pattern: /(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i },
312
+ { label: "credentials-json", pattern: /(^|\/)(credentials|secrets?)\.json$/i },
313
+ { label: "npmrc", pattern: /(^|\/)\.npmrc$/i },
314
+ ];
315
+
316
+ export const getSensitivePrivatePathReason = (pathValue: string): string | null =>
317
+ {
318
+ const normalizedPath = pathValue.replace(/\\/g, "/");
319
+ for (const definition of SENSITIVE_PRIVATE_PATH_PATTERNS)
320
+ {
321
+ if (definition.pattern.test(normalizedPath))
322
+ {
323
+ return `sensitive_path:${definition.label}`;
324
+ }
325
+ }
326
+
327
+ return null;
328
+ };
329
+
330
+ export const selectPrivatePathsForAutoAdd = (
331
+ pendingItems: PendingItem[],
332
+ scopedAbsolutePaths: string[],
333
+ cwd: string,
334
+ ): PrivateAutoAddSelection =>
335
+ {
336
+ const privateItems = pendingItems.filter((item) => item.kind === "private");
337
+ const scopedComparisonKeys = scopedAbsolutePaths.map((path) => toPathComparisonKeyFromAbsolutePath(path));
338
+ const scopedItems = scopedAbsolutePaths.length > 0
339
+ ? privateItems.filter((item) => scopedComparisonKeys.some((scopePath) => isWithinPathScope(item.comparisonKey, scopePath)))
340
+ : privateItems;
341
+
342
+ const blockedPaths: Array<{ path: string; reason: string }> = [];
343
+ const candidatePaths: string[] = [];
344
+
345
+ for (const item of scopedItems)
346
+ {
347
+ const commandPath = toCommandPath(item.normalizedPath, cwd);
348
+ if (commandPath === ".")
349
+ {
350
+ continue;
351
+ }
352
+
353
+ const sensitiveReason = getSensitivePrivatePathReason(commandPath);
354
+ if (sensitiveReason)
355
+ {
356
+ blockedPaths.push({
357
+ path: commandPath,
358
+ reason: sensitiveReason,
359
+ });
360
+ continue;
361
+ }
362
+
363
+ candidatePaths.push(commandPath);
364
+ }
365
+
366
+ return {
367
+ candidatePaths: Array.from(new Set(candidatePaths)),
368
+ blockedPaths,
369
+ };
370
+ };
371
+
372
+ export const filterPendingItemsByScope = (pendingItems: PendingItem[], scopedAbsolutePaths: string[]): PendingItem[] =>
373
+ {
374
+ if (scopedAbsolutePaths.length === 0)
375
+ {
376
+ return pendingItems;
377
+ }
378
+
379
+ const scopedComparisonKeys = scopedAbsolutePaths.map((path) => toPathComparisonKeyFromAbsolutePath(path));
380
+ return pendingItems.filter((item) => scopedComparisonKeys.some((scopePath) => isWithinPathScope(item.comparisonKey, scopePath)));
381
+ };
382
+
383
+ export const resolveCheckinPaths = (requestedPaths: string[], pendingItems: PendingItem[], cwd: string): ResolvedCheckinPaths =>
384
+ {
385
+ const includedAbsolutePaths: string[] = [];
386
+ const fallbackAbsolutePaths: string[] = [];
387
+ const excludedPaths: Array<{ path: string; reason: string }> = [];
388
+ const matchedPendingItems = new Set<string>();
389
+ let shouldApplyChanged = false;
390
+
391
+ for (const requestedPath of requestedPaths)
392
+ {
393
+ const requestedAbsolutePath = toNormalizedAbsolutePath(requestedPath, cwd);
394
+ const requestedComparisonKey = toPathComparisonKey(requestedPath, cwd);
395
+ const matchedItems = pendingItems.filter((item) => isWithinPathScope(item.comparisonKey, requestedComparisonKey));
396
+
397
+ if (matchedItems.length === 0)
398
+ {
399
+ excludedPaths.push({
400
+ path: requestedPath,
401
+ reason: "no_pending_changes",
402
+ });
403
+ continue;
404
+ }
405
+
406
+ for (const item of matchedItems)
407
+ {
408
+ matchedPendingItems.add(`${item.statusCode}:${item.comparisonKey}`);
409
+ }
410
+
411
+ const includesDeletedOrMoved = matchedItems.some((item) => item.kind === "deleted" || item.kind === "moved");
412
+ let fallbackAbsolutePath = requestedAbsolutePath;
413
+ if (includesDeletedOrMoved)
414
+ {
415
+ shouldApplyChanged = true;
416
+ const hasChildMatches = matchedItems.some((item) => item.comparisonKey.length > requestedComparisonKey.length && item.comparisonKey.startsWith(`${requestedComparisonKey}/`));
417
+ const hasDirectDirectoryMatch = matchedItems.some((item) => item.isDirectory && item.comparisonKey === requestedComparisonKey);
418
+ fallbackAbsolutePath = hasChildMatches || hasDirectDirectoryMatch
419
+ ? requestedAbsolutePath
420
+ : toNormalizedAbsolutePath(dirname(requestedAbsolutePath), cwd);
421
+ }
422
+
423
+ includedAbsolutePaths.push(requestedAbsolutePath);
424
+ fallbackAbsolutePaths.push(fallbackAbsolutePath);
425
+ }
426
+
427
+ const resolvedAbsolutePaths = dedupeAndMinimizeAbsolutePaths(includedAbsolutePaths);
428
+ const resolvedFallbackAbsolutePaths = dedupeAndMinimizeAbsolutePaths(fallbackAbsolutePaths);
429
+ const includedPaths = resolvedAbsolutePaths.map((path) => toCommandPath(path, cwd));
430
+ const fallbackPaths = resolvedFallbackAbsolutePaths.map((path) => toCommandPath(path, cwd));
431
+ const rewrittenPaths: string[] = [];
432
+
433
+ return {
434
+ requestedPaths,
435
+ includedPaths,
436
+ includedAbsolutePaths: resolvedAbsolutePaths,
437
+ fallbackPaths,
438
+ fallbackAbsolutePaths: resolvedFallbackAbsolutePaths,
439
+ rewrittenPaths,
440
+ excludedPaths,
441
+ shouldApplyChanged,
442
+ rewriteReason: shouldApplyChanged ? "Detected moved/deleted pending items; enabled --applychanged and will retry with parent-directory scope only if Plastic rejects the path-scoped command." : undefined,
443
+ matchedPendingCount: matchedPendingItems.size,
444
+ };
445
+ };
446
+
447
+ export type PendingSummary = {
448
+ totalPending: number;
449
+ added: number;
450
+ changed: number;
451
+ moved: number;
452
+ deleted: number;
453
+ other: number;
454
+ };
455
+
456
+ export const summarizeShortStatus = (output: string): PendingSummary =>
457
+ {
458
+ const lines = normalizeFindOutputLines(output);
459
+ const summary: PendingSummary = {
460
+ totalPending: lines.length,
461
+ added: 0,
462
+ changed: 0,
463
+ moved: 0,
464
+ deleted: 0,
465
+ other: 0,
466
+ };
467
+
468
+ for (const line of lines)
469
+ {
470
+ const normalized = line.toLowerCase();
471
+ if (normalized.includes("added") || /^a\s/.test(normalized))
472
+ {
473
+ summary.added += 1;
474
+ continue;
475
+ }
476
+
477
+ if (normalized.includes("changed") || normalized.includes("modified") || /^c\s/.test(normalized) || /^m\s/.test(normalized))
478
+ {
479
+ summary.changed += 1;
480
+ continue;
481
+ }
482
+
483
+ if (normalized.includes("moved") || normalized.includes("renamed"))
484
+ {
485
+ summary.moved += 1;
486
+ continue;
487
+ }
488
+
489
+ if (normalized.includes("deleted") || /^d\s/.test(normalized))
490
+ {
491
+ summary.deleted += 1;
492
+ continue;
493
+ }
494
+
495
+ summary.other += 1;
496
+ }
497
+
498
+ return summary;
499
+ };
@@ -0,0 +1,25 @@
1
+ import type { RemovalAttempt, RemovalCapture } from "../execution/removal-command";
2
+ export type { RemovalAttempt, RemovalCapture } from "../execution/removal-command";
3
+ export const REMOVAL_COMMAND = "remove" as const;
4
+ export const safeRemovalText = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 4096 && value.trim().length > 0 && !/[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(value);
5
+ export function validRemovalOperands(paths: unknown): paths is string[] {
6
+ if (!Array.isArray(paths) || !paths.length || paths.length > 256) return false;
7
+ let bytes = 0;
8
+ for (let index = 0; index < paths.length; index++) {
9
+ const path = paths[index];
10
+ if (!safeRemovalText(path) || path.trimStart().startsWith("-") || /^(private|controlled)$/i.test(path.trim())) return false;
11
+ bytes += Buffer.byteLength(path, "utf8");
12
+ if (bytes > 32768) return false;
13
+ }
14
+ return true;
15
+ }
16
+ export type RemovalData = {
17
+ intendedArgv: readonly string[]; requestedOperandCount: number | null; workingDirectory: string | null;
18
+ keepOnDiskRequested: boolean | null; previewRequested: boolean | null; outputFormatRequested: "text" | "json" | null;
19
+ resolutionIntent: "accept-source-deletion"; attempt: RemovalAttempt; capture: RemovalCapture | null;
20
+ observedWorkspaceIdentity: null; observedResolvedItems: null; observedResolution: null; observedPreservation: null; observedPendingState: null;
21
+ verification: "unverified"; effect: "not-attempted" | "not-proven" | "uncertain";
22
+ };
23
+ type Header = { schemaVersion: 1; action: "resolve-delete-change-conflict"; provenance: { source: "plastic"; producer: "@aefree/pi-plastic"; contentTrust: "external" }; completeness: { capture: "complete" | "incomplete" | "unknown"; projection: true }; data: RemovalData };
24
+ export type RemovalError = { code: "invalid_request" | "launch_failed" | "aborted" | "uncertain" | "producer_failed"; message: string };
25
+ export type RemovalReceipt = Header & ({ ok: true; outcome: "command-completed" | "preflight" } | { ok: false; outcome: "failed" | "uncertain"; error: RemovalError });