dsh-flakefinder 0.3.4 → 0.3.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # 更新记录
2
+
3
+ [返回简介](README.md) · [使用说明](docs/USAGE.md) · [验证记录](docs/VALIDATION.md)
4
+
5
+ ## 0.3.5 (2026-10-05)
6
+
7
+ - 测试进程和隔离清单使用当前会话工作区,取消后不再执行后续轮次或保存成功记录;跨实例锁定历史与隔离清单,损坏文件保持原样。
8
+
9
+ ## 0.3.4 (2026-09-28)
10
+
11
+ - 更新官方 Harness 0.2.0-rc.1 的兼容声明和共同加载验证;运行时代码未变。验证范围见[验证记录](docs/VALIDATION.md)。
12
+
13
+ ## 更早的改动
14
+
15
+ 完整历史可查阅 [GitHub 提交记录](https://github.com/STARDUSTLC666/dsh-flakefinder/commits/master)。
package/README.en.md CHANGED
@@ -1,65 +1,46 @@
1
1
  # dsh-flakefinder
2
2
 
3
- > Teach agents the difference between broken code and a flaky test.
3
+ [中文](README.md)
4
4
 
5
- A DeepSeek Harness plugin for test stability: run tests repeatedly, classify flaky cases, keep history, maintain a quarantine manifest, and gate writes with approvals. Zero runtime dependencies.
5
+ ![dsh-flakefinder whale girl plugin cover](https://raw.githubusercontent.com/STARDUSTLC666/dsh-flakefinder/master/assets/cover-whale-girl.png)
6
6
 
7
- ![license](https://img.shields.io/npm/l/dsh-flakefinder) ![stars](https://img.shields.io/github/stars/STARDUSTLC666/dsh-flakefinder?style=social)
7
+ Repeat test runs to identify intermittent failures and keep investigation history.
8
8
 
9
- [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
9
+ [![npm](https://img.shields.io/npm/v/dsh-flakefinder)](https://www.npmjs.com/package/dsh-flakefinder) [![downloads](https://raw.githubusercontent.com/STARDUSTLC666/dsh-suite/npm-downloads/assets/dsh-flakefinder-downloads.svg)](https://www.npmjs.com/package/dsh-flakefinder)
10
10
 
11
- ## Tools
11
+ ## What it does
12
12
 
13
- | Tool | Purpose | Write |
14
- | :-- | :-- | :-- |
15
- | `flaky_detect` | Repeat tests N times and classify stable-pass / stable-fail / flaky | history only |
16
- | `flaky_history` | Query detection history, filter by target | no |
17
- | `flaky_report` | Combine quarantine manifest and history into a report | no |
18
- | `flaky_quarantine` | Write flaky cases to `.flakefinder.json` | yes (approval) |
19
- | `flaky_clear` | Remove recovered cases from the manifest | yes (approval) |
20
-
21
- Frameworks: vitest / jest / pytest (setup.cfg / pyproject / pytest.ini / tox.ini) / node:test, auto-detected in that order. JUnit XML entities and TAP SKIP directives are parsed.
22
-
23
- ## Compatibility
24
-
25
- Validation host: Harness `0.2.0-rc.1` built from official sources (commit `407e65c8`) with Node `24.16.0` on 2026-09-28. All 44 plugin tests pass in an isolated environment; all 18 plugins mount together in one host registering 6 tools, with tool schemas and health-check contracts passing. No live ports or external services were exercised in this round.
26
-
27
- 2026-09-13 fix: retain the service receiver when calling `subprocess.spawn`, preventing failures caused by passing the method as an unbound callback. Verified with official `0.1.5-rc.1` and `0.1.5-rc.2` on Node `24.16.0`. `flaky_detect` runs three real Node test processes through the host subprocess service and returns `stable-pass`.
13
+ - Support Vitest, Jest, pytest and node:test.
14
+ - Track repeated run results and failure history.
15
+ - Generate and maintain a quarantine list.
28
16
 
29
17
  ## Install
30
18
 
31
- ```bash
32
- dsh plugin --profile web add dsh-flakefinder
33
- ```
34
-
35
- ## Uninstall
19
+ In DSH Desktop, install `dsh-flakefinder` from the Plugins panel. If the bundled dsh command is available:
36
20
 
37
21
  ```bash
38
- dsh plugin --profile web remove dsh-flakefinder
22
+ dsh plugin --profile desktop add dsh-flakefinder
39
23
  ```
40
24
 
41
- Then restart the web service. To clean up fully, also remove the plugin entry from your profile `cordis.patch.yml` if you overrode it.
25
+ For the web version, replace `desktop` with `web`. Restart DSH after installation.
42
26
 
27
+ ## Start using it
43
28
 
44
- ## Example
29
+ Ask: “Run this test group ten times, identify intermittent failures and summarize the differences.”
45
30
 
46
- ```text
47
- User: src/checkout.test.ts keeps failing recently, is it real?
48
- Agent:
49
- flaky_detect(target="src/checkout.test.ts", runs=5)
50
- -> verdict: flaky (3/5 passed; failures cluster on fake timers)
51
- -> flaky_quarantine(tests=["src/checkout.test.ts > timer restore"], reason="timer race")
52
- ```
31
+ ## Requirements and configuration
53
32
 
54
- ## Config
33
+ Requires a working test command in your project. Quarantine records and gates follow project configuration.
55
34
 
56
- See `cordis.patch.yml`; defaults: `defaultRuns=5`, `maxRuns=20`, `timeoutMs=120000`, `writeApproval=true`, `pythonPath=python` (`python3` on Linux/macOS; used for pytest runs).
35
+ Detailed configuration, tool arguments and troubleshooting are in the [usage guide](docs/USAGE.en.md). For standalone development, follow the Node requirement in [package.json](package.json).
57
36
 
58
- ## Development
37
+ ## Documentation
59
38
 
60
- ```bash
61
- pnpm test
62
- pnpm typecheck
63
- ```
39
+ - [Usage and troubleshooting](docs/USAGE.en.md)
40
+ - [Changelog](CHANGELOG.md)
41
+ - [Validation scope and history](docs/VALIDATION.md)
42
+ - [Report a problem or suggest a feature](https://github.com/STARDUSTLC666/dsh-flakefinder/issues)
43
+
44
+ ## License
64
45
 
65
- MIT
46
+ [MIT](LICENSE)
package/README.md CHANGED
@@ -1,122 +1,46 @@
1
1
  # dsh-flakefinder
2
2
 
3
- > 让 agent 分清「代码坏了」还是「测试在抽风」。
3
+ [English](README.en.md)
4
4
 
5
- DeepSeek Harness 测试稳定性插件:支持 vitest / jest / pytest / node:test,重复运行测试并识别 flaky 用例,历史留档、隔离清单、写操作审批门。零运行时依赖。
5
+ ![dsh-flakefinder 鲸鱼娘插件封面](https://raw.githubusercontent.com/STARDUSTLC666/dsh-flakefinder/master/assets/cover-whale-girl.png)
6
6
 
7
- ![license](https://img.shields.io/npm/l/dsh-flakefinder) ![stars](https://img.shields.io/github/stars/STARDUSTLC666/dsh-flakefinder?style=social)
7
+ 重复运行测试,找出偶发失败用例并保留调查记录。
8
8
 
9
- [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
9
+ [![npm](https://img.shields.io/npm/v/dsh-flakefinder)](https://www.npmjs.com/package/dsh-flakefinder) [![downloads](https://raw.githubusercontent.com/STARDUSTLC666/dsh-suite/npm-downloads/assets/dsh-flakefinder-downloads.svg)](https://www.npmjs.com/package/dsh-flakefinder)
10
10
 
11
- ## 兼容性
11
+ ## 功能
12
12
 
13
- 验证宿主:官方源码构建的 Harness `0.2.0-rc.1`(commit `407e65c8`)+ Node `24.16.0`(2026-09-28)。44 项插件测试在隔离环境全部通过;同一个宿主里 18 个插件共同加载,注册 6 个工具,工具 schema 与健康检查契约通过。本轮未启用真实端口与外部服务。
14
-
15
- ## 工具
16
-
17
- | 工具 | 作用 | 写操作 |
18
- | :-- | :-- | :-- |
19
- | `flaky_detect` | 重复运行测试 N 次,判定 stable-pass / stable-fail / flaky | 否(写历史) |
20
- | `flaky_history` | 查询检测历史,按目标过滤 | 否 |
21
- | `flaky_report` | 汇总隔离清单 + 历史,输出稳定性报告 | 否 |
22
- | `flaky_quarantine` | 把 flaky 用例写入 `.flakefinder.json` 隔离清单 | 是(审批门) |
23
- | `flaky_clear` | 从隔离清单移除已恢复的用例 | 是(审批门) |
24
-
25
- 支持框架:
26
-
27
- - vitest:读取 `node_modules/vitest/vitest.mjs`,使用 JSON reporter
28
- - jest:读取 `node_modules/jest/bin/jest.js`,使用 `--json --outputFile`
29
- - pytest:`python -m pytest --junitxml`,解析 JUnit XML(支持 setup.cfg / pyproject / pytest.ini / tox.ini 探测与 XML 实体解码)
30
- - node:test:`node --test --test-reporter=tap`,解析 TAP(含 SKIP 指令归类)
31
- - `framework: auto` 按 vitest → jest → pytest → node:test 自动探测
32
-
33
- ## 兼容性
34
-
35
- 已在官方 `@deepseek-ai/dsh@0.1.5-rc.1`、Node `24.16.0` 上验证(2026-09-11):18 个组件与 Modlens 同载,工具 schema、技能注册及离线只读调用检查通过。采用 `cordis.patch.yml` + `dsh.bundle.patch` 组合包模型。Node 要求与该版本 Harness 一致:22.19 及以上的 22.x,或 24 及以上。外部服务的实际业务操作需按各组件配置单独验证。
36
-
37
- 2026-09-13 修复:保留宿主 `subprocess.spawn` 的服务对象,避免方法独立传递后因 `this` 丢失而报错。已在官方 `0.1.5-rc.1` 和 `0.1.5-rc.2`、Node `24.16.0` 上验证。`flaky_detect` 已通过真实宿主进程服务连续运行三轮 Node 测试,正确返回 `stable-pass`。
13
+ - 支持 Vitest、Jest、pytest 和 node:test。
14
+ - 记录重复运行结果与失败历史。
15
+ - 生成和维护用例隔离清单。
38
16
 
39
17
  ## 安装
40
18
 
41
- ```bash
42
- dsh plugin --profile web add dsh-flakefinder
43
- ```
44
-
45
- 或手动安装后在 profile 的 `cordis.patch.yml` 插入:
46
-
47
- ```yaml
48
- - id: flakefinder
49
- name: 'dsh-flakefinder'
50
- config:
51
- defaultRuns: 5
52
- writeApproval: true
53
- ```
54
-
55
- ## 卸载
19
+ 桌面版可在「插件」面板按包名 `dsh-flakefinder` 安装。已配置 dsh 命令时也可使用:
56
20
 
57
21
  ```bash
58
- dsh plugin --profile web remove dsh-flakefinder
22
+ dsh plugin --profile desktop add dsh-flakefinder
59
23
  ```
60
24
 
61
- 卸载后重启 Web 服务。如需彻底清理,可再手动删除自己 profile `cordis.patch.yml` 中覆盖的插件行。
25
+ 网页版把命令中的 `desktop` 改为 `web`。安装后重启 DSH。
62
26
 
27
+ ## 开始使用
63
28
 
64
- ## 使用示例
65
-
66
- ```text
67
- 用户:src/checkout.test.ts 最近老失败,帮我判断一下
68
- Agent:
69
- flaky_detect(target="src/checkout.test.ts", runs=5)
70
- → 判定:flaky;3/5 通过,失败集中在 useFakeTimers 用例
71
- → flaky_quarantine(tests=["src/checkout.test.ts > 定时器恢复"], reason="定时器竞态")
72
- ```
73
-
74
- ## 配置
75
-
76
- | 字段 | 默认 | 说明 |
77
- | :-- | :-- | :-- |
78
- | `defaultRuns` | `5` | `flaky_detect` 默认重复次数(3-20) |
79
- | `maxRuns` | `20` | 单次允许的最大重复次数(3-50) |
80
- | `timeoutMs` | `120000` | 单轮测试运行超时 |
81
- | `graceMs` | `10000` | 超时后宽限 |
82
- | `writeApproval` | `true` | 隔离清单写操作是否走审批门 |
83
- | `dataDir` | `DSH_HOME/.dsh-flakefinder` | 历史存储目录 |
84
- | `quarantineFile` | `<cwd>/.flakefinder.json` | 隔离清单路径 |
85
- | `pythonPath` | `DSH_FLAKEFINDER_PYTHON` 或 `python`/`python3` | pytest 使用的 Python 解释器 |
86
-
87
- ## 隔离清单格式
88
-
89
- ```json
90
- {
91
- "version": 1,
92
- "quarantined": [
93
- {
94
- "file": "src/checkout.test.ts",
95
- "name": "使用假定时器后恢复真实定时器",
96
- "reason": "定时器竞态,见 issue #12",
97
- "since": "2026-08-16T00:00:00.000Z"
98
- }
99
- ]
100
- }
101
- ```
29
+ 可说:“把这组测试重复运行十次,找出偶发失败的用例并总结差异。”
102
30
 
103
- 隔离清单只记录,不修改测试源码;agent 跑测试前应先查阅 `flaky_report`。
31
+ ## 依赖与配置
104
32
 
105
- ## 工程
33
+ 需要项目已有可执行的测试命令。隔离清单用于记录问题,具体门禁按项目配置。
106
34
 
107
- - Node >= 22.13,TypeScript,零运行时依赖
108
- - 测试进程走 DSH 官方 subprocess 服务,argv 数组、无 shell
109
- - 全量单测 40+:解析、判定、存储、审批门、pytest 计划、subprocess 超时、注册与 manifest
110
- - `pnpm test`:构建 + `node --test`
35
+ 详细配置、工具参数与排错见[使用说明](docs/USAGE.md)。从源码独立开发时,Node 要求以 [package.json](package.json) 为准。
111
36
 
112
- ## 发布门禁
37
+ ## 文档
113
38
 
114
- 1. `pnpm test` 全绿
115
- 2. `pnpm typecheck`
116
- 3. 危险模式扫描(eval / child_process / 密钥零容忍)
117
- 4. 真实 profile 冒烟:全新临时 profile 安装插件 → 真 boot → stderr 零报错
118
- 5. manifest 自检(bundle patch / exports / files 白名单)
39
+ - [使用与排错](docs/USAGE.md)
40
+ - [更新记录](CHANGELOG.md)
41
+ - [验证范围与历史记录](docs/VALIDATION.md)
42
+ - [问题反馈与功能建议](https://github.com/STARDUSTLC666/dsh-flakefinder/issues)
119
43
 
120
44
  ## License
121
45
 
122
- MIT
46
+ [MIT](LICENSE)
@@ -0,0 +1,56 @@
1
+ # dsh-flakefinder usage guide
2
+
3
+ [Overview](../README.en.md) · [Changelog](../CHANGELOG.md) · [Validation](VALIDATION.md)
4
+
5
+ ## Current improvements
6
+
7
+ Relative quarantineFile defaults to .flakefinder.json in the current workspace. Cancellation stops the current process. Concurrent writers preserve history and quarantine entries; damaged files are preserved with explicit errors for inspection.
8
+
9
+ ## Tools
10
+
11
+ | Tool | Purpose | Write |
12
+ | :-- | :-- | :-- |
13
+ | `flaky_detect` | Repeat tests N times and classify stable-pass / stable-fail / flaky | history only |
14
+ | `flaky_history` | Query detection history, filter by target | no |
15
+ | `flaky_report` | Combine quarantine manifest and history into a report | no |
16
+ | `flaky_quarantine` | Write flaky cases to `.flakefinder.json` | yes (approval) |
17
+ | `flaky_clear` | Remove recovered cases from the manifest | yes (approval) |
18
+
19
+ Frameworks: vitest / jest / pytest (setup.cfg / pyproject / pytest.ini / tox.ini) / node:test, auto-detected in that order. JUnit XML entities and TAP SKIP directives are parsed.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ dsh plugin --profile web add dsh-flakefinder
25
+ ```
26
+
27
+ ## Uninstall
28
+
29
+ ```bash
30
+ dsh plugin --profile web remove dsh-flakefinder
31
+ ```
32
+
33
+ Then restart the web service. To clean up fully, also remove the plugin entry from your profile `cordis.patch.yml` if you overrode it.
34
+
35
+ ## Example
36
+
37
+ ```text
38
+ User: src/checkout.test.ts keeps failing recently, is it real?
39
+ Agent:
40
+ flaky_detect(target="src/checkout.test.ts", runs=5)
41
+ -> verdict: flaky (3/5 passed; failures cluster on fake timers)
42
+ -> flaky_quarantine(tests=["src/checkout.test.ts > timer restore"], reason="timer race")
43
+ ```
44
+
45
+ ## Config
46
+
47
+ See `cordis.patch.yml`; defaults: `defaultRuns=5`, `maxRuns=20`, `timeoutMs=120000`, `writeApproval=true`, `pythonPath=python` (`python3` on Linux/macOS; used for pytest runs).
48
+
49
+ ## Development
50
+
51
+ ```bash
52
+ pnpm test
53
+ pnpm typecheck
54
+ ```
55
+
56
+ MIT
package/docs/USAGE.md ADDED
@@ -0,0 +1,109 @@
1
+ # dsh-flakefinder 使用说明
2
+
3
+ [返回简介](../README.md) · [更新记录](../CHANGELOG.md) · [验证记录](VALIDATION.md)
4
+
5
+ ## 本次改进
6
+
7
+ 相对 quarantineFile 默认在当前工作区的 .flakefinder.json。取消会停止当前测试进程;同一数据目录的并发写入不会丢失历史或隔离条目。损坏文件会报错并保留,先备份后检查。
8
+
9
+ ## 工具
10
+
11
+ | 工具 | 作用 | 写操作 |
12
+ | :-- | :-- | :-- |
13
+ | `flaky_detect` | 重复运行测试 N 次,判定 stable-pass / stable-fail / flaky | 否(写历史) |
14
+ | `flaky_history` | 查询检测历史,按目标过滤 | 否 |
15
+ | `flaky_report` | 汇总隔离清单 + 历史,输出稳定性报告 | 否 |
16
+ | `flaky_quarantine` | 把 flaky 用例写入 `.flakefinder.json` 隔离清单 | 是(审批门) |
17
+ | `flaky_clear` | 从隔离清单移除已恢复的用例 | 是(审批门) |
18
+
19
+ 支持框架:
20
+
21
+ - vitest:读取 `node_modules/vitest/vitest.mjs`,使用 JSON reporter
22
+ - jest:读取 `node_modules/jest/bin/jest.js`,使用 `--json --outputFile`
23
+ - pytest:`python -m pytest --junitxml`,解析 JUnit XML(支持 setup.cfg / pyproject / pytest.ini / tox.ini 探测与 XML 实体解码)
24
+ - node:test:`node --test --test-reporter=tap`,解析 TAP(含 SKIP 指令归类)
25
+ - `framework: auto` 按 vitest → jest → pytest → node:test 自动探测
26
+
27
+ ## 安装
28
+
29
+ ```bash
30
+ dsh plugin --profile web add dsh-flakefinder
31
+ ```
32
+
33
+ 或手动安装后在 profile 的 `cordis.patch.yml` 插入:
34
+
35
+ ```yaml
36
+ - id: flakefinder
37
+ name: 'dsh-flakefinder'
38
+ config:
39
+ defaultRuns: 5
40
+ writeApproval: true
41
+ ```
42
+
43
+ ## 卸载
44
+
45
+ ```bash
46
+ dsh plugin --profile web remove dsh-flakefinder
47
+ ```
48
+
49
+ 卸载后重启 Web 服务。如需彻底清理,可再手动删除自己 profile `cordis.patch.yml` 中覆盖的插件行。
50
+
51
+ ## 使用示例
52
+
53
+ ```text
54
+ 用户:src/checkout.test.ts 最近老失败,帮我判断一下
55
+ Agent:
56
+ flaky_detect(target="src/checkout.test.ts", runs=5)
57
+ → 判定:flaky;3/5 通过,失败集中在 useFakeTimers 用例
58
+ → flaky_quarantine(tests=["src/checkout.test.ts > 定时器恢复"], reason="定时器竞态")
59
+ ```
60
+
61
+ ## 配置
62
+
63
+ | 字段 | 默认 | 说明 |
64
+ | :-- | :-- | :-- |
65
+ | `defaultRuns` | `5` | `flaky_detect` 默认重复次数(3-20) |
66
+ | `maxRuns` | `20` | 单次允许的最大重复次数(3-50) |
67
+ | `timeoutMs` | `120000` | 单轮测试运行超时 |
68
+ | `graceMs` | `10000` | 超时后宽限 |
69
+ | `writeApproval` | `true` | 隔离清单写操作是否走审批门 |
70
+ | `dataDir` | `DSH_HOME/.dsh-flakefinder` | 历史存储目录 |
71
+ | `quarantineFile` | `<cwd>/.flakefinder.json` | 隔离清单路径 |
72
+ | `pythonPath` | `DSH_FLAKEFINDER_PYTHON` 或 `python`/`python3` | pytest 使用的 Python 解释器 |
73
+
74
+ ## 隔离清单格式
75
+
76
+ ```json
77
+ {
78
+ "version": 1,
79
+ "quarantined": [
80
+ {
81
+ "file": "src/checkout.test.ts",
82
+ "name": "使用假定时器后恢复真实定时器",
83
+ "reason": "定时器竞态,见 issue #12",
84
+ "since": "2026-08-16T00:00:00.000Z"
85
+ }
86
+ ]
87
+ }
88
+ ```
89
+
90
+ 隔离清单只记录,不修改测试源码;agent 跑测试前应先查阅 `flaky_report`。
91
+
92
+ ## 工程
93
+
94
+ - Node >= 22.13,TypeScript,仅依赖文件锁库
95
+ - 测试进程走 DSH 官方 subprocess 服务,argv 数组、无 shell
96
+ - 全量单测 40+:解析、判定、存储、审批门、pytest 计划、subprocess 超时、注册与 manifest
97
+ - `pnpm test`:构建 + `node --test`
98
+
99
+ ## 发布门禁
100
+
101
+ 1. `pnpm test` 全绿
102
+ 2. `pnpm typecheck`
103
+ 3. 危险模式扫描(eval / child_process / 密钥零容忍)
104
+ 4. 真实 profile 冒烟:全新临时 profile 安装插件 → 真 boot → stderr 零报错
105
+ 5. manifest 自检(bundle patch / exports / files 白名单)
106
+
107
+ ## License
108
+
109
+ MIT
@@ -0,0 +1,15 @@
1
+ # dsh-flakefinder 验证记录
2
+
3
+ 本页整理原 README 的历史验证说明,保留当时的版本、日期与范围。自动测试、启动检查、浏览器操作和真实服务验收分别记录,不能相互替代。更详细的版本验收文件仍保留在仓库中。
4
+
5
+ ## 原中文记录
6
+
7
+ 验证宿主:官方源码构建的 Harness `0.2.0-rc.1`(commit `407e65c8`)+ Node `24.16.0`(2026-09-28)。44 项插件测试在隔离环境全部通过;同一个宿主里 18 个插件共同加载,注册 6 个工具,工具 schema 与健康检查契约通过。本轮未启用真实端口与外部服务。
8
+
9
+ 2026-09-13 修复:保留宿主 `subprocess.spawn` 的服务对象,避免方法独立传递后因 `this` 丢失而报错。该路径已在隔离宿主进程服务中实测通过。`flaky_detect` 已通过真实宿主进程服务连续运行三轮 Node 测试,正确返回 `stable-pass`。
10
+
11
+ ## Original English record
12
+
13
+ Validation host: Harness `0.2.0-rc.1` built from official sources (commit `407e65c8`) with Node `24.16.0` on 2026-09-28. All 44 plugin tests pass in an isolated environment; all 18 plugins mount together in one host registering 6 tools, with tool schemas and health-check contracts passing. No live ports or external services were exercised in this round.
14
+
15
+ 2026-09-13 fix: retain the service receiver when calling `subprocess.spawn`, preventing failures caused by passing the method as an unbound callback. Verified against a real isolated host subprocess service. `flaky_detect` runs three real Node test processes through the host subprocess service and returns `stable-pass`.
package/lib/config.d.ts CHANGED
@@ -31,6 +31,7 @@ export interface ResolvedFlakeConfig {
31
31
  writeApproval: boolean;
32
32
  dataDir: string;
33
33
  quarantineFile: string;
34
+ quarantineFileRelative?: string;
34
35
  pythonPath: string;
35
36
  }
36
37
  /** 解析并校验配置,非法值抛出中文错误。 */
package/lib/config.js CHANGED
@@ -49,7 +49,8 @@ export function resolveConfig(config, cwd = process.cwd()) {
49
49
  const pythonPath = typeof cfg.pythonPath === 'string' && cfg.pythonPath.trim() !== ''
50
50
  ? cfg.pythonPath.trim()
51
51
  : (process.env.DSH_FLAKEFINDER_PYTHON?.trim() || (process.platform === 'win32' ? 'python' : 'python3'));
52
- return { defaultRuns, maxRuns, timeoutMs, graceMs, writeApproval, dataDir, quarantineFile, pythonPath };
52
+ const relativeQuarantine = typeof cfg.quarantineFile === 'string' && cfg.quarantineFile.trim() ? cfg.quarantineFile.trim() : '.flakefinder.json';
53
+ return { defaultRuns, maxRuns, timeoutMs, graceMs, writeApproval, dataDir, quarantineFile, pythonPath, ...(path.isAbsolute(relativeQuarantine) ? {} : { quarantineFileRelative: relativeQuarantine }) };
53
54
  }
54
55
  function readRuns(label, value, lo, hi) {
55
56
  if (typeof value !== 'number' || !Number.isFinite(value))
package/lib/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 插件导出 apply(ctx, config):注册五个面向模型的工具(flaky_detect / flaky_history /
5
5
  * flaky_quarantine / flaky_clear / flaky_report)。测试进程走 DSH 官方 subprocess 服务
6
- * (argv 数组、无 shell),隔离清单写操作默认走宿主审批门。零运行时依赖。
6
+ * (argv 数组、无 shell),隔离清单写操作默认走宿主审批门。仅依赖文件锁库。
7
7
  *
8
8
  * @module dsh-flakefinder
9
9
  */
package/lib/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 插件导出 apply(ctx, config):注册五个面向模型的工具(flaky_detect / flaky_history /
5
5
  * flaky_quarantine / flaky_clear / flaky_report)。测试进程走 DSH 官方 subprocess 服务
6
- * (argv 数组、无 shell),隔离清单写操作默认走宿主审批门。零运行时依赖。
6
+ * (argv 数组、无 shell),隔离清单写操作默认走宿主审批门。仅依赖文件锁库。
7
7
  *
8
8
  * @module dsh-flakefinder
9
9
  */
package/lib/runner.d.ts CHANGED
@@ -14,6 +14,8 @@ export interface RunResult {
14
14
  export interface ProcessRunner {
15
15
  run(argv: readonly string[], options?: {
16
16
  timeoutMs?: number;
17
+ cwd?: string;
18
+ signal?: AbortSignal;
17
19
  }): Promise<RunResult>;
18
20
  }
19
21
  export interface SubprocessHandleLike {
@@ -63,4 +65,4 @@ export interface FlakeRun {
63
65
  error?: string;
64
66
  }
65
67
  /** 执行一个测试计划并读取报告;进程失败但报告存在时仍返回报告。 */
66
- export declare function executePlan(runner: ProcessRunner, plan: TestPlan, index: number, timeoutMs: number): Promise<FlakeRun>;
68
+ export declare function executePlan(runner: ProcessRunner, plan: TestPlan, index: number, timeoutMs: number, cwd?: string, signal?: AbortSignal): Promise<FlakeRun>;
package/lib/runner.js CHANGED
@@ -12,19 +12,21 @@ export function createSubprocessRunner(spawn, graceMs, defaultTimeoutMs) {
12
12
  async run(argv, options) {
13
13
  const timeoutMs = options?.timeoutMs ?? defaultTimeoutMs;
14
14
  const controller = new AbortController();
15
+ const signal = options?.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
16
+ signal.throwIfAborted();
15
17
  const timer = setTimeout(() => controller.abort(new Error('flakefinder test run timed out')), timeoutMs);
16
18
  let handle;
17
19
  try {
18
20
  handle = spawn({
19
21
  argv,
20
- cwd: process.cwd(),
22
+ cwd: options?.cwd ?? process.cwd(),
21
23
  stdio: {
22
24
  stdin: 'ignore',
23
25
  stdout: { maxBytes: COLLECT_BYTES },
24
26
  stderr: { maxBytes: COLLECT_BYTES },
25
27
  },
26
28
  graceMs,
27
- signal: controller.signal,
29
+ signal,
28
30
  });
29
31
  const outcome = await handle.done;
30
32
  const stdout = handle.collected.stdout?.readFrom(0).text ?? '';
@@ -42,9 +44,11 @@ function stderrTail(stderr) {
42
44
  return tail || '无错误输出';
43
45
  }
44
46
  /** 执行一个测试计划并读取报告;进程失败但报告存在时仍返回报告。 */
45
- export async function executePlan(runner, plan, index, timeoutMs) {
47
+ export async function executePlan(runner, plan, index, timeoutMs, cwd, signal) {
46
48
  const started = Date.now();
47
- const result = await runner.run(plan.argv, { timeoutMs });
49
+ signal?.throwIfAborted();
50
+ const result = await runner.run(plan.argv, { timeoutMs, cwd, signal });
51
+ signal?.throwIfAborted();
48
52
  const durationMs = Date.now() - started;
49
53
  if (plan.reportKind === 'tap') {
50
54
  return {
package/lib/store.js CHANGED
@@ -5,31 +5,67 @@
5
5
  */
6
6
  import fs from 'node:fs/promises';
7
7
  import path from 'node:path';
8
+ import { randomUUID } from 'node:crypto';
9
+ import lockfile from 'proper-lockfile';
8
10
  const HISTORY_FILE = 'history.json';
9
11
  const HISTORY_LIMIT = 200;
10
12
  export function createStore(dataDir, quarantineFile) {
11
13
  const historyPath = path.join(dataDir, HISTORY_FILE);
14
+ async function readJson(file) {
15
+ try {
16
+ const info = await fs.lstat(file);
17
+ if (!info.isFile() || info.isSymbolicLink() || info.size > 8 * 1024 * 1024)
18
+ throw new Error('文件不是普通 JSON 文件或超过 8 MiB');
19
+ return JSON.parse(await fs.readFile(file, 'utf8'));
20
+ }
21
+ catch (error) {
22
+ if (error.code === 'ENOENT')
23
+ return undefined;
24
+ throw new Error('记录无法读取,原文件已保留:' + file + '。' + (error instanceof Error ? error.message : String(error)));
25
+ }
26
+ }
27
+ async function locked(file, update) {
28
+ await fs.mkdir(path.dirname(file), { recursive: true });
29
+ const unlock = await lockfile.lock(file, { realpath: false, retries: { retries: 30, minTimeout: 25, maxTimeout: 100 } });
30
+ try {
31
+ return await update();
32
+ }
33
+ finally {
34
+ await unlock();
35
+ }
36
+ }
37
+ async function atomicWrite(file, value) {
38
+ const bytes = JSON.stringify(value, null, 2) + '\n';
39
+ if (Buffer.byteLength(bytes) > 8 * 1024 * 1024)
40
+ throw new Error('记录超过 8 MiB,本次没有保存。');
41
+ const temp = file + '.tmp-' + randomUUID();
42
+ try {
43
+ await fs.writeFile(temp, bytes, { flag: 'wx', mode: 0o600 });
44
+ await fs.rename(temp, file);
45
+ }
46
+ finally {
47
+ await fs.unlink(temp).catch(() => { });
48
+ }
49
+ }
12
50
  async function ensureDir() {
13
51
  await fs.mkdir(dataDir, { recursive: true });
14
52
  }
15
53
  async function readHistory() {
16
- try {
17
- const text = await fs.readFile(historyPath, 'utf8');
18
- const parsed = JSON.parse(text);
19
- return Array.isArray(parsed) ? parsed : [];
20
- }
21
- catch {
54
+ const parsed = await readJson(historyPath);
55
+ if (parsed === undefined)
22
56
  return [];
23
- }
57
+ if (!Array.isArray(parsed) || parsed.some(entry => !entry || typeof entry.target !== 'string' || typeof entry.timestamp !== 'string' || !Array.isArray(entry.flakyTests)))
58
+ throw new Error('历史记录格式无效,原文件已保留:' + historyPath);
59
+ return parsed;
24
60
  }
25
61
  async function appendHistory(entry) {
26
62
  await ensureDir();
27
- const list = await readHistory();
28
- list.unshift(entry);
29
- const trimmed = list.slice(0, HISTORY_LIMIT);
30
- const tmp = historyPath + '.tmp-' + process.pid;
31
- await fs.writeFile(tmp, JSON.stringify(trimmed, null, 2), 'utf8');
32
- await fs.rename(tmp, historyPath);
63
+ return locked(historyPath, async () => {
64
+ const list = await readHistory();
65
+ list.unshift(entry);
66
+ const trimmed = list.slice(0, HISTORY_LIMIT);
67
+ await atomicWrite(historyPath, trimmed);
68
+ });
33
69
  }
34
70
  async function listHistory(target, limit) {
35
71
  const list = await readHistory();
@@ -37,55 +73,52 @@ export function createStore(dataDir, quarantineFile) {
37
73
  return filtered.slice(0, limit);
38
74
  }
39
75
  async function readQuarantine() {
40
- try {
41
- const text = await fs.readFile(quarantineFile, 'utf8');
42
- const parsed = JSON.parse(text);
43
- const obj = (parsed ?? {});
44
- return {
45
- version: 1,
46
- quarantined: Array.isArray(obj.quarantined) ? obj.quarantined : [],
47
- };
48
- }
49
- catch {
76
+ const parsed = await readJson(quarantineFile);
77
+ if (parsed === undefined)
50
78
  return { version: 1, quarantined: [] };
51
- }
79
+ const obj = parsed;
80
+ if (!obj || obj.version !== 1 || !Array.isArray(obj.quarantined) || obj.quarantined.some(entry => !entry || typeof entry.file !== 'string' || !entry.file || entry.name !== null && typeof entry.name !== 'string' || typeof entry.reason !== 'string' || typeof entry.since !== 'string'))
81
+ throw new Error('隔离清单格式无效,原文件已保留:' + quarantineFile);
82
+ return obj;
52
83
  }
53
84
  async function writeQuarantine(doc) {
54
85
  await fs.mkdir(path.dirname(quarantineFile), { recursive: true });
55
- const tmp = quarantineFile + '.tmp-' + process.pid;
56
- await fs.writeFile(tmp, JSON.stringify(doc, null, 2) + '\n', 'utf8');
57
- await fs.rename(tmp, quarantineFile);
86
+ await atomicWrite(quarantineFile, doc);
58
87
  }
59
88
  async function addQuarantine(refs, reason) {
60
- const doc = await readQuarantine();
61
- const now = new Date().toISOString();
62
- const added = [];
63
- for (const ref of refs) {
64
- const existing = doc.quarantined.find(item => sameRef(item, ref));
65
- if (existing !== undefined) {
66
- existing.reason = reason;
67
- existing.since = now;
68
- continue;
89
+ return locked(quarantineFile, async () => {
90
+ const doc = await readQuarantine();
91
+ const now = new Date().toISOString();
92
+ const added = [];
93
+ for (const ref of refs) {
94
+ const existing = doc.quarantined.find(item => sameRef(item, ref));
95
+ if (existing !== undefined) {
96
+ existing.reason = reason;
97
+ existing.since = now;
98
+ continue;
99
+ }
100
+ const entry = { file: ref.file, name: ref.name, reason, since: now };
101
+ doc.quarantined.push(entry);
102
+ added.push(entry);
69
103
  }
70
- const entry = { file: ref.file, name: ref.name, reason, since: now };
71
- doc.quarantined.push(entry);
72
- added.push(entry);
73
- }
74
- doc.quarantined.sort((a, b) => a.file.localeCompare(b.file) || (a.name ?? '').localeCompare(b.name ?? ''));
75
- await writeQuarantine(doc);
76
- return { added, file: quarantineFile };
104
+ doc.quarantined.sort((a, b) => a.file.localeCompare(b.file) || (a.name ?? '').localeCompare(b.name ?? ''));
105
+ await writeQuarantine(doc);
106
+ return { added, file: quarantineFile };
107
+ });
77
108
  }
78
109
  async function removeQuarantine(refs) {
79
- const doc = await readQuarantine();
80
- const removed = [];
81
- doc.quarantined = doc.quarantined.filter(item => {
82
- const hit = refs.some(ref => sameRef(item, ref));
83
- if (hit)
84
- removed.push(item);
85
- return !hit;
110
+ return locked(quarantineFile, async () => {
111
+ const doc = await readQuarantine();
112
+ const removed = [];
113
+ doc.quarantined = doc.quarantined.filter(item => {
114
+ const hit = refs.some(ref => sameRef(item, ref));
115
+ if (hit)
116
+ removed.push(item);
117
+ return !hit;
118
+ });
119
+ await writeQuarantine(doc);
120
+ return { removed, file: quarantineFile };
86
121
  });
87
- await writeQuarantine(doc);
88
- return { removed, file: quarantineFile };
89
122
  }
90
123
  return {
91
124
  appendHistory,
package/lib/tools.js CHANGED
@@ -8,7 +8,8 @@ import { buildPlan, readFramework } from './args.js';
8
8
  import { aggregate, flakyTests } from './classify.js';
9
9
  import { assertTarget, optionalInteger, optionalString, requiredString, resolveRuns, } from './config.js';
10
10
  import { executePlan } from './runner.js';
11
- import { formatRef, parseRef } from './store.js';
11
+ import { createStore, formatRef, parseRef } from './store.js';
12
+ import { resolve } from 'node:path';
12
13
  function compileParameters(spec) {
13
14
  const properties = {};
14
15
  const required = [];
@@ -98,6 +99,18 @@ const detectSchema = {
98
99
  };
99
100
  /** 构建五个工具定义。 */
100
101
  export function buildFlakeTools(cfg, runner, store) {
102
+ return buildWorkspaceTools(cfg, runner, store).map(tool => ({ ...tool, async execute(args, exec) {
103
+ const context = exec;
104
+ const signal = context?.signal;
105
+ signal?.throwIfAborted();
106
+ const cwd = context?.agent?.session?.header?.cwd || process.cwd();
107
+ const scopedStore = context?.agent?.session?.header?.cwd && cfg.quarantineFileRelative ? createStore(cfg.dataDir, resolve(cwd, cfg.quarantineFileRelative)) : store;
108
+ const scopedRunner = { run: (argv, options) => runner.run(argv, { ...options, cwd, signal }) };
109
+ const selected = buildWorkspaceTools(cfg, scopedRunner, scopedStore, cwd, signal).find(row => row.name === tool.name);
110
+ return selected.execute(args, exec);
111
+ } }));
112
+ }
113
+ function buildWorkspaceTools(cfg, runner, store, cwd = process.cwd(), signal) {
101
114
  const detect = {
102
115
  name: 'flaky_detect',
103
116
  description: '重复运行测试多次并判定稳定性:stable-pass(全过)/ stable-fail(全挂)/ flaky(时好时坏)。支持 vitest / jest / pytest / node:test,自动探测本地框架。结果写入历史供 flaky_report 分析。',
@@ -119,11 +132,12 @@ export function buildFlakeTools(cfg, runner, store) {
119
132
  const target = assertTarget(requiredString(args, 'target', '测试目标'));
120
133
  const requested = readFramework(args.framework);
121
134
  const runs = resolveRuns(args.runs, cfg);
122
- const plans = Array.from({ length: runs }, (_, index) => buildPlan(process.cwd(), target, requested, index, cfg.pythonPath));
135
+ const plans = Array.from({ length: runs }, (_, index) => buildPlan(cwd, target, requested, index, cfg.pythonPath));
123
136
  const framework = plans[0]?.framework ?? 'node';
124
137
  const executed = [];
125
138
  for (let index = 0; index < plans.length; index += 1) {
126
- executed.push(await executePlan(runner, plans[index], index + 1, cfg.timeoutMs));
139
+ signal?.throwIfAborted();
140
+ executed.push(await executePlan(runner, plans[index], index + 1, cfg.timeoutMs, cwd, signal));
127
141
  }
128
142
  const result = aggregate(executed.map(item => ({ index: item.index, report: item.report, durationMs: item.durationMs, error: item.error })));
129
143
  const flaky = flakyTests(result);
@@ -140,6 +154,7 @@ export function buildFlakeTools(cfg, runner, store) {
140
154
  skippedCount: result.skippedCount,
141
155
  flakyTests: flaky,
142
156
  };
157
+ signal?.throwIfAborted();
143
158
  await store.appendHistory(entry);
144
159
  return {
145
160
  target,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-flakefinder",
3
- "version": "0.3.4",
4
- "description": "DSH 测试稳定性插件:重复运行 vitest/jest/pytest/node:test 找出 flaky 用例,提供历史与隔离清单,零运行时依赖。",
3
+ "version": "0.3.5",
4
+ "description": "重复运行测试,找出偶发失败用例并保留调查记录。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -17,7 +17,9 @@
17
17
  "lib",
18
18
  "cordis.patch.yml",
19
19
  "README.md",
20
- "README.en.md"
20
+ "README.en.md",
21
+ "docs",
22
+ "CHANGELOG.md"
21
23
  ],
22
24
  "scripts": {
23
25
  "build": "tsc -p tsconfig.json",
@@ -45,6 +47,7 @@
45
47
  },
46
48
  "devDependencies": {
47
49
  "@types/node": "^24.0.0",
50
+ "@types/proper-lockfile": "4.1.4",
48
51
  "typescript": "^5.6.0"
49
52
  },
50
53
  "repository": {
@@ -56,5 +59,8 @@
56
59
  },
57
60
  "homepage": "https://github.com/STARDUSTLC666/dsh-flakefinder#readme",
58
61
  "author": "stardustlc",
59
- "packageManager": "pnpm@11.7.0"
62
+ "packageManager": "pnpm@11.7.0",
63
+ "dependencies": {
64
+ "proper-lockfile": "4.1.2"
65
+ }
60
66
  }