@modusensus/dsh-mneme 0.9.0 → 0.9.1
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 +78 -16
- package/bin/cli.mjs +8 -2
- package/docs/CONFIGURATION.md +25 -0
- package/docs/SEMANTIC.md +3 -3
- package/lib/api-standalone.js +78 -12
- package/lib/api.js +282 -57
- package/lib/candidates.js +454 -454
- package/lib/client.js +598 -248
- package/lib/config.js +33 -2
- package/lib/embedding.js +116 -15
- package/lib/index.js +32 -0
- package/lib/inject.js +34 -3
- package/lib/lang.js +124 -0
- package/lib/pipeline-activities.js +178 -0
- package/lib/pipeline-attribution.js +165 -0
- package/lib/pipeline-command.js +279 -0
- package/lib/pipeline-confirm.js +142 -0
- package/lib/pipeline-exec.js +174 -0
- package/lib/pipeline-nudge.js +1281 -0
- package/lib/pipeline-observations.js +181 -0
- package/lib/pipeline-observe.js +133 -0
- package/lib/pipeline-runner.js +346 -0
- package/lib/pipeline-runs.js +442 -0
- package/lib/pipeline.js +128 -7
- package/lib/semantic.js +66 -10
- package/lib/settings.js +132 -1
- package/lib/store.js +77 -3
- package/lib/summarize.js +2 -3
- package/package.json +3 -3
- package/scripts/changelog-prep.mjs +62 -7
- package/scripts/check-sync.js +8 -6
- package/scripts/release-docs.mjs +204 -0
- package/scripts/release-notes.mjs +162 -0
- package/src/api-standalone.js +78 -12
- package/src/api.js +282 -57
- package/src/candidates.js +454 -454
- package/src/config.js +33 -2
- package/src/embedding.js +116 -15
- package/src/index.js +32 -0
- package/src/inject.js +34 -3
- package/src/lang.js +124 -0
- package/src/pipeline-activities.js +178 -0
- package/src/pipeline-attribution.js +165 -0
- package/src/pipeline-command.js +279 -0
- package/src/pipeline-confirm.js +142 -0
- package/src/pipeline-exec.js +174 -0
- package/src/pipeline-nudge.js +1281 -0
- package/src/pipeline-observations.js +181 -0
- package/src/pipeline-observe.js +133 -0
- package/src/pipeline-runner.js +346 -0
- package/src/pipeline-runs.js +442 -0
- package/src/pipeline.js +128 -7
- package/src/semantic.js +66 -10
- package/src/settings.js +132 -1
- package/src/store.js +77 -3
- package/src/summarize.js +2 -3
- package/test/api-hardening.test.js +173 -0
- package/test/api.test.js +98 -1
- package/test/changelog-prep.test.js +74 -8
- package/test/cli-enums.test.js +37 -0
- package/test/client.test.js +152 -3
- package/test/docs-config-keys.test.js +65 -0
- package/test/helpers/write-admission-samples.js +6 -2
- package/test/lib-smoke.test.js +20 -0
- package/test/persona-api.test.js +172 -0
- package/test/persona-flag.test.js +36 -0
- package/test/persona-inject.test.js +145 -0
- package/test/persona-settings.test.js +145 -0
- package/test/pipeline-activities.test.js +155 -0
- package/test/pipeline-attribution.test.js +175 -0
- package/test/pipeline-command.test.js +243 -0
- package/test/pipeline-confirm.test.js +148 -0
- package/test/pipeline-exec.test.js +172 -0
- package/test/pipeline-nudge.test.js +1036 -0
- package/test/pipeline-observations.test.js +157 -0
- package/test/pipeline-observe.test.js +127 -0
- package/test/pipeline-r1r2.test.js +149 -0
- package/test/pipeline-runner.test.js +442 -0
- package/test/pipeline-runs.test.js +767 -0
- package/test/pipeline.test.js +137 -6
- package/test/reindex-backfill.test.js +83 -4
- package/test/release-docs.test.js +130 -0
- package/test/release-notes.test.js +240 -0
- package/test/release-workflow.test.js +16 -0
- package/test/route-policy.test.js +113 -0
- package/test/security-doc.test.js +139 -0
- package/test/sensitive-scan.test.js +44 -0
- package/test/workflow-guards.test.js +156 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
normalizeCommand,
|
|
5
|
+
commandParts,
|
|
6
|
+
attributionCandidates,
|
|
7
|
+
unattributableSteps,
|
|
8
|
+
normalizeObservation
|
|
9
|
+
} from "../src/pipeline-attribution.js";
|
|
10
|
+
|
|
11
|
+
// 这组测试守的是 M.O. 的**归属规则**:把宿主的一次工具动作映射到「可能是工作流的哪一步」。
|
|
12
|
+
// 它最要紧的性质不是「能匹配上」,而是**认不出来时绝不许猜**——误匹配会让错误的步骤
|
|
13
|
+
// 被染成可信,而漏匹配只是少一条账。所以下面失败的用例和成功的用例一样重要。
|
|
14
|
+
|
|
15
|
+
function pipeline(steps) {
|
|
16
|
+
return {
|
|
17
|
+
id: "demo",
|
|
18
|
+
steps: steps.map((s) => ({
|
|
19
|
+
id: s.id,
|
|
20
|
+
tool: s.tool ?? "",
|
|
21
|
+
who: s.who ?? "main",
|
|
22
|
+
gate: s.gate === true
|
|
23
|
+
}))
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── 归一化 ────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
test("归一化:大小写、占位符、多余空白都不影响比较", () => {
|
|
30
|
+
assert.equal(normalizeCommand(" NPM Test "), "npm test");
|
|
31
|
+
// 占位符是给人的读法,不是字面量——留着它会让 `git push <tag>` 谁都匹配不上。
|
|
32
|
+
assert.equal(normalizeCommand("git push <tag>"), "git push");
|
|
33
|
+
assert.equal(normalizeCommand("node script.mjs <版本>"), "node script.mjs");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("占位符去掉而不是换成通配:不许宽到能匹配一切", () => {
|
|
37
|
+
// 若把 <tag> 换成 .*,下面这条就会命中——那是把「推 tag」与「推分支」混为一谈。
|
|
38
|
+
const p = pipeline([{ id: "T2", tool: "git push <tag>" }]);
|
|
39
|
+
const c = attributionCandidates({ command: "git push --force origin main" }, p);
|
|
40
|
+
assert.equal(c.every((x) => x.confidence !== "exact"), true, "占位符不该让任意参数都算「完全一致」");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("并列命令按分隔符拆开,但路径里的斜杠不能把命令切碎", () => {
|
|
44
|
+
assert.deepEqual(commandParts("git log / gh pr list"), ["git log", "gh pr list"]);
|
|
45
|
+
assert.deepEqual(commandParts("npm run badge:sync"), ["npm run badge:sync"]);
|
|
46
|
+
// 这一段是防回归的关键:脚本路径里的 `/` 两侧无空白,不该被当分隔符。
|
|
47
|
+
assert.deepEqual(commandParts("node dsh-mneme/scripts/publish-gate.mjs <版本>"),
|
|
48
|
+
["node dsh-mneme/scripts/publish-gate.mjs"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ── 匹配 ──────────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
test("完全一致:命令与步骤里写的对得上,给 exact 候选", () => {
|
|
54
|
+
const p = pipeline([{ id: "P6", tool: "npm test" }]);
|
|
55
|
+
const c = attributionCandidates({ command: "npm test" }, p);
|
|
56
|
+
assert.equal(c.length, 1);
|
|
57
|
+
assert.equal(c[0].stepId, "P6");
|
|
58
|
+
assert.equal(c[0].confidence, "exact");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("抗噪:同前缀但不同子命令的步骤不该成为候选", () => {
|
|
62
|
+
// 真实工作流里 `git` 出现 5 次、`gh pr` 出现 4 次。若按「第一个词相同」匹配,
|
|
63
|
+
// 一次 `npm test` 会把所有 npm 步骤都报成候选——噪声盖过信号,人看一眼就放弃了。
|
|
64
|
+
const p = pipeline([
|
|
65
|
+
{ id: "P5", tool: "npm run badge:sync" },
|
|
66
|
+
{ id: "P6", tool: "npm test" }
|
|
67
|
+
]);
|
|
68
|
+
const c = attributionCandidates({ command: "npm test" }, p);
|
|
69
|
+
assert.deepEqual(c.map((x) => x.stepId), ["P6"], "只该报真正对得上那一条");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("词前缀才算 weak:同一步多带了参数应被认出来", () => {
|
|
73
|
+
const p = pipeline([{ id: "P3", tool: "gh pr create" }]);
|
|
74
|
+
const c = attributionCandidates({ command: "gh pr create --base main --title 补开发版" }, p);
|
|
75
|
+
assert.equal(c.length, 1);
|
|
76
|
+
assert.equal(c[0].confidence, "weak", "人写的是步骤名,agent 跑的是带参命令行——这该认出来");
|
|
77
|
+
assert.match(c[0].why, /gh pr create/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("命令头相同但参数对不上:给 weak 候选,并如实说明差在哪", () => {
|
|
81
|
+
const p = pipeline([{ id: "V2", tool: "git push" }]);
|
|
82
|
+
const c = attributionCandidates({ command: "git push --delete origin lane" }, p);
|
|
83
|
+
assert.equal(c.length, 1);
|
|
84
|
+
assert.equal(c[0].confidence, "weak");
|
|
85
|
+
// 理由里必须能看出「步骤写的是什么、这次是什么」,否则人没法判断该不该认。
|
|
86
|
+
assert.match(c[0].why, /git push/);
|
|
87
|
+
assert.match(c[0].why, /--delete/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("认不出来就是空数组——不许猜", () => {
|
|
91
|
+
const p = pipeline([{ id: "P6", tool: "npm test" }, { id: "T2", tool: "git tag" }]);
|
|
92
|
+
assert.deepEqual(attributionCandidates({ command: "docker compose up" }, p), []);
|
|
93
|
+
// 拼不出命令行时同样返回空,而不是退回「第一条」。
|
|
94
|
+
assert.deepEqual(attributionCandidates({ tool: "bash" }, p), []);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("同一档多条候选全部返回,不替人挑一条", () => {
|
|
98
|
+
const p = pipeline([
|
|
99
|
+
{ id: "A1", tool: "npm test" },
|
|
100
|
+
{ id: "A2", tool: "npm test" }
|
|
101
|
+
]);
|
|
102
|
+
const c = attributionCandidates({ command: "npm test" }, p);
|
|
103
|
+
assert.deepEqual(c.map((x) => x.stepId), ["A1", "A2"], "两条步骤长得一样时该由人消歧,机器不许自己选");
|
|
104
|
+
assert.equal(c.every((x) => x.confidence === "exact"), true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("空 tool 的步骤结构性地无法归属:不参与匹配,而不是匹配成「什么都能中」", () => {
|
|
108
|
+
const p = pipeline([{ id: "P4", tool: "" }, { id: "P6", tool: "npm test" }]);
|
|
109
|
+
const c = attributionCandidates({ command: "npm test" }, p);
|
|
110
|
+
assert.deepEqual(c.map((x) => x.stepId), ["P6"]);
|
|
111
|
+
assert.deepEqual(attributionCandidates({ command: "任意命令" }, pipeline([{ id: "P4", tool: "" }])), []);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("exact 排在 weak 前面(给人看时先看最可能的那档)", () => {
|
|
115
|
+
// 同一次动作能同时命中两种档位时,exact 必须在前——人从第一条开始看,
|
|
116
|
+
// 把最可能的排在后面等于逼人先读完噪声。
|
|
117
|
+
const p = pipeline([
|
|
118
|
+
{ id: "W", tool: "git push" }, // 这次跑的是 `git push --force`,只算 weak
|
|
119
|
+
{ id: "E", tool: "git push --delete origin lane" } // 这次跑的正是它,exact
|
|
120
|
+
]);
|
|
121
|
+
const c = attributionCandidates({ command: "git push --delete origin lane" }, p);
|
|
122
|
+
assert.equal(c[0].stepId, "E");
|
|
123
|
+
assert.equal(c[0].confidence, "exact");
|
|
124
|
+
assert.equal(c[1].stepId, "W");
|
|
125
|
+
assert.equal(c[1].confidence, "weak");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("弱匹配受得住真实工作流的规模:一次 git push 不会把六条 git 步骤全报出来", () => {
|
|
129
|
+
// 真实那条 19 步的工作流里 git 系命令有 5 条。这条是抗噪的规模版回归锁。
|
|
130
|
+
const real = pipeline([
|
|
131
|
+
{ id: "P1", tool: "git log / gh pr list" },
|
|
132
|
+
{ id: "V2", tool: "git push" },
|
|
133
|
+
{ id: "T1", tool: "git diff" },
|
|
134
|
+
{ id: "T2", tool: "git tag / git push <tag>" },
|
|
135
|
+
{ id: "F3", tool: "git push --delete / git worktree remove" }
|
|
136
|
+
]);
|
|
137
|
+
const c = attributionCandidates({ command: "git push origin main" }, real);
|
|
138
|
+
const ids = c.map((x) => x.stepId);
|
|
139
|
+
// `git log` / `git diff` / `git push --delete` 都不算 `git push`(后者参数方向相反:
|
|
140
|
+
// 步骤写的是「git push --delete」,这次是「git push origin main」,谁也不是谁的前缀)。
|
|
141
|
+
assert.equal(ids.includes("P1"), false, "git log 不该被当成 git push");
|
|
142
|
+
assert.equal(ids.includes("T1"), false, "git diff 不该被当成 git push");
|
|
143
|
+
assert.equal(ids.includes("F3"), false, "git push --delete 与 git push origin main 是不同的两步");
|
|
144
|
+
// 只剩真的以 `git push` 起头、只是多带了参数的那两条。
|
|
145
|
+
assert.deepEqual(ids.sort(), ["T2", "V2"]);
|
|
146
|
+
assert.ok(ids.length < real.steps.length, "候选数必须少于总步数,否则等于没筛");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ── 结构性不可归属 ────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
test("空 tool 的步骤被单独列出来:它是「为什么这一步永远没账」的答案", () => {
|
|
152
|
+
const p = pipeline([{ id: "P6", tool: "npm test" }, { id: "P4", tool: "" }]);
|
|
153
|
+
assert.deepEqual(unattributableSteps(p), ["P4"],
|
|
154
|
+
"没写 tool 的步骤永远不会拿到账——它与「还没跑过」必须分得开");
|
|
155
|
+
assert.deepEqual(unattributableSteps({ steps: [{ id: "X", tool: " " }] }), ["X"], "空白 tool 同样不可归属");
|
|
156
|
+
assert.deepEqual(unattributableSteps(null), [], "坏输入返回空数组,不抛");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ── 观察对象的形状 ────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
test("观察的三态 ok 必须保住:「失败」不能与「不知道」折叠成同一个值", () => {
|
|
162
|
+
assert.equal(normalizeObservation({ command: "npm test", ok: true }).ok, true);
|
|
163
|
+
assert.equal(normalizeObservation({ command: "npm test", ok: false }).ok, false);
|
|
164
|
+
// 宿主没给成败时留 null——把它当 false 会让「跑失败」与「没记录」不可分。
|
|
165
|
+
assert.equal(normalizeObservation({ command: "npm test" }).ok, null);
|
|
166
|
+
assert.equal(normalizeObservation({ tool: "bash" }), null, "拼不出命令行的观察不进归属");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("坏输入都不抛:没有 pipeline、没有观察、空对象一律返回空数组", () => {
|
|
170
|
+
const p = pipeline([{ id: "A1", tool: "npm test" }]);
|
|
171
|
+
for (const bad of [null, undefined, {}, { command: "" }, 0]) {
|
|
172
|
+
assert.deepEqual(attributionCandidates(bad, p), [], `观察 ${JSON.stringify(bad)} 该返回空数组而不是抛`);
|
|
173
|
+
}
|
|
174
|
+
assert.deepEqual(attributionCandidates({ command: "x" }, null), []);
|
|
175
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { parseMoRunInput, createMoRunHandler, registerMoRunCommand, MO_RUN_COMMAND } from "../src/pipeline-command.js";
|
|
4
|
+
|
|
5
|
+
// 这组测试守的是 `/mo-run` 的回执质量。命令回执是这个功能**唯一的对外窗口**
|
|
6
|
+
// (面板侧还没做),所以最要紧的性质是:**失败必须说清是哪种失败**,
|
|
7
|
+
// 尤其「一条观察都没认出来」绝不能被含糊成「完成」。
|
|
8
|
+
|
|
9
|
+
test("解析:三种子命令 + 默认看状态 + 不认识的词给用法", () => {
|
|
10
|
+
assert.deepEqual(parseMoRunInput("start dsh-mneme-release"), { action: "start", pipelineId: "dsh-mneme-release" });
|
|
11
|
+
assert.deepEqual(parseMoRunInput("stop"), { action: "stop" });
|
|
12
|
+
assert.deepEqual(parseMoRunInput("status"), { action: "status" });
|
|
13
|
+
// 不带参数 = 看状态:最安全的默认,不会误开窗
|
|
14
|
+
assert.deepEqual(parseMoRunInput(""), { action: "status" });
|
|
15
|
+
assert.deepEqual(parseMoRunInput(" "), { action: "status" });
|
|
16
|
+
assert.deepEqual(parseMoRunInput(null), { action: "status" });
|
|
17
|
+
assert.equal(parseMoRunInput("whatever").action, "help");
|
|
18
|
+
// 大小写不敏感,id 里带空格也能取全
|
|
19
|
+
assert.deepEqual(parseMoRunInput("START a b"), { action: "start", pipelineId: "a b" });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** 造一个受控的假记录器,回执里只看它返回的东西怎么被翻译。 */
|
|
23
|
+
function fakeRecorder({ start, stop, status, confirm } = {}) {
|
|
24
|
+
return {
|
|
25
|
+
start: start ?? (() => ({ ok: true, pipelineId: "demo", steps: 3 })),
|
|
26
|
+
stop: stop ?? (() => ({ ok: true, pipelineId: "demo", steps: 2 })),
|
|
27
|
+
// P2 起 stop 不再落盘,默认返回"待确认"
|
|
28
|
+
status: status ?? (() => ({ open: false })),
|
|
29
|
+
confirm: confirm ?? (() => ({ ok: true, pipelineId: "demo", steps: 1, notDone: [], unsure: [], unanswered: [] }))
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const invocation = (rawInput) => ({ rawInput, agent: { session: { id: "s1" } } });
|
|
34
|
+
|
|
35
|
+
test("start 成功:说清开了哪条、几步", () => {
|
|
36
|
+
const h = createMoRunHandler({ recorder: fakeRecorder(), language: () => "zh" });
|
|
37
|
+
const r = h(invocation("start demo"));
|
|
38
|
+
assert.equal(r.kind, "success");
|
|
39
|
+
assert.match(r.text, /已开窗/);
|
|
40
|
+
assert.match(r.text, /demo/);
|
|
41
|
+
assert.match(r.text, /3 步/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("start 已开着窗口:给的是「先 stop」,而不是静默顶掉", () => {
|
|
45
|
+
const h = createMoRunHandler({
|
|
46
|
+
recorder: fakeRecorder({ start: () => ({ ok: false, reason: "already-open", pipelineId: "old" }) }),
|
|
47
|
+
language: () => "zh"
|
|
48
|
+
});
|
|
49
|
+
const r = h(invocation("start demo"));
|
|
50
|
+
assert.match(r.text, /already|已经开着|旧|old|先/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("start 找不到工作流:把可用清单给出来(别让人猜)", () => {
|
|
54
|
+
const h = createMoRunHandler({
|
|
55
|
+
recorder: fakeRecorder({ start: () => ({ ok: false, reason: "pipeline-not-found", available: ["a", "b"] }) }),
|
|
56
|
+
language: () => "zh"
|
|
57
|
+
});
|
|
58
|
+
const r = h(invocation("start nope"));
|
|
59
|
+
assert.match(r.text, /a/);
|
|
60
|
+
assert.match(r.text, /b/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("start 读不出来与没有这条是两种话——不许合并成同一句", () => {
|
|
64
|
+
const unreadable = createMoRunHandler({
|
|
65
|
+
recorder: fakeRecorder({ start: () => ({ ok: false, reason: "pipeline-unreadable", detail: "坏 JSON" }) }),
|
|
66
|
+
language: () => "zh"
|
|
67
|
+
});
|
|
68
|
+
const notFound = createMoRunHandler({
|
|
69
|
+
recorder: fakeRecorder({ start: () => ({ ok: false, reason: "pipeline-not-found", available: [] }) }),
|
|
70
|
+
language: () => "zh"
|
|
71
|
+
});
|
|
72
|
+
assert.notEqual(unreadable(invocation("start demo")).text, notFound(invocation("start demo")).text);
|
|
73
|
+
assert.match(unreadable(invocation("start demo")).text, /坏 JSON/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("P2:stop 产出待确认清单,明说「还没有写账」并给出步骤表", () => {
|
|
77
|
+
const h = createMoRunHandler({
|
|
78
|
+
recorder: fakeRecorder({
|
|
79
|
+
stop: () => ({
|
|
80
|
+
ok: true, state: "awaiting-confirmation", notCommands: 5, truncated: false,
|
|
81
|
+
activities: [{ key: "npm test", count: 3, flags: [] }],
|
|
82
|
+
proposal: {
|
|
83
|
+
pipelineId: "demo",
|
|
84
|
+
steps: [
|
|
85
|
+
{ id: "P6", title: "跑测试", declared: "npm test", hintCount: 2 },
|
|
86
|
+
{ id: "V2", title: "推分支", declared: "git push", hintCount: 0 }
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
}),
|
|
91
|
+
language: () => "zh"
|
|
92
|
+
});
|
|
93
|
+
const r = h(invocation("stop"));
|
|
94
|
+
assert.match(r.text, /还没有写账/, "必须明确说这一版不自动落盘");
|
|
95
|
+
assert.match(r.text, /P6/, "步骤表要给出来供人勾");
|
|
96
|
+
assert.match(r.text, /V2/);
|
|
97
|
+
assert.match(r.text, /confirm/, "要给出怎么写确认");
|
|
98
|
+
assert.match(r.text, /npm test ×3/, "观察到的动作清单也要给");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("P2:confirm 成功 → 报步数,并把「没做」「不确定」「没提到」分开", () => {
|
|
102
|
+
const h = createMoRunHandler({
|
|
103
|
+
recorder: fakeRecorder({
|
|
104
|
+
confirm: () => ({ ok: true, pipelineId: "demo", steps: 1, notDone: ["V2"], unsure: [], unanswered: ["A3"] })
|
|
105
|
+
}),
|
|
106
|
+
language: () => "zh"
|
|
107
|
+
});
|
|
108
|
+
const r = h(invocation("confirm P6"));
|
|
109
|
+
assert.equal(r.kind, "success");
|
|
110
|
+
assert.match(r.text, /记入账本/);
|
|
111
|
+
assert.match(r.text, /V2/);
|
|
112
|
+
assert.match(r.text, /A3/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("P2:confirm 的关键一条——什么都没确认时明说没落盘,不许含糊成完成", () => {
|
|
116
|
+
const h = createMoRunHandler({
|
|
117
|
+
recorder: fakeRecorder({
|
|
118
|
+
confirm: () => ({ ok: false, reason: "no-answers", notDone: 0, unsure: 1, unanswered: 1 })
|
|
119
|
+
}),
|
|
120
|
+
language: () => "zh"
|
|
121
|
+
});
|
|
122
|
+
const r = h(invocation("confirm P6:?"));
|
|
123
|
+
assert.equal(r.kind, "error");
|
|
124
|
+
assert.match(r.text, /没有落盘/, "必须明确说没写账");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("P2:confirm 解析——`P6` 表示做了,`V2:-` 没做,`A3:?` 不确定", () => {
|
|
128
|
+
const seen = [];
|
|
129
|
+
const h = createMoRunHandler({
|
|
130
|
+
recorder: fakeRecorder({ confirm: (arg) => { seen.push(arg.answers); return { ok: true, pipelineId: "d", steps: 1, notDone: [], unsure: [], unanswered: [] }; } }),
|
|
131
|
+
language: () => "zh"
|
|
132
|
+
});
|
|
133
|
+
h(invocation("confirm P6 V2:- A3:?"));
|
|
134
|
+
assert.deepEqual(seen[0], { P6: "done", V2: "notDone", A3: "unsure" });
|
|
135
|
+
// 非法标记不放进结果(不是「没做」,是「没答」)
|
|
136
|
+
h(invocation("confirm P6:xyz"));
|
|
137
|
+
assert.deepEqual(seen[1], {});
|
|
138
|
+
// 逗号也当分隔符
|
|
139
|
+
h(invocation("confirm P6,V2"));
|
|
140
|
+
assert.deepEqual(seen[2], { P6: "done", V2: "done" });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("P2:没待确认清单时 confirm 说清先做什么", () => {
|
|
144
|
+
const h = createMoRunHandler({
|
|
145
|
+
recorder: fakeRecorder({ confirm: () => ({ ok: false, reason: "nothing-to-confirm" }) }),
|
|
146
|
+
language: () => "zh"
|
|
147
|
+
});
|
|
148
|
+
const r = h(invocation("confirm P6"));
|
|
149
|
+
assert.match(r.text, /没有待确认/);
|
|
150
|
+
assert.match(r.text, /mo-run start/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("P2:非法回答/未知步骤各自说清", () => {
|
|
154
|
+
const bad = createMoRunHandler({
|
|
155
|
+
recorder: fakeRecorder({ confirm: () => ({ ok: false, reason: "bad-answer-value", stepId: "P6", value: "yes" }) }),
|
|
156
|
+
language: () => "zh"
|
|
157
|
+
});
|
|
158
|
+
assert.match(bad(invocation("confirm P6:yes")).text, /不合法/);
|
|
159
|
+
|
|
160
|
+
const unknown = createMoRunHandler({
|
|
161
|
+
recorder: fakeRecorder({ confirm: () => ({ ok: false, reason: "unknown-step", stepId: "NOPE" }) }),
|
|
162
|
+
language: () => "zh"
|
|
163
|
+
});
|
|
164
|
+
assert.match(unknown(invocation("confirm NOPE")).text, /NOPE/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("stop 没有开着窗口 / 写盘失败:各自说清,不混为一谈", () => {
|
|
168
|
+
const none = createMoRunHandler({
|
|
169
|
+
recorder: fakeRecorder({ stop: () => ({ ok: false, reason: "no-open-window" }) }),
|
|
170
|
+
language: () => "zh"
|
|
171
|
+
});
|
|
172
|
+
const failed = createMoRunHandler({
|
|
173
|
+
recorder: fakeRecorder({ stop: () => ({ ok: false, reason: "write-failed", detail: "磁盘满" }) }),
|
|
174
|
+
language: () => "zh"
|
|
175
|
+
});
|
|
176
|
+
assert.match(none(invocation("stop")).text, /没有开着窗口/);
|
|
177
|
+
assert.match(failed(invocation("stop")).text, /磁盘满/);
|
|
178
|
+
assert.notEqual(none(invocation("stop")).text, failed(invocation("stop")).text);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("status:要能分辨「关着」「开着」「等你确认」三种状态", () => {
|
|
182
|
+
const closed = createMoRunHandler({ recorder: fakeRecorder({ status: () => ({ open: false }) }), language: () => "zh" });
|
|
183
|
+
assert.match(closed(invocation("status")).text, /没有开着/);
|
|
184
|
+
|
|
185
|
+
const awaiting = createMoRunHandler({ recorder: fakeRecorder({ status: () => ({ open: false, awaitingConfirmation: true, pipelineId: "demo", steps: 3 }) }), language: () => "zh" });
|
|
186
|
+
assert.match(awaiting(invocation("status")).text, /等你在确认|确认/, "等待确认是一个独立状态");
|
|
187
|
+
|
|
188
|
+
const open = createMoRunHandler({
|
|
189
|
+
recorder: fakeRecorder({
|
|
190
|
+
status: () => ({ open: true, pipelineId: "demo", startedAt: "2026-10-16T12:00:00.000Z", bufferedEvents: 7, recorded: ["P6"], unattributed: 1, ambiguous: 0, truncated: false })
|
|
191
|
+
}),
|
|
192
|
+
language: () => "zh"
|
|
193
|
+
});
|
|
194
|
+
const r = open(invocation("status"));
|
|
195
|
+
assert.match(r.text, /demo/);
|
|
196
|
+
assert.match(r.text, /7 条事件/);
|
|
197
|
+
assert.match(r.text, /P6/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("英文实例:同一组结果翻成英文,不落回中文", () => {
|
|
201
|
+
const h = createMoRunHandler({
|
|
202
|
+
recorder: fakeRecorder({ confirm: () => ({ ok: false, reason: "no-answers", notDone: 0, unsure: 1, unanswered: 1 }) }),
|
|
203
|
+
language: () => "en"
|
|
204
|
+
});
|
|
205
|
+
const r = h(invocation("confirm P6:?"));
|
|
206
|
+
assert.match(r.text, /nothing was persisted/i);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("注册默认关:旗标不是 true 时一个命令都不注册", () => {
|
|
210
|
+
const registered = [];
|
|
211
|
+
const ctx = { commands: { register: (c) => { registered.push(c); return () => {}; } } };
|
|
212
|
+
assert.equal(registerMoRunCommand({ ctx, recorder: fakeRecorder(), enabled: false }), null);
|
|
213
|
+
assert.equal(registerMoRunCommand({ ctx, recorder: fakeRecorder(), enabled: undefined }), null);
|
|
214
|
+
assert.equal(registered.length, 0, "默认关时不许注册");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("注册打开:命令名走小写字母起头的约定,且带描述与用法提示", () => {
|
|
218
|
+
const registered = [];
|
|
219
|
+
const ctx = { commands: { register: (c) => { registered.push(c); return () => {}; } } };
|
|
220
|
+
const dispose = registerMoRunCommand({ ctx, recorder: fakeRecorder(), enabled: true, language: () => "zh" });
|
|
221
|
+
assert.equal(typeof dispose, "function");
|
|
222
|
+
assert.equal(registered.length, 1);
|
|
223
|
+
assert.equal(registered[0].name, MO_RUN_COMMAND);
|
|
224
|
+
assert.match(registered[0].name, /^[a-z][a-z0-9_-]*$/);
|
|
225
|
+
assert.ok(registered[0].description);
|
|
226
|
+
assert.ok(registered[0].input?.hint);
|
|
227
|
+
assert.equal(typeof registered[0].handler, "function");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("没有命令注册表时不硬注册(返回 null,由装配方决定要不要降级)", () => {
|
|
231
|
+
assert.equal(registerMoRunCommand({ ctx: {}, recorder: fakeRecorder(), enabled: true }), null);
|
|
232
|
+
assert.equal(registerMoRunCommand({ ctx: null, recorder: fakeRecorder(), enabled: true }), null);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("handler 抛异常也不许把异常抛进宿主:退化成用法提示", () => {
|
|
236
|
+
const h = createMoRunHandler({
|
|
237
|
+
recorder: { start: () => { throw new Error("炸了"); }, stop: () => {}, status: () => {} },
|
|
238
|
+
language: () => "zh"
|
|
239
|
+
});
|
|
240
|
+
const r = h(invocation("start demo"));
|
|
241
|
+
assert.equal(r.kind, "error");
|
|
242
|
+
assert.ok(r.text);
|
|
243
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildProposal, normalizeAnswers, runFromAnswers } from "../src/pipeline-confirm.js";
|
|
4
|
+
import { validatePipeline } from "../src/pipeline.js";
|
|
5
|
+
|
|
6
|
+
// 这组测试守的是 P2 的核心语义:**机器不猜,人确认才写账**。
|
|
7
|
+
// 最要紧的三条:
|
|
8
|
+
// ① 提案不改动任何东西(纯函数)
|
|
9
|
+
// ② unsure / 没回答 ≠ notDone——前者什么都不写,后者记 skipped
|
|
10
|
+
// ③ 一步都没确认走过 → 不产 run(账本契约不许零步),而不是伪造一条空的
|
|
11
|
+
|
|
12
|
+
function makePipeline() {
|
|
13
|
+
return validatePipeline({
|
|
14
|
+
id: "demo", name: "演示", project: "a/b", version: 0, status: "active", done: "走完",
|
|
15
|
+
trigger: [{ kind: "phrase", value: "走一遍" }],
|
|
16
|
+
steps: [
|
|
17
|
+
{ id: "P6", phase: "p", title: "跑测试", when: "w", do: "d", who: "main", tool: "npm test", done: "dn", risk: "R0", needs: [], why: "y" },
|
|
18
|
+
{ id: "V2", phase: "p", title: "推分支", when: "w", do: "d", who: "main", tool: "git push", done: "dn", risk: "R2", needs: ["P6"], why: "y" }
|
|
19
|
+
]
|
|
20
|
+
}, "demo.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("提案:列出流程步骤与观察到的动作,但**不预选任何一步**", () => {
|
|
24
|
+
const p = makePipeline();
|
|
25
|
+
const prop = buildProposal({
|
|
26
|
+
pipeline: p,
|
|
27
|
+
activities: [{ key: "npm test", count: 5, flags: [] }, { key: "git status", count: 3, flags: ["--porcelain"] }]
|
|
28
|
+
});
|
|
29
|
+
assert.equal(prop.ok, true);
|
|
30
|
+
assert.deepEqual(prop.steps.map((s) => s.id), ["P6", "V2"]);
|
|
31
|
+
// 步骤里带的是"作者写的 tool 原文",给对照用;没有任何自动勾选
|
|
32
|
+
assert.equal(prop.steps[0].declared, "npm test");
|
|
33
|
+
assert.equal(prop.steps[0].hintCount, 0, "没给候选时次数是 0");
|
|
34
|
+
assert.deepEqual(prop.activities.map((a) => a.key), ["npm test", "git status"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("提案:候选只作提示(一个次数),不预选、不带命令行原文", () => {
|
|
38
|
+
const p = makePipeline();
|
|
39
|
+
const prop = buildProposal({ pipeline: p, hints: { P6: 3 } });
|
|
40
|
+
assert.equal(prop.steps[0].hintCount, 3);
|
|
41
|
+
// 关键:hintCount 只说明"见到过相近命令",没有任何字段表示"已经确认"
|
|
42
|
+
assert.equal("answer" in prop.steps[0], false);
|
|
43
|
+
assert.equal("selected" in prop.steps[0], false);
|
|
44
|
+
// 也不存命令行原文——原文含绝对路径(路径含本机用户名),而这条会进回执
|
|
45
|
+
assert.equal(JSON.stringify(prop).includes("command"), false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("提案:没有工作流时如实拒绝", () => {
|
|
49
|
+
assert.equal(buildProposal({ pipeline: null }).reason, "no-pipeline");
|
|
50
|
+
assert.equal(buildProposal({ pipeline: { id: "x" } }).reason, "no-pipeline");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("校验回答:只认三类值,非法值一律拒(静默当「没发生」是编造)", () => {
|
|
54
|
+
const p = makePipeline();
|
|
55
|
+
assert.equal(normalizeAnswers({ P6: "done" }, p).ok, true);
|
|
56
|
+
const bad = normalizeAnswers({ P6: "yes" }, p);
|
|
57
|
+
assert.equal(bad.ok, false);
|
|
58
|
+
assert.equal(bad.reason, "bad-answer-value");
|
|
59
|
+
const unknown = normalizeAnswers({ NOPE: "done" }, p);
|
|
60
|
+
assert.equal(unknown.reason, "unknown-step");
|
|
61
|
+
assert.equal(normalizeAnswers(null, p).reason, "bad-answers");
|
|
62
|
+
assert.equal(normalizeAnswers([], p).reason, "bad-answers");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("✅ 核心语义:done → uses;notDone → skipped;unsure/没答 → 什么都不写", () => {
|
|
66
|
+
const p = makePipeline();
|
|
67
|
+
const r = runFromAnswers({
|
|
68
|
+
pipeline: p,
|
|
69
|
+
answers: { P6: "done" }, // V2 没回答
|
|
70
|
+
now: () => new Date("2026-10-17T00:00:00.000Z")
|
|
71
|
+
});
|
|
72
|
+
assert.equal(r.kind, "run");
|
|
73
|
+
assert.deepEqual(r.run.steps.map((s) => s.id), ["P6"], "没回答的步骤不许进账");
|
|
74
|
+
assert.equal(r.run.steps[0].skipped, false);
|
|
75
|
+
assert.deepEqual(r.unanswered, ["V2"], "没答的要如实列出来");
|
|
76
|
+
assert.equal(r.run.by, "human", "人确认的账,by 应当是人");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("✅ 核心语义:unsure 与 notDone 决不能混——前者不写,后者记 skipped", () => {
|
|
80
|
+
const p = makePipeline();
|
|
81
|
+
const unsure = runFromAnswers({ pipeline: p, answers: { P6: "done", V2: "unsure" } });
|
|
82
|
+
assert.deepEqual(unsure.run.steps.map((s) => s.id), ["P6"], "unsure 不进账");
|
|
83
|
+
assert.deepEqual(unsure.unsure, ["V2"]);
|
|
84
|
+
|
|
85
|
+
const notDone = runFromAnswers({ pipeline: p, answers: { P6: "done", V2: "notDone" } });
|
|
86
|
+
const v2 = notDone.run.steps.find((s) => s.id === "V2");
|
|
87
|
+
assert.equal(v2.skipped, true, "人明确说没做,才记 skipped");
|
|
88
|
+
assert.match(v2.note, /人确认/);
|
|
89
|
+
// 这条区分的意义:跳过计数正是 RETIRE_AFTER_SKIPS 提议删步的依据,
|
|
90
|
+
// 把"忘了勾"记成"被跳过"就会多出错误提议。
|
|
91
|
+
assert.notDeepEqual(unsure.run.steps.length, notDone.run.steps.length);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("全部 notDone 仍是一条合法记录(人确认「这次一步都没做」是有信息的)", () => {
|
|
95
|
+
const p = makePipeline();
|
|
96
|
+
const r = runFromAnswers({ pipeline: p, answers: { P6: "notDone", V2: "notDone" } });
|
|
97
|
+
// 这不叫"零步"——它明确记录了"这两步这次都没做",是一条完整的、全跳过的 run。
|
|
98
|
+
// 真正进 nothing 的是「什么都没确认」(见下一条)。
|
|
99
|
+
assert.equal(r.kind, "run");
|
|
100
|
+
assert.deepEqual(r.run.steps.map((s) => s.id), ["P6", "V2"]);
|
|
101
|
+
assert.equal(r.run.steps.every((s) => s.skipped === true), true);
|
|
102
|
+
assert.deepEqual(r.notDone, ["P6", "V2"]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("什么都没确认(全 unsure):不产 run——这才是真正的「零步」", () => {
|
|
106
|
+
const p = makePipeline();
|
|
107
|
+
const r = runFromAnswers({ pipeline: p, answers: { P6: "unsure", V2: "unsure" } });
|
|
108
|
+
assert.equal(r.kind, "nothing");
|
|
109
|
+
assert.equal(r.reason, "no-steps-confirmed");
|
|
110
|
+
assert.equal("run" in r, false, "不许伪造一条空 run");
|
|
111
|
+
assert.deepEqual(r.unsure, ["P6", "V2"]);
|
|
112
|
+
assert.deepEqual(r.notDone, []);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("空回答也是 nothing(没答 ≠ 都没做)", () => {
|
|
116
|
+
const p = makePipeline();
|
|
117
|
+
const r = runFromAnswers({ pipeline: p, answers: {} });
|
|
118
|
+
assert.equal(r.kind, "nothing");
|
|
119
|
+
assert.deepEqual(r.unanswered, ["P6", "V2"]);
|
|
120
|
+
assert.deepEqual(r.notDone, [], "没人说没做,就不该出现在 notDone 里");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("非法回答进不来:runFromAnswers 会把它变成 error,而不是悄悄少记一步", () => {
|
|
124
|
+
const p = makePipeline();
|
|
125
|
+
const r = runFromAnswers({ pipeline: p, answers: { P6: "done", V2: "nope" } });
|
|
126
|
+
assert.equal(r.kind, "error");
|
|
127
|
+
assert.equal(r.reason, "bad-answer-value");
|
|
128
|
+
assert.equal("run" in r, false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("产出的 run 能过账本 normalizer(两个契约对得上)", async () => {
|
|
132
|
+
const { normalizeRun } = await import("../src/pipeline-runs.js");
|
|
133
|
+
const p = makePipeline();
|
|
134
|
+
const r = runFromAnswers({ pipeline: p, answers: { P6: "done", V2: "notDone" } });
|
|
135
|
+
const normalized = normalizeRun(r.run);
|
|
136
|
+
assert.equal(normalized.steps.length, 2);
|
|
137
|
+
assert.equal(normalized.steps[0].skipped, false);
|
|
138
|
+
assert.equal(normalized.steps[1].skipped, true);
|
|
139
|
+
assert.equal(normalized.by, "human");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("坏输入不抛", () => {
|
|
143
|
+
const p = makePipeline();
|
|
144
|
+
for (const bad of [null, undefined, [], "x", 0]) {
|
|
145
|
+
assert.equal(runFromAnswers({ pipeline: p, answers: bad }).kind, "error");
|
|
146
|
+
}
|
|
147
|
+
assert.equal(runFromAnswers({ pipeline: null, answers: {} }).kind, "error");
|
|
148
|
+
});
|