pi-shepherd 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/index.ts +1 -1
- package/package.json +1 -4
- package/rules.json +22 -206
- package/shepherd/index.ts +1 -0
- package/shepherd/rules-editor.ts +80 -0
- package/shepherd/rules-tool-helpers.ts +120 -0
- package/shepherd/rules-tool-list.ts +126 -0
- package/shepherd/rules-tool.ts +74 -31
- package/shepherd/rules.ts +15 -3
- package/shepherd/tool-hooks.ts +3 -2
- package/node_modules/@pi-atelier/shared-utils/README.en.md +0 -182
- package/node_modules/@pi-atelier/shared-utils/README.md +0 -182
- package/node_modules/@pi-atelier/shared-utils/package.json +0 -51
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/agents.test.ts +0 -120
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/ephemeral.test.ts +0 -100
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/file-lock.test.ts +0 -152
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/filter-match.test.ts +0 -187
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/memory-parser.test.ts +0 -170
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/paths.test.ts +0 -126
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config-edge.test.ts +0 -138
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config.test.ts +0 -257
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools-mcp.test.ts +0 -189
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools.test.ts +0 -204
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-advanced.test.ts +0 -269
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-array.test.ts +0 -267
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup.test.ts +0 -520
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-read.test.ts +0 -116
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-write.test.ts +0 -119
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/tool-output.test.ts +0 -145
- package/node_modules/@pi-atelier/shared-utils/src/agents.ts +0 -39
- package/node_modules/@pi-atelier/shared-utils/src/ephemeral.ts +0 -42
- package/node_modules/@pi-atelier/shared-utils/src/file-lock.ts +0 -62
- package/node_modules/@pi-atelier/shared-utils/src/filter-match.ts +0 -100
- package/node_modules/@pi-atelier/shared-utils/src/index.ts +0 -71
- package/node_modules/@pi-atelier/shared-utils/src/memory-parser.ts +0 -96
- package/node_modules/@pi-atelier/shared-utils/src/paths.ts +0 -23
- package/node_modules/@pi-atelier/shared-utils/src/project-config.ts +0 -241
- package/node_modules/@pi-atelier/shared-utils/src/project-tools.ts +0 -191
- package/node_modules/@pi-atelier/shared-utils/src/settings-array.ts +0 -73
- package/node_modules/@pi-atelier/shared-utils/src/settings-backup-rollback.ts +0 -104
- package/node_modules/@pi-atelier/shared-utils/src/settings-backup-utils.ts +0 -75
- package/node_modules/@pi-atelier/shared-utils/src/settings-backup.ts +0 -172
- package/node_modules/@pi-atelier/shared-utils/src/settings.ts +0 -104
- package/node_modules/@pi-atelier/shared-utils/src/tool-output.ts +0 -149
- package/node_modules/@pi-atelier/shared-utils/tsconfig.json +0 -9
- package/node_modules/@pi-atelier/shared-utils/vitest.config.ts +0 -24
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
[English](README.en.md) | 程序中文文档
|
|
2
|
-
|
|
3
|
-
# pi-shared-utils
|
|
4
|
-
|
|
5
|
-
Shared utility library for the [pi](https://github.com/earendil-works/pi-coding-agent) extension ecosystem — memory file parsing, path constants, settings management, tool output truncation, and more. Used by 7+ pi extensions.
|
|
6
|
-
|
|
7
|
-
## Why You Need It
|
|
8
|
-
|
|
9
|
-
If you're building a pi extension, you'll inevitably need the same building blocks: reading settings, parsing memory files, truncating tool output, finding agent directories. pi-shared-utils provides these as a single dependency so every extension doesn't reinvent the wheel.
|
|
10
|
-
|
|
11
|
-
**Used by**: pi-memory, pi-context, pi-shepherd, pi-roadmap, pi-session-analyzer, pi-workflow, and more.
|
|
12
|
-
|
|
13
|
-
## How It Works
|
|
14
|
-
|
|
15
|
-
```
|
|
16
|
-
pi-shared-utils provides 6 independent modules:
|
|
17
|
-
|
|
18
|
-
┌─────────────────────────────────────────────────┐
|
|
19
|
-
│ memory-parser ── 解析 topic--kw1,kw2.md 文件名 │
|
|
20
|
-
│ paths ── pi agent 标准路径常量 │
|
|
21
|
-
│ settings ── settings.json 读写扩展配置段 │
|
|
22
|
-
│ tool-output ── 工具输出截断(防上下文溢出) │
|
|
23
|
-
│ agents ── 子代理定义文件发现 │
|
|
24
|
-
│ ephemeral ── 会话临时 hint/label 栈 │
|
|
25
|
-
└─────────────────────────────────────────────────┘
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
Each module is independently importable — use only what you need.
|
|
29
|
-
|
|
30
|
-
## Installation
|
|
31
|
-
|
|
32
|
-
```bash
|
|
33
|
-
pi install git:github.com/catlain/pi-atelier
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
> 这是 pi-atelier monorepo 内的 workspace 包,通常不需要单独安装。其他独立扩展通过 `bundledDependencies` 自动包含它。
|
|
37
|
-
|
|
38
|
-
## 导出模块
|
|
39
|
-
|
|
40
|
-
### 记忆文件解析 (`memory-parser`)
|
|
41
|
-
|
|
42
|
-
解析 `topic--kw1,kw2,kw3.md` 格式的记忆文件名,扫描目录生成索引。
|
|
43
|
-
|
|
44
|
-
```ts
|
|
45
|
-
import { parseFileName, buildFileName, scanMemoryDir } from "@pi-atelier/shared-utils";
|
|
46
|
-
|
|
47
|
-
// 解析文件名 → { topic, keywords }
|
|
48
|
-
const { topic, keywords } = parseFileName("coding_standards--编码,git,lint.md");
|
|
49
|
-
// topic = "coding_standards", keywords = ["编码", "git", "lint"]
|
|
50
|
-
|
|
51
|
-
// 反向构建文件名
|
|
52
|
-
const name = buildFileName("coding_standards", ["编码", "git", "lint"]);
|
|
53
|
-
// "coding_standards--编码,git,lint.md"
|
|
54
|
-
|
|
55
|
-
// 扫描目录,返回 MemoryEntry[]
|
|
56
|
-
const entries = await scanMemoryDir("/path/to/memory");
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
### 路径常量 (`paths`)
|
|
60
|
-
|
|
61
|
-
pi agent 标准路径,避免硬编码。
|
|
62
|
-
|
|
63
|
-
| 常量 | 路径 | 说明 |
|
|
64
|
-
|------|------|------|
|
|
65
|
-
| `AGENT_DIR` | `~/.pi/agent/` | agent 根目录 |
|
|
66
|
-
| `SETTINGS_PATH` | `~/.pi/agent/settings.json` | 全局设置 |
|
|
67
|
-
| `MODELS_CONFIG_PATH` | `~/.pi/agent/models.json` | 模型配置 |
|
|
68
|
-
| `MCP_CONFIG_PATH` | `~/.pi/agent/mcp.json` | MCP 服务器配置 |
|
|
69
|
-
| `MCP_CACHE_PATH` | `~/.pi/agent/mcp-cache/` | MCP 工具缓存 |
|
|
70
|
-
| `AGENTS_DIR` | `~/.pi/agent/agents/` | 子代理定义 |
|
|
71
|
-
| `GLOBAL_RULES_PATH` | `~/.pi/agent/rules.md` | 全局规则 |
|
|
72
|
-
| `MEMORY_DIR` | `~/.pi/agent/memory/` | 全局记忆 |
|
|
73
|
-
| `MEMORY_MD_PATH` | `MEMORY.md` | 记忆索引文件名 |
|
|
74
|
-
|
|
75
|
-
### 设置管理 (`settings`)
|
|
76
|
-
|
|
77
|
-
读写 `settings.json` 中扩展的自定义配置段。
|
|
78
|
-
|
|
79
|
-
```ts
|
|
80
|
-
import { getSettingsSection, patchSettingsSection, getSettingsValue, setSettingsValue } from "@pi-atelier/shared-utils";
|
|
81
|
-
|
|
82
|
-
// 读取扩展配置段
|
|
83
|
-
const config = await getSettingsSection("my-extension");
|
|
84
|
-
|
|
85
|
-
// 增量更新配置
|
|
86
|
-
await patchSettingsSection("my-extension", { enabled: true });
|
|
87
|
-
|
|
88
|
-
// 读取/写入单个值
|
|
89
|
-
const val = await getSettingsValue("my-extension", "key", "default");
|
|
90
|
-
await setSettingsValue("my-extension", "key", "new-value");
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
### 工具输出截断 (`tool-output`)
|
|
94
|
-
|
|
95
|
-
防止工具返回超大结果撑爆 LLM 上下文。
|
|
96
|
-
|
|
97
|
-
```ts
|
|
98
|
-
import { truncateToolOutput, truncatedResult, TOOL_OUTPUT_MAX_LINES } from "@pi-atelier/shared-utils";
|
|
99
|
-
|
|
100
|
-
// 截断过长的输出
|
|
101
|
-
const result = truncateToolOutput(longText, { maxLines: 200 });
|
|
102
|
-
// { text: "...", truncated: true, originalLines: 1500, keptLines: 200 }
|
|
103
|
-
|
|
104
|
-
// 快捷方式:返回 pi tool result 格式
|
|
105
|
-
return truncatedResult(text); // 自动截断 + 返回 { content: [{ type: "text", text }] }
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
### 子代理发现 (`agents`)
|
|
109
|
-
|
|
110
|
-
扫描 `~/.pi/agent/agents/` 目录下的子代理定义文件。
|
|
111
|
-
|
|
112
|
-
```ts
|
|
113
|
-
import { discoverAgents, getAgentDescription, formatAgentsList } from "@pi-atelier/shared-utils";
|
|
114
|
-
|
|
115
|
-
// 发现所有可用子代理
|
|
116
|
-
const agents = await discoverAgents();
|
|
117
|
-
// [{ name: "pv-executor", description: "...", filePath: "..." }, ...]
|
|
118
|
-
|
|
119
|
-
// 获取单个描述
|
|
120
|
-
const desc = await getAgentDescription("pv-executor");
|
|
121
|
-
|
|
122
|
-
// 格式化为可读列表
|
|
123
|
-
const list = formatAgentsList(agents);
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
### 会话临时数据 (`ephemeral`)
|
|
127
|
-
|
|
128
|
-
当前会话的临时 hint/label 栈,会话结束即消失。用于跨工具调用的轻量状态传递。
|
|
129
|
-
|
|
130
|
-
```ts
|
|
131
|
-
import { pushHint, hasHints, peekHints, drainHints, peekLabels } from "@pi-atelier/shared-utils";
|
|
132
|
-
|
|
133
|
-
pushHint({ key: "recent-files", values: ["file1.ts", "file2.ts"] });
|
|
134
|
-
const has = hasHints("recent-files");
|
|
135
|
-
const hints = peekHints("recent-files"); // 查看不移除
|
|
136
|
-
const all = drainHints(); // 取出并清空
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
## Best Practices
|
|
140
|
-
|
|
141
|
-
### ✅ Recommended
|
|
142
|
-
- Import only the modules you need to keep bundle size small
|
|
143
|
-
- Use `truncatedResult()` for all tool outputs — prevents context overflow
|
|
144
|
-
- Use `paths` constants instead of hardcoding `~/.pi/agent/...`
|
|
145
|
-
- Use `settings` module for any persistent configuration
|
|
146
|
-
|
|
147
|
-
### ❌ Not Recommended
|
|
148
|
-
- Don't hardcode pi paths — they may change between versions
|
|
149
|
-
- Don't return raw tool output without truncation
|
|
150
|
-
- Don't use `ephemeral` for persistent data — it's session-scoped only
|
|
151
|
-
|
|
152
|
-
## Limitations
|
|
153
|
-
|
|
154
|
-
| Limitation | Detail |
|
|
155
|
-
|------------|--------|
|
|
156
|
-
| Memory file format only | Only supports `topic--kw1,kw2.md` naming convention |
|
|
157
|
-
| No validation | Settings reads don't validate schema — caller must handle |
|
|
158
|
-
| Ephemeral is in-memory | Lost on process restart, not persisted to disk |
|
|
159
|
-
| Token estimation | `tool-output` truncates by lines, not by token count |
|
|
160
|
-
|
|
161
|
-
## Architecture
|
|
162
|
-
|
|
163
|
-
```
|
|
164
|
-
pi-shared-utils/
|
|
165
|
-
├── src/
|
|
166
|
-
│ ├── index.ts # Re-exports all modules
|
|
167
|
-
│ ├── memory-parser.ts # Memory file name parsing + directory scanning
|
|
168
|
-
│ ├── paths.ts # Path constants (AGENT_DIR, SETTINGS_PATH, ...)
|
|
169
|
-
│ ├── settings.ts # settings.json section read/write
|
|
170
|
-
│ ├── tool-output.ts # Output truncation + truncatedResult helper
|
|
171
|
-
│ ├── agents.ts # Sub-agent discovery from ~/.pi/agent/agents/
|
|
172
|
-
│ ├── ephemeral.ts # Session-scoped hint/label stack
|
|
173
|
-
│ └── __tests__/ # Unit tests
|
|
174
|
-
├── package.json
|
|
175
|
-
└── tsconfig.json
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
**Dependencies**: Zero runtime dependencies (pure Node.js).
|
|
179
|
-
|
|
180
|
-
## License
|
|
181
|
-
|
|
182
|
-
MIT
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@pi-atelier/shared-utils",
|
|
3
|
-
"version": "1.0.0",
|
|
4
|
-
"description": "Shared utilities for pi-atelier extensions — settings, project config, memory parsing, tool output truncation, and more",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "src/index.ts",
|
|
7
|
-
"types": "src/index.ts",
|
|
8
|
-
"exports": {
|
|
9
|
-
".": "./src/index.ts",
|
|
10
|
-
"./*": "./src/*.ts"
|
|
11
|
-
},
|
|
12
|
-
"scripts": {
|
|
13
|
-
"build": "tsup",
|
|
14
|
-
"test": "vitest run",
|
|
15
|
-
"lint": "biome check lib src index.ts",
|
|
16
|
-
"typecheck": "tsc --noEmit"
|
|
17
|
-
},
|
|
18
|
-
"peerDependencies": {
|
|
19
|
-
"@earendil-works/pi-coding-agent": ">=0.6.0"
|
|
20
|
-
},
|
|
21
|
-
"peerDependenciesMeta": {
|
|
22
|
-
"@earendil-works/pi-coding-agent": {
|
|
23
|
-
"optional": true
|
|
24
|
-
}
|
|
25
|
-
},
|
|
26
|
-
"devDependencies": {
|
|
27
|
-
"@biomejs/biome": "^2.4.15",
|
|
28
|
-
"@vitest/coverage-v8": "^3.2.4",
|
|
29
|
-
"tsup": "^8.0.0",
|
|
30
|
-
"typescript": "^5.7.0",
|
|
31
|
-
"vitest": "^3.0.0"
|
|
32
|
-
},
|
|
33
|
-
"tsup": {
|
|
34
|
-
"entry": [
|
|
35
|
-
"src/index.ts"
|
|
36
|
-
],
|
|
37
|
-
"format": [
|
|
38
|
-
"esm"
|
|
39
|
-
],
|
|
40
|
-
"dts": true,
|
|
41
|
-
"clean": true
|
|
42
|
-
},
|
|
43
|
-
"files": [
|
|
44
|
-
"src/",
|
|
45
|
-
"README.md",
|
|
46
|
-
"README.en.md",
|
|
47
|
-
"tsconfig.json",
|
|
48
|
-
"vitest.config.ts",
|
|
49
|
-
"package.json"
|
|
50
|
-
]
|
|
51
|
-
}
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
|
|
3
|
-
// vi.mock factory is hoisted, use vi.hoisted to declare variables at the hoisted position
|
|
4
|
-
const mockFs = vi.hoisted(() => ({
|
|
5
|
-
existsSync: vi.fn(),
|
|
6
|
-
readdirSync: vi.fn(),
|
|
7
|
-
readFileSync: vi.fn(),
|
|
8
|
-
}));
|
|
9
|
-
|
|
10
|
-
vi.mock("node:fs", () => mockFs);
|
|
11
|
-
|
|
12
|
-
import { discoverAgents, getAgentDescription, formatAgentsList } from "../agents";
|
|
13
|
-
|
|
14
|
-
beforeEach(() => {
|
|
15
|
-
vi.clearAllMocks();
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
describe("discoverAgents", () => {
|
|
19
|
-
it("returns empty array when AGENTS_DIR does not exist", () => {
|
|
20
|
-
mockFs.existsSync.mockReturnValue(false);
|
|
21
|
-
expect(discoverAgents()).toEqual([]);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("filters .md files and strips leading underscore", () => {
|
|
25
|
-
mockFs.existsSync.mockReturnValue(true);
|
|
26
|
-
mockFs.readdirSync.mockReturnValue([
|
|
27
|
-
"coder.md",
|
|
28
|
-
"_private.md",
|
|
29
|
-
"reviewer.md",
|
|
30
|
-
"readme.txt",
|
|
31
|
-
"notes.md",
|
|
32
|
-
]);
|
|
33
|
-
const result = discoverAgents();
|
|
34
|
-
expect(result).toEqual(["coder", "reviewer", "notes"]);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("returns empty when only non-md files exist", () => {
|
|
38
|
-
mockFs.existsSync.mockReturnValue(true);
|
|
39
|
-
mockFs.readdirSync.mockReturnValue(["file.txt", "file.json"]);
|
|
40
|
-
expect(discoverAgents()).toEqual([]);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("returns empty when only underscored md files exist", () => {
|
|
44
|
-
mockFs.existsSync.mockReturnValue(true);
|
|
45
|
-
mockFs.readdirSync.mockReturnValue(["_private.md", "_template.md"]);
|
|
46
|
-
expect(discoverAgents()).toEqual([]);
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
describe("getAgentDescription", () => {
|
|
51
|
-
it("extracts description from frontmatter", () => {
|
|
52
|
-
const content = [
|
|
53
|
-
"---",
|
|
54
|
-
"description: 代码审查助手",
|
|
55
|
-
"version: 1.0",
|
|
56
|
-
"---",
|
|
57
|
-
"# Coder Agent",
|
|
58
|
-
"Some content",
|
|
59
|
-
].join("\n");
|
|
60
|
-
mockFs.readFileSync.mockReturnValue(content);
|
|
61
|
-
const result = getAgentDescription("coder");
|
|
62
|
-
expect(result).toBe("代码审查助手");
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("uses default description when no frontmatter", () => {
|
|
66
|
-
mockFs.readFileSync.mockReturnValue("plain content without frontmatter");
|
|
67
|
-
const result = getAgentDescription("coder");
|
|
68
|
-
expect(result).toBe("read, grep, find, ls");
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("uses default description when file read fails", () => {
|
|
72
|
-
mockFs.readFileSync.mockImplementation(() => {
|
|
73
|
-
throw new Error("ENOENT");
|
|
74
|
-
});
|
|
75
|
-
const result = getAgentDescription("nonexistent");
|
|
76
|
-
expect(result).toBe("read, grep, find, ls");
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("uses default description when frontmatter has no description field", () => {
|
|
80
|
-
const content = [
|
|
81
|
-
"---",
|
|
82
|
-
"title: Agent",
|
|
83
|
-
"---",
|
|
84
|
-
"# Content",
|
|
85
|
-
].join("\n");
|
|
86
|
-
mockFs.readFileSync.mockReturnValue(content);
|
|
87
|
-
const result = getAgentDescription("agent");
|
|
88
|
-
expect(result).toBe("read, grep, find, ls");
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("handles description with trailing whitespace", () => {
|
|
92
|
-
const content = [
|
|
93
|
-
"---",
|
|
94
|
-
"description: 帮我写代码 ",
|
|
95
|
-
"---",
|
|
96
|
-
].join("\n");
|
|
97
|
-
mockFs.readFileSync.mockReturnValue(content);
|
|
98
|
-
const result = getAgentDescription("agent");
|
|
99
|
-
expect(result).toBe("帮我写代码");
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
describe("formatAgentsList", () => {
|
|
104
|
-
it("returns placeholder when no agents", () => {
|
|
105
|
-
mockFs.existsSync.mockReturnValue(false);
|
|
106
|
-
expect(formatAgentsList()).toBe("(无可用子代理)");
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("formats agent list with descriptions", () => {
|
|
110
|
-
mockFs.existsSync.mockReturnValue(true);
|
|
111
|
-
mockFs.readdirSync.mockReturnValue(["coder.md", "reviewer.md"]);
|
|
112
|
-
mockFs.readFileSync.mockImplementation((filePath: string) => {
|
|
113
|
-
if (filePath.includes("coder")) return "---\ndescription: Coder助手\n---\n";
|
|
114
|
-
if (filePath.includes("reviewer")) return "---\ndescription: Reviewer助手\n---\n";
|
|
115
|
-
return "";
|
|
116
|
-
});
|
|
117
|
-
const result = formatAgentsList();
|
|
118
|
-
expect(result).toBe("- **coder**: Coder助手\n- **reviewer**: Reviewer助手");
|
|
119
|
-
});
|
|
120
|
-
});
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ephemeral.ts 测试
|
|
3
|
-
*
|
|
4
|
-
* 模块内 _hints/_labels 是模块级变量,每次 import 可能返回缓存模块。
|
|
5
|
-
* 使用 vi.resetModules() 确保每个测试得到干净的模块状态。
|
|
6
|
-
*/
|
|
7
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
8
|
-
|
|
9
|
-
function importEphemeral() {
|
|
10
|
-
return import("../ephemeral");
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
beforeEach(() => {
|
|
14
|
-
vi.resetModules();
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
describe("ephemeral hints lifecycle", () => {
|
|
18
|
-
it("starts empty: hasHints returns false", async () => {
|
|
19
|
-
const { hasHints } = await importEphemeral();
|
|
20
|
-
expect(hasHints()).toBe(false);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("peekHints returns null when empty", async () => {
|
|
24
|
-
const { peekHints } = await importEphemeral();
|
|
25
|
-
expect(peekHints()).toBeNull();
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("drainHints returns null when empty", async () => {
|
|
29
|
-
const { drainHints } = await importEphemeral();
|
|
30
|
-
expect(drainHints()).toBeNull();
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("peekLabels returns empty array when empty", async () => {
|
|
34
|
-
const { peekLabels } = await importEphemeral();
|
|
35
|
-
expect(peekLabels()).toEqual([]);
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
describe("pushHint and peek", () => {
|
|
40
|
-
it("pushHint adds hint, peekHints returns it without consuming", async () => {
|
|
41
|
-
const mod = await importEphemeral();
|
|
42
|
-
mod.pushHint("提示A");
|
|
43
|
-
expect(mod.hasHints()).toBe(true);
|
|
44
|
-
expect(mod.peekHints()).toBe("提示A");
|
|
45
|
-
// peek does not consume
|
|
46
|
-
expect(mod.peekHints()).toBe("提示A");
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("pushHint with label adds label to peekLabels", async () => {
|
|
50
|
-
const mod = await importEphemeral();
|
|
51
|
-
mod.pushHint("提示B", "label-b");
|
|
52
|
-
expect(mod.peekLabels()).toEqual(["label-b"]);
|
|
53
|
-
expect(mod.peekHints()).toBe("提示B");
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("multiple hints joined by double newline", async () => {
|
|
57
|
-
const mod = await importEphemeral();
|
|
58
|
-
mod.pushHint("first");
|
|
59
|
-
mod.pushHint("second");
|
|
60
|
-
expect(mod.peekHints()).toBe("first\n\nsecond");
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
describe("drainHints", () => {
|
|
65
|
-
it("drainHints returns hints and clears state", async () => {
|
|
66
|
-
const mod = await importEphemeral();
|
|
67
|
-
mod.pushHint("hint1", "lbl1");
|
|
68
|
-
mod.pushHint("hint2", "lbl2");
|
|
69
|
-
const result = mod.drainHints();
|
|
70
|
-
expect(result).toBe("hint1\n\nhint2");
|
|
71
|
-
// after drain, state is cleared
|
|
72
|
-
expect(mod.hasHints()).toBe(false);
|
|
73
|
-
expect(mod.peekHints()).toBeNull();
|
|
74
|
-
expect(mod.peekLabels()).toEqual([]);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("labels are cleared after drain", async () => {
|
|
78
|
-
const mod = await importEphemeral();
|
|
79
|
-
mod.pushHint("test", "mylabel");
|
|
80
|
-
mod.drainHints();
|
|
81
|
-
expect(mod.peekLabels()).toEqual([]);
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
describe("multiple push then drain", () => {
|
|
86
|
-
it("push 3 hints without labels", async () => {
|
|
87
|
-
const mod = await importEphemeral();
|
|
88
|
-
mod.pushHint("a");
|
|
89
|
-
mod.pushHint("b");
|
|
90
|
-
mod.pushHint("c");
|
|
91
|
-
expect(mod.drainHints()).toBe("a\n\nb\n\nc");
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("push with labels, peekLabels returns all labels", async () => {
|
|
95
|
-
const mod = await importEphemeral();
|
|
96
|
-
mod.pushHint("x", "l1");
|
|
97
|
-
mod.pushHint("y", "l2");
|
|
98
|
-
expect(mod.peekLabels()).toEqual(["l1", "l2"]);
|
|
99
|
-
});
|
|
100
|
-
});
|
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
|
|
3
|
-
// ── Mock fs ─────────────────────────────────────────────
|
|
4
|
-
// vi.hoisted ensures variables are available before vi.mock factory runs
|
|
5
|
-
|
|
6
|
-
const { mockMkdirSync, mockRmSync, mockStatSync } = vi.hoisted(() => ({
|
|
7
|
-
mockMkdirSync: vi.fn(),
|
|
8
|
-
mockRmSync: vi.fn(),
|
|
9
|
-
mockStatSync: vi.fn(),
|
|
10
|
-
}));
|
|
11
|
-
|
|
12
|
-
vi.mock("node:fs", () => ({
|
|
13
|
-
mkdirSync: mockMkdirSync,
|
|
14
|
-
rmSync: mockRmSync,
|
|
15
|
-
statSync: mockStatSync,
|
|
16
|
-
}));
|
|
17
|
-
|
|
18
|
-
import { acquireLock, releaseLock, withFileLock } from "../file-lock";
|
|
19
|
-
|
|
20
|
-
const EEXIST_ERROR = Object.assign(new Error("EEXIST"), { code: "EEXIST" });
|
|
21
|
-
const UNKNOWN_ERROR = new Error("unknown");
|
|
22
|
-
|
|
23
|
-
function makeRecentStat(): { mtimeMs: number } {
|
|
24
|
-
return { mtimeMs: Date.now() };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function makeStaleStat(): { mtimeMs: number } {
|
|
28
|
-
return { mtimeMs: 0 };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// ── Tests ───────────────────────────────────────────────
|
|
32
|
-
|
|
33
|
-
beforeEach(() => {
|
|
34
|
-
vi.clearAllMocks();
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
describe("acquireLock", () => {
|
|
38
|
-
it("succeeds on first attempt", () => {
|
|
39
|
-
mockMkdirSync.mockReturnValue(undefined);
|
|
40
|
-
acquireLock("/tmp/test.lock");
|
|
41
|
-
expect(mockMkdirSync).toHaveBeenCalledTimes(1);
|
|
42
|
-
expect(mockMkdirSync).toHaveBeenCalledWith("/tmp/test.lock.lock");
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("rethrows non-EEXIST error", () => {
|
|
46
|
-
mockMkdirSync.mockImplementation(() => {
|
|
47
|
-
throw UNKNOWN_ERROR;
|
|
48
|
-
});
|
|
49
|
-
expect(() => acquireLock("/tmp/test.lock")).toThrow(UNKNOWN_ERROR);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("handles EEXIST with stale lock, removes and retries success", () => {
|
|
53
|
-
mockMkdirSync
|
|
54
|
-
.mockImplementationOnce(() => {
|
|
55
|
-
throw EEXIST_ERROR;
|
|
56
|
-
})
|
|
57
|
-
.mockReturnValue(undefined);
|
|
58
|
-
mockStatSync.mockReturnValue(makeStaleStat());
|
|
59
|
-
|
|
60
|
-
acquireLock("/tmp/test.lock");
|
|
61
|
-
|
|
62
|
-
expect(mockMkdirSync).toHaveBeenCalledTimes(2);
|
|
63
|
-
expect(mockStatSync).toHaveBeenCalledTimes(1);
|
|
64
|
-
expect(mockStatSync).toHaveBeenCalledWith("/tmp/test.lock.lock");
|
|
65
|
-
expect(mockRmSync).toHaveBeenCalledWith("/tmp/test.lock.lock", { recursive: true });
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("handles EEXIST with statSync throw, retries and succeeds", () => {
|
|
69
|
-
mockMkdirSync
|
|
70
|
-
.mockImplementationOnce(() => {
|
|
71
|
-
throw EEXIST_ERROR;
|
|
72
|
-
})
|
|
73
|
-
.mockReturnValue(undefined);
|
|
74
|
-
mockStatSync.mockImplementation(() => {
|
|
75
|
-
throw new Error("ENOENT");
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
acquireLock("/tmp/test.lock");
|
|
79
|
-
|
|
80
|
-
expect(mockMkdirSync).toHaveBeenCalledTimes(2);
|
|
81
|
-
expect(mockStatSync).toHaveBeenCalledTimes(1);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("handles EEXIST with non-stale lock, retries and succeeds after busy-wait", () => {
|
|
85
|
-
mockMkdirSync
|
|
86
|
-
.mockImplementationOnce(() => {
|
|
87
|
-
throw EEXIST_ERROR;
|
|
88
|
-
})
|
|
89
|
-
.mockReturnValue(undefined);
|
|
90
|
-
mockStatSync.mockReturnValue(makeRecentStat());
|
|
91
|
-
|
|
92
|
-
acquireLock("/tmp/test.lock");
|
|
93
|
-
|
|
94
|
-
expect(mockMkdirSync).toHaveBeenCalledTimes(2);
|
|
95
|
-
expect(mockStatSync).toHaveBeenCalledTimes(1);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("times out after MAX_ATTEMPTS with EEXIST each time", () => {
|
|
99
|
-
mockMkdirSync.mockImplementation(() => {
|
|
100
|
-
throw EEXIST_ERROR;
|
|
101
|
-
});
|
|
102
|
-
mockStatSync.mockReturnValue(makeRecentStat());
|
|
103
|
-
|
|
104
|
-
expect(() => acquireLock("/tmp/test.lock")).toThrow(
|
|
105
|
-
"无法获取文件锁: /tmp/test.lock.lock(尝试 50 次后超时)",
|
|
106
|
-
);
|
|
107
|
-
|
|
108
|
-
expect(mockMkdirSync).toHaveBeenCalledTimes(50);
|
|
109
|
-
expect(mockStatSync).toHaveBeenCalledTimes(50);
|
|
110
|
-
expect(mockRmSync).not.toHaveBeenCalled();
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
describe("releaseLock", () => {
|
|
115
|
-
it("removes lock directory", () => {
|
|
116
|
-
mockRmSync.mockReturnValue(undefined);
|
|
117
|
-
releaseLock("/tmp/test.lock");
|
|
118
|
-
expect(mockRmSync).toHaveBeenCalledWith("/tmp/test.lock.lock", { recursive: true });
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("silently ignores rmSync errors", () => {
|
|
122
|
-
mockRmSync.mockImplementation(() => {
|
|
123
|
-
throw new Error("ENOENT");
|
|
124
|
-
});
|
|
125
|
-
expect(() => releaseLock("/tmp/test.lock")).not.toThrow();
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
describe("withFileLock", () => {
|
|
130
|
-
it("acquires lock, executes fn, releases lock", () => {
|
|
131
|
-
mockMkdirSync.mockReturnValue(undefined);
|
|
132
|
-
mockRmSync.mockReturnValue(undefined);
|
|
133
|
-
|
|
134
|
-
const result = withFileLock("/tmp/test.lock", () => "done");
|
|
135
|
-
|
|
136
|
-
expect(result).toBe("done");
|
|
137
|
-
expect(mockMkdirSync).toHaveBeenCalledWith("/tmp/test.lock.lock");
|
|
138
|
-
expect(mockRmSync).toHaveBeenCalledWith("/tmp/test.lock.lock", { recursive: true });
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("releases lock even when fn throws", () => {
|
|
142
|
-
mockMkdirSync.mockReturnValue(undefined);
|
|
143
|
-
|
|
144
|
-
expect(() =>
|
|
145
|
-
withFileLock("/tmp/test.lock", () => {
|
|
146
|
-
throw new Error("fn failed");
|
|
147
|
-
}),
|
|
148
|
-
).toThrow("fn failed");
|
|
149
|
-
|
|
150
|
-
expect(mockRmSync).toHaveBeenCalledWith("/tmp/test.lock.lock", { recursive: true });
|
|
151
|
-
});
|
|
152
|
-
});
|