@leonhardthust/dsh-decision-engine 0.0.0-stage → 1.9.0

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +55 -2
  3. package/README.zh.md +57 -0
  4. package/config.example.yml +436 -0
  5. package/cordis.patch.yml +14 -0
  6. package/docs/assets/config-page.png +0 -0
  7. package/docs/assets/decisions-tab.png +0 -0
  8. package/docs/assets/hero.jpeg +0 -0
  9. package/docs/assets/stats-charts-preview.png +0 -0
  10. package/docs/assets/stats-donut-toggle.png +0 -0
  11. package/docs/assets/stats-interactive-charts.png +0 -0
  12. package/docs/assets/stats-point-donut.png +0 -0
  13. package/docs/assets/stats-ranking-bars.png +0 -0
  14. package/docs/lessons/cloudflare-builds-root-directory.md +51 -0
  15. package/docs/lessons/cold-start-timeout.md +134 -0
  16. package/docs/lessons/endpoint-path-vs-model.md +26 -0
  17. package/docs/lessons/model-field-required-by-provider.md +31 -0
  18. package/docs/local-dev-link.md +54 -0
  19. package/docs/releasing.md +67 -0
  20. package/docs/security-revision.md +130 -0
  21. package/icon.svg +6 -0
  22. package/lib/adapters/agent.js +314 -0
  23. package/lib/adapters/route.js +144 -0
  24. package/lib/adapters/tools.js +186 -0
  25. package/lib/adapters/translate.js +143 -0
  26. package/lib/approval-detail.js +325 -0
  27. package/lib/client.charts.js +36 -0
  28. package/lib/client.js +8589 -0
  29. package/lib/config-file.js +136 -0
  30. package/lib/credentials.js +187 -0
  31. package/lib/decision-cache.js +188 -0
  32. package/lib/decision-points.js +205 -0
  33. package/lib/decision-store.js +774 -0
  34. package/lib/endpoint-fallback.js +143 -0
  35. package/lib/engine.js +312 -0
  36. package/lib/guard/advisory.js +100 -0
  37. package/lib/guard/audit.js +79 -0
  38. package/lib/guard/context.js +128 -0
  39. package/lib/guard/effects.js +96 -0
  40. package/lib/guard/fallback.js +139 -0
  41. package/lib/guard/listener.js +191 -0
  42. package/lib/guard/policy.js +28 -0
  43. package/lib/home-paths.js +86 -0
  44. package/lib/index.js +3823 -0
  45. package/lib/outcomes.js +169 -0
  46. package/lib/points/spec.js +645 -0
  47. package/lib/providers.js +958 -0
  48. package/lib/resilience.js +308 -0
  49. package/lib/scope.js +68 -0
  50. package/lib/server-config.js +351 -0
  51. package/lib/session-events.js +75 -0
  52. package/lib/stats-store.js +916 -0
  53. package/lib/stats.js +1293 -0
  54. package/lib/telemetry.js +465 -0
  55. package/lib/tokenizer.js +201 -0
  56. package/lib/tolerance.js +498 -0
  57. package/lib/vendor/microcharts-charts.css +47 -0
  58. package/lib/vendor-src/entry.js +13 -0
  59. package/lib/vendor-src/jsx-shim.js +34 -0
  60. package/locale/en.json +6 -0
  61. package/locale/zh.json +6 -0
  62. package/package.json +101 -3
  63. package/scripts/migrate-profile.mjs +102 -0
