@stofone/dsh-agent-roster 0.0.0-stage → 0.5.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/CHANGELOG.md +139 -0
- package/LICENSE +21 -0
- package/README.md +290 -3
- package/README.zh.md +282 -0
- package/bin/cli.mjs +325 -0
- package/client.js +1950 -0
- package/cordis.patch.yml +9 -0
- package/examples/core-review/roster.yaml +82 -0
- package/examples/research/roster.yaml +42 -0
- package/icon.svg +11 -0
- package/index/registry.json +23 -0
- package/index.js +298 -0
- package/lib/bridge.js +233 -0
- package/lib/config-view.js +59 -0
- package/lib/format.js +223 -0
- package/lib/home.js +108 -0
- package/lib/http.js +122 -0
- package/lib/json.js +89 -0
- package/lib/library.js +161 -0
- package/lib/parse.js +148 -0
- package/lib/registry.js +285 -0
- package/lib/remote.js +623 -0
- package/lib/render.js +343 -0
- package/lib/serialize.js +92 -0
- package/lib/service.js +421 -0
- package/lib/teams-state.js +146 -0
- package/lib/tools.js +601 -0
- package/lib/validate.js +747 -0
- package/lib/verify.js +164 -0
- package/lib/write.js +290 -0
- package/locale/en.json +245 -0
- package/locale/zh.json +245 -0
- package/package.json +98 -4
- package/schema/roster.v1.schema.json +193 -0
package/lib/http.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 路由层的小工具:认证闸门、方法校验、有界 JSON 体、统一响应。
|
|
3
|
+
*
|
|
4
|
+
* 面板的写操作(保存/删除/导入档案)走 HTTP,所以这一层是安全边界:
|
|
5
|
+
* 每个写请求先过宿主的 connection.requestRejection(Host/Origin 围栏 + 浏览器登录凭据),
|
|
6
|
+
* 再校验方法与媒体类型,再限制正文大小。任何一步失败都直接返回,不进入业务代码。
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-agent-roster/http
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** 写请求正文上限:一份档案含 32 个成员的人设在 256 KiB 内足够,超过视为异常。 */
|
|
12
|
+
export const MAX_BODY_BYTES = 256 * 1024;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 发送 JSON 响应。
|
|
16
|
+
*
|
|
17
|
+
* @param {import("node:http").ServerResponse} res
|
|
18
|
+
* @param {number} status
|
|
19
|
+
* @param {unknown} payload
|
|
20
|
+
* @returns {void}
|
|
21
|
+
*/
|
|
22
|
+
export function sendJson(res, status, payload) {
|
|
23
|
+
res.statusCode = status;
|
|
24
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
25
|
+
res.setHeader("cache-control", "no-store");
|
|
26
|
+
res.end(JSON.stringify(payload));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 发送错误响应:机器可读的 code + 可选诊断数组。
|
|
31
|
+
*
|
|
32
|
+
* @param {import("node:http").ServerResponse} res
|
|
33
|
+
* @param {number} status
|
|
34
|
+
* @param {string} code
|
|
35
|
+
* @param {object[]} [diagnostics]
|
|
36
|
+
* @returns {void}
|
|
37
|
+
*/
|
|
38
|
+
export function sendError(res, status, code, diagnostics = []) {
|
|
39
|
+
sendJson(res, status, { ok: false, error: { code }, diagnostics });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 405,并如实声明本路由支持的方法。
|
|
44
|
+
*
|
|
45
|
+
* @param {import("node:http").ServerResponse} res
|
|
46
|
+
* @param {string} allow
|
|
47
|
+
* @returns {void}
|
|
48
|
+
*/
|
|
49
|
+
export function sendMethodNotAllowed(res, allow) {
|
|
50
|
+
res.statusCode = 405;
|
|
51
|
+
res.setHeader("allow", allow);
|
|
52
|
+
res.end();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 造一个认证闸门:返回 true 表示请求已被拒绝并写完响应。
|
|
57
|
+
*
|
|
58
|
+
* connection 缺失(极简组合没有装 connection)时放行——此时 webServer 本身已经只监听
|
|
59
|
+
* 本机回环地址,写操作仍有方法/路径/大小/校验四道闸。
|
|
60
|
+
*
|
|
61
|
+
* @param {object} ctx 宿主上下文
|
|
62
|
+
* @returns {(req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => boolean}
|
|
63
|
+
*/
|
|
64
|
+
export function createAuthGate(ctx) {
|
|
65
|
+
const connection = Reflect.get(ctx, "connection");
|
|
66
|
+
return (req, res) => {
|
|
67
|
+
if (connection === undefined || typeof connection.requestRejection !== "function") return false;
|
|
68
|
+
const rejection = connection.requestRejection(req);
|
|
69
|
+
if (rejection === undefined) return false;
|
|
70
|
+
res.statusCode = rejection;
|
|
71
|
+
res.end();
|
|
72
|
+
return true;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 读取有界 JSON 正文。
|
|
78
|
+
*
|
|
79
|
+
* @param {import("node:http").IncomingMessage} req
|
|
80
|
+
* @returns {Promise<{ ok: true, value: unknown } | { ok: false, status: number, code: string }>}
|
|
81
|
+
*/
|
|
82
|
+
export async function readJsonBody(req) {
|
|
83
|
+
const mediaType = String(req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
|
|
84
|
+
if (mediaType !== "application/json") return { ok: false, status: 415, code: "unsupported-media-type" };
|
|
85
|
+
|
|
86
|
+
const declared = Number(req.headers["content-length"] ?? Number.NaN);
|
|
87
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
88
|
+
req.resume();
|
|
89
|
+
return { ok: false, status: 413, code: "body-too-large" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const chunks = [];
|
|
93
|
+
let total = 0;
|
|
94
|
+
for await (const chunk of req) {
|
|
95
|
+
total += chunk.length;
|
|
96
|
+
if (total > MAX_BODY_BYTES) {
|
|
97
|
+
return { ok: false, status: 413, code: "body-too-large" };
|
|
98
|
+
}
|
|
99
|
+
chunks.push(chunk);
|
|
100
|
+
}
|
|
101
|
+
if (total === 0) return { ok: false, status: 400, code: "empty-body" };
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
return { ok: true, value: JSON.parse(Buffer.concat(chunks).toString("utf8")) };
|
|
105
|
+
} catch {
|
|
106
|
+
return { ok: false, status: 400, code: "invalid-json" };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 断言请求方法,否则写 405 并返回 false。
|
|
112
|
+
*
|
|
113
|
+
* @param {import("node:http").IncomingMessage} req
|
|
114
|
+
* @param {import("node:http").ServerResponse} res
|
|
115
|
+
* @param {string} method
|
|
116
|
+
* @returns {boolean}
|
|
117
|
+
*/
|
|
118
|
+
export function requireMethod(req, res, method) {
|
|
119
|
+
if (req.method === method) return true;
|
|
120
|
+
sendMethodNotAllowed(res, method);
|
|
121
|
+
return false;
|
|
122
|
+
}
|
package/lib/json.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具输出的「无损 JSON」清洗。
|
|
3
|
+
*
|
|
4
|
+
* DSH 运行时会把工具返回值再序列化一遍,并且要求**往返无损**:`undefined` 属性的键
|
|
5
|
+
* 在 `JSON.stringify` 时会被丢掉,回来就对不上号,于是整次调用被判为
|
|
6
|
+
* `value is not lossless JSON` 而失败。这个坑在一次真机验证里踩到过——工具本身没错,
|
|
7
|
+
* 错在返回值里带着 `undefined`。
|
|
8
|
+
*
|
|
9
|
+
* 所以约定:**工具返回值里不允许出现 `undefined`**。缺失一律用 `null` 表达
|
|
10
|
+
* (`null` 能被 JSON 如实往返),或者干脆不带这个键。
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-agent-roster/json
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 递归清洗:对象丢掉值为 `undefined` 的键,数组里的 `undefined` 变成 `null`。
|
|
17
|
+
*
|
|
18
|
+
* 只处理 JSON 能表达的结构(对象、数组、原始值);遇到 `Date`/`Map` 之类不常见的值
|
|
19
|
+
* 原样放行,它们的序列化责任不在本函数。
|
|
20
|
+
*
|
|
21
|
+
* @template T
|
|
22
|
+
* @param {T} value
|
|
23
|
+
* @returns {T} 结构相同、但可无损往返 JSON 的值
|
|
24
|
+
*/
|
|
25
|
+
export function lossless(value) {
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
return value.map((item) => (item === undefined ? null : lossless(item)));
|
|
28
|
+
}
|
|
29
|
+
if (value !== null && typeof value === "object") {
|
|
30
|
+
// 保留原型会给 JSON 往返制造意外(比如 Date 会变成字符串),只按普通对象处理纯数据结构。
|
|
31
|
+
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) {
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
const out = {};
|
|
35
|
+
for (const [key, item] of Object.entries(value)) {
|
|
36
|
+
if (item === undefined) continue;
|
|
37
|
+
out[key] = lossless(item);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 校验一个值能否无损往返 JSON——测试与调试用。
|
|
46
|
+
*
|
|
47
|
+
* @param {unknown} value
|
|
48
|
+
* @returns {{ ok: boolean, reason?: string }}
|
|
49
|
+
*/
|
|
50
|
+
export function isLossless(value) {
|
|
51
|
+
let text;
|
|
52
|
+
try {
|
|
53
|
+
text = JSON.stringify(value);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return { ok: false, reason: `无法序列化:${error.message}` };
|
|
56
|
+
}
|
|
57
|
+
if (text === undefined) return { ok: false, reason: "顶层值不可序列化(undefined/function/symbol)" };
|
|
58
|
+
const back = JSON.parse(text);
|
|
59
|
+
try {
|
|
60
|
+
assertDeepEqual(value, back);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return { ok: false, reason: error.message };
|
|
63
|
+
}
|
|
64
|
+
return { ok: true };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 找第一处结构差异,报出路径,比整段 diff 更容易定位。 */
|
|
68
|
+
function assertDeepEqual(left, right, at = "$") {
|
|
69
|
+
if (left === right) return;
|
|
70
|
+
if (typeof left !== typeof right || left === null || right === null || typeof left !== "object") {
|
|
71
|
+
throw new Error(`${at}: ${describe(left)} ≠ ${describe(right)}`);
|
|
72
|
+
}
|
|
73
|
+
const leftKeys = Object.keys(left);
|
|
74
|
+
const rightKeys = Object.keys(right);
|
|
75
|
+
if (leftKeys.length !== rightKeys.length) {
|
|
76
|
+
const missing = leftKeys.filter((key) => !rightKeys.includes(key));
|
|
77
|
+
throw new Error(`${at}: 键集合不同${missing.length > 0 ? `,丢掉了 ${missing.join(", ")}` : ""}`);
|
|
78
|
+
}
|
|
79
|
+
for (const key of leftKeys) {
|
|
80
|
+
assertDeepEqual(left[key], right[key], Array.isArray(left) ? `${at}[${key}]` : `${at}.${key}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function describe(value) {
|
|
85
|
+
if (value === undefined) return "undefined";
|
|
86
|
+
if (value === null) return "null";
|
|
87
|
+
if (typeof value === "object") return Array.isArray(value) ? "array" : "object";
|
|
88
|
+
return JSON.stringify(value);
|
|
89
|
+
}
|
package/lib/library.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 档案库快照:把"文件系统里的档案"折叠成面板与只读路由能直接渲染的 JSON。
|
|
3
|
+
*
|
|
4
|
+
* 这一层是纯数据投影:不碰 ctx 的生命周期、不注册任何东西,只做三件可以单测的事——
|
|
5
|
+
* 展开工作区 × 档案库目录、给每份档案配上它自己的诊断、跨库同名时点名冲突。
|
|
6
|
+
* 放在 lib/ 而不是 index.js,是为了让测试不必拉起宿主(index.js 会 import dsh-tools)。
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-agent-roster/library
|
|
9
|
+
*/
|
|
10
|
+
import { displayDshHome, isGlobalDirSpec, resolveDshHome, resolveDirSpec } from "./home.js";
|
|
11
|
+
import { scanRosterLibrary } from "./registry.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 采集档案库快照。
|
|
15
|
+
*
|
|
16
|
+
* 默认**不按工作区展开**:档案库是全局的,默认根目录是 `<DSH_HOME>/agent-roster`。
|
|
17
|
+
* 只有显式配置成相对路径(如 `examples`)的目录才按工作区展开——那是给"仓库内示例库"
|
|
18
|
+
* 这类用法留的口子。拿不到工作区注册表时退回进程工作目录并如实标注 `fallbackWorkspace`。
|
|
19
|
+
*
|
|
20
|
+
* 多目录是并列的"库",不合并:每座库单独出统计,同名档案(id 相同)在跨库时会被点名——
|
|
21
|
+
* 两份同 id 的档案并存意味着"改了 A 生效的是 B",这必须让用户看见。
|
|
22
|
+
*
|
|
23
|
+
* @param {{ get?: (key: string) => unknown }} ctx 只用到工作区注册表这一项能力
|
|
24
|
+
* @param {object} config 插件配置
|
|
25
|
+
* @param {{ name: string, version: string }} meta 插件自身元信息
|
|
26
|
+
* @returns {object}
|
|
27
|
+
*/
|
|
28
|
+
export function collectLibrary(ctx, config, meta) {
|
|
29
|
+
const workspaces = listWorkspaces(ctx);
|
|
30
|
+
|
|
31
|
+
const dirSpecs = Array.isArray(config.rosterDirs) && config.rosterDirs.length > 0 ? config.rosterDirs : [""];
|
|
32
|
+
|
|
33
|
+
const reports = [];
|
|
34
|
+
for (const dirSpec of dirSpecs) {
|
|
35
|
+
const targets = isGlobalDirSpec(dirSpec)
|
|
36
|
+
? [{ scope: "global", workspace: "", workspacePath: "", fallbackWorkspace: false }]
|
|
37
|
+
: workspaces.map((workspace) => ({
|
|
38
|
+
scope: "workspace",
|
|
39
|
+
workspace: workspace.title,
|
|
40
|
+
workspacePath: workspace.path,
|
|
41
|
+
fallbackWorkspace: workspace.fallback === true,
|
|
42
|
+
}));
|
|
43
|
+
for (const target of targets) {
|
|
44
|
+
const rootDir = resolveDirSpec(dirSpec, target.workspacePath || process.cwd());
|
|
45
|
+
const scan = scanRosterLibrary({
|
|
46
|
+
rootDir,
|
|
47
|
+
knownToolPolicies: config.knownToolPolicies ?? null,
|
|
48
|
+
});
|
|
49
|
+
reports.push({
|
|
50
|
+
...target,
|
|
51
|
+
dirSpec,
|
|
52
|
+
rootDir,
|
|
53
|
+
missing: scan.missing === true,
|
|
54
|
+
stats: scan.stats,
|
|
55
|
+
rosters: scan.rosters.map((roster) => serializeRoster(roster, scan.diagnostics)),
|
|
56
|
+
diagnostics: scan.diagnostics,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
markDuplicateIds(reports);
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
plugin: meta.name,
|
|
65
|
+
version: meta.version,
|
|
66
|
+
rosterDirs: dirSpecs,
|
|
67
|
+
dshHome: displayDshHome(resolveDshHome(undefined)),
|
|
68
|
+
agentTeamsStateDir: config.agentTeamsStateDir,
|
|
69
|
+
workspaces: reports,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 枚举宿主已知的工作区;拿不到注册表时退回进程工作目录并如实标注 `fallback`。
|
|
75
|
+
*
|
|
76
|
+
* @param {{ get?: (key: string) => unknown }} ctx
|
|
77
|
+
* @returns {{ title: string, path: string, fallback?: boolean }[]}
|
|
78
|
+
*/
|
|
79
|
+
export function listWorkspaces(ctx) {
|
|
80
|
+
const registry = ctx.get?.("workspaceRegistry") ?? ctx.get?.("workspace") ?? undefined;
|
|
81
|
+
return registry !== undefined && typeof registry.list === "function"
|
|
82
|
+
? registry.list().map((workspace) => ({ title: workspace.title ?? "", path: workspace.path }))
|
|
83
|
+
: [{ title: "", path: process.cwd(), fallback: true }];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 同一工作区里跨库重复的档案 id:在后来者上追加一条警告,列出先前那份文件。
|
|
88
|
+
*
|
|
89
|
+
* 只警告、不删档:用户的文件不该被插件悄悄忽略;面板与工具都会显示这条冲突。
|
|
90
|
+
*
|
|
91
|
+
* @param {object[]} reports
|
|
92
|
+
* @returns {void}
|
|
93
|
+
*/
|
|
94
|
+
export function markDuplicateIds(reports) {
|
|
95
|
+
const seen = new Map();
|
|
96
|
+
for (const report of reports) {
|
|
97
|
+
for (const roster of report.rosters) {
|
|
98
|
+
const key = `${report.workspacePath}::${roster.id}`;
|
|
99
|
+
const previous = seen.get(key);
|
|
100
|
+
if (previous !== undefined) {
|
|
101
|
+
const diagnostic = {
|
|
102
|
+
severity: "warn",
|
|
103
|
+
code: "duplicate-roster-id",
|
|
104
|
+
path: "",
|
|
105
|
+
file: roster.file,
|
|
106
|
+
reason:
|
|
107
|
+
`档案 id "${roster.id}" 与 ${previous} 重复。两份同 id 档案并存时,` +
|
|
108
|
+
"工具按配置顺序取先出现的那份,改另一份不会生效;请删除或改名其中一份。",
|
|
109
|
+
};
|
|
110
|
+
report.diagnostics = [...report.diagnostics, diagnostic];
|
|
111
|
+
roster.diagnostics = [...roster.diagnostics, diagnostic];
|
|
112
|
+
} else {
|
|
113
|
+
seen.set(key, `${report.rootDir}/${roster.file}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 面板用的档案投影:字段名保持档案里的 camelCase,减少一次翻译。
|
|
121
|
+
*
|
|
122
|
+
* 人设正文不外发(可能上千字),只给长度;与这份档案同目录的诊断会被带上,
|
|
123
|
+
* 让面板在卡片上就能标出"这份有问题",而不用把整张诊断表翻一遍。
|
|
124
|
+
*
|
|
125
|
+
* @param {object} roster 已通过校验的档案
|
|
126
|
+
* @param {object[]} [scanDiagnostics] 整座库的诊断
|
|
127
|
+
* @returns {object}
|
|
128
|
+
*/
|
|
129
|
+
export function serializeRoster(roster, scanDiagnostics = []) {
|
|
130
|
+
const dir = roster.file.includes("/") ? roster.file.slice(0, roster.file.indexOf("/")) : "";
|
|
131
|
+
const diagnostics = scanDiagnostics.filter(
|
|
132
|
+
(item) => item?.file != null && (item.file === roster.file || String(item.file).startsWith(`${dir}/`)),
|
|
133
|
+
);
|
|
134
|
+
return {
|
|
135
|
+
id: roster.id,
|
|
136
|
+
version: roster.version,
|
|
137
|
+
title: roster.title,
|
|
138
|
+
description: roster.description,
|
|
139
|
+
file: roster.file,
|
|
140
|
+
tags: roster.tags,
|
|
141
|
+
maintainers: roster.maintainers,
|
|
142
|
+
defaults: roster.defaults,
|
|
143
|
+
diagnostics,
|
|
144
|
+
members: roster.members.map((member) => ({
|
|
145
|
+
name: member.name,
|
|
146
|
+
role: member.role,
|
|
147
|
+
title: member.title,
|
|
148
|
+
provider: member.provider ?? null,
|
|
149
|
+
model: member.model ?? null,
|
|
150
|
+
reasoningEffort: member.reasoningEffort ?? null,
|
|
151
|
+
toolPolicy: member.toolPolicy ?? null,
|
|
152
|
+
toolScope: member.toolScope ?? null,
|
|
153
|
+
fallback: member.fallback ?? null,
|
|
154
|
+
maxTokens: member.maxTokens ?? null,
|
|
155
|
+
inherited: member.inherited ?? [],
|
|
156
|
+
tags: member.tags,
|
|
157
|
+
notes: member.notes,
|
|
158
|
+
systemPromptChars: member.systemPrompt.length,
|
|
159
|
+
})),
|
|
160
|
+
};
|
|
161
|
+
}
|
package/lib/parse.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 档案文档解析:YAML 为主、JSON 兼容,纯函数、不碰文件系统。
|
|
3
|
+
*
|
|
4
|
+
* 解析阶段只回答「这段文本能否变成一棵对象树」,语义校验交给 `validate.js`。
|
|
5
|
+
* 所有失败都转成诊断对象,不做抛异常式的静默降级。
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-agent-roster/parse
|
|
8
|
+
*/
|
|
9
|
+
import { parseAllDocuments } from "yaml";
|
|
10
|
+
import { MAX_DOCUMENT_BYTES } from "./format.js";
|
|
11
|
+
|
|
12
|
+
/** 造一条诊断。path 用点号 + 下标表达,例如 `members[2].model`。 */
|
|
13
|
+
export function diagnostic(severity, code, path, reason, file = "") {
|
|
14
|
+
return { severity, code, path, reason, file };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 文本的字节数(按 UTF-8 计,而不是字符数)。 */
|
|
18
|
+
export function byteLength(text) {
|
|
19
|
+
return Buffer.byteLength(String(text ?? ""), "utf8");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 由文件名猜格式;认不出的按 YAML 处理(YAML 是 JSON 的超集)。 */
|
|
23
|
+
export function formatOf(file) {
|
|
24
|
+
return /\.json$/i.test(String(file ?? "")) ? "json" : "yaml";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 解析一份档案 / 索引文档。
|
|
29
|
+
*
|
|
30
|
+
* @param {string} text 文档全文
|
|
31
|
+
* @param {{ file?: string, format?: "yaml" | "json" }} [options]
|
|
32
|
+
* @returns {{ ok: boolean, value: unknown, format: string, diagnostics: object[] }}
|
|
33
|
+
*/
|
|
34
|
+
export function parseDocumentText(text, options = {}) {
|
|
35
|
+
const file = String(options.file ?? "");
|
|
36
|
+
const format = options.format ?? formatOf(file);
|
|
37
|
+
const diagnostics = [];
|
|
38
|
+
|
|
39
|
+
const source = String(text ?? "");
|
|
40
|
+
const bytes = byteLength(source);
|
|
41
|
+
if (bytes > MAX_DOCUMENT_BYTES) {
|
|
42
|
+
diagnostics.push(
|
|
43
|
+
diagnostic(
|
|
44
|
+
"error",
|
|
45
|
+
"document-too-large",
|
|
46
|
+
"",
|
|
47
|
+
`文档 ${bytes} 字节,超过上限 ${MAX_DOCUMENT_BYTES} 字节`,
|
|
48
|
+
file,
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
return { ok: false, value: undefined, format, diagnostics };
|
|
52
|
+
}
|
|
53
|
+
if (source.trim() === "") {
|
|
54
|
+
diagnostics.push(diagnostic("error", "empty-document", "", "文档为空", file));
|
|
55
|
+
return { ok: false, value: undefined, format, diagnostics };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (format === "json") return parseJson(source, file);
|
|
59
|
+
|
|
60
|
+
let documents;
|
|
61
|
+
try {
|
|
62
|
+
documents = parseAllDocuments(source, { uniqueKeys: true, prettyErrors: false });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
diagnostics.push(diagnostic("error", "yaml-syntax", "", messageOf(error), file));
|
|
65
|
+
return { ok: false, value: undefined, format, diagnostics };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const failed = documents.filter((doc) => doc.errors.length > 0);
|
|
69
|
+
if (failed.length > 0) {
|
|
70
|
+
for (const doc of failed) {
|
|
71
|
+
for (const error of doc.errors) {
|
|
72
|
+
diagnostics.push(
|
|
73
|
+
diagnostic(
|
|
74
|
+
"error",
|
|
75
|
+
"yaml-syntax",
|
|
76
|
+
linePath(error),
|
|
77
|
+
String(error.message ?? "").split("\n")[0],
|
|
78
|
+
file,
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { ok: false, value: undefined, format, diagnostics };
|
|
84
|
+
}
|
|
85
|
+
if (documents.length !== 1) {
|
|
86
|
+
diagnostics.push(
|
|
87
|
+
diagnostic(
|
|
88
|
+
"error",
|
|
89
|
+
"multi-document",
|
|
90
|
+
"",
|
|
91
|
+
`一份档案文件只允许一个 YAML 文档,实际 ${documents.length} 个`,
|
|
92
|
+
file,
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
return { ok: false, value: undefined, format, diagnostics };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let value;
|
|
99
|
+
try {
|
|
100
|
+
value = documents[0].toJS({ maxAliasCount: 100 });
|
|
101
|
+
} catch (error) {
|
|
102
|
+
diagnostics.push(diagnostic("error", "yaml-value", "", messageOf(error), file));
|
|
103
|
+
return { ok: false, value: undefined, format, diagnostics };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
107
|
+
diagnostics.push(
|
|
108
|
+
diagnostic("error", "document-not-mapping", "", "文档顶层必须是映射(对象)", file),
|
|
109
|
+
);
|
|
110
|
+
return { ok: false, value: undefined, format, diagnostics };
|
|
111
|
+
}
|
|
112
|
+
return { ok: true, value, format, diagnostics };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseJson(source, file) {
|
|
116
|
+
try {
|
|
117
|
+
const value = JSON.parse(source);
|
|
118
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
value: undefined,
|
|
122
|
+
format: "json",
|
|
123
|
+
diagnostics: [
|
|
124
|
+
diagnostic("error", "document-not-mapping", "", "文档顶层必须是对象", file),
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return { ok: true, value, format: "json", diagnostics: [] };
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
value: undefined,
|
|
133
|
+
format: "json",
|
|
134
|
+
diagnostics: [diagnostic("error", "json-syntax", "", messageOf(error), file)],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function messageOf(error) {
|
|
140
|
+
return String(error?.message ?? error ?? "解析失败").split("\n")[0];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 把 yaml 的语法错误位置折成 `line:col` 形式的 path,便于定位。 */
|
|
144
|
+
function linePath(error) {
|
|
145
|
+
const line = error?.linePos?.[0]?.line;
|
|
146
|
+
const col = error?.linePos?.[0]?.col;
|
|
147
|
+
return Number.isFinite(line) ? `line:${line}${Number.isFinite(col) ? `:${col}` : ""}` : "";
|
|
148
|
+
}
|