@dommaker/harness 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +1 -1
  3. package/dist/cli/commands/check.d.ts +8 -0
  4. package/dist/cli/commands/check.d.ts.map +1 -1
  5. package/dist/cli/commands/check.js +10 -11
  6. package/dist/cli/commands/check.js.map +1 -1
  7. package/dist/cli/commands/status.d.ts +8 -0
  8. package/dist/cli/commands/status.d.ts.map +1 -1
  9. package/dist/cli/commands/status.js +5 -7
  10. package/dist/cli/commands/status.js.map +1 -1
  11. package/dist/cli/commands/sync-docs/capabilities-syncer.d.ts +22 -0
  12. package/dist/cli/commands/sync-docs/capabilities-syncer.d.ts.map +1 -1
  13. package/dist/cli/commands/sync-docs/capabilities-syncer.js +113 -8
  14. package/dist/cli/commands/sync-docs/capabilities-syncer.js.map +1 -1
  15. package/dist/cli/commands/sync-docs/index.d.ts.map +1 -1
  16. package/dist/cli/commands/sync-docs/index.js +36 -4
  17. package/dist/cli/commands/sync-docs/index.js.map +1 -1
  18. package/dist/cli/state-io.d.ts +34 -0
  19. package/dist/cli/state-io.d.ts.map +1 -0
  20. package/dist/cli/state-io.js +71 -0
  21. package/dist/cli/state-io.js.map +1 -0
  22. package/dist/hooks/bootstrap.d.ts +9 -12
  23. package/dist/hooks/bootstrap.d.ts.map +1 -1
  24. package/dist/hooks/bootstrap.js +9 -31
  25. package/dist/hooks/bootstrap.js.map +1 -1
  26. package/dist/hooks/index.d.ts +5 -7
  27. package/dist/hooks/index.d.ts.map +1 -1
  28. package/dist/hooks/index.js +4 -10
  29. package/dist/hooks/index.js.map +1 -1
  30. package/dist/index.d.ts +2 -2
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +3 -7
  33. package/dist/index.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/CONTEXT.md +2 -2
  36. package/src/__tests__/public-exports.test.ts +30 -4
  37. package/src/__tests__/public-type-surface.test.ts +0 -8
  38. package/src/cli/__tests__/state-io.test.ts +50 -0
  39. package/src/cli/commands/CONTEXT.md +4 -0
  40. package/src/cli/commands/__tests__/check-read-count.test.ts +3 -0
  41. package/src/cli/commands/__tests__/check.test.ts +20 -8
  42. package/src/cli/commands/__tests__/registry.test.ts +39 -3
  43. package/src/cli/commands/__tests__/status-extra.test.ts +25 -8
  44. package/src/cli/commands/__tests__/status.test.ts +46 -18
  45. package/src/cli/commands/__tests__/sync-docs-table-layout.test.ts +259 -0
  46. package/src/cli/commands/check.ts +20 -18
  47. package/src/cli/commands/status.ts +12 -7
  48. package/src/cli/commands/sync-docs/capabilities-syncer.ts +139 -9
  49. package/src/cli/commands/sync-docs/index.ts +41 -4
  50. package/src/cli/state-io.ts +53 -0
  51. package/src/hooks/CONTEXT.md +12 -22
  52. package/src/hooks/__tests__/bootstrap.test.ts +21 -70
  53. package/src/hooks/bootstrap.ts +9 -48
  54. package/src/hooks/index.ts +5 -20
  55. package/src/index.ts +1 -13
  56. package/dist/hooks/config.d.ts +0 -30
  57. package/dist/hooks/config.d.ts.map +0 -1
  58. package/dist/hooks/config.js +0 -34
  59. package/dist/hooks/config.js.map +0 -1
  60. package/dist/hooks/pipeline.d.ts +0 -36
  61. package/dist/hooks/pipeline.d.ts.map +0 -1
  62. package/dist/hooks/pipeline.js +0 -133
  63. package/dist/hooks/pipeline.js.map +0 -1
  64. package/dist/hooks/registry.d.ts +0 -70
  65. package/dist/hooks/registry.d.ts.map +0 -1
  66. package/dist/hooks/registry.js +0 -141
  67. package/dist/hooks/registry.js.map +0 -1
  68. package/dist/hooks/types.d.ts +0 -111
  69. package/dist/hooks/types.d.ts.map +0 -1
  70. package/dist/hooks/types.js +0 -9
  71. package/dist/hooks/types.js.map +0 -1
  72. package/src/__tests__/hooks-pipeline.test.ts +0 -201
  73. package/src/hooks/__tests__/config.test.ts +0 -19
  74. package/src/hooks/__tests__/pipeline.test.ts +0 -90
  75. package/src/hooks/__tests__/registry.test.ts +0 -115
  76. package/src/hooks/config.ts +0 -33
  77. package/src/hooks/pipeline.ts +0 -155
  78. package/src/hooks/registry.ts +0 -166
  79. package/src/hooks/types.ts +0 -118
