@jayyuen66/dsh-quality-gate 0.1.1 → 0.1.3

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/host.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // GENERATED by build-host.mjs — edit host.ts / lib/*.ts, then run `node build-host.mjs`.
2
+ import { createRequire } from "node:module";
2
3
  import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import path from "node:path";
@@ -9,7 +10,7 @@ import { deriveProjectKey } from "@jayyuen66/dsh-plugin-shared/lib/project-key";
9
10
  import { editPathOf, scanToolEvents } from "@jayyuen66/dsh-plugin-shared/lib/tool-events";
10
11
  import { settleLessonCall } from "@jayyuen66/dsh-plugin-shared/lib/lesson-bus";
11
12
  import { fieldOf, isRecord } from "@jayyuen66/dsh-plugin-shared/lib/record";
12
- //#region lib/accumulator.ts
13
+ // lib/accumulator.ts
13
14
  /**
14
15
  * 需要过门禁的扩展名(代码与配置;md/txt/图片等免检)。
15
16
  *
@@ -19,7 +20,11 @@ import { fieldOf, isRecord } from "@jayyuen66/dsh-plugin-shared/lib/record";
19
20
  */
20
21
  const GATED_EXT = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|rs|py|json|ya?ml|toml)$/iu;
21
22
  /** 编辑类工具名。str_replace_editor 是 dsh-base 并存的另一套编辑器
22
- * (host 侧只对 create/str_replace/insert 记账,view 只读不入这里)。 */
23
+ * (host 侧只对 create/str_replace/insert 记账,view 只读不入这里)。
24
+ *
25
+ * 这是**唯一真源**:host 侧的同名判定(isEditTool)也用这一份,两边各写一份时新增
26
+ * 编辑器工具只改一侧就会静默分叉——只改 host 则累加器静默丢弃记账,只改这里则 host
27
+ * 根本不会调用 note。 */
23
28
  const EDIT_TOOLS = /* @__PURE__ */ new Set([
24
29
  "edit",
25
30
  "write",
@@ -30,28 +35,29 @@ const DEFAULT_MAX_FILES = 500;
30
35
  var EditAccumulator = class {
31
36
  files = /* @__PURE__ */ new Set();
32
37
  maxFiles;
33
- /** 容量淘汰累计丢弃数:drain 时清零(500-cap 静默淘汰要可观测)。 */
38
+ /** 容量淘汰累计丢弃数:drain 时清零(静默淘汰要可观测)。 */
34
39
  dropped = 0;
35
40
  constructor(options = {}) {
36
- this.maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
41
+ const configured = options.maxFiles;
42
+ this.maxFiles = configured !== void 0 && Number.isInteger(configured) && configured > 0 ? configured : DEFAULT_MAX_FILES;
37
43
  }
38
44
  /** 记一笔:编辑类工具 + 代码/配置扩展名 + 非空路径才收;容量淘汰时计数。 */
39
45
  note(name, filePath) {
40
46
  if (name === void 0 || !EDIT_TOOLS.has(name)) return;
41
47
  if (typeof filePath !== "string" || filePath.length === 0) return;
42
48
  if (!GATED_EXT.test(filePath)) return;
43
- if (this.files.size >= this.maxFiles && !this.files.has(filePath)) this.evictOldest();
49
+ this.files.delete(filePath);
50
+ if (this.files.size >= this.maxFiles) this.evictOldest();
44
51
  this.files.add(filePath);
45
52
  }
46
53
  /**
47
- * 淘汰插入序最旧的一项并计数。调用点已保证集合非空(`size >= maxFiles >= 1`,本包
48
- * maxFiles 只取默认 500 或测试里的 2),所以原意是**取首项这一次动作**,不是迭代——
49
- * 旧写法拿 `for…of` + 无条件 `break` 伪装单次取值,那正是 eslint(no-unreachable-loop)
50
- * 判的「循环体只允许一次迭代」:循环结构在这里没有承载任何语义。改成直接取首个元素,
51
- * 单次淘汰的行为(删最旧 + 计数)与原样一致。
54
+ * 淘汰顺序最旧的一项并计数。
55
+ *
56
+ * 归一化后 `maxFiles >= 1`,而调用点只在 `size >= maxFiles` 时进来,所以集合必有可取首项。
52
57
  */
53
58
  evictOldest() {
54
59
  const [oldest] = this.files;
60
+ /* v8 ignore next 3 —— 上面那条不变量的直接推论:这一支只为类型收窄而写,正常配置下不可达。 */
55
61
  if (oldest === void 0) return;
56
62
  this.files.delete(oldest);
57
63
  this.dropped += 1;
@@ -68,8 +74,7 @@ var EditAccumulator = class {
68
74
  return out;
69
75
  }
70
76
  };
71
- //#endregion
72
- //#region lib/gate-detect.ts
77
+ // lib/gate-detect.ts
73
78
  /**
74
79
  * `pnpm check` 是否可用:必须有已声明的 check 脚本,**信息缺失时一律判否**。
75
80
  *
@@ -83,9 +88,25 @@ var EditAccumulator = class {
83
88
  function pnpmCheckReady(scripts) {
84
89
  return scripts?.includes("check") === true;
85
90
  }
86
- /** 从 ProjectRoot(根目录相对名清单 + 已声明脚本)判定单根门禁。 */
91
+ /**
92
+ * `ruff check .` 是否可用:必须有 ruff 自己的配置面,**信息缺失时一律判否**。
93
+ *
94
+ * 为什么 pyproject.toml 单独不算证据:它不是 ruff 的配置标记,而是 Python 工程最常见的
95
+ * 元数据文件——只写 `[project]`、lint 交给别的工具(flake8 / pylint / ruff 的编辑器集成)
96
+ * 的工程大量存在。对这类工程发 `ruff check .`,一旦宿主 PATH 上有 ruff,就会拿 ruff 的
97
+ * 默认规则集去判一个从未声明用 ruff 的项目,产出与使用者意图无关的告警,再经同一个注入
98
+ * 通道被当成真实缺陷。这正是本模块要压住的假失败,方向与 pnpmCheckReady 一致。
99
+ *
100
+ * 判据收在两处**声明**上:`ruff.toml`(ruff 专属文件名,在场即声明)与 pyproject 里真有
101
+ * 的 `[tool.ruff]` 一族段(由 host 读盘后经 ruffConfigured 传入)。二者都缺=没有信息,
102
+ * 按保守口径不给门禁——代价只是这一轮少一次自动核验,用户可以自己跑。
103
+ */
104
+ function ruffGateReady(files, ruffConfigured) {
105
+ return files.includes("ruff.toml") || ruffConfigured === true;
106
+ }
107
+ /** 从 ProjectRoot(根目录相对名清单 + 已声明脚本 + ruff 配置事实)判定单根门禁。 */
87
108
  function gateForRoot(entry) {
88
- const { root, files, scripts } = entry;
109
+ const { root, files, scripts, ruffConfigured } = entry;
89
110
  const has = (name) => files.includes(name);
90
111
  let gate;
91
112
  if (has("pnpm-workspace.yaml") && has("package.json") && pnpmCheckReady(scripts)) gate = {
@@ -96,7 +117,7 @@ function gateForRoot(entry) {
96
117
  root,
97
118
  command: ["cargo", "check"]
98
119
  };
99
- else if (has("pyproject.toml") || has("ruff.toml")) gate = {
120
+ else if (ruffGateReady(files, ruffConfigured)) gate = {
100
121
  root,
101
122
  command: [
102
123
  "ruff",
@@ -106,11 +127,7 @@ function gateForRoot(entry) {
106
127
  };
107
128
  else if (has("package.json") && has("tsconfig.json")) gate = {
108
129
  root,
109
- command: [
110
- "npx",
111
- "tsc",
112
- "--noEmit"
113
- ]
130
+ command: ["tsc", "--noEmit"]
114
131
  };
115
132
  return gate;
116
133
  }
@@ -123,8 +140,7 @@ function detectGate(roots) {
123
140
  }
124
141
  return out;
125
142
  }
126
- //#endregion
127
- //#region lib/messages.ts
143
+ // lib/messages.ts
128
144
  const MESSAGES = {
129
145
  zh: {
130
146
  evidenceTruncated: "[证据截断] {label} 内存里只保留尾部(首个错误可能在被丢弃的头部)。完整输出:{spill}",
@@ -144,7 +160,6 @@ const MESSAGES = {
144
160
  toolMissing: "命令不存在(工具未安装)",
145
161
  toolNotExecutable: "命令不可执行",
146
162
  toolNotRun: "{cause},门禁没有运行。",
147
- toolStub: "门禁工具本身不可用(npx 抓到的是 stub 包,不是真工具)。",
148
163
  policyUnresolvable: "沙箱策略无法解析({message}),门禁拒绝在未确认的沙箱边界下执行。",
149
164
  sandboxUnavailable: "沙箱在本机不可用({message}):命令被拒绝执行,未做任何检查。",
150
165
  gateStartFailed: "门禁未能启动({message})。",
@@ -168,7 +183,6 @@ const MESSAGES = {
168
183
  toolMissing: "command not found (tool not installed)",
169
184
  toolNotExecutable: "command not executable",
170
185
  toolNotRun: "{cause} — the gate did not run.",
171
- toolStub: "The gate tool itself is unavailable (npx fetched a stub package, not the real tool).",
172
186
  policyUnresolvable: "The sandbox policy could not be resolved ({message}); the gate refuses to run under an unconfirmed sandbox boundary.",
173
187
  sandboxUnavailable: "The sandbox is unavailable on this machine ({message}): the command was denied and no check was performed.",
174
188
  gateStartFailed: "The gate failed to start ({message}).",
@@ -177,33 +191,41 @@ const MESSAGES = {
177
191
  };
178
192
  /** 渲染模板里的 {name} 占位符;未提供的名字留空串(宁可少一段说明,也不给模型留裸花括号)。
179
193
  * 与 client 半官方 `@deepseek-ai/dsh-client-locale` 的 Translate 插值同语义(宿主同规则)。
194
+ *
195
+ * 查参数用 `Object.hasOwn` 而不是直接下标:模板里若出现 `{constructor}` / `{toString}` 这类被
196
+ * Object.prototype 覆盖的名字,裸下标取到的是函数对象、`String()` 之后会渲染成源码串,
197
+ * 与"未提供的名字留空串"这条契约相反。
180
198
  * @param text - 字典里的整行模板。
181
199
  * @param params - 占位符名 → 值(数字会转成字符串)。
182
200
  * @returns 替换后的文本。
183
201
  */
184
202
  function renderTemplate(text, params) {
185
- return text.replaceAll(/\{(?<key>\w+)\}/gu, (_all, key) => {
186
- const value = params[key];
187
- return value === void 0 ? "" : String(value);
188
- });
203
+ const provided = new Map(Object.entries(params));
204
+ return text.replaceAll(/\{(?<key>\w+)\}/gu, (_all, key) => provided.get(key)?.toString() ?? "");
205
+ }
206
+ // lib/feedback-content.ts
207
+ /** 空白折叠成单个空格并去首尾:让副本身处"一行正文"的契约之内。
208
+ * 参数收 `unknown` 而不是 `string`:这是运行时边界(门禁执行结果由 spawn 与上游透传拼出来),
209
+ * 类型注释拦不住 undefined/null。原先直接 `text.replaceAll` 时,异常会冒到
210
+ * pushFeedbackToMemory 的 catch 里 → "门禁失败却一条记忆都没写",且日志里只有一句
211
+ * "memory feedback skipped",看起来像网关挂了。 */
212
+ function oneLine(text) {
213
+ return typeof text === "string" ? text.replaceAll(/\s+/gu, " ").trim() : "";
189
214
  }
190
- //#endregion
191
- //#region lib/feedback-content.ts
192
215
  /** 门禁失败 → 记忆正文:单行化(防换行噪音)。
216
+ * 三处插值都要折叠:`failure` 来自被检查仓库的自由文本,`command` 来自配置里的自定义命令,
217
+ * `root` 可以是任何路径——任一处带换行/制表符,这条"一条记忆正文"就会被 auto-recall 拆成多段。
193
218
  * 不截断:截尾会吃掉多根错误里的中间根因;记忆网关的入库限额由网关侧把关,这里送全文。
194
219
  * 正文语言随官方 locale 偏好走(messages 由调用方按回合取出后传进来)——这条文本会被
195
220
  * auto-recall 注入后续会话的上下文,是模型读的,不是日志。 */
196
221
  function buildFeedbackText({ command, root, failure }, messages) {
197
- const cmd = command.join(" ");
198
- const tail = failure.replaceAll(/\s+/gu, " ").trim();
199
222
  return renderTemplate(messages.memoryFeedback, {
200
- command: cmd,
201
- root,
202
- failure: tail
223
+ command: oneLine(command.join(" ")),
224
+ root: oneLine(root),
225
+ failure: oneLine(failure)
203
226
  });
204
227
  }
205
- //#endregion
206
- //#region lib/gateway-target.ts
228
+ // lib/gateway-target.ts
207
229
  /** env 值 trim 后非空则用之,否则回到 fallback(保留原 `||` 语义,
208
230
  * 又以显式比较规避 prefer-nullish-coalescing 与 strict-boolean-expressions)。 */
209
231
  function envOr(key, fallback) {
@@ -216,28 +238,98 @@ function envOr(key, fallback) {
216
238
  * 端口写死成默认,等于让别人的每次门禁失败都去连一个不存在的服务。设置优先于 env 的理由:
217
239
  * 设置卡是用户显式配置面,env 只能兜底,
218
240
  * 否则用户改了设置卡仍被一个看不见的环境变量越权。
241
+ *
242
+ * 解析不出合法 URL 时同样返回空串(按"未配置"处理):`http://` 这类半截串裁掉斜杠后剩
243
+ * `http:`,如果原样透给调用方,凭据安全判据会把它报成"明文 http 且非回环"——把配置写错
244
+ * 误报成协议不安全,用户照提示改成 https 也修不好。
219
245
  */
220
246
  function resolveGatewayUrl(settingUrl) {
221
247
  const configured = settingUrl.trim();
222
- return (configured === "" ? envOr("TDAI_GATEWAY_URL", "") : configured).replace(/\/+$/u, "");
248
+ const candidate = configured === "" ? envOr("TDAI_GATEWAY_URL", "") : configured;
249
+ if (candidate === "") return "";
250
+ const normalized = candidate.replace(/\/+$/u, "");
251
+ let parsed;
252
+ try {
253
+ parsed = new URL(normalized);
254
+ } catch {
255
+ return "";
256
+ }
257
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
258
+ return normalized;
259
+ }
260
+ /** 是否为 IPv4 回环(127.0.0.0/8 整段,不只是 127.0.0.1)。 */
261
+ function isIpv4Loopback(host) {
262
+ const parts = host.split(".");
263
+ if (parts.length !== 4 || parts[0] !== "127") return false;
264
+ return parts.every((part) => /^\d{1,3}$/u.test(part) && Number(part) <= 255);
223
265
  }
224
- /** x-tdai-service-id:env 可覆盖,默认是网关协议的匿名服务名(不含任何机器信息)。 */
266
+ /**
267
+ * 凭据只允许发往 https 或回环地址:明文 http 到远端会把 Bearer 令牌暴露给同网段
268
+ * (设置卡默认值就是本机 http,属有意用法)。返回 false 时调用方按"未配置"处理,
269
+ * 不解析、不发送凭据。
270
+ *
271
+ * 回环按整段判定而不是三个字面量:IPv4 回环是 127.0.0.0/8,网关绑在 127.0.0.2 /
272
+ * 127.0.1.1 时同样只在本机,判成"不安全"会让记忆反馈静默停掉、而用户从提示里看不出
273
+ * 自己其实就在本机;IPv6 的 IPv4-mapped 写法(URL 把 `[::ffff:127.0.0.1]` 归一成
274
+ * `[::ffff:7f00:1]`)同理。
275
+ *
276
+ * `localhost` 这一支是**有意保留的 fail-open**:它依赖 hosts/DNS 解析,hosts 被指向远端时
277
+ * 本函数仍判安全。保留它是为了让设置卡的默认写法可用;要彻底关掉这个口子只能改成只认 IP
278
+ * 字面量,那会让"本机网关"这条最常见用法需要用户自己去查 IP。
279
+ */
280
+ function isCredentialSafeUrl(url) {
281
+ let parsed;
282
+ try {
283
+ parsed = new URL(url);
284
+ } catch {
285
+ return false;
286
+ }
287
+ if (parsed.protocol === "https:") return true;
288
+ if (parsed.protocol !== "http:") return false;
289
+ const host = parsed.hostname.replaceAll(/^\[|\]$/gu, "");
290
+ return host === "localhost" || isIpv4Loopback(host) || host === "::1" || host.startsWith("::ffff:7f");
291
+ }
292
+ /** `x-tdai-service-id` 的合法形状:网关侧按服务名路由,空白与控制字符会让请求头
293
+ * 在 fetch 处直接抛 TypeError(undici 拒收含换行的头值),把一次投递变成一条异常。
294
+ * 不合规的值一律回落到默认服务名,而不是把用户的笔误送上网关。 */
295
+ const SERVICE_ID_RE = /^[A-Za-z0-9._-]{1,64}$/u;
296
+ /** 默认服务名:网关协议的匿名服务名(不含任何机器信息)。 */
297
+ const DEFAULT_SERVICE_ID = "default";
298
+ /** x-tdai-service-id:env 可覆盖,形状不合规时回落默认值。 */
225
299
  function resolveServiceId() {
226
- return envOr("TDAI_SERVICE_ID", "default");
300
+ const configured = envOr("TDAI_SERVICE_ID", "");
301
+ return SERVICE_ID_RE.test(configured) ? configured : DEFAULT_SERVICE_ID;
227
302
  }
228
- /** cwd → agent_id:'尾目录名-<cwd 前 8 位 sha256>'(与网关插件 workspace-peer 同分桶语义,
229
- * 读/写同键,保证反馈沉淀进 auto-recall 同一记忆桶)。
230
- * 派生本体在 shared/lib/project-key.ts:lesson-loop 的 project 与这里同源一份实现,
231
- * 此前两处各存一份,改一处漏一处就把"教训 ↔ 记忆"的跨系统对照拆断了。 */
303
+ /**
304
+ * 作用:cwd → agent_id,'尾目录名-<cwd 前 8 位 sha256>'(与网关插件 workspace-peer 同分桶
305
+ * 语义,读/写同键,保证反馈沉淀进 auto-recall 同一记忆桶)。
306
+ *
307
+ * 条件:派生本体在 shared/lib/project-key.ts —— lesson-loop 的 project 与这里同源一份实现,
308
+ * 两处各存一份时改一处漏一处,把"教训 ↔ 记忆"的跨系统对照拆断而不报错。
309
+ *
310
+ * 子功能:参数保留 `unknown`(调用方的 cwd 来自会话事件,形状不由本包保证);非字符串、
311
+ * 空串、根路径一律回落 'default'。
312
+ *
313
+ * 执行:直接委托 deriveProjectKey。
314
+ *
315
+ * 验证方式:test/gateway-feedback.test.ts 的 deriveAgentId 组逐条钉住回落面,并有一例
316
+ * 断言它与 deriveProjectKey 逐值同源。
317
+ *
318
+ * 不适用条件:'default' 是所有项目共享的记忆桶 —— 回落因此**可能**把反馈混进别人的桶。
319
+ * 这里不为此加日志:本模块被 host 的 catch 包着、失败只 warn 一次,再往 lib 里塞一条
320
+ * console 会污染测试日志账本(test/setup-logs.ts 要求每条日志都有模板认领);而实际
321
+ * 调用点传的是会话 cwd(host.ts 里由事件流给出,必为字符串),回落是给未来调用方的护栏。
322
+ */
232
323
  function deriveAgentId(cwd) {
233
324
  return deriveProjectKey(cwd);
234
325
  }
235
- //#endregion
236
- //#region lib/gateway-feedback.ts
326
+ // lib/gateway-feedback.ts
237
327
  /** 反馈尽力而为:5s 封顶,超时/失败只 warn(不吃掉门禁主流程)。 */
238
328
  const FEEDBACK_TIMEOUT_MS = 5e3;
239
329
  /** 沉淀门禁失败到 L0 记忆(role=assistant:这是 agent 自身行为教训,归顺 assistant 提炼面)。 */
240
330
  async function pushGateFeedback(input, target, messages) {
331
+ const base = target.url.trim().replace(/\/+$/u, "");
332
+ if (base === "" || target.key.trim() === "") throw new Error("投递目标不完整:url 与 key 都必须非空(目标面应由调用方解析后传入)");
241
333
  const agentId = deriveAgentId(input.cwd);
242
334
  const content = buildFeedbackText(input, messages);
243
335
  const payload = JSON.stringify({
@@ -248,31 +340,36 @@ async function pushGateFeedback(input, target, messages) {
248
340
  content
249
341
  }]
250
342
  });
251
- const res = await fetch(`${target.url}/v2/conversation/add`, {
343
+ const res = await fetch(`${base}/v2/conversation/add`, {
252
344
  method: "POST",
253
345
  headers: {
254
346
  Authorization: `Bearer ${target.key}`,
255
- "x-tdai-service-id": resolveServiceId(),
347
+ "x-tdai-service-id": target.serviceId,
256
348
  "Content-Type": "application/json"
257
349
  },
258
350
  body: payload,
259
351
  signal: AbortSignal.timeout(FEEDBACK_TIMEOUT_MS)
260
352
  });
261
- if (!res.ok) throw new Error(`gateway feedback HTTP ${res.status}`);
262
- await res.arrayBuffer().catch(() => null);
353
+ const drained = await res.arrayBuffer().catch(() => null);
354
+ if (!res.ok) {
355
+ const detail = drained === null ? "" : new TextDecoder().decode(drained).trim().slice(0, 200);
356
+ throw new Error(detail === "" ? `gateway feedback HTTP ${res.status}` : `gateway feedback HTTP ${res.status}:${detail}`, { cause: drained });
357
+ }
263
358
  }
264
- //#endregion
265
- //#region host.ts
359
+ // host.ts
266
360
  /**
267
361
  * 常见项目根标记文件:向上找最近的含清单目录。
268
362
  * ruff.toml 必须在列:只有 ruff 配置、无 pyproject.toml 的 Python
269
363
  * 工程若不在此登记,gate-detect 的 has("ruff.toml") 就是死码——那类工程根本得不到根,
270
364
  * 也就永远没有门禁。
271
365
  */
366
+ const PKG_MANIFEST = "package.json";
367
+ const TSCONFIG_MANIFEST = "tsconfig.json";
368
+ const PYPROJECT_MANIFEST = "pyproject.toml";
272
369
  const MANIFESTS = [
273
- "package.json",
370
+ PKG_MANIFEST,
274
371
  "Cargo.toml",
275
- "pyproject.toml",
372
+ PYPROJECT_MANIFEST,
276
373
  "pnpm-workspace.yaml",
277
374
  "ruff.toml"
278
375
  ];
@@ -330,8 +427,8 @@ function messageOf(error) {
330
427
  return message;
331
428
  }
332
429
  /**
333
- * 会话级 Map 淘汰:把 keep 先删后插移到队尾(JS Map 迭代按插入序),再淘汰队首
334
- * (size - 50) 项 → keep 永不被淘汰,且总大小收敛。泛型复用:注入配额 Map(InjectState)、
430
+ * 会话级 Map 淘汰:把 keep 先删后插移到队尾(JS Map 迭代按插入序),再淘汰队首超出
431
+ * `SESSION_BUDGET` 的项 → keep 永不被淘汰,且总大小收敛。泛型复用:注入配额 Map(InjectState)、
335
432
  * 事件游标 Map(number)与编辑累加器 Map(EditAccumulator)共用同一策略。
336
433
  * keep 不在表内 → 直接返回(inbox-busy 分支的 injectState 就会命中:该会话从未失败过)。
337
434
  */
@@ -391,17 +488,34 @@ function preInjectCheck(agent, signal) {
391
488
  };
392
489
  }
393
490
  /**
394
- * 判定门禁失败是否为"工具本身不可用"(而非代码问题)。
395
- *
396
- * `npx tsc` 在项目未装 typescript 时会**自动安装**已弃用的 stub 包 `tsc@2.0.4`,
397
- * 它打印横幅并以 exit 1 退出——不是 126/127,所以只按退出码判定会误报为代码失败。
398
- * 实测:`npx --no-install` 在本机拦不住 stub(已从 npx 缓存解析),
399
- * 所以必须在输出侧识别,不能只靠加 flag。
491
+ * 本次门禁要跑的 tsc 入口:优先工程自己装的 typescript(与工程惯用的编译器版本一致),
492
+ * 装不到时用本包依赖的那份(随包安装,运行期无网络动作)。两处都解析不到时回落到 PATH 上的
493
+ * `tsc`,由 126/127 那条「工具不可用」出口报成未执行——绝不走 `npx` 的自动下载。
400
494
  */
401
- function gateToolUnavailable(stdout, stderr) {
402
- const out = `${stdout}\n${stderr}`.toLowerCase();
403
- if (out.includes("not the tsc command you are looking for")) return true;
404
- return out.includes("npm warn deprecated tsc@");
495
+ function tscEntry(root) {
496
+ let entry;
497
+ for (const from of [path.join(root, PKG_MANIFEST), import.meta.url]) try {
498
+ const manifest = createRequire(from).resolve("typescript/package.json");
499
+ entry = path.join(path.dirname(manifest), "bin", "tsc");
500
+ break;
501
+ } catch {}
502
+ return entry;
503
+ }
504
+ /** 门禁命令的实际 argv:tsc 的门禁只带逻辑名,入口在运行时按工程根解析。 */
505
+ function gateCommandArgs(gate) {
506
+ if (gate.command[0] !== "tsc") return gate.command;
507
+ /* v8 ignore next 1 —— 本包自己的 typescript 解析不到(安装面坏了)时才走 PATH 回落;
508
+ 正常安装不可达,而那一路由 126/127 报成未执行。 */
509
+ return [tscEntry(gate.root) ?? "tsc", "--noEmit"];
510
+ }
511
+ /** 拼 shell 串:路径可能带空格,含空白或元字符的参数加单引号。
512
+ * 导出只为直接测这段引号处理:tsc 入口是绝对路径,它所在目录未必没有空格。 */
513
+ function shellArg(part) {
514
+ return /^[\w./:=@+-]+$/u.test(part) ? part : `'${part.replaceAll("'", `'\\''`)}'`;
515
+ }
516
+ /** 门禁命令的展示/执行串(与 gateCommandArgs 同源)。 */
517
+ function gateCommandText(gate) {
518
+ return gateCommandArgs(gate).map((part) => shellArg(part)).join(" ");
405
519
  }
406
520
  /** harness run() 抛出的 SandboxUnavailableError(code 或 name 命中即可)。 */
407
521
  function isSandboxUnavailable(error) {
@@ -454,7 +568,7 @@ function evidenceText(messages, stream, label) {
454
568
  return out;
455
569
  }
456
570
  const gateText = (messages, gate, out) => renderTemplate(messages.gateFailed, {
457
- command: gate.command.join(" "),
571
+ command: gateCommandText(gate),
458
572
  root: gate.root,
459
573
  output: out
460
574
  });
@@ -463,19 +577,19 @@ const gateText = (messages, gate, out) => renderTemplate(messages.gateFailed, {
463
577
  * 让模型去修一个装不上的工具/越不过去的沙箱,只会配着配额环烧 token。
464
578
  */
465
579
  const notRunText = (messages, gate, reason) => renderTemplate(messages.gateNotRun, {
466
- command: gate.command.join(" "),
580
+ command: gateCommandText(gate),
467
581
  root: gate.root,
468
582
  reason
469
583
  });
470
584
  /** 配额耗尽后的"放行说明"(用户可见,不再静默 return)。 */
471
585
  const giveUpText = (messages, gate, attempts) => renderTemplate(messages.gaveUp, {
472
586
  attempts,
473
- command: gate.command.join(" "),
587
+ command: gateCommandText(gate),
474
588
  root: gate.root
475
589
  });
476
590
  /** 未执行清单的一行(模块级:不让 renderTemplate 套进 map 再套进 renderTemplate)。 */
477
591
  const skippedGateLine = (messages, gate) => renderTemplate(messages.skippedGateItem, {
478
- command: gate.command.join(" "),
592
+ command: gateCommandText(gate),
479
593
  root: gate.root
480
594
  });
481
595
  /** 预算耗尽后剩余门禁的"未执行"清单:只 warn 等于静默通过,所以要注入给模型与用户。 */
@@ -539,7 +653,7 @@ function interruptedRunOutcome(messages, budgetMs, res) {
539
653
  function unavailableToolOutcome(messages, gate, res, log) {
540
654
  let outcome;
541
655
  if (res.exitCode === 126 || res.exitCode === 127) {
542
- log.warn(`[quality-gate] gate tool unavailable (exit ${res.exitCode}): ${gate.command.join(" ")} — reported as not-run, not as pass`);
656
+ log.warn(`[quality-gate] gate tool unavailable (exit ${res.exitCode}): ${gateCommandText(gate)} — reported as not-run, not as pass`);
543
657
  const cause = res.exitCode === 127 ? messages.toolMissing : messages.toolNotExecutable;
544
658
  outcome = {
545
659
  kind: "not-run",
@@ -558,7 +672,7 @@ function unavailableToolOutcome(messages, gate, res, log) {
558
672
  * 策略拒绝当成代码失败注入"修这些错"。
559
673
  *
560
674
  * 判定序(三支各自成函数,顺序与 warn 次数都不变):沙箱面拦截 → 进程没跑完 →
561
- * 工具装不上(126/127)→ exit 0(部分生效沙箱只 warn)→ npx stub 横幅 → 代码失败。
675
+ * 工具装不上(126/127)→ exit 0(部分生效沙箱只 warn)→ 代码失败。
562
676
  */
563
677
  function classifyRun(messages, gate, budgetMs, res, log) {
564
678
  const facts = res.sandbox;
@@ -571,24 +685,19 @@ function classifyRun(messages, gate, budgetMs, res, log) {
571
685
  const unusable = unavailableToolOutcome(messages, gate, res, log);
572
686
  if (unusable !== void 0) return unusable;
573
687
  if (res.exitCode === 0) {
574
- if (facts?.enforcement === "partial") log.warn(`[quality-gate] gate passed under PARTIAL sandbox enforcement (${sandboxNote(messages, facts)}): ${gate.command.join(" ")} @ ${gate.root}`);
688
+ if (facts?.enforcement === "partial") log.warn(`[quality-gate] gate passed under PARTIAL sandbox enforcement (${sandboxNote(messages, facts)}): ${gateCommandText(gate)} @ ${gate.root}`);
575
689
  return { kind: "pass" };
576
690
  }
577
- if (gateToolUnavailable(res.stdout.text, res.stderr.text)) {
578
- log.warn(`[quality-gate] gate tool unavailable (npx fetched a stub instead of the real tool): ${gate.command.join(" ")} in ${gate.root} — reported as not-run`);
579
- return {
580
- kind: "not-run",
581
- text: `${messages.toolStub}\n${evidenceText(messages, res.stderr, "stderr")}`
582
- };
583
- }
584
691
  return {
585
692
  kind: GATE_KIND_CODE_FAILURE,
586
693
  text: `exit=${res.exitCode}\n${evidenceText(messages, res.stdout, "stdout")}${evidenceText(messages, res.stderr, "stderr")}`
587
694
  };
588
695
  }
589
- /** 是否是会被记账的编辑类工具(edit/write + str_replace_editor)。 */
696
+ /** 是否是会被记账的编辑类工具(edit/write + str_replace_editor)。名单的真源在
697
+ * lib/accumulator.ts 的 EDIT_TOOLS:host 先用它过滤、累加器再用同一集合过滤一次,
698
+ * 两边各写一份时新增编辑器工具只改一侧就会静默分叉。 */
590
699
  function isEditTool(name) {
591
- return name === "edit" || name === "write" || name === "str_replace_editor";
700
+ return EDIT_TOOLS.has(name);
592
701
  }
593
702
  /**
594
703
  * 根 package.json 已声明的 scripts 名清单(值是非空字符串才算"声明了这条脚本")。
@@ -599,7 +708,7 @@ function isEditTool(name) {
599
708
  function declaredScriptsOf(root) {
600
709
  let names;
601
710
  try {
602
- const raw = JSON.parse(readFileSync(`${root}/package.json`, "utf8"));
711
+ const raw = JSON.parse(readFileSync(path.join(root, PKG_MANIFEST), "utf8"));
603
712
  const scripts = fieldOf(raw, "scripts");
604
713
  if (isRecord(scripts)) names = Object.entries(scripts).filter(([, body]) => typeof body === "string" && body.length > 0).map(([name]) => name);
605
714
  } catch {
@@ -612,23 +721,50 @@ function isArrayDelivery(value) {
612
721
  return Array.isArray(value);
613
722
  }
614
723
  /**
724
+ * pyproject 里 ruff 段头的字面形状:`[tool.ruff]` 或它的子表 `[tool.ruff.lint]` 等。
725
+ *
726
+ * `^` 加 `m` 旗标限定为行首;`[tool.ruff]` 与 `[tool.ruff.<子表>]` 都要认——只写子表、
727
+ * 不写父表的 pyproject 是常见写法。数组表(`[[tool.ruff...]]`)同样命中段名,一并算声明。
728
+ */
729
+ const RUFF_SECTION = /^\[\[?tool\.(?:"ruff"|ruff)[\].]/mu;
730
+ /**
731
+ * 根 pyproject.toml 里是否声明了 ruff 配置段(段头形状见 RUFF_SECTION)。
732
+ *
733
+ * 只按行首段头字面匹配,不引 TOML 解析器:判据只需要回答"有没有这一段",而段头在
734
+ * TOML 里必须独占一行(行内写法 `[a.b] c = 1` 非法),故行首匹配与解析器同解。带引号的
735
+ * 键形式(`[tool."ruff"]`)在 TOML 里合法且等价,一并认。
736
+ *
737
+ * 读不到 / 解码失败 → undefined("没有信息"),由 gate-detect 按保守口径处理:不发 ruff
738
+ * 门禁。这里与 declaredScriptsOf 同一方向,理由见 gate-detect 的 ruffGateReady。
739
+ */
740
+ function ruffConfiguredOf(root) {
741
+ let configured;
742
+ try {
743
+ const text = readFileSync(path.join(root, PYPROJECT_MANIFEST), "utf8");
744
+ configured = RUFF_SECTION.test(text);
745
+ } catch {
746
+ configured = void 0;
747
+ }
748
+ return configured;
749
+ }
750
+ /**
615
751
  * 解析事件游标窗口:fork 切点下限 vs 上次已读 seq 取大,返回起始 seq 与本批事件。
616
752
  * 事件流取自官方 `ctx.sessionQuery.observeSession(sessionId)`(见上面 SessionQueryFace:
617
- * 一次交回 `events` 与 `inheritedEventCount`)。三条降级路径一律 `undefined`(调用方直接
618
- * 返回,**游标不推进**、不误报):① `sessionQuery` 未装配(`Context["get"]` 的官方语义即
619
- * "or `undefined` when not (yet) provided",installed cordis/lib/types/reflect.d.ts:12);
620
- * ② `observeSession` 抛错/拒绝(会话已卸载、存储读不出);③ 交回的 events 不是数组
621
- * (契约外交付,由 `isArrayDelivery` 那道**布尔**判据挡——不用 `Array.isArray` 作守卫收窄,
622
- * 它会把官方 `readonly SessionEvent[]` 塌成 `any[]`,类型面当场作废并把 any 传进下游记账)。
753
+ * 一次交回 `events` 与 `inheritedEventCount`)。两条降级路径一律 `undefined`(调用方直接
754
+ * 返回,**游标不推进**、不误报):① `observeSession` 抛错/拒绝(会话已卸载、存储读不出);
755
+ * ② 交回的 events 不是数组(契约外交付,由 `isArrayDelivery` 那道**布尔**判据挡——不用
756
+ * `Array.isArray` 作守卫收窄,它会把官方 `readonly SessionEvent[]` 塌成 `any[]`,类型面
757
+ * 当场作废并把 any 传进下游记账)。`sessionQuery` 服务本身缺席是第三条降级,判定在
758
+ * `readEditedFiles`(那里才能发 warn-once)。
623
759
  * 窗口仍由**本地按 from 切片**:观察面给的是自日志开头的连续切片(`seq = log.length`
624
- * 连续性契约,installed dsh-session/lib/index.js:1285 的注记),而 `from` 是本包游标算出的
760
+ * 连续性契约,installed dsh-session/lib/index.js:1476 的注记),而 `from` 是本包游标算出的
625
761
  * 裸 number——这样连 `SessionLogOffset` 的品牌都不必造(官方只要 `SessionId`,那一位从
626
762
  * `Session.id` 上拿回来就是品牌的)。
627
763
  */
628
764
  async function resolveEventWindow(query, sessionId, lastSeq) {
629
765
  let window;
630
- if (query !== void 0) try {
631
- const observation = await query.observeSession(sessionId);
766
+ try {
767
+ const observation = await query.observeSession(sessionId, { projectionMode: "none" });
632
768
  const forkCut = observation.inheritedEventCount ?? 0;
633
769
  const from = Math.max(forkCut, lastSeq.get(sessionId) ?? forkCut);
634
770
  if (isArrayDelivery(observation.events)) window = {
@@ -682,7 +818,7 @@ function findManifestRoot(file, maxDepth) {
682
818
  let found;
683
819
  let hitDepthLimit = false;
684
820
  for (let depth = 0; depth < maxDepth; depth += 1) {
685
- for (const manifest of MANIFESTS) if (existsSync(`${dir}/${manifest}`)) {
821
+ for (const manifest of MANIFESTS) if (existsSync(path.join(dir, manifest))) {
686
822
  found = dir;
687
823
  break;
688
824
  }
@@ -704,7 +840,7 @@ function findManifestRoot(file, maxDepth) {
704
840
  function workspaceRootUpgrade(root, maxDepth) {
705
841
  let up = path.dirname(root);
706
842
  for (let depth = 0; depth < maxDepth; depth += 1) {
707
- if (existsSync(`${up}/pnpm-workspace.yaml`)) return up;
843
+ if (existsSync(path.join(up, "pnpm-workspace.yaml"))) return up;
708
844
  const parent = path.dirname(up);
709
845
  if (parent === up) break;
710
846
  up = parent;
@@ -718,20 +854,22 @@ function workspaceRootUpgrade(root, maxDepth) {
718
854
  * - tsconfig.json 不作为"根标记"(不进 MANIFESTS,避免改动根向上探测的语义),
719
855
  * 但要进清单集合——gate-detect 用它区分"真 TS 项目"与"只有 package.json 的空壳";
720
856
  * - scripts:根 package.json 已声明的脚本名清单,交给 gate-detect 判定 pnpm check
721
- * 是否可用。**这里只做如实登记,不做取舍**(探测策略集中在纯函数里)。
857
+ * 是否可用。**这里只做如实登记,不做取舍**(探测策略集中在纯函数里);
858
+ * - ruffConfigured:根 pyproject.toml 里是否真有 `[tool.ruff]` 一族段,交给 gate-detect
859
+ * 判定 ruff 门禁是否可用。理由同上——只登记,不取舍。
722
860
  */
723
861
  function probeProjectRoot(root) {
724
- const manifests = MANIFESTS.filter((manifest) => existsSync(`${root}/${manifest}`));
725
- if (existsSync(`${root}/tsconfig.json`)) manifests.push("tsconfig.json");
862
+ const manifests = MANIFESTS.filter((manifest) => existsSync(path.join(root, manifest)));
863
+ if (existsSync(path.join(root, TSCONFIG_MANIFEST))) manifests.push(TSCONFIG_MANIFEST);
726
864
  const scripts = manifests.includes("package.json") ? declaredScriptsOf(root) : void 0;
727
- return scripts === void 0 ? {
865
+ const ruffConfigured = manifests.includes(PYPROJECT_MANIFEST) ? ruffConfiguredOf(root) : void 0;
866
+ const entry = {
728
867
  root,
729
868
  files: manifests
730
- } : {
731
- root,
732
- files: manifests,
733
- scripts
734
869
  };
870
+ if (scripts !== void 0) entry.scripts = scripts;
871
+ if (ruffConfigured !== void 0) entry.ruffConfigured = ruffConfigured;
872
+ return entry;
735
873
  }
736
874
  /** 注入载荷组装(id 唯一,同一毫秒多次注入也不碰撞)。 */
737
875
  const injectMessage = (text) => ({
@@ -758,7 +896,7 @@ function reportProblemToBus(get, attribution, gate, problem, log) {
758
896
  cwd: attribution.cwd,
759
897
  sessionId: attribution.sessionId,
760
898
  turn: attribution.turn,
761
- signature: gate.command.join(" "),
899
+ signature: gateCommandText(gate),
762
900
  detail: problem.text,
763
901
  evidence: {
764
902
  root: gate.root,
@@ -785,6 +923,10 @@ async function resolveFeedbackTarget(cfg, credentials) {
785
923
  ok: false,
786
924
  reason: "memoryGatewayUrl 未配置(留空 = 不启用记忆反馈)"
787
925
  };
926
+ if (!isCredentialSafeUrl(url)) return {
927
+ ok: false,
928
+ reason: `memoryGatewayUrl ${url} 是明文 http 且非回环:Bearer 凭据只发往 https 或本机`
929
+ };
788
930
  if (!isCredentialRefName(cfg.memoryGatewayKeyRef)) return {
789
931
  ok: false,
790
932
  reason: `memoryGatewayKeyRef "${cfg.memoryGatewayKeyRef}" 不是合法凭据引用名`
@@ -802,7 +944,8 @@ async function resolveFeedbackTarget(cfg, credentials) {
802
944
  ok: true,
803
945
  target: {
804
946
  url,
805
- key: hit.value
947
+ key: hit.value,
948
+ serviceId: resolveServiceId()
806
949
  }
807
950
  };
808
951
  }
@@ -857,20 +1000,40 @@ function isGateHost(value) {
857
1000
  return typeof get === "function" && typeof on === "function" && typeof effect === "function" && typeof inject === "function" && isRecord(fiber) && typeof fieldOf(settings, "describe") === "function" && typeof fieldOf(settings, "configure") === "function";
858
1001
  }
859
1002
  /**
1003
+ * 一个文件的最近清单根 → 实际要跑的根:workspace 根优先,**但只在外层真能产出门禁时**才升级。
1004
+ *
1005
+ * 只判「上面有没有 pnpm-workspace.yaml」就升级是漏门禁的一条路:外层若是"有 workspace 清单、
1006
+ * 却没有 check 脚本也没有 tsconfig"的根,gate-detect 对它返回空表,子包本来有的
1007
+ * `npx tsc --noEmit` / `ruff check` 会被这次上溯抹掉,且没有任何输出。
1008
+ */
1009
+ function rootForFile(useRoot, maxDepth) {
1010
+ const inner = probeProjectRoot(useRoot);
1011
+ const upgraded = workspaceRootUpgrade(useRoot, maxDepth);
1012
+ if (upgraded === useRoot) return inner;
1013
+ const outer = probeProjectRoot(upgraded);
1014
+ if (detectGate([outer]).length > 0) return outer;
1015
+ return detectGate([inner]).length > 0 ? inner : outer;
1016
+ }
1017
+ /**
860
1018
  * 从编辑文件路径向上找最近的清单根(层数上限 = Config 的 maxRootDepth,非 volatile
861
1019
  * 部署值),每个根探一次真实盘上事实(清单名 + 已声明脚本)。找不到清单根的文件跳过
862
- * (无门禁面)。
1020
+ * (无门禁面)。同一内层根只判一次盘:一轮改几百个文件时逐文件重复探盘没有意义。
863
1021
  */
864
1022
  function projectRootsOf(files, maxDepth, log) {
865
1023
  const roots = /* @__PURE__ */ new Map();
1024
+ const chosenByInner = /* @__PURE__ */ new Map();
866
1025
  for (const file of files) {
867
1026
  const hit = findManifestRoot(file, maxDepth);
868
1027
  const useRoot = hit.root;
869
1028
  if (useRoot === void 0) {
870
1029
  if (hit.hitDepthLimit) log.warn(`[quality-gate] no manifest within ${maxDepth} levels of ${file}; gate skipped for it`);
871
1030
  } else {
872
- const upgraded = workspaceRootUpgrade(useRoot, maxDepth);
873
- if (!roots.has(upgraded)) roots.set(upgraded, probeProjectRoot(upgraded));
1031
+ let chosen = chosenByInner.get(useRoot);
1032
+ if (chosen === void 0) {
1033
+ chosen = rootForFile(useRoot, maxDepth);
1034
+ chosenByInner.set(useRoot, chosen);
1035
+ }
1036
+ if (!roots.has(chosen.root)) roots.set(chosen.root, chosen);
874
1037
  }
875
1038
  }
876
1039
  return [...roots.values()];
@@ -886,7 +1049,7 @@ async function runGate(messages, shell, gate, budgetMs, context) {
886
1049
  let outcome;
887
1050
  try {
888
1051
  const spec = shell.resolve({
889
- command: gate.command.join(" "),
1052
+ command: gateCommandText(gate),
890
1053
  workdir: gate.root,
891
1054
  stdoutMaxBytes,
892
1055
  timeoutMs: budgetMs,
@@ -895,7 +1058,7 @@ async function runGate(messages, shell, gate, budgetMs, context) {
895
1058
  });
896
1059
  outcome = classifyRun(messages, gate, budgetMs, await (await shell.execute(spec)).result(), log);
897
1060
  } catch (error) {
898
- log.error(`[quality-gate] gate did not run: ${gate.command.join(" ")} @ ${gate.root} — ${messageOf(error)}`);
1061
+ log.error(`[quality-gate] gate did not run: ${gateCommandText(gate)} @ ${gate.root} — ${messageOf(error)}`);
899
1062
  const cause = messageOf(error);
900
1063
  outcome = isSandboxUnavailable(error) ? {
901
1064
  kind: "not-run",
@@ -970,7 +1133,7 @@ function accumulatorOf(rt, sessionId) {
970
1133
  * tool/result 的 isError 标记失败 callId,只记成功的。
971
1134
  * turn-stopping 时本回合 call+result 同处一个增量窗,单次扫描即可关联;无 callId 的事件
972
1135
  * (异常/旧形状/测试 mock)保守直记,避免漏门禁。
973
- * fork 前缀修复(源码核对 0.1.3 后落地):子代理会话 fork 自父会话时,
1136
+ * fork 前缀:子代理会话 fork 自父会话时,
974
1137
  * 事件流含父事件前缀,游标从 0 首读会把父会话的历史编辑一并计入、多跑门禁。
975
1138
  * Session.inheritedEventCount(0.1.2-rc.1 起公开只读)给出 fork 切点——首读下限
976
1139
  * 取切点而非 0,父前缀天然跳过;缺属性退回 0(旧行为,保守不漏门禁)。
@@ -978,8 +1141,22 @@ function accumulatorOf(rt, sessionId) {
978
1141
  async function readEditedFiles(rt, agent, acc) {
979
1142
  const { session } = agent;
980
1143
  const sessionId = session.id;
981
- const window = await resolveEventWindow(rt.svc.get("sessionQuery"), sessionId, rt.lastSeqBySession);
982
- if (window === void 0) return;
1144
+ const query = rt.svc.get("sessionQuery");
1145
+ if (query === void 0) {
1146
+ if (!rt.queryMissingWarned) {
1147
+ rt.queryMissingWarned = true;
1148
+ rt.log.warn("[quality-gate] sessionQuery service unavailable; edit accounting is off, so no gate will run");
1149
+ }
1150
+ return;
1151
+ }
1152
+ const window = await resolveEventWindow(query, sessionId, rt.lastSeqBySession);
1153
+ if (window === void 0) {
1154
+ if (!rt.queryReadWarned) {
1155
+ rt.queryReadWarned = true;
1156
+ rt.log.warn("[quality-gate] session event read returned no window; this turn's gate is skipped (further read failures stay silent)");
1157
+ }
1158
+ return;
1159
+ }
983
1160
  rt.lastSeqBySession.set(sessionId, window.from + window.events.length);
984
1161
  pruneToSessionBudget(rt.lastSeqBySession, sessionId);
985
1162
  const edits = collectEdits(window.events);
@@ -1219,6 +1396,8 @@ function apply(ctx, config) {
1219
1396
  gateAbort: new AbortController(),
1220
1397
  inFlightGates: /* @__PURE__ */ new Set(),
1221
1398
  shellWarned: false,
1399
+ queryMissingWarned: false,
1400
+ queryReadWarned: false,
1222
1401
  disposed: false
1223
1402
  };
1224
1403
  svc.on("agent/turn-stopping", async (payload) => {
@@ -1242,5 +1421,4 @@ var host_default = {
1242
1421
  Config,
1243
1422
  apply
1244
1423
  };
1245
- //#endregion
1246
- export { Config, apply, host_default as default };
1424
+ export { Config, apply, host_default as default, shellArg };