@longzai-intelligence-issues/ledger 0.0.1 → 0.0.2

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.
@@ -0,0 +1,417 @@
1
+ /**
2
+ * migrate-legacy 命令层(文件 IO + 零丢失守卫 + 落盘)
3
+ *
4
+ * 存量注册表清偿唯一合法批量通道:变换核产出 → 守卫逐项对账(原字段/正文行
5
+ * 在场或落入变换台账)→ 幂等落盘。dry-run 全程零写入。
6
+ */
7
+
8
+ import type { LziIssuesConfig } from '@longzai-intelligence-issues/config';
9
+
10
+ import { readFileSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ import {
14
+ cleanValue,
15
+ DISPOSITION_CANDIDATES,
16
+ migrateLegacyCore,
17
+ type MigrationTransformation,
18
+ } from './migrate-legacy.core';
19
+
20
+ /**
21
+ * 单文件迁移入参
22
+ */
23
+ export type MigrateLegacyFileInput = {
24
+ /**
25
+ * 仓库根绝对路径
26
+ */
27
+ root: string;
28
+
29
+ /**
30
+ * issue 文件仓库相对路径(须落 scope registryDir 内)
31
+ */
32
+ file: string;
33
+
34
+ /**
35
+ * 解析后配置(所属域回填取 scope title)
36
+ */
37
+ config: LziIssuesConfig;
38
+
39
+ /**
40
+ * 优先级缺省(原案无优先级与严重程度时回填)
41
+ */
42
+ defaultPriority?: 'P1' | 'P2' | 'P3';
43
+
44
+ /**
45
+ * 来源回填缺省文案
46
+ */
47
+ defaultSource?: string;
48
+
49
+ /**
50
+ * 立案日期回填缺省(YYYY-MM-DD)
51
+ */
52
+ defaultFilingDate?: string;
53
+
54
+ /**
55
+ * 无状态行历史文档的显式灯位注入(--status red|green;灯位语义由操作者定档)
56
+ */
57
+ status?: 'red' | 'green';
58
+
59
+ /**
60
+ * 只呈现不落盘
61
+ */
62
+ dryRun?: boolean;
63
+ };
64
+
65
+ /**
66
+ * 单文件迁移结果
67
+ */
68
+ export type MigrateLegacyFileResult = {
69
+ /**
70
+ * issue 文件仓库相对路径
71
+ */
72
+ file: string;
73
+
74
+ /**
75
+ * 处置形态
76
+ */
77
+ outcome: 'rewritten' | 'unchanged' | 'skipped' | 'failed';
78
+
79
+ /**
80
+ * 结果说明
81
+ */
82
+ message: string;
83
+
84
+ /**
85
+ * 产物全文(rewritten/unchanged 携带,dry-run 复核消费)
86
+ */
87
+ content: string;
88
+
89
+ /**
90
+ * 变换台账(人工复核消费)
91
+ */
92
+ transformations: MigrationTransformation[];
93
+ };
94
+
95
+ /**
96
+ * 注册表文件名编号提取模式(NNNN-slug.md)
97
+ */
98
+ const FILE_NUMBER_PATTERN = /^([0-9]{3,4})-.+\.md$/;
99
+
100
+ /**
101
+ * 解析文件所属 scope(registryDir 前缀最长匹配)
102
+ *
103
+ * @param config - 解析后配置
104
+ * @param file - 仓库相对文件路径
105
+ * @returns scope title;无命中为 null
106
+ */
107
+ function resolveScopeTitle(config: LziIssuesConfig, file: string): string | null {
108
+ /**
109
+ * 候选 scope(按 registryDir 长度降序取最长前缀命中)
110
+ */
111
+ const hit = config.scopes
112
+ .filter((scope) => file === scope.registryDir || file.startsWith(`${scope.registryDir}/`))
113
+ .sort((left, right) => right.registryDir.length - left.registryDir.length)[0];
114
+
115
+ return hit?.title ?? null;
116
+ }
117
+
118
+ /**
119
+ * 零丢失守卫:原文全部内容必须在产物在场(或属已登记变换:状态规范化/字段
120
+ * 别名/优先级映射/H1 补编号/处置段标题改名/表格脚手架丢弃)
121
+ *
122
+ * @param raw - 原文全文
123
+ * @param migrated - 变换核产物全文
124
+ * @param transformedKeys - 已变换字段键集合(状态/日期/优先级等)
125
+ * @returns 缺失项描述列表(空 = 通过)
126
+ */
127
+ function findLostContent(raw: string, migrated: string, transformedKeys: string[]): string[] {
128
+ /**
129
+ * 缺失收集器
130
+ */
131
+ const lost: string[] = [];
132
+
133
+ /**
134
+ * 原文行数组
135
+ */
136
+ const rawLines = raw.split(/\r?\n/);
137
+
138
+ for (let lineIndex = 0; lineIndex < rawLines.length; lineIndex += 1) {
139
+ /**
140
+ * 当前行
141
+ */
142
+ const line = rawLines[lineIndex] ?? '';
143
+
144
+ /**
145
+ * 行首去空白形态
146
+ */
147
+ const trimmed = line.trim();
148
+
149
+ if (trimmed === '') {
150
+ continue;
151
+ }
152
+
153
+ if (migrated.includes(trimmed)) {
154
+ continue;
155
+ }
156
+
157
+ /**
158
+ * 一级标题:补编号/分隔符规范化后标题核心须在场
159
+ */
160
+ if (trimmed.startsWith('# ')) {
161
+ /**
162
+ * 标题核心(去 `# ` 与既有编号 + 分隔符变体)
163
+ */
164
+ const titleCore = trimmed
165
+ .replace(/^#\s+/, '')
166
+ .replace(/^[0-9]{3,4}\s*(?:[-·..::]\s*|\s+)/, '')
167
+ .trim();
168
+
169
+ if (titleCore !== '' && migrated.includes(titleCore)) {
170
+ continue;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * 处置段候选标题改名(候选链与变换核 DISPOSITION_CANDIDATES 单一真源)
176
+ */
177
+ if (
178
+ trimmed.startsWith('## ') &&
179
+ DISPOSITION_CANDIDATES.some((candidate) => candidate.pattern.test(trimmed.slice(3).trim())) &&
180
+ migrated.includes('处置结果/')
181
+ ) {
182
+ continue;
183
+ }
184
+
185
+ /**
186
+ * 表格行:脚手架(表头/分隔行)设计性丢弃;数据行按键值对账
187
+ */
188
+ const tableMatch = /^\|([^|]*)\|([^|]*)\|$/.exec(trimmed);
189
+
190
+ if (tableMatch !== null) {
191
+ /**
192
+ * 键/值单元格
193
+ */
194
+ const key = (tableMatch[1] ?? '').trim();
195
+
196
+ /**
197
+ * 值单元格(与键单元格配对对账)
198
+ */
199
+ const value = (tableMatch[2] ?? '').trim();
200
+
201
+ /**
202
+ * 分隔行单元格形态
203
+ */
204
+ const separatorCellPattern = /^:?-{2,}:?$/;
205
+
206
+ /**
207
+ * 次行表格匹配(表头行判定:分隔行紧前的一行是表头——与解析器同口径)
208
+ */
209
+ const nextLineMatch = /^\|([^|]*)\|([^|]*)\|$/.exec((rawLines[lineIndex + 1] ?? '').trim());
210
+
211
+ /**
212
+ * 次行是否表格分隔行
213
+ */
214
+ const nextIsTableSeparator =
215
+ nextLineMatch !== null &&
216
+ separatorCellPattern.test((nextLineMatch[1] ?? '').trim()) &&
217
+ separatorCellPattern.test((nextLineMatch[2] ?? '').trim());
218
+
219
+ /**
220
+ * 分隔行/表头行(表头任意列名形态均属脚手架——设计性丢弃)
221
+ */
222
+ if (
223
+ (separatorCellPattern.test(key) && separatorCellPattern.test(value)) ||
224
+ (nextIsTableSeparator && key !== '') ||
225
+ key === ''
226
+ ) {
227
+ continue;
228
+ }
229
+
230
+ if (transformedKeys.includes(key) || migrated.includes(`**${key}**: ${value}`)) {
231
+ continue;
232
+ }
233
+
234
+ lost.push(trimmed.slice(0, 60));
235
+
236
+ continue;
237
+ }
238
+
239
+ /**
240
+ * 管道引用行(`> 键:值 | …`):逐段键值对账(与解析器形态一同口径)
241
+ */
242
+ if (trimmed.startsWith('>')) {
243
+ /**
244
+ * 管道分段
245
+ */
246
+ const segments = trimmed.slice(1).split(/[||]/);
247
+
248
+ /**
249
+ * 全部段对账通过标记
250
+ */
251
+ let allMatched = true;
252
+
253
+ for (const segment of segments) {
254
+ /**
255
+ * 段内键值切分
256
+ */
257
+ const pairMatch = /^([^::]{1,8})\s*[::]\s*(.+)$/.exec(segment.trim());
258
+
259
+ if (pairMatch === null) {
260
+ continue;
261
+ }
262
+
263
+ /**
264
+ * 键
265
+ */
266
+ const key = (pairMatch[1] ?? '').trim();
267
+
268
+ /**
269
+ * 清理后值(与引擎 cleanValue 同口径)
270
+ */
271
+ const value = cleanValue(pairMatch[2] ?? '');
272
+
273
+ if (!transformedKeys.includes(key) && !migrated.includes(value)) {
274
+ allMatched = false;
275
+ }
276
+ }
277
+
278
+ if (allMatched) {
279
+ continue;
280
+ }
281
+ }
282
+
283
+ /**
284
+ * 头部字段行:粗体包裹与值清理后的形态在场即可
285
+ */
286
+ const labeled = /^(?:\*\*|- |## )([^*::]{1,20})(?:\*\*)?\s*[::]\s*(.+)$/.exec(line);
287
+
288
+ if (labeled !== null) {
289
+ /**
290
+ * 键
291
+ */
292
+ const key = (labeled[1] ?? '').trim();
293
+
294
+ /**
295
+ * 值(与引擎 cleanValue 同口径清理——全包裹/前导/词后粗体三形态)
296
+ */
297
+ const value = cleanValue(labeled[2] ?? '');
298
+
299
+ if (transformedKeys.includes(key) || migrated.includes(value)) {
300
+ continue;
301
+ }
302
+ }
303
+
304
+ lost.push(trimmed.slice(0, 60));
305
+ }
306
+
307
+ return lost;
308
+ }
309
+
310
+ /**
311
+ * 迁移并(缺省)落盘单份存量文档
312
+ *
313
+ * @param input - 命令入参
314
+ * @returns 单文件迁移结果
315
+ */
316
+ export function migrateLegacyFile(input: MigrateLegacyFileInput): MigrateLegacyFileResult {
317
+ /**
318
+ * 编号(文件名前缀)
319
+ */
320
+ const basename = input.file.split('/').pop() ?? '';
321
+
322
+ /**
323
+ * 文件名编号提取结果(H1 规范化用)
324
+ */
325
+ const numberMatch = FILE_NUMBER_PATTERN.exec(basename);
326
+
327
+ if (numberMatch === null) {
328
+ return {
329
+ file: input.file,
330
+ outcome: 'skipped',
331
+ message: '文件名不属注册表编号形态(NNNN-slug.md)',
332
+ content: '',
333
+ transformations: [],
334
+ };
335
+ }
336
+
337
+ /**
338
+ * 所属域(scope title)
339
+ */
340
+ const scopeTitle = resolveScopeTitle(input.config, input.file);
341
+
342
+ if (scopeTitle === null) {
343
+ return {
344
+ file: input.file,
345
+ outcome: 'skipped',
346
+ message: '文件不在任何已登记 scope 的 registryDir 内——交人工',
347
+ content: '',
348
+ transformations: [],
349
+ };
350
+ }
351
+
352
+ /**
353
+ * 文档全文
354
+ */
355
+ let raw = '';
356
+
357
+ try {
358
+ raw = readFileSync(join(input.root, input.file), 'utf8');
359
+ } catch {
360
+ return {
361
+ file: input.file,
362
+ outcome: 'failed',
363
+ message: '读档失败(文件不存在或不可读)',
364
+ content: '',
365
+ transformations: [],
366
+ };
367
+ }
368
+
369
+ /**
370
+ * 纯变换产物
371
+ */
372
+ const migrated = migrateLegacyCore(raw, {
373
+ number: numberMatch[1] ?? '',
374
+ scopeTitle,
375
+ defaultPriority: input.defaultPriority,
376
+ defaultSource: input.defaultSource,
377
+ defaultFilingDate: input.defaultFilingDate,
378
+ status: input.status,
379
+ });
380
+
381
+ if (migrated.outcome !== 'rewritten') {
382
+ return {
383
+ file: input.file,
384
+ outcome: migrated.outcome,
385
+ message: migrated.message,
386
+ content: migrated.content,
387
+ transformations: migrated.transformations,
388
+ };
389
+ }
390
+
391
+ /**
392
+ * 零丢失守卫(已变换字段键集合:状态/日期别名/优先级/H1)
393
+ */
394
+ const lost = findLostContent(raw, migrated.content, ['状态', '日期', '优先级']);
395
+
396
+ if (lost.length > 0) {
397
+ return {
398
+ file: input.file,
399
+ outcome: 'failed',
400
+ message: `零丢失守卫失败:${lost.length} 项原文内容未在产物在场(如 ${lost[0]})——不落盘`,
401
+ content: migrated.content,
402
+ transformations: migrated.transformations,
403
+ };
404
+ }
405
+
406
+ if (input.dryRun !== true) {
407
+ writeFileSync(join(input.root, input.file), migrated.content);
408
+ }
409
+
410
+ return {
411
+ file: input.file,
412
+ outcome: 'rewritten',
413
+ message: input.dryRun === true ? 'dry-run 通过(未落盘)' : '存量头部已清偿为规范形态',
414
+ content: migrated.content,
415
+ transformations: migrated.transformations,
416
+ };
417
+ }