@@ -0,0 +1,259 @@
1
+ /**
2
+ * sync-docs 表格排版回归测试(harness#171)
3
+ *
4
+ * 症状:撤下 CAPABILITIES.md 的模块登记行时清空该行而非删行,残留空行把
5
+ * CommonMark 表格切断。判定面 = 「上一行与下一行都是 `|` 表格行、本行为空」不得存在。
6
+ * 正本修复在写路径(整行删除 + 存量收拢 + 空表收掉),--check 侧必须报待清理
7
+ * (check/fix 同规则才收敛,ADR-0009 口径)。
8
+ */
9
+ import * as fs from 'fs';
10
+ import * as os from 'os';
11
+ import * as path from 'path';
12
+ import { captureIO, type CapturingIO } from '../../command-contract';
13
+ import { normalizeCapabilitiesTableLayout } from '../sync-docs/capabilities-syncer';
14
+ import { syncDocs } from '../sync-docs';
15
+
16
+ let io: CapturingIO;
17
+ beforeEach(() => {
18
+ io = captureIO();
19
+ });
20
+
21
+ const HEADER = '| 模块 | 文件 | 说明 |\n|------|------|------|\n';
22
+
23
+ /** 「上一行与下一行都是表格行、本行为空」的行数 —— 本票的判定面 */
24
+ function countBlankRowsInsideTable(content: string): number {
25
+ const lines = content.split('\n');
26
+ let n = 0;
27
+ for (let i = 1; i < lines.length - 1; i++) {
28
+ if (
29
+ lines[i].trim() === '' &&
30
+ /^\s*\|/.test(lines[i - 1]) &&
31
+ /^\s*\|/.test(lines[i + 1])
32
+ ) {
33
+ n++;
34
+ }
35
+ }
36
+ return n;
37
+ }
38
+
39
+ /** 建一个 file 模式的最小工程:srcFiles 存在,CAPABILITIES.md 由 rows 拼出 */
40
+ function makeProject(
41
+ name: string,
42
+ srcFiles: string[],
43
+ doc: string
44
+ ): string {
45
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `sync-docs-layout-${name}-`));
46
+ fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
47
+ for (const f of srcFiles) {
48
+ const file = path.join(dir, 'src', f);
49
+ fs.mkdirSync(path.dirname(file), { recursive: true });
50
+ fs.writeFileSync(file, 'export const x = 1;\n');
51
+ }
52
+ fs.writeFileSync(path.join(dir, 'CAPABILITIES.md'), doc);
53
+ return dir;
54
+ }
55
+ const read = (dir: string) => fs.readFileSync(path.join(dir, 'CAPABILITIES.md'), 'utf-8');
56
+ const dataRow = (name: string, file: string, desc = 'D') => `| ${name} | ${file} | ${desc} |`;
57
+
58
+ describe('sync-docs CAPABILITIES.md 表格排版(#171)', () => {
59
+ it('撤中间登记行:整行删除,表格保持连续', async () => {
60
+ const dir = makeProject(
61
+ 'middle',
62
+ ['a.ts', 'c.ts'],
63
+ `# Capabilities\n\n${HEADER}${dataRow('a', 'src/a.ts')}\n${dataRow('b', 'src/b.ts')}\n${dataRow('c', 'src/c.ts')}\n`
64
+ );
65
+
66
+ await syncDocs({ projectPath: dir }, io);
67
+
68
+ const out = read(dir);
69
+ expect(out).not.toContain('src/b.ts');
70
+ expect(countBlankRowsInsideTable(out)).toBe(0);
71
+ expect(out).toBe(`# Capabilities\n\n${HEADER}${dataRow('a', 'src/a.ts')}\n${dataRow('c', 'src/c.ts')}\n`);
72
+ fs.rmSync(dir, { recursive: true, force: true });
73
+ });
74
+
75
+ it('撤目录条目行(studio 撤登记形状):同样整行删除', async () => {
76
+ const dir = makeProject(
77
+ 'dir-entry',
78
+ ['cli/cli.ts', 'harness/h.ts'],
79
+ `# Capabilities\n\n${HEADER}` +
80
+ `${dataRow('cli', 'src/cli/', 'C')}\n` +
81
+ `${dataRow('hooks', 'src/hooks/', 'H')}\n` +
82
+ `${dataRow('harness', 'src/harness/', 'A')}\n`
83
+ );
84
+
85
+ await syncDocs({ projectPath: dir }, io);
86
+
87
+ const out = read(dir);
88
+ expect(out).not.toContain('src/hooks/');
89
+ expect(countBlankRowsInsideTable(out)).toBe(0);
90
+ expect(out).toContain(`${dataRow('cli', 'src/cli/', 'C')}\n${dataRow('harness', 'src/harness/', 'A')}`);
91
+ fs.rmSync(dir, { recursive: true, force: true });
92
+ });
93
+
94
+ it('撤最后一行:不留文件末尾空行', async () => {
95
+ const dir = makeProject(
96
+ 'last',
97
+ ['a.ts'],
98
+ `# Capabilities\n\n${HEADER}${dataRow('a', 'src/a.ts')}\n${dataRow('gone', 'src/gone.ts')}\n`
99
+ );
100
+
101
+ await syncDocs({ projectPath: dir }, io);
102
+
103
+ expect(read(dir)).toBe(`# Capabilities\n\n${HEADER}${dataRow('a', 'src/a.ts')}\n`);
104
+ fs.rmSync(dir, { recursive: true, force: true });
105
+ });
106
+
107
+ it('存量脏行(无其它漂移):--check 报待清理,写模式收拢且收拢后 check 转绿', async () => {
108
+ const dirty = `# Capabilities\n\n${HEADER}${dataRow('a', 'src/a.ts')}\n\n${dataRow('z', 'src/z.ts')}\n`;
109
+ const dir = makeProject('legacy', ['a.ts', 'z.ts'], dirty);
110
+
111
+ expect(countBlankRowsInsideTable(read(dir))).toBe(1);
112
+ const check = await syncDocs({ projectPath: dir, check: true }, io);
113
+ expect(check).toEqual({ kind: 'fail', reason: expect.stringContaining('表格') });
114
+
115
+ await syncDocs({ projectPath: dir }, io);
116
+ const out = read(dir);
117
+ expect(countBlankRowsInsideTable(out)).toBe(0);
118
+ expect(out).toContain('src/a.ts');
119
+ expect(out).toContain('src/z.ts');
120
+ expect(await syncDocs({ projectPath: dir, check: true }, io)).toEqual({ kind: 'ok' });
121
+ fs.rmSync(dir, { recursive: true, force: true });
122
+ });
123
+
124
+ it('表格被撤空:连表头/分隔行一起收掉', async () => {
125
+ const dir = makeProject(
126
+ 'empty-table',
127
+ [],
128
+ `# Capabilities\n\n${HEADER}${dataRow('gone1', 'src/gone1.ts')}\n${dataRow('gone2', 'src/gone2.ts')}\n`
129
+ );
130
+
131
+ await syncDocs({ projectPath: dir }, io);
132
+
133
+ const out = read(dir);
134
+ expect(out).not.toContain('|------|');
135
+ expect(out).not.toContain('模块 | 文件');
136
+ expect(out).not.toMatch(/\n{3,}/);
137
+ expect(await syncDocs({ projectPath: dir, check: true }, io)).toEqual({ kind: 'ok' });
138
+ fs.rmSync(dir, { recursive: true, force: true });
139
+ });
140
+
141
+ it('撤登同时有新增:不得把仍要使用的表头收掉', async () => {
142
+ const dir = makeProject(
143
+ 'remove-and-add',
144
+ ['b.ts', 'fresh.ts'],
145
+ `# Capabilities\n\n${HEADER}${dataRow('a', 'src/gone.ts')}\n${dataRow('b', 'src/b.ts')}\n`
146
+ );
147
+
148
+ await syncDocs({ projectPath: dir }, io);
149
+
150
+ const out = read(dir);
151
+ expect(out).toContain('模块 | 文件 | 说明');
152
+ expect(out).toContain('fresh.ts');
153
+ expect(countBlankRowsInsideTable(out)).toBe(0);
154
+ fs.rmSync(dir, { recursive: true, force: true });
155
+ });
156
+
157
+ it('表格外的空行不动(段落分隔不是脏行)', async () => {
158
+ const doc = `# Capabilities\n\n${HEADER}${dataRow('a', 'src/a.ts')}\n\n## 说明\n\n散文。\n`;
159
+ const dir = makeProject('prose', ['a.ts'], doc);
160
+
161
+ const check = await syncDocs({ projectPath: dir, check: true }, io);
162
+ expect(check).toEqual({ kind: 'ok' });
163
+ expect(read(dir)).toBe(doc);
164
+ fs.rmSync(dir, { recursive: true, force: true });
165
+ });
166
+
167
+ it('代码块内「表头+分隔行无数据行」的示例不被收掉', async () => {
168
+ const doc =
169
+ `# Capabilities\n\n${HEADER}${dataRow('a', 'src/a.ts')}\n\n` +
170
+ `登记格式:\n\n\`\`\`markdown\n| 模块 | 文件 | 说明 |\n|------|------|------|\n\`\`\`\n\n散文。\n`;
171
+ const dir = makeProject('fenced-empty-table', ['a.ts'], doc);
172
+
173
+ expect(await syncDocs({ projectPath: dir, check: true }, io)).toEqual({ kind: 'ok' });
174
+
175
+ await syncDocs({ projectPath: dir }, io);
176
+ expect(read(dir)).toBe(doc);
177
+ fs.rmSync(dir, { recursive: true, force: true });
178
+ });
179
+
180
+ it('代码块内的表格样式行不被收拢(含块内空行)', async () => {
181
+ const doc =
182
+ `# Capabilities\n\n${HEADER}${dataRow('a', 'src/a.ts')}\n\n` +
183
+ `示例:\n\n\`\`\`\n| x | y |\n\n| z | w |\n\`\`\`\n`;
184
+ const dir = makeProject('fenced-blank-row', ['a.ts'], doc);
185
+
186
+ expect(await syncDocs({ projectPath: dir, check: true }, io)).toEqual({ kind: 'ok' });
187
+
188
+ await syncDocs({ projectPath: dir }, io);
189
+ expect(read(dir)).toBe(doc);
190
+ fs.rmSync(dir, { recursive: true, force: true });
191
+ });
192
+
193
+ it('两张空表相邻:一次收拢即干净(规则互相制造触发点,须跑到不动点)', () => {
194
+ // 规则①先吃掉两张空表之间的那个空行(两侧都是表格行),规则②在同一轮里
195
+ // 只看得到「表头+分隔行后面还有表格行」的前一张,收得掉后一张。不迭代就收不干净。
196
+ const first = normalizeCapabilitiesTableLayout(`${HEADER}\n${HEADER}`);
197
+
198
+ expect(first).toEqual({ content: '', blankLines: 1, emptyTables: 2 });
199
+ // 不动点:再跑一次零改动,`--check` 才不会修完还红
200
+ expect(normalizeCapabilitiesTableLayout(first.content)).toEqual({
201
+ content: '',
202
+ blankLines: 0,
203
+ emptyTables: 0,
204
+ });
205
+ });
206
+
207
+ it('收掉空表后不留三个以上连续换行(写模式收干净)', async () => {
208
+ const dir = makeProject(
209
+ 'no-triple-newline',
210
+ [],
211
+ `# Capabilities\n\n${HEADER}${dataRow('gone', 'src/gone.ts')}\n\n## 说明\n\n散文。\n`
212
+ );
213
+
214
+ await syncDocs({ projectPath: dir }, io);
215
+
216
+ const out = read(dir);
217
+ expect(out).not.toMatch(/\n{3,}/);
218
+ expect(out).toContain('## 说明');
219
+ expect(out).toContain('散文。');
220
+ expect(await syncDocs({ projectPath: dir, check: true }, io)).toEqual({ kind: 'ok' });
221
+ fs.rmSync(dir, { recursive: true, force: true });
222
+ });
223
+
224
+ it('围栏内示例行引用已删文件:随幽灵行一起删掉,且 check 收敛(取舍见实现注释)', async () => {
225
+ // 登记条目由 capabilities-parser 全文扫描得出(ADR-0009),块内行也算登记项;
226
+ // 只让幽灵行删除豁免围栏,这个示例会永远被报成已删模块且修不掉。
227
+ const doc =
228
+ `# Capabilities\n\n${HEADER}${dataRow('a', 'src/a.ts')}\n\n` +
229
+ `登记格式示例:\n\n\`\`\`markdown\n${HEADER}${dataRow('gone', 'src/gone.ts')}\n\`\`\`\n`;
230
+ const dir = makeProject('fenced-ghost-entry', ['a.ts'], doc);
231
+
232
+ expect(await syncDocs({ projectPath: dir, check: true }, io)).toMatchObject({ kind: 'fail' });
233
+ expect(io.outText()).toContain('src/gone.ts');
234
+
235
+ await syncDocs({ projectPath: dir }, io);
236
+
237
+ const out = read(dir);
238
+ expect(out).not.toContain('src/gone.ts');
239
+ expect(out).toContain(dataRow('a', 'src/a.ts'));
240
+ // 只吃掉引用已删文件的那一行,块内其余正文留着(不是「把围栏里所有行一起删」)
241
+ expect(out).toContain('```markdown\n' + HEADER);
242
+ expect(countBlankRowsInsideTable(out)).toBe(0);
243
+ expect(await syncDocs({ projectPath: dir, check: true }, io)).toEqual({ kind: 'ok' });
244
+ fs.rmSync(dir, { recursive: true, force: true });
245
+ });
246
+
247
+ it('CRLF 文档撤行:整行连行尾一起删除', async () => {
248
+ const doc = `# Capabilities\r\n\r\n${HEADER.replace(/\n/g, '\r\n')}${dataRow('a', 'src/a.ts')}\r\n${dataRow('gone', 'src/gone.ts')}\r\n`;
249
+ const dir = makeProject('crlf', ['a.ts'], doc);
250
+
251
+ await syncDocs({ projectPath: dir }, io);
252
+
253
+ const out = read(dir);
254
+ expect(out).not.toContain('src/gone.ts');
255
+ expect(out.endsWith(`${dataRow('a', 'src/a.ts')}\r\n`)).toBe(true);
256
+ expect(countBlankRowsInsideTable(out)).toBe(0);
257
+ fs.rmSync(dir, { recursive: true, force: true });
258
+ });
259
+ });
@@ -9,7 +9,6 @@
9
9
  */
