@trim21/personal-pi-extensions 0.1.463 → 0.1.465

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.463",
3
+ "version": "0.1.465",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -24,6 +24,7 @@ import type { Diagnostic as VSCodeDiagnostic, WorkspaceEdit } from "vscode-langu
24
24
 
25
25
  import type { LspServerHandle } from "./adapter.js";
26
26
  import { LANGUAGE_EXTENSIONS } from "./language.js";
27
+ import { editFilePaths } from "./rename.js";
27
28
  import type { FileChange, FileChangeType } from "./watcher.js";
28
29
 
29
30
  // LSP spec 常量
@@ -52,6 +53,13 @@ const FILE_CHANGE_TYPE: Record<FileChangeType, number> = {
52
53
 
53
54
  export type Diagnostic = VSCodeDiagnostic;
54
55
 
56
+ /** 两个路径集合是否一致(用于判断 references 结果是否收敛)。 */
57
+ function samePaths(a: Set<string>, b: Set<string>): boolean {
58
+ if (a.size !== b.size) return false;
59
+ for (const path of a) if (!b.has(path)) return false;
60
+ return true;
61
+ }
62
+
55
63
  /** LSP MethodNotFound(-32601):服务器未实现 prepareRename / rename 请求。 */
56
64
  const LSP_METHOD_NOT_FOUND = -32601;
57
65
 
@@ -61,6 +69,29 @@ const LSP_METHOD_NOT_FOUND = -32601;
61
69
  */
62
70
  export class RenameNotPossibleError extends Error {}
63
71
 
72
+ /**
73
+ * rename edit 未覆盖 references 看到的全部文件:服务器索引可能仍在后台加载。
74
+ * 抛出时发生在写盘之前,整个 rename 无副作用,可稍后重试。
75
+ */
76
+ export class RenameIncompleteError extends Error {
77
+ readonly missing: readonly string[];
78
+ constructor(missing: readonly string[]) {
79
+ 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.`,
84
+ );
85
+ this.missing = missing;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * rename 覆盖校验的轮询节奏。budgetMs 是 references 收敛 + 重试的总预算;
91
+ * 测试可临时缩小以缩短等待。
92
+ */
93
+ export const renameVerificationTiming = { pollMs: 400, budgetMs: 10_000 };
94
+
64
95
  interface PrepareRenameResponse {
65
96
  range?: unknown;
66
97
  placeholder?: string;
@@ -925,24 +956,29 @@ export async function create(input: CreateInput): Promise<LspClient> {
925
956
  const notRenameable = () =>
926
957
  new RenameNotPossibleError(`LSP server "${input.serverID}" cannot rename at ${at}`);
927
958
 
928
- // references 前置作为同步点:服务器必须完成项目加载才能回答引用位置,
929
- // 其响应到达后紧随的 rename 必然基于完整索引(LSP 没有标准化的
930
- // "索引完成"信号,这是协议内唯一的同步手段)。references 请求本身
931
- // 的结果不用于校验 rename 编辑——信任服务器,与编辑器行为一致;
932
- // 服务器不支持 references(MethodNotFound)时直接跳过。
933
- const awaitReferences = async (): Promise<void> => {
934
- try {
935
- await connection.sendRequest<{ uri: string }[] | null>("textDocument/references", {
936
- textDocument: { uri },
937
- position,
938
- context: { includeDeclaration: true },
939
- });
940
- } catch (error) {
941
- if (!(error instanceof ResponseError && error.code === LSP_METHOD_NOT_FOUND)) {
942
- throw error;
943
- }
944
- }
945
- };
959
+ // references 前置 + rename 覆盖校验:LSP 没有标准化的"索引完成"信号,
960
+ // 服务器(如 tsserver)可能在项目加载完成前回答,导致 rename 漏掉
961
+ // 尚未入索引的文件。对策分两层:
962
+ // 1. 收敛检测:references 连续两次文件集合一致才认为索引稳定,防止
963
+ // "服务器根本还没发现某文件"时校验形同虚设;
964
+ // 2. 覆盖校验:references 报告的文件必须都被 rename edit 覆盖,
965
+ // 不完整时等待重试,预算耗尽仍不完整抛 RenameIncompleteError——
966
+ // 调用方尚未写盘,整个操作无副作用,可稍后重试。
967
+ // 服务器不支持 references(MethodNotFound)时跳过校验,信任服务器,
968
+ // 与编辑器行为一致。
969
+ const referencesRequest = () =>
970
+ connection.sendRequest<{ uri: string }[] | null>("textDocument/references", {
971
+ textDocument: { uri },
972
+ position,
973
+ context: { includeDeclaration: true },
974
+ });
975
+
976
+ const toPaths = (locations: { uri: string }[] | null): Set<string> =>
977
+ new Set(
978
+ (locations ?? []).flatMap((location) =>
979
+ location.uri.startsWith("file:") ? [normalize(fileURLToPath(location.uri))] : [],
980
+ ),
981
+ );
946
982
 
947
983
  const sendRename = async (): Promise<WorkspaceEdit | null> => {
948
984
  try {
@@ -977,10 +1013,34 @@ export async function create(input: CreateInput): Promise<LspClient> {
977
1013
  if (typeof prepared.placeholder === "string") placeholder = prepared.placeholder;
978
1014
  }
979
1015
 
980
- await awaitReferences();
981
- const edit = await sendRename();
982
- if (!edit) throw notRenameable();
983
- return placeholder === undefined ? { edit } : { edit, placeholder };
1016
+ let locations: { uri: string }[] | null;
1017
+ try {
1018
+ locations = await referencesRequest();
1019
+ } catch (error) {
1020
+ if (!(error instanceof ResponseError && error.code === LSP_METHOD_NOT_FOUND)) throw error;
1021
+ const edit = await sendRename();
1022
+ if (!edit) throw notRenameable();
1023
+ return placeholder === undefined ? { edit } : { edit, placeholder };
1024
+ }
1025
+
1026
+ const deadline = Date.now() + renameVerificationTiming.budgetMs;
1027
+ let previous: Set<string> | undefined;
1028
+ let current = toPaths(locations);
1029
+ for (;;) {
1030
+ const settled = previous !== undefined && samePaths(previous, current);
1031
+ if (settled || Date.now() >= deadline) {
1032
+ const edit = await sendRename();
1033
+ if (!edit) throw notRenameable();
1034
+ const missing = [...current].filter((path) => !editFilePaths(edit).has(path));
1035
+ if (missing.length === 0) {
1036
+ return placeholder === undefined ? { edit } : { edit, placeholder };
1037
+ }
1038
+ throw new RenameIncompleteError(missing);
1039
+ }
1040
+ previous = current;
1041
+ await sleep(renameVerificationTiming.pollMs);
1042
+ current = toPaths(await referencesRequest());
1043
+ }
984
1044
  },
985
1045
  get diagnostics() {
986
1046
  const result = new Map<string, Diagnostic[]>();
@@ -159,6 +159,14 @@ export async function expandWorkspaceEdit(
159
159
  return applied;
160
160
  }
161
161
 
162
+ /**
163
+ * rename edit 覆盖的文件路径集合(changes + documentChanges 的 text edits)。
164
+ * 供 renameSymbol 用 references 结果做覆盖校验。
165
+ */
166
+ export function editFilePaths(edit: WorkspaceEdit): Set<string> {
167
+ return new Set(collectTextEdits(edit).keys());
168
+ }
169
+
162
170
  /**
163
171
  * 归一化 WorkspaceEdit 为稳定字符串(URI → 规范路径、结构化字段),供比较
164
172
  * 两次 rename 的编辑集合是否一致(同一符号的多次出现 vs 不同符号)。