@zhushanwen/pi-base-tool-enhance 0.2.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.
- package/README.md +31 -0
- package/index.ts +1 -0
- package/package.json +54 -0
- package/skills/base-tool-enhance-ext-config/SKILL.md +76 -0
- package/src/__tests__/background-lifecycle.test.ts +634 -0
- package/src/__tests__/bash-tool.test.ts +573 -0
- package/src/__tests__/config.test.ts +193 -0
- package/src/__tests__/force-patterns.test.ts +230 -0
- package/src/__tests__/index.test.ts +133 -0
- package/src/__tests__/kill-tree.test.ts +76 -0
- package/src/__tests__/notify.test.ts +335 -0
- package/src/__tests__/pending-reconcile.test.ts +237 -0
- package/src/__tests__/reaper.test.ts +373 -0
- package/src/__tests__/registry.test.ts +149 -0
- package/src/__tests__/task-store.test.ts +156 -0
- package/src/__tests__/tool-error-audit.test.ts +92 -0
- package/src/background/notify.ts +218 -0
- package/src/background/output-tail.ts +84 -0
- package/src/background/pending-reconcile.ts +169 -0
- package/src/background/poller.ts +91 -0
- package/src/background/process-exit-guard.ts +106 -0
- package/src/background/registry.ts +203 -0
- package/src/background/spawn-background.ts +275 -0
- package/src/background/subagent-guard.ts +21 -0
- package/src/background/task-store.ts +125 -0
- package/src/background/types.ts +103 -0
- package/src/bash-kill-tool.ts +144 -0
- package/src/bash-output-tool.ts +131 -0
- package/src/bash-tool.ts +226 -0
- package/src/config.ts +167 -0
- package/src/force-patterns.ts +236 -0
- package/src/index.ts +90 -0
- package/src/kill-tree.ts +100 -0
- package/src/reaper.ts +313 -0
- package/src/tool-error-audit.ts +78 -0
package/src/bash-tool.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bash 工具同名 override 定义(设计文档 docs/design/base-tool-enhance.md §3.2 方案 B / §3.5)。
|
|
3
|
+
*
|
|
4
|
+
* 前台行为 100% 委托 pi 官方工厂 createBashToolDefinition——override 后工具的全部
|
|
5
|
+
* 行为归本包负责,前台语义必须跟随 pi 版本升级而不是自研复刻(自研 spawn 会与
|
|
6
|
+
* pi 升级双向漂移,截断规则 / shellPath / PI_* env / stdin transport 细节遗漏即回归)。
|
|
7
|
+
* 本模块真正的增量:
|
|
8
|
+
* 1. input schema 新增 background?: boolean(D2:工具名保持 bash,只扩 schema)
|
|
9
|
+
* 2. description 重写——官方文案不含 background 用法,不重写则模型永远发现不了
|
|
10
|
+
* 新参数,「模型主动要求后台」的路径不可达
|
|
11
|
+
* 3. background:true 分支(M2):spawn 后台任务立即返回 task_id(D14:subagent
|
|
12
|
+
* 进程内降级忽略 background,走前台同步语义)
|
|
13
|
+
* 4. 白名单强制后台(M4,D3/D13):命令命中 force-test/force-longrun/用户正则 →
|
|
14
|
+
* 无视 background 参数强制后台,忽略 LLM 显式 timeout;双模式 timeout 配置注入
|
|
15
|
+
* (前台未填 → foregroundTimeoutSeconds,后台未填 → backgroundTimeoutSeconds)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { AgentToolUpdateCallback, BashToolDetails, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { createBashToolDefinition, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { Type } from "typebox";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
resolveBackgroundTimeoutSec,
|
|
24
|
+
spawnBackgroundTask,
|
|
25
|
+
truncateCommand,
|
|
26
|
+
} from "./background/spawn-background.ts";
|
|
27
|
+
import { isSubagentProcess } from "./background/subagent-guard.ts";
|
|
28
|
+
import { loadBaseToolEnhanceConfig, type BaseToolEnhanceConfig } from "./config.ts";
|
|
29
|
+
import { compileForcePatterns, describeForceMatch, matchForceBackground } from "./force-patterns.ts";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* override 后的 bash input schema。
|
|
33
|
+
*
|
|
34
|
+
* 在官方 schema(bash.js bashSchema:command + timeout?)基础上只追加 background。
|
|
35
|
+
* timeout 单位与官方一致 = 秒;background 缺省 false(不传 = 前台,等价官方行为)。
|
|
36
|
+
*/
|
|
37
|
+
const enhancedBashSchema = Type.Object({
|
|
38
|
+
command: Type.String({ description: "Bash command to execute" }),
|
|
39
|
+
timeout: Type.Optional(
|
|
40
|
+
Type.Number({ description: "Timeout in seconds (optional, no default timeout)" }),
|
|
41
|
+
),
|
|
42
|
+
background: Type.Optional(
|
|
43
|
+
Type.Boolean({
|
|
44
|
+
description:
|
|
45
|
+
"Run in background: returns a task_id immediately instead of waiting for the command to finish. Default false.",
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 重写的 bash description(给 LLM 的,英文,风格对齐官方文案 bash.js:233)。
|
|
52
|
+
*
|
|
53
|
+
* 覆盖设计文档 §3.5 要求的三个要点:
|
|
54
|
+
* 1. background:true 语义(立即返回 task_id,bash_output 查询 / bash_kill 终止)
|
|
55
|
+
* 2. 白名单命中命令自动转后台(即使未要求 background)
|
|
56
|
+
* 3. timeout 单位秒、显式填写会被尊重(唯一例外:白名单强转后台时忽略,D13)
|
|
57
|
+
*
|
|
58
|
+
* bash_output / bash_kill 工具由 M2 单元注册,description 先行提及(D2/§3.5 要求)。
|
|
59
|
+
*/
|
|
60
|
+
const ENHANCED_BASH_DESCRIPTION = [
|
|
61
|
+
"Execute a bash command in the current working directory. Returns stdout and stderr.",
|
|
62
|
+
"Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file.",
|
|
63
|
+
"Optionally provide a timeout in seconds; an explicit timeout is respected, except when a whitelisted long-running command is force-routed to background.",
|
|
64
|
+
"",
|
|
65
|
+
"Background mode: set background: true to start the command without waiting for it. The tool returns immediately with a task_id, the pid, and an output file path, and you can continue other work while it runs.",
|
|
66
|
+
"Poll progress and fetch output with bash_output {task_id} (omit task_id to list all background tasks); terminate a task with bash_kill {task_id}.",
|
|
67
|
+
"",
|
|
68
|
+
"Commands matching the force-background whitelist (test suites, dev servers, watch jobs and similar long-running commands) are automatically routed to background even when background was not requested; in that case the result carries a task_id to poll.",
|
|
69
|
+
].join("\n");
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* spawn 后台任务并拼装立即返回的 tool result(白名单强转与显式 background:true
|
|
73
|
+
* 两分支共用——spawn 入参、失败抛错、返回文案只有这一份,改文案不再有漏改分叉)。
|
|
74
|
+
* extraNotes:分支差异文案(如 force 命中说明),插在 Output file 行与 Poll 指引之间。
|
|
75
|
+
*/
|
|
76
|
+
function startBackgroundAndReply(
|
|
77
|
+
command: string,
|
|
78
|
+
ctx: ExtensionContext,
|
|
79
|
+
config: BaseToolEnhanceConfig,
|
|
80
|
+
timeoutSec: number | undefined,
|
|
81
|
+
extraNotes: string[] = [],
|
|
82
|
+
) {
|
|
83
|
+
const spawned = spawnBackgroundTask({
|
|
84
|
+
command,
|
|
85
|
+
cwd: ctx.cwd,
|
|
86
|
+
dataDir: getAgentDir(),
|
|
87
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
88
|
+
timeoutSec,
|
|
89
|
+
maxConcurrent: config.maxConcurrentBackground,
|
|
90
|
+
});
|
|
91
|
+
if (!spawned.ok) {
|
|
92
|
+
throw new Error(spawned.error);
|
|
93
|
+
}
|
|
94
|
+
const { task } = spawned;
|
|
95
|
+
return {
|
|
96
|
+
content: [
|
|
97
|
+
{
|
|
98
|
+
type: "text" as const,
|
|
99
|
+
text: [
|
|
100
|
+
`Background task started: ${truncateCommand(task.command)}`,
|
|
101
|
+
`task_id: ${task.taskId} pid: ${task.pid}`,
|
|
102
|
+
`Output file: ${task.outputFile}`,
|
|
103
|
+
...extraNotes,
|
|
104
|
+
`Poll with bash_output {task_id:"${task.taskId}"} or omit task_id to list all tasks; terminate with bash_kill {task_id:"${task.taskId}"}.`,
|
|
105
|
+
].join("\n"),
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
details: undefined as undefined,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 构建 bash override 的 ToolDefinition。
|
|
114
|
+
*
|
|
115
|
+
* cwd 处理:官方工厂在调用时固化 cwd(execute 闭包捕获),而 extension load 时机
|
|
116
|
+
* 早于会话 cwd 确定。因此以 execute 时 ctx.cwd 为权威:cwd 变化(cd / session 切换)
|
|
117
|
+
* 就地重建 delegate;同 cwd 复用(工厂是纯函数,重建仅多创建 ops 闭包,单槽缓存
|
|
118
|
+
* 防止每次工具调用重复构建)。promptSnippet / promptGuidelines / renderCall /
|
|
119
|
+
* renderResult 等未覆盖字段从官方 delegate 展开透传——override 掉整个 definition
|
|
120
|
+
* 后不透传会丢掉系统提示里的工具片段(types.d.ts ToolDefinition:无 promptSnippet
|
|
121
|
+
* 的 custom tool 会被 Available tools 段省略)与 TUI 渲染(pi 0.84.1 bash 实装的
|
|
122
|
+
* renderCall 命令格式化 / renderResult elapsed 计时与富结果组件)。
|
|
123
|
+
*/
|
|
124
|
+
export function createBashOverrideToolDefinition() {
|
|
125
|
+
let cachedCwd: string | undefined;
|
|
126
|
+
// 官方工厂返回值的形状 = ToolDefinition(bash.js createBashToolDefinition),
|
|
127
|
+
// 类型交由 pi 公开导出推断,不在本地重复声明(防与 pi 升级漂移)。
|
|
128
|
+
type OfficialBashTool = ReturnType<typeof createBashToolDefinition>;
|
|
129
|
+
let cachedDelegate: OfficialBashTool | undefined;
|
|
130
|
+
|
|
131
|
+
const getDelegate = (cwd: string): OfficialBashTool => {
|
|
132
|
+
if (cachedDelegate === undefined || cachedCwd !== cwd) {
|
|
133
|
+
cachedDelegate = createBashToolDefinition(cwd);
|
|
134
|
+
cachedCwd = cwd;
|
|
135
|
+
}
|
|
136
|
+
return cachedDelegate;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// registerTool 发生在 extension load 时(无 execute ctx),初始 delegate 用
|
|
140
|
+
// 进程 cwd 建立——name/label/promptSnippet/renderCall/renderResult 等静态字段与
|
|
141
|
+
// cwd 无关,execute 路径会被 getDelegate(ctx.cwd) 纠正。
|
|
142
|
+
const initial = getDelegate(process.cwd());
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
// 官方 delegate 展开透传(render 面委托):pi 0.84.1 bash definition 实装带
|
|
146
|
+
// renderCall(命令格式化)/ renderResult(elapsed 计时、富结果组件),独立
|
|
147
|
+
// pi TUI 用户安装本包后渲染不降级为通用组件——凡未在下方显式覆盖的字段
|
|
148
|
+
// (label/promptSnippet/promptGuidelines/renderCall/renderResult 等)全部随
|
|
149
|
+
// delegate 透传,cwd 重建 delegate 时静态闭包引用不变(render 闭包与 cwd 无关)
|
|
150
|
+
...initial,
|
|
151
|
+
name: initial.name, // "bash":同名覆盖内置工具(agent-session _refreshToolRegistry 后注册者胜)
|
|
152
|
+
description: ENHANCED_BASH_DESCRIPTION,
|
|
153
|
+
parameters: enhancedBashSchema,
|
|
154
|
+
async execute(
|
|
155
|
+
toolCallId: string,
|
|
156
|
+
args: { command: string; timeout?: number; background?: boolean },
|
|
157
|
+
signal: AbortSignal | undefined,
|
|
158
|
+
// 与官方 delegate 同型(展开透传后泛型 TDetails=BashToolDetails|undefined
|
|
159
|
+
// 随 render 面传入,execute 签名必须对齐,否则 registerTool 赋值检查不过)
|
|
160
|
+
onUpdate: AgentToolUpdateCallback<BashToolDetails | undefined> | undefined,
|
|
161
|
+
ctx: ExtensionContext,
|
|
162
|
+
) {
|
|
163
|
+
// 配置每次 execute 读时加载(热重载契约:禁止上层缓存,同进程改配置文件
|
|
164
|
+
// 不重启即生效)。D14:subagent 降级是全量的——白名单与 background 参数
|
|
165
|
+
// 同时失效(判定一次,两个分支共用)
|
|
166
|
+
const config = loadBaseToolEnhanceConfig();
|
|
167
|
+
const subagent = isSubagentProcess();
|
|
168
|
+
|
|
169
|
+
// 白名单强制后台(D3/M4):判定在 execute 内部、不改写 input——permission
|
|
170
|
+
// 审批的永远是原始 command/background/timeout(P1 探针结论的前提)。命中 →
|
|
171
|
+
// 强制走 background,无视 background:false/缺省
|
|
172
|
+
if (!subagent) {
|
|
173
|
+
const forceMatch = matchForceBackground(
|
|
174
|
+
args.command,
|
|
175
|
+
compileForcePatterns(config.forceBackgroundPatterns, config.disableBuiltinForcePatterns),
|
|
176
|
+
);
|
|
177
|
+
if (forceMatch !== undefined) {
|
|
178
|
+
// D13:忽略 LLM 显式 timeout(unified-hooks 时代「跑测试带 timeout」习惯
|
|
179
|
+
// 会精确复刻 §2.2 要解决的失败模式),按「配置默认 → 不限」取值
|
|
180
|
+
const timeoutSec = resolveBackgroundTimeoutSec(
|
|
181
|
+
undefined,
|
|
182
|
+
config.backgroundTimeoutSeconds ?? undefined,
|
|
183
|
+
);
|
|
184
|
+
const notes = [
|
|
185
|
+
`Forced to background: command matched force-background whitelist ${describeForceMatch(forceMatch)}.`,
|
|
186
|
+
...(args.timeout !== undefined
|
|
187
|
+
? [
|
|
188
|
+
`Ignored explicit timeout ${args.timeout}s for whitelisted command ` +
|
|
189
|
+
`(background timeout: ${timeoutSec !== undefined ? `${timeoutSec}s (config default)` : "unlimited"}).`,
|
|
190
|
+
]
|
|
191
|
+
: []),
|
|
192
|
+
];
|
|
193
|
+
return startBackgroundAndReply(args.command, ctx, config, timeoutSec, notes);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// background 分支(M2):显式 background:true 且非 subagent 降级(D14——
|
|
198
|
+
// subagent 进程内忽略 background 走前台,保持内置同步语义)。abort/interrupt
|
|
199
|
+
// 不传播到后台任务(D15):本分支不接触 signal,立即返回
|
|
200
|
+
if (args.background === true && !subagent) {
|
|
201
|
+
// timeout 优先级(§3.5):LLM 显式值 > 配置默认 > 不限;无效显式值沿用
|
|
202
|
+
// pi 内置文案抛错(注入只发生在「LLM 未填 && 配置了默认」,D4)
|
|
203
|
+
const timeoutSec = resolveBackgroundTimeoutSec(args.timeout, config.backgroundTimeoutSeconds ?? undefined);
|
|
204
|
+
return startBackgroundAndReply(args.command, ctx, config, timeoutSec);
|
|
205
|
+
}
|
|
206
|
+
const delegate = getDelegate(ctx.cwd);
|
|
207
|
+
// 前台委托:只转发官方 schema 已识别的字段(command/timeout),background
|
|
208
|
+
// 是本包增量,官方 execute 不认识(其解构也只取这两个键,显式构造让
|
|
209
|
+
// 「本层转发面」在代码上自解释)。subagent 降级(D14)与 background 缺省
|
|
210
|
+
// 都落到这条路径。
|
|
211
|
+
//
|
|
212
|
+
// 前台 timeout 注入(M4/G3):LLM 未填 && foregroundTimeoutSeconds 配置了
|
|
213
|
+
// 默认 → 注入;默认 null 不注入 = pi 原生不限时语义(D4)。注入与 subagent
|
|
214
|
+
// 降级正交——D14 只废 background/白名单语义,前台默认超时是全局挂死保护
|
|
215
|
+
const foregroundTimeout =
|
|
216
|
+
args.timeout !== undefined ? args.timeout : (config.foregroundTimeoutSeconds ?? undefined);
|
|
217
|
+
return delegate.execute(
|
|
218
|
+
toolCallId,
|
|
219
|
+
{ command: args.command, timeout: foregroundTimeout },
|
|
220
|
+
signal,
|
|
221
|
+
onUpdate,
|
|
222
|
+
ctx,
|
|
223
|
+
);
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置体系(M4,设计文档 §3.5「配置文件」段 + §3.6「config 解析失败」行)。
|
|
3
|
+
*
|
|
4
|
+
* 委托 llm-shared loadConfig(mtime+size 读时刷新热重载,同 smart-context/permission
|
|
5
|
+
* 范式)——【热重载契约】每次需要配置直接调 loadBaseToolEnhanceConfig,禁止上层
|
|
6
|
+
* 闭包/手动缓存阻断读时刷新(同进程改文件不重启即生效)。
|
|
7
|
+
*
|
|
8
|
+
* normalize 原则(部分坏配置不拖垮可用性):单键类型错/负数/0 → 该键回退默认 +
|
|
9
|
+
* logger.warn,不整体拒载;文件整体解析失败/不存在 → 全默认值(工具照常工作),
|
|
10
|
+
* warn 落日志并指向配置文件路径。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { getConfigPath as getLlmSharedConfigPath, loadConfig } from "@zhushanwen/pi-llm-shared";
|
|
14
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
15
|
+
|
|
16
|
+
import { DEFAULT_MAX_CONCURRENT_BACKGROUND } from "./background/spawn-background.ts";
|
|
17
|
+
|
|
18
|
+
const logger = getLogger("base-tool-enhance");
|
|
19
|
+
|
|
20
|
+
/** llm-shared 泛型 config 的包名(决定文件名 <dataDir>/config/base-tool-enhance-ext-config.json)。 */
|
|
21
|
+
const CONFIG_PKG = "base-tool-enhance";
|
|
22
|
+
|
|
23
|
+
const MS_PER_SECOND = 1000;
|
|
24
|
+
/** setTimeout 延迟上限(int32 ms):timeout 配置换算毫秒后的 clamp 上限。 */
|
|
25
|
+
const INT32_MAX_MS = 2_147_483_647;
|
|
26
|
+
|
|
27
|
+
export interface BaseToolEnhanceConfig {
|
|
28
|
+
/** 用户正则(源字符串),追加到内置两组白名单之后(compile 在 force-patterns.ts)。 */
|
|
29
|
+
forceBackgroundPatterns: string[];
|
|
30
|
+
/** true = 关闭内置 force-test/force-longrun 两组,只用用户正则。 */
|
|
31
|
+
disableBuiltinForcePatterns: boolean;
|
|
32
|
+
/** null = 不注入(D4,pi 原生不限时);数字 = 前台未填 timeout 时的默认秒数。 */
|
|
33
|
+
foregroundTimeoutSeconds: number | null;
|
|
34
|
+
/** null = 不注入;数字 = 后台未填 timeout 时的默认秒数。 */
|
|
35
|
+
backgroundTimeoutSeconds: number | null;
|
|
36
|
+
maxConcurrentBackground: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 默认配置(零配置状态:内置白名单两组全生效、双模式不注入 timeout、并发 8)。 */
|
|
40
|
+
export const DEFAULT_BASE_TOOL_ENHANCE_CONFIG: BaseToolEnhanceConfig = {
|
|
41
|
+
forceBackgroundPatterns: [],
|
|
42
|
+
disableBuiltinForcePatterns: false,
|
|
43
|
+
foregroundTimeoutSeconds: null,
|
|
44
|
+
backgroundTimeoutSeconds: null,
|
|
45
|
+
// 单一来源:与 spawnBackgroundTask 的 opts.maxConcurrent 缺省同值(M2 常量)
|
|
46
|
+
maxConcurrentBackground: DEFAULT_MAX_CONCURRENT_BACKGROUND,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** 配置文件完整路径(诊断文案与测试用)。 */
|
|
50
|
+
export function getConfigFilePath(): string {
|
|
51
|
+
return getLlmSharedConfigPath(CONFIG_PKG);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
55
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function describeValue(value: unknown): string {
|
|
59
|
+
return typeof value === "string" ? JSON.stringify(value) : String(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function warnInvalid(key: string, value: unknown, fallback: string): void {
|
|
63
|
+
// 可操作错误信息:指出键、实际值、回退结果与配置文件路径
|
|
64
|
+
logger.warn(
|
|
65
|
+
`Config key '${key}' invalid (got ${describeValue(value)}), falling back to ${fallback}. ` +
|
|
66
|
+
`Fix or remove it in ${getLlmSharedConfigPath(CONFIG_PKG)}.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* timeout 键归一化:null/缺省 → null(不注入,D4);正有限数 → 原值;
|
|
72
|
+
* 类型错/负数/0/非有限 → null + warn;换算毫秒超 int32 上限 → clamp + warn。
|
|
73
|
+
*/
|
|
74
|
+
function normalizeTimeoutSeconds(raw: unknown, key: string): number | null {
|
|
75
|
+
if (raw === null || raw === undefined) return null;
|
|
76
|
+
if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) {
|
|
77
|
+
warnInvalid(key, raw, "null (no timeout injection)");
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
if (raw * MS_PER_SECOND > INT32_MAX_MS) {
|
|
81
|
+
const clamped = INT32_MAX_MS / MS_PER_SECOND;
|
|
82
|
+
logger.warn(
|
|
83
|
+
`Config key '${key}' ${raw}s exceeds int32 ms limit, clamped to ${clamped}s ` +
|
|
84
|
+
`(${getLlmSharedConfigPath(CONFIG_PKG)}).`,
|
|
85
|
+
);
|
|
86
|
+
return clamped;
|
|
87
|
+
}
|
|
88
|
+
return raw;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* forceBackgroundPatterns 归一化:非数组 → 空 + warn;单条非字符串或 compile 失败 →
|
|
93
|
+
* 仅丢弃该条 + warn(一条坏正则不让所有前台命令全挂),其余条目保留。
|
|
94
|
+
*/
|
|
95
|
+
function normalizeForceBackgroundPatterns(raw: unknown): string[] {
|
|
96
|
+
if (raw === undefined || raw === null) return [];
|
|
97
|
+
if (!Array.isArray(raw)) {
|
|
98
|
+
warnInvalid("forceBackgroundPatterns", raw, "[] (no user patterns)");
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
const valid: string[] = [];
|
|
102
|
+
raw.forEach((item, index) => {
|
|
103
|
+
if (typeof item !== "string") {
|
|
104
|
+
warnInvalid(`forceBackgroundPatterns[${index}]`, item, "dropped");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
new RegExp(item);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
logger.warn(
|
|
111
|
+
`Config key 'forceBackgroundPatterns[${index}]' is not a valid regex (${item}), dropped: ` +
|
|
112
|
+
`${err instanceof Error ? err.message : String(err)} (${getLlmSharedConfigPath(CONFIG_PKG)}).`,
|
|
113
|
+
);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
valid.push(item);
|
|
117
|
+
});
|
|
118
|
+
return valid;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeDisableBuiltinForcePatterns(raw: unknown): boolean {
|
|
122
|
+
if (typeof raw === "boolean") return raw;
|
|
123
|
+
if (raw !== undefined) warnInvalid("disableBuiltinForcePatterns", raw, "false");
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 正有限数取 floor(并发数语义为整数);类型错/负数/0 → 默认 8 + warn。 */
|
|
128
|
+
function normalizeMaxConcurrentBackground(raw: unknown): number {
|
|
129
|
+
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
|
|
130
|
+
return Math.floor(raw);
|
|
131
|
+
}
|
|
132
|
+
if (raw !== undefined) warnInvalid("maxConcurrentBackground", raw, String(DEFAULT_MAX_CONCURRENT_BACKGROUND));
|
|
133
|
+
return DEFAULT_MAX_CONCURRENT_BACKGROUND;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 单键归一化(永不 throw——llm-shared 只在文件整体解析失败时走 catch 回退,键级
|
|
138
|
+
* 问题在这里就地消化)。未知键忽略(前向兼容)。
|
|
139
|
+
*/
|
|
140
|
+
export function normalizeBaseToolEnhanceConfig(raw: unknown): BaseToolEnhanceConfig {
|
|
141
|
+
if (!isPlainObject(raw)) {
|
|
142
|
+
if (raw !== undefined && raw !== null) {
|
|
143
|
+
logger.warn(
|
|
144
|
+
`Config root is not a JSON object (${describeValue(raw)}), using all defaults ` +
|
|
145
|
+
`(${getLlmSharedConfigPath(CONFIG_PKG)}).`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return { ...DEFAULT_BASE_TOOL_ENHANCE_CONFIG };
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
forceBackgroundPatterns: normalizeForceBackgroundPatterns(raw.forceBackgroundPatterns),
|
|
152
|
+
disableBuiltinForcePatterns: normalizeDisableBuiltinForcePatterns(raw.disableBuiltinForcePatterns),
|
|
153
|
+
foregroundTimeoutSeconds: normalizeTimeoutSeconds(raw.foregroundTimeoutSeconds, "foregroundTimeoutSeconds"),
|
|
154
|
+
backgroundTimeoutSeconds: normalizeTimeoutSeconds(raw.backgroundTimeoutSeconds, "backgroundTimeoutSeconds"),
|
|
155
|
+
maxConcurrentBackground: normalizeMaxConcurrentBackground(raw.maxConcurrentBackground),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 加载配置(每次调用直接走 loadConfig 读时刷新——热重载契约,禁止上层缓存)。
|
|
161
|
+
* 文件不存在 / 坏 JSON → 全默认值 + warn(§3.6:工具按默认值继续工作)。
|
|
162
|
+
*/
|
|
163
|
+
export function loadBaseToolEnhanceConfig(): BaseToolEnhanceConfig {
|
|
164
|
+
return loadConfig(CONFIG_PKG, DEFAULT_BASE_TOOL_ENHANCE_CONFIG, normalizeBaseToolEnhanceConfig, (msg) => {
|
|
165
|
+
logger.warn(msg);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 强制后台白名单(M4,设计文档 §3.5「内置正则基线」+「匹配语义」段)。
|
|
3
|
+
*
|
|
4
|
+
* 两组内置正则 + 用户配置正则合并:
|
|
5
|
+
* - force-test:逐条迁自 unified-hooks test-timeout-guard.ts(主体逐条迁移;锚定
|
|
6
|
+
* 前缀从原 `(^|\s|&&|\|{1,2}|;)` 收紧为命令位置锚定 CMD_ANCHOR,匹配语义双向
|
|
7
|
+
* 翻转——见 FORCE_TEST_PATTERN_ENTRIES 注释)
|
|
8
|
+
* - force-longrun:M4 定稿清单,原则 = 命令语义上无自然退出点(dev server / watch /
|
|
9
|
+
* tail -f 等),按命令名与 flag 组合匹配
|
|
10
|
+
*
|
|
11
|
+
* 匹配语义(防误伤的关键):正则一律锚定**命令位置**——行首,或 `;` / `&&` / `||` /
|
|
12
|
+
* `|` / 换行之后的命令起始位,不做裸子串匹配。用户正则同样自动加锚(组内统一语义,
|
|
13
|
+
* 用户正则无需也不应自带 `^`)。正则近似匹配的固有局限(诚实登记):引号内换行 /
|
|
14
|
+
* heredoc 内容理论上可构造误伤样例、`$(...)` 内命令会漏报——force 命中转后台是
|
|
15
|
+
* 非破坏性的,漏报由模型显式 background:true 兜底。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** 命令位置锚定前缀:行首,或 `;`/`&&`/`||`/`|`/换行之后的命令起始位(后随可选空白)。 */
|
|
19
|
+
const CMD_ANCHOR = String.raw`(?:^|&&|\|\||;|\||\n)\s*`;
|
|
20
|
+
|
|
21
|
+
/** 内置条目:pattern 已含 CMD_ANCHOR 前缀;label 为语义标签(诊断/result 文案引用)。 */
|
|
22
|
+
export interface BuiltinForcePatternEntry {
|
|
23
|
+
pattern: RegExp;
|
|
24
|
+
label: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* force-test 组:测试套件命令(迁自 unified-hooks test-timeout-guard.ts:17-60,
|
|
29
|
+
* 主体逐条迁移;锚定收紧为命令位置本身是语义变化,两个方向翻转——
|
|
30
|
+
* - 不再误伤:原 `\s` 前缀会把 `git commit -m "fix: npm test"` 这类参数文本当命令
|
|
31
|
+
* 命中,锚定后不再命中
|
|
32
|
+
* - 新增漏报:wrapper 形态 `sudo npm test` / `timeout 300 npm test` /
|
|
33
|
+
* `xargs npm test` 原靠 `\s` 前缀命中,锚定后 wrapper 名占命令位置、目标命令退到
|
|
34
|
+
* 参数位不再命中(§3.5 匹配语义的 wrapper 局限),由模型显式 background:true 兜底
|
|
35
|
+
*/
|
|
36
|
+
export const FORCE_TEST_PATTERN_ENTRIES: readonly BuiltinForcePatternEntry[] = [
|
|
37
|
+
// === Node.js / JS / TS ===
|
|
38
|
+
// 包管理器 test 脚本:npm test / pnpm test / yarn test / bun test
|
|
39
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`(pnpm|npm|yarn|bun)\s+test\b`), label: "npm test" },
|
|
40
|
+
// pnpm/npm run 跑 test 系脚本名(npm run test:unit / run test:watch 等)
|
|
41
|
+
{
|
|
42
|
+
pattern: new RegExp(CMD_ANCHOR + String.raw`(pnpm|npm)\s+(--filter\s+\S+\s+)?run\s+\S*test`),
|
|
43
|
+
label: "npm run test",
|
|
44
|
+
},
|
|
45
|
+
// npx 直接调用测试 runner
|
|
46
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`npx\s+vitest\b`), label: "vitest" },
|
|
47
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`npx\s+jest\b`), label: "jest" },
|
|
48
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`npx\s+mocha\b`), label: "mocha" },
|
|
49
|
+
// e2e runner:npx cypress / npx playwright
|
|
50
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`npx\s+(cypress|playwright)\s`), label: "e2e runner" },
|
|
51
|
+
// vue-cli-service / react-scripts test
|
|
52
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`npx\s+vue-cli-service\s+test`), label: "vue-cli test" },
|
|
53
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`npx\s+react-scripts\s+test`), label: "react-scripts test" },
|
|
54
|
+
// node_modules 直调 runner:./node_modules/.bin/vitest 等
|
|
55
|
+
{
|
|
56
|
+
pattern: new RegExp(CMD_ANCHOR + String.raw`\.\/?node_modules\/\.bin\/(vitest|jest|mocha)\b`),
|
|
57
|
+
label: "direct test runner",
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
// === Python ===
|
|
61
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`pytest\b`), label: "pytest" },
|
|
62
|
+
// python -m pytest / python3 -m unittest
|
|
63
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`python[3]?\s+-m\s+(pytest|unittest)\b`), label: "python test" },
|
|
64
|
+
// uv run pytest / poetry run pytest
|
|
65
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`(uv|poetry)\s+run\s+pytest\b`), label: "uv/poetry pytest" },
|
|
66
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`nosetests\b`), label: "nosetests" },
|
|
67
|
+
|
|
68
|
+
// === Java / JVM ===
|
|
69
|
+
// mvn test / mvnw test(mvn verify 等含 test 的 goal 由 \S*test 覆盖)
|
|
70
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`(mvn|\.\/mvnw)\s+\S*test`), label: "maven test" },
|
|
71
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`(gradle|\.\/gradlew)\s+\S*test`), label: "gradle test" },
|
|
72
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`sbt\s+test\b`), label: "sbt test" },
|
|
73
|
+
|
|
74
|
+
// === Go ===
|
|
75
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`go\s+test\b`), label: "go test" },
|
|
76
|
+
|
|
77
|
+
// === Rust ===
|
|
78
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`cargo\s+test\b`), label: "cargo test" },
|
|
79
|
+
|
|
80
|
+
// === .NET ===
|
|
81
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`dotnet\s+test\b`), label: "dotnet test" },
|
|
82
|
+
|
|
83
|
+
// === Ruby ===
|
|
84
|
+
// rspec / bundle exec rspec
|
|
85
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`(rspec|bundle\s+exec\s+rspec)\b`), label: "rspec" },
|
|
86
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`rake\s+test\b`), label: "rake test" },
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* force-longrun 组(M4 定稿):命令语义上无自然退出点的长驻命令。判定标准 = 按命令
|
|
91
|
+
* 名与 flag 组合匹配,不做 `--watch` 字面量子串匹配(`rg --files | grep watch` 不命中
|
|
92
|
+
* ——watch flag 断言限定在同一命令段内、且以独立参数形态出现)。
|
|
93
|
+
*/
|
|
94
|
+
export const FORCE_LONGRUN_PATTERN_ENTRIES: readonly BuiltinForcePatternEntry[] = [
|
|
95
|
+
// ── dev server:npm run dev 系 ──
|
|
96
|
+
// 包管理器 dev 脚本(npm/pnpm/yarn/bun run dev,含 dev:web 等冒号变体——dev:* 按约定都是 dev server)
|
|
97
|
+
{
|
|
98
|
+
pattern: new RegExp(CMD_ANCHOR + String.raw`(pnpm|npm|yarn|bun)\s+(--filter\s+\S+\s+)?run\s+dev\b`),
|
|
99
|
+
label: "package run dev",
|
|
100
|
+
},
|
|
101
|
+
// pnpm/yarn/bun 允许省略 run 的 dev 脚本简写(npm 无此语义故不含)
|
|
102
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`(pnpm|yarn|bun)\s+dev\b`), label: "package dev" },
|
|
103
|
+
|
|
104
|
+
// ── dev server:npx 直接调用 ──
|
|
105
|
+
// vite dev server(vite 缺省即 serve;排除 vite build——build 有自然退出点。
|
|
106
|
+
// 双 lookahead:先钉死 vite 后必须是空白/结尾(防回溯绕过),再排除 build 子命令)
|
|
107
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`npx\s+vite(?=\s|$)(?!\s*build\b)`), label: "vite" },
|
|
108
|
+
// next dev / nuxt dev / ng serve:框架 dev server 子命令
|
|
109
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`npx\s+next\s+dev\b`), label: "next dev" },
|
|
110
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`npx\s+nuxt\s+dev\b`), label: "nuxt dev" },
|
|
111
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`npx\s+ng\s+serve\b`), label: "ng serve" },
|
|
112
|
+
// webpack dev server:webpack-cli v4 serve 子命令 / 旧版 webpack-dev-server 直跑
|
|
113
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`npx\s+webpack\s+serve\b`), label: "webpack serve" },
|
|
114
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`npx\s+webpack-dev-server\b`), label: "webpack-dev-server" },
|
|
115
|
+
|
|
116
|
+
// ── dev server:语言内置 serve 命令(无自然退出点)──
|
|
117
|
+
// python 静态文件服务器
|
|
118
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`python[3]?\s+-m\s+http\.server\b`), label: "http.server" },
|
|
119
|
+
// django runserver(python manage.py runserver)
|
|
120
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`python[3]?\s+manage\.py\s+runserver\b`), label: "runserver" },
|
|
121
|
+
// flask run(flask --app x run 由段内 run 断言覆盖形态此处只认常见直跑)
|
|
122
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`flask\s+run\b`), label: "flask run" },
|
|
123
|
+
// rails server / rails s
|
|
124
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`rails\s+(s|server)\b`), label: "rails server" },
|
|
125
|
+
// php artisan serve
|
|
126
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`php\s+artisan\s+serve\b`), label: "artisan serve" },
|
|
127
|
+
|
|
128
|
+
// ── 显式 watch flags:工具名 + 同段内独立 watch 参数组合 ──
|
|
129
|
+
// vitest/jest --watch / --watchAll(排除 --watch=false——显式关 watch 就是一次性运行)
|
|
130
|
+
{
|
|
131
|
+
pattern: new RegExp(
|
|
132
|
+
CMD_ANCHOR + String.raw`(?:npx\s+)?(?:vitest|jest)\b[^;&|\n]*\s--watch(?:All)?\b(?!=)`,
|
|
133
|
+
),
|
|
134
|
+
label: "vitest/jest --watch",
|
|
135
|
+
},
|
|
136
|
+
// tsc --watch / tsc -w(持续增量编译)
|
|
137
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`(?:npx\s+)?tsc\b[^;&|\n]*\s(?:--watch|-w)\b`), label: "tsc watch" },
|
|
138
|
+
// 构建工具 watch 模式:sass / webpack / esbuild / rollup --watch
|
|
139
|
+
{
|
|
140
|
+
pattern: new RegExp(CMD_ANCHOR + String.raw`(?:npx\s+)?(?:sass|webpack|esbuild|rollup)\b[^;&|\n]*\s--watch\b`),
|
|
141
|
+
label: "build --watch",
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
// ── 天然长驻工具 ──
|
|
145
|
+
// cargo watch(文件变更即重跑命令,直到手动停止)
|
|
146
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`cargo\s+watch\b`), label: "cargo watch" },
|
|
147
|
+
// watchexec / nodemon(通用文件监听重启器)
|
|
148
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`watchexec\b`), label: "watchexec" },
|
|
149
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`nodemon\b`), label: "nodemon" },
|
|
150
|
+
// tail -f / tail -F(持续追踪日志文件;可选中段容纳 tail -n 100 -f 形态)
|
|
151
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`tail\s(?:[^;&|\n]*\s)?-[fF]\b`), label: "tail -f" },
|
|
152
|
+
// ngrok(隧道转发,直到手动停止)
|
|
153
|
+
{ pattern: new RegExp(CMD_ANCHOR + String.raw`ngrok\b`), label: "ngrok" },
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
/** 命中来源:内置组(组名 test/longrun)或用户正则。 */
|
|
157
|
+
export type ForceMatchSource = "builtin-test" | "builtin-longrun" | "user";
|
|
158
|
+
|
|
159
|
+
/** 编译后的白名单条目。 */
|
|
160
|
+
export interface ForcePattern {
|
|
161
|
+
re: RegExp;
|
|
162
|
+
source: ForceMatchSource;
|
|
163
|
+
/** result 文案引用名:内置 = 组名(test/longrun);用户 = 正则字面量前 40 字符。 */
|
|
164
|
+
name: string;
|
|
165
|
+
/** 细化诊断:内置条目的语义标签。 */
|
|
166
|
+
label?: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 用户正则字面量在 result 文案中的展示长度上限。 */
|
|
170
|
+
const USER_PATTERN_DISPLAY_LIMIT = 40;
|
|
171
|
+
|
|
172
|
+
function compileUserPattern(source: string): ForcePattern | undefined {
|
|
173
|
+
// 防御性 compile:config normalize 已丢弃非法正则,此处再兜一层(不信任边界)
|
|
174
|
+
try {
|
|
175
|
+
return {
|
|
176
|
+
re: new RegExp(CMD_ANCHOR + source),
|
|
177
|
+
source: "user",
|
|
178
|
+
name:
|
|
179
|
+
source.length > USER_PATTERN_DISPLAY_LIMIT
|
|
180
|
+
? `${source.slice(0, USER_PATTERN_DISPLAY_LIMIT)}…`
|
|
181
|
+
: source,
|
|
182
|
+
};
|
|
183
|
+
} catch {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 合并白名单(§3.5 行为矩阵):
|
|
190
|
+
* - 零配置(无用户正则、不 disable)→ 两组内置全生效
|
|
191
|
+
* - disableBuiltinForcePatterns:true → 两组内置关闭
|
|
192
|
+
* - 用户正则始终追加(与内置并存时匹配任一即命中)
|
|
193
|
+
*/
|
|
194
|
+
export function compileForcePatterns(userPatterns: readonly string[], disableBuiltin: boolean): ForcePattern[] {
|
|
195
|
+
const patterns: ForcePattern[] = [];
|
|
196
|
+
if (!disableBuiltin) {
|
|
197
|
+
for (const entry of FORCE_TEST_PATTERN_ENTRIES) {
|
|
198
|
+
patterns.push({ re: entry.pattern, source: "builtin-test", name: "test", label: entry.label });
|
|
199
|
+
}
|
|
200
|
+
for (const entry of FORCE_LONGRUN_PATTERN_ENTRIES) {
|
|
201
|
+
patterns.push({ re: entry.pattern, source: "builtin-longrun", name: "longrun", label: entry.label });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
for (const source of userPatterns) {
|
|
205
|
+
const compiled = compileUserPattern(source);
|
|
206
|
+
if (compiled !== undefined) patterns.push(compiled);
|
|
207
|
+
}
|
|
208
|
+
return patterns;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 白名单命中结果。 */
|
|
212
|
+
export interface ForcePatternMatch {
|
|
213
|
+
source: ForceMatchSource;
|
|
214
|
+
/** result 文案引用名(组名 test/longrun 或用户正则字面量前 40 字符)。 */
|
|
215
|
+
name: string;
|
|
216
|
+
/** 内置条目语义标签(用户正则无)。 */
|
|
217
|
+
label?: string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 判定命令是否命中白名单(命令位置锚定,见文件头「匹配语义」)。 */
|
|
221
|
+
export function matchForceBackground(command: string, patterns: readonly ForcePattern[]): ForcePatternMatch | undefined {
|
|
222
|
+
for (const p of patterns) {
|
|
223
|
+
if (p.re.test(command)) {
|
|
224
|
+
return { source: p.source, name: p.name, ...(p.label !== undefined ? { label: p.label } : {}) };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** result 文案用的命中描述:内置组报组名 + 标签,用户正则报字面量。 */
|
|
231
|
+
export function describeForceMatch(match: ForcePatternMatch): string {
|
|
232
|
+
if (match.source === "user") {
|
|
233
|
+
return `user pattern '${match.name}'`;
|
|
234
|
+
}
|
|
235
|
+
return match.label !== undefined ? `pattern '${match.name}' (${match.label})` : `pattern '${match.name}'`;
|
|
236
|
+
}
|