10
10
 
11
11
  import chalk from 'chalk';
12
- import * as fs from 'fs';
13
12
  import * as path from 'path';
14
13
  import { ConstraintChecker } from '../../core/constraints/checker';
15
14
  import { IRON_LAWS, GUIDELINES, PROMPTS } from '../../core/constraints/definitions';
@@ -24,6 +23,7 @@ import { readJsonl } from '../../utils/jsonl';
24
23
  import { DEFAULT_TRACE_FILE, type ExecutionTrace } from '../../types/trace';
25
24
  import type { ConstraintResult, ConstraintTrigger } from '../../types/constraint';
26
25
  import { log, processIO, type CommandIO, type CommandResult } from '../command-contract';
26
+ import { fileStateIO, type StateIO } from '../state-io';
27
27
 
28
28
  /** 证据行着色(与调用处所属结论块一致) */
29
29
  const EVIDENCE_PAINT = {
@@ -74,6 +74,13 @@ export interface CheckOptions {
74
74
  * 测试据此断言「同一次运行内同一项目文件至多读一次」。
75
75
  */
76
76
  runEnv?: RunEnv;
77
+ /**
78
+ * 状态文件接缝(非 CLI flag;ADR-0026)
79
+ *
80
+ * 缺省 = 真实 fs 实现(fileStateIO)。注入则 `.harness/.state.json` 的读写
81
+ * 走替身,测试据此不碰真文件系统。
82
+ */
83
+ stateIO?: StateIO;
77
84
  }
78
85
 
79
86
  /**
@@ -91,6 +98,7 @@ export async function check(
91
98
  // 一次 run 一份证据与观察面(#87 / ADR-0023):入口构造,沿生效集、context、checker 向下传
92
99
  const evidence = options.evidence ?? createGitEvidence(projectPath);
93
100
  const runEnv = options.runEnv ?? createRunEnv(projectPath);
101
+ const stateIO = options.stateIO ?? fileStateIO(projectPath);
94
102
 
95
103
  // 生效约束集(ADR-0001):内置 → preset → config.yml 禁用 → custom 追加 → scenes 过滤。
96
104
  // --preset 仅在没有项目自定义配置时覆盖 config.yml 的 preset(工单 23 语义:
@@ -221,7 +229,7 @@ export async function check(
221
229
  log(io, chalk.green('✅ 约束检查通过'));
222
230
 
223
231
  // 智能提示
224
- const hint = await getSmartHint(projectPath);
232
+ const hint = await getSmartHint(projectPath, stateIO);
225
233
  if (hint) {
226
234
  log(io);
227
235
  log(io, chalk.gray('────────────────────────────────────'));
@@ -241,10 +249,12 @@ const TRACE_HINT_THRESHOLD = 50;
241
249
 
242
250
  /**
243
251
  * 智能提示:检查是否需要提示用户下一步操作
252
+ *
253
+ * 状态读写经 StateIO(ADR-0026):读-改-写,`status` 不再把 `shownHints` 抹掉,
254
+ * 「首次达到阈值」的去重自此真生效。
244
255
  */
245
- async function getSmartHint(projectPath: string): Promise<string | null> {
256
+ async function getSmartHint(projectPath: string, stateIO: StateIO): Promise<string | null> {
246
257
  const tracesPath = path.join(projectPath, DEFAULT_TRACE_FILE);
247
- const statePath = path.join(projectPath, '.harness', '.state.json');
248
258
 
249
259
  // 只读够 TRACE_HINT_THRESHOLD 行即停(坏行照旧占位,条数口径与改前的纯计数逐字一致)——
250
260
  // traces.log 是 append-only 无上限文件,为一个比较符整读不成立
@@ -256,19 +266,12 @@ async function getSmartHint(projectPath: string): Promise<string | null> {
256
266
  if (traceCount === 0) {
257
267
  return null;
258
268
  }
259
- // 读取状态
260
- let state: {
261
- shownHints?: string[];
262
- lastStatusRun?: string;
263
- lastDiagnoseRun?: string;
264
- } = {};
265
- if (fs.existsSync(statePath)) {
266
- state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
267
- }
269
+ // 读取状态(经接缝;文件缺失 → {})
270
+ const state = stateIO.read();
268
271
  state.shownHints = state.shownHints || [];
269
-
272
+
270
273
  const hints: string[] = [];
271
-
274
+
272
275
  // 条件 1: 记录数首次达到阈值
273
276
  if (traceCount >= TRACE_HINT_THRESHOLD && !state.shownHints.includes('trace_50')) {
274
277
  hints.push('📊 记录已足够,运行 harness status 查看统计');
@@ -277,11 +280,10 @@ async function getSmartHint(projectPath: string): Promise<string | null> {
277
280
 
278
281
  // 保存状态
279
282
  if (hints.length > 0) {
280
- fs.mkdirSync(path.dirname(statePath), { recursive: true });
281
- fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
283
+ stateIO.write(state);
282
284
  return hints.join('\n');
283
285
  }
284
-
286
+
285
287
  return null;
286
288
  }
287
289
 
@@ -13,6 +13,7 @@ import { DEFAULT_TRACE_FILE } from '../../types/trace';
13
13
  import type { ExecutionTrace } from '../../types/trace';
14
14
  import type { TraceSummary, TraceAnomaly } from '../../types/trace';
15
15
  import { log, logError, processIO, type CommandIO, type CommandResult } from '../command-contract';
16
+ import { fileStateIO, type StateIO } from '../state-io';
16
17
 
17
18
  export interface StatusOptions {
18
19
  /** 项目路径 */
@@ -23,6 +24,13 @@ export interface StatusOptions {
23
24
  anomalies?: boolean;
24
25
  /** 时间范围(小时) */
25
26
  hours?: number;
27
+ /**
28
+ * 状态文件接缝(非 CLI flag;ADR-0026)
29
+ *
30
+ * 缺省 = 真实 fs 实现(fileStateIO)。注入则 `.harness/.state.json` 的读写
31
+ * 走替身,测试据此不碰真文件系统。
32
+ */
33
+ stateIO?: StateIO;
26
34
  }
27
35
 
28
36
  /**
@@ -32,7 +40,7 @@ export async function status(options: StatusOptions, io: CommandIO = processIO):
32
40
  const projectPath = options.projectPath || process.cwd();
33
41
  const harnessDir = path.join(projectPath, '.harness');
34
42
  const tracesPath = path.join(projectPath, DEFAULT_TRACE_FILE);
35
- const statePath = path.join(harnessDir, '.state.json');
43
+ const stateIO = options.stateIO ?? fileStateIO(projectPath);
36
44
 
37
45
  log(io, chalk.blue('📊 Harness 状态'));
38
46
  log(io);
@@ -139,12 +147,9 @@ export async function status(options: StatusOptions, io: CommandIO = processIO):
139
147
  log(io);
140
148
  }
141
149
 
142
- // 更新状态文件
143
- const state = {
144
- lastStatusRun: new Date().toISOString(),
145
- };
146
- fs.mkdirSync(path.dirname(statePath), { recursive: true });
147
- fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
150
+ // 更新状态文件:读-改-写(ADR-0026 决策 2)——不再整文件重写,
151
+ // 否则 check 写入的 shownHints 会被抹掉、智能提示去重失效
152
+ stateIO.write({ ...stateIO.read(), lastStatusRun: new Date().toISOString() });
148
153
 
149
154
  // 下一步建议
150
155
  log(io, chalk.blue('💡 下一步建议:'));
@@ -54,12 +54,22 @@ export async function updateCapabilitiesFile(
54
54
 
55
55
  // 如果有表格行,更新表格
56
56
  if (existingFiles.length > 0) {
57
- // 移除已删除文件的行
58
- for (const removed of result.removed) {
59
- const escapedFile = removed.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
60
- // 第二列存完整路径,basename 只在末尾出现,用 [^|]* 匹配路径前缀
61
- const rowRegex = new RegExp(`^\\|[^|]*\\|[^|]*\\b${escapedFile}\\s*\\|.*$`, 'gm');
62
- content = content.replace(rowRegex, '');
57
+ // 移除已删除文件的行(整行连行尾一起删——只清行内容会留一个空行,
58
+ // CommonMark 据此把一张表切成若干小表,harness#171)。
59
+ // 这里**不**豁免围栏代码块,与下方排版收拢刻意不同口径:登记条目由
60
+ // capabilities-parser 全文扫描得出(ADR-0009),围栏内的行同样算登记项;
61
+ // 只让删除认围栏而条目不认,块内示例行会一直被解析成幽灵条目,`--check`
62
+ // 从此每轮都报同一个已删文件且永远修不掉。豁免要生效得连登记面一起改。
63
+ if (result.removed.length > 0) {
64
+ const deadRowRegexes = result.removed.map((removed) => {
65
+ const escapedFile = removed.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
66
+ // 第二列存完整路径,basename 只在末尾出现,用 [^|]* 匹配路径前缀
67
+ return new RegExp(`^\\|[^|]*\\|[^|]*\\b${escapedFile}\\s*\\|.*\\r?$`);
68
+ });
69
+ content = content
70
+ .split('\n')
71
+ .filter((line) => !deadRowRegexes.some((rowRegex) => rowRegex.test(line)))
72
+ .join('\n');
63
73
  }
64
74
 
65
75
  // 添加新文件的行(在最后一个表格行之后);module 模式跳过
@@ -80,14 +90,17 @@ export async function updateCapabilitiesFile(
80
90
  content = content.replace(lastTableRow, lastTableRow + '\n' + newRows);
81
91
  }
82
92
  }
83
-
84
- // 清理多余空行
85
- content = content.replace(/\n{3,}/g, '\n\n');
86
93
  } else {
87
94
  // 没有表格,追加模块表格(module 模式按目录聚合)
88
95
  content += '\n\n' + (mode === 'module' ? generateDirTable(currentModules) : generateModuleTable(currentModules));
89
96
  }
90
97
 
98
+ // 表格排版收拢放在增删之后:有新行的表不会被误判为空表(#171)
99
+ content = normalizeCapabilitiesTableLayout(content).content;
100
+ // 多余空行的清理收在收拢之后一处:收拢本身会新产出连续空行
101
+ // (收掉一张夹在两段散文之间的空表就留下 `\n\n\n`,#171)
102
+ content = content.replace(/\n{3,}/g, '\n\n');
103
+
91
104
  // 更新最后更新时间
92
105
  const now = new Date().toISOString().split('T')[0];
93
106
  content = content.replace(
@@ -98,6 +111,123 @@ export async function updateCapabilitiesFile(
98
111
  await fs.writeFile(capabilitiesPath, content, 'utf-8');
99
112
  }
100
113
 
114
+ /** 表格行:以 `|` 起始(表头、分隔行、数据行都算) */
115
+ const TABLE_ROW_REGEX = /^\s*\|/;
116
+
117
+ /** 表格分隔行(|------|------|) */
118
+ const TABLE_SEPARATOR_REGEX = /^\s*\|[\s:|-]+\|\s*$/;
119
+
120
+ /** 围栏代码块的起止行(``` / ~~~,允许缩进与信息串) */
121
+ const FENCE_LINE_REGEX = /^\s*(?:```|~~~)/;
122
+
123
+ export interface TableLayoutNormalization {
124
+ /** 收拢后的内容 */
125
+ content: string;
126
+ /** 被删掉的「表格内空行」行数 */
127
+ blankLines: number;
128
+ /** 被收掉的「空表」(表头+分隔行且无数据行)张数 */
129
+ emptyTables: number;
130
+ }
131
+
132
+ /** 参与排版判定的行:正文 + 是否落在围栏代码块内 */
133
+ interface LayoutLine {
134
+ text: string;
135
+ fenced: boolean;
136
+ }
137
+
138
+ /**
139
+ * 按行切开并标出围栏代码块(``` / ~~~)内的行
140
+ *
141
+ * 只服务排版收拢:CAPABILITIES.md 是手写文档,块内长得像表格的示例行不是排版脏行,
142
+ * 收它就是删用户正文(harness#171)。登记条目面不在此列——见 `updateCapabilitiesFile`
143
+ * 里「幽灵行删除不豁免围栏」的取舍注释。
144
+ */
145
+ function parseLayoutLines(content: string): LayoutLine[] {
146
+ const parsed: LayoutLine[] = [];
147
+ let inFence = false;
148
+ for (const text of content.split('\n')) {
149
+ if (FENCE_LINE_REGEX.test(text)) {
150
+ parsed.push({ text, fenced: true });
151
+ inFence = !inFence;
152
+ continue;
153
+ }
154
+ parsed.push({ text, fenced: inFence });
155
+ }
156
+ return parsed;
157
+ }
158
+
159
+ const isTableRow = (line: LayoutLine | undefined): line is LayoutLine =>
160
+ line !== undefined && !line.fenced && TABLE_ROW_REGEX.test(line.text);
161
+
162
+ const isTableSeparator = (line: LayoutLine | undefined): boolean =>
163
+ line !== undefined && !line.fenced && TABLE_SEPARATOR_REGEX.test(line.text);
164
+
165
+ /** 走一遍两条收拢规则(不迭代) */
166
+ function collapseTableLayoutOnce(content: string): TableLayoutNormalization {
167
+ const parsed = parseLayoutLines(content);
168
+
169
+ // ① 收拢表格内空行:一段连续空行,两侧最近非空行都是表格行 → 整段丢弃
170
+ const kept: LayoutLine[] = [];
171
+ let blankLines = 0;
172
+ for (let i = 0; i < parsed.length; ) {
173
+ if (parsed[i].text.trim() !== '') {
174
+ kept.push(parsed[i]);
175
+ i++;
176
+ continue;
177
+ }
178
+ let end = i;
179
+ while (end < parsed.length && parsed[end].text.trim() === '') end++;
180
+ if (isTableRow(kept[kept.length - 1]) && isTableRow(parsed[end])) {
181
+ blankLines += end - i;
182
+ } else {
183
+ kept.push(...parsed.slice(i, end));
184
+ }
185
+ i = end;
186
+ }
187
+
188
+ // ② 收掉空表:表头 + 分隔行后面没有数据行
189
+ const out: string[] = [];
190
+ let emptyTables = 0;
191
+ for (let i = 0; i < kept.length; i++) {
192
+ if (isTableRow(kept[i]) && isTableSeparator(kept[i + 1]) && !isTableRow(kept[i + 2])) {
193
+ emptyTables++;
194
+ i++;
195
+ continue;
196
+ }
197
+ out.push(kept[i].text);
198
+ }
199
+
200
+ return { content: out.join('\n'), blankLines, emptyTables };
201
+ }
202
+
203
+ /**
204
+ * 收拢 CAPABILITIES.md 的表格排版(harness#171)
205
+ *
206
+ * 两条规则:① 删掉夹在两个表格行之间的空行(CommonMark 会在此切断表格);
207
+ * ② 收掉没有数据行的表头+分隔行。表格外的空行(段落分隔)不动;围栏代码块内的行整体豁免。
208
+ *
209
+ * 规则互相制造对方的触发点(①把两张空表之间的那个空行吃掉后,②一轮只收得掉后一张),
210
+ * 所以跑到不动点:**一次 `sync-docs` 必须把 `--check` 报出来的东西全清掉**,
211
+ * 否则下游 CI 修完还是红的。计数 = 各轮合计(每轮只删当轮存在的行,不会重计)。
212
+ *
213
+ * 幂等,且 `--check` 与写模式共用此正本——判定面就是「返回内容与入参是否不同」,
214
+ * 因此不存在「check 报了 fix 修不掉」的不收敛(ADR-0009 口径)。
215
+ */
216
+ export function normalizeCapabilitiesTableLayout(content: string): TableLayoutNormalization {
217
+ let current = content;
218
+ let blankLines = 0;
219
+ let emptyTables = 0;
220
+ for (;;) {
221
+ const pass = collapseTableLayoutOnce(current);
222
+ if (pass.blankLines === 0 && pass.emptyTables === 0) {
223
+ return { content: current, blankLines, emptyTables };
224
+ }
225
+ blankLines += pass.blankLines;
226
+ emptyTables += pass.emptyTables;
227
+ current = pass.content;
228
+ }
229
+ }
230
+
101
231
  /**
102
232
  * 生成 CAPABILITIES.md 内容
103
233
  */
@@ -34,6 +34,7 @@ import {
34
34
  parseCapabilitiesFiles,
35
35
  updateCapabilitiesFile,
36
36
  compactCapabilitiesContent,
37
+ normalizeCapabilitiesTableLayout,
37
38
  } from './capabilities-syncer';
38
39
  import {
39
40
  createContextMd,
@@ -264,7 +265,15 @@ export async function syncDocs(
264
265
  }
265
266
  const hasAgentsIssues = options.agents === true && agentsMdStale;
266
267
 
267
- const hasTableIssues = result.added.length > 0 || result.removed.length > 0;
268
+ // 表格排版脏行(#171):撤登记时残留的空行会把 CAPABILITIES.md 的表格切断。
269
+ // 判定与修复共用 normalizeCapabilitiesTableLayout 正本——同一份规则,check 报的 fix 必清得掉。
270
+ const tableLayout = capsIsCapabilityListing
271
+ ? { blankLines: 0, emptyTables: 0 }
272
+ : normalizeCapabilitiesTableLayout(capsContent);
273
+ const hasTableLayoutIssues = tableLayout.blankLines > 0 || tableLayout.emptyTables > 0;
274
+
275
+ const hasTableEntryIssues = result.added.length > 0 || result.removed.length > 0;
276
+ const hasTableIssues = hasTableEntryIssues || hasTableLayoutIssues;
268
277
  const hasCapIssues = capCountMismatches.length > 0;
269
278
  // mtime 只作提示,不参与判定(harness#142):判定面是内容漂移与缺失
270
279
  const hasContextIssues = result.contextMissing.length > 0 || result.contextContentDrift.length > 0;
@@ -287,6 +296,7 @@ export async function syncDocs(
287
296
  contextMissing: result.contextMissing.length,
288
297
  contextStale: result.contextStale.length,
289
298
  contextContentDrift: result.contextContentDrift.length,
299
+ tableLayoutDirty: hasTableLayoutIssues,
290
300
  },
291
301
  contextMissing: result.contextMissing.map(d => ({
292
302
  dir: d,
@@ -314,7 +324,7 @@ export async function syncDocs(
314
324
  });
315
325
  }
316
326
 
317
- if (!capsIsCapabilityListing && hasTableIssues) {
327
+ if (!capsIsCapabilityListing && hasTableEntryIssues) {
318
328
  if (capsMode === 'module') {
319
329
  // module 模式:added 为聚合后的未覆盖目录,需人工登记目录条目
320
330
  jsonOutput.added = result.added.map(d => ({ dir: d }));
@@ -341,6 +351,19 @@ export async function syncDocs(
341
351
  }
342
352
  }
343
353
 
354
+ if (hasTableLayoutIssues) {
355
+ jsonOutput.tableLayout = {
356
+ blankLines: tableLayout.blankLines,
357
+ emptyTables: tableLayout.emptyTables,
358
+ };
359
+ (jsonOutput.resolution as Array<Record<string, unknown>>).push({
360
+ action: 'sync-capabilities-table-layout',
361
+ command: 'harness sync-docs',
362
+ details:
363
+ 'CAPABILITIES.md 的表格被空行切断、或残留无数据行的空表头,运行 harness sync-docs 收拢',
364
+ });
365
+ }
366
+
344
367
  if (hasContextIssues) {
345
368
  (jsonOutput.resolution as Array<Record<string, unknown>>).push(
346
369
  ...(result.contextMissing.length > 0
@@ -400,6 +423,16 @@ export async function syncDocs(
400
423
  result.removed.forEach(f => log(io, chalk.gray(` - ${f}`)));
401
424
  }
402
425
 
426
+ if (hasTableLayoutIssues) {
427
+ log(io, chalk.yellow(`\n🧹 CAPABILITIES.md 表格排版待收拢:`));
428
+ if (tableLayout.blankLines > 0) {
429
+ log(io, chalk.gray(` - 表格内空行 ${tableLayout.blankLines} 处(CommonMark 会在此把表格切断)`));
430
+ }
431
+ if (tableLayout.emptyTables > 0) {
432
+ log(io, chalk.gray(` - 无数据行的空表 ${tableLayout.emptyTables} 张(连表头/分隔行一起收掉)`));
433
+ }
434
+ }
435
+
403
436
  if (result.contextMissing.length > 0) {
404
437
  log(io, chalk.yellow(`\n📋 缺少 CONTEXT.md:`));
405
438
  result.contextMissing.forEach(d => log(io, chalk.gray(` - ${d}/CONTEXT.md`)));
@@ -450,10 +483,14 @@ export async function syncDocs(
450
483
  if (isCheck) {
451
484
  log(io, chalk.red('\n❌ 文档不是最新的,请运行 harness sync-docs 更新'));
452
485
  // reason 必须可定位(harness#142):CI 判红时要直接拿到文件与符号,不靠翻 stdout
486
+ const tableLayoutReason = hasTableLayoutIssues
487
+ ? `;CAPABILITIES.md 表格排版待收拢(表格内空行 ${tableLayout.blankLines} 处、`
488
+ + `空表 ${tableLayout.emptyTables} 张)`
489
+ : '';
453
490
  return drift(
454
491
  contextDriftReason
455
- ? `文档不是最新的(CONTEXT.md 与实现漂移:${contextDriftReason})`
456
- : '文档不是最新的,请运行 harness sync-docs 更新'
492
+ ? `文档不是最新的(CONTEXT.md 与实现漂移:${contextDriftReason})${tableLayoutReason}`
493
+ : `文档不是最新的,请运行 harness sync-docs 更新${tableLayoutReason}`
457
494
  );
458
495
  }
459
496