@longzai-intelligence-issues/ledger 0.0.2 → 0.0.4

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,384 @@
1
+ /**
2
+ * migrate-legacy 头部收敛(体首残留元信息块吸收与键去重,自 core 拆出守文件行上限)
3
+ *
4
+ * 事故背景见 docs/issues/0012:方言缺口致元信息落 residual 挂于规范头部下方,
5
+ * 此模块提供机械收敛通道(吸收、键去重、注入/回填标记让位原始语义)。
6
+ */
7
+
8
+ import type { MigrationTransformation } from './migrate-legacy.core';
9
+
10
+ import { canonicalizeStatusValue, cleanValue } from './migrate-legacy.vocab';
11
+
12
+ /**
13
+ * 体首残留元信息块吸收产物
14
+ */
15
+ type AbsorbResult = {
16
+ /**
17
+ * 吸收行数(元信息行数,不含间夹空行)
18
+ */
19
+ consumedLines: number;
20
+
21
+ /**
22
+ * 吸收后的正文余行
23
+ */
24
+ restLines: string[];
25
+ };
26
+
27
+ /**
28
+ * 吸收触发认定的核心元信息键(块内至少含其一才整块吸收——纯描述性键
29
+ * 开头的正文不受影响)
30
+ */
31
+ const CORE_METADATA_KEYS: ReadonlySet<string> = new Set([
32
+ '状态',
33
+ '优先级',
34
+ '立案日期',
35
+ '日期',
36
+ '来源',
37
+ '严重程度',
38
+ '严重度',
39
+ '所属域',
40
+ '处置日期',
41
+ '沿革',
42
+ '阻塞',
43
+ ]);
44
+
45
+ /**
46
+ * 吸收正文首部的残留元信息块(历史批量清偿事故形态收敛)
47
+ *
48
+ * 两段式:先收集正文首部连续的元信息形态行(键白名单外键也入列——键值形态
49
+ * 本身即头部元信息特征,未知键升格为额外字段),再按触发条件(含核心键 /
50
+ * ≥2 行 / 含多行子项结构)整块吸收或全不吸收——正文证据散文行不在键值
51
+ * 形态内,天然中断收集。
52
+ *
53
+ * @param bodyLines - 正文行
54
+ * @param fields - 头部字段收集器(吸收行入列)
55
+ * @returns 吸收产物
56
+ */
57
+ export function absorbLeadingMetadata(
58
+ bodyLines: string[],
59
+ fields: Array<{ key: string; value: string }>,
60
+ ): AbsorbResult {
61
+ /**
62
+ * 吸收的原始行区(含间夹空行,末尾空行归还正文)
63
+ */
64
+ const consumedRegion: string[] = [];
65
+
66
+ /**
67
+ * 元信息行命中计数
68
+ */
69
+ let metadataHits = 0;
70
+
71
+ /**
72
+ * 核心键命中标记(整块吸收触发条件)
73
+ */
74
+ let coreHit = false;
75
+
76
+ /**
77
+ * 带子项的多行元信息命中标记(结构特征即头部元信息,触发整块吸收)
78
+ */
79
+ let childrenHit = false;
80
+
81
+ /**
82
+ * 游标(首个非元信息行位置)
83
+ */
84
+ let cursor = 0;
85
+
86
+ /**
87
+ * 吸收行暂存(触发条件确认后再入列,防误吞后回滚复杂化)
88
+ */
89
+ const absorbedFields: Array<{ key: string; value: string }> = [];
90
+
91
+ while (cursor < bodyLines.length) {
92
+ /**
93
+ * 当前行
94
+ */
95
+ const line = bodyLines[cursor] ?? '';
96
+
97
+ if (line.trim() === '') {
98
+ consumedRegion.push(line);
99
+
100
+ cursor += 1;
101
+
102
+ continue;
103
+ }
104
+
105
+ /**
106
+ * 元信息行匹配(单行形态;键不设白名单——键值形态本身即元信息特征,
107
+ * 未知键以额外字段升格,键长度上限 20 防散文前缀误判)
108
+ */
109
+ const match = /^(?:\*\*|- \*\*|- )([^*::]{1,20})(?:\*\*)?\s*[::]\s*(.+)$/.exec(line);
110
+
111
+ /**
112
+ * 多行形态匹配(`- 键:` 空值 + 缩进子项)
113
+ */
114
+ const multiLineKey = /^(?:\*\*|- \*\*|- )([^*::]{1,20})(?:\*\*)?\s*[::]\s*$/.exec(line);
115
+
116
+ if (match === null && multiLineKey === null) {
117
+ break;
118
+ }
119
+
120
+ /**
121
+ * 键(单行或多行形态取一)
122
+ */
123
+ const key = ((match ?? multiLineKey)?.[1] ?? '').trim();
124
+
125
+ if (key === '') {
126
+ break;
127
+ }
128
+
129
+ consumedRegion.push(line);
130
+
131
+ metadataHits += 1;
132
+
133
+ if (CORE_METADATA_KEYS.has(key)) {
134
+ coreHit = true;
135
+ }
136
+
137
+ if (match !== null) {
138
+ absorbedFields.push({ key, value: cleanValue(match[2] ?? '') });
139
+
140
+ cursor += 1;
141
+
142
+ continue;
143
+ }
144
+
145
+ /**
146
+ * 多行形态:收集缩进深于键行的子项(子项文本去列表标记后以「;」并入单行值)
147
+ */
148
+ const childTexts: string[] = [];
149
+
150
+ /**
151
+ * 键行缩进宽度
152
+ */
153
+ const keyIndent = line.length - line.trimStart().length;
154
+
155
+ cursor += 1;
156
+
157
+ while (cursor < bodyLines.length) {
158
+ /**
159
+ * 候选子项行
160
+ */
161
+ const childLine = bodyLines[cursor] ?? '';
162
+
163
+ if (childLine.trim() === '') {
164
+ break;
165
+ }
166
+
167
+ /**
168
+ * 子项缩进
169
+ */
170
+ const childIndent = childLine.length - childLine.trimStart().length;
171
+
172
+ if (childIndent <= keyIndent) {
173
+ break;
174
+ }
175
+
176
+ consumedRegion.push(childLine);
177
+
178
+ childTexts.push(childLine.trim().replace(/^-\s*/, ''));
179
+
180
+ cursor += 1;
181
+ }
182
+
183
+ if (childTexts.length === 0) {
184
+ /**
185
+ * 空值键无子项:按空值入列(键确在元信息形态内,吸收防重复挂留)
186
+ */
187
+ absorbedFields.push({ key, value: '' });
188
+ } else {
189
+ absorbedFields.push({ key, value: childTexts.join(';') });
190
+
191
+ childrenHit = true;
192
+ }
193
+ }
194
+
195
+ if (metadataHits === 0 || (!coreHit && metadataHits < 2 && !childrenHit)) {
196
+ return { consumedLines: 0, restLines: bodyLines };
197
+ }
198
+
199
+ fields.push(...absorbedFields);
200
+
201
+ /**
202
+ * 吸收区末尾空行归还正文(保持正文段前空行结构)
203
+ */
204
+ while (
205
+ consumedRegion.length > 0 &&
206
+ (consumedRegion[consumedRegion.length - 1] ?? '').trim() === ''
207
+ ) {
208
+ consumedRegion.pop();
209
+
210
+ cursor -= 1;
211
+ }
212
+
213
+ return { consumedLines: metadataHits, restLines: bodyLines.slice(cursor) };
214
+ }
215
+
216
+ /**
217
+ * 键去重与语义让位产物
218
+ */
219
+ type FieldConvergence = {
220
+ /**
221
+ * 状态让位值(注入定档让位原始语义后的规范化值;未发生让位为 undefined)
222
+ */
223
+ statusValue?: string;
224
+
225
+ /**
226
+ * 来源让位标记(回填缺省让位原始语义时置 true——守卫对来源键放行)
227
+ */
228
+ sourceReplaced: boolean;
229
+ };
230
+
231
+ /**
232
+ * 键去重与语义让位
233
+ *
234
+ * 状态/来源:首值含注入或回填标记(存量清偿定档 / 格式清偿迁移回填)而后值
235
+ * 为原始语义时,用后值的规范化结果替换首值——原始信息优先;其余键重复时
236
+ * 保留首现值并记台账。
237
+ *
238
+ * @param fields - 头部字段(原地收敛)
239
+ * @param transformations - 变换台账
240
+ * @returns 收敛产物(状态让位值供终值写回优先采用)
241
+ */
242
+ export function convergeDuplicateFields(
243
+ fields: Array<{ key: string; value: string }>,
244
+ transformations: MigrationTransformation[],
245
+ ): FieldConvergence {
246
+ /**
247
+ * 状态让位值(终值写回优先采用)
248
+ */
249
+ let convergedStatusValue: string | undefined;
250
+
251
+ /**
252
+ * 来源让位标记(守卫放行依据)
253
+ */
254
+ let sourceReplaced = false;
255
+
256
+ /**
257
+ * 状态原始值定位(重复且首值含注入标记)
258
+ */
259
+ const statusEntries = fields.filter((field) => field.key === '状态');
260
+
261
+ if (statusEntries.length > 1) {
262
+ /**
263
+ * 注入定档首值
264
+ */
265
+ const injectedFirst = statusEntries[0]?.value.includes('存量清偿定档:原案无状态行') === true;
266
+
267
+ /**
268
+ * 原始语义候选(不含注入标记的首个后值)
269
+ */
270
+ const originalCandidate = statusEntries
271
+ .slice(1)
272
+ .find((field) => !field.value.includes('存量清偿定档:原案无状态行'));
273
+
274
+ if (injectedFirst && originalCandidate !== undefined) {
275
+ /**
276
+ * 原始语义规范化产物
277
+ */
278
+ const reCanonical = canonicalizeStatusValue(originalCandidate.value);
279
+
280
+ if (reCanonical !== null) {
281
+ /**
282
+ * 状态首现条目(让位写回目标)
283
+ */
284
+ const first = fields.find((field) => field.key === '状态');
285
+
286
+ if (first !== undefined) {
287
+ first.value = reCanonical.value;
288
+ }
289
+
290
+ convergedStatusValue = reCanonical.value;
291
+
292
+ transformations.push({
293
+ kind: 'duplicate-field-converged',
294
+ detail: `状态 键去重:注入定档让位原始语义 → ${reCanonical.value}`,
295
+ });
296
+ }
297
+ }
298
+ }
299
+
300
+ /**
301
+ * 来源原始值定位(重复且首值含回填标记)
302
+ */
303
+ const sourceEntries = fields.filter((field) => field.key === '来源');
304
+
305
+ if (sourceEntries.length > 1) {
306
+ /**
307
+ * 回填首值
308
+ */
309
+ const backfilledFirst = sourceEntries[0]?.value.includes('格式清偿迁移回填') === true;
310
+
311
+ /**
312
+ * 原始语义候选
313
+ */
314
+ const originalCandidate = sourceEntries
315
+ .slice(1)
316
+ .find((field) => !field.value.includes('格式清偿迁移回填'));
317
+
318
+ if (backfilledFirst && originalCandidate !== undefined) {
319
+ /**
320
+ * 来源首现条目(让位写回目标)
321
+ */
322
+ const first = fields.find((field) => field.key === '来源');
323
+
324
+ if (first !== undefined) {
325
+ first.value = originalCandidate.value;
326
+ }
327
+
328
+ sourceReplaced = true;
329
+
330
+ transformations.push({
331
+ kind: 'duplicate-field-converged',
332
+ detail: `来源 键去重:回填缺省让位原始语义 → ${originalCandidate.value.slice(0, 60)}`,
333
+ });
334
+ }
335
+ }
336
+
337
+ /**
338
+ * 通用去重(保留各键首现条目;异值合并为分号单行,不丢信息——状态/来源的
339
+ * 让位已在上方完成)
340
+ */
341
+ const keeperIndex = new Map<string, number>();
342
+
343
+ for (let index = 0; index < fields.length; index += 1) {
344
+ /**
345
+ * 正向遍历到的字段条目(首现登记为保留位,重复并入后移除)
346
+ */
347
+ const field = fields[index];
348
+
349
+ if (field === undefined) {
350
+ continue;
351
+ }
352
+
353
+ /**
354
+ * 该键首现保留位下标
355
+ */
356
+ const firstIndex = keeperIndex.get(field.key);
357
+
358
+ if (firstIndex === undefined) {
359
+ keeperIndex.set(field.key, index);
360
+
361
+ continue;
362
+ }
363
+
364
+ /**
365
+ * 首现保留条目(异值并入目标)
366
+ */
367
+ const keeper = fields[firstIndex];
368
+
369
+ if (field.value !== '' && keeper !== undefined && !keeper.value.includes(field.value)) {
370
+ keeper.value = keeper.value === '' ? field.value : `${keeper.value};${field.value}`;
371
+ }
372
+
373
+ transformations.push({
374
+ kind: 'duplicate-field-converged',
375
+ detail: `${field.key} 键去重:重复值并入首现(${field.value.slice(0, 40)})`,
376
+ });
377
+
378
+ fields.splice(index, 1);
379
+
380
+ index -= 1;
381
+ }
382
+
383
+ return { statusValue: convergedStatusValue, sourceReplaced };
384
+ }