@mison/wecom-cleaner 1.2.0 → 1.2.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 CHANGED
@@ -27,6 +27,7 @@
27
27
  - [常用参数](#常用参数)
28
28
  - [数据与审计文件](#数据与审计文件)
29
29
  - [开发与质量门禁](#开发与质量门禁)
30
+ - [测试矩阵](#测试矩阵)
30
31
  - [发布与打包](#发布与打包)
31
32
  - [FAQ](#faq)
32
33
 
@@ -158,10 +159,10 @@ GitHub 备选方式(无 npm 包依赖):
158
159
  curl -fsSL https://raw.githubusercontent.com/MisonL/wecom-cleaner/main/scripts/install-skill.sh | bash
159
160
  ```
160
161
 
161
- 若需安装指定版本标签(例如 `v1.1.0`):
162
+ 若需安装指定版本标签(例如 `v1.2.1`):
162
163
 
163
164
  ```bash
164
- curl -fsSL https://raw.githubusercontent.com/MisonL/wecom-cleaner/main/scripts/install-skill.sh | bash -s -- --ref v1.1.0
165
+ curl -fsSL https://raw.githubusercontent.com/MisonL/wecom-cleaner/main/scripts/install-skill.sh | bash -s -- --ref v1.2.1
165
166
  ```
166
167
 
167
168
  Agent 侧统一任务入口脚本(位于 `skills/wecom-cleaner-agent/scripts/`):
@@ -384,19 +385,25 @@ npm run format:check
384
385
  发布前推荐全量门禁:
385
386
 
386
387
  ```bash
388
+ npm run format:check
387
389
  npm run check
388
390
  npm run test:coverage:check
389
- npm run format:check
391
+ npm run release:gate
390
392
  npm run e2e:smoke -- --keep
391
393
  npm run pack:tgz:dry-run
392
394
  ```
393
395
 
394
396
  当前基线(主分支):
395
397
 
396
- - 单元测试:`78/78` 通过。
397
- - 覆盖率:`statements 88.28%`,`branches 74.44%`,`functions 94.89%`,`lines 88.28%`。
398
+ - 单元测试:`91/91` 通过。
399
+ - 覆盖率:`statements 88.49%`,`branches 75.01%`,`functions 95.02%`,`lines 88.49%`。
398
400
  - 全菜单 smoke:通过(含恢复冲突分支与 doctor JSON 分支)。
399
401
 
402
+ ## 测试矩阵
403
+
404
+ - 场景矩阵与断言模板见:[`docs/TEST_MATRIX.md`](./docs/TEST_MATRIX.md)
405
+ - 新增动作或输出字段时,需同步更新矩阵并补对应测试。
406
+
400
407
  ## 发布与打包
401
408
 
402
409
  `prepack` 会自动执行:
@@ -423,26 +430,27 @@ npm run pack:tgz
423
430
 
424
431
  ```bash
425
432
  # 1) 发布前检查
433
+ npm run format:check
426
434
  npm run check
427
435
  npm run test:coverage:check
428
- npm run format:check
436
+ npm run release:gate
429
437
  npm run e2e:smoke
430
438
  npm run pack:tgz
431
439
  npm run pack:release-assets
432
440
 
433
441
  # 2) 推送主分支与标签
434
442
  git push origin main
435
- git tag v1.2.0
436
- git push origin v1.2.0
443
+ git tag v1.2.1
444
+ git push origin v1.2.1
437
445
 
438
446
  # 3) 发布 GitHub Release(附 npm 包 + 双架构核心附件)
439
- gh release create v1.2.0 \
440
- --title "v1.2.0" \
441
- --notes-file docs/releases/v1.2.0.md \
442
- wecom-cleaner-1.2.0.tgz \
443
- dist/release/wecom-cleaner-core-v1.2.0-darwin-x64 \
444
- dist/release/wecom-cleaner-core-v1.2.0-darwin-arm64 \
445
- dist/release/wecom-cleaner-core-v1.2.0-SHA256SUMS.txt
447
+ gh release create v1.2.1 \
448
+ --title "v1.2.1" \
449
+ --notes-file docs/releases/v1.2.1.md \
450
+ wecom-cleaner-1.2.1.tgz \
451
+ dist/release/wecom-cleaner-core-v1.2.1-darwin-x64 \
452
+ dist/release/wecom-cleaner-core-v1.2.1-darwin-arm64 \
453
+ dist/release/wecom-cleaner-core-v1.2.1-SHA256SUMS.txt
446
454
 
447
455
  # 4) 发布 npm
448
456
  npm publish --access public
@@ -0,0 +1,106 @@
1
+ # 测试矩阵(稳定性与极端场景)
2
+
3
+ 本文用于约束 `wecom-cleaner` 在复杂组合场景与极端输入下的行为一致性,避免“看似成功但语义错误”。
4
+
5
+ ## 目标
6
+
7
+ - 覆盖高风险动作:年月清理、全量空间治理、恢复、回收区治理。
8
+ - 锁定无交互契约:JSON 字段兼容、退出码语义稳定、text 卡片结论可读。
9
+ - 验证边界安全:路径越界、符号链接逃逸、索引异常、权限失败、并发竞争。
10
+
11
+ ## 维度定义
12
+
13
+ ### 维度 A:入口
14
+
15
+ - 交互模式(TUI)
16
+ - 无交互 CLI(`--output json|text`)
17
+ - Agent 脚本(`skills/wecom-cleaner-agent/scripts/*.sh`)
18
+
19
+ ### 维度 B:动作
20
+
21
+ - `cleanup_monthly`
22
+ - `analysis_only`
23
+ - `space_governance`
24
+ - `restore`
25
+ - `recycle_maintain`
26
+ - `doctor`
27
+
28
+ ### 维度 C:范围组合
29
+
30
+ - 账号:`current` / `all` / 指定 ID 集合
31
+ - 月份:显式 `--months` / `--cutoff-month` / 自动窗口
32
+ - 类别:默认 / 指定列表 / `all`
33
+ - 文件存储目录来源:`preset` / `configured` / `auto` / `all`
34
+
35
+ ### 维度 D:执行模式
36
+
37
+ - dry-run(预演)
38
+ - 真实执行(`--dry-run false --yes`)
39
+ - 真实执行后复核(同条件二次运行)
40
+
41
+ ### 维度 E:异常与边界
42
+
43
+ - 路径越界(`../`、绝对路径、非法根目录)
44
+ - 符号链接逃逸(raw 路径在根内,realpath 在根外)
45
+ - 索引异常(损坏行、批次根不一致、异常 `batchId`)
46
+ - 系统失败(`EACCES`、`ENOENT`、`ENOSPC`)
47
+ - 并发锁冲突与陈旧锁恢复
48
+
49
+ ### 维度 F:规模
50
+
51
+ - 小样本(< 50 目录)
52
+ - 中样本(50~500 目录)
53
+ - 大样本(> 500 目录,强调耗时与稳定性)
54
+
55
+ ## 关键断言模板(每个场景最少验证)
56
+
57
+ ### 1) 退出码
58
+
59
+ - `0`:动作完成(可含业务失败明细)
60
+ - `2`:参数/用法错误
61
+ - `3`:真实执行缺少 `--yes`
62
+
63
+ ### 2) JSON 契约(无交互)
64
+
65
+ 必须存在且类型稳定:
66
+
67
+ - `ok: boolean`
68
+ - `action: string`
69
+ - `dryRun: boolean | null`
70
+ - `summary: object`
71
+ - `warnings: array`
72
+ - `errors: array`
73
+ - `meta.durationMs: number`
74
+ - `meta.engine: string`
75
+
76
+ ### 3) 业务语义
77
+
78
+ - dry-run 不修改源目录
79
+ - 真实执行删除采用“移动到回收区”,可按批次恢复
80
+ - 无目标场景不得生成真实批次(或写入误导性“成功删除”)
81
+ - 部分失败时必须可见失败统计与错误明细
82
+
83
+ ### 4) 审计一致性
84
+
85
+ - `index.jsonl` 记录动作、状态、路径、错误类型
86
+ - 越界/异常路径必须落审计(`error_type=PATH_VALIDATION_FAILED`)
87
+ - 回收区治理异常批次不得触发越界删除
88
+
89
+ ## 最小必跑清单(回归基线)
90
+
91
+ 1. `cleanup_monthly`:`--cutoff-month` + `--output json` + dry-run(有目标 / 无目标)
92
+ 2. `cleanup_monthly`:真实执行 + 复核(复核命中应下降或归零)
93
+ 3. `space_governance`:`suggested-only` 与 `allow-recent-active` 组合
94
+ 4. `restore`:`skip/overwrite/rename` 三冲突策略
95
+ 5. `recycle_maintain`:`disabled` / `no_candidate` / `partial_failed`
96
+ 6. `doctor`:只读模式不创建状态目录
97
+ 7. Agent 报告脚本:成功、无目标、失败三态退出码与卡片完整性
98
+
99
+ ## 当前门禁(执行顺序)
100
+
101
+ 1. `npm run check`
102
+ 2. `npm run test:coverage:check`
103
+ 3. `shellcheck skills/wecom-cleaner-agent/scripts/*.sh`
104
+ 4. `npm run e2e:smoke`
105
+
106
+ 说明:若新增动作/字段,需先补此文档矩阵与断言,再提交实现。
@@ -0,0 +1,33 @@
1
+ # v1.2.1 发布说明
2
+
3
+ 发布日期:2026-02-26
4
+
5
+ ## 亮点
6
+
7
+ - 发布流程新增一键门禁:`npm run release:gate`,统一覆盖格式检查、语法检查、覆盖率门禁、Shell 脚本静态检查、E2E smoke 与打包预演。
8
+ - 回收区治理安全性进一步增强:新增 `realpath` 级边界校验,拦截符号链接越界路径。
9
+ - Agent 无交互脚本失败语义加固:失败时稳定返回非 0 退出码并输出可读错误原因,避免自动化误判成功。
10
+
11
+ ## 重要修复
12
+
13
+ - 修复回收区治理的符号链接逃逸风险(`recycle_path_symlink_escape` / `batch_root_symlink_escape` 等)。
14
+ - 修复 `recycle_maintain` 业务失败场景下无交互契约测试断言,确保“退出码 + JSON 输出”语义一致。
15
+ - 修复报告脚本在 `set -u` 下错误分支变量展开异常,避免失败被错误吞掉。
16
+
17
+ ## 质量状态
18
+
19
+ - 发布门禁:全部通过。
20
+ - 单元测试:`91/91` 通过。
21
+ - 覆盖率(门禁):
22
+ - statements:`88.49%`
23
+ - branches:`75.01%`
24
+ - functions:`95.02%`
25
+ - lines:`88.49%`
26
+
27
+ ## 资产
28
+
29
+ - npm 包:`wecom-cleaner-1.2.1.tgz`
30
+ - GitHub Release 附件:
31
+ - `wecom-cleaner-core-v1.2.1-darwin-x64`
32
+ - `wecom-cleaner-core-v1.2.1-darwin-arm64`
33
+ - `wecom-cleaner-core-v1.2.1-SHA256SUMS.txt`
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "targets": {
5
5
  "darwin-x64": {
6
6
  "binaryName": "wecom-cleaner-core",
7
7
  "sha256": "c703a8bdb6df39df776c6a3ab28b3553916d487cf7c38612989566b4ecea34c9",
8
- "url": "https://github.com/MisonL/wecom-cleaner/releases/download/v1.2.0/wecom-cleaner-core-v1.2.0-darwin-x64"
8
+ "url": "https://github.com/MisonL/wecom-cleaner/releases/download/v1.2.1/wecom-cleaner-core-v1.2.1-darwin-x64"
9
9
  },
10
10
  "darwin-arm64": {
11
11
  "binaryName": "wecom-cleaner-core",
12
12
  "sha256": "8d0a15efcb70a266e15c752212f15570c006a931681249d6cd41a1c438941f72",
13
- "url": "https://github.com/MisonL/wecom-cleaner/releases/download/v1.2.0/wecom-cleaner-core-v1.2.0-darwin-arm64"
13
+ "url": "https://github.com/MisonL/wecom-cleaner/releases/download/v1.2.1/wecom-cleaner-core-v1.2.1-darwin-arm64"
14
14
  }
15
15
  }
16
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mison/wecom-cleaner",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "企业微信本地聊天缓存清理工具(交互式 CLI/TUI)",
5
5
  "author": "MisonL",
6
6
  "license": "MIT",
@@ -43,6 +43,7 @@
43
43
  "format:check": "prettier --check .",
44
44
  "check": "node --check src/*.js",
45
45
  "e2e:smoke": "bash scripts/e2e-smoke.sh",
46
+ "release:gate": "bash scripts/release-gate.sh",
46
47
  "prepack": "npm run build:native:release && npm run check",
47
48
  "pack:tgz": "node scripts/pack-tgz.js",
48
49
  "pack:tgz:dry-run": "node scripts/pack-tgz.js --dry-run",
@@ -190,7 +190,7 @@ run_cmd_to_file() {
190
190
  if ! wecom-cleaner "${cmd_parts[@]}" >"$output_file" 2>"$err_file"; then
191
191
  local err_head
192
192
  err_head="$(head -n 3 "$err_file" 2>/dev/null || true)"
193
- echo "执行失败(dry-run=$dry_run):${err_head:-未知错误}" >&2
193
+ echo "执行失败(dry-run=${dry_run}):${err_head:-未知错误}" >&2
194
194
  return 1
195
195
  fi
196
196
  }
@@ -275,6 +275,7 @@ if [[ -z "$selected_categories_human" ]]; then
275
275
  fi
276
276
 
277
277
  printf '\n=== 清理结果(给用户)===\n'
278
+ printf -- '- 执行结论:%s(%s)\n' "$conclusion" "$reason"
278
279
  if [[ "$executed" == "true" ]]; then
279
280
  printf -- '- 已完成:已清理 %s 项聊天缓存,释放 %s。\n' "$execute_success" "$(human_bytes "$execute_reclaimed")"
280
281
  elif [[ "$preview_matched" -eq 0 ]]; then
@@ -287,6 +288,7 @@ printf -- '- 你的目标:清理 %s 及之前的企业微信聊天缓存。\n'
287
288
  printf '\n你关心的范围\n'
288
289
  printf -- '- 账号:%s(识别到 %s 个账号)\n' "$account_scope_label" "$scope_accounts"
289
290
  printf -- '- 数据类型:%s\n' "$selected_categories_human"
291
+ printf -- '- 筛选月份桶:%s,筛选类别数:%s\n' "$scope_months" "$scope_categories"
290
292
  if [[ -n "$matched_month_start" && -n "$matched_month_end" ]]; then
291
293
  printf -- '- 实际命中月份:%s ~ %s\n' "$matched_month_start" "$matched_month_end"
292
294
  else
@@ -300,6 +302,7 @@ printf -- '- 命中字节:%s(命中目录当前大小)\n' "$(human_bytes "
300
302
  printf -- '- 预计释放:%s(预演估算)\n' "$(human_bytes "$preview_reclaimed")"
301
303
  if [[ "$executed" == "true" ]]; then
302
304
  printf -- '- 实际释放:%s(真实执行结果)\n' "$(human_bytes "$execute_reclaimed")"
305
+ printf -- '- 执行明细:成功 %s / 跳过 %s / 失败 %s\n' "$execute_success" "$execute_skipped" "$execute_failed"
303
306
  printf -- '- 清理批次:%s(可用于恢复)\n' "$execute_batch"
304
307
  printf -- '- 复核结果:剩余可清理 %s 项\n' "$verify_matched"
305
308
  else
@@ -431,6 +434,13 @@ if [[ "$executed" == "true" ]]; then
431
434
  fi
432
435
  fi
433
436
 
437
+ printf '\n运行状态\n'
438
+ printf -- '- 扫描引擎:%s\n' "$engine"
439
+ printf -- '- 总耗时:%s ms\n' "$duration_total"
440
+ printf -- '- 告警:%s\n' "$warnings_total"
441
+ printf -- '- 错误:%s\n' "$errors_total"
442
+ printf -- '- 预演失败项:%s\n' "$preview_failed"
443
+
434
444
  if [[ "$warnings_total" -gt 0 || "$errors_total" -gt 0 ]]; then
435
445
  printf '\n异常与提示\n'
436
446
  printf -- '- 告警:%s\n' "$warnings_total"
@@ -143,7 +143,7 @@ run_cmd_to_file() {
143
143
  fi
144
144
  if ! wecom-cleaner "${cmd_parts[@]}" >"$output_file" 2>"$err_file"; then
145
145
  err_head="$(head -n 3 "$err_file" 2>/dev/null || true)"
146
- echo "执行失败(dry-run=$dry_run):${err_head:-未知错误}" >&2
146
+ echo "执行失败(dry-run=${dry_run}):${err_head:-未知错误}" >&2
147
147
  return 1
148
148
  fi
149
149
  }
@@ -145,7 +145,7 @@ run_cmd_to_file() {
145
145
  fi
146
146
  if ! wecom-cleaner "${cmd_parts[@]}" >"$output_file" 2>"$err_file"; then
147
147
  err_head="$(head -n 3 "$err_file" 2>/dev/null || true)"
148
- echo "执行失败(dry-run=$dry_run):${err_head:-未知错误}" >&2
148
+ echo "执行失败(dry-run=${dry_run}):${err_head:-未知错误}" >&2
149
149
  return 1
150
150
  fi
151
151
  }
@@ -173,7 +173,7 @@ run_cmd_to_file() {
173
173
  fi
174
174
  if ! wecom-cleaner "${cmd_parts[@]}" >"$output_file" 2>"$err_file"; then
175
175
  err_head="$(head -n 3 "$err_file" 2>/dev/null || true)"
176
- echo "执行失败(dry-run=$dry_run):${err_head:-未知错误}" >&2
176
+ echo "执行失败(dry-run=${dry_run}):${err_head:-未知错误}" >&2
177
177
  return 1
178
178
  fi
179
179
  }
@@ -241,6 +241,7 @@ warnings_total=$((warnings_preview + warnings_exec + warnings_verify))
241
241
  errors_total=$((errors_preview + errors_exec + errors_verify))
242
242
 
243
243
  printf '\n=== 全量空间治理结果(给用户)===\n'
244
+ printf -- '- 执行结论:%s(%s)\n' "$conclusion" "$reason"
244
245
  if [[ "$executed" == "true" ]]; then
245
246
  printf -- '- 已完成:已治理 %s 项空间目标,释放 %s。\n' "$execute_success" "$(human_bytes "$execute_reclaimed")"
246
247
  elif [[ "$matched_targets" -eq 0 ]]; then
@@ -263,6 +264,7 @@ printf -- '- 命中体积:%s\n' "$(human_bytes "$matched_bytes")"
263
264
  printf -- '- 预计释放:%s\n' "$(human_bytes "$preview_reclaimed")"
264
265
  if [[ "$executed" == "true" ]]; then
265
266
  printf -- '- 实际释放:%s\n' "$(human_bytes "$execute_reclaimed")"
267
+ printf -- '- 执行明细:成功 %s / 跳过 %s / 失败 %s\n' "$execute_success" "$execute_skipped" "$execute_failed"
266
268
  printf -- '- 清理批次:%s(可用于恢复)\n' "$execute_batch"
267
269
  printf -- '- 复核结果:剩余可治理 %s 项\n' "$verify_matched"
268
270
  else
@@ -51,8 +51,23 @@ function isPathWithinRoot(rootPath, targetPath) {
51
51
  return !rel.startsWith('..') && !path.isAbsolute(rel);
52
52
  }
53
53
 
54
- function resolveBatchRootFromEntries(recycleRoot, batch) {
54
+ async function safeRealpath(targetPath) {
55
+ try {
56
+ return await fs.realpath(targetPath);
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ async function resolveBatchRootFromEntries(recycleRoot, batch) {
55
63
  const recycleRootAbs = path.resolve(String(recycleRoot || ''));
64
+ const recycleRootReal = await safeRealpath(recycleRootAbs);
65
+ if (!recycleRootReal) {
66
+ return {
67
+ ok: false,
68
+ invalidReason: 'missing_recycle_root',
69
+ };
70
+ }
56
71
  const entries = Array.isArray(batch?.entries) ? batch.entries : [];
57
72
  if (entries.length === 0) {
58
73
  return {
@@ -78,6 +93,19 @@ function resolveBatchRootFromEntries(recycleRoot, batch) {
78
93
  invalidReason: 'recycle_path_outside_recycle_root',
79
94
  };
80
95
  }
96
+ const recyclePathReal = await safeRealpath(recyclePathAbs);
97
+ if (!recyclePathReal) {
98
+ return {
99
+ ok: false,
100
+ invalidReason: 'recycle_path_unresolvable',
101
+ };
102
+ }
103
+ if (!isPathWithinRoot(recycleRootReal, recyclePathReal)) {
104
+ return {
105
+ ok: false,
106
+ invalidReason: 'recycle_path_symlink_escape',
107
+ };
108
+ }
81
109
 
82
110
  const batchRootAbs = path.dirname(recyclePathAbs);
83
111
  if (!isPathWithinRoot(recycleRootAbs, batchRootAbs)) {
@@ -92,7 +120,20 @@ function resolveBatchRootFromEntries(recycleRoot, batch) {
92
120
  invalidReason: 'batch_root_is_recycle_root',
93
121
  };
94
122
  }
95
- rootSet.add(batchRootAbs);
123
+ const batchRootReal = await safeRealpath(batchRootAbs);
124
+ if (!batchRootReal) {
125
+ return {
126
+ ok: false,
127
+ invalidReason: 'batch_root_unresolvable',
128
+ };
129
+ }
130
+ if (!isPathWithinRoot(recycleRootReal, batchRootReal)) {
131
+ return {
132
+ ok: false,
133
+ invalidReason: 'batch_root_symlink_escape',
134
+ };
135
+ }
136
+ rootSet.add(batchRootReal);
96
137
  }
97
138
 
98
139
  if (rootSet.size !== 1) {
@@ -282,7 +323,7 @@ export async function maintainRecycleBin({ indexPath, recycleRoot, policy, dryRu
282
323
  onProgress(i + 1, selected.candidates.length);
283
324
  }
284
325
 
285
- const resolvedBatchRoot = resolveBatchRootFromEntries(recycleRoot, batch);
326
+ const resolvedBatchRoot = await resolveBatchRootFromEntries(recycleRoot, batch);
286
327
  if (!resolvedBatchRoot.ok) {
287
328
  summary.failBatches += 1;
288
329
  summary.operations.push({