@@ -0,0 +1,143 @@
1
+ /**
2
+ * 思维链翻译门槛:包住 `translator.translate()`。
3
+ *
4
+ * 翻译链路没有提供任何 hook —— `ctx.translator` 是一个 Service,`cotTranslation`
5
+ * 直接调用它的 `translate(spec, signal)`。所以这里只能包住那个方法。
6
+ *
7
+ * 包装是「有边界的、可撤销的」,不是野生 monkeypatch:
8
+ *
9
+ * - 用 `ctx.effect(...)` 注册,卸载时一定把原方法装回去;
10
+ * - 原方法的 `this` 保持为 translator 实例,签名与返回值完全不变;
11
+ * - 判定失败一律调用原方法(fail-open),绝不让翻译因为守卫而不可用;
12
+ * - 返回一个空串表示「不需要翻译」会破坏调用方语义,因此**跳过翻译时
13
+ * 返回原文**——调用方拿到的是「已经是目标语言」的合理解释。
14
+ *
15
+ * 生效方式与省 token 的关系:翻译按条计费。判为 skip 时直接返回原文,
16
+ * 那条就完全不发请求;判为 translate 时正常走下原方法。
17
+ *
18
+ * @module dsh-decision-engine/adapters/translate
19
+ */
20
+
21
+ /** 判断一段文本里「值得翻译的正文」占比是否够高(本地预筛,免费的)。 */
22
+ function looksLikeProse(text) {
23
+ if (typeof text !== "string" || text.length === 0) return false;
24
+ // 去掉代码块、行内代码、URL —— 这些不需要翻译。
25
+ const prose = text
26
+ .replace(/```[\s\S]*?```/g, " ")
27
+ .replace(/`[^`]*`/g, " ")
28
+ .replace(/https?:\/\/\S+/g, " ");
29
+ // 统计字母/汉字数量;纯符号/数字/路径不值得发一次翻译请求。
30
+ let letters = 0;
31
+ for (const char of prose) {
32
+ if (/[\p{L}]/u.test(char)) letters += 1;
33
+ }
34
+ return letters >= 4;
35
+ }
36
+
37
+ /**
38
+ * 包住 translator.translate。
39
+ *
40
+ * @param ctx - cordis 插件上下文。
41
+ * @param deps - 运行期依赖。
42
+ * @returns 是否成功挂载。
43
+ */
44
+ export function mountCotTranslatePoint(ctx, deps) {
45
+ const { runner, getConfig, telemetry } = deps;
46
+
47
+ const translator = ctx.get?.("translator");
48
+ if (translator === undefined || typeof translator.translate !== "function") {
49
+ ctx.logger?.info?.("dsh-decision-engine: 未找到 translator 服务,思维链翻译门槛不挂载");
50
+ return false;
51
+ }
52
+
53
+ const original = translator.translate;
54
+ /** 命中缓存的「需要翻译」判断:同一段文本重复翻译时不再问模型。 */
55
+ const decisions = new Map();
56
+ const MAX_DECISIONS = 256;
57
+
58
+ const remember = (key, value) => {
59
+ if (decisions.has(key)) decisions.delete(key);
60
+ decisions.set(key, value);
61
+ while (decisions.size > MAX_DECISIONS) {
62
+ const oldest = decisions.keys().next();
63
+ if (oldest.done) break;
64
+ decisions.delete(oldest.value);
65
+ }
66
+ };
67
+
68
+ const wrapped = async function translate(spec, signal) {
69
+ // 这个点关掉时连一次判断都不做,直接走原路径。
70
+ if (!runner.participates("cotTranslate")) return original.call(this, spec, signal);
71
+
72
+ try {
73
+ const text = typeof spec?.text === "string" ? spec.text : "";
74
+ // 本地预筛:明显不需要翻译的文本零成本跳过,不必问模型。
75
+ if (!looksLikeProse(text)) {
76
+ await telemetry.log({
77
+ ts: new Date().toISOString(),
78
+ point: "cotTranslate",
79
+ tag: "cotTranslate",
80
+ action: "skip",
81
+ detail: "decision-engine-cot: 本地预筛判定为无正文,跳过翻译",
82
+ latencyMs: 0,
83
+ cached: false,
84
+ });
85
+ return text;
86
+ }
87
+
88
+ // 缓存键:目标语言 + 文本摘要。同一段文本重复翻译(重放、重渲染)很常见。
89
+ const key = `${spec.targetLanguage ?? "auto"}|${text.length}|${text.slice(0, 64)}`;
90
+ let verdict = decisions.get(key);
91
+ if (verdict === undefined) {
92
+ const cfg = getConfig();
93
+ const result = await runner.ask(
94
+ "cotTranslate",
95
+ {
96
+ text,
97
+ targetLanguage: spec.targetLanguage,
98
+ sourceLanguage: spec.sourceLanguage,
99
+ head: text.slice(0, 400),
100
+ },
101
+ // `spec.sessionId` 只有**付费** provider 才有(AnonymousTranslationSpec
102
+ // 没有这个字段)。因此这个点的会话归属是「有则记、没有就算不可归属」:
103
+ // 免费通道(google / bing)的决策不会出现在任何会话的「决策历史」里。
104
+ // 这是刻意的取舍,不是遗漏——为它编造一个会话 id 会让用户看到一条
105
+ // 不属于自己会话的记录,比缺失更糟(见 DEC-022)。
106
+ { signal, sessionId: typeof spec.sessionId === "string" ? spec.sessionId : "" },
107
+ );
108
+ // 降级 / observe / 关闭:一律按「需要翻译」处理,走原方法。
109
+ if (result.action === undefined || !runner.enforces("cotTranslate")) {
110
+ return original.call(this, spec, signal);
111
+ }
112
+ verdict = result.action.kind === "translate";
113
+ remember(key, verdict);
114
+ }
115
+
116
+ if (verdict === true) return original.call(this, spec, signal);
117
+
118
+ // 判为不需要翻译:返回原文。这与「已经是目标语言」的语义一致,
119
+ // 调用方不会看到异常,用户看到的还是那段文本。
120
+ await telemetry.log({
121
+ ts: new Date().toISOString(),
122
+ point: "cotTranslate",
123
+ tag: "cotTranslate",
124
+ action: "skip",
125
+ detail: "decision-engine-cot: 决策判定无需翻译,返回原文",
126
+ latencyMs: 0,
127
+ cached: true,
128
+ });
129
+ return text;
130
+ } catch (error) {
131
+ telemetry.noteAdapterError("cotTranslate", error, ctx);
132
+ // 任何意外都必须回到原生行为:翻译不能因为守卫而不可用。
133
+ return original.call(this, spec, signal);
134
+ }
135
+ };
136
+
137
+ translator.translate = wrapped;
138
+ // 卸载时恢复原方法:否则插件热卸载后 translator 会一直指着我们的包装。
139
+ ctx.effect(() => () => {
140
+ if (translator.translate === wrapped) translator.translate = original;
141
+ });
142
+ return true;
143
+ }
@@ -0,0 +1,325 @@
1
+ /**
2
+ * 审批卡正文那块命令的可读化:**把一条 shell 命令拆成能读的几条**。
3
+ *
4
+ * ## 为什么需要这个模块
5
+ *
6
+ * 宿主审批卡(`conversation.approval.detail`)只把 `args.command` 原文原样
7
+ * 放进一个等宽字体块里,样式是 `word-break: break-all` 且**没有**
8
+ * `white-space: pre-wrap`——换行全被折叠。于是一条十几行的 `python3 - <<'PY'`
9
+ * 会把整张卡撑成一坨既不分行、也看不出在干什么的墙。
10
+ *
11
+ * 我们接管那个 slot 之后就能自己渲染,但「能读」不等于「格式化好看」:
12
+ * 用户真正想知道的是**这条命令都做了哪些事、其中哪些是危险动作**。
13
+ * 决策端点只有 choice / score / noul 三种题型、没有自由文本,所以这件事
14
+ * 不能交给小模型,只能在这里用**确定性**的切分规则做。
15
+ *
16
+ * ## 设计上的三条原则
17
+ *
18
+ * 1. **绝不改写原文**:摘要只是「在原文之上加一层导读」,原文必须能原样展开
19
+ * 看到。用户按「允许」之后真正执行的是原文,摘要与原文不一致就是欺骗。
20
+ * 2. **宁可少说,不要乱说**:切分只看确定的结构(换行、`;`、`&&`、`||`、
21
+ * 管道、heredoc 包裹),猜不到的一律不猜。识别不出的部分归到最后一段,
22
+ * 而不是假装它是某条命令。
23
+ * 3. **纯函数**:不发请求、不读文件、不碰 DOM。所以它能在 Node 里单测,
24
+ * 也能被 client bundle 直接引用。
25
+ *
26
+ * @module dsh-decision-engine/approval-detail
27
+ */
28
+
29
+ /** 一条命令最多列几条摘要;超出的合并成「还有 N 条」。 */
30
+ export const MAX_SUMMARY_STEPS = 12;
31
+
32
+ /** 每条摘要最多显示多少个字符,超出截断。 */
33
+ export const MAX_STEP_CHARS = 72;
34
+
35
+ /**
36
+ * 危险动作的识别规则:命中即给该条摘要标一个警示级别。
37
+ *
38
+ * 这些判断刻意**粗**——它们是提示,不是判据。真正的拦截在守卫的
39
+ * `decideGuard()` 里,这里只负责让用户一眼看到「这条命令里有 rm -rf」。
40
+ *
41
+ * 顺序有意义:先匹配更高危的规则,同一条命令只取最严重的那级。
42
+ */
43
+ const DANGER_RULES = [
44
+ {
45
+ // 递归删除、强删、磁盘级写:破坏性最强。
46
+ level: "high",
47
+ pattern: /\brm\s+(?:-[a-zA-Z]*[rf][a-zA-Z]*\s+)+(?:\/|~|\$\w+|\*)/,
48
+ },
49
+ { level: "high", pattern: /\b(?:mkfs|dd\s+if=|>\/dev\/sd)\b/ },
50
+ { level: "high", pattern: /\bchmod\s+(?:-R\s+)?(?:777|a\+rwx)\b/ },
51
+ // 把远端内容直接交给 shell 执行:等价于执行任意代码。
52
+ { level: "high", pattern: /\b(?:curl|wget)\b[^|]*\|\s*(?:sudo\s+)?(?:ba|z|k|fi)?sh\b/ },
53
+ // 提权。
54
+ { level: "medium", pattern: /\bsudo\b/ },
55
+ // 覆盖面大的批量移动 / 删除。
56
+ { level: "medium", pattern: /\brm\s+-[a-zA-Z]*r[a-zA-Z]*\b/ },
57
+ { level: "medium", pattern: /\bmv\s+.*\s+(?:\/|~)(?:\s|$)/ },
58
+ // 进程 / 服务级改动。
59
+ { level: "medium", pattern: /\b(?:kill|killall|pkill|shutdown|reboot)\b/ },
60
+ // 会改仓库/系统状态、但不是破坏性的。
61
+ { level: "low", pattern: /\bgit\s+(?:push|reset\s+--hard|clean\s+-[a-zA-Z]*f)\b/ },
62
+ { level: "low", pattern: /\b(?:pip|npm|pnpm|yarn|cargo|gem|brew)\s+(?:install|add|uninstall|remove)\b/ },
63
+ { level: "low", pattern: /\b(?:apt|apt-get|yum|dnf|apk)\s+(?:install|remove|update|upgrade)\b/ },
64
+ ];
65
+
66
+ /**
67
+ * 危险级别的排序权重(数字越大越严重)。
68
+ * @param level - 级别 id。
69
+ * @returns 权重。
70
+ */
71
+ function dangerWeight(level) {
72
+ if (level === "high") return 3;
73
+ if (level === "medium") return 2;
74
+ if (level === "low") return 1;
75
+ return 0;
76
+ }
77
+
78
+ /**
79
+ * 判断一条命令片段的危险级别。
80
+ * @param text - 单条命令片段。
81
+ * @returns 最严重的级别 id,或 null(没命中任何规则)。
82
+ */
83
+ export function dangerLevelOf(text) {
84
+ const value = typeof text === "string" ? text : "";
85
+ let worst = null;
86
+ for (const rule of DANGER_RULES) {
87
+ if (rule.pattern.test(value) && dangerWeight(rule.level) > dangerWeight(worst)) {
88
+ worst = rule.level;
89
+ }
90
+ }
91
+ return worst;
92
+ }
93
+
94
+ /**
95
+ * 压成一行:把连续空白折叠成单个空格,去掉首尾空白。
96
+ * @param text - 任意文本。
97
+ * @returns 单行文本。
98
+ */
99
+ function oneLine(text) {
100
+ return String(text ?? "")
101
+ .replace(/\s+/g, " ")
102
+ .trim();
103
+ }
104
+
105
+ /**
106
+ * 把一段超长文本截断到 `MAX_STEP_CHARS`。
107
+ * @param text - 单行文本。
108
+ * @returns 截断后的文本。
109
+ */
110
+ function clip(text) {
111
+ if (text.length <= MAX_STEP_CHARS) return text;
112
+ return `${text.slice(0, MAX_STEP_CHARS - 1)}…`;
113
+ }
114
+
115
+ /**
116
+ * 找出「被包裹起来的整段脚本」:heredoc、`python3 -c "…"`、引号内的长字符串。
117
+ *
118
+ * 这类命令的重点不是它内部的语法,而是**它在执行一段多行脚本**——所以摘要
119
+ * 应该说「执行 python 脚本(N 行)」,而不是把脚本正文当成几条命令切开。
120
+ *
121
+ * @param command - 原始命令。
122
+ * @returns `{kind, lines}`,不是整段脚本时返回 null。
123
+ */
124
+ function wrappedScriptOf(command) {
125
+ // heredoc:`<<'PY'` / `<<-"EOF"` 等,正文在下一个分隔符行之间。
126
+ const heredoc = command.match(/<<[-~]?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/);
127
+ if (heredoc !== null) {
128
+ const marker = heredoc[2];
129
+ const bodyStart = command.indexOf("\n", heredoc.index);
130
+ const bodyEnd = bodyStart < 0 ? -1 : command.indexOf(marker, bodyStart);
131
+ const body = bodyStart < 0 ? "" : command.slice(bodyStart, bodyEnd < 0 ? undefined : bodyEnd);
132
+ const lines = body.split("\n").filter((line) => line.trim().length > 0).length;
133
+ return { kind: "heredoc", lines };
134
+ }
135
+ // 单行的 `python3 -c "…"` / `sh -c "…"`:也算「执行一段脚本文本」。
136
+ if (/\b(?:python[0-9.]*|node|ruby|perl|sh|bash|zsh)\s+-[cce]\b/.test(command) && command.includes("\n")) {
137
+ const lines = command.split("\n").filter((line) => line.trim().length > 0).length;
138
+ return { kind: "inline-script", lines };
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /**
144
+ * 把一条 shell 命令切成若干「步骤」。
145
+ *
146
+ * 切分只看**确定的分隔符**:换行、`;`、`&&`、`||`、`|`。引号与括号内部
147
+ * 的分隔符不切(否则 `echo "a; b"` 会被错误地拆成两条)。heredoc 的正文
148
+ * 整段保留,因为它是同一个命令的输入——但**摘要里只留它的开头一行**:
149
+ * 「python3 - <<'PY'」本身已经说明了一切,把那 4 行脚本正文压缩进一条摘要
150
+ * 只会重新变成一坨不可读的东西(正文在展开态里照原样显示)。
151
+ *
152
+ * 刻意**不**做完整的 shell 解析:真正的解析要处理转义、变量展开、子 shell、
153
+ * 函数定义……那是一整个 parser 的工作量,而这里只需要一个「大致看清有几步」
154
+ * 的导读。切不准时宁可把剩余部分并成一条。
155
+ *
156
+ * @param command - 原始命令文本。
157
+ * @returns 步骤数组:`{text, line}`,line 是它大致来自第几行(1 起)。
158
+ */
159
+ export function stepsOf(command) {
160
+ const source = typeof command === "string" ? command : "";
161
+ if (source.trim().length === 0) return [];
162
+
163
+ const steps = [];
164
+ let buffer = "";
165
+ let line = 1;
166
+ let bufferLine = 1;
167
+ // 引号状态:单引号、双引号各一个开关;heredoc 另算(见下)。
168
+ let inSingle = false;
169
+ let inDouble = false;
170
+ let heredocMarker = null;
171
+ /** heredoc 命中时,它开头那一行的文本(摘要要留这一行)。 */
172
+ let heredocHeader = "";
173
+
174
+ const flush = () => {
175
+ const text = oneLine(buffer);
176
+ if (text.length > 0) steps.push({ text, line: bufferLine });
177
+ buffer = "";
178
+ };
179
+
180
+ for (let index = 0; index < source.length; index += 1) {
181
+ const char = source[index];
182
+ const next = source[index + 1];
183
+
184
+ // heredoc:从命中 `<<'X'` 到分隔符行,整段不切。
185
+ if (heredocMarker === null) {
186
+ const rest = source.slice(index);
187
+ const heredocMatch = rest.match(/^<<[-~]?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/);
188
+ if (heredocMatch !== null && !inSingle && !inDouble) {
189
+ heredocMarker = heredocMatch[2];
190
+ // 记下「本步骤到 heredoc 标记为止」的文本作为摘要;正文丢掉。
191
+ buffer += heredocMatch[0];
192
+ heredocHeader = buffer;
193
+ index += heredocMatch[0].length - 1;
194
+ continue;
195
+ }
196
+ } else {
197
+ if (char === "\n") {
198
+ line += 1;
199
+ // 日分隔符单独成行才算结束。
200
+ const lineEnd = source.indexOf("\n", index + 1);
201
+ const currentLine = source.slice(index + 1, lineEnd < 0 ? undefined : lineEnd).trim();
202
+ if (currentLine === heredocMarker) {
203
+ // 整段脚本结束:把「开头一行」作为这一步的摘要,正文不进去。
204
+ buffer = heredocHeader;
205
+ heredocMarker = null;
206
+ heredocHeader = "";
207
+ flush();
208
+ // 跳过正文与分隔符行,避免它们进入下一步骤。
209
+ index = lineEnd < 0 ? source.length - 1 : lineEnd - 1;
210
+ bufferLine = line;
211
+ continue;
212
+ }
213
+ }
214
+ // 正文一律不写进 buffer(它由 summarizedCommand 的 script.lines 计数)。
215
+ continue;
216
+ }
217
+
218
+ if (char === "\n") {
219
+ flush();
220
+ line += 1;
221
+ bufferLine = line;
222
+ continue;
223
+ }
224
+ if (char === "'" && !inDouble) {
225
+ inSingle = !inSingle;
226
+ buffer += char;
227
+ continue;
228
+ }
229
+ if (char === '"' && !inSingle) {
230
+ inDouble = !inDouble;
231
+ buffer += char;
232
+ continue;
233
+ }
234
+ // 只在引号外切分。`;` 与 `&&` / `||` / `|` 都是步骤边界。
235
+ if (!inSingle && !inDouble) {
236
+ const isPipeline = char === "|" && next === "|";
237
+ const isAnd = char === "&" && next === "&";
238
+ const isPipe = char === "|" && !isPipeline;
239
+ if ((char === ";" || isAnd || isPipeline || isPipe) && buffer.trim().length > 0) {
240
+ flush();
241
+ if (isAnd || isPipeline) index += 1;
242
+ bufferLine = line;
243
+ continue;
244
+ }
245
+ // 开头就是分隔符(例如 `; ls`)时丢掉它,不要产生空步骤。
246
+ if (char === ";" || isAnd || isPipeline || isPipe) continue;
247
+ }
248
+ buffer += char;
249
+ }
250
+ // 收尾:heredoc 没找到结束标记时(命令被截断),把开头一行留下。
251
+ if (heredocMarker !== null) buffer = heredocHeader;
252
+ flush();
253
+ return steps;
254
+ }
255
+
256
+ /**
257
+ * 归纳一条命令:步骤清单 + 危险标记 + 是否整段脚本。
258
+ *
259
+ * 危险级别算**两次**:一次对整条原文(`danger`),一次对每个步骤(`steps[].danger`)。
260
+ * 为什么不能只算步骤:像 `curl https://x.sh | sh` 这种「把远端脚本喂给 shell」的
261
+ * 形态,切分后会变成 `curl https://x.sh` 与 `sh` 两条,单独看哪条都无害——
262
+ * 危险恰恰在它们的**组合**里。所以整条原文必须单独过一遍规则。
263
+ *
264
+ * @param command - 原始命令文本。
265
+ * @returns `{steps, script, danger, total, truncated, lines, dangerCount}`:
266
+ * - `steps`:`{text, line, danger}` 数组(已按 MAX_SUMMARY_STEPS 截断);
267
+ * - `script`:整段脚本的识别结果(`{kind, lines}` 或 null)——它决定标题怎么措辞;
268
+ * - `danger`:**整条命令**最严重的危险级别(null = 没命中任何规则);
269
+ * - `total`:切出来的总步骤数(未截断前);
270
+ * - `truncated`:是否因为条数上限被截断;
271
+ * - `lines`:原文行数;
272
+ * - `dangerCount`:带危险标记的步骤数(整条命中而步骤都没命中时至少为 1)。
273
+ */
274
+ export function summarizeCommand(command) {
275
+ const source = typeof command === "string" ? command : "";
276
+ const script = wrappedScriptOf(source);
277
+ const raw = stepsOf(source);
278
+ const total = raw.length;
279
+ const kept = raw.slice(0, MAX_SUMMARY_STEPS);
280
+ const steps = kept.map((step) => ({
281
+ text: clip(step.text),
282
+ line: step.line,
283
+ danger: dangerLevelOf(step.text),
284
+ }));
285
+ const danger = dangerLevelOf(source);
286
+ const flagged = steps.filter((step) => step.danger !== null).length;
287
+ return {
288
+ steps,
289
+ script,
290
+ danger,
291
+ total,
292
+ truncated: total > MAX_SUMMARY_STEPS,
293
+ lines: source.split("\n").length,
294
+ dangerCount: flagged === 0 && danger !== null ? 1 : flagged,
295
+ };
296
+ }
297
+
298
+ /**
299
+ * 把归纳结果压成一行给人看的摘要(纯文本,不渲染)。
300
+ *
301
+ * 为什么除了 React 组件还要有这一份:**同一个「这条命令在干什么」的判断
302
+ * 应该只有一处措辞**。组件把结构画出来,这里给出它的文字形式——测试对着
303
+ * 这一行断言,比对着 React 树断言稳得多;以后加日志或遥测也直接用它。
304
+ *
305
+ * @param command - 原始命令文本。
306
+ * @param wordings - 该语言的词句表;缺省用中文。
307
+ * @returns 一行摘要文本。
308
+ */
309
+ export function summaryLine(command, wordings = {}) {
310
+ const w = {
311
+ empty: "(空命令)",
312
+ steps: (n) => `${n} 个步骤`,
313
+ script: (kind, lines) => (kind === "heredoc" ? `执行一段脚本(${lines} 行)` : `执行一段内联脚本(${lines} 行)`),
314
+ danger: (n) => `含 ${n} 个危险动作`,
315
+ truncated: (n) => `另有 ${n} 个步骤未展开`,
316
+ ...wordings,
317
+ };
318
+ const result = summarizeCommand(command);
319
+ if (result.total === 0 && result.script === null) return w.empty;
320
+ const parts = [];
321
+ parts.push(result.script === null ? w.steps(result.total) : w.script(result.script.kind, result.script.lines));
322
+ if (result.danger !== null) parts.push(w.danger(result.dangerCount));
323
+ if (result.truncated) parts.push(w.truncated(result.total - MAX_SUMMARY_STEPS));
324
+ return parts.join(" · ");
325
+ }