@modusensus/dsh-mneme 0.9.3 → 0.9.5

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 (102) hide show
  1. package/README.md +56 -10
  2. package/bin/cli.mjs +215 -2
  3. package/docs/CONFIGURATION.md +10 -2
  4. package/docs/DAEMON.md +9 -2
  5. package/docs/DISTILL.md +66 -4
  6. package/docs/SHARE.md +552 -0
  7. package/docs/SLEEP.md +1 -1
  8. package/docs/STORAGE.md +1 -1
  9. package/lib/api-standalone.js +286 -8
  10. package/lib/api.js +254 -3
  11. package/lib/client.js +1000 -50
  12. package/lib/config.js +50 -1
  13. package/lib/document.js +15 -2
  14. package/lib/dream/decisions.js +88 -3
  15. package/lib/dream/sleep.js +63 -13
  16. package/lib/dream.js +66 -10
  17. package/lib/index.js +6 -1
  18. package/lib/inject.js +10 -25
  19. package/lib/lang.js +48 -6
  20. package/lib/memory-source.js +9 -0
  21. package/lib/organize.js +9 -3
  22. package/lib/pipeline.js +62 -1
  23. package/lib/profile-extract.js +161 -0
  24. package/lib/provenance-http.js +126 -0
  25. package/lib/serve.js +13 -0
  26. package/lib/service.js +70 -6
  27. package/lib/settings.js +17 -13
  28. package/lib/share-http.js +281 -0
  29. package/lib/share-import.js +234 -0
  30. package/lib/share.js +335 -0
  31. package/lib/store.js +272 -11
  32. package/lib/summarize.js +118 -8
  33. package/lib/tools.js +240 -1
  34. package/lib/user-settings.js +78 -0
  35. package/package.json +1 -1
  36. package/src/api-standalone.js +286 -8
  37. package/src/api.js +254 -3
  38. package/src/config.js +50 -1
  39. package/src/document.js +15 -2
  40. package/src/dream/decisions.js +88 -3
  41. package/src/dream/sleep.js +63 -13
  42. package/src/dream.js +66 -10
  43. package/src/index.js +6 -1
  44. package/src/inject.js +10 -25
  45. package/src/lang.js +48 -6
  46. package/src/memory-source.js +9 -0
  47. package/src/organize.js +9 -3
  48. package/src/pipeline.js +62 -1
  49. package/src/profile-extract.js +161 -0
  50. package/src/provenance-http.js +126 -0
  51. package/src/serve.js +13 -0
  52. package/src/service.js +70 -6
  53. package/src/settings.js +17 -13
  54. package/src/share-http.js +281 -0
  55. package/src/share-import.js +234 -0
  56. package/src/share.js +335 -0
  57. package/src/store.js +272 -11
  58. package/src/summarize.js +118 -8
  59. package/src/tools.js +240 -1
  60. package/src/user-settings.js +78 -0
  61. package/test/api.test.js +384 -3
  62. package/test/audit.test.js +2 -2
  63. package/test/cli-receive.test.js +390 -0
  64. package/test/cli-share.test.js +437 -0
  65. package/test/client-share.test.js +225 -0
  66. package/test/client.test.js +215 -6
  67. package/test/dedupe-candidate-window.test.js +180 -0
  68. package/test/dream-candidate-hybrid.test.js +2 -2
  69. package/test/dream-fence.test.js +164 -0
  70. package/test/dream.test.js +87 -5
  71. package/test/helpers/dream-mock.js +6 -2
  72. package/test/lang.test.js +11 -0
  73. package/test/llm-message-source.test.js +1 -1
  74. package/test/mint-source.test.js +1 -1
  75. package/test/organize-tool.test.js +232 -0
  76. package/test/persona-addressing.test.js +83 -0
  77. package/test/persona-settings.test.js +30 -8
  78. package/test/pipeline-r1r2.test.js +129 -0
  79. package/test/policy-epoch.test.js +2 -2
  80. package/test/profile-extract.test.js +269 -0
  81. package/test/provenance-http.test.js +184 -0
  82. package/test/readme-claims.test.js +45 -0
  83. package/test/reasoning-effort.test.js +10 -10
  84. package/test/scope-candidates.test.js +7 -4
  85. package/test/scope-state.test.js +130 -1
  86. package/test/serve.test.js +62 -0
  87. package/test/share-foreign-rows.test.js +144 -0
  88. package/test/share-http.test.js +144 -0
  89. package/test/share-import.test.js +577 -0
  90. package/test/share-imported-guard.test.js +311 -0
  91. package/test/share-imported-identity.test.js +111 -0
  92. package/test/share-pack.test.js +511 -0
  93. package/test/share-receive-http.test.js +756 -0
  94. package/test/share-routes.test.js +337 -0
  95. package/test/share-store.test.js +133 -0
  96. package/test/share-validate.test.js +101 -0
  97. package/test/standalone-api.test.js +524 -5
  98. package/test/summarize-parse-diagnostics.test.js +75 -0
  99. package/test/summarize.test.js +240 -2
  100. package/test/tools-dto-schema-matrix.test.js +48 -5
  101. package/test/tools.test.js +6 -1
  102. package/test/user-settings-preview.test.js +127 -0
