@jayyuen66/dsh-quality-gate 0.1.2 → 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
@@ -10,7 +10,7 @@ import { deriveProjectKey } from "@jayyuen66/dsh-plugin-shared/lib/project-key";
10
10
  import { editPathOf, scanToolEvents } from "@jayyuen66/dsh-plugin-shared/lib/tool-events";
11
11
  import { settleLessonCall } from "@jayyuen66/dsh-plugin-shared/lib/lesson-bus";
12
12
  import { fieldOf, isRecord } from "@jayyuen66/dsh-plugin-shared/lib/record";
13
- //#region lib/accumulator.ts
13
+ // lib/accumulator.ts
14
14
  /**
15
15
  * 需要过门禁的扩展名(代码与配置;md/txt/图片等免检)。
16
16
  *
@@ -20,7 +20,11 @@ import { fieldOf, isRecord } from "@jayyuen66/dsh-plugin-shared/lib/record";
20
20
  */
21
21
  const GATED_EXT = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|rs|py|json|ya?ml|toml)$/iu;
22
22
  /** 编辑类工具名。str_replace_editor 是 dsh-base 并存的另一套编辑器
23
- * (host 侧只对 create/str_replace/insert 记账,view 只读不入这里)。 */
23
+ * (host 侧只对 create/str_replace/insert 记账,view 只读不入这里)。
24
+ *
25
+ * 这是**唯一真源**:host 侧的同名判定(isEditTool)也用这一份,两边各写一份时新增
26
+ * 编辑器工具只改一侧就会静默分叉——只改 host 则累加器静默丢弃记账,只改这里则 host
27
+ * 根本不会调用 note。 */
24
28
  const EDIT_TOOLS = /* @__PURE__ */ new Set([
25
29
  "edit",
26
30
  "write",
@@ -34,24 +38,26 @@ var EditAccumulator = class {
34
38
  /** 容量淘汰累计丢弃数:drain 时清零(静默淘汰要可观测)。 */
35
39
  dropped = 0;
36
40
  constructor(options = {}) {
37
- 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;
38
43
  }
39
44
  /** 记一笔:编辑类工具 + 代码/配置扩展名 + 非空路径才收;容量淘汰时计数。 */
40
45
  note(name, filePath) {
41
46
  if (name === void 0 || !EDIT_TOOLS.has(name)) return;
42
47
  if (typeof filePath !== "string" || filePath.length === 0) return;
43
48
  if (!GATED_EXT.test(filePath)) return;
44
- 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();
45
51
  this.files.add(filePath);
46
52
  }
47
53
  /**
48
- * 淘汰插入序最旧的一项并计数。`maxFiles` 配成 0 时调用点对空集也成立,所以取到的首项
49
- * 可以不存在——那一支什么都不做,由该退化配置的用例钉住。
50
- * 这里只需要**取首项这一次动作**,不需要迭代:`for…of` + 无条件 `break` 正是
51
- * eslint(no-unreachable-loop) 判的「循环体只允许一次迭代」——循环结构在这里不承载任何语义。
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",
@@ -119,8 +140,7 @@ function detectGate(roots) {
119
140
  }
120
141
  return out;
121
142
  }
122
- //#endregion
123
- //#region lib/messages.ts
143
+ // lib/messages.ts
124
144
  const MESSAGES = {
125
145
  zh: {
126
146
  evidenceTruncated: "[证据截断] {label} 内存里只保留尾部(首个错误可能在被丢弃的头部)。完整输出:{spill}",
@@ -171,33 +191,41 @@ const MESSAGES = {
171
191
  };
172
192
  /** 渲染模板里的 {name} 占位符;未提供的名字留空串(宁可少一段说明,也不给模型留裸花括号)。
173
193
  * 与 client 半官方 `@deepseek-ai/dsh-client-locale` 的 Translate 插值同语义(宿主同规则)。
194
+ *
195
+ * 查参数用 `Object.hasOwn` 而不是直接下标:模板里若出现 `{constructor}` / `{toString}` 这类被
196
+ * Object.prototype 覆盖的名字,裸下标取到的是函数对象、`String()` 之后会渲染成源码串,
197
+ * 与"未提供的名字留空串"这条契约相反。
174
198
  * @param text - 字典里的整行模板。
175
199
  * @param params - 占位符名 → 值(数字会转成字符串)。
176
200
  * @returns 替换后的文本。
177
201
  */
178
202
  function renderTemplate(text, params) {
179
- return text.replaceAll(/\{(?<key>\w+)\}/gu, (_all, key) => {
180
- const value = params[key];
181
- return value === void 0 ? "" : String(value);
182
- });
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() : "";
183
214
  }
184
- //#endregion
185
- //#region lib/feedback-content.ts
186
215
  /** 门禁失败 → 记忆正文:单行化(防换行噪音)。
216
+ * 三处插值都要折叠:`failure` 来自被检查仓库的自由文本,`command` 来自配置里的自定义命令,
217
+ * `root` 可以是任何路径——任一处带换行/制表符,这条"一条记忆正文"就会被 auto-recall 拆成多段。
187
218
  * 不截断:截尾会吃掉多根错误里的中间根因;记忆网关的入库限额由网关侧把关,这里送全文。
188
219
  * 正文语言随官方 locale 偏好走(messages 由调用方按回合取出后传进来)——这条文本会被
189
220
  * auto-recall 注入后续会话的上下文,是模型读的,不是日志。 */
190
221
  function buildFeedbackText({ command, root, failure }, messages) {
191
- const cmd = command.join(" ");
192
- const tail = failure.replaceAll(/\s+/gu, " ").trim();
193
222
  return renderTemplate(messages.memoryFeedback, {
194
- command: cmd,
195
- root,
196
- failure: tail
223
+ command: oneLine(command.join(" ")),
224
+ root: oneLine(root),
225
+ failure: oneLine(failure)
197
226
  });
198
227
  }
199
- //#endregion
200
- //#region lib/gateway-target.ts
228
+ // lib/gateway-target.ts
201
229
  /** env 值 trim 后非空则用之,否则回到 fallback(保留原 `||` 语义,
202
230
  * 又以显式比较规避 prefer-nullish-coalescing 与 strict-boolean-expressions)。 */
203
231
  function envOr(key, fallback) {
@@ -210,15 +238,44 @@ function envOr(key, fallback) {
210
238
  * 端口写死成默认,等于让别人的每次门禁失败都去连一个不存在的服务。设置优先于 env 的理由:
211
239
  * 设置卡是用户显式配置面,env 只能兜底,
212
240
  * 否则用户改了设置卡仍被一个看不见的环境变量越权。
241
+ *
242
+ * 解析不出合法 URL 时同样返回空串(按"未配置"处理):`http://` 这类半截串裁掉斜杠后剩
243
+ * `http:`,如果原样透给调用方,凭据安全判据会把它报成"明文 http 且非回环"——把配置写错
244
+ * 误报成协议不安全,用户照提示改成 https 也修不好。
213
245
  */
214
246
  function resolveGatewayUrl(settingUrl) {
215
247
  const configured = settingUrl.trim();
216
- 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);
217
265
  }
218
266
  /**
219
267
  * 凭据只允许发往 https 或回环地址:明文 http 到远端会把 Bearer 令牌暴露给同网段
220
268
  * (设置卡默认值就是本机 http,属有意用法)。返回 false 时调用方按"未配置"处理,
221
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。
222
279
  */
223
280
  function isCredentialSafeUrl(url) {
224
281
  let parsed;
@@ -230,25 +287,49 @@ function isCredentialSafeUrl(url) {
230
287
  if (parsed.protocol === "https:") return true;
231
288
  if (parsed.protocol !== "http:") return false;
232
289
  const host = parsed.hostname.replaceAll(/^\[|\]$/gu, "");
233
- return host === "localhost" || host === "127.0.0.1" || host === "::1";
290
+ return host === "localhost" || isIpv4Loopback(host) || host === "::1" || host.startsWith("::ffff:7f");
234
291
  }
235
- /** x-tdai-service-id:env 可覆盖,默认是网关协议的匿名服务名(不含任何机器信息)。 */
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 可覆盖,形状不合规时回落默认值。 */
236
299
  function resolveServiceId() {
237
- return envOr("TDAI_SERVICE_ID", "default");
300
+ const configured = envOr("TDAI_SERVICE_ID", "");
301
+ return SERVICE_ID_RE.test(configured) ? configured : DEFAULT_SERVICE_ID;
238
302
  }
239
- /** cwd → agent_id:'尾目录名-<cwd 前 8 位 sha256>'(与网关插件 workspace-peer 同分桶语义,
240
- * 读/写同键,保证反馈沉淀进 auto-recall 同一记忆桶)。
241
- * 派生本体在 shared/lib/project-key.ts:lesson-loop 的 project 与这里同源一份实现,
242
- * 此前两处各存一份,改一处漏一处就把"教训 ↔ 记忆"的跨系统对照拆断了。 */
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
+ */
243
323
  function deriveAgentId(cwd) {
244
324
  return deriveProjectKey(cwd);
245
325
  }
246
- //#endregion
247
- //#region lib/gateway-feedback.ts
326
+ // lib/gateway-feedback.ts
248
327
  /** 反馈尽力而为:5s 封顶,超时/失败只 warn(不吃掉门禁主流程)。 */
249
328
  const FEEDBACK_TIMEOUT_MS = 5e3;
250
329
  /** 沉淀门禁失败到 L0 记忆(role=assistant:这是 agent 自身行为教训,归顺 assistant 提炼面)。 */
251
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 都必须非空(目标面应由调用方解析后传入)");
252
333
  const agentId = deriveAgentId(input.cwd);
253
334
  const content = buildFeedbackText(input, messages);
254
335
  const payload = JSON.stringify({
@@ -259,21 +340,23 @@ async function pushGateFeedback(input, target, messages) {
259
340
  content
260
341
  }]
261
342
  });
262
- const res = await fetch(`${target.url}/v2/conversation/add`, {
343
+ const res = await fetch(`${base}/v2/conversation/add`, {
263
344
  method: "POST",
264
345
  headers: {
265
346
  Authorization: `Bearer ${target.key}`,
266
- "x-tdai-service-id": resolveServiceId(),
347
+ "x-tdai-service-id": target.serviceId,
267
348
  "Content-Type": "application/json"
268
349
  },
269
350
  body: payload,
270
351
  signal: AbortSignal.timeout(FEEDBACK_TIMEOUT_MS)
271
352
  });
272
- if (!res.ok) throw new Error(`gateway feedback HTTP ${res.status}`);
273
- 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
+ }
274
358
  }
275
- //#endregion
276
- //#region host.ts
359
+ // host.ts
277
360
  /**
278
361
  * 常见项目根标记文件:向上找最近的含清单目录。
279
362
  * ruff.toml 必须在列:只有 ruff 配置、无 pyproject.toml 的 Python
@@ -281,10 +364,12 @@ async function pushGateFeedback(input, target, messages) {
281
364
  * 也就永远没有门禁。
282
365
  */
283
366
  const PKG_MANIFEST = "package.json";
367
+ const TSCONFIG_MANIFEST = "tsconfig.json";
368
+ const PYPROJECT_MANIFEST = "pyproject.toml";
284
369
  const MANIFESTS = [
285
370
  PKG_MANIFEST,
286
371
  "Cargo.toml",
287
- "pyproject.toml",
372
+ PYPROJECT_MANIFEST,
288
373
  "pnpm-workspace.yaml",
289
374
  "ruff.toml"
290
375
  ];
@@ -608,9 +693,11 @@ function classifyRun(messages, gate, budgetMs, res, log) {
608
693
  text: `exit=${res.exitCode}\n${evidenceText(messages, res.stdout, "stdout")}${evidenceText(messages, res.stderr, "stderr")}`
609
694
  };
610
695
  }
611
- /** 是否是会被记账的编辑类工具(edit/write + str_replace_editor)。 */
696
+ /** 是否是会被记账的编辑类工具(edit/write + str_replace_editor)。名单的真源在
697
+ * lib/accumulator.ts 的 EDIT_TOOLS:host 先用它过滤、累加器再用同一集合过滤一次,
698
+ * 两边各写一份时新增编辑器工具只改一侧就会静默分叉。 */
612
699
  function isEditTool(name) {
613
- return name === "edit" || name === "write" || name === "str_replace_editor";
700
+ return EDIT_TOOLS.has(name);
614
701
  }
615
702
  /**
616
703
  * 根 package.json 已声明的 scripts 名清单(值是非空字符串才算"声明了这条脚本")。
@@ -621,7 +708,7 @@ function isEditTool(name) {
621
708
  function declaredScriptsOf(root) {
622
709
  let names;
623
710
  try {
624
- const raw = JSON.parse(readFileSync(`${root}/package.json`, "utf8"));
711
+ const raw = JSON.parse(readFileSync(path.join(root, PKG_MANIFEST), "utf8"));
625
712
  const scripts = fieldOf(raw, "scripts");
626
713
  if (isRecord(scripts)) names = Object.entries(scripts).filter(([, body]) => typeof body === "string" && body.length > 0).map(([name]) => name);
627
714
  } catch {
@@ -634,6 +721,33 @@ function isArrayDelivery(value) {
634
721
  return Array.isArray(value);
635
722
  }
636
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
+ /**
637
751
  * 解析事件游标窗口:fork 切点下限 vs 上次已读 seq 取大,返回起始 seq 与本批事件。
638
752
  * 事件流取自官方 `ctx.sessionQuery.observeSession(sessionId)`(见上面 SessionQueryFace:
639
753
  * 一次交回 `events` 与 `inheritedEventCount`)。两条降级路径一律 `undefined`(调用方直接
@@ -704,7 +818,7 @@ function findManifestRoot(file, maxDepth) {
704
818
  let found;
705
819
  let hitDepthLimit = false;
706
820
  for (let depth = 0; depth < maxDepth; depth += 1) {
707
- for (const manifest of MANIFESTS) if (existsSync(`${dir}/${manifest}`)) {
821
+ for (const manifest of MANIFESTS) if (existsSync(path.join(dir, manifest))) {
708
822
  found = dir;
709
823
  break;
710
824
  }
@@ -726,7 +840,7 @@ function findManifestRoot(file, maxDepth) {
726
840
  function workspaceRootUpgrade(root, maxDepth) {
727
841
  let up = path.dirname(root);
728
842
  for (let depth = 0; depth < maxDepth; depth += 1) {
729
- if (existsSync(`${up}/pnpm-workspace.yaml`)) return up;
843
+ if (existsSync(path.join(up, "pnpm-workspace.yaml"))) return up;
730
844
  const parent = path.dirname(up);
731
845
  if (parent === up) break;
732
846
  up = parent;
@@ -740,20 +854,22 @@ function workspaceRootUpgrade(root, maxDepth) {
740
854
  * - tsconfig.json 不作为"根标记"(不进 MANIFESTS,避免改动根向上探测的语义),
741
855
  * 但要进清单集合——gate-detect 用它区分"真 TS 项目"与"只有 package.json 的空壳";
742
856
  * - scripts:根 package.json 已声明的脚本名清单,交给 gate-detect 判定 pnpm check
743
- * 是否可用。**这里只做如实登记,不做取舍**(探测策略集中在纯函数里)。
857
+ * 是否可用。**这里只做如实登记,不做取舍**(探测策略集中在纯函数里);
858
+ * - ruffConfigured:根 pyproject.toml 里是否真有 `[tool.ruff]` 一族段,交给 gate-detect
859
+ * 判定 ruff 门禁是否可用。理由同上——只登记,不取舍。
744
860
  */
745
861
  function probeProjectRoot(root) {
746
- const manifests = MANIFESTS.filter((manifest) => existsSync(`${root}/${manifest}`));
747
- 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);
748
864
  const scripts = manifests.includes("package.json") ? declaredScriptsOf(root) : void 0;
749
- return scripts === void 0 ? {
865
+ const ruffConfigured = manifests.includes(PYPROJECT_MANIFEST) ? ruffConfiguredOf(root) : void 0;
866
+ const entry = {
750
867
  root,
751
868
  files: manifests
752
- } : {
753
- root,
754
- files: manifests,
755
- scripts
756
869
  };
870
+ if (scripts !== void 0) entry.scripts = scripts;
871
+ if (ruffConfigured !== void 0) entry.ruffConfigured = ruffConfigured;
872
+ return entry;
757
873
  }
758
874
  /** 注入载荷组装(id 唯一,同一毫秒多次注入也不碰撞)。 */
759
875
  const injectMessage = (text) => ({
@@ -828,7 +944,8 @@ async function resolveFeedbackTarget(cfg, credentials) {
828
944
  ok: true,
829
945
  target: {
830
946
  url,
831
- key: hit.value
947
+ key: hit.value,
948
+ serviceId: resolveServiceId()
832
949
  }
833
950
  };
834
951
  }
@@ -1304,5 +1421,4 @@ var host_default = {
1304
1421
  Config,
1305
1422
  apply
1306
1423
  };
1307
- //#endregion
1308
1424
  export { Config, apply, host_default as default, shellArg };
package/package.json CHANGED
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "@jayyuen66/dsh-quality-gate",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "dsh 插件:回合收口质量门禁(在 agent/turn-stopping 按项目形态跑 pnpm check / cargo check / ruff check / tsc,未过则注入修复指令开新轮)",
5
+ "keywords": [
6
+ "ci",
7
+ "code-quality",
8
+ "cordis",
9
+ "dsh",
10
+ "dsh-plugin",
11
+ "gate"
12
+ ],
5
13
  "license": "MIT",
6
14
  "author": "JayYuen",
7
15
  "repository": {
@@ -31,50 +39,50 @@
31
39
  "registry": "https://registry.npmjs.org"
32
40
  },
33
41
  "dependencies": {
34
- "@jayyuen66/dsh-plugin-shared": "^0.1.6",
42
+ "@jayyuen66/dsh-plugin-shared": "0.1.9",
35
43
  "typescript": "7.0.2"
36
44
  },
37
45
  "devDependencies": {
38
46
  "@deepseek-ai/cordis": "4.0.5",
39
- "@deepseek-ai/dsh-agent": "0.2.1-rc.2",
40
- "@deepseek-ai/dsh-api-remotes": "0.2.1-rc.2",
41
- "@deepseek-ai/dsh-client-locale": "0.2.1-rc.2",
42
- "@deepseek-ai/dsh-client-store": "0.2.1-rc.2",
43
- "@deepseek-ai/dsh-client-ui-layout": "0.2.1-rc.2",
44
- "@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.1-rc.2",
45
- "@deepseek-ai/dsh-client-ui-renderer": "0.2.1-rc.2",
46
- "@deepseek-ai/dsh-client-ui-settings": "0.2.1-rc.2",
47
- "@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.2",
48
- "@deepseek-ai/dsh-credentials": "0.2.1-rc.2",
49
- "@deepseek-ai/dsh-llm": "0.2.1-rc.2",
50
- "@deepseek-ai/dsh-package-manifest": "0.2.1-rc.2",
51
- "@deepseek-ai/dsh-sandbox": "0.2.1-rc.2",
52
- "@deepseek-ai/dsh-sandbox-policy": "0.2.1-rc.2",
53
- "@deepseek-ai/dsh-scope": "0.2.1-rc.2",
54
- "@deepseek-ai/dsh-session": "0.2.1-rc.2",
55
- "@deepseek-ai/dsh-session-projection": "0.2.1-rc.2",
56
- "@deepseek-ai/dsh-session-query": "0.2.1-rc.2",
57
- "@deepseek-ai/dsh-session-title": "0.2.1-rc.2",
58
- "@deepseek-ai/dsh-settings": "0.2.1-rc.2",
59
- "@deepseek-ai/dsh-shell": "0.2.1-rc.2",
60
- "@deepseek-ai/dsh-subprocess": "0.2.1-rc.2",
47
+ "@deepseek-ai/dsh-agent": "0.2.1-rc.3",
48
+ "@deepseek-ai/dsh-api-remotes": "0.2.1-rc.3",
49
+ "@deepseek-ai/dsh-client-locale": "0.2.1-rc.3",
50
+ "@deepseek-ai/dsh-client-store": "0.2.1-rc.3",
51
+ "@deepseek-ai/dsh-client-ui-layout": "0.2.1-rc.3",
52
+ "@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.1-rc.3",
53
+ "@deepseek-ai/dsh-client-ui-renderer": "0.2.1-rc.3",
54
+ "@deepseek-ai/dsh-client-ui-settings": "0.2.1-rc.3",
55
+ "@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.3",
56
+ "@deepseek-ai/dsh-credentials": "0.2.1-rc.3",
57
+ "@deepseek-ai/dsh-llm": "0.2.1-rc.3",
58
+ "@deepseek-ai/dsh-package-manifest": "0.2.1-rc.3",
59
+ "@deepseek-ai/dsh-sandbox": "0.2.1-rc.3",
60
+ "@deepseek-ai/dsh-sandbox-policy": "0.2.1-rc.3",
61
+ "@deepseek-ai/dsh-scope": "0.2.1-rc.3",
62
+ "@deepseek-ai/dsh-session": "0.2.1-rc.3",
63
+ "@deepseek-ai/dsh-session-projection": "0.2.1-rc.3",
64
+ "@deepseek-ai/dsh-session-query": "0.2.1-rc.3",
65
+ "@deepseek-ai/dsh-session-title": "0.2.1-rc.3",
66
+ "@deepseek-ai/dsh-settings": "0.2.1-rc.3",
67
+ "@deepseek-ai/dsh-shell": "0.2.1-rc.3",
68
+ "@deepseek-ai/dsh-subprocess": "0.2.1-rc.3",
61
69
  "@deepseek-ai/schemastery": "3.18.5",
62
70
  "@types/node": "26.6.5",
63
71
  "@types/react": "19.3.0",
64
72
  "@vitest/coverage-v8": "5.0.5",
65
73
  "eslint-plugin-sonarjs": "4.2.2",
66
- "oxfmt": "0.73.0",
67
- "oxlint": "1.88.0",
68
- "oxlint-tsgolint": "7.0.2004",
74
+ "oxfmt": "0.74.0",
75
+ "oxlint": "1.89.0",
76
+ "oxlint-tsgolint": "7.0.2005",
69
77
  "react": "^19.3.0",
70
- "rolldown": "1.2.14",
78
+ "rolldown": "1.2.15",
71
79
  "typescript": "7.0.2",
72
- "vite": "8.3.4",
80
+ "vite": "8.3.5",
73
81
  "vitest": "5.0.5"
74
82
  },
75
83
  "peerDependencies": {
76
- "@deepseek-ai/dsh": "^0.2.1-rc.2",
77
- "@deepseek-ai/dsh-credentials": "0.2.1-rc.2",
84
+ "@deepseek-ai/dsh": "^0.2.1-rc.3",
85
+ "@deepseek-ai/dsh-credentials": "0.2.1-rc.3",
78
86
  "@deepseek-ai/schemastery": "3.18.5"
79
87
  },
80
88
  "peerDependenciesMeta": {
@@ -84,7 +92,7 @@
84
92
  },
85
93
  "icon": "icon.svg",
86
94
  "engines": {
87
- "dsh": "^0.2.1-rc.2",
95
+ "dsh": "^0.2.1-rc.3",
88
96
  "node": ">=22.12.0"
89
97
  },
90
98
  "dsh": {
@@ -98,7 +106,7 @@
98
106
  }
99
107
  },
100
108
  "scripts": {
101
- "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json",
109
+ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json && tsc --noEmit -p tsconfig.build-scripts.json",
102
110
  "lint": "oxlint .",
103
111
  "test": "vitest run --coverage",
104
112
  "check:pack": "node pack-check.mjs",