@trim21/personal-pi-extensions 0.1.476 → 0.1.478

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.476",
3
+ "version": "0.1.478",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -63,6 +63,9 @@ function samePaths(a: Set<string>, b: Set<string>): boolean {
63
63
  /** LSP MethodNotFound(-32601):服务器未实现 prepareRename / rename 请求。 */
64
64
  const LSP_METHOD_NOT_FOUND = -32601;
65
65
 
66
+ /** LSP ContentModified(-32801):服务器处理请求期间内容被修改,重发请求即可。 */
67
+ const LSP_CONTENT_MODIFIED = -32801;
68
+
66
69
  /**
67
70
  * 位置不可 rename 或服务器不具备 rename 能力;与传输失败等意外错误区分,
68
71
  * 供调用方在多候选探测时跳过该 client 而不是中断整个操作。
@@ -75,14 +78,27 @@ export class RenameNotPossibleError extends Error {}
75
78
  */
76
79
  export class RenameIncompleteError extends Error {
77
80
  readonly missing: readonly string[];
78
- constructor(missing: readonly string[]) {
81
+ readonly extra: readonly string[];
82
+ constructor(missing: readonly string[], extra: readonly string[] = []) {
83
+ const parts: string[] = [];
84
+ if (missing.length > 0) {
85
+ parts.push(
86
+ `textDocument/references found the symbol in ${missing.length} file(s) ` +
87
+ `that the rename edit does not cover (${missing.join(", ")})`,
88
+ );
89
+ }
90
+ if (extra.length > 0) {
91
+ parts.push(
92
+ `the rename edit touches ${extra.length} file(s) ` +
93
+ `that textDocument/references did not report (${extra.join(", ")})`,
94
+ );
95
+ }
79
96
  super(
80
- `LSP rename incomplete: textDocument/references found the symbol in ` +
81
- `${missing.length} file(s) that the rename edit does not cover ` +
82
- `(${missing.join(", ")}). The server index may still be loading; ` +
83
- `nothing was modified, retry shortly.`,
97
+ `LSP rename incomplete: ${parts.join("; ")}. ` +
98
+ `The server index may still be loading; nothing was modified, retry shortly.`,
84
99
  );
85
100
  this.missing = missing;
101
+ this.extra = extra;
86
102
  }
87
103
  }
88
104
 
@@ -90,7 +106,27 @@ export class RenameIncompleteError extends Error {
90
106
  * rename 覆盖校验的轮询节奏。budgetMs 是 references 收敛 + 重试的总预算;
91
107
  * 测试可临时缩小以缩短等待。
92
108
  */
93
- export const renameVerificationTiming = { pollMs: 400, budgetMs: 10_000 };
109
+ export const renameVerificationTiming = {
110
+ pollMs: 400,
111
+ budgetMs: 10_000,
112
+ /** ContentModified(-32801) 重试上限:服务器处理期间文档被修改,重发请求即可。 */
113
+ contentModifiedRetries: 3,
114
+ };
115
+
116
+ /**
117
+ * ContentModified 语义:请求处理期间 salsa 数据库被文件变更修改,服务器请
118
+ * 客户端重发请求。重发无副作用,直接重试;仅连续超限才向上抛,避免无限循环。
119
+ */
120
+ async function retryOnContentModified<T>(fn: () => Promise<T>): Promise<T> {
121
+ for (let attempt = 0; ; attempt++) {
122
+ try {
123
+ return await fn();
124
+ } catch (error) {
125
+ if (!(error instanceof ResponseError && error.code === LSP_CONTENT_MODIFIED)) throw error;
126
+ if (attempt >= renameVerificationTiming.contentModifiedRetries) throw error;
127
+ }
128
+ }
129
+ }
94
130
 
95
131
  interface PrepareRenameResponse {
96
132
  range?: unknown;
@@ -956,22 +992,28 @@ export async function create(input: CreateInput): Promise<LspClient> {
956
992
  const notRenameable = () =>
957
993
  new RenameNotPossibleError(`LSP server "${input.serverID}" cannot rename at ${at}`);
958
994
 
959
- // references 前置 + rename 覆盖校验:LSP 没有标准化的"索引完成"信号,
995
+ // references 前置 + rename 双向校验:LSP 没有标准化的"索引完成"信号,
960
996
  // 服务器(如 tsserver)可能在项目加载完成前回答,导致 rename 漏掉
961
- // 尚未入索引的文件。对策分两层:
997
+ // 尚未入索引的文件。对策分三层:
962
998
  // 1. 收敛检测:references 连续两次文件集合一致才认为索引稳定,防止
963
999
  // "服务器根本还没发现某文件"时校验形同虚设;
964
- // 2. 覆盖校验:references 报告的文件必须都被 rename edit 覆盖,
965
- // 不完整时等待重试,预算耗尽仍不完整抛 RenameIncompleteError——
966
- // 调用方尚未写盘,整个操作无副作用,可稍后重试。
1000
+ // 2. 覆盖校验(missing):references 报告的文件必须都被 rename edit
1001
+ // 覆盖,缺失说明服务器索引落后,抛 RenameIncompleteError;
1002
+ // 3. 一致性校验(extra):rename 触及的文件超出已收敛的 references
1003
+ // 集合,说明两次请求之间项目覆盖在增长(rename 晚于 references,
1004
+ // 索引仍在加载),此时 rename 的结果本身不可信——回到 references
1005
+ // 轮询等重新收敛,再重发 rename 复检;预算耗尽仍不一致时抛
1006
+ // RenameIncompleteError——调用方尚未写盘,整个操作无副作用。
967
1007
  // 服务器不支持 references(MethodNotFound)时跳过校验,信任服务器,
968
1008
  // 与编辑器行为一致。
969
1009
  const referencesRequest = () =>
970
- connection.sendRequest<{ uri: string }[] | null>("textDocument/references", {
971
- textDocument: { uri },
972
- position,
973
- context: { includeDeclaration: true },
974
- });
1010
+ retryOnContentModified(() =>
1011
+ connection.sendRequest<{ uri: string }[] | null>("textDocument/references", {
1012
+ textDocument: { uri },
1013
+ position,
1014
+ context: { includeDeclaration: true },
1015
+ }),
1016
+ );
975
1017
 
976
1018
  const toPaths = (locations: { uri: string }[] | null): Set<string> =>
977
1019
  new Set(
@@ -982,11 +1024,13 @@ export async function create(input: CreateInput): Promise<LspClient> {
982
1024
 
983
1025
  const sendRename = async (): Promise<WorkspaceEdit | null> => {
984
1026
  try {
985
- return await connection.sendRequest<WorkspaceEdit | null>("textDocument/rename", {
986
- textDocument: { uri },
987
- position,
988
- newName: request.newName,
989
- });
1027
+ return await retryOnContentModified(() =>
1028
+ connection.sendRequest<WorkspaceEdit | null>("textDocument/rename", {
1029
+ textDocument: { uri },
1030
+ position,
1031
+ newName: request.newName,
1032
+ }),
1033
+ );
990
1034
  } catch (error) {
991
1035
  if (error instanceof ResponseError && error.code === LSP_METHOD_NOT_FOUND) {
992
1036
  throw notRenameable();
@@ -999,9 +1043,11 @@ export async function create(input: CreateInput): Promise<LspClient> {
999
1043
  if (hasPrepareProvider) {
1000
1044
  let prepared: PrepareRenameResponse | null;
1001
1045
  try {
1002
- prepared = await connection.sendRequest<PrepareRenameResponse | null>(
1003
- "textDocument/prepareRename",
1004
- { textDocument: { uri }, position },
1046
+ prepared = await retryOnContentModified(() =>
1047
+ connection.sendRequest<PrepareRenameResponse | null>("textDocument/prepareRename", {
1048
+ textDocument: { uri },
1049
+ position,
1050
+ }),
1005
1051
  );
1006
1052
  } catch (error) {
1007
1053
  if (error instanceof ResponseError && error.code === LSP_METHOD_NOT_FOUND) {
@@ -1028,14 +1074,27 @@ export async function create(input: CreateInput): Promise<LspClient> {
1028
1074
  let current = toPaths(locations);
1029
1075
  for (;;) {
1030
1076
  const settled = previous !== undefined && samePaths(previous, current);
1031
- if (settled || Date.now() >= deadline) {
1077
+ const expired = Date.now() >= deadline;
1078
+ if (settled || expired) {
1032
1079
  const edit = await sendRename();
1033
1080
  if (!edit) throw notRenameable();
1034
- const missing = [...current].filter((path) => !editFilePaths(edit).has(path));
1035
- if (missing.length === 0) {
1081
+ const editPaths = editFilePaths(edit);
1082
+ const missing: string[] = [];
1083
+ const extra: string[] = [];
1084
+ for (const path of current) {
1085
+ if (!editPaths.has(path)) missing.push(path);
1086
+ }
1087
+ for (const path of editPaths) {
1088
+ if (!current.has(path)) extra.push(path);
1089
+ }
1090
+ if (missing.length === 0 && extra.length === 0) {
1036
1091
  return placeholder === undefined ? { edit } : { edit, placeholder };
1037
1092
  }
1038
- throw new RenameIncompleteError(missing);
1093
+ if (expired || missing.length > 0) {
1094
+ throw new RenameIncompleteError(missing, extra);
1095
+ }
1096
+ // 收敛后 rename 仍报出 references 没有的文件:references 快照已过时,
1097
+ // 继续轮询到重新收敛后再重发 rename 复检(预算耗尽则向上抛)。
1039
1098
  }
1040
1099
  previous = current;
1041
1100
  await sleep(renameVerificationTiming.pollMs);