@modusensus/dsh-mneme 0.9.0 → 0.9.2
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 +82 -19
- package/bin/cli.mjs +69 -9
- package/docs/CONFIGURATION.md +48 -0
- package/docs/SEMANTIC.md +3 -3
- package/lib/api-standalone.js +78 -12
- package/lib/api.js +339 -59
- package/lib/candidates.js +454 -454
- package/lib/client.js +669 -256
- package/lib/config.js +50 -2
- package/lib/dream/sleep.js +35 -12
- package/lib/dream.js +86 -14
- package/lib/embedding.js +153 -22
- 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/scope-state.js +277 -0
- package/lib/semantic.js +66 -10
- package/lib/service.js +80 -3
- package/lib/settings.js +139 -1
- package/lib/store.js +216 -3
- package/lib/summarize.js +2 -3
- package/lib/tools.js +71 -0
- 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 +339 -59
- package/src/candidates.js +454 -454
- package/src/config.js +50 -2
- package/src/dream/sleep.js +35 -12
- package/src/dream.js +86 -14
- package/src/embedding.js +153 -22
- 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/scope-state.js +277 -0
- package/src/semantic.js +66 -10
- package/src/service.js +80 -3
- package/src/settings.js +139 -1
- package/src/store.js +216 -3
- package/src/summarize.js +2 -3
- package/src/tools.js +71 -0
- package/test/api-hardening.test.js +173 -0
- package/test/api.test.js +210 -1
- package/test/changelog-prep.test.js +74 -8
- package/test/cli-enums.test.js +37 -0
- package/test/cli-flag-value.test.js +86 -0
- package/test/client.test.js +216 -6
- package/test/distill-tx-embed-gap.test.js +110 -0
- package/test/docs-config-keys.test.js +65 -0
- package/test/dream-maxtokens-fallback.test.js +132 -0
- package/test/embed-text-reasons.test.js +112 -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/readme-claims.test.js +68 -0
- 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/scope-state.test.js +481 -0
- package/test/security-doc.test.js +139 -0
- package/test/sensitive-scan.test.js +44 -0
- package/test/tools-dto-schema-matrix.test.js +12 -3
- package/test/tools.test.js +6 -6
- package/test/workflow-guards.test.js +156 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { businessCommandOf, activityKey, tallyActivities } from "../src/pipeline-activities.js";
|
|
4
|
+
|
|
5
|
+
// 这组测试守两件事:
|
|
6
|
+
// ① 分组结果落在「人能扫」的量级(业务命令才进清单,shell 语法不进)
|
|
7
|
+
// ② 🔴 隐私硬规则:位置参数一律丢弃——回执要显示在界面上,而完整命令行里
|
|
8
|
+
// 几乎必然含绝对路径(路径里含本机用户名)。这一条比功能本身重要。
|
|
9
|
+
|
|
10
|
+
test("业务命令:抽出 {动词, 子命令, 旗标}", () => {
|
|
11
|
+
assert.deepEqual(businessCommandOf("git status"), { verb: "git", sub: "status", flags: [] });
|
|
12
|
+
assert.deepEqual(businessCommandOf("git push origin main"), { verb: "git", sub: "push", flags: [] });
|
|
13
|
+
assert.deepEqual(businessCommandOf("gh pr merge --rebase"), { verb: "gh", sub: "pr", flags: ["--rebase"] });
|
|
14
|
+
assert.deepEqual(businessCommandOf("npm test"), { verb: "npm", sub: "test", flags: [] });
|
|
15
|
+
// 子命令可空
|
|
16
|
+
assert.deepEqual(businessCommandOf("npm"), { verb: "npm", sub: "", flags: [] });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("🔴 隐私硬规则:位置参数一律丢弃,只留动词/子命令/旗标", () => {
|
|
20
|
+
// 实测里真实出现过这种形状:路径含本机用户名。回执绝不能把它带出去。
|
|
21
|
+
const r = businessCommandOf('set-location "c:\\users\\somebody\\desktop\\proj"; git commit -m "x"');
|
|
22
|
+
// 整段里第一个可识别的是 set-location(不在名单)→ 由分段器处理;这里直接测 git commit
|
|
23
|
+
assert.equal(businessCommandOf("git commit -m \"修好了\"").verb, "git");
|
|
24
|
+
assert.deepEqual(businessCommandOf("git commit -m \"修好了\"").flags, ["-m"]);
|
|
25
|
+
// 位置参数(文件名、分支名、路径)一个都不该出现在结果里
|
|
26
|
+
const withPath = businessCommandOf("git add src/foo.js C:/Users/name/x.md");
|
|
27
|
+
assert.equal(JSON.stringify(withPath).includes("Users"), false, "路径绝不能出现在结果里");
|
|
28
|
+
assert.equal(JSON.stringify(withPath).includes("foo.js"), false, "文件名是位置参数,该丢");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("🔴 旗标里的路径参数也要挡住(--file=C:/x 这种)", () => {
|
|
32
|
+
const r = businessCommandOf("git commit --file=C:/Users/name/msg.txt -m x");
|
|
33
|
+
assert.equal(JSON.stringify(r).includes("Users"), false, "带路径的旗标不许原样带出");
|
|
34
|
+
// 纯旗标照常留
|
|
35
|
+
assert.deepEqual(businessCommandOf("gh pr list --state all").flags.includes("--state"), true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("非业务命令一律不产清单项(shell 语法不是活动)", () => {
|
|
39
|
+
// 实测里密度最高的就是这些——它们不是"做了什么",是句子成分
|
|
40
|
+
for (const noise of [
|
|
41
|
+
'write-output "=== 检查 ==="',
|
|
42
|
+
"select-object -first 10",
|
|
43
|
+
"foreach-object { $_ }",
|
|
44
|
+
"echo hello",
|
|
45
|
+
"}",
|
|
46
|
+
"if (test-path x) { }",
|
|
47
|
+
"$erroractionpreference='continue'",
|
|
48
|
+
"cd C:/some/dir"
|
|
49
|
+
]) {
|
|
50
|
+
assert.equal(businessCommandOf(noise), null, `${noise} 不该进清单`);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("分段:一条命令里的多个业务动作分别计入", () => {
|
|
55
|
+
const r = tallyActivities([{ command: 'cd C:/x; git status; echo "---"; npm test' }]);
|
|
56
|
+
const keys = r.activities.map((a) => a.key).sort();
|
|
57
|
+
assert.deepEqual(keys, ["git status", "npm test"], "cd 与 echo 应被排除");
|
|
58
|
+
assert.equal(r.shellNoise, 2, "shell 噪音要单独计数,不能与业务动作混为一谈");
|
|
59
|
+
assert.equal(r.totalSegments, 4);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("计数与排序:按出现次数降序,同次数按名字", () => {
|
|
63
|
+
const r = tallyActivities([
|
|
64
|
+
{ command: "git status" },
|
|
65
|
+
{ command: "npm test; git status" },
|
|
66
|
+
{ command: "git status && gh pr list" }
|
|
67
|
+
]);
|
|
68
|
+
assert.equal(r.activities[0].key, "git status");
|
|
69
|
+
assert.equal(r.activities[0].count, 3);
|
|
70
|
+
assert.equal(r.businessSegments, 5);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("旗标并集:同一动作的不同旗标合并展示,且截断", () => {
|
|
74
|
+
const r = tallyActivities([
|
|
75
|
+
{ command: "gh pr merge --rebase" },
|
|
76
|
+
{ command: "gh pr merge --squash" }
|
|
77
|
+
]);
|
|
78
|
+
assert.equal(r.activities.length, 1);
|
|
79
|
+
assert.equal(r.activities[0].count, 2);
|
|
80
|
+
assert.deepEqual(r.activities[0].flags.sort(), ["--rebase", "--squash"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("🔴 回归:脚本路径不得被当成子命令(端到端实测抓到的真 bug)", () => {
|
|
84
|
+
// 实测里出现过 `node d:\dsh\.dsh\_tmp-mo-probe.mjs` —— 原实现把整条路径当 sub,
|
|
85
|
+
// 于是回执里带出本机路径(可能含用户名)。这正是隐私红线要挡的东西。
|
|
86
|
+
const r = businessCommandOf('node d:\\dsh\\.dsh\\_tmp-probe.mjs');
|
|
87
|
+
assert.equal(r.verb, "node");
|
|
88
|
+
assert.equal(JSON.stringify(r).includes("dsh"), false, "路径绝不能出现在结果里");
|
|
89
|
+
assert.equal(r.sub, "", "路径不是子命令,宁可留空");
|
|
90
|
+
// 相对路径同理
|
|
91
|
+
const r2 = businessCommandOf("node test/pipeline-nudge.test.js --test");
|
|
92
|
+
assert.equal(r2.sub, "", "文件名不是子命令");
|
|
93
|
+
assert.deepEqual(r2.flags, ["--test"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("🔴 回归:`git -C <path> log` 要扫到 log,而不是把 `-C` 的值当子命令", () => {
|
|
97
|
+
// 实测出现过 `git (join-path -C --oneline --porcelain` 这种读不懂的清单行。
|
|
98
|
+
const r = businessCommandOf('git -C C:/some/dir log --oneline -3');
|
|
99
|
+
assert.equal(r.verb, "git");
|
|
100
|
+
assert.equal(r.sub, "log", "子命令在 -C 的值之后,必须扫描而不是取第一个");
|
|
101
|
+
assert.equal(JSON.stringify(r).includes("some"), false, "路径不许带出");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("子命令形状窄:含 $、括号、引号、长串的一律不算", () => {
|
|
105
|
+
for (const bad of ["$p", "$(Get-Item", "C:/x/y", "d:\\a", "averyveryverylongsubcommandname", "'\"", "{", "@{"]) {
|
|
106
|
+
const r = businessCommandOf(`git ${bad} status`);
|
|
107
|
+
// 要么扫不到(sub 空),要么扫到 status;无论如何不能是那个坏值
|
|
108
|
+
assert.equal(r.sub === bad.toLowerCase(), false, `${bad} 不该被当成子命令`);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("旗标里带 = 或路径的一律丢(--file=C:/x 这种)", () => {
|
|
113
|
+
const r = businessCommandOf("git commit --file=C:/Users/name/msg.txt -m x");
|
|
114
|
+
assert.equal(JSON.stringify(r).includes("Users"), false);
|
|
115
|
+
assert.deepEqual(r.flags, ["-m"], "带值/路径的旗标整个丢掉");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("重定向与引号不影响识别", () => {
|
|
119
|
+
assert.deepEqual(businessCommandOf('npm test 2>&1 | Select-String "x"'), { verb: "npm", sub: "test", flags: [] });
|
|
120
|
+
assert.equal(businessCommandOf("'git' 'status'").verb, "git");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("坏输入不抛:空、非串、缺字段一律安全返回", () => {
|
|
124
|
+
for (const bad of [null, undefined, "", " ", 0, {}]) {
|
|
125
|
+
assert.equal(businessCommandOf(bad), null);
|
|
126
|
+
}
|
|
127
|
+
for (const bad of [null, undefined, [], [{}], [{ command: null }], [0, "x"]]) {
|
|
128
|
+
const r = tallyActivities(bad);
|
|
129
|
+
assert.deepEqual(r.activities, []);
|
|
130
|
+
assert.equal(typeof r.shellNoise, "number");
|
|
131
|
+
}
|
|
132
|
+
assert.equal(activityKey(null), "");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("产出可读:key 是 `动词 子命令`,trim 过", () => {
|
|
136
|
+
assert.equal(activityKey({ verb: "git", sub: "status" }), "git status");
|
|
137
|
+
assert.equal(activityKey({ verb: "npm", sub: "" }), "npm");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("没有子命令时用第一个旗标当键:node --test 与 node --check 不能并成一行", () => {
|
|
141
|
+
// 端到端实测里 16 次 node 调用被并成一条 —— 但它们语义不同(跑测试 vs 查语法)。
|
|
142
|
+
const r = tallyActivities([
|
|
143
|
+
{ command: "node --test x.test.js" },
|
|
144
|
+
{ command: "node --test y.test.js" },
|
|
145
|
+
{ command: "node --check src/foo.js" }
|
|
146
|
+
]);
|
|
147
|
+
const keys = r.activities.map((a) => a.key).sort();
|
|
148
|
+
assert.deepEqual(keys, ["node --check", "node --test"], "旗标区分语义,该分开");
|
|
149
|
+
assert.equal(r.activities.find((a) => a.key === "node --test").count, 2);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("旗标只当补充:有子命令时键仍以子命令为准", () => {
|
|
153
|
+
const k = activityKey({ verb: "git", sub: "status", flags: ["-C", "--porcelain"] });
|
|
154
|
+
assert.equal(k, "git status", "-C 后面本来跟着被丢弃的路径,不该挤进键里");
|
|
155
|
+
});
|
|
@@ -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
|
+
});
|