@gavinc-cn/touchstone-dsh 0.1.1 → 0.3.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 +39 -11
- package/README.zh.md +25 -10
- package/backend/lib/access.js +44 -0
- package/backend/lib/agents.js +341 -0
- package/backend/lib/args.js +94 -0
- package/backend/lib/auth.js +195 -0
- package/backend/lib/board.js +6154 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2053 -0
- package/backend/lib/cookie.js +76 -0
- package/backend/lib/db.js +2260 -0
- package/backend/lib/dshdriver.js +1148 -0
- package/backend/lib/dshevents.js +851 -0
- package/backend/lib/export-cases.js +261 -0
- package/backend/lib/feishu.js +1738 -0
- package/backend/lib/feishu_cfg.js +228 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +1674 -0
- package/backend/lib/feishu_provision.js +855 -0
- package/backend/lib/feishu_rest.js +398 -0
- package/backend/lib/feishu_slash.js +568 -0
- package/backend/lib/feishu_ws.js +950 -0
- package/backend/lib/http.js +312 -0
- package/backend/lib/lib.js +973 -0
- package/backend/lib/loadcase.js +749 -0
- package/backend/lib/loadgen.js +1407 -0
- package/backend/lib/localbus.js +164 -0
- package/backend/lib/lock.js +405 -0
- package/backend/lib/proc.js +278 -0
- package/backend/lib/prompts.js +1021 -0
- package/backend/lib/pybase64.js +114 -0
- package/backend/lib/pyjson.js +214 -0
- package/backend/lib/query.js +125 -0
- package/backend/lib/rag.js +1310 -0
- package/backend/lib/runner.js +4520 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +237 -0
- package/backend/lib/sessparse.js +1176 -0
- package/backend/lib/sse.js +207 -0
- package/backend/lib/static.js +84 -0
- package/backend/lib/taskjson.js +302 -0
- package/backend/lib/waitq.js +2114 -0
- package/backend/lib/worktree.js +588 -0
- package/backend/lib/zstd-frames.js +218 -0
- package/backend/routes/admin.js +553 -0
- package/backend/routes/agents.js +149 -0
- package/backend/routes/assets.js +145 -0
- package/backend/routes/board.js +1220 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +215 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +297 -0
- package/backend/routes/projects.js +1059 -0
- package/backend/routes/sessions.js +946 -0
- package/backend/routes/tasks.js +1530 -0
- package/backend/schema/fixtures/ancient.sql +189 -0
- package/backend/schema/fixtures/pre_alter.sql +177 -0
- package/backend/schema/migrations.json +154 -0
- package/backend/schema/schema.json +1451 -0
- package/backend/schema/schema.sql +216 -0
- package/backend/server.js +498 -0
- package/board.py +805 -72
- package/chat.py +590 -17
- package/db.py +356 -140
- package/dsh-plugin/cordis.patch.yml +7 -3
- package/dsh-plugin/install.sh +28 -11
- package/dsh-plugin/lib/agent-driver.js +710 -89
- package/dsh-plugin/lib/client.bundle.js +94 -9
- package/dsh-plugin/lib/index.js +529 -39
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/src/client.js +94 -9
- package/dshdriver.py +74 -5
- package/dshevents.py +65 -3
- package/feishu.py +1197 -34
- package/feishu_conv.py +920 -0
- package/lifecycle.py +116 -13
- package/package.json +6 -1
- package/requirements.txt +2 -1
- package/server.py +218 -26
- package/sessparse.py +150 -44
- package/touchstone.py +6 -3
- package/waitq.py +23 -0
- package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
- package/webui/dist-plugin/assets/index-UrMyeAXF.js +484 -0
- package/webui/dist-plugin/index.html +2 -2
- package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
- package/webui/dist-plugin/assets/index-DtwKoi1T.js +0 -473
|
@@ -0,0 +1,973 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共享解析/路径工具的**读侧子集**(`lib.py` + `server.py` 顶部文件系统小工具的对应物,M3 读路径)。
|
|
3
|
+
*
|
|
4
|
+
* ── 为什么只搬「读」的一半 ──────────────────────────────────────────────────────
|
|
5
|
+
* `lib.py` 里还有一批**写**函数(`set_md_field`/`mark_cases_retest`/`ensure_gitignore`/
|
|
6
|
+
* `git_log_changes` 等),它们服务的是任务收尾与建/改项目路径 —— 属 M4/M5/M6,搬进来只会
|
|
7
|
+
* 让 M3 的只读边界变糊。本文件只放**纯读**:文本读、字段解析、status.md、用例目录定位、
|
|
8
|
+
* bug 报告关联用例,以及 `server.py` 里那几个「路径 → 是否可读/是否越界」的函数
|
|
9
|
+
* (`fs_roots`/`_within_root`/`resolve_project_file`/`read_text_preview`/`fs_browse`)。
|
|
10
|
+
* 它们虽在 Python 侧写在 server.py(不在 lib.py),但都是**无状态纯函数**,按 M3 的分工
|
|
11
|
+
* 收进本模块,路由文件只留「取参 → 调函数 → 定响应」。
|
|
12
|
+
*
|
|
13
|
+
* ── 逐点等价的口径(每条都对着 Python 源码核过) ─────────────────────────────────
|
|
14
|
+
* 1. **正则**:Python `re` 与 JS `RegExp` 有三处会分叉,这里逐条抹平 ——
|
|
15
|
+
* ① `\s`:Python str 模式的 `\s` 是 Unicode 空白(含 `\x1c-\x1f`、`\x85`),而 JS 的 `\s`
|
|
16
|
+
* 多含 `\ufeff`、少含那几个控制字符 ⇒ **一律展开成 `PY_WS` 字符类**(与 db.js 同表);
|
|
17
|
+
* ② `.`:Python 的 `.` 只排除 `\n`,JS 还排除 `\r`/`U+2028`/`U+2029` ⇒ 一律写成 `[^\n]`;
|
|
18
|
+
* ③ `\d`:Python 的 `\d` 是 Unicode 十进制数字,JS 是 `[0-9]`。**这一条有意不抹平**:
|
|
19
|
+
* 平台里的 `\d` 只出现在 FS 用例号、轮次号、日期型目录名上,全是 ASCII;改成 Unicode
|
|
20
|
+
* 口径还得连 `int()` 一起改,收益为零、复杂度爆表。差异仅对「用阿拉伯数字命名的用例目录」
|
|
21
|
+
* 成立,写在这里备案而不是静默。
|
|
22
|
+
* 2. **文本模式换行**:Python `open(..., encoding="utf-8", errors="replace")` 默认做 universal
|
|
23
|
+
* newlines ⇒ `\r\n`/`\r` 都变 `\n`。**读文本一律走 `readText`(做这层转换)**;而
|
|
24
|
+
* `read_text_preview` 在 Python 侧是 `open(path, "rb")` ⇒ **不转换**,故预览正文按原字节解码。
|
|
25
|
+
* 3. **`str.strip()` / `str.isdigit()` / `int(str)`**:分别按 Python 的空白集合、ASCII 数字、
|
|
26
|
+
* 「可选符号 + 数字」实现(见 `pyStrip`/`pyIsDigits`/`pyIntOr`)。
|
|
27
|
+
* 4. **`os.path`**:`join` 的「后段绝对路径覆盖前段」(`join('/a','/etc') == '/etc'`)与
|
|
28
|
+
* `path.join` 语义**不同**,用 `pyJoin`;`normpath` 在 POSIX 下保留开头两个斜杠
|
|
29
|
+
* (`//a/b` 不变),`path.normalize` 会压成一个 ⇒ 用 `pyNormpath`。
|
|
30
|
+
* 5. **`os.stat().st_mtime`**:CPython 用 `sec + nsec*1e-9`(先说 `st_mtime_ns` 整除,
|
|
31
|
+
* 不是 `ns/1e9`),实测 184/184 个真实文件的 repr 与 `pyMtimeFloat` 逐字符相同。
|
|
32
|
+
* 本文件只做「ns → Python 等值浮点/int」这一层,缓存指纹见 `casescan.js`。
|
|
33
|
+
*
|
|
34
|
+
* 已知有意保留的差异(都不影响真实案例库,别当 bug 修):
|
|
35
|
+
* - `\d` 按 ASCII(见上 ③);
|
|
36
|
+
* - `readText` 的 UTF-8 坏字节替换粒度:Python `errors="replace"` 与 JS `Buffer.toString('utf8')`
|
|
37
|
+
* 都按「最长可解码子串」各出一个 U+FFFD,实测常见坏字节序列一致;极端序列不保证逐字符相同;
|
|
38
|
+
* - `realpathOrResolve`:Python `os.path.realpath(strict=False)` 对**不存在**的路径也能解析
|
|
39
|
+
* (把能解析的前缀解掉、余下原样拼接),Node 的 `fs.realpathSync` 直接抛 ENOENT ⇒ 回落
|
|
40
|
+
* `path.resolve`。两者只在「不存在的路径**且**其已存在前缀里有符号链接」时分叉。
|
|
41
|
+
*
|
|
42
|
+
* 零 npm 依赖:只用 node:fs / node:path / node:url。
|
|
43
|
+
*
|
|
44
|
+
* **M6.9 追加(文件末尾)**:`normalizeProjectName` / `pathLastSegment` / `fsMkdir`
|
|
45
|
+
* —— 前两个是纯函数,`fsMkdir` 是本文件第一个**写盘**函数(`server.py:fs_mkdir`:
|
|
46
|
+
* 建一个子目录,写作用域严格限定在调用方给的已存在父目录下)。
|
|
47
|
+
*/
|
|
48
|
+
import { spawnSync } from 'node:child_process';
|
|
49
|
+
import fs from 'node:fs';
|
|
50
|
+
import path from 'node:path';
|
|
51
|
+
|
|
52
|
+
// --------------------------------------------------------------------------
|
|
53
|
+
// Python 字符串语义的小工具
|
|
54
|
+
// --------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/** Python `str.isspace()` 的字符集合(JS `\s` 多含 U+FEFF、少含 U+001C-U+001F/U+0085) */
|
|
57
|
+
const PY_SPACE = ' \t\n\v\f\r\x1c-\x1f\x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000';
|
|
58
|
+
const PY_STRIP_RE = new RegExp(`^[${PY_SPACE}]+|[${PY_SPACE}]+$`, 'g');
|
|
59
|
+
|
|
60
|
+
/** Python `\s` 的展开形态:直接嵌进正则源码用(与 db.js 的 PY_SPACE 同表) */
|
|
61
|
+
export const PY_WS = `[${PY_SPACE}]`;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Python `str.strip()`(正则里 `\s` 的口径也以它为准)。
|
|
65
|
+
* @param {unknown} value
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
export function pyStrip(value) {
|
|
69
|
+
return String(value == null ? '' : value).replace(PY_STRIP_RE, '');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 按 **码点** 比较两个字符串(Python `sorted()` 的口径)。
|
|
74
|
+
*
|
|
75
|
+
* 为什么不能直接 `<`:JS 的字符串比较按 UTF-16 码元,代理对(U+10000 以上)会被当成
|
|
76
|
+
* 0xD800-0xDFFF 区间参与比较,与 Python 的码点序在「U+E000-U+FFFF vs 补充平面」这一对
|
|
77
|
+
* 上正好反过来 —— 文件名字典序会与 Python 分叉。案例库里出现 emoji 目录时就会显形。
|
|
78
|
+
* @param {string} a
|
|
79
|
+
* @param {string} b
|
|
80
|
+
* @returns {number} -1 / 0 / 1
|
|
81
|
+
*/
|
|
82
|
+
export function pyStrCompare(a, b) {
|
|
83
|
+
const ia = String(a)[Symbol.iterator]();
|
|
84
|
+
const ib = String(b)[Symbol.iterator]();
|
|
85
|
+
for (;;) {
|
|
86
|
+
const x = ia.next();
|
|
87
|
+
const y = ib.next();
|
|
88
|
+
if (x.done || y.done) {
|
|
89
|
+
if (x.done && y.done) return 0;
|
|
90
|
+
return x.done ? -1 : 1;
|
|
91
|
+
}
|
|
92
|
+
const ca = x.value.codePointAt(0);
|
|
93
|
+
const cb = y.value.codePointAt(0);
|
|
94
|
+
if (ca !== cb) return ca < cb ? -1 : 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Python `sorted(list)` 的等价(码点序 + 稳定) */
|
|
99
|
+
export function pySorted(values) {
|
|
100
|
+
return [...values].sort(pyStrCompare);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Python `sorted(list, reverse=True)`:**不是**「升序再反转」——相等元素的相对次序仍保持 */
|
|
104
|
+
export function pySortedReverse(values) {
|
|
105
|
+
return [...values].sort((a, b) => pyStrCompare(b, a));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Python `str.isdigit()` 的 ASCII 子集(只认 `[0-9]`,见文件头 ③)。
|
|
110
|
+
* @param {unknown} value
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
export function pyIsDigits(value) {
|
|
114
|
+
return /^[0-9]+$/.test(String(value == null ? '' : value));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Python `int(str)` 的容错版:解析失败返回 `fallback`(对齐 `try: int(x) except ValueError`)。
|
|
119
|
+
*
|
|
120
|
+
* 接受「两侧 Python 空白 + 可选正负号 + ASCII 数字」(`int(' -8 ')` = -8、
|
|
121
|
+
* `int('8.0')` 抛 ValueError ⇒ fallback)。空串与只有符号的串同样落 fallback。
|
|
122
|
+
* @param {unknown} value
|
|
123
|
+
* @param {number} fallback
|
|
124
|
+
* @returns {number}
|
|
125
|
+
*/
|
|
126
|
+
export function pyIntOr(value, fallback) {
|
|
127
|
+
const text = pyStrip(value);
|
|
128
|
+
if (!/^[+-]?[0-9]+$/.test(text)) return fallback;
|
|
129
|
+
const n = Number(text);
|
|
130
|
+
return Number.isFinite(n) ? n : fallback;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* CPython `_PyTime_AsSecondsDouble`:`st_mtime_ns` → `st_mtime`(float 秒)。
|
|
135
|
+
*
|
|
136
|
+
* 为什么不能写 `Number(ns) / 1e9`:那是「一次除法一次舍入」,CPython 是
|
|
137
|
+
* 「秒部分 + 纳秒部分×1e-9」(两次舍入),两者在约一半的真实时间戳上差 1 ULP
|
|
138
|
+
* (实测 184 个文件里 58 个的 repr 不同)——而 `live_mtime` 是要进 JSON 响应体的。
|
|
139
|
+
* @param {bigint} ns `st_mtime_ns`
|
|
140
|
+
* @returns {number}
|
|
141
|
+
*/
|
|
142
|
+
export function pyMtimeFloat(ns) {
|
|
143
|
+
const sec = ns / 1000000000n;
|
|
144
|
+
const rem = ns % 1000000000n;
|
|
145
|
+
return Number(sec) + Number(rem) * 1e-9;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `int(os.path.getmtime(path))`:CPython 先算 float 再 `int()`(向零截断)。
|
|
150
|
+
* @param {bigint} ns `st_mtime_ns`
|
|
151
|
+
* @returns {number}
|
|
152
|
+
*/
|
|
153
|
+
export function pyMtimeInt(ns) {
|
|
154
|
+
return Math.trunc(pyMtimeFloat(ns));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --------------------------------------------------------------------------
|
|
158
|
+
// 路径(os.path 的逐点等价子集)
|
|
159
|
+
// --------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* `os.path.join`(POSIX 口径):后段是绝对路径时**覆盖**前面全部段。
|
|
163
|
+
*
|
|
164
|
+
* 为什么不能用 `path.join`:`path.join('/a/b', '/etc')` = `/a/b/etc`(Node 只拼接),
|
|
165
|
+
* 而 Python 给 `/etc`。`_api_get_bug` 的 `os.path.join(row["bug_dir"], bug_dir)` 正是
|
|
166
|
+
* 这个形态(`bugs/%2Ftmp` 会命中),用错就是行为分叉。
|
|
167
|
+
* @param {...(string|null|undefined)} parts
|
|
168
|
+
* @returns {string}
|
|
169
|
+
*/
|
|
170
|
+
export function pyJoin(...parts) {
|
|
171
|
+
let out = '';
|
|
172
|
+
for (const raw of parts) {
|
|
173
|
+
const part = String(raw == null ? '' : raw);
|
|
174
|
+
if (part.startsWith('/')) out = part;
|
|
175
|
+
else if (out === '' || out.endsWith('/')) out += part;
|
|
176
|
+
else out += `/${part}`;
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* `posixpath.normpath`(POSIX 口径):折叠 `.`/`//`、解析 `..`、**保留开头两个斜杠**。
|
|
183
|
+
* @param {string} value
|
|
184
|
+
* @returns {string}
|
|
185
|
+
*/
|
|
186
|
+
export function pyNormpath(value) {
|
|
187
|
+
const p = String(value == null ? '' : value);
|
|
188
|
+
if (p === '') return '.';
|
|
189
|
+
let initialSlashes = p.startsWith('/');
|
|
190
|
+
if (initialSlashes && p.startsWith('//') && !p.startsWith('///')) initialSlashes = 2;
|
|
191
|
+
const out = [];
|
|
192
|
+
for (const comp of p.split('/')) {
|
|
193
|
+
if (comp === '' || comp === '.') continue;
|
|
194
|
+
if (comp !== '..' || (!initialSlashes && out.length === 0) || out[out.length - 1] === '..') {
|
|
195
|
+
out.push(comp);
|
|
196
|
+
} else if (out.length) {
|
|
197
|
+
out.pop();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
let joined = out.join('/');
|
|
201
|
+
if (initialSlashes) joined = `${'/'.repeat(initialSlashes)}${joined}`;
|
|
202
|
+
return joined || '.';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** `os.path.isabs`:POSIX 只看开头是不是 `/`(Windows 分支交给 path.isAbsolute) */
|
|
206
|
+
export function pyIsAbs(value) {
|
|
207
|
+
return path.isAbsolute(String(value == null ? '' : value));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* `os.path.realpath(strict=False)` 的可用近似:能解析就真解析,不存在则 `path.resolve`。
|
|
212
|
+
* 差异见文件头「已知有意保留的差异」。
|
|
213
|
+
* @param {string} value
|
|
214
|
+
* @returns {string}
|
|
215
|
+
*/
|
|
216
|
+
export function realpathOrResolve(value) {
|
|
217
|
+
const p = String(value == null ? '' : value);
|
|
218
|
+
try {
|
|
219
|
+
return fs.realpathSync(p);
|
|
220
|
+
} catch {
|
|
221
|
+
return path.resolve(p);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** `os.path.isdir`(跟随符号链接;出错一律 false,与 Python 的 `except OSError → False` 同) */
|
|
226
|
+
export function isDir(value) {
|
|
227
|
+
try {
|
|
228
|
+
return fs.statSync(value).isDirectory();
|
|
229
|
+
} catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** `os.path.isfile`(跟随符号链接;出错 false) */
|
|
235
|
+
export function isFile(value) {
|
|
236
|
+
try {
|
|
237
|
+
return fs.statSync(value).isFile();
|
|
238
|
+
} catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** `os.path.exists`(跟随符号链接;出错 false) */
|
|
244
|
+
export function pathExists(value) {
|
|
245
|
+
try {
|
|
246
|
+
fs.statSync(value);
|
|
247
|
+
return true;
|
|
248
|
+
} catch {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** `os.path.basename`(尾部分隔符忽略;空串 → 空串) */
|
|
254
|
+
export function pyBasename(value) {
|
|
255
|
+
return path.basename(String(value == null ? '' : value));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** `os.path.dirname`(根目录返回自身,与 Python 同) */
|
|
259
|
+
export function pyDirname(value) {
|
|
260
|
+
return path.dirname(String(value == null ? '' : value));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** `os.path.getsize`(出错抛,与 Python 同:调用方按 OSError 分档) */
|
|
264
|
+
export function pathSize(value) {
|
|
265
|
+
return fs.statSync(value).size;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* `os.path.relpath(path, start)` 的 POSIX 等价。
|
|
270
|
+
* 两处口径差:Node 的 `path.relative` 在同路径时给空串(Python 给 `.`);`start` 为空串时
|
|
271
|
+
* Python 会抛 ValueError(调用方 `_api_project_file` 已用 `if root_dir else path` 挡掉)。
|
|
272
|
+
* @param {string} target
|
|
273
|
+
* @param {string} start
|
|
274
|
+
* @returns {string}
|
|
275
|
+
*/
|
|
276
|
+
export function pyRelpath(target, start) {
|
|
277
|
+
const rel = path.relative(String(start), String(target));
|
|
278
|
+
return rel === '' ? '.' : rel;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --------------------------------------------------------------------------
|
|
282
|
+
// 文本读与字段解析(lib.py 读侧)
|
|
283
|
+
// --------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* `lib.read_text`:读 UTF-8 文本,失败返回空串;**并按 Python 文本模式做 universal newlines**。
|
|
287
|
+
* 末尾 `\r\n`/`\r` 全变 `\n` —— 这条直接影响 bug 报告全文(`content`)与 INDEX/ROUNDS 解析,
|
|
288
|
+
* 是「看起来一样但其实不一样」的典型坑(Windows 上生成的文件会整篇带 `\r`)。
|
|
289
|
+
* @param {string} filePath
|
|
290
|
+
* @returns {string}
|
|
291
|
+
*/
|
|
292
|
+
export function readText(filePath) {
|
|
293
|
+
let text;
|
|
294
|
+
try {
|
|
295
|
+
text = fs.readFileSync(String(filePath), 'utf8');
|
|
296
|
+
} catch {
|
|
297
|
+
return ''; // Python 只吞 OSError(含 IsADirectoryError)
|
|
298
|
+
}
|
|
299
|
+
return text.replace(/\r\n?/g, '\n');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** status.md / bug_report.md 字段行:`- **状态**: 失败` 或 `**状态**: 已分析,待修复` */
|
|
303
|
+
const FIELD_RE = new RegExp(`^${PY_WS}*(?:-${PY_WS}*)?\\*\\*([^\\n]+?)\\*\\*${PY_WS}*[::]${PY_WS}*([^\\n]*)$`, 'gm');
|
|
304
|
+
|
|
305
|
+
/** bug_report.md 里的关联用例编号(`FS0001`) */
|
|
306
|
+
export const FS_ID_RE = /FS\d{4}/g;
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* `lib.parse_fields`:把 `- **字段**: 值` 列表解析成对象。
|
|
310
|
+
* 同名键后者覆盖前者(Python dict 推导同);键值都按 Python `str.strip()` 归整。
|
|
311
|
+
* @param {string} text
|
|
312
|
+
* @returns {Record<string, string>}
|
|
313
|
+
*/
|
|
314
|
+
export function parseFields(text) {
|
|
315
|
+
const out = {};
|
|
316
|
+
for (const m of String(text == null ? '' : text).matchAll(FIELD_RE)) {
|
|
317
|
+
out[pyStrip(m[1])] = pyStrip(m[2]);
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* `lib.norm_status`:状态字段归一化(模板未填写时是 `未执行 / 通过 / ...`,取第一项)。
|
|
324
|
+
* @param {unknown} value
|
|
325
|
+
* @returns {string}
|
|
326
|
+
*/
|
|
327
|
+
export function normStatus(value) {
|
|
328
|
+
const text = value == null ? '' : String(value);
|
|
329
|
+
if (!text) return '未执行';
|
|
330
|
+
return pyStrip(text.split('/')[0]);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* `lib.parse_status_md`:解析用例 status.md(键序与 Python 返回的 dict 一致)。
|
|
335
|
+
* @param {string} filePath
|
|
336
|
+
* @returns {{status: string, last_run: string, round: string, fail_reason: string,
|
|
337
|
+
* bug_report: string, summary: string}}
|
|
338
|
+
*/
|
|
339
|
+
export function parseStatusMd(filePath) {
|
|
340
|
+
const fields = parseFields(readText(filePath));
|
|
341
|
+
return {
|
|
342
|
+
status: normStatus(fields['状态']),
|
|
343
|
+
last_run: fields['最近执行时间'] || '',
|
|
344
|
+
round: fields['执行轮次'] || '',
|
|
345
|
+
fail_reason: fields['失败原因'] || '',
|
|
346
|
+
bug_report: fields['关联 bug_report'] || '',
|
|
347
|
+
summary: fields['结果摘要'] || '',
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** 用例目录名:`FS0004_用例名称`(`^...$` 锚定,故 `FS0004_x_y` 也匹配、`FS0004` 单独不匹配) */
|
|
352
|
+
export const CASE_DIR_RE = new RegExp(`^(FS\\d{4})_([^\\n]+)$`);
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* `lib.find_case_dirs`:在案例库根下按唯一 id 定位用例目录。
|
|
356
|
+
*
|
|
357
|
+
* `os.walk` 的三条语义逐条对齐:① 跳过隐藏目录(含**不深入**);② 符号链接目录进
|
|
358
|
+
* 「dirnames」参与用例目录匹配但**不深入**(`followlinks=False`);③ 找齐 `case_ids`
|
|
359
|
+
* 立即收工。返回按 `id` 升序(Python `sorted(dict.items())` 的元组序 = id 序)。
|
|
360
|
+
* @param {string} root 案例库根
|
|
361
|
+
* @param {string[]} caseIds
|
|
362
|
+
* @returns {Array<[string, string]>} `[[caseId, 绝对目录], ...]`
|
|
363
|
+
*/
|
|
364
|
+
export function findCaseDirs(root, caseIds) {
|
|
365
|
+
const want = new Set(caseIds || []);
|
|
366
|
+
const found = new Map();
|
|
367
|
+
if (want.size === 0 || !isDir(root)) return [];
|
|
368
|
+
|
|
369
|
+
const walk = (dir) => {
|
|
370
|
+
let entries;
|
|
371
|
+
try {
|
|
372
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
373
|
+
} catch {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
// 先按 os.walk 的 dirnames 语义筛一遍(隐藏项剔除;符号链接保留但不深入)
|
|
377
|
+
const dirs = entries.filter((ent) => {
|
|
378
|
+
if (ent.name.startsWith('.')) return false;
|
|
379
|
+
if (ent.isDirectory()) return true;
|
|
380
|
+
return ent.isSymbolicLink() && isDir(pyJoin(dir, ent.name));
|
|
381
|
+
});
|
|
382
|
+
for (const ent of dirs) {
|
|
383
|
+
const m = CASE_DIR_RE.exec(ent.name);
|
|
384
|
+
if (!m || !want.has(m[1])) continue;
|
|
385
|
+
found.set(m[1], pyJoin(dir, ent.name));
|
|
386
|
+
want.delete(m[1]);
|
|
387
|
+
if (want.size === 0) return; // Python 找齐即 return(不再递归)
|
|
388
|
+
}
|
|
389
|
+
for (const ent of dirs) {
|
|
390
|
+
if (want.size === 0) return;
|
|
391
|
+
if (ent.isDirectory()) walk(pyJoin(dir, ent.name));
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
walk(String(root));
|
|
396
|
+
return [...found.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* `lib.bug_cases`:读 bug 报告全文并提取关联 case id(去重、码点升序)。
|
|
401
|
+
* @param {string} bugDirPath
|
|
402
|
+
* @returns {{content: string, ids: string[]}}
|
|
403
|
+
*/
|
|
404
|
+
export function bugCases(bugDirPath) {
|
|
405
|
+
const content = readText(pyJoin(bugDirPath, 'bug_report.md'));
|
|
406
|
+
const ids = pySorted(new Set(content.match(FS_ID_RE) || []));
|
|
407
|
+
return { content, ids };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* `lib.runtime_dir`:运行期机器产物目录(`<工作目录>/<name>`)。
|
|
412
|
+
* @param {string} workDir
|
|
413
|
+
* @param {string} name
|
|
414
|
+
* @returns {string}
|
|
415
|
+
*/
|
|
416
|
+
export function runtimeDir(workDir, name) {
|
|
417
|
+
return pyJoin(workDir, name);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** 运行产物目录名(对齐 `lib.py:RUNTIME_DIR_NAMES`;顺序即 .gitignore 追加序) */
|
|
421
|
+
export const RUNTIME_DIR_NAMES = Object.freeze(['.web', '.live', 'board_media']);
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* `lib.ensure_runtime_dirs`:确保 `<工作目录>` 下运行产物目录就绪,并**幂等**维护
|
|
425
|
+
* `.gitignore`(缺哪条补哪条;已有内容——含用户手写的其他规则——一律不动)。
|
|
426
|
+
*
|
|
427
|
+
* 三个刻意的等价点(照 Python):
|
|
428
|
+
* - 目录建不出来 / `.gitignore` 写不进去**都不抛错**(后续日志写入自然报启动失败);
|
|
429
|
+
* - `.gitignore` 判存在用**整行 strip 后**的集合比对(`" .web/ "` 也算已有);
|
|
430
|
+
* - 追加前若原文非空且**不以换行结尾**,先补一个换行(免把两条规则粘成一行)。
|
|
431
|
+
*
|
|
432
|
+
* @param {string} workDir
|
|
433
|
+
*/
|
|
434
|
+
export function ensureRuntimeDirs(workDir) {
|
|
435
|
+
for (const name of RUNTIME_DIR_NAMES) {
|
|
436
|
+
try {
|
|
437
|
+
fs.mkdirSync(runtimeDir(workDir, name), { recursive: true });
|
|
438
|
+
} catch { /* 建不出来不抛:与 Python 同(后续写入自然失败) */ }
|
|
439
|
+
}
|
|
440
|
+
const gi = pyJoin(workDir, '.gitignore');
|
|
441
|
+
try {
|
|
442
|
+
const text = readText(gi);
|
|
443
|
+
const lines = new Set(text.split('\n').map((ln) => ln.trim()));
|
|
444
|
+
const missing = RUNTIME_DIR_NAMES.filter((name) => !lines.has(`${name}/`));
|
|
445
|
+
if (missing.length) {
|
|
446
|
+
const head = text && !text.endsWith('\n') ? '\n' : '';
|
|
447
|
+
fs.appendFileSync(gi, `${head}${missing.map((n) => `${n}/`).join('\n')}\n`, 'utf8');
|
|
448
|
+
}
|
|
449
|
+
} catch { /* 读不到/写不进不抛:与 Python 同 */ }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// --------------------------------------------------------------------------
|
|
453
|
+
// 目录浏览与项目文件读取(server.py 的同名函数)
|
|
454
|
+
// --------------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
/** 目录浏览 API 单目录子目录数上限:防超大目录拖垮响应(对齐 `server.py:FS_BROWSE_LIMIT`) */
|
|
457
|
+
export const FS_BROWSE_LIMIT = 500;
|
|
458
|
+
|
|
459
|
+
/** 项目文件预览读取上限:超出只给前 256KB 并置 truncated(对齐 `server.py:FILE_PREVIEW_MAX`) */
|
|
460
|
+
export const FILE_PREVIEW_MAX = 256 * 1024;
|
|
461
|
+
|
|
462
|
+
/** 原文下载上限:超过拒绝(对齐 `server.py:FILE_RAW_MAX`) */
|
|
463
|
+
export const FILE_RAW_MAX = 32 * 1024 * 1024;
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* `fs_roots`:按服务端平台返回目录浏览的根路径列表。
|
|
467
|
+
* Windows 逐盘符 A:~Z: 探测实际存在的盘符根;Linux/macOS 单根 `/`。
|
|
468
|
+
* @returns {string[]}
|
|
469
|
+
*/
|
|
470
|
+
export function fsRoots() {
|
|
471
|
+
if (process.platform === 'win32') {
|
|
472
|
+
const roots = [];
|
|
473
|
+
for (let i = 0; i < 26; i += 1) {
|
|
474
|
+
const letter = String.fromCharCode(65 + i);
|
|
475
|
+
if (pathExists(`${letter}:\\`)) roots.push(`${letter}:\\`);
|
|
476
|
+
}
|
|
477
|
+
return roots.length ? roots : ['C:\\'];
|
|
478
|
+
}
|
|
479
|
+
return ['/'];
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** `os.path.normcase`(POSIX 恒等;Windows 转反斜杠并小写) */
|
|
483
|
+
function normcase(value) {
|
|
484
|
+
const text = String(value);
|
|
485
|
+
return process.platform === 'win32' ? text.replace(/\//g, '\\').toLowerCase() : text;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* `_within_root`:path 是否等于 root 或位于 root 之内(调用前双方均须 realpath 规范化)。
|
|
490
|
+
* @param {string} value
|
|
491
|
+
* @param {string} root
|
|
492
|
+
* @returns {boolean}
|
|
493
|
+
*/
|
|
494
|
+
export function withinRoot(value, root) {
|
|
495
|
+
const p = String(value == null ? '' : value);
|
|
496
|
+
const r = String(root == null ? '' : root);
|
|
497
|
+
if (!p || !r) return false;
|
|
498
|
+
const np = normcase(p);
|
|
499
|
+
const nr = normcase(r.replace(/[/\\]+$/, ''));
|
|
500
|
+
return np === nr || np.startsWith(`${nr}${path.sep}`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** 浏览器平台名(对齐 Python:`os.name == "nt"` → windows,darwin → macos,其余 linux) */
|
|
504
|
+
function platformName() {
|
|
505
|
+
if (process.platform === 'win32') return 'windows';
|
|
506
|
+
if (process.platform === 'darwin') return 'macos';
|
|
507
|
+
return 'linux';
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* `fs_browse`:列出一个目录下的子目录(不含文件),供前端目录选择对话框浏览。
|
|
512
|
+
*
|
|
513
|
+
* 返回 `platform/roots/path/parent/dirs`;`rawPath` 为空时是根视角(dirs 即根列表);
|
|
514
|
+
* 目录不存在或不可读返回 `null`(调用方 400)。条目用 **stat 跟随符号链接**判定目录
|
|
515
|
+
* (Python `entry.is_dir()` 的默认行为),坏条目跳过而不中断整个列举。
|
|
516
|
+
* @param {string} rawPath
|
|
517
|
+
* @returns {{platform: string, roots: string[], path: string, parent: string|null,
|
|
518
|
+
* dirs: Array<{name: string, path: string}>}|null}
|
|
519
|
+
*/
|
|
520
|
+
export function fsBrowse(rawPath) {
|
|
521
|
+
const platform = platformName();
|
|
522
|
+
const roots = fsRoots();
|
|
523
|
+
if (!pyStrip(rawPath)) {
|
|
524
|
+
return { platform, roots, path: '', parent: null, dirs: roots.map((r) => ({ name: r, path: r })) };
|
|
525
|
+
}
|
|
526
|
+
const dirPath = realpathOrResolve(rawPath);
|
|
527
|
+
if (!isDir(dirPath)) return null;
|
|
528
|
+
let entries;
|
|
529
|
+
try {
|
|
530
|
+
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
531
|
+
} catch {
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
const dirs = [];
|
|
535
|
+
for (const ent of entries) {
|
|
536
|
+
try {
|
|
537
|
+
const full = pyJoin(dirPath, ent.name);
|
|
538
|
+
// Python `entry.is_dir()` 跟随符号链接且可能抛 OSError(无权限/已失效)⇒ 跳过该条目
|
|
539
|
+
if (ent.isDirectory() || (ent.isSymbolicLink() && isDir(full))) {
|
|
540
|
+
dirs.push({ name: ent.name, path: full });
|
|
541
|
+
}
|
|
542
|
+
} catch {
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
if (dirs.length >= FS_BROWSE_LIMIT) break;
|
|
546
|
+
}
|
|
547
|
+
dirs.sort((a, b) => pyStrCompare(a.name, b.name));
|
|
548
|
+
let parent = pyDirname(dirPath);
|
|
549
|
+
if (parent === dirPath) parent = null; // dirname 对根目录返回自身 ⇒ 视为已到根
|
|
550
|
+
return { platform, roots, path: dirPath, parent, dirs };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* `resolve_project_file`:把会话里出现的路径解析为项目范围内可读的文件(越界不读)。
|
|
555
|
+
*
|
|
556
|
+
* 解析规则(与 agent CLI 的 cwd=project_dir 一致):绝对路径原样规范化;相对路径先按
|
|
557
|
+
* `<项目目录>`、再按 `<工作目录>` 拼接;两侧都存在时项目目录优先。
|
|
558
|
+
*
|
|
559
|
+
* @param {object} project 项目行(需 `project_dir` / `work_dir`)
|
|
560
|
+
* @param {string} rawPath 原始路径(会话里出现的形态)
|
|
561
|
+
* @returns {{path: string|null, rootKey: ''|'project'|'work',
|
|
562
|
+
* err: ''|'empty'|'outside'|'dir'|'missing'}}
|
|
563
|
+
* `err='outside'` 表示落在两个根之外(含 `../` 穿越与任意系统路径),调用方一律 403。
|
|
564
|
+
*/
|
|
565
|
+
export function resolveProjectFile(project, rawPath) {
|
|
566
|
+
const raw = pyStrip(rawPath);
|
|
567
|
+
if (!raw) return { path: null, rootKey: '', err: 'empty' };
|
|
568
|
+
const roots = [];
|
|
569
|
+
for (const [key, value] of [['project', project.project_dir], ['work', project.work_dir]]) {
|
|
570
|
+
const dir = pyStrip(value);
|
|
571
|
+
if (dir) roots.push([key, realpathOrResolve(dir)]);
|
|
572
|
+
}
|
|
573
|
+
const candidates = [];
|
|
574
|
+
if (pyIsAbs(raw)) {
|
|
575
|
+
candidates.push([null, realpathOrResolve(raw)]);
|
|
576
|
+
} else {
|
|
577
|
+
for (const [key, root] of roots) candidates.push([key, realpathOrResolve(pyJoin(root, raw))]);
|
|
578
|
+
}
|
|
579
|
+
const inside = candidates.filter(([, p]) => roots.some(([, root]) => withinRoot(p, root)));
|
|
580
|
+
const outside = candidates
|
|
581
|
+
.filter(([key, p]) => !inside.some(([k, q]) => k === key && q === p))
|
|
582
|
+
.map(([, p]) => p);
|
|
583
|
+
for (const cand of inside) {
|
|
584
|
+
let [key, p] = cand;
|
|
585
|
+
if (isFile(p)) {
|
|
586
|
+
// 绝对路径命中时归属按「包含它的根」判定(file 视图里显示相对路径要用 root_key)
|
|
587
|
+
if (key === null) {
|
|
588
|
+
const hit = roots.find(([, root]) => withinRoot(p, root));
|
|
589
|
+
key = hit ? hit[0] : '';
|
|
590
|
+
}
|
|
591
|
+
return { path: p, rootKey: key, err: '' };
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
for (const [key, p] of inside) {
|
|
595
|
+
if (pathExists(p)) return { path: p, rootKey: key || '', err: 'dir' };
|
|
596
|
+
}
|
|
597
|
+
if (inside.length) return { path: inside[0][1], rootKey: '', err: 'missing' };
|
|
598
|
+
return { path: outside.length ? outside[0] : raw, rootKey: '', err: 'outside' };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** 严格 UTF-8 解码(`ignoreBOM: true`:Node 的 TextDecoder 默认**吃掉**开头 BOM,Python 不会) */
|
|
602
|
+
const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* `read_text_preview`:读文件前 `limit` 字节并按 UTF-8 解码。
|
|
606
|
+
*
|
|
607
|
+
* 二进制判定:含 NUL 字节,或非 UTF-8(**截断切断多字节字符时先退 1~3 字节重试**,
|
|
608
|
+
* 否则「刚好切在半个汉字上」会被误判成二进制);二进制不返回内容。
|
|
609
|
+
* 注意本函数读的是**原始字节**(Python 侧 `open(path,"rb")`)⇒ 不做换行转换。
|
|
610
|
+
* @param {string} filePath
|
|
611
|
+
* @param {number} [limit]
|
|
612
|
+
* @returns {{text: string, size: number, truncated: boolean, binary: boolean}}
|
|
613
|
+
*/
|
|
614
|
+
export function readTextPreview(filePath, limit = FILE_PREVIEW_MAX) {
|
|
615
|
+
const size = pathSize(filePath); // 抛 OSError ⇒ 调用方按 404 处理
|
|
616
|
+
const fd = fs.openSync(filePath, 'r');
|
|
617
|
+
let data;
|
|
618
|
+
try {
|
|
619
|
+
const buf = Buffer.allocUnsafe(limit + 1);
|
|
620
|
+
const n = fs.readSync(fd, buf, 0, limit + 1, 0);
|
|
621
|
+
data = buf.subarray(0, n);
|
|
622
|
+
} finally {
|
|
623
|
+
fs.closeSync(fd);
|
|
624
|
+
}
|
|
625
|
+
const truncated = data.length > limit;
|
|
626
|
+
if (truncated) data = data.subarray(0, limit);
|
|
627
|
+
if (data.includes(0)) return { text: '', size, truncated, binary: true };
|
|
628
|
+
try {
|
|
629
|
+
return { text: STRICT_UTF8.decode(data), size, truncated, binary: false };
|
|
630
|
+
} catch {
|
|
631
|
+
if (truncated) {
|
|
632
|
+
for (const drop of [1, 2, 3]) {
|
|
633
|
+
try {
|
|
634
|
+
const cut = data.subarray(0, Math.max(0, data.length - drop));
|
|
635
|
+
return { text: STRICT_UTF8.decode(cut), size, truncated, binary: false };
|
|
636
|
+
} catch {
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return { text: '', size, truncated, binary: true };
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// --------------------------------------------------------------------------
|
|
646
|
+
// git 摘录(`lib.git_log_changes`,M6-4 随 prompts.js 一起落地)
|
|
647
|
+
// --------------------------------------------------------------------------
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Python `str.splitlines()` 的切分点:比 `split("\n")` 多切 `\v \f \x1c-\x1e \x85 \u2028 \u2029`
|
|
651
|
+
* (`\r\n`/`\r` 已被下面的 universal-newlines 折成 `\n`,这里只需覆盖其余八个)。
|
|
652
|
+
*
|
|
653
|
+
* 为什么值得单独一条:`git log --pretty=… --name-only` 的每个提交块都走它切行,
|
|
654
|
+
* 一条含 `\x0b` 的提交标题在两版下会切成不同的行数 ⇒ 提交行/文件行错位。
|
|
655
|
+
*/
|
|
656
|
+
const PY_SPLITLINES_RE = /\n|\v|\f|\x1c|\x1d|\x1e|\x85|\u2028|\u2029/;
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Python `text[:n]` 的**码点**切片。
|
|
660
|
+
*
|
|
661
|
+
* 为什么 lib.js 自带一份而不 import rag.js 的 `pySliceCodePoints`:rag.js **import 本文件**
|
|
662
|
+
* (用 `pyJoin`/`readText`/…),反向 import 会成环。两处实现同一语义,各自的文件头已互相备案。
|
|
663
|
+
*/
|
|
664
|
+
function sliceCodePoints(text, n) {
|
|
665
|
+
const cps = Array.from(String(text));
|
|
666
|
+
return cps.slice(0, n).join('');
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* `lib.git_log_changes`:摘录日期范围(`YYYY-MM-DD`,空=不限)内的 git 提交与变更文件。
|
|
671
|
+
*
|
|
672
|
+
* 逐点对齐 `lib.py:215-244`:
|
|
673
|
+
* ① 命令与参数顺序逐字照抄(`-C`/`-c core.quotepath=false`/`--pretty=format:%x00%h %ad %an %s`/
|
|
674
|
+
* `--date=short`/`--name-only`;`--since`/`--until` 只在非空时追加,且补 ` 00:00:00`/` 23:59:59`);
|
|
675
|
+
* ② 返回 `{ok: true, commits, files}` / `{ok: false, error}`——**非 git 仓库/git 不可用/无提交
|
|
676
|
+
* 一律 `ok:false` 静默降级**,不抛异常(`prompts._date_range_ctx` 据此给降级文案);
|
|
677
|
+
* ③ 提交块以 `\x00` 分隔(文件名不会含 NUL),块首行是提交头、其余行是变更文件;
|
|
678
|
+
* 空块(连续 NUL / 尾部 NUL)与「只含空白的行」跳过;
|
|
679
|
+
* ④ `files` 去重后按**码点**升序(`pyStrCompare`:JS 默认的 UTF-16 码元序在星平面字符上会分叉);
|
|
680
|
+
* ⑤ `commits[:limit]` 截断的是**提交条数**,与 `files` 无关;
|
|
681
|
+
* ⑥ `stderr` 先按 Python `str.strip()` 裁、再按**码点**截 300;
|
|
682
|
+
* ⑦ stdout 按文本模式读 ⇒ 与 `readText` 同一条 universal-newlines 折行(Python `text=True` 的行为)。
|
|
683
|
+
*
|
|
684
|
+
* 已知残余差异(备案,不修):
|
|
685
|
+
* - `git` 不在 PATH 时 Python 给 `git 不可用: [Errno 2] No such file or directory: 'git'`、
|
|
686
|
+
* Node 给 `git 不可用: spawnSync git ENOENT`——两侧**都降级**但文案不同;该分支不进对拍判据
|
|
687
|
+
* (真跑 git 拿不到这个形态,除非把 PATH 清空)。
|
|
688
|
+
* - `timeout=20s` 的超时文案同理(Python `Command '…' timed out after 20 seconds`)。
|
|
689
|
+
*
|
|
690
|
+
* @param {string} projectDir 项目目录(`git -C` 的目标)
|
|
691
|
+
* @param {string} [dateFrom] `YYYY-MM-DD`,空=不限
|
|
692
|
+
* @param {string} [dateTo] `YYYY-MM-DD`,空=不限
|
|
693
|
+
* @param {number} [limit] 提交条数上限(默认 100,照 Python)
|
|
694
|
+
* @returns {{ok: true, commits: string[], files: string[]} | {ok: false, error: string}}
|
|
695
|
+
*/
|
|
696
|
+
export function gitLogChanges(projectDir, dateFrom = '', dateTo = '', limit = 100) {
|
|
697
|
+
const cmd = ['-C', String(projectDir), '-c', 'core.quotepath=false', 'log',
|
|
698
|
+
'--pretty=format:%x00%h %ad %an %s', '--date=short', '--name-only'];
|
|
699
|
+
if (dateFrom) cmd.push(`--since=${dateFrom} 00:00:00`);
|
|
700
|
+
if (dateTo) cmd.push(`--until=${dateTo} 23:59:59`);
|
|
701
|
+
// 不传 env:继承本进程环境(照 subprocess.run 的缺省)——harness 的 GIT_ENV 与单测里
|
|
702
|
+
// 放在 PATH 前端的「假 git」都靠这条继承生效。
|
|
703
|
+
const p = spawnSync('git', cmd, { encoding: 'utf8', timeout: 20_000, maxBuffer: 64 * 1024 * 1024 });
|
|
704
|
+
if (p.error) return { ok: false, error: `git 不可用: ${p.error.message}` };
|
|
705
|
+
if (p.status !== 0) return { ok: false, error: sliceCodePoints(pyStrip(p.stderr ?? ''), 300) };
|
|
706
|
+
const stdout = String(p.stdout ?? '').replace(/\r\n?/g, '\n'); // 见 ⑦
|
|
707
|
+
const commits = [];
|
|
708
|
+
const files = [];
|
|
709
|
+
for (const blk of stdout.split('\x00')) {
|
|
710
|
+
const lines = blk.split(PY_SPLITLINES_RE).filter((ln) => pyStrip(ln) !== '');
|
|
711
|
+
if (!lines.length) continue;
|
|
712
|
+
commits.push(lines[0]);
|
|
713
|
+
for (const ln of lines.slice(1)) files.push(ln);
|
|
714
|
+
}
|
|
715
|
+
return { ok: true, commits: commits.slice(0, limit), files: pySorted(new Set(files)) };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// --------------------------------------------------------------------------
|
|
719
|
+
// 案例库写口与固化判定(`lib.py:50-109 / 176-212`,M6-6 接线前补齐)
|
|
720
|
+
//
|
|
721
|
+
// 为什么要补齐:`runner` 的轮次执行体(复测前置标记 / 收尾判通过)与
|
|
722
|
+
// `POST /api/projects/<pid>/bugs/<dir>/reject`(改 bug 报告状态)都要用这几个口;
|
|
723
|
+
// 此前 `tasks.js` 里有一份就地私有的 `bug_cases_scripted`(其注释自认「M6 落地后应换成
|
|
724
|
+
// lib 的导出」),本批归位。
|
|
725
|
+
// --------------------------------------------------------------------------
|
|
726
|
+
|
|
727
|
+
/** 已固化复测脚本文件名(`lib.py:177`):用例目录内存在该文件即视为已固化(文件即事实,无登记表) */
|
|
728
|
+
export const VERIFY_SCRIPT_NAME = 'verify.py';
|
|
729
|
+
|
|
730
|
+
/** 正则元字符转义(`re.escape` 的等价物,只覆盖本文件用到的 `**字段**` 形态) */
|
|
731
|
+
function escapeRegExp(text) {
|
|
732
|
+
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* `lib.set_md_field`:改写 md 里 `- **字段**: 值` 一行;行不存在时在文末追加。
|
|
737
|
+
* 返回**是否命中已有行**(Python 同)。`read_text` 读空时直接 false(不建文件)。
|
|
738
|
+
*
|
|
739
|
+
* 与 Python 的唯一形态差异(**实际不可达**,备案):Python 的 `pattern.sub(new_line, text)`
|
|
740
|
+
* 会把替换串里的 `\1` 当组引用解释;这里用回调(`prefix + value`),值里的反斜杠是字面量。
|
|
741
|
+
* 本口的值只有「已拒绝」「需要复测」两个固定串,故无分叉。
|
|
742
|
+
*/
|
|
743
|
+
export function setMdField(filePath, field, value) {
|
|
744
|
+
const text = readText(filePath);
|
|
745
|
+
if (!text) return false;
|
|
746
|
+
const pattern = new RegExp(
|
|
747
|
+
`^(\\s*(?:-\\s*)?\\*\\*${escapeRegExp(field)}\\*\\*\\s*[::]\\s*).*$`, 'm',
|
|
748
|
+
);
|
|
749
|
+
const out = pattern.test(text)
|
|
750
|
+
? text.replace(pattern, (matched, prefix) => prefix + String(value))
|
|
751
|
+
: `${text.replace(/\n+$/, '')}\n\n- **${field}**: ${value}\n`;
|
|
752
|
+
try {
|
|
753
|
+
fs.writeFileSync(filePath, out, 'utf8');
|
|
754
|
+
return true;
|
|
755
|
+
} catch {
|
|
756
|
+
return false; // 写不进去不抛(照 Python `except OSError`)
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* `lib.mark_cases_retest`:把关联 case 的 `status.md` 状态改成「需要复测」(复测任务开始前调)。
|
|
762
|
+
* 返回**改成功**的 case id 列表(找不到目录/写不进去的 id 不进结果)。
|
|
763
|
+
*/
|
|
764
|
+
export function markCasesRetest(root, caseIds) {
|
|
765
|
+
const paths = [];
|
|
766
|
+
for (const [cid, dir] of findCaseDirs(root, caseIds)) {
|
|
767
|
+
if (setMdField(pyJoin(dir, 'status.md'), '状态', '需要复测')) paths.push(cid);
|
|
768
|
+
}
|
|
769
|
+
return paths;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* `lib.cases_all_passed`:关联 case 是否**全部**「通过」。
|
|
774
|
+
*
|
|
775
|
+
* 有 id 定位不到目录时视为未通过(`lib.py:117-120` 的理由:否则「找不到」被静默忽略,
|
|
776
|
+
* 剩下找到的全通过就会误判整体通过——bug 报告引用不存在/改名案例的场景)。
|
|
777
|
+
*/
|
|
778
|
+
export function casesAllPassed(root, caseIds) {
|
|
779
|
+
if (!caseIds || caseIds.length === 0) return false;
|
|
780
|
+
const dirs = findCaseDirs(root, caseIds);
|
|
781
|
+
if (dirs.length !== new Set(caseIds).size) return false;
|
|
782
|
+
for (const [, dir] of dirs) {
|
|
783
|
+
if (parseStatusMd(pyJoin(dir, 'status.md')).status !== '通过') return false;
|
|
784
|
+
}
|
|
785
|
+
return true;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* `lib.bug_cases_scripted`:用例集合是否**全部已固化**(每个用例目录里都有 `verify.py`)。
|
|
790
|
+
*
|
|
791
|
+
* 全部固化且用例数 > 0 才 True;任一未固化 / 有 id 定位不到目录 / 集合为空 ⇒ False
|
|
792
|
+
* (调用方回落 agent 复测路径)。`verify.py` 是**目录**不算固化(`os.path.isfile` 口径)。
|
|
793
|
+
*/
|
|
794
|
+
export function bugCasesScripted(casesRoot, caseIds) {
|
|
795
|
+
const dirs = findCaseDirs(casesRoot, caseIds);
|
|
796
|
+
if (dirs.length === 0 || dirs.length !== new Set(caseIds).size) return false;
|
|
797
|
+
return dirs.every(([, dir]) => isFile(pyJoin(dir, VERIFY_SCRIPT_NAME)));
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* `lib.ensure_gitignore(base_dir, entry)`:在 `<base_dir>/.gitignore` 幂等追加一行忽略项。
|
|
802
|
+
*
|
|
803
|
+
* 与 `ensureRuntimeDirs` 的 .gitignore 维护同构:已有内容(含用户手写规则)一律不动;
|
|
804
|
+
* `base_dir` 不存在时顺带创建;写不进去不抛错。
|
|
805
|
+
*/
|
|
806
|
+
export function ensureGitignore(baseDir, entry) {
|
|
807
|
+
try {
|
|
808
|
+
fs.mkdirSync(baseDir, { recursive: true });
|
|
809
|
+
} catch {
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
const gi = pyJoin(baseDir, '.gitignore');
|
|
813
|
+
const text = readText(gi);
|
|
814
|
+
const lines = new Set(text.split(/\r\n|\r|\n|\u2028|\u2029/).map((ln) => ln.trim()));
|
|
815
|
+
if (lines.has(String(entry))) return;
|
|
816
|
+
try {
|
|
817
|
+
const head = text && !text.endsWith('\n') ? '\n' : '';
|
|
818
|
+
fs.appendFileSync(gi, `${head}${entry}\n`, 'utf8');
|
|
819
|
+
} catch { /* 写不进不抛:与 Python 同 */ }
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// --------------------------------------------------------------------------
|
|
823
|
+
// M6.9:项目命名 / 目录新建(server.py 的三个纯函数)
|
|
824
|
+
// --------------------------------------------------------------------------
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Python `bool(x)` 的真值口径,用于 `(value or "")`。
|
|
828
|
+
*
|
|
829
|
+
* 为什么不直接写 `value || ''`:JS 的 `[]`/`{}` 是**真**,而 Python 的空 list/dict 是假
|
|
830
|
+
* (`normalize_project_name({})` 在 Python 给 `"project"`、在错实现下给 `"[object Object]"`)。
|
|
831
|
+
* 与 `rag.js:pyTruthy` 同表;这里**不 import 它**是模块图原因:`rag.js` 已经
|
|
832
|
+
* `import ... from './lib.js'`,反向 import 会成环(db.js / builtin-assets.js 也是各自私有的
|
|
833
|
+
* 同款小函数,属既有约定)。
|
|
834
|
+
* 注:`NaN` 在 Python 里是真(`float('nan')`),本函数按 JS 口径判假 —— 差异只在「直接传
|
|
835
|
+
* NaN」时成立,JSON 解不出 NaN,路由入参不可能走到。
|
|
836
|
+
* @param {unknown} value
|
|
837
|
+
* @returns {boolean}
|
|
838
|
+
*/
|
|
839
|
+
function pyTruthy(value) {
|
|
840
|
+
if (value === null || value === undefined) return false;
|
|
841
|
+
switch (typeof value) {
|
|
842
|
+
case 'boolean': return value;
|
|
843
|
+
case 'number': return value !== 0 && !Number.isNaN(value);
|
|
844
|
+
case 'string': return value !== '';
|
|
845
|
+
case 'object':
|
|
846
|
+
return Array.isArray(value) ? value.length > 0 : Object.keys(value).length > 0;
|
|
847
|
+
default: return true;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** Python `(value or "")`:假值一律回落空串(其余原样返回,类型不变) */
|
|
852
|
+
function pyOrEmpty(value) {
|
|
853
|
+
return pyTruthy(value) ? value : '';
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* `normalize_project_name(name)`:项目名规范化——去两端空白、斜杠防路径穿越。
|
|
858
|
+
* 对齐 `server.py:815`:`(name or "").strip().replace("/", "_").replace("\\", "_")`,
|
|
859
|
+
* 结果为空则回落 `"project"`。空值/假值(`0`/`False`/`[]`/`{}`)走 Python 的 `or ""`。
|
|
860
|
+
* @param {unknown} name
|
|
861
|
+
* @returns {string}
|
|
862
|
+
*/
|
|
863
|
+
export function normalizeProjectName(name) {
|
|
864
|
+
const text = pyStrip(pyOrEmpty(name));
|
|
865
|
+
return text.replace(/\//g, '_').replace(/\\/g, '_') || 'project';
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* `path_last_segment(path)`:取路径最后一段(项目名称留空时的自动命名来源)。
|
|
870
|
+
* 对齐 `server.py:825`:先 `(path or "").strip()`,再按 `/` 与 `\` 的**连续串**切分
|
|
871
|
+
* (`re.split(r"[\\/]+", ...)`,空段滤掉),从尾往前找第一个**非纯盘符段**
|
|
872
|
+
* (`re.fullmatch(r"[A-Za-z]:", seg)` 的形态,如 `D:`);找不到返回空串。
|
|
873
|
+
* @param {unknown} pathValue
|
|
874
|
+
* @returns {string}
|
|
875
|
+
*/
|
|
876
|
+
export function pathLastSegment(pathValue) {
|
|
877
|
+
const text = pyStrip(pyOrEmpty(pathValue));
|
|
878
|
+
const segs = text.split(/[\\/]+/).filter((s) => s !== '');
|
|
879
|
+
for (let i = segs.length - 1; i >= 0; i -= 1) {
|
|
880
|
+
if (!/^[A-Za-z]:$/.test(segs[i])) return segs[i];
|
|
881
|
+
}
|
|
882
|
+
return '';
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* `fs_mkdir(raw_path, raw_name)`:在指定目录下新建一个子目录(目录选择对话框「新建文件夹」)。
|
|
887
|
+
* 对齐 `server.py:968`,返回**二元组** `[data, err]`:
|
|
888
|
+
* - 成功 `[{name, path}, null]`;`path` 是新目录的绝对路径(realpath)
|
|
889
|
+
* - 失败 `[null, 原因文本]`——直接展示给用户,故文案逐字照抄 Python(中文)
|
|
890
|
+
*
|
|
891
|
+
* 逐条对齐的判据(顺序也照抄,先父目录后名称):
|
|
892
|
+
* 1. 父目录:`(raw_path or "").strip()` 为空、或 `realpath` 后不是目录 ⇒ 拒;
|
|
893
|
+
* 2. 名称:`(raw_name or "").strip()`;空 ⇒ 拒;
|
|
894
|
+
* 3. 名称是 `.`/`..`、含 `/` 或 `\`、含 NUL ⇒ 拒;
|
|
895
|
+
* 4. `name != name.rstrip(". ")`(结尾是点或空格,任意组合)⇒ 拒 —— Windows 会截掉结尾
|
|
896
|
+
* 点/空格,跨平台统一拒绝。注:结尾**空白**已被上面 `strip()` 吃掉,所以这条实际只对
|
|
897
|
+
* 结尾的 `.` 生效(`"x "` → `"x"` 合法);
|
|
898
|
+
* 5. `os.path.lexists(target)`:**存在即可、不看类型、不跟随符号链接** ⇒ Node 用 `lstatSync`
|
|
899
|
+
* (`fs.existsSync` 会漏掉悬空符号链接,与 Python 分叉);不覆盖、不静默复用;
|
|
900
|
+
* 6. `os.mkdir(target)`,`OSError` ⇒ `"新建失败:<原因>"`。
|
|
901
|
+
*
|
|
902
|
+
* ── 与 Python 的差异(如实登记,别当 bug 修) ────────────────────────────────────
|
|
903
|
+
* - **`OSError` 的原因文案不可能逐字节相同**:Python `e.strerror` 是 errno 的英文文案
|
|
904
|
+
* (如 `File name too long` / `Permission denied`),Node 的 fs 错误**没有 `strerror`
|
|
905
|
+
* 字段**(实测 `err.strerror === undefined`,只有 `errno`/`code`/`syscall`/`path`),
|
|
906
|
+
* 其 `err.message` 形如 `ENAMETOOLONG: name too long, mkdir '/tmp/x/y'`。这里用
|
|
907
|
+
* `err.message`(不硬编假文案),故该分支只保证前缀 `新建失败:` 相同;
|
|
908
|
+
* - `os.path.realpath` 的符号链接展开:存在路径上 `realpathSync` 与 Python 等价
|
|
909
|
+
* (父目录是符号链接时会落到真身并返回解析后的路径);`realpathOrResolve` 对**不存在**
|
|
910
|
+
* 路径的近似差异见本文件头「已知有意保留的差异」。
|
|
911
|
+
* @param {unknown} rawPath 父目录(可为前端传来的相对/带尾斜杠形态)
|
|
912
|
+
* @param {unknown} rawName 新目录名(单级)
|
|
913
|
+
* @returns {[{name: string, path: string}|null, string|null]}
|
|
914
|
+
*/
|
|
915
|
+
export function fsMkdir(rawPath, rawName) {
|
|
916
|
+
let parent = pyStrip(pyOrEmpty(rawPath));
|
|
917
|
+
if (!parent || !isDir(realpathOrResolve(parent))) {
|
|
918
|
+
return [null, '请先进入一个存在的目录再新建文件夹'];
|
|
919
|
+
}
|
|
920
|
+
parent = realpathOrResolve(parent);
|
|
921
|
+
const name = pyStrip(pyOrEmpty(rawName));
|
|
922
|
+
if (!name) return [null, '文件夹名称不能为空'];
|
|
923
|
+
if (name === '.' || name === '..' || /[\\/]/.test(name) || name.includes('\x00')) {
|
|
924
|
+
return [null, '文件夹名称不能包含路径分隔符,也不能是 . 或 ..'];
|
|
925
|
+
}
|
|
926
|
+
if (name !== name.replace(/[. ]+$/, '')) { // str.rstrip(". "):结尾点/空格一律拒
|
|
927
|
+
return [null, '文件夹名称不能以点或空格结尾'];
|
|
928
|
+
}
|
|
929
|
+
const target = pyJoin(parent, name);
|
|
930
|
+
try {
|
|
931
|
+
fs.lstatSync(target); // os.path.lexists:悬空符号链接也算存在
|
|
932
|
+
return [null, '同名文件或目录已存在'];
|
|
933
|
+
} catch { /* 不存在 ⇒ 继续创建 */ }
|
|
934
|
+
try {
|
|
935
|
+
fs.mkdirSync(target);
|
|
936
|
+
} catch (e) { // 无写权限/名称过长等,回给用户看原因
|
|
937
|
+
return [null, `新建失败:${e && e.message ? e.message : e}`];
|
|
938
|
+
}
|
|
939
|
+
return [{ name, path: realpathOrResolve(target) }, null];
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* 飞书回显掩码:`server.py:1727 _mask_webhook` 的逐字复刻。
|
|
944
|
+
*
|
|
945
|
+
* Python 原式 `return url if len(url) <= 48 else url[:48] + "…"`——**口径是码点不是
|
|
946
|
+
* UTF-16 单元**(`len()` 数的是 str 的字符)。实测边界(M7 侦察,oracle 逐字测):
|
|
947
|
+
* 47 ASCII + 1 emoji(码点 48)⇒ **不打码**;48 ASCII + 1 emoji(码点 49)⇒ 打码;
|
|
948
|
+
* 50 个中文 ⇒ 前 48 字 + `…`。用 `String.length` 会在代理对处差 1。
|
|
949
|
+
* 空值口径照抄 `if not url: return ""`(`""`/`null`/`undefined`/`0` 都回空串)。
|
|
950
|
+
*
|
|
951
|
+
* 用处:`GET /api/me/feishu-cfg` 的 `default_webhook`、`GET /api/me/feishu/outbox`
|
|
952
|
+
* 的 `target`、`GET /api/projects/<n>/feishu-hook` 的 `webhook_url`(三处同一函数)。
|
|
953
|
+
* @param {unknown} url
|
|
954
|
+
* @returns {string}
|
|
955
|
+
*/
|
|
956
|
+
export function maskWebhook(url) {
|
|
957
|
+
if (!url) return '';
|
|
958
|
+
const text = String(url);
|
|
959
|
+
const codePoints = Array.from(text);
|
|
960
|
+
return codePoints.length <= 48 ? text : codePoints.slice(0, 48).join('') + '…';
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* 飞书绑定号掩码:`server.py:2129` 的内联式 `oid[:6] + "…" if len(oid) > 6 else oid`。
|
|
965
|
+
* 同样按**码点**切(实测:8 码点中文 ⇒ 前 6 字 + `…`;`""` 原样回空串)。
|
|
966
|
+
* @param {unknown} openId
|
|
967
|
+
* @returns {string}
|
|
968
|
+
*/
|
|
969
|
+
export function maskOpenId(openId) {
|
|
970
|
+
const text = openId == null ? '' : String(openId);
|
|
971
|
+
const codePoints = Array.from(text);
|
|
972
|
+
return codePoints.length > 6 ? codePoints.slice(0, 6).join('') + '…' : text;
|
|
973
|
+
}
|