@@ -0,0 +1,437 @@
1
+ // CLI `share` 的端到端覆盖:真 spawn `bin/cli.mjs`,打到一个顶替 Bearer 数据面的假 HTTP
2
+ // 服务上(127.0.0.1、端口 0,不要求本机开着 DSH,也不出环回)。
3
+ //
4
+ // 为什么要有这个文件:`cmdShare`/`printShare`/`expandHome` 约 78 行在 A7 里只被一次性探针
5
+ // 手工跑过(task-A7-report §5 的 concerned 第 1 条),bin/cli.mjs 对外只 export parseArgv
6
+ // 那几个解析件,命令本体从 `node --test` 够不着。codecov 看的是补丁覆盖率,这条路径没有
7
+ // 锁就等于「下一个改动它的人只能再手跑一遍」。
8
+ //
9
+ // 为什么假服务不自己编响应体:答复由 **出货的那一层**生成(parseShareBody + shareReply,
10
+ // 就是 api-standalone.js 里挂 /share 的同一对函数),rows 是假的、协议是真的。于是
11
+ // `kind` / `too-large` / `hint` / `bytes` 这些字段一漂移,这里当场红 —— 手写字面量的假服务
12
+ // 只会把「测试自己编的形状」锁成契约。数据面自身的口径另有 share-routes.test.js 与
13
+ // share-http.test.js 两处锁,本文件只管 CLI 这张嘴。
14
+ //
15
+ // spawn 必须异步:服务跑在本进程里,spawnSync 会堵住事件循环,CLI 只能等到自己 10s 超时
16
+ // (maintenance.test.js:23 那条教训,同一个坑不再踩一次)。
17
+ import test from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { spawn } from "node:child_process";
20
+ import { createServer } from "node:http";
21
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { dirname, join, resolve } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import { parseShareBody, shareReply } from "../src/share-http.js";
26
+
27
+ const CLI = resolve(dirname(fileURLToPath(import.meta.url)), "..", "bin", "cli.mjs");
28
+ const TOKEN = "cli-share-test-token";
29
+ const PACKAGE_VERSION = "0.9.4";
30
+ // 打包侧拿来遮正文里 home 前缀的入参(share.js 的 createHomeRedactor),跟 os.homedir() 无关:
31
+ // 子进程的 HOME 被下面的 scratch 顶掉了,两者刻意不是同一个目录。
32
+ const SOURCE_HOME = "/fake/home/alex";
33
+ const NOW = () => new Date("2026-10-22T00:00:00.000Z");
34
+
35
+ const row = (over) => ({
36
+ id: "r", type: "decision", title: "t", content: "c", tags: [], importance: 3,
37
+ archived: 0, forgotten: 0, created_at: "2026-10-01T00:00:00.000Z",
38
+ updated_at: "2026-10-01T00:00:00.000Z", content_hash: "h", ...over
39
+ });
40
+
41
+ // 两条进包 + 一条归档(archived)+ 一条 summary(excluded_type):跳过清单里两种原因都要
42
+ // 有实物,printShare 那六档命名才不会退化成「只测了 happy path 的计数器」。
43
+ const THIN_ROWS = [
44
+ row({ id: "1", type: "decision", title: "选 SQLite", content: "存储层用 node:sqlite", tags: ["存储"], importance: 4 }),
45
+ row({ id: "2", type: "preference", title: "浅色主题", content: "界面默认浅色" }),
46
+ row({ id: "3", type: "project", title: "归档项", content: "旧决定", archived: 1 }),
47
+ row({ id: "4", type: "summary", title: "会话摘要", content: "摘出来的正文" })
48
+ ];
49
+
50
+ // 413 只由**最终序列化字节**决定,水位 MAX_PACK_BYTES = 3 MiB 是硬编码、没有注入缝,所以这
51
+ // 一条只能靠体积本身撞上去(1.2M 个中文字 ≈ 3.4 MB UTF-8)。假服务不自己回 413。
52
+ const FAT_ROWS = [row({ id: "f", title: "长正文", content: "字".repeat(1_200_000) })];
53
+
54
+ /** 顶替数据面的假服务:认 POST /share、验 Bearer,其余按 api-standalone 的口径回错码。 */
55
+ async function startPlane() {
56
+ const state = { rows: THIN_ROWS, requests: [], lastStatus: null, lastBodyText: "" };
57
+ const server = createServer((req, res) => {
58
+ const chunks = [];
59
+ req.on("data", (c) => chunks.push(c));
60
+ req.on("end", () => {
61
+ const text = Buffer.concat(chunks).toString("utf8");
62
+ const { pathname } = new URL(req.url, "http://127.0.0.1");
63
+ state.requests.push({ method: req.method, pathname, auth: req.headers.authorization ?? "", body: text });
64
+ let out;
65
+ try {
66
+ if (req.method !== "POST" || pathname !== "/share") out = { status: 404, body: { error: "not-found" } };
67
+ else if (req.headers.authorization !== `Bearer ${TOKEN}`) out = { status: 401, body: { error: "unauthorized" } };
68
+ else {
69
+ const parsed = parseShareBody(text);
70
+ out = parsed.ok
71
+ ? shareReply({ service: { all: () => state.rows }, packageVersion: PACKAGE_VERSION, homeDir: SOURCE_HOME, now: NOW, options: parsed.options })
72
+ : { status: 400, body: { error: parsed.error } };
73
+ }
74
+ } catch (err) {
75
+ // 漏这一兜,异常就顺着 socket 变成「一个字节都没回」,现场只剩 CLI 的 10s 超时。
76
+ out = { status: 500, body: { error: "fixture-throw", detail: String(err) } };
77
+ }
78
+ state.lastStatus = out.status;
79
+ state.lastBodyText = JSON.stringify(out.body);
80
+ res.writeHead(out.status, { "content-type": "application/json" });
81
+ res.end(state.lastBodyText);
82
+ });
83
+ });
84
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
85
+ const port = server.address().port;
86
+ return {
87
+ url: `http://127.0.0.1:${port}`,
88
+ state,
89
+ overLimit() { state.rows = FAT_ROWS; },
90
+ // 终端那格要看得懂「标题为什么是空的」,得有一批含凭据的被跳过行;默认那批
91
+ // THIN_ROWS 被好几条用例共用作计数基准,不能为了这一格改它。
92
+ use(rows) { state.rows = rows; },
93
+ close: () => new Promise((r) => { server.closeIdleConnections?.(); server.close(r); })
94
+ };
95
+ }
96
+
97
+ /**
98
+ * 每个用例一套临时目录:`home` 顶替子进程的 HOME(tilde 展开与 cli.json 都落在这里),
99
+ * `out` 是产物目录 —— 「干跑不许写文件」就断言这个目录空。
100
+ */
101
+ function scratch() {
102
+ const root = mkdtempSync(join(tmpdir(), "mneme-cli-share-"));
103
+ const home = join(root, "home");
104
+ const out = join(root, "out");
105
+ mkdirSync(home, { recursive: true });
106
+ mkdirSync(out, { recursive: true });
107
+ return {
108
+ home, out,
109
+ cleanup() {
110
+ try { rmSync(root, { recursive: true, force: true }); } catch { /* win 偶发文件锁,残留临时目录无害 */ }
111
+ }
112
+ };
113
+ }
114
+
115
+ async function withPlane(fn) {
116
+ const plane = await startPlane();
117
+ const s = scratch();
118
+ try {
119
+ return await fn({
120
+ plane, home: s.home, out: s.out, url: plane.url, token: TOKEN,
121
+ auth: ["--url", plane.url, "--token", TOKEN]
122
+ });
123
+ } finally {
124
+ s.cleanup();
125
+ await plane.close();
126
+ }
127
+ }
128
+
129
+ /**
130
+ * HOME/USERPROFILE 一起改:os.homedir() 先读 HOME、Windows 再读 USERPROFILE,子进程里的
131
+ * `~` 与 `~\` 才都展开进临时目录(断言失败时也不会有产物落进真人 home)。
132
+ * 同时把 cwd 放到临时目录:本机那份 ~/.dsh-mneme/cli.json 不参与,产物也只进 scratch。
133
+ *
134
+ * DSH_MNEME_URL/TOKEN 给的是 **fixture 那一面**,不是清空。清空会顺着
135
+ * `firstNonEmpty(flag, env, file) ?? DEFAULT_URL` 落到 `DEFAULT_URL = 127.0.0.1:8790`,
136
+ * 那正是她本机活实例的默认端口:今天每个调用点都显式带 --url/--token 所以打不到,但那是
137
+ * 「暂时没漏」而不是「漏不到」。指到 fixture 之后,未来少带一对旗标的调用点打的是本测试
138
+ * 自己起的那面;那种错会变成断言当场红,而不是拿她的活库跑测试、还因为只读路由回 401
139
+ * 而表现成「这条用例偶尔红」。
140
+ */
141
+ function runCli(ctx, args, extraEnv = {}) {
142
+ return new Promise((res, rej) => {
143
+ const child = spawn(process.execPath, [CLI, ...args], {
144
+ cwd: ctx.out,
145
+ env: { ...process.env, HOME: ctx.home, USERPROFILE: ctx.home, DSH_MNEME_URL: ctx.url, DSH_MNEME_TOKEN: ctx.token, ...extraEnv },
146
+ stdio: ["ignore", "pipe", "pipe"]
147
+ });
148
+ let stdout = "";
149
+ let stderr = "";
150
+ child.stdout.on("data", (d) => { stdout += d; });
151
+ child.stderr.on("data", (d) => { stderr += d; });
152
+ child.on("error", rej);
153
+ child.on("exit", (code) => res({ code, stdout, stderr }));
154
+ });
155
+ }
156
+
157
+ const listFiles = (dir) => readdirSync(dir).sort();
158
+
159
+ test("CLI share --dry-run:报数并解释没写文件,输出目录一个字节不多", { timeout: 60000 }, async () => {
160
+ await withPlane(async (ctx) => {
161
+ const dry = await runCli(ctx, ["share", "--dry-run", ...ctx.auth]);
162
+ assert.equal(dry.code, 0, dry.stderr);
163
+ assert.match(dry.stdout, /^干跑报告: 2 条$/m);
164
+ assert.match(dry.stdout, /跳过: 归档 1, 不随包走 1/);
165
+ assert.match(dry.stdout, /- \[archived\] 归档项/);
166
+ assert.match(dry.stdout, /- \[excluded_type\] 会话摘要/);
167
+ assert.match(dry.stdout, /没写文件:加 --out <路径> 才会落盘/);
168
+ assert.ok(!dry.stdout.includes("已写出"), "干跑不许报落盘");
169
+ assert.deepEqual(listFiles(ctx.out), [], "干跑不许产出任何文件");
170
+
171
+ // 同时给 --dry-run 与 --out:偏好干跑,那个路径也不许被创建(cmdShare 的 `if (dryRun) return`
172
+ // 排在写盘之前)。这一条锁的是「安全的那一侧」:脚本以为有文件会拿到 none,但不能反过来
173
+ // ——带了 --dry-run 却动了盘。
174
+ const both = join(ctx.out, "must-not-exist.json");
175
+ const combo = await runCli(ctx, ["share", "--dry-run", "--out", both, ...ctx.auth]);
176
+ assert.equal(combo.code, 0, combo.stderr);
177
+ assert.ok(!existsSync(both), "--dry-run 在场时 --out 不得落盘");
178
+ assert.deepEqual(listFiles(ctx.out), []);
179
+ });
180
+ });
181
+
182
+ test("漏传 --url/--token 的调用点打到的是 fixture 那一面,不是她本机 8790", { timeout: 60000 }, async () => {
183
+ await withPlane(async (ctx) => {
184
+ // 这条锁的是 runCli 那份 env,不是 CLI 的行为:DSH_MNEME_URL/TOKEN 给的是本测试自己
185
+ // 起的端口,所以「未来某个调用点忘了带旗标」落在这里是当场可看见的一次请求,而不是
186
+ // 悄悄打到 `DEFAULT_URL = 127.0.0.1:8790`(她本机活实例的默认端口,只读面回 401、
187
+ // 老构建回 404,表现成「这条用例偶尔红」)。清空那两个变量才是这里要防的回归。
188
+ const before = ctx.plane.state.requests.length;
189
+ const run = await runCli(ctx, ["share", "--dry-run"]);
190
+ assert.equal(run.code, 0, run.stderr);
191
+ assert.equal(ctx.plane.state.requests.length, before + 1, "请求要落到 fixture 这一面");
192
+ assert.deepEqual(listFiles(ctx.out), []);
193
+ });
194
+ });
195
+
196
+ test("CLI 的跳过清单:含凭据那行不给标题,终端要写得像「有意不给」", { timeout: 60000 }, async () => {
197
+ await withPlane(async (ctx) => {
198
+ // 这条锁的是终端那一格:src/share.js 在源头把命中密钥判据的行的标题留空,skip_list
199
+ // 于是只剩原因。空标题直接打出来是一行尾随空格,读的人会当成「这条本来没标题」,
200
+ // 而它恰恰是最需要被看见的那一类拒绝。
201
+ const secret = "P@ssw0rd-for-prod-db";
202
+ ctx.plane.use([
203
+ row({ id: "1", type: "project", title: "干净的归档行", content: "没有凭据", archived: 1 }),
204
+ row({ id: "2", type: "decision", title: `生产库密码 ${secret} 别丢`, content: `password: ${secret}`, archived: 1 })
205
+ ]);
206
+ const dry = await runCli(ctx, ["share", "--dry-run", ...ctx.auth]);
207
+ assert.equal(dry.code, 0, dry.stderr);
208
+ assert.match(dry.stdout, /^干跑报告: 0 条$/m);
209
+ assert.match(dry.stdout, /- \[archived\] 干净的归档行/);
210
+ assert.match(dry.stdout, /- \[archived\] (标题不外发)/);
211
+ // 终端与落盘是同一份 report 的两张嘴:这里也不许出现那个值。
212
+ assert.ok(!dry.stdout.includes(secret), "CLI 不许把凭据标题打到终端");
213
+ assert.ok(!dry.stderr.includes(secret), "错误面同样不许");
214
+ });
215
+ });
216
+
217
+ // 那句「跳过清单已截断」的判据。旧写法拿**清单长度**当证据(`skip_list.length >= 50`),
218
+ // 而清单长度是被服务端截过的读数:恰好 50 条被跳时清单是全量的,那句话在此刻撒谎,
219
+ // 而 50 这个数在 CLI 里是抄来的第二份口径(水位一改就错)。真证据一直在场:
220
+ // skipped 的六个计数走全量。判据因此是「账比清单长」,与面板 ShareReport 同一条
221
+ // (lib/client.js 的 totalSkipped > list.length)。
222
+ // 两格各咬一个方向,缺一不可:
223
+ // ① 恰好 50(清单=账=水位)→ 不许报截断。旧判据在这一格红,本条就是它的行为锁;
224
+ // ② 55 条被跳(账 55 > 清单 50)→ 必须报。这一格旧判据也报,防的是「把提示干脆删掉」
225
+ // 冒充修复——删掉提示的人只会看见这一条红。
226
+ test("CLI 那句「已截断」由六个计数之和判定,恰好等于水位时不报", { timeout: 60000 }, async () => {
227
+ await withPlane(async (ctx) => {
228
+ // 水位住在服务端(src/share.js 的 SHARE_SKIP_LIST_MAX),这里只从它自己的答复里读,
229
+ // 不在测试里再抄一个 50:抄了就又是两份口径,测试会跟着水位一起漂。
230
+ const shareText = readFileSync(resolve(dirname(CLI), "..", "src", "share.js"), "utf8");
231
+ const cap = Number(shareText.match(/SHARE_SKIP_LIST_MAX = (\d+)/)?.[1]);
232
+ assert.ok(Number.isInteger(cap) && cap > 0, "src/share.js 的水位要读得出来");
233
+ const archivedRows = (n, tag) => Array.from({ length: n }, (_, i) =>
234
+ row({ id: `${tag}${i}`, type: "decision", title: `归档行 ${tag}${i}`, content: `正文 ${tag}${i}`, archived: 1 }));
235
+
236
+ // ①:恰好到水位——清单全量,没有东西被截掉。
237
+ ctx.plane.use(archivedRows(cap, "a"));
238
+ const atCap = await runCli(ctx, ["share", "--dry-run", ...ctx.auth]);
239
+ assert.equal(atCap.code, 0, atCap.stderr);
240
+ assert.match(atCap.stdout, new RegExp(`跳过: 归档 ${cap}`), "计数要走全量");
241
+ assert.ok(!atCap.stdout.includes("跳过清单已截断"),
242
+ `清单长度恰好等于水位时不许报截断,got: ${atCap.stdout}`);
243
+
244
+ // ②:账比清单长——这一刻清单确实被截了,必须说。
245
+ ctx.plane.use(archivedRows(cap + 5, "b"));
246
+ const overCap = await runCli(ctx, ["share", "--dry-run", ...ctx.auth]);
247
+ assert.equal(overCap.code, 0, overCap.stderr);
248
+ assert.match(overCap.stdout, new RegExp(`跳过: 归档 ${cap + 5}`), "计数仍是全量");
249
+ assert.match(overCap.stdout, /跳过清单已截断,计数仍为全量/, "账比清单长时这句提示必须在");
250
+ });
251
+ });
252
+
253
+ test("CLI 报得出包内折叠:included 少的那几条要有去处", { timeout: 60000 }, async () => {
254
+ await withPlane(async (ctx) => {
255
+ // 同标题、同正文、同内容锚而 scope 不同的两条在库里是两条合法记忆,在包里共用一把
256
+ // external_id,于是出包侧折成一条。终端只报 `干跑报告: 1 条` 的话,读的人以为自己
257
+ // 少发了 —— 这一格要说清那条去哪了(src/share.js 的 report.deduped)。
258
+ ctx.plane.use([
259
+ row({ id: "1", type: "decision", title: "同一个标题", content: "同一份正文", content_hash: "same-hash", agent_scope: "preset-a" }),
260
+ row({ id: "2", type: "decision", title: "同一个标题", content: "同一份正文", content_hash: "same-hash", agent_scope: "preset-b" })
261
+ ]);
262
+ const dry = await runCli(ctx, ["share", "--dry-run", ...ctx.auth]);
263
+ assert.equal(dry.code, 0, dry.stderr);
264
+ assert.match(dry.stdout, /^干跑报告: 1 条$/m);
265
+ assert.match(dry.stdout, /包内重复已折叠: 1 条/);
266
+ assert.match(dry.stdout, /跳过: 无/, "折叠不走跳过那本账");
267
+ assert.deepEqual(JSON.parse(ctx.plane.state.lastBodyText).report.skip_list, []);
268
+ });
269
+ });
270
+
271
+ test("CLI share --out:数据面答复逐字落盘,解析回来 kind 与 report 都对", { timeout: 60000 }, async () => {
272
+ await withPlane(async (ctx) => {
273
+ const dest = join(ctx.out, "pack.json");
274
+ const run = await runCli(ctx, ["share", "--out", dest, ...ctx.auth]);
275
+ assert.equal(run.code, 0, run.stderr);
276
+ assert.match(run.stdout, /^分享包: 2 条$/m);
277
+ assert.ok(run.stdout.includes(`已写出: ${dest}`), run.stdout);
278
+ assert.equal(ctx.plane.state.requests.at(-1).auth, `Bearer ${TOKEN}`, "Bearer 头得真的带上,否则这条只是本地写文件");
279
+
280
+ const text = readFileSync(dest, "utf8");
281
+ // 逐字相同:CLI 只做 JSON.parse → JSON.stringify 的往返,不许顺手美化、加换行或改键序
282
+ // ——这份文件是要原样 POST 给对面的(车道 C),多一个空格对面重算的锚就对不上。
283
+ assert.equal(text, ctx.plane.state.lastBodyText);
284
+ const pack = JSON.parse(text);
285
+ assert.equal(pack.kind, "mneme.share/v1");
286
+ assert.equal(pack.report.included, 2);
287
+ assert.equal(pack.alias, "", "没给 --alias 时包里是空串,不是 undefined");
288
+ });
289
+ });
290
+
291
+ test("CLI share 不带 --out 也不带 --dry-run:先解释再退出,一个请求都不发", { timeout: 60000 }, async () => {
292
+ await withPlane(async (ctx) => {
293
+ const run = await runCli(ctx, ["share", ...ctx.auth]);
294
+ assert.notEqual(run.code, 0, "整库答复直接打 stdout 会把报告淹掉,这条必须拒");
295
+ assert.match(run.stderr, /要么写文件(--out <路径>),要么只看报告(--dry-run)/);
296
+ assert.ok(run.stderr.includes("dsh-mneme share --out ~/Desktop/pack.json"),
297
+ `拒绝里要带一条能照抄的例子,got: ${run.stderr}`);
298
+ assert.deepEqual(ctx.plane.state.requests, [], "拒绝发生在请求之前:不许先打一轮再拒");
299
+ assert.deepEqual(listFiles(ctx.out), [], "拒绝路径不留文件");
300
+ });
301
+ });
302
+
303
+ test("CLI share --out 的 `~` 前缀:展开到子进程 home(PowerShell/cmd 不会替她展开)", { timeout: 60000 }, async () => {
304
+ await withPlane(async (ctx) => {
305
+ // Git Bash 会展开 `~/…`,PowerShell 与 cmd 给到 CLI 的是字面量 —— USAGE 里的例子正是
306
+ // 这一串,所以 expandHome 是那个例子在 Windows 上能不能用的全部区别。
307
+ const NAMES = ["dsh-mneme-cli-share-tilde-probe.json", "dsh-mneme-cli-share-tilde-probe-win.json"];
308
+ const tilde = await runCli(ctx, ["share", "--out", `~/${NAMES[0]}`, ...ctx.auth]);
309
+ assert.equal(tilde.code, 0, `tilde 展开失败就是 writeFileSync 的 ENOENT 堆栈:${tilde.stderr}`);
310
+ assert.ok(existsSync(join(ctx.home, NAMES[0])), `${NAMES[0]} 应落在子进程 home 里,got: ${tilde.stdout}`);
311
+ assert.ok(tilde.stdout.includes(NAMES[0]) && !tilde.stdout.includes(`~/${NAMES[0]}`),
312
+ `打印的应是展开后的路径,got: ${tilde.stdout}`);
313
+
314
+ const backslash = await runCli(ctx, ["share", "--out", `~\\${NAMES[1]}`, ...ctx.auth]);
315
+ assert.equal(backslash.code, 0, backslash.stderr);
316
+ assert.ok(existsSync(join(ctx.home, NAMES[1])), `${NAMES[1]}:反斜杠形态同一条规则(expandHome 认 ~\\)`);
317
+
318
+ // 绝对路径不受影响:expandHome 只改 `~`/`~/`/`~\` 开头,别的原样交给 writeFileSync。
319
+ const abs = join(ctx.out, "abs.json");
320
+ const plain = await runCli(ctx, ["share", "--out", abs, ...ctx.auth]);
321
+ assert.equal(plain.code, 0, plain.stderr);
322
+ assert.ok(existsSync(abs) && !existsSync(join(ctx.home, "abs.json")));
323
+ // 这里**不**留「万一 HOME 覆盖没生效就去真人 home 里把这两个名字擦掉」的兜底:那个
324
+ // rmSync 的靶子是 os.homedir(),只有在测试本来就该红(覆盖失效)的情况下才会动她家目录,
325
+ // 而它删的是「同名」不是「本测试刚写的那个 inode」。HOME 覆盖有没有生效由上面两条
326
+ // existsSync(join(ctx.home, …)) 负责——红了就停手,产物留在临时 home 里等她自己看。
327
+ });
328
+ });
329
+
330
+ test("CLI share 撞 413:拒答体自带的 hint 与两个字节数走 failHttp 转出来", { timeout: 60000 }, async () => {
331
+ await withPlane(async (ctx) => {
332
+ ctx.plane.overLimit();
333
+ const dest = join(ctx.out, "never.json");
334
+ const run = await runCli(ctx, ["share", "--out", dest, ...ctx.auth]);
335
+ assert.equal(run.code, 1);
336
+ assert.equal(ctx.plane.state.lastStatus, 413, "体积要真撞上去,假服务不许自己编状态码");
337
+ assert.match(run.stderr, /请求失败(HTTP 413):too-large \[\/share\]/);
338
+ // 56f7b06 修的就是这两句:旧 failHttp 只取 data.error,「大了多少」「怎么分批」留在体里
339
+ // 没人看得见 —— 最需要下一步动作的那次拒绝,反而是最没信息量的那次。
340
+ assert.match(run.stderr, /用 --type 或 --tag 分批/);
341
+ assert.match(run.stderr, /量到 \d+(\.\d)? M?B,水位 3\.0 MB/);
342
+ assert.ok(!existsSync(dest), "拒绝路径不许留半个包");
343
+ });
344
+ });
345
+
346
+ test("CLI share --json 撞 413:stdout 保持干净,整份拒答体(含 report)走 stderr", { timeout: 60000 }, async () => {
347
+ await withPlane(async (ctx) => {
348
+ ctx.plane.overLimit();
349
+ const run = await runCli(ctx, ["share", "--json", "--out", join(ctx.out, "never.json"), ...ctx.auth]);
350
+ assert.equal(run.code, 1);
351
+ // --json 的约定:stdout 只有那一份 JSON。拒答走 fail → console.error(rawJson),
352
+ // 所以 stdout 是空的,而 stderr 是可以直接 parse 的原始响应体。
353
+ assert.equal(run.stdout.trim(), "", `--json 的 stdout 不许掺解释行:${run.stdout}`);
354
+ const body = JSON.parse(run.stderr);
355
+ assert.equal(body.error, "too-large");
356
+ assert.ok(body.bytes > body.limitBytes, "两个字节数都在体里,出口才有「大了多少」可说");
357
+ assert.match(body.hint, /分批/);
358
+ assert.equal(body.report.included, 1, "完整报告走 --json:分批的依据就是这份数");
359
+ assert.ok(!("memories" in body), "413 不许把刚拒绝掉的几 MB 再发一遍");
360
+ });
361
+ });
362
+
363
+ test("CLI share --json 成功答复:stdout 是一份能 parse 的 JSON,尾巴不许掺「已写出」", { timeout: 60000 }, async () => {
364
+ await withPlane(async (ctx) => {
365
+ const dest = join(ctx.out, "json-pack.json");
366
+ const written = await runCli(ctx, ["share", "--out", dest, "--json", ...ctx.auth]);
367
+ assert.equal(written.code, 0, written.stderr);
368
+ // A7 报告里实测过的那次 corruption:尾部追一行 已写出 就撞
369
+ // SyntaxError: Unexpected non-whitespace character after JSON(jq 同理)。
370
+ const parsed = JSON.parse(written.stdout);
371
+ assert.deepEqual(Object.keys(parsed).sort(), ["file", "report"]);
372
+ assert.equal(parsed.file, dest, "落盘路径已在 payload 里,那行提示只对人有意义");
373
+ assert.equal(parsed.report.included, 2);
374
+ assert.ok(!written.stdout.includes("已写出"));
375
+ assert.ok(existsSync(dest), "--json 不改变落盘这件事");
376
+
377
+ const dry = await runCli(ctx, ["share", "--dry-run", "--json", ...ctx.auth]);
378
+ assert.equal(dry.code, 0, dry.stderr);
379
+ const dryParsed = JSON.parse(dry.stdout);
380
+ assert.deepEqual(Object.keys(dryParsed).sort(), ["pack", "report"]);
381
+ assert.equal(dryParsed.pack.kind, "mneme.share/v1");
382
+ assert.equal(dryParsed.report.included, 2);
383
+ });
384
+ });
385
+
386
+ test("CLI share --type/--alias/--tag:按数据面认得的形状上线", { timeout: 60000 }, async () => {
387
+ await withPlane(async (ctx) => {
388
+ const run = await runCli(ctx, [
389
+ "share", "--type", "decision,preference", "--alias", "桉桉的库", "--tag", "存储", "--dry-run", ...ctx.auth
390
+ ]);
391
+ assert.equal(run.code, 0, run.stderr);
392
+ assert.deepEqual(JSON.parse(ctx.plane.state.requests.at(-1).body), {
393
+ type: ["decision", "preference"], alias: "桉桉的库", tag: "存储"
394
+ }, "--type 是逗号列表 → 数组;空旗标不该冒出键");
395
+ // 收窄是真的生效(不是发出去没人读):两条里只有带 存储 标签那条进包。
396
+ assert.match(run.stdout, /^干跑报告: 1 条 来自 桉桉的库$/m);
397
+ assert.deepEqual(listFiles(ctx.out), []);
398
+ });
399
+ });
400
+
401
+ // CLI 与 src/share.js 之间没有 import 关系(bin/cli.mjs 是零依赖 HTTP 客户端,那条理由
402
+ // 写在它自己的注释里),所以「六个跳过原因键」在 CLI 侧是抄过去的字面量。抄的东西不会
403
+ // 自己报错:share.js 改一个键名,CLI 就把那一档读成 0(跳过原因在终端上凭空少一类)。
404
+ // 这条静态锁是两份文本之间唯一的接缝——它不重跑逻辑,只比对字面量集合。
405
+ //
406
+ // 「与 50 的截断数同源」那一半是**换掉**而不是删掉:判据从「清单长度到 50」改成「六个
407
+ // 计数之和 > 清单长度」之后,CLI 侧不再有那个数,水位只住在 src/share.js 一处(面板侧
408
+ // lib/client.js 的 ShareReport 同一条取舍,那里也从不写 50)。没有抄本,就没有可比对的
409
+ // 接缝——所以这里改锁两件方向相反的事:
410
+ // ① CLI 不许再出现截断阈值:`).length >= 数字` 那个形状本身就是「恰好一水位就误报」的
411
+ // 旧缺陷,数值对不对这件事永远锁不住,只能靠不出现;
412
+ // ② 那句提示必须由「账比清单长」判定。
413
+ // 判据真不真由上面那条行为用例真跑 CLI 证明(恰好到水位不报、超水位才报),这里只锁形状。
414
+ test("CLI 抄过去的原因键跟 src/share.js 同源,且不硬写截断水位", () => {
415
+ const shareText = readFileSync(resolve(dirname(CLI), "..", "src", "share.js"), "utf8");
416
+ const cliText = readFileSync(CLI, "utf8");
417
+
418
+ const skippedBlock = shareText.match(/const skipped = \{([^}]*)\}/)?.[1] ?? "";
419
+ const shareKeys = [...skippedBlock.matchAll(/([a-z_]+):\s*0/g)].map((m) => m[1]);
420
+ const namedBlock = cliText.match(/const named = \[([\s\S]*?)\];/)?.[1] ?? "";
421
+ const cliKeys = [...namedBlock.matchAll(/\['([a-z_]+)'/g)].map((m) => m[1]);
422
+ // 两条正则都必须真的抓到东西,否则这个用例退化成永真(同本文件对假服务的口径)。
423
+ assert.equal(shareKeys.length, 6, `share.js 的 skipped 应有六个键,抓到 ${shareKeys.length} 个:${shareKeys}`);
424
+ assert.equal(cliKeys.length, 6, `cli.mjs 的 named 应有六档,抓到 ${cliKeys.length} 个:${cliKeys}`);
425
+ assert.deepEqual(cliKeys, shareKeys, "CLI 的档位名必须与 report.skipped 的键逐字同序");
426
+
427
+ // ①:那六个键在「求和那一行」也得原样出现一次——判据读的是同一本账,不是第七份口径。
428
+ const censusBlock = cliText.match(/const skippedTotal = \[([\s\S]*?)\]/)?.[1] ?? "";
429
+ const censusKeys = [...censusBlock.matchAll(/'([a-z_]+)'/g)].map((m) => m[1]);
430
+ assert.deepEqual(censusKeys, shareKeys, "截断判据的六个计数键必须与 report.skipped 同序同源");
431
+ assert.ok(/\)\.length\s*>=\s*\d+/.test(cliText) === false,
432
+ "cli.mjs 不许再硬写截断水位:那个数字只住在 src/share.js 的 SHARE_SKIP_LIST_MAX");
433
+
434
+ // ②:提示由「账比清单长」判定,与面板同一条判据。
435
+ assert.ok(cliText.includes("skippedTotal > (r.skip_list ?? []).length)"),
436
+ "那句「跳过清单已截断」必须由六个计数之和与清单长度之比来判定");
437
+ });