@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,147 @@
1
+ /**
2
+ * lint 基线再生命令核(--write-baseline --issue <指针>)
3
+ *
4
+ * 棘轮契约:存在基线外新增违规即拒绝再生(先处置新增违规);再生以现行违规
5
+ * 全集重建基线(修复存量即自动缩短),渲染经 config 包 renderBaselineRegeneratedConfig。
6
+ */
7
+
8
+ import type { LintBaselineEntry, LziIssuesConfig } from '@longzai-intelligence-issues/config';
9
+
10
+ import { renderBaselineRegeneratedConfig } from '@longzai-intelligence-issues/config';
11
+
12
+ import { type LintViolation, runLint } from './lint.core.js';
13
+
14
+ /**
15
+ * 基线再生结果
16
+ */
17
+ export type WriteLintBaselineResult =
18
+ | {
19
+ /**
20
+ * 再生成功标记
21
+ */
22
+ ok: true;
23
+
24
+ /**
25
+ * 再生产物全文(整文件写回 lzi-issues.config.ts)
26
+ */
27
+ content: string;
28
+
29
+ /**
30
+ * 再生条目数
31
+ */
32
+ entryCount: number;
33
+ }
34
+ | {
35
+ /**
36
+ * 再生成功标记
37
+ */
38
+ ok: false;
39
+
40
+ /**
41
+ * 拒绝原因
42
+ */
43
+ error: string;
44
+ };
45
+
46
+ /**
47
+ * 基线再生命令入参
48
+ */
49
+ export type WriteLintBaselineInput = {
50
+ /**
51
+ * 仓库根绝对路径
52
+ */
53
+ root: string;
54
+
55
+ /**
56
+ * 解析后配置
57
+ */
58
+ config: LziIssuesConfig;
59
+
60
+ /**
61
+ * 收编指针(非空;作为新条目盖章)
62
+ */
63
+ issuePointer: string;
64
+ };
65
+
66
+ /**
67
+ * 基线再生命令核
68
+ *
69
+ * @param input - 命令入参
70
+ * @returns 再生结果
71
+ */
72
+ export function runWriteLintBaseline(input: WriteLintBaselineInput): WriteLintBaselineResult {
73
+ if (input.issuePointer.trim() === '') {
74
+ return { ok: false, error: '再生须携带非空 --issue <指针>(收编盖章)' };
75
+ }
76
+
77
+ /**
78
+ * 全集违规(跳过基线豁免口径)
79
+ */
80
+ const raw = runLint({
81
+ root: input.root,
82
+ config: input.config,
83
+ ignoreBaseline: true,
84
+ }).violations.filter((violation) => violation.rule !== 'baseline-stale');
85
+
86
+ /**
87
+ * 旧基线键集合
88
+ */
89
+ const oldKeys = new Set(
90
+ input.config.lint.baseline.map((entry) => `${entry.file}::${entry.rule}`),
91
+ );
92
+
93
+ /**
94
+ * 基线外新增违规(须先处置)
95
+ */
96
+ const newOutsideOld = raw.filter(
97
+ (violation) => !oldKeys.has(`${violation.file}::${violation.rule}`),
98
+ );
99
+
100
+ if (newOutsideOld.length > 0) {
101
+ /**
102
+ * 新增违规清单(拒绝报文)
103
+ */
104
+ const listing = newOutsideOld
105
+ .slice(0, 10)
106
+ .map((violation) => `${violation.file} [${violation.rule}]`)
107
+ .join('\n');
108
+
109
+ return {
110
+ ok: false,
111
+ error: `存在 ${newOutsideOld.length} 项基线外新增违规,拒绝再生(先处置新增违规后再收编存量):\n${listing}`,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * 再生条目(现行全集 + 收编指针盖章)
117
+ */
118
+ const entries: LintBaselineEntry[] = raw.map((violation) => ({
119
+ file: violation.file,
120
+ rule: violation.rule,
121
+ issue: input.issuePointer,
122
+ }));
123
+
124
+ return {
125
+ ok: true,
126
+ content: renderBaselineRegeneratedConfig(input.config, 'lint', entries),
127
+ entryCount: entries.length,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * 从 lint 违规导出基线条目(排序去重;测试与工具消费)
133
+ *
134
+ * @param violations - 违规列表
135
+ * @param issuePointer - 收编指针
136
+ * @returns 基线条目列表
137
+ */
138
+ export function deriveEntriesFromViolations(
139
+ violations: readonly LintViolation[],
140
+ issuePointer: string,
141
+ ): LintBaselineEntry[] {
142
+ return violations.map((violation) => ({
143
+ file: violation.file,
144
+ rule: violation.rule,
145
+ issue: issuePointer,
146
+ }));
147
+ }
@@ -0,0 +1,584 @@
1
+ /**
2
+ * lint 规则核(12 条核心规则 + 规则包扩展点 + 棘轮基线三态)
3
+ *
4
+ * 判定全部基于 registry.parser 的解析产物(口径单一真源);规则包经
5
+ * registerLintRulePack 注册、config lint.rulePacks 启用(引擎零仓库知识)。
6
+ * 基线三态:命中豁免→suppressed;未命中→active 红;条目无现行违规→stale 红
7
+ * (棘轮只许缩短)。
8
+ */
9
+
10
+ import type { LintBaselineEntry, LziIssuesConfig } from '@longzai-intelligence-issues/config';
11
+
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+
15
+ import { matchMiniGlob } from '@/fs/mini-glob.utils';
16
+ import { walkFilesUnderPrefixes } from '@/fs/walk-surface.utils';
17
+ import { ASCII_KEBAB_SLUG_PATTERN } from '@/parser/format.utils';
18
+ import { type IssueDocRecord, scanRegistryDir } from '@/parser/registry.parser';
19
+
20
+ /**
21
+ * lint 违规
22
+ */
23
+ export type LintViolation = {
24
+ /**
25
+ * issue 文件仓库相对 posix 路径
26
+ */
27
+ file: string;
28
+
29
+ /**
30
+ * 违规行号(0 = 全文级)
31
+ */
32
+ line: number;
33
+
34
+ /**
35
+ * 规则名
36
+ */
37
+ rule: string;
38
+
39
+ /**
40
+ * 人类可读违规消息
41
+ */
42
+ message: string;
43
+ };
44
+
45
+ /**
46
+ * lint 结果
47
+ */
48
+ export type LintResult = {
49
+ /**
50
+ * 违规列表(active + baseline-stale,按 file/rule 排序)
51
+ */
52
+ violations: LintViolation[];
53
+
54
+ /**
55
+ * 基线豁免计数(命中存量)
56
+ */
57
+ suppressed: number;
58
+
59
+ /**
60
+ * 基线条目总数
61
+ */
62
+ baselineCount: number;
63
+ };
64
+
65
+ /**
66
+ * 规则判定上下文
67
+ */
68
+ export type LintRuleContext = {
69
+ /**
70
+ * 仓库根绝对路径
71
+ */
72
+ root: string;
73
+
74
+ /**
75
+ * 所属 scope key
76
+ */
77
+ scopeKey: string;
78
+
79
+ /**
80
+ * 注册表目录(posix 相对)
81
+ */
82
+ registryDir: string;
83
+
84
+ /**
85
+ * 该注册表全部文件名集合(blocking 引用存在性判定)
86
+ */
87
+ registryBasenames: ReadonlySet<string>;
88
+
89
+ /**
90
+ * 解析后配置
91
+ */
92
+ config: LziIssuesConfig;
93
+ };
94
+
95
+ /**
96
+ * 单条规则(返回违规消息即违规;null 放行)
97
+ */
98
+ export type LintRule = {
99
+ /**
100
+ * 规则名(基线 (file, rule) 豁免键成分)
101
+ */
102
+ name: string;
103
+
104
+ /**
105
+ * 判定函数
106
+ *
107
+ * @param record - 解析产物
108
+ * @param ctx - 判定上下文
109
+ * @returns 违规消息;放行为 null
110
+ */
111
+ evaluate: (record: IssueDocRecord, ctx: LintRuleContext) => string | null;
112
+ };
113
+
114
+ /**
115
+ * 规则包注册表(packId → 规则列表)
116
+ */
117
+ const rulePackRegistry = new Map<string, readonly LintRule[]>();
118
+
119
+ /**
120
+ * 注册 lint 规则包(域规则扩展点;覆盖同名包)
121
+ *
122
+ * @param packId - 规则包 id(config lint.rulePacks 引用)
123
+ * @param rules - 规则列表
124
+ */
125
+ export function registerLintRulePack(packId: string, rules: readonly LintRule[]): void {
126
+ rulePackRegistry.set(packId, rules);
127
+ }
128
+
129
+ /**
130
+ * 「状态行含判据锚点指引」识别标记(手工翻绿识别的判定成分)
131
+ */
132
+ const CANONICAL_GREEN_HINT = '判据证据锚点见处置段';
133
+
134
+ /**
135
+ * 读取评论边车条目 issueId 列表(Reflect.get 无断言窄化)
136
+ *
137
+ * @param value - 边车解析值
138
+ * @returns issueId 列表;结构非法(items 非数组)返回 null
139
+ */
140
+ function readSidecarIssueIds(value: unknown): string[] | null {
141
+ if (typeof value !== 'object' || value === null) {
142
+ return null;
143
+ }
144
+
145
+ /**
146
+ * items 字段值
147
+ */
148
+ const items = Reflect.get(value, 'items');
149
+
150
+ if (items === undefined) {
151
+ return [];
152
+ }
153
+
154
+ if (!Array.isArray(items)) {
155
+ return null;
156
+ }
157
+
158
+ /**
159
+ * issueId 收集器
160
+ */
161
+ const ids: string[] = [];
162
+
163
+ for (const item of items) {
164
+ if (typeof item !== 'object' || item === null) {
165
+ continue;
166
+ }
167
+
168
+ /**
169
+ * 单条 issueId
170
+ */
171
+ const issueId = Reflect.get(item, 'issueId');
172
+
173
+ if (typeof issueId === 'string' && issueId !== '') {
174
+ ids.push(issueId);
175
+ }
176
+ }
177
+
178
+ return ids;
179
+ }
180
+
181
+ /**
182
+ * 核心规则全集(13 条)
183
+ */
184
+ export const CORE_LINT_RULES: readonly LintRule[] = [
185
+ {
186
+ name: 'status-line-missing',
187
+ evaluate: (record) =>
188
+ record.status === 'unknown'
189
+ ? '头部 30 行内未定位到状态行(状态行是处理情况的第一入口)'
190
+ : null,
191
+ },
192
+ {
193
+ name: 'status-line-legacy-format',
194
+ evaluate: (record, ctx) =>
195
+ ctx.config.lint.strictLegacy &&
196
+ (record.status === 'legacy-yellow' || record.status === 'legacy-green')
197
+ ? `状态行为旧格式变体(${record.status})——须迁移为规范五态写法`
198
+ : null,
199
+ },
200
+ {
201
+ name: 'yellow-no-pending-reason',
202
+ evaluate: (record) =>
203
+ (record.status === 'yellow' || record.status === 'legacy-yellow') &&
204
+ record.pendingReason === null
205
+ ? '黄灯(处理中)状态行须括注 ≥4 字挂起原因'
206
+ : null,
207
+ },
208
+ {
209
+ name: 'green-no-disposition-section',
210
+ evaluate: (record) =>
211
+ (record.status === 'green' || record.status === 'legacy-green') &&
212
+ !record.hasDispositionSection
213
+ ? '绿灯缺少「处置结果/验证口径」段'
214
+ : null,
215
+ },
216
+ {
217
+ name: 'green-disposition-placeholder',
218
+ evaluate: (record) =>
219
+ (record.status === 'green' || record.status === 'legacy-green') &&
220
+ record.hasDispositionSection &&
221
+ record.dispositionIsPlaceholder
222
+ ? '处置段仍为占位形态(处置中/待回填)——禁止虚标终态'
223
+ : null,
224
+ },
225
+ {
226
+ name: 'green-canonical-no-evidence-block',
227
+ evaluate: (record) =>
228
+ record.status === 'green' &&
229
+ record.statusLine.includes(CANONICAL_GREEN_HINT) &&
230
+ !record.hasEvidenceAnchorBlock
231
+ ? '状态行声称「判据证据锚点见处置段」而证据锚点块缺席(手工翻绿识别——收口须经 verify-close)'
232
+ : null,
233
+ },
234
+ {
235
+ name: 'frozen-format',
236
+ evaluate: (record) =>
237
+ record.status === 'frozen' && !/⚪\s*已冻结/.test(record.statusLine)
238
+ ? '冻结状态行须为「⚪ 已冻结(…)」规范形态'
239
+ : null,
240
+ },
241
+ {
242
+ name: 'frozen-no-adjudication-note',
243
+ evaluate: (record) =>
244
+ record.status === 'frozen' && !record.statusLine.includes('用户裁定')
245
+ ? '冻结状态行须含「用户裁定」语(工具拒绝形式冻结)'
246
+ : null,
247
+ },
248
+ {
249
+ name: 'issue-slug-non-ascii',
250
+ evaluate: (record) =>
251
+ ASCII_KEBAB_SLUG_PATTERN.test(record.slug)
252
+ ? null
253
+ : `slug 段须为纯 ASCII kebab(小写字母/数字/连字符,禁中文与非 ASCII 字符):${record.file}`,
254
+ },
255
+ {
256
+ name: 'issue-slug-contains-digit',
257
+ evaluate: (record, ctx) => {
258
+ if (!/\d/.test(record.slug)) {
259
+ return null;
260
+ }
261
+
262
+ /**
263
+ * 仓库相对路径(白名单匹配口径)
264
+ */
265
+ const repoRelative = `${ctx.registryDir}/${record.file}`;
266
+
267
+ /**
268
+ * 白名单命中判定结果
269
+ */
270
+ const whitelisted = ctx.config.slugDigitAllowlist.some((entry) =>
271
+ matchMiniGlob(entry.glob, repoRelative),
272
+ );
273
+
274
+ return whitelisted
275
+ ? null
276
+ : `slug 段含数字(白名单登记请到配置 slugDigitAllowlist):${record.file}`;
277
+ },
278
+ },
279
+ {
280
+ name: 'canceled-no-reason',
281
+ evaluate: (record) =>
282
+ record.status === 'canceled' && record.pendingReason === null
283
+ ? '已取消(⚫)状态行须括注 ≥4 字取消原因'
284
+ : null,
285
+ },
286
+ {
287
+ name: 'blocking-ref-unknown',
288
+ evaluate: (record, ctx) => {
289
+ /**
290
+ * 缺席阻塞引用列表
291
+ */
292
+ const missing = record.blockedByRefs.filter(
293
+ (ref) => !ctx.registryBasenames.has(`${ref.number}.md`),
294
+ );
295
+
296
+ return missing.length > 0
297
+ ? `「阻塞」引用的 issue 不存在:${missing.map((ref) => ref.number).join('、')}`
298
+ : null;
299
+ },
300
+ },
301
+ {
302
+ name: 'orphan-sidecar',
303
+ evaluate: (record, ctx) => {
304
+ /**
305
+ * 评论边车路径(.lzi/issues/comments/<scopeKey>/<NNNN>.json)
306
+ */
307
+ const commentsPath = join(
308
+ ctx.root,
309
+ '.lzi',
310
+ 'issues',
311
+ 'comments',
312
+ ctx.scopeKey,
313
+ `${record.id}.json`,
314
+ );
315
+
316
+ if (!existsSync(commentsPath)) {
317
+ return null;
318
+ }
319
+
320
+ try {
321
+ /**
322
+ * 边车解析产物
323
+ */
324
+ const parsed: unknown = JSON.parse(readFileSync(commentsPath, 'utf8'));
325
+
326
+ /**
327
+ * 边车 issueId 列表(结构非法返回 null)
328
+ */
329
+ const ids = readSidecarIssueIds(parsed);
330
+
331
+ if (ids === null) {
332
+ return '评论边车结构非法(items 非数组)——孤儿判定降级为违规提示';
333
+ }
334
+
335
+ /**
336
+ * 指向不存在 issue 的引用列表
337
+ */
338
+ const orphans = ids.filter((issueId) => !ctx.registryBasenames.has(`${issueId}.md`));
339
+
340
+ return orphans.length > 0
341
+ ? `评论边车存在孤儿引用(issue 不存在):${orphans.join('、')}`
342
+ : null;
343
+ } catch {
344
+ return '评论边车 JSON 解析失败(孤儿判定降级为违规提示)';
345
+ }
346
+ },
347
+ },
348
+ ];
349
+
350
+ /**
351
+ * lint 命令入参
352
+ */
353
+ export type RunLintInput = {
354
+ /**
355
+ * 仓库根绝对路径
356
+ */
357
+ root: string;
358
+
359
+ /**
360
+ * 解析后配置
361
+ */
362
+ config: LziIssuesConfig;
363
+
364
+ /**
365
+ * 限定注册表目录(缺省全部 scope)
366
+ */
367
+ dirRels?: readonly string[];
368
+
369
+ /**
370
+ * 是否跳过基线豁免(write-baseline 再生口径:全集违规)
371
+ */
372
+ ignoreBaseline?: boolean;
373
+ };
374
+
375
+ /**
376
+ * 运行 lint(核心规则 + 启用的规则包 + 棘轮基线三态)
377
+ *
378
+ * @param input - 命令入参
379
+ * @returns lint 结果(violations 非空即违规)
380
+ */
381
+ export function runLint(input: RunLintInput): LintResult {
382
+ /**
383
+ * 生效规则列表(核心 + config 启用的规则包)
384
+ */
385
+ const rules: LintRule[] = [...CORE_LINT_RULES];
386
+
387
+ for (const packId of input.config.lint.rulePacks) {
388
+ /**
389
+ * 命中规则包
390
+ */
391
+ const pack = rulePackRegistry.get(packId);
392
+
393
+ if (pack !== undefined) {
394
+ rules.push(...pack);
395
+ }
396
+ }
397
+
398
+ /**
399
+ * 参与判定的 scope 列表
400
+ */
401
+ const scopes =
402
+ input.dirRels === undefined
403
+ ? input.config.scopes
404
+ : input.config.scopes.filter((scope) => input.dirRels?.includes(scope.registryDir));
405
+
406
+ /**
407
+ * 全量违规收集器(基线豁免前)
408
+ */
409
+ const raw: LintViolation[] = [];
410
+
411
+ for (const scope of scopes) {
412
+ /**
413
+ * 注册表解析产物列表
414
+ */
415
+ const records = scanRegistryDir(join(input.root, scope.registryDir));
416
+
417
+ /**
418
+ * 注册表文件名集合(blocking 存在性判定)
419
+ */
420
+ const basenames = new Set(records.map((record) => record.file));
421
+
422
+ /**
423
+ * 判定上下文
424
+ */
425
+ const ctx: LintRuleContext = {
426
+ root: input.root,
427
+ scopeKey: scope.key,
428
+ registryDir: scope.registryDir,
429
+ registryBasenames: basenames,
430
+ config: input.config,
431
+ };
432
+
433
+ for (const record of records) {
434
+ for (const rule of rules) {
435
+ /**
436
+ * 规则判定结果
437
+ */
438
+ const message = rule.evaluate(record, ctx);
439
+
440
+ if (message !== null) {
441
+ raw.push({
442
+ file: `${scope.registryDir}/${record.file}`,
443
+ line: record.statusLineNumber,
444
+ rule: rule.name,
445
+ message,
446
+ });
447
+ }
448
+ }
449
+ }
450
+ }
451
+
452
+ /**
453
+ * 基线查找表 ((file, rule) → issue 指针)
454
+ */
455
+ const baseline = new Map<string, LintBaselineEntry>(
456
+ input.config.lint.baseline.map((entry) => [`${entry.file}::${entry.rule}`, entry]),
457
+ );
458
+
459
+ if (input.ignoreBaseline === true) {
460
+ return {
461
+ violations: raw.sort(compareViolations),
462
+ suppressed: 0,
463
+ baselineCount: baseline.size,
464
+ };
465
+ }
466
+
467
+ /**
468
+ * active 违规(基线外新增)
469
+ */
470
+ const active: LintViolation[] = [];
471
+
472
+ /**
473
+ * 基线豁免计数
474
+ */
475
+ let suppressed = 0;
476
+
477
+ /**
478
+ * 已命中基线键集合(stale 对账用)
479
+ */
480
+ const matchedBaselineKeys = new Set<string>();
481
+
482
+ for (const violation of raw) {
483
+ /**
484
+ * 基线键
485
+ */
486
+ const key = `${violation.file}::${violation.rule}`;
487
+
488
+ if (baseline.has(key)) {
489
+ suppressed += 1;
490
+ matchedBaselineKeys.add(key);
491
+
492
+ continue;
493
+ }
494
+
495
+ active.push(violation);
496
+ }
497
+
498
+ /**
499
+ * stale 基线条目(无对应现行违规——棘轮只许缩短)
500
+ */
501
+ const stale: LintViolation[] = [...baseline.entries()]
502
+ .filter(([key]) => !matchedBaselineKeys.has(key))
503
+ .map(([key, entry]) => ({
504
+ file: key.split('::')[0] ?? '',
505
+ line: 0,
506
+ rule: 'baseline-stale',
507
+ message: `基线条目已无对应现行违规——棘轮只许缩短,须同步删除该条目(收编指针:${entry.issue})`,
508
+ }));
509
+
510
+ return {
511
+ violations: [...active, ...stale].sort(compareViolations),
512
+ suppressed,
513
+ baselineCount: baseline.size,
514
+ };
515
+ }
516
+
517
+ /**
518
+ * 违规排序比较器(file 升序、rule 次序)
519
+ *
520
+ * @param left - 左违规
521
+ * @param right - 右违规
522
+ * @returns 排序值
523
+ */
524
+ function compareViolations(left: LintViolation, right: LintViolation): number {
525
+ if (left.file !== right.file) {
526
+ return left.file.localeCompare(right.file);
527
+ }
528
+
529
+ return left.rule.localeCompare(right.rule);
530
+ }
531
+
532
+ /**
533
+ * 全仓 sidecar 孤儿扫描辅助(orphan-sidecar 的目录级补充:边车存在而注册表无文件)
534
+ *
535
+ * @param root - 仓库根绝对路径
536
+ * @param config - 解析后配置
537
+ * @returns 孤儿边车违规列表
538
+ */
539
+ export function scanOrphanSidecars(root: string, config: LziIssuesConfig): LintViolation[] {
540
+ /**
541
+ * 违规收集器
542
+ */
543
+ const violations: LintViolation[] = [];
544
+
545
+ for (const scope of config.scopes) {
546
+ /**
547
+ * 评论边车目录
548
+ */
549
+ const sidecarDir = join(root, '.lzi', 'issues', 'comments', scope.key);
550
+
551
+ if (!existsSync(sidecarDir)) {
552
+ continue;
553
+ }
554
+
555
+ /**
556
+ * 注册表现存编号集合
557
+ */
558
+ const existing = new Set(
559
+ scanRegistryDir(join(root, scope.registryDir)).map((record) => record.id),
560
+ );
561
+
562
+ for (const file of walkFilesUnderPrefixes(sidecarDir, [''])) {
563
+ /**
564
+ * 边车编号(文件名去扩展名)
565
+ */
566
+ const id =
567
+ file
568
+ .split('/')
569
+ .pop()
570
+ ?.replace(/\.json$/, '') ?? '';
571
+
572
+ if (id !== '' && !existing.has(id)) {
573
+ violations.push({
574
+ file: `.lzi/issues/comments/${scope.key}/${id}.json`,
575
+ line: 0,
576
+ rule: 'orphan-sidecar',
577
+ message: `评论边车为孤儿(注册表无 ${scope.registryDir}/${id}-*.md)——请联动清理`,
578
+ });
579
+ }
580
+ }
581
+ }
582
+
583
+ return violations;
584
+ }