@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.
@@ -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
- if (!d.conflictPaths.every(s => s.startsWith("/") && safeText(s))) return false;
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 !== 0)) return false;
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;
@@ -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 || seen.has(row.path);
86
- seen.add(row.path); return invalid;
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>1||d.checkinReadiness==="blocked"))return false;
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;