@longzai-intelligence-issues/ledger 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +26 -0
  3. package/dist/index.d.ts +2123 -0
  4. package/dist/index.js +78 -0
  5. package/dist/rolldown-runtime-BqT_7tdF.js +1 -0
  6. package/lzi-builder.config.ts +15 -0
  7. package/lzi-bun.config.ts +8 -0
  8. package/oxlint.config.ts +3 -0
  9. package/package.json +39 -0
  10. package/src/__tests__/close/verify-close.commands.test.ts +560 -0
  11. package/src/__tests__/docs-refs/docs-refs.commands.test.ts +213 -0
  12. package/src/__tests__/fs/mini-glob.utils.test.ts +71 -0
  13. package/src/__tests__/fs/walk-surface.utils.test.ts +170 -0
  14. package/src/__tests__/health/health.commands.test.ts +131 -0
  15. package/src/__tests__/lint/lint.core.test.ts +332 -0
  16. package/src/__tests__/numbering/numbering.commands.test.ts +194 -0
  17. package/src/__tests__/numbering/numbering.core.test.ts +318 -0
  18. package/src/__tests__/parser/guide-fixture.utils.test.ts +45 -0
  19. package/src/__tests__/parser/registry.parser.test.ts +316 -0
  20. package/src/__tests__/prompts/prompts.commands.test.ts +47 -0
  21. package/src/__tests__/template/issue-template.renderer.test.ts +133 -0
  22. package/src/close/evidence-reader.utils.ts +201 -0
  23. package/src/close/green-flip.commands.ts +199 -0
  24. package/src/close/verify-close.commands.ts +676 -0
  25. package/src/docs-refs/docs-refs.commands.ts +693 -0
  26. package/src/freeze/freeze.commands.ts +261 -0
  27. package/src/fs/mini-glob.utils.ts +216 -0
  28. package/src/fs/walk-surface.utils.ts +298 -0
  29. package/src/health/health.commands.ts +327 -0
  30. package/src/index.ts +46 -0
  31. package/src/lint/lint-baseline.commands.ts +147 -0
  32. package/src/lint/lint.core.ts +584 -0
  33. package/src/normalize/normalize-header.commands.ts +361 -0
  34. package/src/numbering/numbering.commands.ts +375 -0
  35. package/src/numbering/numbering.core.ts +685 -0
  36. package/src/parser/format.utils.ts +279 -0
  37. package/src/parser/guide-fixture.utils.ts +117 -0
  38. package/src/parser/registry.parser.ts +679 -0
  39. package/src/prompts/prompts.commands.ts +211 -0
  40. package/src/template/issue-template.renderer.ts +351 -0
  41. package/src/triage/triage.classify.ts +93 -0
  42. package/src/vault/vault.commands.ts +434 -0
  43. package/tsconfig/.cache/build.tsbuildinfo +1 -0
  44. package/tsconfig/.cache/node.tsbuildinfo +1 -0
  45. package/tsconfig/.cache/test.tsbuildinfo +1 -0
  46. package/tsconfig/app.json +13 -0
  47. package/tsconfig/build.json +15 -0
  48. package/tsconfig/node.json +12 -0
  49. package/tsconfig/test.json +15 -0
  50. package/tsconfig.json +23 -0
