@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,693 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* docs 引用检查与机械修复(check / fix)
|
|
3
|
+
*
|
|
4
|
+
* 扫描 docs.scanRoots 的 markdown,提取 issue 引用 token(编号 / 编号+slug /
|
|
5
|
+
* 不完整 slug 前缀);全 scope 按 (scopeKey, number) 查找,带 slug 段先精确后
|
|
6
|
+
* 前缀;0 命中→doc-ref-unknown,>1→doc-ref-ambiguous(列候选),前缀唯一→
|
|
7
|
+
* 通过(不完整 slug 引用支持)。fix 对唯一前缀机械展开完整 slug;独立棘轮基线。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { LintBaselineEntry, LziIssuesConfig } from '@longzai-intelligence-issues/config';
|
|
11
|
+
|
|
12
|
+
import { renderBaselineRegeneratedConfig } from '@longzai-intelligence-issues/config';
|
|
13
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { walkFilesUnderRoots } from '@/fs/walk-surface.utils';
|
|
17
|
+
import { scanRegistryDir } from '@/parser/registry.parser';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* docs 检查入参
|
|
21
|
+
*/
|
|
22
|
+
export type DocsCheckInput = {
|
|
23
|
+
/**
|
|
24
|
+
* 仓库根绝对路径
|
|
25
|
+
*/
|
|
26
|
+
root: string;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 解析后配置
|
|
30
|
+
*/
|
|
31
|
+
config: LziIssuesConfig;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 跳过基线豁免(write-baseline 再生口径)
|
|
35
|
+
*/
|
|
36
|
+
ignoreBaseline?: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* docs 修带入参
|
|
41
|
+
*/
|
|
42
|
+
export type DocsFixInput = {
|
|
43
|
+
/**
|
|
44
|
+
* 仓库根绝对路径
|
|
45
|
+
*/
|
|
46
|
+
root: string;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 解析后配置
|
|
50
|
+
*/
|
|
51
|
+
config: LziIssuesConfig;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 只呈现不落盘
|
|
55
|
+
*/
|
|
56
|
+
dryRun?: boolean;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* docs 修复结果
|
|
61
|
+
*/
|
|
62
|
+
export type DocsFixResult = {
|
|
63
|
+
/**
|
|
64
|
+
* 已修复文件清单
|
|
65
|
+
*/
|
|
66
|
+
fixedFiles: string[];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 拒绝项清单(待人工面,存在即 exit 1 语义)
|
|
70
|
+
*/
|
|
71
|
+
refused: DocRefViolation[];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* docs 基线再生命令入参
|
|
76
|
+
*/
|
|
77
|
+
export type DocsBaselineInput = {
|
|
78
|
+
/**
|
|
79
|
+
* 仓库根绝对路径
|
|
80
|
+
*/
|
|
81
|
+
root: string;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 解析后配置
|
|
85
|
+
*/
|
|
86
|
+
config: LziIssuesConfig;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 收编指针
|
|
90
|
+
*/
|
|
91
|
+
issuePointer: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* docs 基线再生结果
|
|
96
|
+
*/
|
|
97
|
+
export type DocsBaselineResult =
|
|
98
|
+
| { ok: true; content: string; entryCount: number }
|
|
99
|
+
| { ok: false; error: string };
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* docs 引用违例
|
|
103
|
+
*/
|
|
104
|
+
export type DocRefViolation = {
|
|
105
|
+
/**
|
|
106
|
+
* 引用所在文件仓库相对 posix 路径
|
|
107
|
+
*/
|
|
108
|
+
file: string;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 行号(1 起)
|
|
112
|
+
*/
|
|
113
|
+
line: number;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 列号(1 起)
|
|
117
|
+
*/
|
|
118
|
+
column: number;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 规则名(doc-ref-unknown / doc-ref-ambiguous / baseline-stale)
|
|
122
|
+
*/
|
|
123
|
+
rule: string;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 违规消息
|
|
127
|
+
*/
|
|
128
|
+
message: string;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* docs 引用检查结果
|
|
133
|
+
*/
|
|
134
|
+
export type DocRefCheckResult = {
|
|
135
|
+
/**
|
|
136
|
+
* 违例列表(active + baseline-stale)
|
|
137
|
+
*/
|
|
138
|
+
violations: DocRefViolation[];
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 基线豁免计数
|
|
142
|
+
*/
|
|
143
|
+
suppressed: number;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 扫描文件数
|
|
147
|
+
*/
|
|
148
|
+
scannedFiles: number;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 引用 token 提取模式(`[0035]` / `[0035-slug]` / `[0035-slug](./0035-slug.md)`)
|
|
153
|
+
*/
|
|
154
|
+
const DOC_REF_TOKEN_PATTERN = /\[([0-9]{3,4})(?:-([a-z0-9-]+))?\]/g;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 注册表索引条目
|
|
158
|
+
*/
|
|
159
|
+
type RegistryIndexEntry = {
|
|
160
|
+
/**
|
|
161
|
+
* scope key
|
|
162
|
+
*/
|
|
163
|
+
scopeKey: string;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 编号
|
|
167
|
+
*/
|
|
168
|
+
number: string;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 完整 slug
|
|
172
|
+
*/
|
|
173
|
+
slug: string;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 候选解析结果
|
|
178
|
+
*/
|
|
179
|
+
type RefResolution =
|
|
180
|
+
| { kind: 'ok' }
|
|
181
|
+
| { kind: 'unknown'; message: string }
|
|
182
|
+
| { kind: 'ambiguous'; message: string };
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 构建全 scope 注册表索引
|
|
186
|
+
*
|
|
187
|
+
* @param root - 仓库根绝对路径
|
|
188
|
+
* @param config - 解析后配置
|
|
189
|
+
* @returns 索引条目列表
|
|
190
|
+
*/
|
|
191
|
+
function buildRegistryIndex(root: string, config: LziIssuesConfig): RegistryIndexEntry[] {
|
|
192
|
+
return config.scopes.flatMap((scope) =>
|
|
193
|
+
scanRegistryDir(join(root, scope.registryDir)).map((record) => ({
|
|
194
|
+
scopeKey: scope.key,
|
|
195
|
+
number: record.id,
|
|
196
|
+
slug: record.slug,
|
|
197
|
+
})),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 解析引用 token(先精确后前缀;跨 scope 多义列候选)
|
|
203
|
+
*
|
|
204
|
+
* @param tokenNumber - 引用编号
|
|
205
|
+
* @param tokenSlug - 引用 slug 段(可空)
|
|
206
|
+
* @param index - 注册表索引
|
|
207
|
+
* @returns 解析结果
|
|
208
|
+
*/
|
|
209
|
+
function resolveRefToken(
|
|
210
|
+
tokenNumber: string,
|
|
211
|
+
tokenSlug: string | null,
|
|
212
|
+
index: readonly RegistryIndexEntry[],
|
|
213
|
+
): RefResolution {
|
|
214
|
+
/**
|
|
215
|
+
* 同编号候选集
|
|
216
|
+
*/
|
|
217
|
+
const numberMatches = index.filter((entry) => entry.number === tokenNumber);
|
|
218
|
+
|
|
219
|
+
if (numberMatches.length === 0) {
|
|
220
|
+
return { kind: 'unknown', message: `引用编号 ${tokenNumber} 在全部 scope 中无命中` };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* slug 精确命中集
|
|
225
|
+
*/
|
|
226
|
+
const exact = tokenSlug === null ? [] : numberMatches.filter((entry) => entry.slug === tokenSlug);
|
|
227
|
+
|
|
228
|
+
if (exact.length === 1) {
|
|
229
|
+
return { kind: 'ok' };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* slug 前缀命中集(不完整 slug 引用支持)
|
|
234
|
+
*/
|
|
235
|
+
const prefixed =
|
|
236
|
+
tokenSlug === null
|
|
237
|
+
? numberMatches
|
|
238
|
+
: numberMatches.filter((entry) => entry.slug.startsWith(tokenSlug));
|
|
239
|
+
|
|
240
|
+
if (prefixed.length === 1) {
|
|
241
|
+
return { kind: 'ok' };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (prefixed.length === 0) {
|
|
245
|
+
return {
|
|
246
|
+
kind: 'unknown',
|
|
247
|
+
message: `引用 ${tokenNumber}-${tokenSlug} 在 scope 候选集中无 slug 命中(编号命中:${numberMatches.map((e) => `${e.scopeKey}/${e.number}-${e.slug}`).join('、')})`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
kind: 'ambiguous',
|
|
253
|
+
message: `引用 ${tokenNumber}${tokenSlug === null ? '' : `-${tokenSlug}`} 命中不唯一——候选:${prefixed.map((e) => `${e.scopeKey}/${e.number}-${e.slug}`).join('、')}(请展开完整 slug 或注明 scope)`,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* docs 引用检查
|
|
259
|
+
*
|
|
260
|
+
* @param input - 命令入参
|
|
261
|
+
* @param input.root - 仓库根绝对路径
|
|
262
|
+
* @param input.config - 解析后配置
|
|
263
|
+
* @param input.ignoreBaseline - 跳过基线豁免(write-baseline 再生口径)
|
|
264
|
+
* @returns 检查结果
|
|
265
|
+
* @throws {@link Error} 配置未登记 docs 节
|
|
266
|
+
*/
|
|
267
|
+
export function runDocsCheck(input: DocsCheckInput): DocRefCheckResult {
|
|
268
|
+
if (input.config.docs === undefined) {
|
|
269
|
+
throw new Error('配置未登记 docs 节(docs.scanRoots)——docs 引用检查显式拒绝隐式扫描面');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 注册表索引
|
|
274
|
+
*/
|
|
275
|
+
const index = buildRegistryIndex(input.root, input.config);
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 违例收集器(基线前)
|
|
279
|
+
*/
|
|
280
|
+
const raw: DocRefViolation[] = [];
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 扫描文件数
|
|
284
|
+
*/
|
|
285
|
+
let scannedFiles = 0;
|
|
286
|
+
|
|
287
|
+
for (const scanRoot of input.config.docs.scanRoots) {
|
|
288
|
+
/**
|
|
289
|
+
* 扫描根下文件列表(严格遍历缺根抛错 + 排除剪枝)
|
|
290
|
+
*/
|
|
291
|
+
const files = walkFilesUnderRoots(join(input.root, scanRoot), {
|
|
292
|
+
excludeGlobs: input.config.docs.exclusions.map((entry) => entry.glob),
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
scannedFiles += files.length;
|
|
296
|
+
|
|
297
|
+
for (const file of files) {
|
|
298
|
+
if (!input.config.docs.includeExtensions.some((ext) => file.endsWith(ext))) {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 文档全文
|
|
304
|
+
*/
|
|
305
|
+
const content = readFileSync(join(input.root, scanRoot, file), 'utf8');
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 行起点偏移列表
|
|
309
|
+
*/
|
|
310
|
+
const lineStarts = computeLineStarts(content);
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 相对扫描根的路径(基线键口径)
|
|
314
|
+
*/
|
|
315
|
+
const relFile = scanRoot === '' ? file : `${scanRoot}/${file}`;
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* token 全局匹配游标
|
|
319
|
+
*/
|
|
320
|
+
const pattern = new RegExp(DOC_REF_TOKEN_PATTERN.source, 'g');
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 当前 token 命中(全局游标逐次推进)
|
|
324
|
+
*/
|
|
325
|
+
let match = pattern.exec(content);
|
|
326
|
+
|
|
327
|
+
while (match !== null) {
|
|
328
|
+
/**
|
|
329
|
+
* 引用编号与 slug 段
|
|
330
|
+
*/
|
|
331
|
+
const tokenNumber = match[1] ?? '';
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* 引用 slug 段(纯编号引用为 null)
|
|
335
|
+
*/
|
|
336
|
+
const tokenSlug = match[2] ?? null;
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* 解析结果
|
|
340
|
+
*/
|
|
341
|
+
const resolution = resolveRefToken(tokenNumber, tokenSlug, index);
|
|
342
|
+
|
|
343
|
+
if (resolution.kind !== 'ok') {
|
|
344
|
+
/**
|
|
345
|
+
* 命中位置(行 1 起 / 列 1 起)
|
|
346
|
+
*/
|
|
347
|
+
const located = locateOffset(lineStarts, match.index ?? 0);
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* 违例登记
|
|
351
|
+
*/
|
|
352
|
+
raw.push({
|
|
353
|
+
file: relFile,
|
|
354
|
+
line: located.line,
|
|
355
|
+
column: located.column,
|
|
356
|
+
rule: resolution.kind === 'unknown' ? 'doc-ref-unknown' : 'doc-ref-ambiguous',
|
|
357
|
+
message: resolution.message,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
match = pattern.exec(content);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* 基线查找表
|
|
368
|
+
*/
|
|
369
|
+
const baseline = new Map<string, LintBaselineEntry>(
|
|
370
|
+
(input.config.docs.baseline ?? []).map((entry) => [`${entry.file}::${entry.rule}`, entry]),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
if (input.ignoreBaseline === true) {
|
|
374
|
+
return { violations: raw, suppressed: 0, scannedFiles };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* active 违例
|
|
379
|
+
*/
|
|
380
|
+
const active: DocRefViolation[] = [];
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* 豁免计数
|
|
384
|
+
*/
|
|
385
|
+
let suppressed = 0;
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* 命中基线键集合
|
|
389
|
+
*/
|
|
390
|
+
const matchedKeys = new Set<string>();
|
|
391
|
+
|
|
392
|
+
for (const violation of raw) {
|
|
393
|
+
/**
|
|
394
|
+
* 基线键
|
|
395
|
+
*/
|
|
396
|
+
const key = `${violation.file}::${violation.rule}`;
|
|
397
|
+
|
|
398
|
+
if (baseline.has(key)) {
|
|
399
|
+
suppressed += 1;
|
|
400
|
+
matchedKeys.add(key);
|
|
401
|
+
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
active.push(violation);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* stale 基线条目
|
|
410
|
+
*/
|
|
411
|
+
const stale: DocRefViolation[] = [...baseline.entries()]
|
|
412
|
+
.filter(([key]) => !matchedKeys.has(key))
|
|
413
|
+
.map(([key, entry]) => ({
|
|
414
|
+
file: key.split('::')[0] ?? '',
|
|
415
|
+
line: 0,
|
|
416
|
+
column: 0,
|
|
417
|
+
rule: 'baseline-stale',
|
|
418
|
+
message: `docs 基线条目已无对应现行违规——棘轮只许缩短,须同步删除该条目(收编指针:${entry.issue})`,
|
|
419
|
+
}));
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
violations: [...active, ...stale].sort(
|
|
423
|
+
(a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule),
|
|
424
|
+
),
|
|
425
|
+
suppressed,
|
|
426
|
+
scannedFiles,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* docs 引用机械修复(唯一前缀展开为完整 slug)
|
|
432
|
+
*
|
|
433
|
+
* @param input - 命令入参
|
|
434
|
+
* @param input.root - 仓库根绝对路径
|
|
435
|
+
* @param input.config - 解析后配置
|
|
436
|
+
* @param input.dryRun - 只呈现不落盘
|
|
437
|
+
* @returns 修复结果(fixed / refused 清单;存在 refused 即 exit 1 语义)
|
|
438
|
+
* @throws {@link Error} 配置未登记 docs 节
|
|
439
|
+
*/
|
|
440
|
+
export function runDocsFix(input: DocsFixInput): DocsFixResult {
|
|
441
|
+
if (input.config.docs === undefined) {
|
|
442
|
+
throw new Error('配置未登记 docs 节(docs.scanRoots)——docs 修复显式拒绝隐式扫描面');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* 注册表索引
|
|
447
|
+
*/
|
|
448
|
+
const index = buildRegistryIndex(input.root, input.config);
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* 修复文件清单
|
|
452
|
+
*/
|
|
453
|
+
const fixedFiles: string[] = [];
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* 拒绝项清单(待人工面)
|
|
457
|
+
*/
|
|
458
|
+
const refused: DocRefViolation[] = [];
|
|
459
|
+
|
|
460
|
+
for (const scanRoot of input.config.docs.scanRoots) {
|
|
461
|
+
/**
|
|
462
|
+
* 扫描根下文件列表
|
|
463
|
+
*/
|
|
464
|
+
const files = walkFilesUnderRoots(join(input.root, scanRoot), {
|
|
465
|
+
excludeGlobs: input.config.docs.exclusions.map((entry) => entry.glob),
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
for (const file of files) {
|
|
469
|
+
if (!input.config.docs.includeExtensions.some((ext) => file.endsWith(ext))) {
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* 文档全文
|
|
475
|
+
*/
|
|
476
|
+
const content = readFileSync(join(input.root, scanRoot, file), 'utf8');
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* token 编辑列表(逆序应用防偏移漂移)
|
|
480
|
+
*/
|
|
481
|
+
const edits: Array<{ start: number; end: number; replacement: string }> = [];
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* token 全局匹配游标
|
|
485
|
+
*/
|
|
486
|
+
const pattern = new RegExp(DOC_REF_TOKEN_PATTERN.source, 'g');
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* 当前 token 命中(全局游标逐次推进)
|
|
490
|
+
*/
|
|
491
|
+
let match = pattern.exec(content);
|
|
492
|
+
|
|
493
|
+
while (match !== null) {
|
|
494
|
+
/**
|
|
495
|
+
* 引用编号与 slug 段
|
|
496
|
+
*/
|
|
497
|
+
const tokenNumber = match[1] ?? '';
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* 引用 slug 段(纯编号引用为 null)
|
|
501
|
+
*/
|
|
502
|
+
const tokenSlug = match[2] ?? null;
|
|
503
|
+
|
|
504
|
+
if (tokenSlug !== null) {
|
|
505
|
+
/**
|
|
506
|
+
* 前缀唯一命中
|
|
507
|
+
*/
|
|
508
|
+
const prefixed = index.filter(
|
|
509
|
+
(entry) => entry.number === tokenNumber && entry.slug.startsWith(tokenSlug),
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
if (prefixed.length > 1) {
|
|
513
|
+
/**
|
|
514
|
+
* 位置定位
|
|
515
|
+
*/
|
|
516
|
+
const located = locateOffset(computeLineStarts(content), match.index ?? 0);
|
|
517
|
+
|
|
518
|
+
refused.push({
|
|
519
|
+
file,
|
|
520
|
+
line: located.line,
|
|
521
|
+
column: located.column,
|
|
522
|
+
rule: 'refused',
|
|
523
|
+
message: `引用 ${tokenNumber}-${tokenSlug} 前缀命中不唯一,无法机械归位(候选:${prefixed.map((e) => `${e.scopeKey}/${e.number}-${e.slug}`).join('、')})——需人工核定`,
|
|
524
|
+
});
|
|
525
|
+
} else if (prefixed.length === 1 && prefixed[0]?.slug !== tokenSlug) {
|
|
526
|
+
/**
|
|
527
|
+
* 完整展开编辑(label 段替换,其余文本逐字节不动)
|
|
528
|
+
*/
|
|
529
|
+
edits.push({
|
|
530
|
+
start: match.index ?? 0,
|
|
531
|
+
end: (match.index ?? 0) + match[0].length,
|
|
532
|
+
replacement: `[${tokenNumber}-${prefixed[0]?.slug}]`,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
match = pattern.exec(content);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (edits.length === 0) {
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* 修复产物(逆序编辑)
|
|
546
|
+
*/
|
|
547
|
+
let fixed = content;
|
|
548
|
+
|
|
549
|
+
for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
|
|
550
|
+
fixed = `${fixed.slice(0, edit.start)}${edit.replacement}${fixed.slice(edit.end)}`;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (input.dryRun !== true && fixed !== content) {
|
|
554
|
+
writeFileSync(join(input.root, scanRoot, file), fixed);
|
|
555
|
+
|
|
556
|
+
fixedFiles.push(file);
|
|
557
|
+
} else if (input.dryRun === true) {
|
|
558
|
+
fixedFiles.push(file);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return { fixedFiles, refused };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* docs 基线再生命令核(拒绝扩基线)
|
|
568
|
+
*
|
|
569
|
+
* @param input - 命令入参
|
|
570
|
+
* @param input.root - 仓库根绝对路径
|
|
571
|
+
* @param input.config - 解析后配置
|
|
572
|
+
* @param input.issuePointer - 收编指针
|
|
573
|
+
* @returns 再生结果
|
|
574
|
+
* @throws {@link Error} 配置未登记 docs 节
|
|
575
|
+
*/
|
|
576
|
+
export function runWriteDocsBaseline(input: DocsBaselineInput): DocsBaselineResult {
|
|
577
|
+
if (input.config.docs === undefined) {
|
|
578
|
+
throw new Error('配置未登记 docs 节——无法再生 docs 基线');
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (input.issuePointer.trim() === '') {
|
|
582
|
+
return { ok: false, error: '再生须携带非空 --issue <指针>(收编盖章)' };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* 全集违例(跳过基线口径)
|
|
587
|
+
*/
|
|
588
|
+
const raw = runDocsCheck({
|
|
589
|
+
root: input.root,
|
|
590
|
+
config: input.config,
|
|
591
|
+
ignoreBaseline: true,
|
|
592
|
+
}).violations;
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* 旧基线键集合
|
|
596
|
+
*/
|
|
597
|
+
const oldKeys = new Set(
|
|
598
|
+
(input.config.docs.baseline ?? []).map((entry) => `${entry.file}::${entry.rule}`),
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* 基线外新增违规
|
|
603
|
+
*/
|
|
604
|
+
const newOutside = raw.filter((v) => !oldKeys.has(`${v.file}::${v.rule}`));
|
|
605
|
+
|
|
606
|
+
if (newOutside.length > 0) {
|
|
607
|
+
return {
|
|
608
|
+
ok: false,
|
|
609
|
+
error: `存在 ${newOutside.length} 项基线外新增违规,拒绝再生(先处置新增违规后再收编存量):\n${newOutside
|
|
610
|
+
.slice(0, 10)
|
|
611
|
+
.map((v) => `${v.file}:${v.line} [${v.rule}]`)
|
|
612
|
+
.join('\n')}`,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* 再生条目
|
|
618
|
+
*/
|
|
619
|
+
const entries: LintBaselineEntry[] = raw.map((v) => ({
|
|
620
|
+
file: v.file,
|
|
621
|
+
rule: v.rule,
|
|
622
|
+
issue: input.issuePointer,
|
|
623
|
+
}));
|
|
624
|
+
|
|
625
|
+
return {
|
|
626
|
+
ok: true,
|
|
627
|
+
content: renderBaselineRegeneratedConfig(input.config, 'docs', entries),
|
|
628
|
+
entryCount: entries.length,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* 计算行起点偏移列表
|
|
634
|
+
*
|
|
635
|
+
* @param content - 文档全文
|
|
636
|
+
* @returns 每行起始偏移列表
|
|
637
|
+
*/
|
|
638
|
+
function computeLineStarts(content: string): number[] {
|
|
639
|
+
/**
|
|
640
|
+
* 行起点收集器(首行起点 0)
|
|
641
|
+
*/
|
|
642
|
+
const starts = [0];
|
|
643
|
+
|
|
644
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
645
|
+
if (content[index] === '\n') {
|
|
646
|
+
starts.push(index + 1);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return starts;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* 行列定位结果(1 起)
|
|
655
|
+
*/
|
|
656
|
+
type LineColumn = {
|
|
657
|
+
/**
|
|
658
|
+
* 行号
|
|
659
|
+
*/
|
|
660
|
+
line: number;
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* 列号
|
|
664
|
+
*/
|
|
665
|
+
column: number;
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* 偏移定位行列(1 起)
|
|
670
|
+
*
|
|
671
|
+
* @param lineStarts - 行起点列表
|
|
672
|
+
* @param offset - 字符偏移
|
|
673
|
+
* @returns 行与列
|
|
674
|
+
*/
|
|
675
|
+
function locateOffset(lineStarts: readonly number[], offset: number): LineColumn {
|
|
676
|
+
/**
|
|
677
|
+
* 命中行下标(二分语义:最后一个不大于 offset 的起点)
|
|
678
|
+
*/
|
|
679
|
+
let lineIndex = 0;
|
|
680
|
+
|
|
681
|
+
for (let index = 0; index < lineStarts.length; index += 1) {
|
|
682
|
+
if ((lineStarts[index] ?? 0) <= offset) {
|
|
683
|
+
lineIndex = index;
|
|
684
|
+
} else {
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return {
|
|
690
|
+
line: lineIndex + 1,
|
|
691
|
+
column: offset - (lineStarts[lineIndex] ?? 0) + 1,
|
|
692
|
+
};
|
|
693
|
+
}
|