@dommaker/harness 0.16.1 → 0.16.3
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 +26 -0
- package/README.md +1 -0
- package/bin/harness.js +2 -1
- package/dist/cli/commands/sync-docs.d.ts +2 -0
- package/dist/cli/commands/sync-docs.d.ts.map +1 -1
- package/dist/cli/commands/sync-docs.js +322 -2
- package/dist/cli/commands/sync-docs.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/CONTEXT.md +3 -3
- package/src/cli/commands/__tests__/sync-docs-agents.test.ts +423 -0
- package/src/cli/commands/__tests__/sync-docs.test.ts +27 -0
- package/src/cli/commands/sync-docs.ts +357 -2
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sync-docs --agents(AGENTS.md 生成)测试
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import { syncDocs } from '../sync-docs';
|
|
8
|
+
|
|
9
|
+
describe('sync-docs --agents', () => {
|
|
10
|
+
const tempDir = path.join(process.cwd(), 'temp-test-sync-docs-agents');
|
|
11
|
+
|
|
12
|
+
beforeAll(() => {
|
|
13
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterAll(() => {
|
|
17
|
+
try {
|
|
18
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
19
|
+
} catch {
|
|
20
|
+
// ignore
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
let consoleSpy: jest.SpyInstance;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
jest.clearAllMocks();
|
|
28
|
+
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
consoleSpy.mockRestore();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 创建夹具仓库:package.json + .harness/config.yml + 知识库 + CLAUDE.md 治理块 + src/docs 目录
|
|
37
|
+
*/
|
|
38
|
+
function createFixture(dir: string, opts: { pnpm?: boolean; withCapabilities?: boolean } = {}): void {
|
|
39
|
+
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
40
|
+
fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
|
|
41
|
+
fs.mkdirSync(path.join(dir, '.harness', 'knowledge'), { recursive: true });
|
|
42
|
+
|
|
43
|
+
fs.writeFileSync(path.join(dir, 'src', 'app.ts'), '/**\n * App module\n */\nexport const app = 1;');
|
|
44
|
+
fs.writeFileSync(path.join(dir, 'src', 'CONTEXT.md'), '# src\n\n夹具模块文档\n');
|
|
45
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
|
|
46
|
+
name: 'fixture-app',
|
|
47
|
+
description: '测试夹具项目',
|
|
48
|
+
scripts: {
|
|
49
|
+
dev: 'vite',
|
|
50
|
+
build: 'tsc',
|
|
51
|
+
test: 'jest',
|
|
52
|
+
'test:e2e': 'jest e2e',
|
|
53
|
+
typecheck: 'tsc --noEmit',
|
|
54
|
+
lint: 'eslint .',
|
|
55
|
+
start: 'node dist/index.js',
|
|
56
|
+
clean: 'rimraf dist',
|
|
57
|
+
},
|
|
58
|
+
}, null, 2));
|
|
59
|
+
fs.writeFileSync(path.join(dir, '.harness', 'config.yml'), 'preset: standard\nenabled: true\n');
|
|
60
|
+
fs.writeFileSync(path.join(dir, '.harness', 'knowledge', 'k1.md'), '# k1\n');
|
|
61
|
+
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), [
|
|
62
|
+
'# CLAUDE.md',
|
|
63
|
+
'',
|
|
64
|
+
'## Governance Rules',
|
|
65
|
+
'<!-- HARNESS_CONSTRAINTS_START -->',
|
|
66
|
+
'### Iron Laws (违反将阻断)',
|
|
67
|
+
'- **law_a**: 描述 a',
|
|
68
|
+
'- **law_b**: 描述 b',
|
|
69
|
+
'### Guidelines (应遵循)',
|
|
70
|
+
'- **g_a**: 描述',
|
|
71
|
+
'- **g_b**: 描述',
|
|
72
|
+
'- **g_c**: 描述',
|
|
73
|
+
'### Tips',
|
|
74
|
+
'- **t_a**: 描述',
|
|
75
|
+
'<!-- HARNESS_CONSTRAINTS_END -->',
|
|
76
|
+
].join('\n'));
|
|
77
|
+
|
|
78
|
+
if (opts.pnpm) {
|
|
79
|
+
fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), 'lockfileVersion: 1\n');
|
|
80
|
+
}
|
|
81
|
+
if (opts.withCapabilities) {
|
|
82
|
+
fs.writeFileSync(
|
|
83
|
+
path.join(dir, 'CAPABILITIES.md'),
|
|
84
|
+
'# Capabilities\n\n| 模块 | 文件 | 说明 |\n|------|------|------|\n| app | src/app.ts | App module |'
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
it('应该从夹具仓库生成 AGENTS.md(pnpm 项目)', async () => {
|
|
90
|
+
const testDir = path.join(tempDir, 'gen-pnpm');
|
|
91
|
+
createFixture(testDir, { pnpm: true });
|
|
92
|
+
|
|
93
|
+
await syncDocs({ projectPath: testDir, agents: true });
|
|
94
|
+
|
|
95
|
+
const agentsPath = path.join(testDir, 'AGENTS.md');
|
|
96
|
+
expect(fs.existsSync(agentsPath)).toBe(true);
|
|
97
|
+
const content = fs.readFileSync(agentsPath, 'utf-8');
|
|
98
|
+
|
|
99
|
+
// 项目简介
|
|
100
|
+
expect(content).toContain('**fixture-app** — 测试夹具项目');
|
|
101
|
+
// 目录结构
|
|
102
|
+
expect(content).toContain('`docs/`');
|
|
103
|
+
expect(content).toContain('项目文档');
|
|
104
|
+
expect(content).toContain('`src/`');
|
|
105
|
+
expect(content).toContain('源码目录');
|
|
106
|
+
// 常用命令(仅 curated,不含 clean;pnpm 前缀)
|
|
107
|
+
expect(content).toContain('pnpm dev');
|
|
108
|
+
expect(content).toContain('pnpm build');
|
|
109
|
+
expect(content).toContain('pnpm test:e2e');
|
|
110
|
+
expect(content).toContain('pnpm typecheck');
|
|
111
|
+
expect(content).toContain('pnpm start');
|
|
112
|
+
expect(content).not.toContain('clean');
|
|
113
|
+
// 约束与治理
|
|
114
|
+
expect(content).toContain('preset: standard');
|
|
115
|
+
expect(content).toContain('Iron Laws 2 条、Guidelines 3 条');
|
|
116
|
+
// 知识入口
|
|
117
|
+
expect(content).toContain('项目知识库(1 条)');
|
|
118
|
+
expect(content).toContain('CONTEXT.md');
|
|
119
|
+
expect(content).toContain('现有 1 个');
|
|
120
|
+
|
|
121
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('npm 项目应该使用 npm run 前缀', async () => {
|
|
125
|
+
const testDir = path.join(tempDir, 'gen-npm');
|
|
126
|
+
createFixture(testDir);
|
|
127
|
+
|
|
128
|
+
await syncDocs({ projectPath: testDir, agents: true });
|
|
129
|
+
|
|
130
|
+
const content = fs.readFileSync(path.join(testDir, 'AGENTS.md'), 'utf-8');
|
|
131
|
+
expect(content).toContain('npm run build');
|
|
132
|
+
expect(content).toContain('npm test');
|
|
133
|
+
expect(content).toContain('npm start');
|
|
134
|
+
expect(content).toContain('npm run typecheck');
|
|
135
|
+
|
|
136
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('重复运行应该幂等(内容一致且 --check 通过)', async () => {
|
|
140
|
+
const testDir = path.join(tempDir, 'idempotent');
|
|
141
|
+
createFixture(testDir, { pnpm: true, withCapabilities: true });
|
|
142
|
+
|
|
143
|
+
await syncDocs({ projectPath: testDir, agents: true });
|
|
144
|
+
const first = fs.readFileSync(path.join(testDir, 'AGENTS.md'), 'utf-8');
|
|
145
|
+
|
|
146
|
+
// 第二次运行:无任何差异
|
|
147
|
+
const secondRun = await syncDocs({ projectPath: testDir, agents: true });
|
|
148
|
+
const second = fs.readFileSync(path.join(testDir, 'AGENTS.md'), 'utf-8');
|
|
149
|
+
expect(second).toBe(first);
|
|
150
|
+
expect(secondRun).toBe(true);
|
|
151
|
+
|
|
152
|
+
// --check 模式应通过
|
|
153
|
+
const checkResult = await syncDocs({ projectPath: testDir, agents: true, check: true });
|
|
154
|
+
expect(checkResult).toBe(true);
|
|
155
|
+
|
|
156
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('--check 模式应该检测 AGENTS.md 缺失', async () => {
|
|
160
|
+
const testDir = path.join(tempDir, 'check-missing');
|
|
161
|
+
createFixture(testDir, { withCapabilities: true });
|
|
162
|
+
|
|
163
|
+
const result = await syncDocs({ projectPath: testDir, agents: true, check: true });
|
|
164
|
+
expect(result).toBe(false);
|
|
165
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('缺少 AGENTS.md'));
|
|
166
|
+
// check 模式不写入
|
|
167
|
+
expect(fs.existsSync(path.join(testDir, 'AGENTS.md'))).toBe(false);
|
|
168
|
+
|
|
169
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('--check 模式应该检测内容漂移', async () => {
|
|
173
|
+
const testDir = path.join(tempDir, 'check-drift');
|
|
174
|
+
createFixture(testDir, { pnpm: true, withCapabilities: true });
|
|
175
|
+
|
|
176
|
+
// 先生成
|
|
177
|
+
await syncDocs({ projectPath: testDir, agents: true });
|
|
178
|
+
|
|
179
|
+
// 漂移 1:手改 AGENTS.md
|
|
180
|
+
fs.appendFileSync(path.join(testDir, 'AGENTS.md'), '\n手动追加的一行\n');
|
|
181
|
+
expect(await syncDocs({ projectPath: testDir, agents: true, check: true })).toBe(false);
|
|
182
|
+
|
|
183
|
+
// 重新生成恢复
|
|
184
|
+
await syncDocs({ projectPath: testDir, agents: true });
|
|
185
|
+
expect(await syncDocs({ projectPath: testDir, agents: true, check: true })).toBe(true);
|
|
186
|
+
|
|
187
|
+
// 漂移 2:package.json 删除一个 curated 脚本
|
|
188
|
+
const pkgPath = path.join(testDir, 'package.json');
|
|
189
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
190
|
+
delete pkg.scripts.lint;
|
|
191
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
|
|
192
|
+
expect(await syncDocs({ projectPath: testDir, agents: true, check: true })).toBe(false);
|
|
193
|
+
|
|
194
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('未启用 --agents 时不应生成 AGENTS.md', async () => {
|
|
198
|
+
const testDir = path.join(tempDir, 'no-agents');
|
|
199
|
+
createFixture(testDir, { withCapabilities: true });
|
|
200
|
+
|
|
201
|
+
const result = await syncDocs({ projectPath: testDir });
|
|
202
|
+
expect(result).toBe(true);
|
|
203
|
+
expect(fs.existsSync(path.join(testDir, 'AGENTS.md'))).toBe(false);
|
|
204
|
+
|
|
205
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('--json 模式应该输出 agentsMd 状态', async () => {
|
|
209
|
+
const testDir = path.join(tempDir, 'json-agents');
|
|
210
|
+
createFixture(testDir, { withCapabilities: true });
|
|
211
|
+
|
|
212
|
+
const result = await syncDocs({ projectPath: testDir, agents: true, check: true, json: true });
|
|
213
|
+
expect(result).toBe(false);
|
|
214
|
+
|
|
215
|
+
const jsonCall = consoleSpy.mock.calls.find(
|
|
216
|
+
(call: unknown[]) => typeof call[0] === 'string' && call[0].startsWith('{')
|
|
217
|
+
);
|
|
218
|
+
expect(jsonCall).toBeDefined();
|
|
219
|
+
const parsed = JSON.parse(jsonCall![0]);
|
|
220
|
+
expect(parsed.stale).toBe(true);
|
|
221
|
+
expect(parsed.agentsMd).toEqual({ file: 'AGENTS.md', exists: false, stale: true });
|
|
222
|
+
expect(parsed.resolution.some((r: { action: string }) => r.action === 'sync-agents-md')).toBe(true);
|
|
223
|
+
|
|
224
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('yarn 项目应该使用 yarn 前缀执行脚本', async () => {
|
|
228
|
+
const testDir = path.join(tempDir, 'gen-yarn');
|
|
229
|
+
createFixture(testDir);
|
|
230
|
+
fs.writeFileSync(path.join(testDir, 'yarn.lock'), '# yarn lockfile v1\n');
|
|
231
|
+
|
|
232
|
+
await syncDocs({ projectPath: testDir, agents: true });
|
|
233
|
+
|
|
234
|
+
const content = fs.readFileSync(path.join(testDir, 'AGENTS.md'), 'utf-8');
|
|
235
|
+
expect(content).toContain('yarn dev');
|
|
236
|
+
expect(content).toContain('yarn build');
|
|
237
|
+
expect(content).toContain('yarn test');
|
|
238
|
+
expect(content).toContain('yarn start');
|
|
239
|
+
expect(content).not.toContain('npm run');
|
|
240
|
+
|
|
241
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('空项目应该生成兜底内容(无 package.json、无治理配置、无知识库)', async () => {
|
|
245
|
+
const testDir = path.join(tempDir, 'empty-project');
|
|
246
|
+
// 只放应被跳过的目录:依赖/构建产物/隐藏目录
|
|
247
|
+
fs.mkdirSync(path.join(testDir, 'node_modules'), { recursive: true });
|
|
248
|
+
fs.mkdirSync(path.join(testDir, 'coverage'), { recursive: true });
|
|
249
|
+
fs.mkdirSync(path.join(testDir, '.next'), { recursive: true });
|
|
250
|
+
fs.mkdirSync(path.join(testDir, '.vscode'), { recursive: true });
|
|
251
|
+
|
|
252
|
+
const result = await syncDocs({ projectPath: testDir, agents: true });
|
|
253
|
+
expect(result).toBe(false); // AGENTS.md 缺失 → 有问题,但已生成
|
|
254
|
+
|
|
255
|
+
const content = fs.readFileSync(path.join(testDir, 'AGENTS.md'), 'utf-8');
|
|
256
|
+
// 项目名回退到目录名,无 description
|
|
257
|
+
expect(content).toContain('**empty-project**');
|
|
258
|
+
expect(content).not.toContain('**empty-project** —');
|
|
259
|
+
// 兜底分支
|
|
260
|
+
expect(content).toContain('(未检测到顶层目录)');
|
|
261
|
+
expect(content).toContain('(package.json 中未检测到常用脚本)');
|
|
262
|
+
expect(content).toContain('未检测到 harness 治理配置');
|
|
263
|
+
expect(content).toContain('缺失目录可由 `harness sync-docs` 生成模板');
|
|
264
|
+
expect(content).not.toContain('项目知识库');
|
|
265
|
+
|
|
266
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('package.json 无 description 时回退 config.yml,required_dirs 缺失时静默跳过', async () => {
|
|
270
|
+
const testDir = path.join(tempDir, 'config-fallback');
|
|
271
|
+
fs.mkdirSync(path.join(testDir, '.harness'), { recursive: true });
|
|
272
|
+
fs.writeFileSync(path.join(testDir, 'package.json'), JSON.stringify({ name: 'cfg-app' }));
|
|
273
|
+
fs.writeFileSync(path.join(testDir, '.harness', 'config.yml'), [
|
|
274
|
+
'description: 来自配置的项目描述',
|
|
275
|
+
'governance:',
|
|
276
|
+
' context_files:',
|
|
277
|
+
' enabled: true',
|
|
278
|
+
' required_dirs:',
|
|
279
|
+
' - src',
|
|
280
|
+
'',
|
|
281
|
+
].join('\n'));
|
|
282
|
+
|
|
283
|
+
const result = await syncDocs({ projectPath: testDir, agents: true });
|
|
284
|
+
expect(result).toBe(false);
|
|
285
|
+
|
|
286
|
+
// src 目录不存在:扫描/统计静默跳过,不中断生成
|
|
287
|
+
const content = fs.readFileSync(path.join(testDir, 'AGENTS.md'), 'utf-8');
|
|
288
|
+
// description 来自 config.yml
|
|
289
|
+
expect(content).toContain('**cfg-app** — 来自配置的项目描述');
|
|
290
|
+
// 有治理配置但无 preset
|
|
291
|
+
expect(content).toContain('治理配置:`.harness/config.yml`');
|
|
292
|
+
expect(content).not.toContain('preset:');
|
|
293
|
+
// .harness 是保留的知名隐藏目录
|
|
294
|
+
expect(content).toContain('`.harness/`');
|
|
295
|
+
|
|
296
|
+
// 写入模式已补建 src/CONTEXT.md
|
|
297
|
+
expect(fs.existsSync(path.join(testDir, 'src', 'CONTEXT.md'))).toBe(true);
|
|
298
|
+
|
|
299
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('monorepo 应该标注 apps/packages 成员、子包 description 与占位目录', async () => {
|
|
303
|
+
const testDir = path.join(tempDir, 'monorepo');
|
|
304
|
+
fs.mkdirSync(path.join(testDir, 'src'), { recursive: true });
|
|
305
|
+
fs.writeFileSync(path.join(testDir, 'src', 'app.ts'), '/**\n * App module\n */\nexport const app = 1;');
|
|
306
|
+
fs.mkdirSync(path.join(testDir, 'apps', 'web'), { recursive: true });
|
|
307
|
+
fs.mkdirSync(path.join(testDir, 'apps', 'api'), { recursive: true });
|
|
308
|
+
fs.mkdirSync(path.join(testDir, 'apps', '.hidden'), { recursive: true });
|
|
309
|
+
fs.writeFileSync(path.join(testDir, 'apps', 'web', 'package.json'), JSON.stringify({ name: 'web' }));
|
|
310
|
+
fs.writeFileSync(path.join(testDir, 'apps', 'api', 'package.json'), JSON.stringify({ name: 'api' }));
|
|
311
|
+
fs.mkdirSync(path.join(testDir, 'packages'), { recursive: true });
|
|
312
|
+
fs.mkdirSync(path.join(testDir, 'services'), { recursive: true });
|
|
313
|
+
fs.writeFileSync(path.join(testDir, 'services', 'package.json'), JSON.stringify({ name: 'svc', description: '内部服务目录' }));
|
|
314
|
+
fs.mkdirSync(path.join(testDir, 'misc'), { recursive: true });
|
|
315
|
+
fs.writeFileSync(path.join(testDir, 'package.json'), JSON.stringify({
|
|
316
|
+
name: 'mono-app',
|
|
317
|
+
description: '单仓项目',
|
|
318
|
+
scripts: { test: 'jest' },
|
|
319
|
+
}));
|
|
320
|
+
|
|
321
|
+
const result = await syncDocs({ projectPath: testDir, agents: true });
|
|
322
|
+
expect(result).toBe(false); // CAPABILITIES.md/AGENTS.md 缺失 → 已生成
|
|
323
|
+
|
|
324
|
+
const content = fs.readFileSync(path.join(testDir, 'AGENTS.md'), 'utf-8');
|
|
325
|
+
expect(content).toContain('| `apps/` | monorepo 应用:api、web |');
|
|
326
|
+
expect(content).toContain('| `packages/` | monorepo 工作区 |');
|
|
327
|
+
expect(content).toContain('| `services/` | 内部服务目录 |');
|
|
328
|
+
expect(content).toContain('| `misc/` | — |');
|
|
329
|
+
expect(content).toContain('| `src/` | 源码目录 |');
|
|
330
|
+
|
|
331
|
+
// 无 CAPABILITIES.md 时应整体新建
|
|
332
|
+
const caps = fs.readFileSync(path.join(testDir, 'CAPABILITIES.md'), 'utf-8');
|
|
333
|
+
expect(caps).toContain('| app | src/app.ts |');
|
|
334
|
+
|
|
335
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('CLAUDE.md 治理块无约束分节时不显示计数', async () => {
|
|
339
|
+
const testDir = path.join(tempDir, 'governance-no-sections');
|
|
340
|
+
createFixture(testDir, { withCapabilities: true });
|
|
341
|
+
fs.writeFileSync(path.join(testDir, 'CLAUDE.md'), '# CLAUDE.md\n\n## Governance Rules\n\n(尚未配置约束)\n');
|
|
342
|
+
|
|
343
|
+
await syncDocs({ projectPath: testDir, agents: true });
|
|
344
|
+
|
|
345
|
+
const content = fs.readFileSync(path.join(testDir, 'AGENTS.md'), 'utf-8');
|
|
346
|
+
expect(content).toContain('Governance Rules 块');
|
|
347
|
+
expect(content).not.toContain('Iron Laws');
|
|
348
|
+
|
|
349
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('--json 幂等时 agentsMd 应为 exists:true、stale:false', async () => {
|
|
353
|
+
const testDir = path.join(tempDir, 'json-idempotent');
|
|
354
|
+
createFixture(testDir, { pnpm: true, withCapabilities: true });
|
|
355
|
+
|
|
356
|
+
await syncDocs({ projectPath: testDir, agents: true });
|
|
357
|
+
|
|
358
|
+
const result = await syncDocs({ projectPath: testDir, agents: true, json: true });
|
|
359
|
+
expect(result).toBe(true);
|
|
360
|
+
|
|
361
|
+
const jsonCall = consoleSpy.mock.calls.find(
|
|
362
|
+
(call: unknown[]) => typeof call[0] === 'string' && call[0].startsWith('{')
|
|
363
|
+
);
|
|
364
|
+
expect(jsonCall).toBeDefined();
|
|
365
|
+
const parsed = JSON.parse(jsonCall![0]);
|
|
366
|
+
expect(parsed.stale).toBe(false);
|
|
367
|
+
expect(parsed.agentsMd).toEqual({ file: 'AGENTS.md', exists: true, stale: false });
|
|
368
|
+
expect(parsed.resolution.some((r: { action: string }) => r.action === 'sync-agents-md')).toBe(false);
|
|
369
|
+
|
|
370
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('应该检测 CONTEXT.md 缺失/过时并给出 resolution(json + 人读输出)', async () => {
|
|
374
|
+
const testDir = path.join(tempDir, 'context-stale');
|
|
375
|
+
createFixture(testDir, { withCapabilities: true });
|
|
376
|
+
// 启用 context_files 并要求 lib(不存在 → 缺失)
|
|
377
|
+
fs.writeFileSync(path.join(testDir, '.harness', 'config.yml'), [
|
|
378
|
+
'preset: standard',
|
|
379
|
+
'governance:',
|
|
380
|
+
' context_files:',
|
|
381
|
+
' enabled: true',
|
|
382
|
+
' required_dirs:',
|
|
383
|
+
' - src',
|
|
384
|
+
' - lib',
|
|
385
|
+
'',
|
|
386
|
+
].join('\n'));
|
|
387
|
+
// src 子目录树:触发子目录递归扫描 + CONTEXT.md 过时检测
|
|
388
|
+
fs.mkdirSync(path.join(testDir, 'src', 'modules', 'deep'), { recursive: true });
|
|
389
|
+
fs.mkdirSync(path.join(testDir, 'src', '__tests__'), { recursive: true });
|
|
390
|
+
fs.writeFileSync(path.join(testDir, 'src', 'modules', 'util.ts'), '/**\n * Util module\n */\nexport const util = 1;');
|
|
391
|
+
fs.writeFileSync(path.join(testDir, 'src', 'modules', 'index.ts'), 'export * from \'./util\';');
|
|
392
|
+
fs.writeFileSync(path.join(testDir, 'src', 'modules', 'deep', 'deep.ts'), '/**\n * Deep module\n */\nexport const deep = 1;');
|
|
393
|
+
fs.writeFileSync(path.join(testDir, 'src', 'modules', 'CONTEXT.md'), '# modules\n');
|
|
394
|
+
// 人为把 CONTEXT.md 的 mtime 拨到过去 → 源码比文档新 → 过时
|
|
395
|
+
const past = new Date(Date.now() - 60_000);
|
|
396
|
+
fs.utimesSync(path.join(testDir, 'src', 'modules', 'CONTEXT.md'), past, past);
|
|
397
|
+
|
|
398
|
+
// json 模式:结构化输出缺失/过时
|
|
399
|
+
const jsonResult = await syncDocs({ projectPath: testDir, agents: true, check: true, json: true });
|
|
400
|
+
expect(jsonResult).toBe(false);
|
|
401
|
+
const jsonCall = consoleSpy.mock.calls.find(
|
|
402
|
+
(call: unknown[]) => typeof call[0] === 'string' && call[0].startsWith('{')
|
|
403
|
+
);
|
|
404
|
+
expect(jsonCall).toBeDefined();
|
|
405
|
+
const parsed = JSON.parse(jsonCall![0]);
|
|
406
|
+
expect(parsed.summary.contextMissing).toBe(1);
|
|
407
|
+
expect(parsed.summary.contextStale).toBe(1);
|
|
408
|
+
expect(parsed.contextMissing).toEqual([{ dir: 'lib', file: 'lib/CONTEXT.md' }]);
|
|
409
|
+
expect(parsed.contextStale).toEqual([{ dir: 'src/modules', file: 'src/modules/CONTEXT.md' }]);
|
|
410
|
+
expect(parsed.resolution.some((r: { action: string }) => r.action === 'create-context-md')).toBe(true);
|
|
411
|
+
expect(parsed.resolution.some((r: { action: string }) => r.action === 'update-context-md')).toBe(true);
|
|
412
|
+
expect(parsed.agentsMd).toEqual({ file: 'AGENTS.md', exists: false, stale: true });
|
|
413
|
+
|
|
414
|
+
// 人读模式:输出过时提示
|
|
415
|
+
consoleSpy.mockClear();
|
|
416
|
+
const humanResult = await syncDocs({ projectPath: testDir, agents: true, check: true });
|
|
417
|
+
expect(humanResult).toBe(false);
|
|
418
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('可能过时'));
|
|
419
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('缺少 CONTEXT.md'));
|
|
420
|
+
|
|
421
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
422
|
+
});
|
|
423
|
+
});
|
|
@@ -331,5 +331,32 @@ describe('sync-docs command', () => {
|
|
|
331
331
|
|
|
332
332
|
fs.rmSync(testDir, { recursive: true, force: true });
|
|
333
333
|
});
|
|
334
|
+
|
|
335
|
+
it('写入模式应移除已删除文件的行', async () => {
|
|
336
|
+
const testDir = path.join(tempDir, 'remove-stale');
|
|
337
|
+
const srcDir = path.join(testDir, 'src');
|
|
338
|
+
fs.mkdirSync(srcDir, { recursive: true });
|
|
339
|
+
|
|
340
|
+
// 创建两个源文件
|
|
341
|
+
fs.writeFileSync(path.join(srcDir, 'kept.ts'), 'export const kept = 1;');
|
|
342
|
+
fs.writeFileSync(path.join(srcDir, 'removed.ts'), 'export const removed = 1;');
|
|
343
|
+
|
|
344
|
+
// 先让 syncDocs 创建初始 CAPABILITIES.md(包含两个文件)
|
|
345
|
+
await syncDocs({ projectPath: testDir });
|
|
346
|
+
let content = fs.readFileSync(path.join(testDir, 'CAPABILITIES.md'), 'utf-8');
|
|
347
|
+
expect(content).toContain('kept.ts');
|
|
348
|
+
expect(content).toContain('removed.ts');
|
|
349
|
+
|
|
350
|
+
// 删除 removed.ts
|
|
351
|
+
fs.unlinkSync(path.join(srcDir, 'removed.ts'));
|
|
352
|
+
|
|
353
|
+
// 重新运行 syncDocs(写入模式)
|
|
354
|
+
await syncDocs({ projectPath: testDir });
|
|
355
|
+
content = fs.readFileSync(path.join(testDir, 'CAPABILITIES.md'), 'utf-8');
|
|
356
|
+
expect(content).toContain('kept.ts');
|
|
357
|
+
expect(content).not.toContain('removed.ts');
|
|
358
|
+
|
|
359
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
360
|
+
});
|
|
334
361
|
});
|
|
335
362
|
});
|