@gavinc-cn/touchstone-dsh 0.2.0 → 0.4.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/LICENSE +0 -0
- package/README.md +23 -15
- package/README.zh.md +17 -12
- package/auth.py +0 -0
- 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 +6227 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2090 -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 +1747 -0
- package/backend/lib/feishu_cfg.js +262 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +2077 -0
- package/backend/lib/feishu_provision.js +886 -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 +426 -0
- package/backend/lib/proc.js +361 -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 +4546 -0
- package/backend/lib/seed_hint.js +40 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +227 -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 +1228 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +249 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +298 -0
- package/backend/routes/projects.js +1138 -0
- package/backend/routes/sessions.js +1003 -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 +505 -0
- package/board.py +96 -13
- package/builtin_assets.py +0 -0
- package/builtin_prompts/free_style/flow.md +0 -0
- package/builtin_prompts/free_style/load_test.md +0 -0
- package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
- package/builtin_prompts/free_style/templates/case_template.md +0 -0
- package/builtin_prompts/free_style/templates/index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
- package/builtin_prompts/free_style/templates/status_template.md +0 -0
- package/chat.py +33 -0
- package/db.py +294 -129
- package/dsh-plugin/cordis.patch.yml +0 -0
- package/dsh-plugin/install.sh +22 -8
- package/dsh-plugin/lib/client.bundle.js +111 -2
- package/dsh-plugin/lib/index.js +340 -29
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/build.mjs +0 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/scripts/select-bundle.mjs +0 -0
- package/dsh-plugin/src/client.js +111 -2
- package/dshevents.py +0 -0
- package/export_cases.py +0 -0
- package/extensions/dsh-autocommit/asset.json +0 -0
- package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
- package/extensions/dsh-autocommit/pkg/package.json +0 -0
- package/feishu.py +417 -3
- package/feishu_conv.py +0 -0
- package/lib.py +0 -0
- package/lifecycle.py +116 -13
- package/loadcase.py +0 -0
- package/loadgen.py +0 -0
- package/localbus.py +0 -0
- package/migrate_p7b.py +0 -0
- package/package.json +6 -1
- package/platcompat.py +0 -0
- package/prompts.py +0 -0
- package/rag.py +0 -0
- package/requirements.txt +0 -0
- package/server.py +155 -11
- package/touchstone.py +0 -0
- package/waitq.py +0 -0
- package/webui/dist-plugin/assets/index-3QsYQi7y.css +1 -0
- package/webui/dist-plugin/assets/index-B8AMtW7y.js +486 -0
- package/webui/dist-plugin/index.html +2 -2
- package/worktree.py +0 -0
- package/webui/dist-plugin/assets/index-JLwyZZxC.js +0 -484
- package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `base64.b64decode` 的 Node 对应物(M5-4 附件上传端点用)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么不能直接用 `Buffer.from(s, 'base64')`
|
|
5
|
+
* Node 的解码器**永不报错**:非法字符丢弃、长度随便、`=` 随便放,坏输入静默产出垃圾字节。
|
|
6
|
+
* Python 的 `base64.b64decode`(`binascii.a2b_base64`,`strict_mode=False`)在坏输入上
|
|
7
|
+
* **抛 `binascii.Error`(ValueError 子类)**,`_api_board_upload_media` 据此回
|
|
8
|
+
* 400 `{"error":"bad base64"}`。若 Node 照 Node 的口径走,坏 base64 会被写成一个垃圾附件
|
|
9
|
+
* 文件并回 200 —— 用户看到「上传成功」但内容损坏(**比报错更难查**)。
|
|
10
|
+
*
|
|
11
|
+
* ## 口径(逐条实测自 CPython 3.12 的 `binascii.a2b_base64`)
|
|
12
|
+
* 1. **非 ASCII 的 str**:Python 先 `encode('ascii')` ⇒ `ValueError`。
|
|
13
|
+
* 2. 过滤:删掉所有不在 `A-Za-z0-9+/=` 的字符(空白、`!`、中文…),保留 `=`。
|
|
14
|
+
* 3. 主体解码:每 4 个数据字符出 3 字节;遇 `=` 的分支是**反直觉的那个**:
|
|
15
|
+
* · `=` 前已攒 **3** 个数据字符 ⇒ 出 2 字节并**立即结束**(**其后的输入整段被忽略**:
|
|
16
|
+
* `'aGVsbG8=extra'` → `b'hello'`);
|
|
17
|
+
* · `=` 前已攒 **2** 个数据字符、且**紧跟着还有一个 `=`** ⇒ 出 1 字节并立即结束
|
|
18
|
+
* (`'0x= ==0A'` → 1 字节);只有**一个** `=` 时该 `=` 被**跳过**、继续攒
|
|
19
|
+
* (`'AA=AA'` → 3 字节 = `'AAAA'`);
|
|
20
|
+
* · 其余(0 或 1 个数据字符):跳过。
|
|
21
|
+
* 4. 收尾校验(未触发上面两个「立即结束」时):余数 r = 数据字符数 % 4
|
|
22
|
+
* · r = 0 ⇒ 成功;r = 1 ⇒ **错误**(Python 文案是「…cannot be 1 more than a multiple of 4」);
|
|
23
|
+
* · r = 2 ⇒ 要求结尾**至少两个** `=`;r = 3 ⇒ 要求结尾**至少一个** `=`,否则
|
|
24
|
+
* 「Incorrect padding」。判据只看**结尾**的 `=`,中间位置的 `=` 不作数。
|
|
25
|
+
*
|
|
26
|
+
* ## 保真度证据
|
|
27
|
+
* 与 `base64.b64decode` 做了 **760,000 条随机串**的差分(三种偏置字母表:混合/`=` 密集/
|
|
28
|
+
* 纯 `=/`;长度 0..20,含非 ASCII 与空白),**零不一致**;外加 27 条人工语料(含本文件
|
|
29
|
+
* 注释里的每一个例子)。见 `backend/test/pybase64.test.mjs`(表来自 Python 实跑)。
|
|
30
|
+
*
|
|
31
|
+
* ## 与 Python 的形态差异(已登记)
|
|
32
|
+
* · 异常类名 `PyB64Error` 对应 `binascii.Error`;**异常文案不逐字复刻**(调用方只判类型,
|
|
33
|
+
* 正文由端点写死 `bad base64`)。
|
|
34
|
+
* · Python 的 `str()` 对**容器**给 repr(`{'a': 1}`);Node 侧走全仓已登记的容器口径
|
|
35
|
+
* (见 `doc_ai/spec/dsh_plugin/dsh插件形态.md` §23.7)——上传端点的 `data` 只会是字符串,
|
|
36
|
+
* 该差异只在「有人把 `data` 传成对象」时可见,两侧同为拒绝/同为乱码的概率不保证。
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** `binascii.Error` 的对应物(ValueError 子类);调用方据此回 400 `bad base64`。 */
|
|
40
|
+
export class PyB64Error extends Error {
|
|
41
|
+
constructor(message) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = 'PyB64Error';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 标准 base64 字母表(`A-Za-z0-9+/`,`=` 单独处理) */
|
|
48
|
+
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
49
|
+
|
|
50
|
+
/** 字符 → 6 bit 值(查表;非字母表字符不在表内) */
|
|
51
|
+
const VAL = new Map();
|
|
52
|
+
for (let i = 0; i < ALPHABET.length; i += 1) VAL.set(ALPHABET[i], i);
|
|
53
|
+
|
|
54
|
+
/** 一位字符是否是字母表成员(含 `=` 由调用方另判) */
|
|
55
|
+
function inAlphabet(ch) {
|
|
56
|
+
return VAL.has(ch);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 解 base64(Python `base64.b64decode(text)` 的非严格口径)。
|
|
61
|
+
*
|
|
62
|
+
* @param {string} text 输入串(**必须已是 str**;调用方按 Python `str(x or "")` 归一)
|
|
63
|
+
* @returns {Buffer} 解出的字节
|
|
64
|
+
* @throws {PyB64Error} 命中 Python 会抛 `ValueError`/`binascii.Error` 的任一情形
|
|
65
|
+
*/
|
|
66
|
+
export function b64decode(text) {
|
|
67
|
+
const s = String(text);
|
|
68
|
+
// ① 非 ASCII:Python `_bytes_from_decode_data` 走 s.encode('ascii') ⇒ ValueError
|
|
69
|
+
// (按码位判,不接受任何 > 0x7f 的字符)
|
|
70
|
+
for (const ch of s) {
|
|
71
|
+
if (ch.codePointAt(0) > 0x7f) {
|
|
72
|
+
throw new PyB64Error('string argument should contain only ASCII characters');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// ② 过滤非字母表字符(保留 '=')
|
|
76
|
+
const kept = [];
|
|
77
|
+
for (const ch of s) {
|
|
78
|
+
if (ch === '=' || inAlphabet(ch)) kept.push(ch);
|
|
79
|
+
}
|
|
80
|
+
const out = [];
|
|
81
|
+
let quad = [];
|
|
82
|
+
const flush = (n) => {
|
|
83
|
+
// n = 本 quad 的有效数据字符数(4→3 字节 / 3→2 字节 / 2→1 字节)
|
|
84
|
+
const v = [VAL.get(quad[0]), VAL.get(quad[1]), quad.length > 2 ? VAL.get(quad[2]) : 0,
|
|
85
|
+
quad.length > 3 ? VAL.get(quad[3]) : 0];
|
|
86
|
+
out.push((v[0] << 2) | (v[1] >> 4));
|
|
87
|
+
if (n >= 3) out.push(((v[1] & 0x0f) << 4) | (v[2] >> 2));
|
|
88
|
+
if (n >= 4) out.push(((v[2] & 0x03) << 6) | v[3]);
|
|
89
|
+
};
|
|
90
|
+
for (let i = 0; i < kept.length; i += 1) {
|
|
91
|
+
const ch = kept[i];
|
|
92
|
+
if (ch === '=') {
|
|
93
|
+
if (quad.length === 3) { flush(3); return Buffer.from(out); } // 出 2 字节,其后忽略
|
|
94
|
+
if (quad.length === 2 && kept[i + 1] === '=') { // 双 '=' ⇒ 出 1 字节
|
|
95
|
+
flush(2);
|
|
96
|
+
return Buffer.from(out);
|
|
97
|
+
}
|
|
98
|
+
continue; // 其余位置的 '=' 跳过
|
|
99
|
+
}
|
|
100
|
+
quad.push(ch);
|
|
101
|
+
if (quad.length === 4) { flush(4); quad = []; }
|
|
102
|
+
}
|
|
103
|
+
const r = quad.length;
|
|
104
|
+
if (r === 0) return Buffer.from(out);
|
|
105
|
+
if (r === 1) {
|
|
106
|
+
throw new PyB64Error('Invalid base64-encoded string: number of data characters cannot be 1'
|
|
107
|
+
+ ' more than a multiple of 4');
|
|
108
|
+
}
|
|
109
|
+
let trail = 0;
|
|
110
|
+
for (let i = kept.length - 1; i >= 0 && kept[i] === '='; i -= 1) trail += 1;
|
|
111
|
+
if (trail < 4 - r) throw new PyB64Error('Incorrect padding');
|
|
112
|
+
flush(r);
|
|
113
|
+
return Buffer.from(out);
|
|
114
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python `json.dumps(obj, ensure_ascii=False)` 的 Node 对应物(M1,2026-10-11)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:两版后端**开同一个库**,JSON 列的原始文本也是「数据」的一部分。Python 侧一律
|
|
5
|
+
* `json.dumps(..., ensure_ascii=False)`(indent=None 时的默认分隔符 `", "` / `": "`),若 Node 侧
|
|
6
|
+
* 用 `JSON.stringify`(紧凑 `,` / `:`),同一份逻辑数据的落库文本就会不同 —— 对拍里表现为
|
|
7
|
+
* 「值一样、文本不一样」,只能靠白名单遮住,等于永久放弃这几列的逐字节可比性。
|
|
8
|
+
* 本模块把这层口径补齐。首个使用点:`db.js` 的 `feishu_appsettings_bridge`(写
|
|
9
|
+
* `feishu_user_cfgs.json`);M3+ 的 `board_settings.json` / `user_prefs.value` /
|
|
10
|
+
* `app_settings.json` / `wait_items.meta` / `tasks.payload` 等 JSON 写入点复用同一入口。
|
|
11
|
+
*
|
|
12
|
+
* 逐字符对齐 CPython 的部分(有测试向量钉住):
|
|
13
|
+
* - 分隔符 `", "` 与 `": "`;空容器为 `{}` / `[]`;
|
|
14
|
+
* - 非 ASCII 原样输出(ensure_ascii=False):中文、emoji 都不转义,也不拆代理对;
|
|
15
|
+
* - 转义集合与 CPython 相同:`"` `\` `\b` `\f` `\n` `\r` `\t`,其余 < U+0020 走 `\u00xx`
|
|
16
|
+
* (小写 hex);DEL(U+007F)、U+2028/U+2029、`/`、`'` **不**转义(与 CPython 一致);
|
|
17
|
+
* - `true` / `false` / `null`;数组;对象(键保持插入序,等价于 dict 保序);
|
|
18
|
+
* - 整数(含负零以外的负数、0):十进制原文逐字符相同(超过 2^53 请传 BigInt,见下)。
|
|
19
|
+
*
|
|
20
|
+
* **浮点的残余偏差(已知、原理上不可消除;如实记录,别当 bug 修)**:
|
|
21
|
+
* - Python 区分 int 与 float:`json.dumps({"x": 1.0})` → `{"x": 1.0}`,而 JS 的 `1.0` 就是 `1`,
|
|
22
|
+
* 类型信息在 `JSON.parse` 时就丢了 ⇒ 本实现输出 `{"x": 1}`。**无法对齐**(信息不存在);
|
|
23
|
+
* - 指数记法阈值不同:CPython repr 在 exp < -4 或 exp >= 16 时切指数,JS 在 exp < -7 或
|
|
24
|
+
* exp >= 21 时切 ⇒ `1e-05`(Py) vs `0.00001`(JS)、`1e+16`(Py) vs `10000000000000000`(JS);
|
|
25
|
+
* - **指数补零**(2026-10-11 评审补记,与阈值无关的另一类):两侧都落指数记法时,CPython 的
|
|
26
|
+
* 指数至少两位补零、JS 不补 ⇒ `1e-07`(Py) vs `1e-7`(JS)、`1e-08`(Py) vs `1e-8`(JS);
|
|
27
|
+
* - **≥1e21 的普通 number**(2026-10-11 评审补记):两侧都切指数但形态可能不同,且「普通 number
|
|
28
|
+
* 到这个量级已丢精度」这个理由对 1e21 这种**可精确表示**的值并不成立——所以别指望「整数一律
|
|
29
|
+
* 逐字符相同」覆盖它;该量级请传 BigInt 或字符串;
|
|
30
|
+
* - `-0.0`:Python → `-0.0`,JS `String(-0)` → `0`;
|
|
31
|
+
* - 尾数两侧都是「最短往返」表示,故 1.5 / 0.1 / 3.141592653589793 这类一致;
|
|
32
|
+
* - NaN/Infinity:CPython 默认 `allow_nan=True`,输出 `NaN` / `Infinity` / `-Infinity`
|
|
33
|
+
* (严格 JSON 非法),本实现同样输出这三个字面量。
|
|
34
|
+
* 结论:JSON 列里若出现**整数值的浮点**、`<1e-4` 或 `≥1e16` 量级的浮点,对拍仍会看到文本差异
|
|
35
|
+
* —— 那属于上述固有偏差;字符串/整数/布尔/null/嵌套结构一律逐字节一致。
|
|
36
|
+
*
|
|
37
|
+
* JS 专有值的边界(一律抛 TypeError,绝不静默写坏数据):
|
|
38
|
+
* - `undefined` / 函数 / symbol:Python 侧无对应物(「没有值」请显式传 null);
|
|
39
|
+
* - Date / Map / Set / 类实例:CPython 只认 dict/list/str/int/float/bool/None,同类对象抛 TypeError;
|
|
40
|
+
* 本模块只接受**普通对象**(含 null 原型对象,如 node:sqlite 返回的行);
|
|
41
|
+
* - 超过 2^53 的整数请传 BigInt:输出十进制原文,与 Python 任意精度 int 逐字符相同
|
|
42
|
+
* (普通 number 到这个量级已经丢精度,属调用方问题)。
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** 与 CPython `py_encode_basestring_ascii`/`escape` 相同的命名转义(其余 < U+0020 走 \u00xx) */
|
|
46
|
+
const STRING_ESCAPES = new Map([
|
|
47
|
+
['"', '\\"'],
|
|
48
|
+
['\\', '\\\\'],
|
|
49
|
+
['\b', '\\b'],
|
|
50
|
+
['\f', '\\f'],
|
|
51
|
+
['\n', '\\n'],
|
|
52
|
+
['\r', '\\r'],
|
|
53
|
+
['\t', '\\t'],
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 序列化成 CPython `json.dumps(obj, ensure_ascii=False)` 的默认形态。
|
|
58
|
+
* @param {unknown} value 普通对象/数组/字符串/数字/布尔/null/BigInt(嵌套任意层)
|
|
59
|
+
* @returns {string} JSON 文本
|
|
60
|
+
* @throws {TypeError} 遇到 Python 侧不存在的类型(undefined/函数/symbol)或非普通对象(Date/Map/Set/类实例)
|
|
61
|
+
*/
|
|
62
|
+
export function pyJsonDumps(value) {
|
|
63
|
+
return encodeValue(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 序列化成 CPython `json.dumps(obj, ensure_ascii=False, indent=N)` 的形态(M6-2)。
|
|
68
|
+
*
|
|
69
|
+
* 与 `pyJsonDumps` 的差别只有**缩进与分隔符**(CPython 的规则:`indent` 非 None 时
|
|
70
|
+
* `item_separator=','`、`key_separator=': '`,并在每个元素前换行 + 缩进 `indent*层`):
|
|
71
|
+
* - 空容器仍是 `{}` / `[]`(CPython 对空容器不换行);
|
|
72
|
+
* - 嵌套容器紧跟键值冒号后的同一行开始(`"a": {\n …`);
|
|
73
|
+
* - 字符串/数字/布尔/null 的编码与无缩进版**共用同一段实现**(含 ensure_ascii=False、
|
|
74
|
+
* 控制字符 `\u00xx`、浮点的已知偏差——见文件头)。
|
|
75
|
+
*
|
|
76
|
+
* 使用点:`export_cases.py:126`(`retest-candidates --json`)、`loadcase.py:547`、
|
|
77
|
+
* `loadgen.py:618`(报告落盘的 `indent=1`)。
|
|
78
|
+
*
|
|
79
|
+
* @param {unknown} value
|
|
80
|
+
* @param {number|string} indent 空格数(或自定义缩进串,照 CPython 允许字符串)
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
export function pyJsonDumpsIndent(value, indent = 1) {
|
|
84
|
+
const pad = typeof indent === 'string' ? indent : ' '.repeat(Math.max(0, Number(indent) || 0));
|
|
85
|
+
return encodeIndented(value, pad, 0);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 带层级的编码(缩进形态;其余规则与 `encodeValue` 逐条相同) */
|
|
89
|
+
function encodeIndented(value, pad, level) {
|
|
90
|
+
if (value === null) return 'null';
|
|
91
|
+
switch (typeof value) {
|
|
92
|
+
case 'boolean':
|
|
93
|
+
return value ? 'true' : 'false';
|
|
94
|
+
case 'number':
|
|
95
|
+
return encodeNumber(value);
|
|
96
|
+
case 'bigint':
|
|
97
|
+
return value.toString();
|
|
98
|
+
case 'string':
|
|
99
|
+
return encodeString(value);
|
|
100
|
+
case 'object': {
|
|
101
|
+
const inner = pad.repeat(level + 1);
|
|
102
|
+
const close = pad.repeat(level);
|
|
103
|
+
if (Array.isArray(value)) {
|
|
104
|
+
if (value.length === 0) return '[]';
|
|
105
|
+
const parts = value.map((item) => inner + encodeIndented(item, pad, level + 1));
|
|
106
|
+
return `[\n${parts.join(',\n')}\n${close}]`;
|
|
107
|
+
}
|
|
108
|
+
const proto = Object.getPrototypeOf(value);
|
|
109
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
110
|
+
throw new TypeError('pyJsonDumpsIndent: 只接受普通对象/数组'
|
|
111
|
+
+ '(Date、Map、Set、类实例在 Python 侧同样抛 TypeError)');
|
|
112
|
+
}
|
|
113
|
+
const keys = Object.keys(value);
|
|
114
|
+
if (keys.length === 0) return '{}';
|
|
115
|
+
const parts = keys.map((key) => inner + encodeString(key) + ': '
|
|
116
|
+
+ encodeIndented(value[key], pad, level + 1));
|
|
117
|
+
return `{\n${parts.join(',\n')}\n${close}}`;
|
|
118
|
+
}
|
|
119
|
+
default:
|
|
120
|
+
throw new TypeError(`pyJsonDumpsIndent: 不能序列化 ${typeof value}`
|
|
121
|
+
+ '(Python 侧同类对象抛 TypeError;缺值请显式传 null)');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 任意 JSON 值的编码分派。
|
|
127
|
+
* @param {unknown} value
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
function encodeValue(value) {
|
|
131
|
+
if (value === null) return 'null';
|
|
132
|
+
switch (typeof value) {
|
|
133
|
+
case 'boolean':
|
|
134
|
+
return value ? 'true' : 'false';
|
|
135
|
+
case 'number':
|
|
136
|
+
return encodeNumber(value);
|
|
137
|
+
case 'bigint':
|
|
138
|
+
return value.toString();
|
|
139
|
+
case 'string':
|
|
140
|
+
return encodeString(value);
|
|
141
|
+
case 'object':
|
|
142
|
+
return Array.isArray(value) ? encodeArray(value) : encodeObject(value);
|
|
143
|
+
default:
|
|
144
|
+
throw new TypeError(`pyJsonDumps: 不能序列化 ${typeof value}`
|
|
145
|
+
+ '(Python 侧同类对象抛 TypeError;缺值请显式传 null)');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 数字编码:NaN/±Infinity 用 Python 字面量,其余用 JS 最短往返表示(见文件头浮点偏差说明)。
|
|
151
|
+
* @param {number} value
|
|
152
|
+
* @returns {string}
|
|
153
|
+
*/
|
|
154
|
+
function encodeNumber(value) {
|
|
155
|
+
if (Number.isNaN(value)) return 'NaN'; // CPython allow_nan=True
|
|
156
|
+
if (value === Infinity) return 'Infinity';
|
|
157
|
+
if (value === -Infinity) return '-Infinity';
|
|
158
|
+
return String(value);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 字符串编码:按**码点**遍历(代理对不被拆开),转义集合与 CPython ensure_ascii=False 相同。
|
|
163
|
+
* @param {string} value
|
|
164
|
+
* @returns {string} 带引号的 JSON 字符串
|
|
165
|
+
*/
|
|
166
|
+
function encodeString(value) {
|
|
167
|
+
let out = '"';
|
|
168
|
+
for (const ch of value) {
|
|
169
|
+
const escape = STRING_ESCAPES.get(ch);
|
|
170
|
+
if (escape !== undefined) {
|
|
171
|
+
out += escape;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const code = ch.codePointAt(0);
|
|
175
|
+
if (code < 0x20) {
|
|
176
|
+
// 其余控制字符:\u00xx(小写 hex,与 CPython 一致)
|
|
177
|
+
out += '\\u' + code.toString(16).padStart(4, '0');
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
out += ch;
|
|
181
|
+
}
|
|
182
|
+
return out + '"';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 数组编码(元素间 `", "`)。稀疏数组的洞会走到 encodeValue(undefined) 并抛 TypeError。
|
|
187
|
+
* @param {unknown[]} value
|
|
188
|
+
* @returns {string}
|
|
189
|
+
*/
|
|
190
|
+
function encodeArray(value) {
|
|
191
|
+
const parts = [];
|
|
192
|
+
for (let i = 0; i < value.length; i += 1) parts.push(encodeValue(value[i]));
|
|
193
|
+
return '[' + parts.join(', ') + ']';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 对象编码(键保持插入序,`": "` 分隔,项间 `", "`)。
|
|
198
|
+
* 只接受普通对象(Object.prototype 或 null 原型):Date/Map/Set/类实例在 Python 侧同样不可序列化,
|
|
199
|
+
* 与其静默写成 `{}` 不如在此抛错。
|
|
200
|
+
* @param {object} value
|
|
201
|
+
* @returns {string}
|
|
202
|
+
*/
|
|
203
|
+
function encodeObject(value) {
|
|
204
|
+
const proto = Object.getPrototypeOf(value);
|
|
205
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
206
|
+
throw new TypeError('pyJsonDumps: 只接受普通对象/数组'
|
|
207
|
+
+ '(Date、Map、Set、类实例在 Python 侧同样抛 TypeError)');
|
|
208
|
+
}
|
|
209
|
+
const parts = [];
|
|
210
|
+
for (const key of Object.keys(value)) {
|
|
211
|
+
parts.push(encodeString(key) + ': ' + encodeValue(value[key]));
|
|
212
|
+
}
|
|
213
|
+
return '{' + parts.join(', ') + '}';
|
|
214
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 查询串与百分号编解码 —— `urllib.parse` 对应物(`server.py:do_GET` 的第一件事就是
|
|
3
|
+
* `parse_qs(self.path.split("?", 1)[1])`,几乎每个读端点都吃它的结果)。
|
|
4
|
+
*
|
|
5
|
+
* **为什么必须自己写**:Node 的 `URLSearchParams` 与 Python `parse_qs` 有四处可观察差异,
|
|
6
|
+
* 每一处都能让读端点行为漂移:
|
|
7
|
+
* ① `URLSearchParams` 保留空值(`?raw=` → `raw=""`),Python 缺省 **丢弃空值**
|
|
8
|
+
* (`keep_blank_values=False`)⇒ `parsed.get("raw", [""])[0]` 得到 `""` 而不是
|
|
9
|
+
* 「没这个键」。端点里大量 `or "main"` 兜底就是靠这个区别生效的;
|
|
10
|
+
* ② Python 3.10 起 `;` **不再是分隔符**(`a=1;a=2` 是一个键 `a` 值 `1;a=2`);
|
|
11
|
+
* `URLSearchParams` 同样不切 `;`,但 `parse_qsl(separator=...)` 的历史差异常被记错;
|
|
12
|
+
* ③ 解码口径:键与值都走 `unquote_plus`(`+`→空格 后再百分号解码),
|
|
13
|
+
* 而**路径里的** `unquote(media_id)` 不处理 `+`;
|
|
14
|
+
* ④ 非法百分号转义(`%ZZ`)原样保留,非法 UTF-8 字节按 U+FFFD 替换(`errors='replace'`)。
|
|
15
|
+
*
|
|
16
|
+
* 期望值全部由本机 Python 3.12 实测产出(`backend/test/query.test.mjs` 里逐条钉住),
|
|
17
|
+
* 不是按文档猜的。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 收集百分号转义字节的游标解码器:连续 `%XX` 攒成一个字节串一起做 UTF-8 解码
|
|
22
|
+
* (多字节字符必须一起解),遇到非转义字符就先冲掉已攒字节。
|
|
23
|
+
*
|
|
24
|
+
* 与 Python `unquote` 的实现同构:Python 用 `_asciire` 把串切成「ASCII 段/非 ASCII 段」,
|
|
25
|
+
* 对含 `%` 的段做 `unquote_to_bytes(...).decode(encoding, 'replace')`,其余原样拼接。
|
|
26
|
+
* 区别仅在于 Python 是「整段一次解码」而这里按转义连续段解码——对 UTF-8 而言等价
|
|
27
|
+
* (同一段里的字节本来就该一起解;跨段的多字节字符在 Python 里也会被拆成两次
|
|
28
|
+
* `decode` 从而各自出替换符,这里同样)。
|
|
29
|
+
*/
|
|
30
|
+
function makeByteDecoder() {
|
|
31
|
+
const decoder = new TextDecoder('utf-8', { fatal: false });
|
|
32
|
+
let bytes = [];
|
|
33
|
+
return {
|
|
34
|
+
/** 攒一个转义字节 */
|
|
35
|
+
push(byte) { bytes.push(byte); },
|
|
36
|
+
/** 冲掉已攒字节,返回解码文本(无字节时返回空串) */
|
|
37
|
+
flush() {
|
|
38
|
+
if (bytes.length === 0) return '';
|
|
39
|
+
const out = decoder.decode(Uint8Array.from(bytes));
|
|
40
|
+
bytes = [];
|
|
41
|
+
return out;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const HEX = /^[0-9A-Fa-f]{2}$/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `urllib.parse.unquote(s)`:`+` **不**转空格;非法转义原样;非法 UTF-8 → U+FFFD。
|
|
50
|
+
*/
|
|
51
|
+
export function unquote(value) {
|
|
52
|
+
const s = String(value ?? '');
|
|
53
|
+
if (!s.includes('%')) return s; // 与 Python 的快路径一致(也避开无谓的构造)
|
|
54
|
+
const dec = makeByteDecoder();
|
|
55
|
+
let out = '';
|
|
56
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
57
|
+
const ch = s[i];
|
|
58
|
+
if (ch === '%' && i + 2 < s.length && HEX.test(s.slice(i + 1, i + 3))) {
|
|
59
|
+
dec.push(parseInt(s.slice(i + 1, i + 3), 16));
|
|
60
|
+
i += 2;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
out += dec.flush() + ch; // 非转义字符:先冲字节段,再原样输出
|
|
64
|
+
}
|
|
65
|
+
return out + dec.flush();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `urllib.parse.unquote_plus(s)`:先 `+`→空格,再按 `unquote` 解(查询串键值口径) */
|
|
69
|
+
export function unquotePlus(value) {
|
|
70
|
+
return unquote(String(value ?? '').replace(/\+/g, ' '));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `urllib.parse.quote(s, safe='/')`:安全字符 = 字母数字 + `_.-~` + `safe`;
|
|
75
|
+
* 其余按 UTF-8 逐字节 `%XX`(大写十六进制)。
|
|
76
|
+
*/
|
|
77
|
+
export function quote(value, safe = '/') {
|
|
78
|
+
let out = '';
|
|
79
|
+
for (const byte of Buffer.from(String(value ?? ''), 'utf8')) {
|
|
80
|
+
const ch = String.fromCharCode(byte);
|
|
81
|
+
const keep = (byte < 128
|
|
82
|
+
&& (/[A-Za-z0-9]/.test(ch) || ch === '_' || ch === '.' || ch === '-' || ch === '~'
|
|
83
|
+
|| safe.includes(ch)));
|
|
84
|
+
out += keep ? ch : `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `urllib.parse.parse_qs(qs)`:`&` 分隔(**不切 `;`**)、空值丢弃、同名键聚成数组、
|
|
91
|
+
* 键值都过 `unquote_plus`。
|
|
92
|
+
*
|
|
93
|
+
* 返回**无原型对象**(等价于 Python dict):查询串里出现 `__proto__=x` 时不能污染
|
|
94
|
+
* 原型链——Python 里它只是个普通键。
|
|
95
|
+
*/
|
|
96
|
+
export function parseQs(qs) {
|
|
97
|
+
const out = Object.create(null);
|
|
98
|
+
const s = String(qs ?? '');
|
|
99
|
+
if (!s) return out;
|
|
100
|
+
for (const pair of s.split('&')) {
|
|
101
|
+
if (!pair) continue; // 'a=1&&b=2' 的空段
|
|
102
|
+
const eq = pair.indexOf('=');
|
|
103
|
+
const rawName = eq === -1 ? pair : pair.slice(0, eq);
|
|
104
|
+
const rawValue = eq === -1 ? '' : pair.slice(eq + 1);
|
|
105
|
+
// keep_blank_values=False:**空值整对丢弃**('raw' 与 'raw=' 都不出现在结果里);
|
|
106
|
+
// 空名但值非空('=1')Python 仍保留成 {'': ['1']},此处照抄
|
|
107
|
+
if (rawValue === '') continue;
|
|
108
|
+
const name = unquotePlus(rawName);
|
|
109
|
+
const value = unquotePlus(rawValue);
|
|
110
|
+
if (out[name]) out[name].push(value);
|
|
111
|
+
else out[name] = [value];
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `parsed.get(name, [def])[0]` 的对应物(端点里最常见的取值形态)。
|
|
118
|
+
*
|
|
119
|
+
* 注意传 `def` 的语义:Python 只在**键不存在**时用默认值;键存在则取首个值。
|
|
120
|
+
* 由于 `parseQs` 已丢弃空值,键存在即值非空——与 Python 逐点同。
|
|
121
|
+
*/
|
|
122
|
+
export function qsGet(parsed, name, def = '') {
|
|
123
|
+
const values = parsed ? parsed[name] : undefined;
|
|
124
|
+
return values && values.length ? values[0] : def;
|
|
125
|
+
}
|