@trim21/personal-pi-extensions 0.1.509 → 0.1.511

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.509",
3
+ "version": "0.1.511",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -100,6 +100,7 @@
100
100
  "dependencies": {
101
101
  "@cortexkit/aft-bridge": "0.55.0",
102
102
  "@mozilla/readability": "^0.6.0",
103
+ "@parcel/watcher": "^2.6.0",
103
104
  "@vscode/tree-sitter-wasm": "^0.3.1",
104
105
  "jsonc-parser": "^3.3.1",
105
106
  "linkedom": "^0.18.0",
@@ -1,14 +1,13 @@
1
1
  /**
2
2
  * LSP 服务器插件契约:每个语言服务器一个 adapter class,向管理器提供统一接口。
3
3
  *
4
- * - findRoot 返回 undefined 表示该文件不应启用此服务器(目录外 / 无项目标记);
4
+ * - root 由管理器按 workingDir 计算(缺省调用 cwd);文件必须位于 root 之内
5
+ * 且命中 include(未配置时全匹配)才会由该服务器处理;
5
6
  * - spawn 返回 undefined 表示服务器不可用(二进制未安装)。
6
7
  */
7
8
 
8
9
  import type { ChildProcessWithoutNullStreams } from "node:child_process";
9
- import { dirname, join } from "node:path";
10
-
11
- import { exists, walkUp } from "./bin.js";
10
+ import { resolve } from "node:path";
12
11
 
13
12
  export interface LspServerHandle {
14
13
  process: ChildProcessWithoutNullStreams;
@@ -29,27 +28,21 @@ export interface LspServerAdapter {
29
28
  readonly kind?: ServerKind;
30
29
  /** 关联的文件扩展名(含点,小写);空数组表示匹配所有文件。 */
31
30
  readonly extensions: readonly string[];
31
+ /**
32
+ * 文件 glob(相对 root 或调用 cwd);缺省/空 = 匹配所有文件。
33
+ * 过滤在管理器的 client 匹配阶段完成,adapter 不再持有 findRoot。
34
+ */
35
+ readonly include?: readonly string[];
36
+ /** 服务器工作目录(即 LSP root):绝对路径或相对调用 cwd 的路径;缺省即 cwd。 */
37
+ readonly workingDir?: string;
32
38
  /** per-server initialize 握手超时(ms);缺省用全局配置 / client 默认。 */
33
39
  readonly startupTimeoutMs?: number;
34
40
  /** per-server 诊断等待时长(ms);缺省用全局配置 / client 默认。 */
35
41
  readonly diagnosticsWaitMs?: number;
36
- findRoot(file: string, cwd: string): Promise<string | undefined>;
37
42
  spawn(root: string, cwd: string): Promise<LspServerHandle | undefined>;
38
43
  }
39
44
 
40
- /**
41
- * 从文件所在目录向上(到 cwd)找项目标记文件;找不到时返回 cwd(opencode
42
- * NearestRoot 宽松语义,保证项目内文件至少有一个 root)。
43
- */
44
- export function nearestRoot(
45
- markers: readonly string[],
46
- file: string,
47
- cwd: string,
48
- ): Promise<string> {
49
- for (const dir of walkUp(dirname(file), cwd)) {
50
- for (const marker of markers) {
51
- if (exists(join(dir, marker))) return Promise.resolve(dir);
52
- }
53
- }
54
- return Promise.resolve(cwd);
45
+ /** 计算服务器 root:workingDir 未配置时即调用 cwd,否则相对 cwd 解析(绝对路径原样)。 */
46
+ export function serverRoot(workingDir: string | undefined, cwd: string): string {
47
+ return workingDir === undefined ? cwd : resolve(cwd, workingDir);
55
48
  }
@@ -9,7 +9,8 @@
9
9
  * 合并结果解析为 `ResolvedLspConfig`(缺省值应用、超时换算为 ms、白名单转
10
10
  * Set),消费方不接触"未配置"歧义;
11
11
  * 没有内置默认服务器,所有服务器均须在配置里定义;
12
- * 配置在每个工具的调用 cwd 下惰性读取;enabled 引用不存在的服务器 id
12
+ * 配置在 session_start 预读并按 cwd 缓存,cwd 变化或 /lsp-reload 时重读;
13
+ * schema 外的未知字段以 warning 上报。enabled 引用不存在的服务器 id
13
14
  * 是配置错误:全局配置在扩展加载(createLspService)时抛错,本地配置在
14
15
  * session 开始预加载时通知,工具调用时校验抛错兜底。disabled 中未注册
15
16
  * 的 id 直接忽略;
@@ -29,7 +30,7 @@ import { type Static, Type } from "typebox";
29
30
  import { Value } from "typebox/value";
30
31
  import type { Hover, WorkspaceEdit } from "vscode-languageserver-types";
31
32
 
32
- import { type LspServerAdapter } from "./adapter.js";
33
+ import { type LspServerAdapter, serverRoot } from "./adapter.js";
33
34
  import {
34
35
  clientDefaults,
35
36
  create,
@@ -45,6 +46,7 @@ import {
45
46
  import { report } from "./diagnostic.js";
46
47
  import {
47
48
  createAdapters,
49
+ matchesInclude,
48
50
  mergeServerRecords,
49
51
  type ServerConfig,
50
52
  serverConfigSchema,
@@ -197,11 +199,35 @@ function isMissingFile(error: unknown): boolean {
197
199
  return (error as { code?: unknown }).code === "ENOENT";
198
200
  }
199
201
 
202
+ /**
203
+ * 收集 schema 之外的未知字段警告(typebox 不校验 additionalProperties,
204
+ * 未知键会静默存活在解析结果里,这里显式报告避免配置写错无感知)。
205
+ */
206
+ function unknownFieldWarnings(config: LspConfig, filePath: string): string[] {
207
+ const warnings: string[] = [];
208
+ const check = (record: object, known: Record<string, unknown>, scope: string): void => {
209
+ for (const key of Object.keys(record)) {
210
+ if (!(key in known)) warnings.push(`${filePath}${scope}: unknown field "${key}" ignored`);
211
+ }
212
+ };
213
+ check(config, lspConfigSchema.properties, "");
214
+ if (config.watch) check(config.watch, watchConfigSchema.properties, " watch");
215
+ for (const [id, server] of Object.entries(config.servers ?? {})) {
216
+ check(server, serverConfigSchema.properties, ` (server "${id}")`);
217
+ }
218
+ return warnings;
219
+ }
220
+
200
221
  /** 读取并解析单个配置文件;文件不存在视为空配置,JSON / typebox 校验错误直接抛出。 */
201
- async function readConfigFile(filePath: string): Promise<LspConfig> {
222
+ async function readConfigFile(
223
+ filePath: string,
224
+ onWarning?: (message: string) => void,
225
+ ): Promise<LspConfig> {
202
226
  try {
203
227
  const raw = await readFile(filePath, "utf8");
204
- return Value.Parse(lspConfigSchema, JSON.parse(raw) as unknown);
228
+ const config = Value.Parse(lspConfigSchema, JSON.parse(raw) as unknown);
229
+ for (const message of unknownFieldWarnings(config, filePath)) onWarning?.(message);
230
+ return config;
205
231
  } catch (error) {
206
232
  if (isMissingFile(error)) return {};
207
233
  throw error;
@@ -209,10 +235,12 @@ async function readConfigFile(filePath: string): Promise<LspConfig> {
209
235
  }
210
236
 
211
237
  /** 同步版 readConfigFile(扩展加载时校验全局配置用)。 */
212
- function readConfigFileSync(filePath: string): LspConfig {
238
+ function readConfigFileSync(filePath: string, onWarning?: (message: string) => void): LspConfig {
213
239
  try {
214
240
  const raw = readFileSync(filePath, "utf8");
215
- return Value.Parse(lspConfigSchema, JSON.parse(raw) as unknown);
241
+ const config = Value.Parse(lspConfigSchema, JSON.parse(raw) as unknown);
242
+ for (const message of unknownFieldWarnings(config, filePath)) onWarning?.(message);
243
+ return config;
216
244
  } catch (error) {
217
245
  if (isMissingFile(error)) return {};
218
246
  throw error;
@@ -267,14 +295,15 @@ export function mergeConfig(globalConfig: LspConfig, localConfig: LspConfig): Ls
267
295
  };
268
296
  }
269
297
 
270
- /** 读取全局 + 本地配置,合并并解析为生效配置。 */
298
+ /** 读取全局 + 本地配置,合并并解析为生效配置;未知字段警告经 onWarning 上报。 */
271
299
  export async function loadLspConfig(
272
300
  cwd: string,
273
301
  globalConfigPath: string = join(homedir(), ".pi", "agent", "lsp.json"),
302
+ onWarning?: (message: string) => void,
274
303
  ): Promise<ResolvedLspConfig> {
275
304
  const [globalConfig, localConfig] = await Promise.all([
276
- readConfigFile(globalConfigPath),
277
- readConfigFile(join(cwd, ".pi", "lsp.json")),
305
+ readConfigFile(globalConfigPath, onWarning),
306
+ readConfigFile(join(cwd, ".pi", "lsp.json"), onWarning),
278
307
  ]);
279
308
  return resolveConfig(mergeConfig(globalConfig, localConfig));
280
309
  }
@@ -311,6 +340,10 @@ interface LspState {
311
340
  cwd: string | undefined;
312
341
  /** client → adapter 扩展名集合,fan-out 时按扩展名过滤。 */
313
342
  clientExtensions: Map<LspClient, readonly string[]>;
343
+ /** 缓存的生效配置(configCwd 一致时复用;cwd 变化或 reload 后重读)。 */
344
+ config: ResolvedLspConfig | undefined;
345
+ /** config 的加载 cwd。 */
346
+ configCwd: string | undefined;
314
347
  }
315
348
 
316
349
  /** 渲染 LSP status 文本的回调(传入 undefined 表示清除)。 */
@@ -426,6 +459,10 @@ export interface LspServiceStartupOptions {
426
459
  notifyIntervalMs?: number;
427
460
  /** 会话级通知:启动失败时主动上报,不依赖触发请求恰好携带 notify。 */
428
461
  notify?: ExtensionUIContext["notify"];
462
+ /** session_start 预读的生效配置:直接注入缓存,避免首个工具调用重复读盘。 */
463
+ initialConfig?: ResolvedLspConfig;
464
+ /** initialConfig 对应的 cwd。 */
465
+ initialCwd?: string;
429
466
  }
430
467
 
431
468
  export function createLspService(
@@ -453,10 +490,23 @@ export function createLspService(
453
490
  servers: new Map(),
454
491
  cwd: undefined,
455
492
  clientExtensions: new Map(),
493
+ config: startupOptions?.initialConfig,
494
+ configCwd: startupOptions?.initialCwd,
456
495
  };
457
496
 
458
497
  let renderStatus: StatusRenderer | undefined;
459
498
 
499
+ /** 配置缓存:同一 cwd 内复用;cwd 变化或 reload 清缓存后重读。 */
500
+ async function currentConfig(cwd: string): Promise<ResolvedLspConfig> {
501
+ if (state.config && state.configCwd === cwd) return state.config;
502
+ const config = await loadLspConfig(cwd, globalConfigPath, (message) =>
503
+ sessionNotify?.(message, "warning"),
504
+ );
505
+ state.config = config;
506
+ state.configCwd = cwd;
507
+ return config;
508
+ }
509
+
460
510
  // ── 工作区文件监听(watcher)───────────────────────────────────────────────
461
511
 
462
512
  let watcher: WorkspaceWatcher | undefined;
@@ -526,7 +576,7 @@ export function createLspService(
526
576
  /** 首个 client 建立 / 会话 cwd 变化时(重)建监听器;watch.enabled: false 时不启动。 */
527
577
  async function ensureWatcher(cwd: string, notify?: ExtensionUIContext["notify"]): Promise<void> {
528
578
  if (state.closing || state.disabled) return;
529
- const config = await loadLspConfig(cwd, globalConfigPath);
579
+ const config = await currentConfig(cwd);
530
580
  const watch = config.watch;
531
581
  if (!watch.enabled) return;
532
582
  if (watcher && watcherCwd === cwd) return;
@@ -612,7 +662,7 @@ export function createLspService(
612
662
  }
613
663
  if (state.closing || state.disabled) return [];
614
664
  if (!containsPath(file, cwd)) return [];
615
- const config = await loadLspConfig(cwd, globalConfigPath);
665
+ const config = await currentConfig(cwd);
616
666
  const timeout = {
617
667
  diagnosticsDebounceMs: config.diagnosticsDebounceMs,
618
668
  diagnosticsDocumentWaitTimeoutMs: config.diagnosticsDocumentWaitTimeoutMs,
@@ -633,8 +683,9 @@ export function createLspService(
633
683
  for (const adapter of active) {
634
684
  if (adapterFilter && !adapterFilter(adapter)) continue;
635
685
  if (adapter.extensions.length > 0 && !adapter.extensions.includes(extension)) continue;
636
- const root = await adapter.findRoot(file, cwd);
637
- if (!root) continue;
686
+ const root = serverRoot(adapter.workingDir, cwd);
687
+ if (!containsPath(file, root)) continue;
688
+ if (!matchesInclude(adapter.include ?? [], file, root, cwd)) continue;
638
689
  const key = root + adapter.id;
639
690
  const failedAt = state.brokenFailAt.get(key);
640
691
  if (failedAt !== undefined) {
@@ -925,6 +976,9 @@ export function createLspService(
925
976
 
926
977
  async function reload(serverID: string): Promise<void> {
927
978
  state.closing = true;
979
+ // 配置缓存失效:下次工具调用按当前 cwd 重新读盘,让配置修改生效
980
+ state.config = undefined;
981
+ state.configCwd = undefined;
928
982
  const targets = state.clients.filter((client) => client.serverID === serverID);
929
983
  await Promise.all(targets.map((client) => client.shutdown())).catch(() => {
930
984
  // 个别进程退出失败不阻止清理流程
@@ -1036,7 +1090,13 @@ export function createLspManager(
1036
1090
  service = undefined;
1037
1091
  }
1038
1092
  try {
1039
- const config = await loadLspConfig(ctx.cwd, options?.globalConfigPath);
1093
+ const config = await loadLspConfig(ctx.cwd, options?.globalConfigPath, (message) => {
1094
+ try {
1095
+ ctx.ui.notify(message, "warning");
1096
+ } catch {
1097
+ /* 旧会话 ctx 已失效:通知无处可去 */
1098
+ }
1099
+ });
1040
1100
  validateConfig(config, options?.adapters);
1041
1101
  if (enabledServerCount(config, options?.adapters) === 0) return;
1042
1102
  // 下面两个闭包被 service 长期持有,可能在会话被替换或 reload 后仍触发
@@ -1053,6 +1113,9 @@ export function createLspManager(
1053
1113
  /* 旧会话 ctx 已失效或 UI 不可用:通知无处可去 */
1054
1114
  }
1055
1115
  },
1116
+ // session_start 已读盘的配置直接注入缓存,首个工具调用无需重复读盘
1117
+ initialConfig: config,
1118
+ initialCwd: ctx.cwd,
1056
1119
  });
1057
1120
  // footer status 显示当前所有 LSP server 状态(无 UI 时不显示)
1058
1121
  next.attachStatus((text) => {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 配置驱动的 LSP 服务器:一份 JSON 配置定义一个语言服务器(bin/args/rootMarkers/
2
+ * 配置驱动的 LSP 服务器:一份 JSON 配置定义一个语言服务器(bin/args/workingDir/
3
3
  * languageId/超时等),替代为每个语言写一个 adapter class。
4
4
  *
5
5
  * 配置文件沿用 lsp.json(全局 ~/.pi/agent/lsp.json + 本地 <cwd>/.pi/lsp.json):
@@ -7,6 +7,9 @@
7
7
  * 覆盖、新增 id、全局其余保留),之后受顶层 enabled/disabled 列表过滤。
8
8
  * 没有内置默认服务器:未配置 servers 时不启动任何服务器。
9
9
  *
10
+ * 不做向上查找项目根的猜测:root 即调用 cwd(或 per-server workingDir),
11
+ * 文件归属由「文件位于 root 之内」+ include glob 判定。
12
+ *
10
13
  * executable 发现统一由用户配置:bin 支持绝对路径 / 项目工作区
11
14
  * (node_modules/.bin、.venv/bin、venv/bin)/ PATH,不再内置各语言的
12
15
  * 特殊探测逻辑(tsserver 路径、venv python 等)。
@@ -20,12 +23,7 @@ import { promisify } from "node:util";
20
23
  import { minimatch } from "minimatch";
21
24
  import { type Static, Type } from "typebox";
22
25
 
23
- import {
24
- type LspServerAdapter,
25
- type LspServerHandle,
26
- nearestRoot,
27
- type ServerKind,
28
- } from "./adapter.js";
26
+ import { type LspServerAdapter, type LspServerHandle, type ServerKind } from "./adapter.js";
29
27
  import { exists, findBinaryInWorkspace, which } from "./bin.js";
30
28
  import { spawnProcess } from "./launch.js";
31
29
 
@@ -34,13 +32,14 @@ export const serverConfigSchema = Type.Object({
34
32
  include: Type.Optional(Type.Array(Type.String())),
35
33
  /** 服务器类型:language(真语言服务器,缺省)或 linter(只实现 LSP 协议的 lint)。 */
36
34
  kind: Type.Optional(Type.Union([Type.Literal("language"), Type.Literal("linter")])),
37
- /** 项目根标记文件(从文件目录向上查找);缺省用调用 cwd 作为根。 */
38
- rootMarkers: Type.Optional(Type.Array(Type.String())),
35
+ /**
36
+ * 服务器工作目录(即 LSP root):绝对路径或相对调用 cwd 的路径;缺省即 cwd。
37
+ * 文件必须位于该目录内才会由本服务器处理;spawn 工作目录与 rootUri 均用它。
38
+ */
39
+ workingDir: Type.Optional(Type.String()),
39
40
  /** 可执行文件:绝对路径、相对调用 cwd 的路径,或名字(项目工作区优先,PATH 兜底)。 */
40
41
  bin: Type.Optional(Type.String()),
41
42
  args: Type.Optional(Type.Array(Type.String())),
42
- /** 启动工作目录,支持 {root} / {cwd} 模板;缺省 {root}。 */
43
- cwd: Type.Optional(Type.String()),
44
43
  /**
45
44
  * 追加到子进程的环境变量。string 值支持 {root} / {cwd} 模板与 ${VAR} 引用;
46
45
  * {sh: [...]} 在启动时执行命令(argv 直接执行、不经 shell),stdout trim 后作为值,
@@ -173,7 +172,7 @@ async function resolveBinary(bin: string, root: string, cwd: string): Promise<st
173
172
  * 支持 `!` 否定模式排除;多 pattern 数组拆开判断(任意 positive 命中且
174
173
  * 不被任何 negative 排除),避免库对混合数组的语义差异。
175
174
  */
176
- function matchesInclude(
175
+ export function matchesInclude(
177
176
  patterns: readonly string[],
178
177
  file: string,
179
178
  root: string,
@@ -196,11 +195,13 @@ function matchesInclude(
196
195
  return candidates.some((candidate) => matches(candidate));
197
196
  }
198
197
 
199
- /** 由配置构建的通用 adapter;include 精确过滤在 findRoot 完成,extensions 不设扩展名过滤。 */
198
+ /** 由配置构建的通用 adapter;include 过滤在 lsp.ts 的 client 匹配阶段完成,extensions 不设扩展名过滤。 */
200
199
  export class ConfigAdapter implements LspServerAdapter {
201
200
  readonly id: string;
202
201
  readonly kind: ServerKind;
203
202
  readonly extensions: readonly string[] = [];
203
+ readonly include: readonly string[];
204
+ readonly workingDir: string | undefined;
204
205
  readonly startupTimeoutMs: number | undefined;
205
206
  readonly diagnosticsWaitMs: number | undefined;
206
207
  readonly config: ServerConfig;
@@ -209,27 +210,21 @@ export class ConfigAdapter implements LspServerAdapter {
209
210
  this.id = id;
210
211
  this.config = config;
211
212
  this.kind = config.kind ?? "language";
213
+ this.include = config.include ?? [];
214
+ this.workingDir = config.workingDir;
212
215
  this.startupTimeoutMs = config.startupTimeoutMs;
213
216
  this.diagnosticsWaitMs = config.diagnosticsWaitMs;
214
217
  }
215
218
 
216
- findRoot(file: string, cwd: string): Promise<string | undefined> {
217
- const root = nearestRoot(this.config.rootMarkers ?? [], file, cwd);
218
- return root.then((resolved) =>
219
- matchesInclude(this.config.include ?? [], file, resolved, cwd) ? resolved : undefined,
220
- );
221
- }
222
-
223
219
  async spawn(root: string, cwd: string): Promise<LspServerHandle | undefined> {
224
220
  const bin = this.config.bin;
225
221
  if (!bin) return undefined;
226
222
  const resolved = await resolveBinary(bin, root, cwd);
227
223
  if (!resolved) return undefined;
228
- const spawnCwd = resolveTemplate(this.config.cwd ?? "{root}", root, cwd);
229
224
  const env = await resolveEnv(this.config.env, root, cwd);
230
225
  return {
231
226
  process: spawnProcess(resolved, this.config.args ?? [], {
232
- cwd: spawnCwd,
227
+ cwd: root,
233
228
  env,
234
229
  }),
235
230
  initialization: interpolateEnvDeep(this.config.initializationOptions, {
@@ -1,25 +1,29 @@
1
1
  /**
2
- * 工作区文件监听器:单个递归 fs.watch + 去抖批量回调。
2
+ * 工作区文件监听器:@parcel/watcher 事件源 + 去抖批量回调。
3
3
  *
4
- * - 事件源用 `node:fs/promises` `watch(dir, { recursive: true, signal })`,
5
- * 不引入 chokidar(与仓库"FS 一律用 node:fs/promises"约定一致);
6
- * - create / delete / rename 在底层都表现为 `rename`,内容改动为 `change`,
7
- * LSP created / changed / deleted 类型由 `lstat` 判定;
4
+ * - 事件源用 @parcel/watcher(原生实现,Linux 正确管理 inotify 生命周期,
5
+ * 忽略目录不建 watch,事件已区分 create / update / delete),
6
+ * 映射为 LSP created / changed / deleted;
7
+ * - parcel 不报告目标是否目录:非删除事件用 lstat 学习已知目录集合并丢弃
8
+ * 目录事件;删除事件据此标记 isDirectory,由上层对驻留文档补 deleted;
8
9
  * - 尾部去抖(缺省 300ms)合并短时洪峰,最长 flushMs(缺省 1s)强制清批;
9
10
  * - 内置忽略 `node_modules` / `.git` / `dist` / `build` / `.venv` / `venv` /
10
- * `target` / `coverage`,配置可追加;
11
- * - 忽略列表同时下传给 `fs.watch` 的 `ignore` 选项做内核层排除(Node >= 24.14 /
12
- * 26 的 recursive watch 对命中路径不创建 inotify watch,避免大型 `.git` 等
13
- * 子树耗尽 watch 配额导致 ENOSPC 崩溃);运行环境不支持时静默回退为事件层过滤;
11
+ * `target` / `coverage`,配置可追加;忽略列表下传 parcel 的 `ignore`
12
+ * 选项做后端层排除(忽略目录不递归、不建 watch),事件层再用 minimatch
13
+ * 过滤一遍兜底语义差异;
14
14
  * - 单批超过 maxBatch(缺省 500)截断并回调 onTruncated 提示一次;
15
- * - 目录事件默认丢弃;目录被删除时上报(isDirectory: true),由上层对其中
16
- * 的驻留文档补 deleted 事件。监听器启动或运行期失败时调用 onError 降级,
17
- * 不抛错。
15
+ * - 目录事件默认丢弃;目录被删除时上报(isDirectory: true)。监听器启动或
16
+ * 运行期失败时调用 onError 降级,不抛错。
18
17
  */
19
18
 
20
- import { lstat, watch, type WatchOptions as FsWatchOptions } from "node:fs/promises";
21
- import { join, normalize, relative, sep } from "node:path";
19
+ import { lstat } from "node:fs/promises";
20
+ import { normalize, relative, sep } from "node:path";
22
21
 
22
+ import {
23
+ type AsyncSubscription,
24
+ type Event as ParcelWatcherEvent,
25
+ subscribe,
26
+ } from "@parcel/watcher";
23
27
  import { minimatch } from "minimatch";
24
28
 
25
29
  export type FileChangeType = "created" | "changed" | "deleted";
@@ -68,34 +72,6 @@ function isIgnored(path: string, dir: string, patterns: string[]): boolean {
68
72
  return patterns.some((pattern) => minimatch(candidate, pattern));
69
73
  }
70
74
 
71
- /**
72
- * 为内核层 `fs.watch` 的 `ignore` 选项派生 pattern:为每条「目录内容」形态的
73
- * glob(尾部为通配目录段)追加「目录本身」形态并去重——Node 内部按相对路径
74
- * 对每个子项逐一匹配,只给内容形态时忽略目录本身仍会建 watch。
75
- *
76
- * 与事件层 minimatch 的语义差异(Node 内部 matcher 固定 `matchBase: true`、
77
- * `nonegate: true`):无斜杠 pattern 按 basename 匹配任意层级,`!` 否定在
78
- * 内核层不生效。内核层只会少产生事件,差异部分仍由事件层兜底。
79
- */
80
- function kernelIgnorePatterns(patterns: readonly string[]): string[] {
81
- const derived = new Set<string>();
82
- for (const pattern of patterns) {
83
- derived.add(pattern);
84
- if (pattern.endsWith("/**")) derived.add(pattern.slice(0, -3));
85
- }
86
- return [...derived];
87
- }
88
-
89
- /**
90
- * `ignore` 选项的运行时支持始于 Node 24.14 / 26,@types/node 24.x 尚未声明;
91
- * 同时需剔除 fs 模块 WatchOptions.encoding 中的 "buffer" 字面量,否则不可赋给
92
- * fs/promises watch 返回 string filename 的重载。
93
- */
94
- type FsWatchOptionsWithIgnore = Omit<FsWatchOptions, "encoding"> & {
95
- encoding?: BufferEncoding;
96
- ignore?: readonly string[];
97
- };
98
-
99
75
  /**
100
76
  * 启动对 dir 的递归监听。onBatch 收到去抖合并后的批次;stop 后不再回调。
101
77
  * 返回的 promise 只在监听器无法建立(目录不存在等)时 reject——运行期
@@ -109,12 +85,14 @@ export function watchWorkspace(
109
85
  const debounceMs = options?.debounceMs ?? 300;
110
86
  const flushMs = options?.flushMs ?? 1_000;
111
87
  const maxBatch = options?.maxBatch ?? 500;
112
- const ignorePatterns = kernelIgnorePatterns([...DEFAULT_IGNORE, ...(options?.ignore ?? [])]);
113
- const abort = new AbortController();
88
+ const ignorePatterns = [...DEFAULT_IGNORE, ...(options?.ignore ?? [])];
114
89
 
115
- // stat 成功事件学习已知目录,目录被删除时据此标记 isDirectory
90
+ // 从非删除事件学习已知目录,目录被删除时据此标记 isDirectory
116
91
  const seenDirectories = new Set<string>();
117
92
 
93
+ let stopped = false;
94
+ let subscription: AsyncSubscription | undefined;
95
+
118
96
  let pending: FileChange[] = [];
119
97
  let flushTimer: ReturnType<typeof setTimeout> | undefined;
120
98
  let maxTimer: ReturnType<typeof setTimeout> | undefined;
@@ -139,17 +117,14 @@ export function watchWorkspace(
139
117
  };
140
118
 
141
119
  const push = (change: FileChange): void => {
120
+ if (stopped) return;
142
121
  if (pending.length === 0) maxTimer = setTimeout(flush, flushMs);
143
122
  pending.push(change);
144
123
  if (flushTimer) clearTimeout(flushTimer);
145
124
  flushTimer = setTimeout(flush, debounceMs);
146
125
  };
147
126
 
148
- const classify = async (
149
- filename: string,
150
- eventType: "rename" | "change",
151
- ): Promise<FileChange | undefined> => {
152
- const path = normalize(join(dir, filename));
127
+ const classify = async (path: string, type: FileChangeType): Promise<FileChange | undefined> => {
153
128
  if (path === dir) return undefined;
154
129
  let isDirectory = false;
155
130
  let exists = true;
@@ -161,50 +136,65 @@ export function watchWorkspace(
161
136
  exists = false;
162
137
  if (seenDirectories.delete(path)) isDirectory = true;
163
138
  }
164
- if (eventType === "change") {
165
- if (!exists) return { path, type: "deleted", isDirectory };
166
- if (isDirectory) return undefined;
167
- return { path, type: "changed", isDirectory };
168
- }
169
- // rename:创建 / 删除 / 移入移出
139
+ if (type === "deleted") return { path, type, isDirectory };
140
+ // 事件与 lstat 之间的竞态:目标已消失按删除处理
170
141
  if (!exists) return { path, type: "deleted", isDirectory };
171
142
  if (isDirectory) return undefined;
172
- return { path, type: "created", isDirectory };
143
+ return { path, type, isDirectory };
173
144
  };
174
145
 
175
- const consumer = (async () => {
176
- try {
177
- const watchOptions: FsWatchOptionsWithIgnore = {
178
- recursive: true,
179
- signal: abort.signal,
180
- ignore: ignorePatterns,
181
- };
182
- const iterator = watch(dir, watchOptions);
183
- for await (const event of iterator) {
184
- if (!event.filename) continue;
185
- const change = await classify(
186
- event.filename,
187
- event.eventType === "change" ? "change" : "rename",
188
- );
146
+ const handleError = (error: unknown): void => {
147
+ if (stopped) return;
148
+ options?.onError?.(`workspace watcher failed for ${dir}: ${String(error)}`);
149
+ };
150
+
151
+ const handleEvents = (error: Error | null, events: ParcelWatcherEvent[]): void => {
152
+ if (error) {
153
+ handleError(error);
154
+ return;
155
+ }
156
+ void (async () => {
157
+ for (const event of events) {
158
+ if (stopped) return;
159
+ const path = normalize(event.path);
160
+ const type: FileChangeType =
161
+ event.type === "create" ? "created" : event.type === "update" ? "changed" : "deleted";
162
+ const change = await classify(path, type);
189
163
  if (!change) continue;
190
164
  if (isIgnored(change.path, dir, ignorePatterns)) continue;
191
165
  push(change);
192
166
  }
167
+ })();
168
+ };
169
+
170
+ async function startSubscription(): Promise<void> {
171
+ try {
172
+ subscription = await subscribe(dir, handleEvents, { ignore: ignorePatterns });
173
+ if (stopped) {
174
+ await subscription.unsubscribe();
175
+ subscription = undefined;
176
+ }
193
177
  } catch (error) {
194
- if (abort.signal.aborted) return;
195
- options?.onError?.(`workspace watcher failed for ${dir}: ${String(error)}`);
178
+ handleError(error);
196
179
  }
197
- })();
180
+ }
181
+
182
+ const started = startSubscription();
198
183
 
199
184
  // 目录不存在 / 无权限等启动期问题在这里暴露,让调用方可以降级
200
185
  return lstat(dir).then(() => ({
201
186
  async stop(): Promise<void> {
202
- abort.abort();
203
- try {
204
- await consumer;
205
- } catch {
206
- // 监听器已因 abort 正常退出
187
+ stopped = true;
188
+ clearTimers();
189
+ if (subscription) {
190
+ try {
191
+ await subscription.unsubscribe();
192
+ } catch {
193
+ // 订阅已失败或重复 stop
194
+ }
195
+ subscription = undefined;
207
196
  }
197
+ await started;
208
198
  },
209
199
  }));
210
200
  }