@@ -0,0 +1,318 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import {
4
+ auditCoverage,
5
+ computeNextNumber,
6
+ extractFileKey,
7
+ extractSlugSegment,
8
+ keyLeadingNumber,
9
+ scanScopeForFindings,
10
+ } from '@/numbering/numbering.core';
11
+
12
+ /**
13
+ * numericPrefix 规则
14
+ */
15
+ const RULE = { kind: 'numericPrefix' } as const;
16
+
17
+ describe('extractFileKey', () => {
18
+ test('numericPrefix:前缀数字 + 分隔符', () => {
19
+ expect(extractFileKey('0001-a-b.md', RULE)).toBe('0001');
20
+ expect(extractFileKey('0012.foo.md', RULE)).toBe('0012');
21
+ expect(extractFileKey('0035_bar.md', RULE)).toBe('0035');
22
+ expect(extractFileKey('0035 bar.md', RULE)).toBe('0035');
23
+ expect(extractFileKey('42.md', RULE)).toBe('42');
24
+ });
25
+
26
+ test('无键文件返回 null(不占号)', () => {
27
+ expect(extractFileKey('README.md', RULE)).toBeNull();
28
+ expect(extractFileKey('notes-0001.md', RULE)).toBeNull();
29
+ });
30
+
31
+ test('compositePrefix / numericSuffix / customPattern', () => {
32
+ expect(extractFileKey('12-34-report.md', { kind: 'compositePrefix', groups: 2 })).toBe('12-34');
33
+ expect(extractFileKey('feedback-7.md', { kind: 'numericSuffix' })).toBe('7');
34
+ expect(extractFileKey('page-3-note-9.md', { kind: 'numericSuffix' })).toBe('9');
35
+ expect(extractFileKey('R0007-x.md', { kind: 'customPattern', pattern: 'R(?<key>\\d+)' })).toBe(
36
+ '0007',
37
+ );
38
+ expect(
39
+ extractFileKey('no-match.md', { kind: 'customPattern', pattern: 'R(?<key>\\d+)' }),
40
+ ).toBeNull();
41
+ });
42
+ });
43
+
44
+ describe('keyLeadingNumber', () => {
45
+ test('前导数值', () => {
46
+ expect(keyLeadingNumber('0035')).toBe(35);
47
+ expect(keyLeadingNumber('12-34')).toBe(12);
48
+ expect(keyLeadingNumber('abc')).toBeNull();
49
+ });
50
+ });
51
+
52
+ describe('computeNextNumber', () => {
53
+ test('max+1 顺延取首个空闲', () => {
54
+ const result = computeNextNumber({
55
+ basenames: ['0001-a.md', '0003-c.md'],
56
+ rule: RULE,
57
+ reserved: [],
58
+ });
59
+
60
+ expect(result).toEqual({ ok: true, number: '0004', skippedOccupied: 0, skippedReserved: 0 });
61
+ });
62
+
63
+ test('跳过磁盘空洞已占号与保留号', () => {
64
+ const result = computeNextNumber({
65
+ basenames: ['0005-e.md', '0006-f.md'],
66
+ rule: RULE,
67
+ reserved: ['0007'],
68
+ });
69
+
70
+ expect(result).toEqual({ ok: true, number: '0008', skippedOccupied: 0, skippedReserved: 1 });
71
+
72
+ const withHole = computeNextNumber({
73
+ basenames: ['0001-a.md', '0003-c.md', '0004-d.md'],
74
+ rule: RULE,
75
+ reserved: [],
76
+ });
77
+
78
+ expect(withHole.ok).toBe(true);
79
+ expect(withHole.ok === true && withHole.number).toBe('0005');
80
+ });
81
+
82
+ test('宽度随数值最大键现行形态(3→4 位自然越界)', () => {
83
+ const result = computeNextNumber({
84
+ basenames: ['999-last.md'],
85
+ rule: RULE,
86
+ reserved: [],
87
+ });
88
+
89
+ expect(result.ok).toBe(true);
90
+ expect(result.ok === true && result.number).toBe('1000');
91
+ });
92
+
93
+ test('空范围拒绝取号', () => {
94
+ const result = computeNextNumber({ basenames: ['README.md'], rule: RULE, reserved: [] });
95
+
96
+ expect(result.ok).toBe(false);
97
+ expect(result.ok === false && result.error).toContain('人工定号');
98
+ });
99
+
100
+ test('无键文件不占号(README 不影响)', () => {
101
+ const result = computeNextNumber({
102
+ basenames: ['0001-a.md', 'README.md'],
103
+ rule: RULE,
104
+ reserved: [],
105
+ });
106
+
107
+ expect(result.ok && result.number).toBe('0002');
108
+ });
109
+ });
110
+
111
+ describe('extractSlugSegment', () => {
112
+ test('剥键与扩展名取 slug 段', () => {
113
+ expect(extractSlugSegment('0048-runs-list-grace.md')).toBe('runs-list-grace');
114
+ });
115
+
116
+ test('非前缀数字形态返回 null', () => {
117
+ expect(extractSlugSegment('README.md')).toBeNull();
118
+ });
119
+
120
+ test('无扩展名返回 null', () => {
121
+ expect(extractSlugSegment('0048-runs')).toBeNull();
122
+ });
123
+ });
124
+
125
+ describe('scanScopeForFindings', () => {
126
+ /**
127
+ * 扫描入参基座
128
+ */
129
+ const baseInput = {
130
+ scopeKey: 'root',
131
+ rule: RULE,
132
+ reserved: [] as Array<{ number: string; reason: string }>,
133
+ allowlist: [],
134
+ slugDigitAllowlist: [] as string[],
135
+ toRepoRelative: (basename: string) => `docs/issues/${basename}`,
136
+ fullMode: true,
137
+ };
138
+
139
+ test('撞号(豁免精确覆盖前)', () => {
140
+ const findings = scanScopeForFindings({
141
+ ...baseInput,
142
+ basenames: ['0053-a.md', '0053-b.md'],
143
+ });
144
+
145
+ expect(findings).toHaveLength(1);
146
+ expect(findings[0]?.kind).toBe('duplicate');
147
+ expect(findings[0]?.message).toContain('0053');
148
+ });
149
+
150
+ test('豁免精确覆盖(排序文件集双向相等)免报', () => {
151
+ const findings = scanScopeForFindings({
152
+ ...baseInput,
153
+ basenames: ['0053-b.md', '0053-a.md'],
154
+ allowlist: [
155
+ {
156
+ scopeKey: 'root',
157
+ number: '0053',
158
+ files: ['docs/issues/0053-a.md', 'docs/issues/0053-b.md'],
159
+ status: '维持现状',
160
+ reason: '历史撞号',
161
+ },
162
+ ],
163
+ });
164
+
165
+ expect(findings).toEqual([]);
166
+ });
167
+
168
+ test('豁免文件集不精确(漂移)判 duplicate 且条目 stale', () => {
169
+ const findings = scanScopeForFindings({
170
+ ...baseInput,
171
+ basenames: ['0053-a.md', '0053-b.md'],
172
+ allowlist: [
173
+ {
174
+ scopeKey: 'root',
175
+ number: '0053',
176
+ files: ['docs/issues/0053-a.md'],
177
+ status: '维持现状',
178
+ reason: 'r',
179
+ },
180
+ ],
181
+ });
182
+
183
+ expect(findings.some((f) => f.kind === 'duplicate')).toBe(true);
184
+ expect(findings.some((f) => f.kind === 'allowlistStale')).toBe(true);
185
+ });
186
+
187
+ test('保留号被占用(报文含理由)', () => {
188
+ const findings = scanScopeForFindings({
189
+ ...baseInput,
190
+ basenames: ['0081-x.md'],
191
+ reserved: [{ number: '0081', reason: '历史空洞不回收' }],
192
+ });
193
+
194
+ expect(findings[0]?.kind).toBe('reservedOccupied');
195
+ expect(findings[0]?.message).toContain('历史空洞不回收');
196
+ });
197
+
198
+ test('零填充位数混用', () => {
199
+ const findings = scanScopeForFindings({
200
+ ...baseInput,
201
+ basenames: ['0035-a.md', '0123-b.md', '123-c.md'],
202
+ });
203
+
204
+ expect(findings.some((f) => f.kind === 'widthInconsistent')).toBe(true);
205
+ });
206
+
207
+ test('无填充自然增长族不判混用', () => {
208
+ const findings = scanScopeForFindings({
209
+ ...baseInput,
210
+ basenames: ['1-a.md', '9-b.md', '35-c.md'],
211
+ });
212
+
213
+ expect(findings.some((f) => f.kind === 'widthInconsistent')).toBe(false);
214
+ });
215
+
216
+ test('豁免条目无现行撞号组判 stale(棘轮只许缩短)', () => {
217
+ const findings = scanScopeForFindings({
218
+ ...baseInput,
219
+ basenames: ['0001-a.md'],
220
+ allowlist: [
221
+ {
222
+ scopeKey: 'root',
223
+ number: '0099',
224
+ files: ['docs/issues/0099-a.md', 'docs/issues/0099-b.md'],
225
+ status: '待清偿',
226
+ reason: 'r',
227
+ },
228
+ ],
229
+ });
230
+
231
+ expect(findings[0]?.kind).toBe('allowlistStale');
232
+ });
233
+
234
+ test('slug 含数字违规与白名单豁免', () => {
235
+ const findings = scanScopeForFindings({
236
+ ...baseInput,
237
+ basenames: ['0048-runs-list-grace.md', '0049-model-v2.md'],
238
+ slugDigitPolicy: 'forbid',
239
+ slugDigitAllowlist: ['docs/issues/0049-model-v2.md'],
240
+ });
241
+
242
+ expect(findings).toEqual([]);
243
+ });
244
+
245
+ test('slug 含数字未豁免违规', () => {
246
+ const findings = scanScopeForFindings({
247
+ ...baseInput,
248
+ basenames: ['0049-model-v2.md'],
249
+ slugDigitPolicy: 'forbid',
250
+ });
251
+
252
+ expect(findings[0]?.kind).toBe('slugContainsDigit');
253
+ });
254
+
255
+ test('全量档白名单失效对账(增量档关闭)', () => {
256
+ const full = scanScopeForFindings({
257
+ ...baseInput,
258
+ basenames: ['0001-a.md'],
259
+ slugDigitPolicy: 'forbid',
260
+ slugDigitAllowlist: ['docs/issues/0099-ghost.md'],
261
+ });
262
+
263
+ expect(full.some((f) => f.kind === 'slugDigitAllowlistStale')).toBe(true);
264
+
265
+ const incremental = scanScopeForFindings({
266
+ ...baseInput,
267
+ basenames: ['0001-a.md'],
268
+ slugDigitPolicy: 'forbid',
269
+ slugDigitAllowlist: ['docs/issues/0099-ghost.md'],
270
+ fullMode: false,
271
+ });
272
+
273
+ expect(incremental.some((f) => f.kind === 'slugDigitAllowlistStale')).toBe(false);
274
+ });
275
+ });
276
+
277
+ describe('auditCoverage', () => {
278
+ test('未覆盖编号文件按目录聚合 coverageGap', () => {
279
+ const findings = auditCoverage({
280
+ files: ['docs/decisions/0001-x.md', 'docs/decisions/0002-y.md', 'docs/issues/0001-z.md'],
281
+ numberishPattern: '^\\d{1,4}[-._]',
282
+ registryDirs: ['docs/issues'],
283
+ pending: [],
284
+ });
285
+
286
+ expect(findings).toHaveLength(1);
287
+ expect(findings[0]?.kind).toBe('coverageGap');
288
+ expect(findings[0]?.message).toContain('docs/decisions');
289
+ expect(findings[0]?.message).toContain('0001-x.md');
290
+ });
291
+
292
+ test('pending 覆盖免报且未命中判 pendingStale', () => {
293
+ const findings = auditCoverage({
294
+ files: ['docs/reports/0005-r.md'],
295
+ numberishPattern: '^\\d{1,4}[-._]',
296
+ registryDirs: [],
297
+ pending: [
298
+ { glob: 'docs/reports/*.md', reason: '规划中' },
299
+ { glob: 'docs/plans/*.md', reason: '规划中' },
300
+ ],
301
+ });
302
+
303
+ expect(findings).toHaveLength(1);
304
+ expect(findings[0]?.kind).toBe('pendingStale');
305
+ expect(findings[0]?.message).toContain('docs/plans/*.md');
306
+ });
307
+
308
+ test('非形似编号文件不入判定面', () => {
309
+ const findings = auditCoverage({
310
+ files: ['docs/notes/README.md'],
311
+ numberishPattern: '^\\d{1,4}[-._]',
312
+ registryDirs: [],
313
+ pending: [],
314
+ });
315
+
316
+ expect(findings).toEqual([]);
317
+ });
318
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import {
4
+ GUIDE_FIXTURE_END_MARK,
5
+ GUIDE_FIXTURE_START_MARK,
6
+ loadIssueFileFormatGuide,
7
+ sliceGuideFixtureSample,
8
+ } from '@/parser/guide-fixture.utils';
9
+ import { renderIssueTemplateSkeleton } from '@/template/issue-template.renderer';
10
+
11
+ /**
12
+ * 指南全文(每次测试现读真源,杜绝缓存漂移)
13
+ */
14
+ const guide = loadIssueFileFormatGuide();
15
+
16
+ describe('格式指南活样板零漂移', () => {
17
+ test('指南存在且夹具标记在位', () => {
18
+ expect(guide.includes(GUIDE_FIXTURE_START_MARK)).toBe(true);
19
+ expect(guide.includes(GUIDE_FIXTURE_END_MARK)).toBe(true);
20
+ });
21
+
22
+ test('活样板与骨架渲染真源逐字一致', () => {
23
+ expect(sliceGuideFixtureSample(guide)).toBe(renderIssueTemplateSkeleton().replace(/\n+$/, ''));
24
+ });
25
+ });
26
+
27
+ describe('sliceGuideFixtureSample 负路径', () => {
28
+ test('起点标记缺席显式抛错', () => {
29
+ expect(() => sliceGuideFixtureSample(`正文\n${GUIDE_FIXTURE_END_MARK}`)).toThrow(
30
+ '起点标记缺席',
31
+ );
32
+ });
33
+
34
+ test('终点标记缺席显式抛错', () => {
35
+ expect(() => sliceGuideFixtureSample(`正文\n${GUIDE_FIXTURE_START_MARK}`)).toThrow(
36
+ '终点标记缺席',
37
+ );
38
+ });
39
+
40
+ test('标记次序颠倒显式抛错', () => {
41
+ expect(() =>
42
+ sliceGuideFixtureSample(`${GUIDE_FIXTURE_END_MARK}\n正文\n${GUIDE_FIXTURE_START_MARK}`),
43
+ ).toThrow('次序颠倒');
44
+ });
45
+ });
@@ -0,0 +1,316 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+
4
+ import {
5
+ parseCriteriaBlock,
6
+ parseIssueDoc,
7
+ parseRegistryFileName,
8
+ scanRegistryDir,
9
+ } from '@/parser/registry.parser';
10
+
11
+ /**
12
+ * 基准样例文档(粗体标签形态头部)
13
+ */
14
+ const SAMPLE_DOC = [
15
+ '# 0001 - 样例问题标题',
16
+ '',
17
+ '**优先级**: P2',
18
+ '**状态**: 🔴 待处理',
19
+ '**立案日期**: 2026-09-14',
20
+ '**所属域**: 样例域',
21
+ '**来源**: 用户裁定(2026-09-14)',
22
+ '**沿革**: [0002](./0002-prior-issue.md)',
23
+ '**阻塞**: [0003](./0003-blocker.md);[0004]',
24
+ '',
25
+ '## 一、问题动机',
26
+ '',
27
+ '> 问题原文',
28
+ '',
29
+ '## 二、现状与证据',
30
+ '',
31
+ '排查记录',
32
+ '',
33
+ '## 三、处置方向',
34
+ '',
35
+ '【1】备选方案和分析',
36
+ '',
37
+ '## 四、处置结果/验证口径',
38
+ '',
39
+ '处置完成',
40
+ ].join('\n');
41
+
42
+ describe('parseRegistryFileName', () => {
43
+ test('命中口径:编号 + slug + .md', () => {
44
+ expect(parseRegistryFileName('0035-runs-list-grace.md')).toEqual({
45
+ id: '0035',
46
+ slug: 'runs-list-grace',
47
+ });
48
+ });
49
+
50
+ test('3 位编号同样命中', () => {
51
+ expect(parseRegistryFileName('035-a.md')).toEqual({ id: '035', slug: 'a' });
52
+ });
53
+
54
+ test('README 不命中', () => {
55
+ expect(parseRegistryFileName('README.md')).toBeNull();
56
+ });
57
+
58
+ test('无 slug 段不命中', () => {
59
+ expect(parseRegistryFileName('0001.md')).toBeNull();
60
+ });
61
+
62
+ test('5 位编号不命中(口径 3-4 位)', () => {
63
+ expect(parseRegistryFileName('00001-too-long.md')).toBeNull();
64
+ });
65
+ });
66
+
67
+ describe('parseIssueDoc 状态行', () => {
68
+ test('粗体标签形态定位成功并归类 red', () => {
69
+ const record = parseIssueDoc('0001-sample.md', SAMPLE_DOC, 0);
70
+
71
+ expect(record?.status).toBe('red');
72
+ expect(record?.statusLineNumber).toBe(4);
73
+ expect(record?.title).toBe('0001 - 样例问题标题');
74
+ });
75
+
76
+ test('半角冒号裸键形态同样定位', () => {
77
+ const record = parseIssueDoc('0001-sample.md', '状态: 🟡 处理中(等待上游发版才能复测)', 0);
78
+
79
+ expect(record?.status).toBe('yellow');
80
+ expect(record?.pendingReason).toBe('等待上游发版才能复测');
81
+ });
82
+
83
+ test('窗口外(第 31 行起)不定位,归 unknown', () => {
84
+ const record = parseIssueDoc(
85
+ '0001-sample.md',
86
+ `${Array.from({ length: 30 }, () => '正文行').join('\n')}\n状态: 🔴 待处理`,
87
+ 0,
88
+ );
89
+
90
+ expect(record?.status).toBe('unknown');
91
+ expect(record?.statusLineNumber).toBe(0);
92
+ });
93
+
94
+ test('挂起原因不足 4 字符判 null', () => {
95
+ const record = parseIssueDoc('0001-sample.md', '状态: 🟡 处理中(等待)', 0);
96
+
97
+ expect(record?.status).toBe('yellow');
98
+ expect(record?.pendingReason).toBeNull();
99
+ });
100
+
101
+ test('旧格式黄灯归类 legacy-yellow', () => {
102
+ const record = parseIssueDoc('0001-sample.md', '**状态**: 🟡 某旧写法', 0);
103
+
104
+ expect(record?.status).toBe('legacy-yellow');
105
+ });
106
+
107
+ test('规范绿灯归类 green', () => {
108
+ const record = parseIssueDoc(
109
+ '0001-sample.md',
110
+ '**状态**: 🟢 已处置(修复完成——判据证据锚点见处置段)',
111
+ 0,
112
+ );
113
+
114
+ expect(record?.status).toBe('green');
115
+ });
116
+
117
+ test('旧格式绿灯归类 legacy-green', () => {
118
+ const record = parseIssueDoc('0001-sample.md', '**状态**: 🟢 done', 0);
119
+
120
+ expect(record?.status).toBe('legacy-green');
121
+ });
122
+
123
+ test('已冻结优先归类 frozen', () => {
124
+ const record = parseIssueDoc('0001-sample.md', '**状态**: ⚪ 已冻结(用户裁定暂停推进)', 0);
125
+
126
+ expect(record?.status).toBe('frozen');
127
+ });
128
+
129
+ test('已取消归类 canceled 并提取括注原因', () => {
130
+ const record = parseIssueDoc('0001-sample.md', '**状态**: ⚫ 已取消(重复立案并入 0002)', 0);
131
+
132
+ expect(record?.status).toBe('canceled');
133
+ expect(record?.pendingReason).toBe('重复立案并入 0002');
134
+ });
135
+ });
136
+
137
+ describe('parseIssueDoc 处置段', () => {
138
+ test('处置段在位且终态内容非占位', () => {
139
+ const record = parseIssueDoc('0001-sample.md', SAMPLE_DOC, 0);
140
+
141
+ expect(record?.hasDispositionSection).toBe(true);
142
+ expect(record?.dispositionIsPlaceholder).toBe(false);
143
+ });
144
+
145
+ test('窗口内命中占位模式判占位', () => {
146
+ const record = parseIssueDoc(
147
+ '0001-sample.md',
148
+ '状态: 🟢 已处置\n\n## 四、处置结果/验证口径\n\n(处置中)\n',
149
+ 0,
150
+ );
151
+
152
+ expect(record?.dispositionIsPlaceholder).toBe(true);
153
+ });
154
+
155
+ test('正文状语形态不计占位', () => {
156
+ const record = parseIssueDoc(
157
+ '0001-sample.md',
158
+ '状态: 🟢 已处置\n\n## 四、处置结果/验证口径\n\n处置中新核实的证据链如下。\n',
159
+ 0,
160
+ );
161
+
162
+ expect(record?.dispositionIsPlaceholder).toBe(false);
163
+ });
164
+
165
+ test('占位出现在窗口外(第 9 行起)不计', () => {
166
+ const record = parseIssueDoc(
167
+ '0001-sample.md',
168
+ [
169
+ '状态: 🟢 已处置',
170
+ '## 四、处置结果/验证口径',
171
+ ...Array.from({ length: 8 }, () => '正文行'),
172
+ '(待回填)',
173
+ ].join('\n'),
174
+ 0,
175
+ );
176
+
177
+ expect(record?.dispositionIsPlaceholder).toBe(false);
178
+ });
179
+ });
180
+
181
+ describe('parseIssueDoc 证据锚点与沿革阻塞', () => {
182
+ test('证据锚点块两种标题形态均在位判定', () => {
183
+ const evidenceClose = parseIssueDoc(
184
+ '0001-sample.md',
185
+ '状态: 🟢 已处置\n\n## 四、处置结果/验证口径\n\n证据锚点(末次通过零执行收口):\n- 退出码 0\n',
186
+ 0,
187
+ );
188
+
189
+ const rerunClose = parseIssueDoc(
190
+ '0001-sample.md',
191
+ '状态: 🟢 已处置\n\n## 四、处置结果/验证口径\n\n判据执行证据(证书化收口):\n- 证书指纹 abc\n',
192
+ 0,
193
+ );
194
+
195
+ expect(evidenceClose?.hasEvidenceAnchorBlock).toBe(true);
196
+ expect(rerunClose?.hasEvidenceAnchorBlock).toBe(true);
197
+ });
198
+
199
+ test('沿革阻塞引用提取(链接与纯编号两形态)', () => {
200
+ const record = parseIssueDoc('0001-sample.md', SAMPLE_DOC, 0);
201
+
202
+ expect(record?.historyRefs).toEqual([{ number: '0002', slug: 'prior-issue' }]);
203
+ expect(record?.blockedByRefs).toEqual([
204
+ { number: '0003', slug: 'blocker' },
205
+ { number: '0004', slug: null },
206
+ ]);
207
+ });
208
+
209
+ test('裸键元信息行(无粗体)同样提取', () => {
210
+ const record = parseIssueDoc('0001-sample.md', '沿革: [0007](./0007-x.md)', 0);
211
+
212
+ expect(record?.historyRefs).toEqual([{ number: '0007', slug: 'x' }]);
213
+ });
214
+ });
215
+
216
+ describe('parseCriteriaBlock', () => {
217
+ test('合法块解析(summary + 多判据 + 注释行跳过)', () => {
218
+ const raw = [
219
+ '```criteria',
220
+ '# 注释行',
221
+ 'summary: 判据全绿',
222
+ '单包测试|test|packages/demo|bun run test',
223
+ '类型检查|typecheck|packages/demo|bun run typecheck',
224
+ '```',
225
+ ].join('\n');
226
+
227
+ const parsed = parseCriteriaBlock(raw);
228
+
229
+ expect(parsed.summary).toBe('判据全绿');
230
+ expect(parsed.criteria).toHaveLength(2);
231
+ expect(parsed.criteria[0]).toEqual({
232
+ label: '单包测试',
233
+ channel: 'test',
234
+ cwd: 'packages/demo',
235
+ command: 'bun run test',
236
+ });
237
+ });
238
+
239
+ test('多块拒收并带第二块行号', () => {
240
+ const raw = ['```criteria', 'a|b|c|d', '```', '正文', '```criteria', 'e|f|g|h', '```'].join(
241
+ '\n',
242
+ );
243
+
244
+ expect(() => parseCriteriaBlock(raw)).toThrow('第二块');
245
+ });
246
+
247
+ test('未闭合拒收', () => {
248
+ const raw = ['```criteria', 'a|b|c|d'].join('\n');
249
+
250
+ expect(() => parseCriteriaBlock(raw)).toThrow('未闭合');
251
+ });
252
+
253
+ test('判据非四段拒收并带行号', () => {
254
+ const raw = ['```criteria', 'summary: s', '只有三段|b|c', '```'].join('\n');
255
+
256
+ expect(() => parseCriteriaBlock(raw)).toThrow('第 3 行');
257
+ });
258
+
259
+ test('channel 不合法拒收', () => {
260
+ const raw = ['```criteria', '标签|<channel>|cwd|cmd', '```'].join('\n');
261
+
262
+ expect(() => parseCriteriaBlock(raw)).toThrow('channel');
263
+ });
264
+
265
+ test('summary 空值与重复均拒收', () => {
266
+ expect(() =>
267
+ parseCriteriaBlock(['```criteria', 'summary: ', 'a|b|c|d', '```'].join('\n')),
268
+ ).toThrow('summary 值为空');
269
+
270
+ expect(() =>
271
+ parseCriteriaBlock(['```criteria', 'summary: a', 'summary: b', 'a|b|c|d', '```'].join('\n')),
272
+ ).toThrow('summary 重复');
273
+ });
274
+
275
+ test('空块(零判据)拒收', () => {
276
+ expect(() => parseCriteriaBlock(['```criteria', 'summary: s', '```'].join('\n'))).toThrow(
277
+ '至少一条判据',
278
+ );
279
+ });
280
+
281
+ test('块缺席拒收', () => {
282
+ expect(() => parseCriteriaBlock('无块的文档')).toThrow('未找到');
283
+ });
284
+ });
285
+
286
+ describe('parseIssueDoc criteria 错误记录', () => {
287
+ test('畸形块记录 criteriaError 不中断读档', () => {
288
+ const raw = ['状态: 🟡 处理中(补充判据中)', '', '```criteria', '坏行', '```'].join('\n');
289
+
290
+ const record = parseIssueDoc('0001-sample.md', raw, 0);
291
+
292
+ expect(record?.criteria).toBeNull();
293
+ expect(record?.criteriaError).toContain('第 4 行');
294
+ });
295
+ });
296
+
297
+ describe('scanRegistryDir', () => {
298
+ test('目录缺席返回空数组', () => {
299
+ expect(scanRegistryDir('/nonexistent/registry-dir')).toEqual([]);
300
+ });
301
+
302
+ test('只收口径内文件并按编号升序', () => {
303
+ const dir = `${import.meta.dir}/__tmp-scan__`;
304
+
305
+ mkdirSync(dir, { recursive: true });
306
+ writeFileSync(`${dir}/0002-second.md`, '状态: 🔴 待处理');
307
+ writeFileSync(`${dir}/0001-first.md`, '状态: 🔴 待处理');
308
+ writeFileSync(`${dir}/README.md`, '说明文件不收编');
309
+
310
+ const records = scanRegistryDir(dir);
311
+
312
+ expect(records.map((record) => record.id)).toEqual(['0001', '0002']);
313
+
314
+ rmSync(dir, { recursive: true, force: true });
315
+ });
316
+ });