@aefree/pi-plastic 0.8.4 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/docs/2026-10-06-merging.md +11 -11
- package/docs/2026-10-06-status.md +3 -2
- package/docs/2026-10-06-workspace-operations.md +2 -2
- package/docs/2026-10-21-observed-workflow-profiles.md +54 -0
- package/docs/README.md +1 -0
- package/package.json +2 -2
- package/src/domain/checkin-contract.ts +5 -4
- package/src/domain/copied-merge.ts +1 -1
- package/src/domain/merge-output.ts +22 -7
- package/src/domain/merged-workspace.ts +63 -27
- package/src/domain/pending.ts +48 -0
- package/src/domain/server-merge-contract.ts +1 -1
- package/src/domain/status-xml.ts +20 -6
- package/src/operations/checkin-receipt.ts +61 -2
- package/src/operations/consolidated-diff.ts +31 -7
- package/src/operations/patch-receipt.ts +9 -2
- package/src/operations/server-merge.ts +62 -11
- package/src/pi/checkin-output.ts +5 -2
- package/src/pi/closeout-output.ts +1 -1
- package/src/pi/diff-output.ts +2 -2
- package/src/pi/server-merge-output.ts +11 -3
- package/src/pi/status-output.ts +38 -4
- package/src/pi/workspace-merge-output.ts +1 -1
package/src/pi/diff-output.ts
CHANGED
|
@@ -58,9 +58,9 @@ export async function executeDiffOutput(args:unknown){
|
|
|
58
58
|
const json=(args as {format?:unknown})?.format==="json";
|
|
59
59
|
let text=JSON.stringify(dto);
|
|
60
60
|
if(!json){
|
|
61
|
-
if("data"in dto)text=dto.mode==="workspace"?"## Workspace Diff\n"+(dto.ok?"":"Partial failure: "+dto.error.code+"\n")+JSON.stringify(dto.data.counts)+"\nPrivate excluded: "+dto.data.excludedPrivate+"; unmatched paths: "+dto.data.unmatched.length+"\n"+dto.data.outcomes.map(o=>o.path+": "+o.status+(o.status==="compared"?"\n"+(o.comparison.excerpt?.text??o.comparison.status):o.status==="unavailable"?" ("+o.error.code+")":"")).join("\n\n"):"## Diff\n"+dto.data.status+"\n"+(dto.data.excerpt?.text??"");
|
|
61
|
+
if("data"in dto)text=dto.mode==="workspace"?"## Workspace Diff\n"+(dto.ok?"":"Partial failure: "+dto.error.code+"\n")+JSON.stringify(dto.data.counts)+"\nPrivate excluded: "+dto.data.excludedPrivate+"; unmatched paths: "+dto.data.unmatched.length+"\n"+dto.data.outcomes.map(o=>o.path+": "+o.status+(o.status==="compared"?"\n"+(o.comparison.excerpt?.text??o.comparison.status):o.status==="unavailable"?" ("+o.error.code+"/"+o.error.stage+"): "+o.error.message:"")).join("\n\n"):"## Diff\n"+dto.data.status+"\n"+(dto.data.excerpt?.text??"");
|
|
62
62
|
else text=dto.error.message;
|
|
63
63
|
}
|
|
64
|
-
if(text.length>24000)text=json?JSON.stringify({action:"diff",mode:dto.mode,ok:dto.ok,note:"Presentation omitted; use structuredContent for the bounded producer observation."}):text.slice(0,23920)+"\n[Presentation truncated; inspect the structured projection/completeness fields.]";
|
|
64
|
+
if(text.length>24000)text=json?JSON.stringify({action:"diff",mode:dto.mode,ok:dto.ok,...(!dto.ok?{error:dto.error}:{}),...(dto.mode==="workspace"&&"data"in dto?{counts:dto.data.counts,failures:dto.data.outcomes.flatMap((o,index)=>o.status==="unavailable"?[{index,error:o.error}]:[])}:{}),note:"Presentation omitted; use structuredContent for the bounded producer observation. Failure indices refer only to returned outcomes."}):text.slice(0,23920)+"\n[Presentation truncated; inspect the structured projection/completeness fields.]";
|
|
65
65
|
return {content:[{type:"text" as const,text}],details:{},structuredContent:dto,isError:!dto.ok};
|
|
66
66
|
}
|
|
@@ -18,7 +18,7 @@ const data = {
|
|
|
18
18
|
command: nullable(Type.Array(str(8192), { maxItems: 16 })),
|
|
19
19
|
capability: object({ state: enumOf(["not-checked", "advertised", "missing", "unavailable"]), missingTokens: Type.Array(str(64), { maxItems: 9 }) }),
|
|
20
20
|
attempt, capture: object({ help: nullable(capture), merge: nullable(capture) }),
|
|
21
|
-
parse: nullable(object({ records: uint(500), malformed: Type.Boolean(), unknownOperations: uint(500), changesetsObserved: uint(500), conflictsObserved: uint(500), warningsObserved: uint(500) })),
|
|
21
|
+
parse: nullable(object({ records: uint(500), malformed: Type.Boolean(), unknownOperations: uint(500), changesetsObserved: uint(500), conflictsObserved: uint(500), warningsObserved: uint(500), unknownOperationClasses: Type.Array(enumOf(["unsupported-operation", "APPLY/unsupported-subtype", "DIS_OP_WARN/unsupported-subtype"]), { maxItems: 3, uniqueItems: true }), resolvedConflictsVerified: uint(500) })),
|
|
22
22
|
observedChangesets: Type.Array(changeset, { maxItems: 100 }), conflictPaths: Type.Array(str(), { maxItems: 100 }),
|
|
23
23
|
counts: object({ changesetsReturned: uint(100), changesetsOmitted: uint(500), conflictsReturned: uint(100), conflictsOmitted: uint(500) }),
|
|
24
24
|
serverAliasEquivalence: Type.Literal("unverified"), mergeLinkIdentity: Type.Literal("unverified"), xlinkEffects: Type.Literal("unverified"), wouldRun: Type.Boolean(), remoteAnalysis: enumOf(["not-performed", "performed"]),
|
|
@@ -38,7 +38,13 @@ export function validateServerMergeOutput(value: unknown): value is MergeReceipt
|
|
|
38
38
|
const dto = value as MergeReceipt, d = dto.data, a = d.attempt, p = d.parse;
|
|
39
39
|
const safeText = (s: string) => !/[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(s);
|
|
40
40
|
for (const cs of [...d.observedChangesets, ...(d.createdChangeset ? [d.createdChangeset] : [])]) if (!splitServerBranch("br:" + cs.branch + "@" + cs.repository + "@" + cs.server) || ![cs.branch, cs.repository, cs.server, cs.mount].every(safeText)) return false;
|
|
41
|
-
|
|
41
|
+
const canonicalPath = (s: string) => s.length > 1 && s.startsWith("/") && safeText(s) && !s.includes(String.fromCharCode(92)) && s.split("/").slice(1).every(part => !!part && part !== "." && part !== ".." && !/[. ]$/.test(part));
|
|
42
|
+
if (!d.conflictPaths.every(canonicalPath)) return false;
|
|
43
|
+
const safeComment = (s: string) => s.length <= 4096 && !!s.trim() && safeText(s.replace(/[\r\n]/g, ""));
|
|
44
|
+
if (d.command?.some(s => s.startsWith("-c=") ? !safeComment(s.slice(3)) : !safeText(s))) return false;
|
|
45
|
+
if (d.requestedIdentity && !Object.values(d.requestedIdentity).every(safeText)) return false;
|
|
46
|
+
if (p && (p.conflictsObserved + p.changesetsObserved > p.records || p.resolvedConflictsVerified > 0 && new Set(d.conflictPaths.map(s => s.toLowerCase())).size !== d.conflictPaths.length)) return false;
|
|
47
|
+
if (p && (p.unknownOperationClasses.length !== p.unknownOperations || p.resolvedConflictsVerified > p.conflictsObserved || p.resolvedConflictsVerified > 0 && (d.mode !== "apply" || !d.resolutionsFile || d.unlistedConflictPolicy !== null || d.resolutionsFile.keepSource !== 0 || d.resolutionsFile.keepDestination !== 0 || d.resolutionsFile.resultFiles !== p.resolvedConflictsVerified || d.resolutionsFile.entries !== p.resolvedConflictsVerified))) return false;
|
|
42
48
|
if (p && (p.warningsObserved > p.records || d.mode === "apply" && p.warningsObserved > 0 && ["completed", "no-op", "conflict"].includes(dto.outcome))) return false;
|
|
43
49
|
if (!p && (d.observedChangesets.length || d.conflictPaths.length || Object.values(d.counts).some(Boolean))) return false;
|
|
44
50
|
if (dto.completeness.capture === "complete" && !(d.capture.merge?.complete ?? d.capture.help?.complete)) return false;
|
|
@@ -60,7 +66,9 @@ export function validateServerMergeOutput(value: unknown): value is MergeReceipt
|
|
|
60
66
|
if (c.changesetsReturned + c.conflictsReturned > 100) return false;
|
|
61
67
|
if (c.changesetsReturned !== d.observedChangesets.length || c.conflictsReturned !== d.conflictPaths.length || p && (c.changesetsReturned + c.changesetsOmitted !== p.changesetsObserved || c.conflictsReturned + c.conflictsOmitted !== p.conflictsObserved)) return false;
|
|
62
68
|
if ((c.changesetsOmitted || c.conflictsOmitted) && dto.completeness.projection) return false;
|
|
63
|
-
if (dto.outcome === "completed" && (a.exitCode !== 0 || !d.requestedIdentity || !d.createdChangeset || d.createdChangeset.branch !== d.requestedIdentity.branch || d.createdChangeset.repository !== d.requestedIdentity.repository || d.createdChangeset.mount !== "/" || p?.changesetsObserved !== 1 || p.conflictsObserved !==
|
|
69
|
+
if (dto.outcome === "completed" && (a.exitCode !== 0 || !d.requestedIdentity || !d.createdChangeset || d.createdChangeset.branch !== d.requestedIdentity.branch || d.createdChangeset.repository !== d.requestedIdentity.repository || d.createdChangeset.server !== d.requestedIdentity.server || d.createdChangeset.mount !== "/" || p?.changesetsObserved !== 1 || p.conflictsObserved !== p.resolvedConflictsVerified)) return false;
|
|
70
|
+
if (["completed", "no-op", "analyzed"].includes(dto.outcome) && d.capture.merge?.stderrBytes) return false;
|
|
71
|
+
if (dto.outcome === "completed" && (!d.command || d.command[0] !== "cm" || d.command[1] !== "merge" || d.command[2] !== d.requestedIdentity?.source || !d.command.includes("--to=" + d.requestedIdentity?.target) || !d.command.includes("--merge") || !d.command.includes("--nointeractiveresolution") || !d.command.some(a => a.startsWith("-c=") && a.slice(3).trim()) || !d.wouldRun || d.capability.state !== "advertised" || d.capability.missingTokens.length > 0 || !d.capture.help?.complete || d.capture.help.stderrBytes || dto.completeness.capture !== "complete" || d.resolutionsFile && !d.command.includes("--fileconflictsresolutionsfile=" + d.resolutionsFile.path) || p?.resolvedConflictsVerified && d.command.some(a => a === "--keepsource" || a === "--keepdestination") || !d.observedChangesets.some(c => c.id === d.createdChangeset?.id && c.branch === d.createdChangeset.branch && c.repository === d.createdChangeset.repository && c.server === d.createdChangeset.server && c.mount === d.createdChangeset.mount))) return false;
|
|
64
72
|
if (dto.outcome === "no-op" && (a.exitCode !== 0 || p?.changesetsObserved !== 0 || p.conflictsObserved !== 0)) return false;
|
|
65
73
|
if (dto.outcome === "conflict" && (a.exitCode === 0 || p?.changesetsObserved !== 0 || !p.conflictsObserved)) return false;
|
|
66
74
|
for (const cap of [d.capture.help, d.capture.merge]) if (cap && (cap.stdoutRetainedBytes > cap.stdoutBytes || cap.stderrRetainedBytes > cap.stderrBytes || cap.complete && (!cap.validUtf8 || cap.truncated))) return false;
|
package/src/pi/status-output.ts
CHANGED
|
@@ -25,15 +25,17 @@ const item = object({
|
|
|
25
25
|
path: Type.String({ minLength: 1, maxLength: 4096, pattern: statusPathPattern }), isDirectory: Type.Boolean(),
|
|
26
26
|
revisionId: Type.Optional(Type.String({ minLength: 1, maxLength: 128, pattern: "^[0-9]+$" })),
|
|
27
27
|
sourcePath: Type.Optional(Type.String({ minLength: 1, maxLength: 4096, pattern: statusPathPattern })),
|
|
28
|
+
similarity: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
|
|
28
29
|
});
|
|
29
30
|
const xmlCount = Type.Integer({ minimum: 0, maximum: 20000 });
|
|
30
31
|
const xmlSummary = { totalPending: xmlCount, added: xmlCount, changed: xmlCount, moved: xmlCount, deleted: xmlCount, other: Type.Literal(0), private: xmlCount, tracked: xmlCount };
|
|
31
32
|
const xmlPathPattern = `^(?:/|[A-Za-z]:\\\\|\\\\\\\\)[^\\u0000-\\u001f${process.platform === "win32" ? "?" : ""}]*$`;
|
|
32
33
|
const xmlItem = object({
|
|
33
|
-
statusCode: Type.Union([Type.Literal("CH"), Type.Literal("CO"), Type.Literal("CP"), Type.Literal("RP"), Type.Literal("AD"), Type.Literal("DE"), Type.Literal("LD"), Type.Literal("MV"), Type.Literal("PR")]),
|
|
34
|
+
statusCode: Type.Union([Type.Literal("CH"), Type.Literal("CO"), Type.Literal("CP"), Type.Literal("RP"), Type.Literal("AD"), Type.Literal("DE"), Type.Literal("LD"), Type.Literal("MV"), Type.Literal("LM"), Type.Literal("PR")]),
|
|
34
35
|
kind: Type.Union([Type.Literal("added"), Type.Literal("changed"), Type.Literal("moved"), Type.Literal("deleted"), Type.Literal("private")]),
|
|
35
36
|
path: Type.String({ minLength: 1, maxLength: 4096, pattern: xmlPathPattern }), isDirectory: Type.Boolean(),
|
|
36
37
|
sourcePath: Type.Optional(Type.String({ minLength: 1, maxLength: 4096, pattern: xmlPathPattern })),
|
|
38
|
+
similarity: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
|
|
37
39
|
});
|
|
38
40
|
export const statusOutputSchema = Type.Union([
|
|
39
41
|
object({ ...common, schemaVersion: Type.Literal(2), ok: Type.Literal(true), data: object({
|
|
@@ -72,18 +74,49 @@ const envelope = { schemaVersion: 1, action: "status", provenance } as const;
|
|
|
72
74
|
type StatusErrorCode = Extract<StatusOutput, { ok: false; schemaVersion: 1 }>["error"]["code"];
|
|
73
75
|
const errorOutput = (code: StatusErrorCode, message: string, schemaVersion: 1 | 2 = 1): StatusOutput => ({ ...envelope, schemaVersion, ok: false, completeness: { read: "incomplete", capture: "unknown", projection: false }, error: { code, message } });
|
|
74
76
|
|
|
77
|
+
// Independent DTO trust-boundary validation mirrors the observation identity rules.
|
|
78
|
+
const statusObservationPathKey = (value: string): string | null => {
|
|
79
|
+
if (!value || value.length > 4096 || /[\u0000-\u001f\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(value)) return null;
|
|
80
|
+
const path = value.replace(/\\/g, "/");
|
|
81
|
+
if (!/^(?:\/|[A-Za-z]:\/)/.test(path) || path.split("/").some(part => part === "." || part === "..")) return null;
|
|
82
|
+
const windows = /^[A-Za-z]:\//.test(path) || path.startsWith("//");
|
|
83
|
+
const tail = path.slice(path.startsWith("//") ? 2 : windows ? 3 : 1);
|
|
84
|
+
if (tail.includes("//") || (windows && (/[<>:"|?*]/.test(tail) || tail.split("/").some(part => /[. ]$/.test(part))))) return null;
|
|
85
|
+
if (path.startsWith("//") && !/^\/\/[^/]+\/[^/]+\//.test(path)) return null;
|
|
86
|
+
const key = windows ? path.toLowerCase() : path;
|
|
87
|
+
return key.length > (windows ? 3 : 1) ? key.replace(/\/$/, "") : key;
|
|
88
|
+
};
|
|
89
|
+
const statusObservationPathRoot = (value: string): string => {
|
|
90
|
+
const path = value.replace(/\\/g, "/").toLowerCase();
|
|
91
|
+
return path.match(/^[a-z]:/)?.[0] ?? (path.startsWith("//") ? path.split("/").slice(0, 4).join("/") : "/");
|
|
92
|
+
};
|
|
93
|
+
|
|
75
94
|
export function validateStatusOutput(value: unknown, schemaVersion: 1 | 2 = 1): StatusOutput {
|
|
76
95
|
if (!Check(statusOutputSchema, value)) return errorOutput("invalid_producer_data", "Status producer data did not match the output contract.", schemaVersion);
|
|
77
96
|
const checked = value as StatusOutput;
|
|
97
|
+
if (checked.ok && checked.data.mode === "machine") {
|
|
98
|
+
const invalidMove = checked.data.items.some(row => {
|
|
99
|
+
if (row.statusCode !== "LM") return row.similarity !== undefined;
|
|
100
|
+
const destination = statusObservationPathKey(row.path);
|
|
101
|
+
const source = statusObservationPathKey(row.sourcePath ?? "");
|
|
102
|
+
return row.kind !== "moved" || row.similarity === undefined || !source || !destination || source === destination ||
|
|
103
|
+
statusObservationPathRoot(row.path) !== statusObservationPathRoot(row.sourcePath ?? "") || !/^[1-9][0-9]{0,19}$/.test(row.revisionId ?? "");
|
|
104
|
+
});
|
|
105
|
+
if (invalidMove) return errorOutput("invalid_producer_data", "Status producer data did not match the output contract.", schemaVersion);
|
|
106
|
+
}
|
|
78
107
|
if (checked.ok && checked.data.mode === "xml") {
|
|
79
108
|
const data = checked.data;
|
|
80
109
|
const { parsed, returned, omitted, excluded, overCap } = data.itemCount;
|
|
81
110
|
const seen = new Set<string>();
|
|
82
|
-
const kinds = { CH: "changed", CO: "changed", CP: "added", RP: "changed", AD: "added", DE: "deleted", LD: "deleted", MV: "moved", PR: "private" };
|
|
111
|
+
const kinds = { CH: "changed", CO: "changed", CP: "added", RP: "changed", AD: "added", DE: "deleted", LD: "deleted", MV: "moved", LM: "moved", PR: "private" };
|
|
83
112
|
const invalidRows = data.items.some(row => {
|
|
84
113
|
const unsafe = (path: string) => /[\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(path);
|
|
85
|
-
const invalid = unsafe(row.path) || (row.sourcePath !== undefined && unsafe(row.sourcePath)) || row.kind !== kinds[row.statusCode] || (row.kind === "moved") !== Boolean(row.sourcePath) || row.sourcePath === row.path
|
|
86
|
-
|
|
114
|
+
const invalid = unsafe(row.path) || (row.sourcePath !== undefined && unsafe(row.sourcePath)) || row.kind !== kinds[row.statusCode] || (row.kind === "moved") !== Boolean(row.sourcePath) || row.sourcePath === row.path;
|
|
115
|
+
const keys = [row.path, ...(row.sourcePath ? [row.sourcePath] : [])].map(statusObservationPathKey);
|
|
116
|
+
const invalidIdentity = keys.some(key => !key || seen.has(key)) || new Set(keys).size !== keys.length;
|
|
117
|
+
for (const key of keys) if (key) seen.add(key);
|
|
118
|
+
const invalidSimilarity = row.statusCode === "LM" ? row.similarity === undefined || statusObservationPathRoot(row.path) !== statusObservationPathRoot(row.sourcePath ?? "") : row.similarity !== undefined;
|
|
119
|
+
return invalid || invalidIdentity || invalidSimilarity;
|
|
87
120
|
});
|
|
88
121
|
if (data.summary.added + data.summary.changed + data.summary.deleted + data.summary.moved + data.summary.private + data.summary.other !== parsed || data.summary.other !== 0 || data.summary.tracked !== parsed - data.summary.private || invalidRows || returned !== data.items.length || parsed !== data.parse.records || parsed !== data.summary.totalPending || omitted !== parsed - returned || excluded !== 0 || overCap !== omitted || checked.completeness.read !== "complete" || checked.completeness.capture !== "complete" || checked.completeness.projection !== (returned === parsed)) return errorOutput("invalid_producer_data", "Status producer data did not match the output contract.", 2);
|
|
89
122
|
}
|
|
@@ -110,6 +143,7 @@ export function projectStatusOutput(observation: StatusObservation, maxItems = 1
|
|
|
110
143
|
statusCode: row.statusCode, kind: row.kind, path: row.workspacePath, isDirectory: row.isDirectory,
|
|
111
144
|
...(row.revisionId !== undefined ? { revisionId: row.revisionId } : {}),
|
|
112
145
|
...(row.sourceWorkspacePath !== undefined ? { sourcePath: row.sourceWorkspacePath } : {}),
|
|
146
|
+
...(row.similarity !== undefined ? { similarity: row.similarity } : {}),
|
|
113
147
|
})).filter(row => Check(item, row));
|
|
114
148
|
const items = eligible.slice(0, maxItems);
|
|
115
149
|
const excluded = pendingItems.length - eligible.length;
|
|
@@ -36,7 +36,7 @@ export function validateWorkspaceMergeOutput(v:unknown):v is WorkspaceMergeRecei
|
|
|
36
36
|
if(d.fullStatus.admission!=="not-observed"&&(!d.shortStatus.capture?.complete||d.shortStatus.attempt.exitCode!==0))return false;
|
|
37
37
|
if(d.protocol==="not-observed"&&(d.apply.admission==="admitted"||d.fileConflictCount!==null)||d.protocol==="unsupported"&&(d.fileConflictCount!==null||d.apply.admission!=="unsupported"))return false;
|
|
38
38
|
if(["already-connected","apply-add","apply-add-copied","file-conflict","merged-candidate"].includes(d.protocol)&&d.apply.admission!=="admitted")return false;
|
|
39
|
-
if(d.protocol==="merged-candidate"&&(d.fileConflictCount===null||d.fileConflictCount>
|
|
39
|
+
if(d.protocol==="merged-candidate"&&(d.fileConflictCount===null||d.fileConflictCount>128||d.checkinReadiness==="blocked"))return false;
|
|
40
40
|
if(d.protocol==="file-conflict"?(d.fileConflictCount===null||d.fileConflictCount===0||d.checkinReadiness!=="blocked"):d.checkinReadiness==="blocked")return false;
|
|
41
41
|
if(["already-connected","apply-add","apply-add-copied"].includes(d.protocol)&&d.fileConflictCount!==0)return false;
|
|
42
42
|
if(d.protocol==="merged-candidate"&&d.checkinReadiness==="supported-no-unresolved-signals"&&(d.strategy!=="auto"||d.forced!==false||d.cherrypicking!==false))return false;
|