@trim21/personal-pi-extensions 0.1.593 → 0.1.594

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.593",
3
+ "version": "0.1.594",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -32,7 +32,7 @@ import type {
32
32
 
33
33
  import type { LspServerHandle } from "./adapter.js";
34
34
  import { LANGUAGE_EXTENSIONS } from "./language.js";
35
- import { editFilePaths } from "./rename.js";
35
+ import { editFilePaths, stabilityAcceptable, trackStability } from "./rename.js";
36
36
  import type { FileChange, FileChangeType } from "./watcher.js";
37
37
 
38
38
  // LSP spec 常量
@@ -61,13 +61,6 @@ const FILE_CHANGE_TYPE: Record<FileChangeType, number> = {
61
61
 
62
62
  export type Diagnostic = VSCodeDiagnostic;
63
63
 
64
- /** 两个路径集合是否一致(用于判断 references 结果是否收敛)。 */
65
- function samePaths(a: Set<string>, b: Set<string>): boolean {
66
- if (a.size !== b.size) return false;
67
- for (const path of a) if (!b.has(path)) return false;
68
- return true;
69
- }
70
-
71
64
  /** LSP MethodNotFound(-32601):服务器未实现 prepareRename / rename 请求。 */
72
65
  const LSP_METHOD_NOT_FOUND = -32601;
73
66
 
@@ -115,6 +108,9 @@ export class RenameIncompleteError extends Error {
115
108
  `that textDocument/references did not report (${extra.join(", ")})`,
116
109
  );
117
110
  }
111
+ if (parts.length === 0) {
112
+ parts.push("the references result has not been stable long enough to trust");
113
+ }
118
114
  super(
119
115
  `LSP rename incomplete: ${parts.join("; ")}. ` +
120
116
  `The server index may still be loading; nothing was modified, retry shortly.`,
@@ -130,9 +126,20 @@ export class RenameIncompleteError extends Error {
130
126
  */
131
127
  export const renameVerificationTiming = {
132
128
  pollMs: 400,
133
- budgetMs: 10_000,
129
+ budgetMs: 15_000,
134
130
  /** ContentModified(-32801) 重试上限:服务器处理期间文档被修改,重发请求即可。 */
135
131
  contentModifiedRetries: 3,
132
+ /** 接受结果所需的最少连续一致采样次数。 */
133
+ settleSamples: 3,
134
+ /** 就绪已证实(拿到过当前版本的诊断结论)时的稳定窗口下限(ms)。 */
135
+ stableFloorReadyMs: 400,
136
+ /**
137
+ * 就绪未证实(栅栏只是等满预算放行)时的稳定窗口下限(ms)。服务器加载
138
+ * 项目期间 references 只覆盖已发现的文件,残缺答案能连续多次一致(假稳定),
139
+ * 短窗口会把它误判成最终结果——CI 实测漏改跨文件引用,故要求 references
140
+ * 文件集合至少持续一致这么久才接受。
141
+ */
142
+ stableFloorUnreadyMs: 4_000,
136
143
  };
137
144
 
138
145
  /**
@@ -1027,19 +1034,24 @@ export async function create(input: CreateInput): Promise<LspClient> {
1027
1034
  });
1028
1035
  }
1029
1036
 
1037
+ /**
1038
+ * 等待「当前文档版本」的诊断结论。返回值表示就绪是否被证实:true = 拿到了
1039
+ * 当前版本的诊断结果(push / pull / 已有结论);false = 等满预算或连接关闭
1040
+ * 都没等到——如 typescript-language-server 对干净文档永不推送、也不实现 pull。
1041
+ */
1030
1042
  async function waitForDocumentDiagnostics(request: {
1031
1043
  path: string;
1032
1044
  version: number;
1033
1045
  after?: number;
1034
1046
  signal?: AbortSignal;
1035
- }): Promise<void> {
1047
+ }): Promise<boolean> {
1036
1048
  // 服务器对当前内容的最新结论已在手(最后一次 push 晚于最后一次内容同步,
1037
1049
  // 即此后没有再通知过内容变化):直接返回已有结果。部分服务器在诊断集合
1038
1050
  // 不变时不再推送(typescript-language-server 空→空不发布),等新 push 只会
1039
1051
  // 耗满整个窗口后得到同样的「无诊断」。
1040
1052
  const known = published.get(request.path);
1041
1053
  const document = files[request.path];
1042
- if (known !== undefined && document !== undefined && known.at >= document.syncedAt) return;
1054
+ if (known !== undefined && document !== undefined && known.at >= document.syncedAt) return true;
1043
1055
 
1044
1056
  const startedAt = request.after ?? Date.now();
1045
1057
  // 「上一份 push 为空」的文档用短安静期(见 clientDefaults.diagnosticsSilentWaitTimeoutMs)。
@@ -1059,12 +1071,11 @@ export async function create(input: CreateInput): Promise<LspClient> {
1059
1071
 
1060
1072
  while (!connectionClosed && !request.signal?.aborted) {
1061
1073
  const remaining = budget - (Date.now() - startedAt);
1062
- if (remaining <= 0) return;
1074
+ if (remaining <= 0) return false;
1063
1075
  const result = await requestDocumentDiagnostics(request.path);
1064
- if (result.matched) return;
1076
+ if (result.matched) return true;
1065
1077
  if (result.timedOut) {
1066
- await pushWait;
1067
- return;
1078
+ return await pushWait;
1068
1079
  }
1069
1080
  const next = await Promise.race([
1070
1081
  pushWait.then((ready) => (ready ? ("push" as const) : ("timeout" as const))),
@@ -1073,8 +1084,9 @@ export async function create(input: CreateInput): Promise<LspClient> {
1073
1084
  ),
1074
1085
  sleep(Math.min(remaining, PULL_RETRY_INTERVAL_MS)).then(() => "interval" as const),
1075
1086
  ]);
1076
- if (next === "push") return;
1087
+ if (next === "push") return true;
1077
1088
  }
1089
+ return false;
1078
1090
  }
1079
1091
 
1080
1092
  async function waitForFullDiagnostics(request: {
@@ -1301,13 +1313,15 @@ export async function create(input: CreateInput): Promise<LspClient> {
1301
1313
  // rename 前强制同步磁盘内容,保证服务器基于最新文本计算编辑
1302
1314
  const version = await openDocument({ path: resolvedPath });
1303
1315
  // 索引就绪栅栏:项目异步加载完成前,服务器对 references 的答复只包含当前
1304
- // 打开的文件(尚未发现其它文件),而"连续两次一致"会把它误判成"索引已收
1305
- // 敛",于是 rename 漏掉跨文件引用——CI 上实测到(失败那次 3.0s 返回、只改
1306
- // 1 个文件,而通过的 7.9s)。服务器为本文件产生的第一份诊断报告要等项目加
1307
- // 载完成,用它当就绪信号;本会话已经收到过报告(文档早已驻留)时不再等待,
1308
- // 免得给常见路径白加延迟。
1309
- if (!published.has(resolvedPath) && !pullDiagnostics.has(resolvedPath)) {
1310
- await waitForDocumentDiagnostics({
1316
+ // 打开的文件(尚未发现其它文件),稳定窗口太短会把它误判成"索引已收敛",
1317
+ // 于是 rename 漏掉跨文件引用——CI 上实测到。服务器为本文件产生的第一份诊断
1318
+ // 报告要等项目加载完成,用它当就绪信号;本会话已经收到过报告(文档早已驻留)
1319
+ // 时不再等待,免得给常见路径白加延迟。返回值记录就绪是否被证实:证明不了
1320
+ //(typescript-language-server 对干净文档永不推送、也不实现 pull,栅栏只能
1321
+ // 等满预算放行)时,下面的稳定窗口用更长的下限。
1322
+ let indexReady = published.has(resolvedPath) || pullDiagnostics.has(resolvedPath);
1323
+ if (!indexReady) {
1324
+ indexReady = await waitForDocumentDiagnostics({
1311
1325
  path: resolvedPath,
1312
1326
  version,
1313
1327
  signal: request.signal,
@@ -1321,8 +1335,9 @@ export async function create(input: CreateInput): Promise<LspClient> {
1321
1335
  // references 前置 + rename 双向校验:LSP 没有标准化的"索引完成"信号,
1322
1336
  // 服务器(如 tsserver)可能在项目加载完成前回答,导致 rename 漏掉
1323
1337
  // 尚未入索引的文件。对策分三层:
1324
- // 1. 收敛检测:references 连续两次文件集合一致才认为索引稳定,防止
1325
- // "服务器根本还没发现某文件"时校验形同虚设;
1338
+ // 1. 稳定窗口:references 文件集合连续 settleSamples 次一致、且持续
1339
+ // 一致超过稳定下限(就绪未证实用长窗口)才认为收敛,防止"服务器
1340
+ // 根本还没发现某文件"时残缺答案的假稳定被误判为最终结果;
1326
1341
  // 2. 覆盖校验(missing):references 报告的文件必须都被 rename edit
1327
1342
  // 覆盖,缺失说明服务器索引落后,抛 RenameIncompleteError;
1328
1343
  // 3. 一致性校验(extra):rename 触及的文件超出已收敛的 references
@@ -1404,37 +1419,54 @@ export async function create(input: CreateInput): Promise<LspClient> {
1404
1419
  return placeholder === undefined ? { edit } : { edit, placeholder };
1405
1420
  }
1406
1421
 
1422
+ const minStableMs = indexReady
1423
+ ? renameVerificationTiming.stableFloorReadyMs
1424
+ : renameVerificationTiming.stableFloorUnreadyMs;
1407
1425
  const deadline = Date.now() + renameVerificationTiming.budgetMs;
1408
- let previous: Set<string> | undefined;
1409
- let current = toPaths(locations);
1426
+ let stability = trackStability({
1427
+ previous: undefined,
1428
+ paths: toPaths(locations),
1429
+ now: Date.now(),
1430
+ });
1410
1431
  for (;;) {
1411
1432
  request.signal?.throwIfAborted();
1412
- const settled = previous !== undefined && samePaths(previous, current);
1413
- const expired = Date.now() >= deadline;
1414
- if (settled || expired) {
1433
+ const now = Date.now();
1434
+ const stable = stabilityAcceptable(stability, {
1435
+ now,
1436
+ minSamples: renameVerificationTiming.settleSamples,
1437
+ minStableMs,
1438
+ });
1439
+ const expired = now >= deadline;
1440
+ if (stable || expired) {
1415
1441
  const edit = await sendRename();
1416
1442
  if (!edit) throw notRenameable();
1417
1443
  const editPaths = editFilePaths(edit);
1418
1444
  const missing: string[] = [];
1419
1445
  const extra: string[] = [];
1420
- for (const path of current) {
1446
+ for (const path of stability.paths) {
1421
1447
  if (!editPaths.has(path)) missing.push(path);
1422
1448
  }
1423
1449
  for (const path of editPaths) {
1424
- if (!current.has(path)) extra.push(path);
1450
+ if (!stability.paths.has(path)) extra.push(path);
1425
1451
  }
1426
1452
  if (missing.length === 0 && extra.length === 0) {
1427
- return placeholder === undefined ? { edit } : { edit, placeholder };
1453
+ if (stable) return placeholder === undefined ? { edit } : { edit, placeholder };
1454
+ // 双向一致但稳定窗口未达标:索引可能仍在加载、残缺答案假稳定,
1455
+ // 宁可报可重试的不完整错误,也不把残缺结果当成功写盘。
1456
+ throw new RenameIncompleteError([], []);
1428
1457
  }
1429
1458
  if (expired || missing.length > 0) {
1430
1459
  throw new RenameIncompleteError(missing, extra);
1431
1460
  }
1432
- // 收敛后 rename 仍报出 references 没有的文件:references 快照已过时,
1461
+ // rename 报出 references 没有的文件:references 快照已过时,
1433
1462
  // 继续轮询到重新收敛后再重发 rename 复检(预算耗尽则向上抛)。
1434
1463
  }
1435
- previous = current;
1436
1464
  await sleepWithSignal(renameVerificationTiming.pollMs, request.signal);
1437
- current = toPaths(await referencesRequest());
1465
+ stability = trackStability({
1466
+ previous: stability,
1467
+ paths: toPaths(await referencesRequest()),
1468
+ now: Date.now(),
1469
+ });
1438
1470
  }
1439
1471
  },
1440
1472
  async definition(request: InspectPositionRequest): Promise<InspectLocation[]> {
@@ -192,6 +192,48 @@ export function canonicalizeEdit(edit: WorkspaceEdit): string {
192
192
 
193
193
  const WORD_PATTERN = /[\p{L}\p{N}_$]+/gu;
194
194
 
195
+ /** 两个路径集合是否一致(用于判断 references 结果是否收敛)。 */
196
+ export function samePathSet(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
197
+ if (a.size !== b.size) return false;
198
+ for (const path of a) if (!b.has(path)) return false;
199
+ return true;
200
+ }
201
+
202
+ /** references 文件集合的稳定窗口:连续一致的采样累加计数,集合变化即重启窗口。 */
203
+ export interface StabilityRun {
204
+ /** 本窗口采样到的文件集合(与后续采样比较用)。 */
205
+ readonly paths: ReadonlySet<string>;
206
+ /** 窗口起始时间戳(ms,最后一次集合变化的时刻)。 */
207
+ readonly since: number;
208
+ /** 窗口内连续一致的采样次数(≥1)。 */
209
+ readonly samples: number;
210
+ }
211
+
212
+ /**
213
+ * 用一次新的采样推进稳定窗口。服务器项目加载期间 references 只覆盖已发现
214
+ * 的文件,残缺答案可以连续多次一致——「两次一致」不等于「索引收敛」,所以
215
+ * 窗口还要持续足够时长(见 stabilityAcceptable)。
216
+ */
217
+ export function trackStability(input: {
218
+ previous: StabilityRun | undefined;
219
+ paths: ReadonlySet<string>;
220
+ now: number;
221
+ }): StabilityRun {
222
+ const { previous, paths, now } = input;
223
+ if (previous !== undefined && samePathSet(previous.paths, paths)) {
224
+ return { paths, since: previous.since, samples: previous.samples + 1 };
225
+ }
226
+ return { paths, since: now, samples: 1 };
227
+ }
228
+
229
+ /** 稳定窗口是否达到可接受的稳定度:连续 minSamples 次一致,且集合已持续一致 minStableMs 毫秒。 */
230
+ export function stabilityAcceptable(
231
+ run: StabilityRun,
232
+ input: { now: number; minSamples: number; minStableMs: number },
233
+ ): boolean {
234
+ return run.samples >= input.minSamples && input.now - run.since >= input.minStableMs;
235
+ }
236
+
195
237
  /**
196
238
  * 一行内 `symbol` 的候选位置(0-based,列相对行首):
197
239
  * - `character` 缺省:枚举行内与 `symbol` 相同的词出现位置;