@zhushanwen/pi-ext-guards 0.3.0 → 0.4.1

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": "@zhushanwen/pi-ext-guards",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Shared runtime-environment guards for Pi extensions — oncePerProcess(key, fn) dedupes cross-session side-effect operations against pi factory re-invocation and handler accumulation (shared library, not a Pi extension, zero dependencies).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -3,7 +3,7 @@
3
3
  // guardStaleCtx 单测——验收条款逐条对应(crash-resilience u1-ext-guard):
4
4
  // a. 前置代际检查:isCtxStale=true → fn 不执行、stale 静默降级(返回 undefined)
5
5
  // b. 非 stale 错误原样上抛(守卫不吞真实 bug,同步 throw 与 async rejection 双面)
6
- // c. onStale 回调:前置命中无参调用;文案分诊命中携带错误调用;缺省走 XYZ_AGENT_DEBUG=1 stderr
6
+ // c. onStale 回调:前置命中无参调用;文案分诊命中携带错误调用;缺省走 TAIJI_AGENT_DEBUG=1 stderr
7
7
  // d. 文案子串匹配:STALE_CTX_MARKER 命中即静默(isCtxStale=false 时文案兜底仍生效)
8
8
  // e. async fn:stale rejection → resolve undefined;非 stale rejection → 原样 reject
9
9
  // f. 正常路径:返回值/resolve 值原样透传(守卫不改变正常路径,A2 负面验证的单测面)
@@ -140,8 +140,8 @@ describe("guardStaleCtx async fn(F2 形态异步面,scheduler tick 迁移形
140
140
  });
141
141
 
