@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/CHANGELOG.md +79 -0
- package/LICENSE +1 -1
- package/README.md +178 -93
- package/README.zh-CN.md +180 -91
- package/client.js +45 -26
- package/cordis.patch.yml +3 -1
- package/host.js +176 -60
- package/package.json +41 -33
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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:
|
|
195
|
-
root,
|
|
196
|
-
failure:
|
|
223
|
+
command: oneLine(command.join(" ")),
|
|
224
|
+
root: oneLine(root),
|
|
225
|
+
failure: oneLine(failure)
|
|
197
226
|
});
|
|
198
227
|
}
|
|
199
|
-
|
|
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
|
-
|
|
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 === "
|
|
290
|
+
return host === "localhost" || isIpv4Loopback(host) || host === "::1" || host.startsWith("::ffff:7f");
|
|
234
291
|
}
|
|
235
|
-
/** x-tdai-service-id
|
|
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
|
-
|
|
300
|
+
const configured = envOr("TDAI_SERVICE_ID", "");
|
|
301
|
+
return SERVICE_ID_RE.test(configured) ? configured : DEFAULT_SERVICE_ID;
|
|
238
302
|
}
|
|
239
|
-
/**
|
|
240
|
-
*
|
|
241
|
-
*
|
|
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
|
-
|
|
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(`${
|
|
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":
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
747
|
-
if (existsSync(
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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.
|
|
40
|
-
"@deepseek-ai/dsh-api-remotes": "0.2.1-rc.
|
|
41
|
-
"@deepseek-ai/dsh-client-locale": "0.2.1-rc.
|
|
42
|
-
"@deepseek-ai/dsh-client-store": "0.2.1-rc.
|
|
43
|
-
"@deepseek-ai/dsh-client-ui-layout": "0.2.1-rc.
|
|
44
|
-
"@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.1-rc.
|
|
45
|
-
"@deepseek-ai/dsh-client-ui-renderer": "0.2.1-rc.
|
|
46
|
-
"@deepseek-ai/dsh-client-ui-settings": "0.2.1-rc.
|
|
47
|
-
"@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.
|
|
48
|
-
"@deepseek-ai/dsh-credentials": "0.2.1-rc.
|
|
49
|
-
"@deepseek-ai/dsh-llm": "0.2.1-rc.
|
|
50
|
-
"@deepseek-ai/dsh-package-manifest": "0.2.1-rc.
|
|
51
|
-
"@deepseek-ai/dsh-sandbox": "0.2.1-rc.
|
|
52
|
-
"@deepseek-ai/dsh-sandbox-policy": "0.2.1-rc.
|
|
53
|
-
"@deepseek-ai/dsh-scope": "0.2.1-rc.
|
|
54
|
-
"@deepseek-ai/dsh-session": "0.2.1-rc.
|
|
55
|
-
"@deepseek-ai/dsh-session-projection": "0.2.1-rc.
|
|
56
|
-
"@deepseek-ai/dsh-session-query": "0.2.1-rc.
|
|
57
|
-
"@deepseek-ai/dsh-session-title": "0.2.1-rc.
|
|
58
|
-
"@deepseek-ai/dsh-settings": "0.2.1-rc.
|
|
59
|
-
"@deepseek-ai/dsh-shell": "0.2.1-rc.
|
|
60
|
-
"@deepseek-ai/dsh-subprocess": "0.2.1-rc.
|
|
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.
|
|
67
|
-
"oxlint": "1.
|
|
68
|
-
"oxlint-tsgolint": "7.0.
|
|
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.
|
|
78
|
+
"rolldown": "1.2.15",
|
|
71
79
|
"typescript": "7.0.2",
|
|
72
|
-
"vite": "8.3.
|
|
80
|
+
"vite": "8.3.5",
|
|
73
81
|
"vitest": "5.0.5"
|
|
74
82
|
},
|
|
75
83
|
"peerDependencies": {
|
|
76
|
-
"@deepseek-ai/dsh": "^0.2.1-rc.
|
|
77
|
-
"@deepseek-ai/dsh-credentials": "0.2.1-rc.
|
|
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.
|
|
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",
|