@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.
Files changed (106) hide show
  1. package/README.md +82 -19
  2. package/bin/cli.mjs +69 -9
  3. package/docs/CONFIGURATION.md +48 -0
  4. package/docs/SEMANTIC.md +3 -3
  5. package/lib/api-standalone.js +78 -12
  6. package/lib/api.js +339 -59
  7. package/lib/candidates.js +454 -454
  8. package/lib/client.js +669 -256
  9. package/lib/config.js +50 -2
  10. package/lib/dream/sleep.js +35 -12
  11. package/lib/dream.js +86 -14
  12. package/lib/embedding.js +153 -22
  13. package/lib/index.js +32 -0
  14. package/lib/inject.js +34 -3
  15. package/lib/lang.js +124 -0
  16. package/lib/pipeline-activities.js +178 -0
  17. package/lib/pipeline-attribution.js +165 -0
  18. package/lib/pipeline-command.js +279 -0
  19. package/lib/pipeline-confirm.js +142 -0
  20. package/lib/pipeline-exec.js +174 -0
  21. package/lib/pipeline-nudge.js +1281 -0
  22. package/lib/pipeline-observations.js +181 -0
  23. package/lib/pipeline-observe.js +133 -0
  24. package/lib/pipeline-runner.js +346 -0
  25. package/lib/pipeline-runs.js +442 -0
  26. package/lib/pipeline.js +128 -7
  27. package/lib/scope-state.js +277 -0
  28. package/lib/semantic.js +66 -10
  29. package/lib/service.js +80 -3
  30. package/lib/settings.js +139 -1
  31. package/lib/store.js +216 -3
  32. package/lib/summarize.js +2 -3
  33. package/lib/tools.js +71 -0
  34. package/package.json +3 -3
  35. package/scripts/changelog-prep.mjs +62 -7
  36. package/scripts/check-sync.js +8 -6
  37. package/scripts/release-docs.mjs +204 -0
  38. package/scripts/release-notes.mjs +162 -0
  39. package/src/api-standalone.js +78 -12
  40. package/src/api.js +339 -59
  41. package/src/candidates.js +454 -454
  42. package/src/config.js +50 -2
  43. package/src/dream/sleep.js +35 -12
  44. package/src/dream.js +86 -14
  45. package/src/embedding.js +153 -22
  46. package/src/index.js +32 -0
  47. package/src/inject.js +34 -3
  48. package/src/lang.js +124 -0
  49. package/src/pipeline-activities.js +178 -0
  50. package/src/pipeline-attribution.js +165 -0
  51. package/src/pipeline-command.js +279 -0
  52. package/src/pipeline-confirm.js +142 -0
  53. package/src/pipeline-exec.js +174 -0
  54. package/src/pipeline-nudge.js +1281 -0
  55. package/src/pipeline-observations.js +181 -0
  56. package/src/pipeline-observe.js +133 -0
  57. package/src/pipeline-runner.js +346 -0
  58. package/src/pipeline-runs.js +442 -0
  59. package/src/pipeline.js +128 -7
  60. package/src/scope-state.js +277 -0
  61. package/src/semantic.js +66 -10
  62. package/src/service.js +80 -3
  63. package/src/settings.js +139 -1
  64. package/src/store.js +216 -3
  65. package/src/summarize.js +2 -3
  66. package/src/tools.js +71 -0
  67. package/test/api-hardening.test.js +173 -0
  68. package/test/api.test.js +210 -1
  69. package/test/changelog-prep.test.js +74 -8
  70. package/test/cli-enums.test.js +37 -0
  71. package/test/cli-flag-value.test.js +86 -0
  72. package/test/client.test.js +216 -6
  73. package/test/distill-tx-embed-gap.test.js +110 -0
  74. package/test/docs-config-keys.test.js +65 -0
  75. package/test/dream-maxtokens-fallback.test.js +132 -0
  76. package/test/embed-text-reasons.test.js +112 -0
  77. package/test/helpers/write-admission-samples.js +6 -2
  78. package/test/lib-smoke.test.js +20 -0
  79. package/test/persona-api.test.js +172 -0
  80. package/test/persona-flag.test.js +36 -0
  81. package/test/persona-inject.test.js +145 -0
  82. package/test/persona-settings.test.js +145 -0
  83. package/test/pipeline-activities.test.js +155 -0
  84. package/test/pipeline-attribution.test.js +175 -0
  85. package/test/pipeline-command.test.js +243 -0
  86. package/test/pipeline-confirm.test.js +148 -0
  87. package/test/pipeline-exec.test.js +172 -0
  88. package/test/pipeline-nudge.test.js +1036 -0
  89. package/test/pipeline-observations.test.js +157 -0
  90. package/test/pipeline-observe.test.js +127 -0
  91. package/test/pipeline-r1r2.test.js +149 -0
  92. package/test/pipeline-runner.test.js +442 -0
  93. package/test/pipeline-runs.test.js +767 -0
  94. package/test/pipeline.test.js +137 -6
  95. package/test/readme-claims.test.js +68 -0
  96. package/test/reindex-backfill.test.js +83 -4
  97. package/test/release-docs.test.js +130 -0
  98. package/test/release-notes.test.js +240 -0
  99. package/test/release-workflow.test.js +16 -0
  100. package/test/route-policy.test.js +113 -0
  101. package/test/scope-state.test.js +481 -0
  102. package/test/security-doc.test.js +139 -0
  103. package/test/sensitive-scan.test.js +44 -0
  104. package/test/tools-dto-schema-matrix.test.js +12 -3
  105. package/test/tools.test.js +6 -6
  106. package/test/workflow-guards.test.js +156 -0