142
142
  describe("guardStaleCtx onStale 缺省(默认 debug 日志)", () => {
143
- it("XYZ_AGENT_DEBUG=1:stderr 写入守卫降级行(含 label 与错误文案)", () => {
144
- vi.stubEnv("XYZ_AGENT_DEBUG", "1");
143
+ it("TAIJI_AGENT_DEBUG=1:stderr 写入守卫降级行(含 label 与错误文案)", () => {
144
+ vi.stubEnv("TAIJI_AGENT_DEBUG", "1");
145
145
  const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
146
146
 
147
147
  guardStaleCtx(() => {
@@ -154,8 +154,8 @@ describe("guardStaleCtx onStale 缺省(默认 debug 日志)", () => {
154
154
  expect(line).toContain(STALE_CTX_MARKER);
155
155
  });
156
156
 
157
- it("XYZ_AGENT_DEBUG 未设:静默(不写 stderr)", () => {
158
- vi.stubEnv("XYZ_AGENT_DEBUG", "");
157
+ it("TAIJI_AGENT_DEBUG 未设:静默(不写 stderr)", () => {
158
+ vi.stubEnv("TAIJI_AGENT_DEBUG", "");
159
159
  const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
160
160
 
161
161
  guardStaleCtx(() => {
@@ -0,0 +1,109 @@
1
+ // src/__tests__/predicates.test.ts
2
+ //
3
+ // isRecord / isEnoentError / isSubagentProcess 单测(ext-simplify-17 D3/D2/D4 新导出):
4
+ // - isRecord 排数组严版:数组必须 false(钉值,防回退到允数组宽版)
5
+ // - isEnoentError scheduler 严版:仅 Error 子类 + code === "ENOENT" 才 true,
6
+ // 非 Error 对象带 code 是宽版才接受的形态,严版必须 false
7
+ // - isSubagentProcess 三态 + env 注入隔离 + 旧键宁缺勿污钉值(D4 重锚定)
8
+
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import { isEnoentError, isRecord, isSubagentProcess } from "../index.ts";
12
+
13
+ describe("isRecord", () => {
14
+ it("对象 true", () => {
15
+ expect(isRecord({})).toBe(true);
16
+ expect(isRecord({ key: "value" })).toBe(true);
17
+ });
18
+
19
+ it("null false", () => {
20
+ expect(isRecord(null)).toBe(false);
21
+ });
22
+
23
+ it("undefined false", () => {
24
+ expect(isRecord(undefined)).toBe(false);
25
+ });
26
+
27
+ it("数组 false(排数组钉值——Record 语义不含数组)", () => {
28
+ expect(isRecord([])).toBe(false);
29
+ expect(isRecord([{ a: 1 }])).toBe(false);
30
+ });
31
+
32
+ it("字符串 false", () => {
33
+ expect(isRecord("text")).toBe(false);
34
+ });
35
+ });
36
+
37
+ describe("isEnoentError", () => {
38
+ it("Error 带 code=ENOENT true(Node fs 错误的构造形态)", () => {
39
+ expect(isEnoentError(Object.assign(new Error("no such file"), { code: "ENOENT" }))).toBe(
40
+ true,
41
+ );
42
+ });
43
+
44
+ it("普通 Error false(无 code 属性)", () => {
45
+ expect(isEnoentError(new Error("x"))).toBe(false);
46
+ });
47
+
48
+ it("code 为其他值 false", () => {
49
+ expect(isEnoentError(Object.assign(new Error("denied"), { code: "EACCES" }))).toBe(false);
50
+ });
51
+
52
+ it("非 Error 对象带 code=ENOENT false(严版:宽版才接受的形态)", () => {
53
+ expect(isEnoentError({ code: "ENOENT" })).toBe(false);
54
+ });
55
+
56
+ it("undefined false", () => {
57
+ expect(isEnoentError(undefined)).toBe(false);
58
+ });
59
+ });
60
+
61
+ describe("isSubagentProcess", () => {
62
+ it("TAIJI_AGENT_SUBAGENT=1 命中(引擎 spawn 链恒注入的现行标记)", () => {
63
+ expect(isSubagentProcess({ TAIJI_AGENT_SUBAGENT: "1" })).toBe(true);
64
+ });
65
+
66
+ it("值非 \"1\" 不命中(标记必须是引擎注入的字面 \"1\")", () => {
67
+ expect(isSubagentProcess({ TAIJI_AGENT_SUBAGENT: "0" })).toBe(false);
68
+ expect(isSubagentProcess({ TAIJI_AGENT_SUBAGENT: "" })).toBe(false);
69
+ expect(isSubagentProcess({ TAIJI_AGENT_SUBAGENT: "true" })).toBe(false);
70
+ });
71
+
72
+ it("缺失不命中(顶层会话 → 消费方不降级)", () => {
73
+ expect(isSubagentProcess({})).toBe(false);
74
+ });
75
+
76
+ it("env 参数注入隔离:传入的 env 与宿主 process.env 无关(宿主即使带标记,空 env 也不命中)", () => {
77
+ const saved = process.env.TAIJI_AGENT_SUBAGENT;
78
+ try {
79
+ process.env.TAIJI_AGENT_SUBAGENT = "1";
80
+ // 显式传 env:宿主的标记不渗透,判据只看参数
81
+ expect(isSubagentProcess({})).toBe(false);
82
+ expect(isSubagentProcess({ TAIJI_AGENT_SUBAGENT: "1" })).toBe(true);
83
+ } finally {
84
+ if (saved === undefined) delete process.env.TAIJI_AGENT_SUBAGENT;
85
+ else process.env.TAIJI_AGENT_SUBAGENT = saved;
86
+ }
87
+ });
88
+
89
+ it("缺省参数读 process.env(不传 env 时以宿主环境为判据)", () => {
90
+ const saved = process.env.TAIJI_AGENT_SUBAGENT;
91
+ try {
92
+ process.env.TAIJI_AGENT_SUBAGENT = "1";
93
+ expect(isSubagentProcess()).toBe(true);
94
+ delete process.env.TAIJI_AGENT_SUBAGENT;
95
+ expect(isSubagentProcess()).toBe(false);
96
+ } finally {
97
+ if (saved === undefined) delete process.env.TAIJI_AGENT_SUBAGENT;
98
+ else process.env.TAIJI_AGENT_SUBAGENT = saved;
99
+ }
100
+ });
101
+
102
+ it("旧 PI_SUBAGENT_* 身份键不再命中([HISTORICAL] D4 重锚前两键任一存在即命中;引擎协议化后无写入方,宁缺勿污)", () => {
103
+ expect(isSubagentProcess({ PI_SUBAGENT_ROOT_SESSION_ID: "s1" })).toBe(false);
104
+ expect(isSubagentProcess({ PI_SUBAGENT_SELF_RECORD_ID: "r1" })).toBe(false);
105
+ expect(isSubagentProcess({ PI_SUBAGENT_ROOT_SESSION_ID: "s1", PI_SUBAGENT_SELF_RECORD_ID: "r1" })).toBe(
106
+ false,
107
+ );
108
+ });
109
+ });
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // @zhushanwen/pi-ext-guards —— pi 运行环境守卫共享库(不是 Pi extension,零依赖纯函数,
4
4
  // 无 pi SDK peerDep——供任意 @zhushanwen/pi-* 包与子代理核心消费)。
5
5
  //
6
- // 背景与设计依据:docs/design/file-lock-unification-and-reaper-sink.md §2.2 P3 / §3.2 D3。
6
+ // 背景与设计依据:docs/architecture/file-lock-unification-and-reaper-sink.md §2.2 P3 / §3.2 D3。
7
7
  // pi 的 extension 缓存按 cwd 失效:switch_session 时 cwd 不变则 factory 被二次调用且
8
8
  // handler 累积注册——session_start handler 的真实派发语义是「每 session × factory
9
9
  // 调用次数」,不是「每 session 一次」。handler 体内的跨 session 副作用操作(写非本
@@ -83,9 +83,61 @@ export function toErrorMessage(e: unknown): string {
83
83
  return e instanceof Error ? e.message : String(e);
84
84
  }
85
85
 
86
+ /**
87
+ * 判断 value 是否为非 null、非数组的 object(`Record` 语义本义:可按字符串键安全取值)。
88
+ *
89
+ * canonical 来自 ext-simplify-17 D3(排数组严版,设计文档
90
+ * docs/architecture/ext-simplify-17-shared-extraction.md §3.1)。
91
+ */
92
+ export function isRecord(value: unknown): value is Record<string, unknown> {
93
+ return typeof value === "object" && value !== null && !Array.isArray(value);
94
+ }
95
+
96
+ /**
97
+ * 判断 err 是否为 Node fs 的 ENOENT 错误(`Error` 实例且 `code === "ENOENT"`)。
98
+ *
99
+ * canonical 来自 ext-simplify-17 D2(scheduler 严版,`"code" in err` 收窄避免
100
+ * `as Record` 强转——taste/no-unsafe-cast)。
101
+ */
102
+ export function isEnoentError(err: unknown): boolean {
103
+ return err instanceof Error && "code" in err && err.code === "ENOENT";
104
+ }
105
+
106
+ // ── subagent 进程判据(isSubagentProcess,ext-simplify-17 D4)────────────
107
+ //
108
+ // 背景:extension 需要知道「当前 pi 进程是否引擎链上的 subagent 子进程」来决定降级
109
+ // (base-tool-enhance background 降级——bte background 生命周期设计的 D14 项,
110
+ // commit 8599abd0c,该设计未在现行 docs 留档 / smart-context R6 不注册工具,
111
+ // 规则定义见 smart-context src/index.ts R 规则族注释)。判据锚
112
+ // TAIJI_AGENT_SUBAGENT=1——subagent-engine-sdk env.ts 的 buildEngineChildEnv 对每个引擎
113
+ // 子进程恒注入(L0 层最后写入),再经引擎内二次 spawn(buildOutboundChildEnv 全量
114
+ // 继承)传到 subagent 内的 bash。旧判据(PI_SUBAGENT_ROOT_SESSION_ID /
115
+ // PI_SUBAGENT_SELF_RECORD_ID 两键)在引擎协议化后已无写入方(20260914 探针证实,
116
+ // 报告 .tmp/dev-flow/ext-simplify-17-d4-probe.md),判据失效态由 D4 重锚收敛于此。
117
+
118
+ /** subagent 进程标记 env 键(引擎 spawn 链恒注入 "1",subagent-engine-sdk env.ts L0)。 */
119
+ export const SUBAGENT_MARKER_ENV = "TAIJI_AGENT_SUBAGENT";
120
+
121
+ /**
122
+ * 判断当前进程是否引擎链上的 subagent 子进程。
123
+ *
124
+ * 判据 = `env[SUBAGENT_MARKER_ENV] === "1"`(canonical 来自 ext-simplify-17 D4,
125
+ * 设计文档 docs/architecture/ext-simplify-17-shared-extraction.md §3.1)。口径
126
+ * 「宁缺勿污」:只有引擎 spawn 链上的真 subagent 才命中——人工 export 旧
127
+ * PI_SUBAGENT_ROOT_SESSION_ID / PI_SUBAGENT_SELF_RECORD_ID 身份键不再算(两键现
128
+ * 无写入方,见分节注释)。
129
+ *
130
+ * @param env env 快照;缺省读 process.env,显式传入即与宿主进程环境完全隔离(测试注入用)
131
+ */
132
+ export function isSubagentProcess(
133
+ env: Record<string, string | undefined> = process.env,
134
+ ): boolean {
135
+ return env[SUBAGENT_MARKER_ENV] === "1";
136
+ }
137
+
86
138
  // ── stale ctx 守卫(guardStaleCtx,崩溃韧性 D1)────────────────────────
87
139
  //
88
- // 背景(docs/design/crash-resilience.md §3.3 D1 / §2.2 事件 E1):pi 的 extension API
140
+ // 背景:pi 的 extension API
89
141
  // 对象(pi / ctx)在 session 替换(newSession/fork/switchSession/reload)后被 runner
90
142
  // 标记 stale,再调用其方法会**同步抛错**(loader.js/runner.js 的 assertActive,错误文案
91
143
  // 含 STALE_CTX_MARKER)。跨 session 生命周期存活的异步回调(compact 的 onComplete/onError、
@@ -120,7 +172,7 @@ export interface GuardStaleCtxOptions {
120
172
  isCtxStale?: () => boolean;
121
173
  /**
122
174
  * stale 降级通知(静默降级的观测点):前置检查命中时无参调用;fn 抛错被分诊为
123
- * stale 时以捕获的错误为参调用。缺省 = 仅 XYZ_AGENT_DEBUG=1 时写一行 stderr
175
+ * stale 时以捕获的错误为参调用。缺省 = 仅 TAIJI_AGENT_DEBUG=1 时写一行 stderr
124
176
  * debug(本包零依赖、无 pi handle,不能走 extension-logger;需要落盘日志的
125
177
  * 接入方传入自己的 debugLog/logger.warn)。
126
178
  */
@@ -137,9 +189,9 @@ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
137
189
  );
138
190
  }
139
191
 
140
- /** 缺省降级通知:XYZ_AGENT_DEBUG=1 时 stderr 一行(对齐 extensions 包 XYZ_AGENT_DEBUG 惯例)。 */
192
+ /** 缺省降级通知:TAIJI_AGENT_DEBUG=1 时 stderr 一行(对齐 extensions 包 TAIJI_AGENT_DEBUG 惯例)。 */
141
193
  function defaultOnStale(label: string, error?: unknown): void {
142
- if (process.env.XYZ_AGENT_DEBUG !== "1") return;
194
+ if (process.env.TAIJI_AGENT_DEBUG !== "1") return;
143
195
  const detail =
144
196
  error === undefined
145
197
  ? "generation check (isCtxStale)"