@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.
- package/CHANGELOG.md +18 -0
- package/README.md +26 -0
- package/dist/index.d.ts +2123 -0
- package/dist/index.js +78 -0
- package/dist/rolldown-runtime-BqT_7tdF.js +1 -0
- package/lzi-builder.config.ts +15 -0
- package/lzi-bun.config.ts +8 -0
- package/oxlint.config.ts +3 -0
- package/package.json +39 -0
- package/src/__tests__/close/verify-close.commands.test.ts +560 -0
- package/src/__tests__/docs-refs/docs-refs.commands.test.ts +213 -0
- package/src/__tests__/fs/mini-glob.utils.test.ts +71 -0
- package/src/__tests__/fs/walk-surface.utils.test.ts +170 -0
- package/src/__tests__/health/health.commands.test.ts +131 -0
- package/src/__tests__/lint/lint.core.test.ts +332 -0
- package/src/__tests__/numbering/numbering.commands.test.ts +194 -0
- package/src/__tests__/numbering/numbering.core.test.ts +318 -0
- package/src/__tests__/parser/guide-fixture.utils.test.ts +45 -0
- package/src/__tests__/parser/registry.parser.test.ts +316 -0
- package/src/__tests__/prompts/prompts.commands.test.ts +47 -0
- package/src/__tests__/template/issue-template.renderer.test.ts +133 -0
- package/src/close/evidence-reader.utils.ts +201 -0
- package/src/close/green-flip.commands.ts +199 -0
- package/src/close/verify-close.commands.ts +676 -0
- package/src/docs-refs/docs-refs.commands.ts +693 -0
- package/src/freeze/freeze.commands.ts +261 -0
- package/src/fs/mini-glob.utils.ts +216 -0
- package/src/fs/walk-surface.utils.ts +298 -0
- package/src/health/health.commands.ts +327 -0
- package/src/index.ts +46 -0
- package/src/lint/lint-baseline.commands.ts +147 -0
- package/src/lint/lint.core.ts +584 -0
- package/src/normalize/normalize-header.commands.ts +361 -0
- package/src/numbering/numbering.commands.ts +375 -0
- package/src/numbering/numbering.core.ts +685 -0
- package/src/parser/format.utils.ts +279 -0
- package/src/parser/guide-fixture.utils.ts +117 -0
- package/src/parser/registry.parser.ts +679 -0
- package/src/prompts/prompts.commands.ts +211 -0
- package/src/template/issue-template.renderer.ts +351 -0
- package/src/triage/triage.classify.ts +93 -0
- package/src/vault/vault.commands.ts +434 -0
- package/tsconfig/.cache/build.tsbuildinfo +1 -0
- package/tsconfig/.cache/node.tsbuildinfo +1 -0
- package/tsconfig/.cache/test.tsbuildinfo +1 -0
- package/tsconfig/app.json +13 -0
- package/tsconfig/build.json +15 -0
- package/tsconfig/node.json +12 -0
- package/tsconfig/test.json +15 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 编号门禁核心(纯函数,零 fs)
|
|
3
|
+
*
|
|
4
|
+
* apex numbering-guard 语义迁移:四类键型提取、取号(max+1 顺延跳已占与保留、
|
|
5
|
+
* 宽度随最大键)、五类违例扫描(目录=编号命名空间)、slug 数字策略、覆盖面
|
|
6
|
+
* 审计。文件系统编排由 numbering.commands 承担,本模块可独立测试。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { matchMiniGlob } from '@/fs/mini-glob.utils';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 编号键规则(判别联合;v1 issues 注册表固定 numericPrefix,其余键型供通用引擎使用)
|
|
13
|
+
*/
|
|
14
|
+
export type NumberingKeyRule =
|
|
15
|
+
| { kind: 'numericPrefix' }
|
|
16
|
+
| { kind: 'compositePrefix'; groups: number }
|
|
17
|
+
| { kind: 'numericSuffix' }
|
|
18
|
+
| { kind: 'customPattern'; pattern: string };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 编号违例种类
|
|
22
|
+
*/
|
|
23
|
+
export type NumberingFindingKind =
|
|
24
|
+
| 'duplicate'
|
|
25
|
+
| 'reservedOccupied'
|
|
26
|
+
| 'widthInconsistent'
|
|
27
|
+
| 'allowlistStale'
|
|
28
|
+
| 'slugContainsDigit'
|
|
29
|
+
| 'slugDigitAllowlistStale';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 编号违例
|
|
33
|
+
*/
|
|
34
|
+
export type NumberingFinding = {
|
|
35
|
+
/**
|
|
36
|
+
* 违例种类
|
|
37
|
+
*/
|
|
38
|
+
kind: NumberingFindingKind;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 所属 scope(slugDigitAllowlistStale 为 'global')
|
|
42
|
+
*/
|
|
43
|
+
scopeKey: string;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 人类可读违例消息(含文件清单与处置指引)
|
|
47
|
+
*/
|
|
48
|
+
message: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 审计违例种类
|
|
53
|
+
*/
|
|
54
|
+
export type NumberingAuditFindingKind = 'coverageGap' | 'pendingStale';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 审计违例
|
|
58
|
+
*/
|
|
59
|
+
export type NumberingAuditFinding = {
|
|
60
|
+
/**
|
|
61
|
+
* 违例种类
|
|
62
|
+
*/
|
|
63
|
+
kind: NumberingAuditFindingKind;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 人类可读违例消息
|
|
67
|
+
*/
|
|
68
|
+
message: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 编号豁免条目(引擎消费形态;files 为仓库相对 posix 路径,精确文件集)
|
|
73
|
+
*/
|
|
74
|
+
export type NumberingAllowlistEntry = {
|
|
75
|
+
/**
|
|
76
|
+
* 所属 scope key
|
|
77
|
+
*/
|
|
78
|
+
scopeKey: string;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 豁免编号
|
|
82
|
+
*/
|
|
83
|
+
number: string;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 精确文件集(排序后与磁盘对照)
|
|
87
|
+
*/
|
|
88
|
+
files: string[];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 棘轮状态
|
|
92
|
+
*/
|
|
93
|
+
status: string;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 豁免理由
|
|
97
|
+
*/
|
|
98
|
+
reason: string;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 提取文件名编号键
|
|
103
|
+
*
|
|
104
|
+
* 提不出键的文件(README 等)返回 null,天然不占号。
|
|
105
|
+
*
|
|
106
|
+
* @param basename - 文件名
|
|
107
|
+
* @param rule - 键规则
|
|
108
|
+
* @returns 编号键原文;无键为 null
|
|
109
|
+
*/
|
|
110
|
+
export function extractFileKey(basename: string, rule: NumberingKeyRule): string | null {
|
|
111
|
+
if (rule.kind === 'numericPrefix') {
|
|
112
|
+
return /^(\d+)(?=[-._ ]|$)/.exec(basename)?.[1] ?? null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (rule.kind === 'compositePrefix') {
|
|
116
|
+
/**
|
|
117
|
+
* 复合键正则(2-5 段数字,段间 - _ . 或空格分隔)
|
|
118
|
+
*/
|
|
119
|
+
const pattern = new RegExp(`^(\\d+(?:[-._ ]\\d+){${rule.groups - 1}})(?=[-._ ]|$)`);
|
|
120
|
+
|
|
121
|
+
return pattern.exec(basename)?.[1] ?? null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (rule.kind === 'numericSuffix') {
|
|
125
|
+
/**
|
|
126
|
+
* 剥最后一个扩展名后的主干
|
|
127
|
+
*/
|
|
128
|
+
const stem = basename.replace(/\.[^.]+$/, '');
|
|
129
|
+
|
|
130
|
+
return /(\d+)$/.exec(stem)?.[1] ?? null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 自定义正则(具名捕获组 key)
|
|
135
|
+
*/
|
|
136
|
+
const custom = new RegExp(rule.pattern).exec(basename);
|
|
137
|
+
|
|
138
|
+
return custom?.groups?.key ?? null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 提取键的前导数值
|
|
143
|
+
*
|
|
144
|
+
* @param key - 编号键
|
|
145
|
+
* @returns 前导数值;非数字键为 null
|
|
146
|
+
*/
|
|
147
|
+
export function keyLeadingNumber(key: string): number | null {
|
|
148
|
+
/**
|
|
149
|
+
* 前导数字命中
|
|
150
|
+
*/
|
|
151
|
+
const match = /^\d+/.exec(key);
|
|
152
|
+
|
|
153
|
+
return match === null ? null : Number.parseInt(match[0] ?? '', 10);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 取号入参
|
|
158
|
+
*/
|
|
159
|
+
export type NextNumberInput = {
|
|
160
|
+
/**
|
|
161
|
+
* 扫描面文件名列表(键提取口径占号)
|
|
162
|
+
*/
|
|
163
|
+
basenames: readonly string[];
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 键规则
|
|
167
|
+
*/
|
|
168
|
+
rule: NumberingKeyRule;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 保留号列表(不回收复用)
|
|
172
|
+
*/
|
|
173
|
+
reserved: readonly string[];
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 取号结果
|
|
178
|
+
*/
|
|
179
|
+
export type NextNumberResult =
|
|
180
|
+
| { ok: true; number: string; skippedOccupied: number; skippedReserved: number }
|
|
181
|
+
| { ok: false; error: string };
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 取号:max+1 起顺延,跳过磁盘已占与保留号,取首个空闲
|
|
185
|
+
*
|
|
186
|
+
* 宽度 = 数值最大键的字符串长度(零填充跟随现行形态);空范围拒绝取号(无法
|
|
187
|
+
* 推断位数,人工定号)。
|
|
188
|
+
*
|
|
189
|
+
* @param input - 取号入参
|
|
190
|
+
* @returns 取号结果
|
|
191
|
+
*/
|
|
192
|
+
export function computeNextNumber(input: NextNumberInput): NextNumberResult {
|
|
193
|
+
/**
|
|
194
|
+
* 已占键集合(磁盘原文形态,保留零填充)
|
|
195
|
+
*/
|
|
196
|
+
const occupied = new Set<string>();
|
|
197
|
+
|
|
198
|
+
for (const basename of input.basenames) {
|
|
199
|
+
/**
|
|
200
|
+
* 文件键
|
|
201
|
+
*/
|
|
202
|
+
const key = extractFileKey(basename, input.rule);
|
|
203
|
+
|
|
204
|
+
if (key !== null) {
|
|
205
|
+
occupied.add(key);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (occupied.size === 0) {
|
|
210
|
+
return { ok: false, error: '该范围当前无编号文件,无法推断位数——请人工定号后落盘' };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 数值最大键(宽度与起点基准)
|
|
215
|
+
*/
|
|
216
|
+
let maxKey = '';
|
|
217
|
+
|
|
218
|
+
for (const key of occupied) {
|
|
219
|
+
if ((keyLeadingNumber(key) ?? 0) > (keyLeadingNumber(maxKey) || 0)) {
|
|
220
|
+
maxKey = key;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (maxKey === '') {
|
|
225
|
+
return { ok: false, error: '该范围无数字前缀键,无法取号' };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 零填充宽度
|
|
230
|
+
*/
|
|
231
|
+
const width = maxKey.length;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 保留号集合
|
|
235
|
+
*/
|
|
236
|
+
const reserved = new Set(input.reserved);
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 跳过统计
|
|
240
|
+
*/
|
|
241
|
+
let skippedOccupied = 0;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 跳过保留号计数
|
|
245
|
+
*/
|
|
246
|
+
let skippedReserved = 0;
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 候选号(自 max+1 顺延)
|
|
250
|
+
*/
|
|
251
|
+
let candidate = (keyLeadingNumber(maxKey) ?? 0) + 1;
|
|
252
|
+
|
|
253
|
+
for (;;) {
|
|
254
|
+
/**
|
|
255
|
+
* 零填充形态候选
|
|
256
|
+
*/
|
|
257
|
+
const formatted = String(candidate).padStart(width, '0');
|
|
258
|
+
|
|
259
|
+
if (occupied.has(formatted)) {
|
|
260
|
+
skippedOccupied += 1;
|
|
261
|
+
candidate += 1;
|
|
262
|
+
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (reserved.has(formatted)) {
|
|
267
|
+
skippedReserved += 1;
|
|
268
|
+
candidate += 1;
|
|
269
|
+
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return { ok: true, number: formatted, skippedOccupied, skippedReserved };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 提取文件名 slug 段(剥前缀键 + 单个分隔符 + 最后一个扩展名)
|
|
279
|
+
*
|
|
280
|
+
* 空.slug 放行(返回空串);非前缀数字形态返回 null(不属判定面)。
|
|
281
|
+
*
|
|
282
|
+
* @param basename - 文件名
|
|
283
|
+
* @returns slug 段;非判定面为 null
|
|
284
|
+
*/
|
|
285
|
+
export function extractSlugSegment(basename: string): string | null {
|
|
286
|
+
/**
|
|
287
|
+
* 前缀数字键 + 分隔符 + slug + 扩展名形态
|
|
288
|
+
*/
|
|
289
|
+
const match = /^\d+[-._ ](.+)\.[^.]+$/.exec(basename);
|
|
290
|
+
|
|
291
|
+
if (match === null) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return match[1] ?? '';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 违例扫描入参(单 scope)
|
|
300
|
+
*/
|
|
301
|
+
export type ScopeScanInput = {
|
|
302
|
+
/**
|
|
303
|
+
* scope key
|
|
304
|
+
*/
|
|
305
|
+
scopeKey: string;
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 扫描面文件名列表
|
|
309
|
+
*/
|
|
310
|
+
basenames: readonly string[];
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 键规则
|
|
314
|
+
*/
|
|
315
|
+
rule: NumberingKeyRule;
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 保留号(含理由,报文引用)
|
|
319
|
+
*/
|
|
320
|
+
reserved: ReadonlyArray<{ number: string; reason: string }>;
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 该 scope 的豁免条目(files 为仓库相对 posix 路径)
|
|
324
|
+
*/
|
|
325
|
+
allowlist: readonly NumberingAllowlistEntry[];
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* slug 数字策略('forbid' 启用判定)
|
|
329
|
+
*/
|
|
330
|
+
slugDigitPolicy?: 'forbid';
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* slug 数字白名单 glob(仓库相对路径命中豁免)
|
|
334
|
+
*/
|
|
335
|
+
slugDigitAllowlist: readonly string[];
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* 文件仓库相对路径构造器(slug 白名单匹配口径)
|
|
339
|
+
*/
|
|
340
|
+
toRepoRelative: (basename: string) => string;
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 全量档标记(true 时追加 slug 白名单失效对账)
|
|
344
|
+
*/
|
|
345
|
+
fullMode: boolean;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* 排序数组双向相等判定
|
|
350
|
+
*
|
|
351
|
+
* @param left - 左列表
|
|
352
|
+
* @param right - 右列表
|
|
353
|
+
* @returns 排序后是否逐项相等
|
|
354
|
+
*/
|
|
355
|
+
function sortedEqual(left: readonly string[], right: readonly string[]): boolean {
|
|
356
|
+
if (left.length !== right.length) {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* 左右排序副本
|
|
362
|
+
*/
|
|
363
|
+
const sortedLeft = [...left].sort();
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* 右列表排序副本
|
|
367
|
+
*/
|
|
368
|
+
const sortedRight = [...right].sort();
|
|
369
|
+
|
|
370
|
+
return sortedLeft.every((item, index) => item === sortedRight[index]);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 单 scope 违例扫描(目录=编号命名空间)
|
|
375
|
+
*
|
|
376
|
+
* 判定顺序:先撞号组(豁免须排序文件集双向相等精确覆盖)后保留号;全部组判
|
|
377
|
+
* 完后逐豁免条目对账(棘轮只许缩短)。
|
|
378
|
+
*
|
|
379
|
+
* @param input - 扫描入参
|
|
380
|
+
* @returns 违例列表
|
|
381
|
+
*/
|
|
382
|
+
export function scanScopeForFindings(input: ScopeScanInput): NumberingFinding[] {
|
|
383
|
+
/**
|
|
384
|
+
* 违例收集器
|
|
385
|
+
*/
|
|
386
|
+
const findings: NumberingFinding[] = [];
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* 键分组(键 → 文件名列表)
|
|
390
|
+
*/
|
|
391
|
+
const groups = new Map<string, string[]>();
|
|
392
|
+
|
|
393
|
+
for (const basename of input.basenames) {
|
|
394
|
+
/**
|
|
395
|
+
* 文件键
|
|
396
|
+
*/
|
|
397
|
+
const key = extractFileKey(basename, input.rule);
|
|
398
|
+
|
|
399
|
+
if (key === null) {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
groups.set(key, [...(groups.get(key) ?? []), basename]);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* 保留号查找表
|
|
408
|
+
*/
|
|
409
|
+
const reservedMap = new Map(input.reserved.map((entry) => [entry.number, entry.reason]));
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* 已对账豁免条目下标集
|
|
413
|
+
*/
|
|
414
|
+
const consumedAllowlist = new Set<number>();
|
|
415
|
+
|
|
416
|
+
for (const [key, files] of groups) {
|
|
417
|
+
if (files.length >= 2) {
|
|
418
|
+
/**
|
|
419
|
+
* 撞号豁免命中判定(同键且排序文件集双向相等)
|
|
420
|
+
*/
|
|
421
|
+
const coveredIndex = input.allowlist.findIndex(
|
|
422
|
+
(entry, index) =>
|
|
423
|
+
!consumedAllowlist.has(index) &&
|
|
424
|
+
entry.number === key &&
|
|
425
|
+
sortedEqual(
|
|
426
|
+
entry.files,
|
|
427
|
+
files.map((file) => input.toRepoRelative(file)),
|
|
428
|
+
),
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
if (coveredIndex === -1) {
|
|
432
|
+
findings.push({
|
|
433
|
+
kind: 'duplicate',
|
|
434
|
+
scopeKey: input.scopeKey,
|
|
435
|
+
message: `scope「${input.scopeKey}」编号 ${key} 撞号:${files.join('、')}——先入库者保号,后到线顺延清偿`,
|
|
436
|
+
});
|
|
437
|
+
} else {
|
|
438
|
+
consumedAllowlist.add(coveredIndex);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* 单文件组:保留号占用判定
|
|
446
|
+
*/
|
|
447
|
+
const reservedReason = reservedMap.get(key);
|
|
448
|
+
|
|
449
|
+
if (reservedReason !== undefined) {
|
|
450
|
+
findings.push({
|
|
451
|
+
kind: 'reservedOccupied',
|
|
452
|
+
scopeKey: input.scopeKey,
|
|
453
|
+
message: `scope「${input.scopeKey}」保留号 ${key} 被占用(${files.join('、')};保留理由:${reservedReason}——保留号不回收复用,请让号重编`,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* 零填充族位数一致性:存在长度>1 且以 0 起首的键即成族(目录全键入族),
|
|
460
|
+
* 族内键长度集合 >1 即混用(混入无填充键同样计混用)
|
|
461
|
+
*/
|
|
462
|
+
const zeroPaddedExists = [...groups.keys()].some((key) => key.length > 1 && key.startsWith('0'));
|
|
463
|
+
|
|
464
|
+
if (zeroPaddedExists) {
|
|
465
|
+
/**
|
|
466
|
+
* 族内键长度集合(目录全部键)
|
|
467
|
+
*/
|
|
468
|
+
const widths = new Set([...groups.keys()].map((key) => key.length));
|
|
469
|
+
|
|
470
|
+
if (widths.size > 1) {
|
|
471
|
+
findings.push({
|
|
472
|
+
kind: 'widthInconsistent',
|
|
473
|
+
scopeKey: input.scopeKey,
|
|
474
|
+
message: `scope「${input.scopeKey}」零填充位数混用(族内键长 ${[...widths].sort((a, b) => a - b).join('/')}):${[...groups.keys()].slice(0, 6).join('、')}`,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* 豁免条目对账(allowlistStale——棘轮只许缩短)
|
|
481
|
+
*/
|
|
482
|
+
input.allowlist.forEach((entry, index) => {
|
|
483
|
+
if (consumedAllowlist.has(index)) {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* 同键磁盘现状(对账展示用)
|
|
489
|
+
*/
|
|
490
|
+
const diskFiles = entry.files.filter((file) => {
|
|
491
|
+
/**
|
|
492
|
+
* 磁盘文件名
|
|
493
|
+
*/
|
|
494
|
+
const basename = file.split('/').pop() ?? '';
|
|
495
|
+
|
|
496
|
+
return (groups.get(entry.number) ?? []).includes(basename);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
findings.push({
|
|
500
|
+
kind: 'allowlistStale',
|
|
501
|
+
scopeKey: input.scopeKey,
|
|
502
|
+
message: `scope「${input.scopeKey}」豁免条目(${entry.number},${entry.status})已无对应现行撞号组——棘轮只许缩短,须同步删除该条目(登记集:${entry.files.join('、')};磁盘现状:${diskFiles.length > 0 ? diskFiles.join('、') : '无'})`,
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
if (input.slugDigitPolicy === 'forbid') {
|
|
507
|
+
/**
|
|
508
|
+
* slug 白名单命中集合(全量档失效对账用)
|
|
509
|
+
*/
|
|
510
|
+
const allowlistHits = new Set<string>();
|
|
511
|
+
|
|
512
|
+
for (const basename of input.basenames) {
|
|
513
|
+
/**
|
|
514
|
+
* slug 段
|
|
515
|
+
*/
|
|
516
|
+
const slug = extractSlugSegment(basename);
|
|
517
|
+
|
|
518
|
+
if (slug === null || slug === '' || !/\d/.test(slug)) {
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* 仓库相对路径(白名单匹配口径)
|
|
524
|
+
*/
|
|
525
|
+
const repoRelative = input.toRepoRelative(basename);
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* 白名单命中判定
|
|
529
|
+
*/
|
|
530
|
+
const whitelisted = input.slugDigitAllowlist.some((glob) => {
|
|
531
|
+
if (matchMiniGlob(glob, repoRelative)) {
|
|
532
|
+
allowlistHits.add(glob);
|
|
533
|
+
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return false;
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
if (!whitelisted) {
|
|
541
|
+
findings.push({
|
|
542
|
+
kind: 'slugContainsDigit',
|
|
543
|
+
scopeKey: input.scopeKey,
|
|
544
|
+
message: `scope「${input.scopeKey}」slug 段含数字:${basename}(白名单登记请到配置 slugDigitAllowlist,逐条带理由)`,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (input.fullMode) {
|
|
550
|
+
/**
|
|
551
|
+
* 白名单失效对账(policy 启用域内命中不到任何文件即失效)
|
|
552
|
+
*/
|
|
553
|
+
for (const glob of input.slugDigitAllowlist) {
|
|
554
|
+
if (!allowlistHits.has(glob)) {
|
|
555
|
+
findings.push({
|
|
556
|
+
kind: 'slugDigitAllowlistStale',
|
|
557
|
+
scopeKey: 'global',
|
|
558
|
+
message: `slug 白名单条目「${glob}」在启用域内已匹配不到任何文件——棘轮只许缩短,须同步删除该条目`,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return findings;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* 覆盖面审计入参
|
|
570
|
+
*/
|
|
571
|
+
export type AuditCoverageInput = {
|
|
572
|
+
/**
|
|
573
|
+
* 全仓扫描面文件列表(posix 相对路径,已过全局排除)
|
|
574
|
+
*/
|
|
575
|
+
files: readonly string[];
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* 形似编号筛选模式(作用于 basename)
|
|
579
|
+
*/
|
|
580
|
+
numberishPattern: string;
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* 已登记注册表目录列表(文件落于其下即被 scope 收编)
|
|
584
|
+
*/
|
|
585
|
+
registryDirs: readonly string[];
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* pending 探针列表(glob + 理由;命中即视为已规划覆盖)
|
|
589
|
+
*/
|
|
590
|
+
pending: ReadonlyArray<{ glob: string; reason: string }>;
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* 覆盖面审计:形似编号文件必须被 scope / 排除 / pending 三者之一覆盖
|
|
595
|
+
*
|
|
596
|
+
* 未覆盖按目录聚合为 coverageGap;pending 未命中计 pendingStale。
|
|
597
|
+
*
|
|
598
|
+
* @param input - 审计入参
|
|
599
|
+
* @returns 审计违例列表
|
|
600
|
+
*/
|
|
601
|
+
export function auditCoverage(input: AuditCoverageInput): NumberingAuditFinding[] {
|
|
602
|
+
/**
|
|
603
|
+
* 违例收集器
|
|
604
|
+
*/
|
|
605
|
+
const findings: NumberingAuditFinding[] = [];
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* 形似编号筛选正则
|
|
609
|
+
*/
|
|
610
|
+
const numberish = new RegExp(input.numberishPattern);
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* 未覆盖文件按目录聚合
|
|
614
|
+
*/
|
|
615
|
+
const gapDirs = new Map<string, string[]>();
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* pending 命中标记
|
|
619
|
+
*/
|
|
620
|
+
const pendingMatched = new Set<string>();
|
|
621
|
+
|
|
622
|
+
for (const file of input.files) {
|
|
623
|
+
/**
|
|
624
|
+
* 文件名
|
|
625
|
+
*/
|
|
626
|
+
const basename = file.split('/').pop() ?? '';
|
|
627
|
+
|
|
628
|
+
if (!numberish.test(basename)) {
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* scope 收编判定
|
|
634
|
+
*/
|
|
635
|
+
const coveredByScope = input.registryDirs.some(
|
|
636
|
+
(dir) => file === dir || file.startsWith(`${dir}/`),
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
if (coveredByScope) {
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* pending 覆盖判定
|
|
645
|
+
*/
|
|
646
|
+
const pendingHit = input.pending.find((entry) => {
|
|
647
|
+
if (matchMiniGlob(entry.glob, file)) {
|
|
648
|
+
pendingMatched.add(entry.glob);
|
|
649
|
+
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
return false;
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
if (pendingHit !== undefined) {
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* 未覆盖:按目录聚合
|
|
662
|
+
*/
|
|
663
|
+
const dir = file.split('/').slice(0, -1).join('/');
|
|
664
|
+
|
|
665
|
+
gapDirs.set(dir, [...(gapDirs.get(dir) ?? []), basename]);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
for (const dir of [...gapDirs.keys()].sort((a, b) => a.localeCompare(b))) {
|
|
669
|
+
findings.push({
|
|
670
|
+
kind: 'coverageGap',
|
|
671
|
+
message: `未登记编号区:目录 ${dir === '' ? '(仓库根)' : dir} 有 ${gapDirs.get(dir)?.length ?? 0} 个编号文件未被任何 scope/pending 覆盖——${(gapDirs.get(dir) ?? []).join('、')}(请在 lzi-issues.config.ts 登记 scope 或 pending 并注明理由)`,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
for (const entry of input.pending) {
|
|
676
|
+
if (!pendingMatched.has(entry.glob)) {
|
|
677
|
+
findings.push({
|
|
678
|
+
kind: 'pendingStale',
|
|
679
|
+
message: `pending 条目「${entry.glob}」已匹配不到任何编号文件——棘轮只许缩短,须同步删除该条目(登记理由:${entry.reason})`,
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return findings;
|
|
685
|
+
}
|