@@ -205,3 +205,47 @@ test("长文本上的回溯有上界:写阻塞类回归的计时护栏", () =>
205
205
  assert.ok(ms < 2000, `${text.length} 字符耗时 ${ms.toFixed(1)}ms,超 2000ms 预算(回溯上界被破坏?)`);
206
206
  }
207
207
  });
208
+
209
+ // 仓库文本守卫:语料里的凭据值必须以拼接形式存在,仓库里不得留下完整形状。
210
+ // 触发这件事的是 GitHub secret scanning 的 alert #1——write-admission-samples.js 里
211
+ // openai-key 那条曾经是整串字面量(同文件其它四条都已是 assemble 拼接),被报成
212
+ // openai_api_key 的 open=true 告警。它本身不是泄露(正文是 a-z、0-9 的顺序串),
213
+ // 但扫描器只看形状;而这条漏网说明「逐条记得改」靠不住,所以用一条测试把它钉住:
214
+ // 以后谁再往语料里加字面量,这里先红,不用等 GitHub 来通知。
215
+ test("语料里不留完整形状的凭据字面量(secret scanning alert #1 的回归锁)", async () => {
216
+ const { readFile, readdir } = await import("node:fs/promises");
217
+ const helpersDir = new URL("./helpers/", import.meta.url);
218
+ const files = (await readdir(helpersDir)).filter((f) => f.endsWith(".js"));
219
+ // 前缀集合与长度都对齐 src/sensitive-scan.js 的 github_token / slack_token /
220
+ // openai_key 三条规则——只有形状一致才谈得上「扫描器会认」,前缀写窄了等于给
221
+ // gho_ / ghs_ / xoxp- 这些同族凭据开后门。改那三条规则时这里要跟着改。
222
+ // 唯一放宽的是 sk- 的下限:生产规则是 24,这里用 20,宁可多报——漏报的代价是
223
+ // GitHub 再报一次告警,多报的代价只是有人来把这行改成拼接。
224
+ const rules = [
225
+ // kind 与生产判据对齐,下面的断言拿它反查 scanSensitive 是不是真认这个形状。
226
+ { re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/, what: "OpenAI key", kind: "openai_key", probe: `sk-${"a1".repeat(13)}` },
227
+ { re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b/, what: "GitHub token", kind: "github_token", probe: `ghs_${"a1".repeat(18)}` },
228
+ { re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/, what: "Slack token", kind: "slack_token", probe: `xoxp-${"a1".repeat(8)}` }
229
+ ];
230
+ // 「与生产规则同源」不能只是注释:CodeRabbit 在 #465 指出前一版把前缀写窄了
231
+ // (只认 ghp_ / xoxb-,漏了 gho_/ghs_/xoxp-),注释却写着同源。所以这里反查
232
+ // scanSensitive——守卫的每条规则必须真的被生产判据认成同一个 kind,否则这条红。
233
+ // 生产规则收窄前缀时,这条测试会先于「GitHub 又报一次告警」发现不一致。
234
+ // 两段合起来才叫同源:探针要同时被**守卫的规则**和**生产判据**认下,且 kind 一致。
235
+ // 只查一边的话,任一侧收窄前缀都不会红——那正是 CodeRabbit 指出的失效模式。
236
+ for (const { re, what, kind, probe } of rules) {
237
+ assert.match(probe, re, `守卫自己的 ${what} 规则认不出 ${probe},前缀写窄了?`);
238
+ assert.equal(scanSensitive(probe)?.kind, kind, `生产判据不认 ${probe} 为 ${kind},守卫与它不同源`);
239
+ }
240
+ for (const file of files) {
241
+ const text = await readFile(new URL(file, helpersDir), "utf8");
242
+ for (const { re, what } of rules) {
243
+ const hit = text.match(re);
244
+ assert.equal(
245
+ hit,
246
+ null,
247
+ `${file} 里出现了完整的 ${what} 字面量(${hit?.[0]?.slice(0, 12)}…):按 assemble 拼接,别整串写`
248
+ );
249
+ }
250
+ }
251
+ });
@@ -38,7 +38,8 @@ const TOOL_NAMES = [
38
38
  "memory_forget",
39
39
  "memory_archive",
40
40
  "memory_register_document",
41
- "memory_runtime"
41
+ "memory_runtime",
42
+ "set_scope_state"
42
43
  ];
43
44
 
44
45
  function setup(config = {}) {
@@ -121,7 +122,7 @@ test("工具矩阵与 TOOL_NAMES 清单一致(新增工具必须补进矩阵
121
122
  assert.deepEqual(Object.keys(byName).sort(), [...TOOL_NAMES].sort());
122
123
  });
123
124
 
124
- test("A 实跑:10 个工具每个可安全触达的分支,真实返回 ⊆ 声明的 output schema", async () => {
125
+ test("A 实跑:11 个工具每个可安全触达的分支,真实返回 ⊆ 声明的 output schema", async () => {
125
126
  const { store, service, byName } = setup({ documentMemoryEnabled: true });
126
127
  const shapes = seedShapes(store);
127
128
  const annotated = shapes[shapes.length - 1];
@@ -197,6 +198,14 @@ test("A 实跑:10 个工具每个可安全触达的分支,真实返回 ⊆
197
198
  assert.equal((await drive(byName, "memory_archive", { id: archiveTarget.id, archived: false })).memory.archived, false);
198
199
  note("memory_archive");
199
200
 
201
+ // --- set_scope_state(#471):两个方向的分支——按下与松开各驱动一次 ---
202
+ const pressed = await drive(byName, "set_scope_state", { dimension: "workspace", scope_value: "proj-alpha", state: "paused" });
203
+ assert.equal(pressed.scope.state, "paused");
204
+ assert.equal(pressed.scope.scope_value, "proj-alpha");
205
+ const released = await drive(byName, "set_scope_state", { dimension: "workspace", scope_value: "proj-alpha", state: "active" });
206
+ assert.equal(released.scope.state, "active");
207
+ note("set_scope_state");
208
+
200
209
  // --- memory_runtime:只驱动只读与「无载荷早退」两条分支 ---
201
210
  // provision 会联网下载数十至上百 MB(且可能 adopt 本机载荷、硬链接数以百 MB 计),
202
211
  // verify 命中载荷时会真实加载模型——两者都不属于单元测试该做的事。status 与其
@@ -285,7 +294,7 @@ test("B' search / list / get 三个消费方共用同一份 item schema(#184
285
294
  }
286
295
  });
287
296
 
288
- test("C 结构:全部 10 个工具的 output schema 闭合、required ⊆ properties、每项带 type", () => {
297
+ test("C 结构:全部 11 个工具的 output schema 闭合、required ⊆ properties、每项带 type", () => {
289
298
  const { byName } = setup();
290
299
  for (const name of TOOL_NAMES) {
291
300
  const schema = byName[name].output.schema;
@@ -49,11 +49,11 @@ function walkSchema(node, path, problems) {
49
49
  }
50
50
  }
51
51
 
52
- test("registers ten tools with correct names", () => {
52
+ test("registers eleven tools with correct names", () => {
53
53
  const { registered } = setup();
54
54
  const names = registered.map((t) => t.name).sort();
55
55
  // #230 新增 memory_register_document(agent 产长文档的指针行铸造口)。
56
- assert.deepEqual(names, ["memory_archive", "memory_delete", "memory_forget", "memory_get", "memory_list", "memory_register_document", "memory_runtime", "memory_save", "memory_search", "memory_update"]);
56
+ assert.deepEqual(names, ["memory_archive", "memory_delete", "memory_forget", "memory_get", "memory_list", "memory_register_document", "memory_runtime", "memory_save", "memory_search", "memory_update", "set_scope_state"]);
57
57
  });
58
58
 
59
59
  // v0.8.5 tool-exposure gate: disabling a tool hides it from the model entirely
@@ -62,19 +62,19 @@ test("registers ten tools with correct names", () => {
62
62
  test("disableMemorySearch hides only memory_search", () => {
63
63
  const { registered } = setup({}, { disableMemorySearch: true });
64
64
  const names = registered.map((t) => t.name).sort();
65
- assert.deepEqual(names, ["memory_archive", "memory_delete", "memory_forget", "memory_get", "memory_list", "memory_register_document", "memory_runtime", "memory_save", "memory_update"]);
65
+ assert.deepEqual(names, ["memory_archive", "memory_delete", "memory_forget", "memory_get", "memory_list", "memory_register_document", "memory_runtime", "memory_save", "memory_update", "set_scope_state"]);
66
66
  });
67
67
 
68
68
  test("disableMemoryArchive hides only memory_archive", () => {
69
69
  const { registered } = setup({}, { disableMemoryArchive: true });
70
70
  const names = registered.map((t) => t.name).sort();
71
- assert.deepEqual(names, ["memory_delete", "memory_forget", "memory_get", "memory_list", "memory_register_document", "memory_runtime", "memory_save", "memory_search", "memory_update"]);
71
+ assert.deepEqual(names, ["memory_delete", "memory_forget", "memory_get", "memory_list", "memory_register_document", "memory_runtime", "memory_save", "memory_search", "memory_update", "set_scope_state"]);
72
72
  });
73
73
 
74
74
  test("both disable flags hide both tools", () => {
75
75
  const { registered } = setup({}, { disableMemorySearch: true, disableMemoryArchive: true });
76
76
  const names = registered.map((t) => t.name).sort();
77
- assert.deepEqual(names, ["memory_delete", "memory_forget", "memory_get", "memory_list", "memory_register_document", "memory_runtime", "memory_save", "memory_update"]);
77
+ assert.deepEqual(names, ["memory_delete", "memory_forget", "memory_get", "memory_list", "memory_register_document", "memory_runtime", "memory_save", "memory_update", "set_scope_state"]);
78
78
  });
79
79
 
80
80
  // Description discipline: with all tools exposed, the descriptions must steer a
@@ -90,7 +90,7 @@ test("memory_search/archive descriptions carry tool-use discipline", () => {
90
90
 
91
91
  test("compiled schemas pass the enforced DSH subset (defineTool projection)", () => {
92
92
  const { registered } = setup();
93
- assert.equal(registered.length, 10);
93
+ assert.equal(registered.length, 11);
94
94
  for (const tool of registered) {
95
95
  assertSupportedJsonSchema(tool.parameters);
96
96
  assertSupportedJsonSchema(tool.output.schema);
@@ -0,0 +1,156 @@
1
+ // 工作流配置的回归锁:安全扫描、发布权限、发版准备的注入面不能悄悄退回。
2
+ // 锁的都是「配置漂移」——CI 配置没有类型检查,改错了照样绿。
3
+ import test from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { readFileSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+ import path from "node:path";
8
+
9
+ const repoRoot = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
10
+
11
+ test("security.yml 的 osv-scanner 保持 fail-on-vuln: true", () => {
12
+ // 2026-10 审计:这里原本是 false(纯提醒,PR 上永远是绿的)。改回 false 就等于
13
+ // 「有已知漏洞也不红」,而测试目录原本没有任何东西会察觉这件事。
14
+ const yml = readFileSync(path.join(repoRoot, ".github", "workflows", "security.yml"), "utf8");
15
+ assert.match(yml, /fail-on-vuln:\s*true/, "osv-scanner 必须保持 fail-on-vuln: true");
16
+ assert.doesNotMatch(yml, /fail-on-vuln:\s*false/, "不得退回只上传 SARIF 不拦的档位");
17
+ });
18
+
19
+ // 剥掉整行注释再断言:注释里会**描述**「id-token: write 该发给谁」,
20
+ // 直接对全文 grep 会被自己写的说明文字骗过(第一版就栽在这里)。
21
+ function codeOnly(yml) {
22
+ return yml.split(/\r?\n/).filter((l) => !/^\s*#/.test(l)).join("\n");
23
+ }
24
+
25
+ // 按缩进切出单个 job 的文本块:工作流顶层是 0 缩进,job 名是 2 缩进。
26
+ // 只做文本切片不做完整 YAML 解析——仓库零依赖,为一条断言引 yaml 解析器不划算。
27
+ function jobBlock(yml, name) {
28
+ const lines = codeOnly(yml).split(/\r?\n/);
29
+ const start = lines.findIndex((l) => l === " " + name + ":");
30
+ assert.ok(start >= 0, "找不到 job: " + name);
31
+ let end = lines.length;
32
+ for (let i = start + 1; i < lines.length; i++) {
33
+ if (/^ [A-Za-z0-9_-]+:$/.test(lines[i])) { end = i; break; }
34
+ }
35
+ return lines.slice(start, end).join("\n");
36
+ }
37
+
38
+ test("release.yml 的 provenance 权限只发给 publish,不发给工作流顶层", () => {
39
+ // 三件事分开断言,因为「全文件 grep id-token: write」会被两种错误都骗过:
40
+ // ①有人把 id-token 提到工作流顶层(verify/test 也拿到 OIDC 能力)
41
+ // ②有人把 publish 的权限删了但别的 job 还有(provenance 会失败)
42
+ const yml = readFileSync(path.join(repoRoot, ".github", "workflows", "release.yml"), "utf8");
43
+ assert.match(yml, /npm publish[^\n]*--provenance/, "publish 必须带 --provenance");
44
+
45
+ // YAML 允许把顶层键写成 "jobs": 或 'jobs':,那也是合法的工作流。
46
+ // 只匹配裸 jobs: 会在那种写法下把整个文件当成 head,于是 publish 里合法的
47
+ // id-token 被误判成「顶层声明」——失败方向是误红不是漏检,但假红会磨损这条锁。
48
+ const head = codeOnly(yml).split(/\r?\n(?=["']?jobs["']?:\s*$)/m)[0];
49
+ assert.doesNotMatch(head, /id-token:/, "工作流顶层不得声明 id-token(会泄漏给所有 job)");
50
+
51
+ const publish = jobBlock(yml, "publish");
52
+ assert.match(publish, /id-token:\s*write/, "publish job 需要 id-token: write");
53
+ assert.doesNotMatch(publish, /contents:\s*write/, "publish 只读代码,不需要 contents: write");
54
+
55
+ const release = jobBlock(yml, "release");
56
+ assert.match(release, /contents:\s*write/, "release job 建 Release 需要 contents: write");
57
+ });
58
+
59
+ // 把每个 `run: |` 块的正文切出来。要断言的是「脚本正文里没有模板展开」,
60
+ // 而 run 块之外(env、with、name)出现 ${{ }} 是正常且必需的。
61
+ function runBlocks(yml) {
62
+ const lines = codeOnly(yml).split(/\r?\n/);
63
+ const blocks = [];
64
+ for (let i = 0; i < lines.length; i++) {
65
+ const m = /^(\s*)run:\s*\|\s*$/.exec(lines[i]);
66
+ if (!m) continue;
67
+ const indent = m[1].length;
68
+ let j = i + 1;
69
+ for (; j < lines.length; j++) {
70
+ const line = lines[j];
71
+ if (!line.trim()) continue;
72
+ if (line.match(/^\s*/)[0].length <= indent) break;
73
+ }
74
+ blocks.push(lines.slice(i + 1, j).join("\n"));
75
+ i = j - 1;
76
+ }
77
+ return blocks;
78
+ }
79
+
80
+ test("release-prep.yml 的版本号走 env 注入,不内插进 run 脚本", () => {
81
+ // 2026-10 审计:zizmor 对 `--head "release-prep/${{ inputs.version }}"` 报 template-injection
82
+ // (模板展开可能带进攻击者可控的代码)。虽然上游 Validate 已用 ^[0-9]+\.[0-9]+\.[0-9]+$ 卡过格式,
83
+ // 但这里让扫描器看清「值不经过 shell 重解析」比让它推理上游校验够不够严便宜得多。
84
+ // 退回内插不会有任何测试变红,所以在这里锁住。
85
+ const yml = readFileSync(path.join(repoRoot, ".github", "workflows", "release-prep.yml"), "utf8");
86
+ assert.match(yml, /VERSION:\s*\$\{\{\s*inputs\.version\s*\}\}/, "版本号必须先进 env 再进脚本");
87
+ const blocks = runBlocks(yml);
88
+ assert.ok(blocks.length >= 3, "至少应切出 Validate / Count tests / Create PR 三个 run 块,实际 " + blocks.length);
89
+ for (const block of blocks) {
90
+ assert.doesNotMatch(block, /\$\{\{/, "run 脚本正文里不得出现模板展开:" + block.slice(0, 120));
91
+ }
92
+ });
93
+
94
+ test("release-prep.yml 与 badge:sync 用同一个记账口径(套件总数,不是通过数)", () => {
95
+ // v0.8.11 徽章漂移的同类失效:两条路径各有一套口径时,谁都不知道该信哪个数。
96
+ // 徽章报的是 CI(ubuntu)口径——平台条件跳过的用例在 pass 里不计、在 tests 里计入,
97
+ // 于是从一台有跳过条件的机器上跑发版准备,会写进一个比 badge:sync 小 1 的徽章,
98
+ // 而且它进 PR 时 CI 是绿的(徽章只出现在文档里,没有测试会去比对它)。
99
+ const yml = readFileSync(path.join(repoRoot, ".github", "workflows", "release-prep.yml"), "utf8");
100
+ const blocks = runBlocks(yml);
101
+ const countBlock = blocks.find((b) => /node --test/.test(b));
102
+ assert.ok(countBlock, "应当能切出跑测试的那个 run 块");
103
+ assert.ok(countBlock.includes("tests [0-9]+"), "计数必须取 tests N(与 sync-test-badge.mjs 同口径)");
104
+ assert.ok(!countBlock.includes("pass [0-9]+"), "不得退回 pass N:那是另一套记账口径");
105
+ });
106
+
107
+ test("发版准备的两份人写清单都点出跨 minor 要挪 SECURITY.md 支持版本表", () => {
108
+ // 跨 minor 时 security-doc.test.js 会红,而那时人往往已经忘了这张表——工作流开的 PR
109
+ // 就是最后一道能把话说清楚的时机。commit-msg 与 pr-body 是两份独立的文本,
110
+ // 少一份就等于一半的路径上没有提示(reviewer 只看 PR 正文,git log 只看提交信息)。
111
+ const yml = readFileSync(path.join(repoRoot, ".github", "workflows", "release-prep.yml"), "utf8");
112
+ const hits = yml.match(/SECURITY\.md 中英两张支持版本表/g) || [];
113
+ assert.equal(hits.length, 2, "commit message 与 PR 正文各要有一条,实际 " + hits.length);
114
+ });
115
+
116
+ test("release-prep.yml 的重跑守卫:已有 PR 不动分支,只覆盖机器产出", () => {
117
+ // 2026-10-18 实测:工作流 8 步里前 7 步全成功,最后一步 git push 被
118
+ // "! [rejected] release-prep/0.9.1 -> release-prep/0.9.1 (fetch first)" 打回——
119
+ // 上一次运行已经把同名分支推上去了。不可重入的代价不是「重跑失败」这么轻:
120
+ // 人会以为流程坏了,转去手工发版,整套自动化就此被绕开。
121
+ //
122
+ // 但「能重跑」不能退化成「无条件 force push」:分支上可能已经有人补过 CHANGELOG
123
+ // 条目,那正是这套流程留给人的那部分工作,覆盖掉比不能重跑更糟。所以守卫分三档:
124
+ // 有进行中的 PR → 什么都不动;顶端是机器产出且没有 PR → 按重跑覆盖;其他 → 停下要人看。
125
+ const yml = readFileSync(path.join(repoRoot, ".github", "workflows", "release-prep.yml"), "utf8");
126
+ const blocks = runBlocks(yml);
127
+ const pushBlock = blocks.find((b) => /git push/.test(b));
128
+ assert.ok(pushBlock, "应当能切出含 git push 的那个 run 块");
129
+
130
+ const prLookup = pushBlock.indexOf('--state open');
131
+ const pushAt = pushBlock.indexOf("git push");
132
+ assert.ok(prLookup > 0, "重跑前必须查有没有进行中的 PR(gh pr list --state open)");
133
+ assert.ok(prLookup < pushAt, "查 PR 必须发生在 push 之前");
134
+
135
+ // 三档判定:没有 PR 才继续;继续时顶端提交必须是本工作流生成的。
136
+ assert.ok(pushBlock.includes("--force-with-lease="), "重跑覆盖必须用 --force-with-lease(锁住 fetch 到的 SHA)");
137
+ assert.ok(!/git push[^\n]*--force(?!-with-lease)/.test(pushBlock), "不得退回无条件 --force");
138
+ // 断言的是那个 if 本身,不是变量名:变量名留在 case 里、判定被删掉,
139
+ // 只查 includes("MACHINE_TAIL") 照样绿(第一版就是这么漏的)。
140
+ assert.match(pushBlock, /if \[ "\$MACHINE_TAIL" != "yes" \]; then/, "必须有「顶端不是机器产出就停下」的那道判定");
141
+ });
142
+
143
+ test("release-prep.yml 开 PR 失败时给的是可行动的报错,不是裸 GraphQL 报错", () => {
144
+ // 第二个真因(2026-10-18 实测):仓库级开关 can_approve_pull_request_reviews=false,
145
+ // job 里写 pull-requests: write 覆盖不了它,最后一步报
146
+ // "GitHub Actions is not permitted to create or approve pull requests"。
147
+ // 这个开关在**组织层**默认关着时仓库层根本改不动(gh api 返回 409 Conflict),
148
+ // 所以要让人一眼看出该去哪里开、开完能不能直接重跑。
149
+ const yml = readFileSync(path.join(repoRoot, ".github", "workflows", "release-prep.yml"), "utf8");
150
+ // 只在**可执行部分**里找:这几句话在注释里也出现过(注释是在解释为什么),
151
+ // 对全文 grep 会被自己写的说明文字骗过——报错文案被删干净了照样绿。
152
+ const code = codeOnly(yml);
153
+ assert.match(code, /not permitted to create or approve pull requests/, "报错文案要点名这个具体的 GitHub 报错,人才搜得到");
154
+ assert.match(code, /Settings → Actions → General/, "必须写清去哪个设置页开");
155
+ assert.match(code, /Allow GitHub Actions to create and approve pull requests/, "必须写清要打开的那个开关叫什么");
156
+ });