@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.
@@ -104,6 +104,21 @@ describe('parseIssueDoc 状态行', () => {
104
104
  expect(record?.status).toBe('legacy-yellow');
105
105
  });
106
106
 
107
+ test('标题行内含「连接状态:」词组不误定位(跳过标题行命中真状态行)', () => {
108
+ const record = parseIssueDoc(
109
+ '0001-sample.md',
110
+ [
111
+ '# 0001 - 调试面板被「连接状态: API=不可达, WS=未配置」刷屏',
112
+ '',
113
+ '**状态**: 🟢 已处置(按方案 B 落地)',
114
+ ].join('\n'),
115
+ 0,
116
+ );
117
+
118
+ expect(record?.status).toBe('green');
119
+ expect(record?.statusLineNumber).toBe(3);
120
+ });
121
+
107
122
  test('规范绿灯归类 green', () => {
108
123
  const record = parseIssueDoc(
109
124
  '0001-sample.md',
package/src/index.ts CHANGED
@@ -33,6 +33,10 @@ export * from './migrate/migrate-legacy.commands.js';
33
33
 
34
34
  export * from './migrate/migrate-legacy.core.js';
35
35
 
36
+ export * from './migrate/migrate-legacy.vocab.js';
37
+
38
+ export * from './migrate/migrate-legacy.backfill.js';
39
+
36
40
  export * from './numbering/numbering.commands.js';
37
41
 
38
42
  export * from './numbering/numbering.core.js';
@@ -287,6 +287,57 @@ export const CORE_LINT_RULES: readonly LintRule[] = [
287
287
  : `slug 段含数字(scope 政策 slugDigitPolicy 或白名单 slugDigitAllowlist 登记):${record.file}`;
288
288
  },
289
289
  },
290
+ {
291
+ name: 'duplicate-metadata-key',
292
+ evaluate: (record) => {
293
+ /**
294
+ * 头部区行(首行起至首个二级标题,上限 40 行——覆盖规范头部与其后
295
+ * 紧邻的残留方言块)
296
+ */
297
+ const headerLines: string[] = [];
298
+
299
+ for (const line of record.rawContent.split(/\r?\n/)) {
300
+ if (line.startsWith('## ') || headerLines.length >= 40) {
301
+ break;
302
+ }
303
+
304
+ headerLines.push(line);
305
+ }
306
+
307
+ /**
308
+ * 规范键命中计数器
309
+ */
310
+ const counts = new Map<string, number>();
311
+
312
+ for (const line of headerLines) {
313
+ /**
314
+ * 元信息行匹配(粗体 / 列表 / 列表+粗体复合前缀通吃)
315
+ */
316
+ const match =
317
+ /^(?:\*\*|- \*\*|- )(优先级|状态|立案日期|所属域|来源|严重程度)(?:\*\*)?\s*[::]/.exec(
318
+ line.trim(),
319
+ );
320
+
321
+ if (match !== null) {
322
+ /**
323
+ * 命中的元信息键
324
+ */
325
+ const key = match[1] ?? '';
326
+
327
+ counts.set(key, (counts.get(key) ?? 0) + 1);
328
+ }
329
+ }
330
+
331
+ /**
332
+ * 重复键列表
333
+ */
334
+ const duplicated = [...counts.entries()].filter(([, count]) => count > 1).map(([key]) => key);
335
+
336
+ return duplicated.length > 0
337
+ ? `头部区元信息键重复:${duplicated.join('、')}(方言残留块与规范头部并存——须经 migrate-legacy 收敛)`
338
+ : null;
339
+ },
340
+ },
290
341
  {
291
342
  name: 'canceled-no-reason',
292
343
  evaluate: (record) =>
@@ -0,0 +1,149 @@
1
+ /**
2
+ * migrate-legacy 必填元信息回填(自 core 拆出守函数行上限)
3
+ *
4
+ * 字段别名(日期 → 立案日期)、立案日期/优先级回填与档位映射。缺省未给时
5
+ * 返回 skip 语由调用方裁决,不在此抛错。
6
+ */
7
+
8
+ import type { MigrationTransformation } from './migrate-legacy.core';
9
+
10
+ import { mapPriorityValue } from './migrate-legacy.vocab';
11
+
12
+ /**
13
+ * 回填入参(与 MigrateCoreOptions 的回填子集 + 字段工作副本)
14
+ */
15
+ export type BackfillInput = {
16
+ /**
17
+ * 头部字段工作副本(原地回填)
18
+ */
19
+ fields: Array<{ key: string; value: string }>;
20
+
21
+ /**
22
+ * 变换台账
23
+ */
24
+ transformations: MigrationTransformation[];
25
+
26
+ /**
27
+ * 优先级缺省
28
+ */
29
+ defaultPriority?: 'P1' | 'P2' | 'P3';
30
+
31
+ /**
32
+ * 立案日期回填缺省
33
+ */
34
+ defaultFilingDate?: string;
35
+ };
36
+
37
+ /**
38
+ * 回填产物
39
+ */
40
+ export type BackfillResult = {
41
+ /**
42
+ * skip 语(缺省未给交人工时非空)
43
+ */
44
+ skipMessage: string | null;
45
+ };
46
+
47
+ /**
48
+ * 执行字段别名与必填回填
49
+ *
50
+ * @param input - 回填入参
51
+ * @returns 回填产物(skipMessage 非空即调用方应 skipped 返回)
52
+ */
53
+ export function backfillRequiredFields(input: BackfillInput): BackfillResult {
54
+ /**
55
+ * 解构字段副本与台账
56
+ */
57
+ const { fields, transformations } = input;
58
+
59
+ /**
60
+ * 字段别名:日期 → 立案日期(已有立案日期则保留原字段不动)
61
+ */
62
+ const filingIndex = fields.findIndex((field) => field.key === '立案日期');
63
+
64
+ /**
65
+ * 旧字段名「日期」下标(方言主流的立案日期载体)
66
+ */
67
+ const dateIndex = fields.findIndex((field) => field.key === '日期');
68
+
69
+ if (filingIndex === -1 && dateIndex !== -1) {
70
+ /**
71
+ * 待改名的日期字段
72
+ */
73
+ const dateField = fields[dateIndex];
74
+
75
+ if (dateField !== undefined) {
76
+ fields[dateIndex] = { key: '立案日期', value: dateField.value };
77
+
78
+ transformations.push({
79
+ kind: 'field-alias',
80
+ detail: `日期 → 立案日期(${dateField.value})`,
81
+ });
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 立案日期缺省回填(未给缺省则交人工)
87
+ */
88
+ if (!fields.some((field) => field.key === '立案日期')) {
89
+ if (input.defaultFilingDate === undefined) {
90
+ return { skipMessage: '缺立案日期且未给回填缺省——交人工' };
91
+ }
92
+
93
+ fields.push({ key: '立案日期', value: input.defaultFilingDate });
94
+
95
+ transformations.push({
96
+ kind: 'required-backfilled',
97
+ detail: `立案日期 ← ${input.defaultFilingDate}`,
98
+ });
99
+ }
100
+
101
+ /**
102
+ * 优先级:既有值映射 / 严重程度映射 / 缺省回填
103
+ */
104
+ const priorityIndex = fields.findIndex((field) => field.key === '优先级');
105
+
106
+ if (priorityIndex !== -1) {
107
+ /**
108
+ * 既有优先级值
109
+ */
110
+ const existing = fields[priorityIndex]?.value ?? '';
111
+
112
+ /**
113
+ * 映射产物(中文档位 → P 档位)
114
+ */
115
+ const mapped = mapPriorityValue(existing);
116
+
117
+ if (mapped !== null && mapped !== existing) {
118
+ fields[priorityIndex] = { key: '优先级', value: mapped };
119
+
120
+ transformations.push({ kind: 'priority-mapped', detail: `优先级 ${existing} → ${mapped}` });
121
+ }
122
+ } else {
123
+ /**
124
+ * 严重程度映射或缺省回填
125
+ */
126
+ const severityField = fields.find((field) => field.key === '严重程度');
127
+
128
+ /**
129
+ * 回填值
130
+ */
131
+ const backfill =
132
+ (severityField !== undefined ? mapPriorityValue(severityField.value) : null) ??
133
+ input.defaultPriority ??
134
+ null;
135
+
136
+ if (backfill === null) {
137
+ return { skipMessage: '缺优先级且未给回填缺省——交人工' };
138
+ }
139
+
140
+ fields.push({ key: '优先级', value: backfill });
141
+
142
+ transformations.push({
143
+ kind: severityField !== undefined ? 'priority-mapped' : 'priority-backfilled',
144
+ detail: `优先级 ← ${backfill}${severityField !== undefined ? `(严重程度 ${severityField.value})` : '(缺省回填)'}`,
145
+ });
146
+ }
147
+
148
+ return { skipMessage: null };
149
+ }
@@ -10,12 +10,14 @@ import type { LziIssuesConfig } from '@longzai-intelligence-issues/config';
10
10
  import { readFileSync, writeFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
 
13
+ import { HEADING_ALONE_METADATA_KEYS } from '@/normalize/normalize-header.commands';
14
+
13
15
  import {
14
- cleanValue,
15
16
  DISPOSITION_CANDIDATES,
16
17
  migrateLegacyCore,
17
18
  type MigrationTransformation,
18
19
  } from './migrate-legacy.core';
20
+ import { cleanValue } from './migrate-legacy.vocab';
19
21
 
20
22
  /**
21
23
  * 单文件迁移入参
@@ -163,7 +165,7 @@ function findLostContent(raw: string, migrated: string, transformedKeys: string[
163
165
  */
164
166
  const titleCore = trimmed
165
167
  .replace(/^#\s+/, '')
166
- .replace(/^[0-9]{3,4}\s*(?:[-·..::]\s*|\s+)/, '')
168
+ .replace(/^[0-9]{3,4}\s*(?:[-—―·..::-]+\s*|\s+)/, '')
167
169
  .trim();
168
170
 
169
171
  if (titleCore !== '' && migrated.includes(titleCore)) {
@@ -261,9 +263,9 @@ function findLostContent(raw: string, migrated: string, transformedKeys: string[
261
263
  }
262
264
 
263
265
  /**
264
- *
266
+ * 键(清粗体包裹——`> **键**: 值` 复合形态与头部字段行对账同口径)
265
267
  */
266
- const key = (pairMatch[1] ?? '').trim();
268
+ const key = (pairMatch[1] ?? '').replace(/^\*+|\*+$/g, '').trim();
267
269
 
268
270
  /**
269
271
  * 清理后值(与引擎 cleanValue 同口径)
@@ -281,9 +283,25 @@ function findLostContent(raw: string, migrated: string, transformedKeys: string[
281
283
  }
282
284
 
283
285
  /**
284
- * 头部字段行:粗体包裹与值清理后的形态在场即可
286
+ * 多行元信息形态:空值键行(键已并入规范头部即通过)
285
287
  */
286
- const labeled = /^(?:\*\*|- |## )([^*::]{1,20})(?:\*\*)?\s*[::]\s*(.+)$/.exec(line);
288
+ const emptyKeyMatch = /^(?:\*\*|- \*\*|- )([^*::]{1,20})(?:\*\*)?\s*[::]\s*$/.exec(trimmed);
289
+
290
+ if (emptyKeyMatch !== null && migrated.includes(`**${(emptyKeyMatch[1] ?? '').trim()}**:`)) {
291
+ continue;
292
+ }
293
+
294
+ /**
295
+ * 列表子项行:去列表标记后的文本在场即通过(多行元信息子项已并入单行值)
296
+ */
297
+ if (trimmed.startsWith('- ') && migrated.includes(trimmed.slice(2).trim())) {
298
+ continue;
299
+ }
300
+
301
+ /**
302
+ * 头部字段行:粗体包裹与值清理后的形态在场即可(含列表+粗体复合方言)
303
+ */
304
+ const labeled = /^(?:\*\*|- \*\*|- |## )([^*::]{1,20})(?:\*\*)?\s*[::]\s*(.+)$/.exec(line);
287
305
 
288
306
  if (labeled !== null) {
289
307
  /**
@@ -301,6 +319,42 @@ function findLostContent(raw: string, migrated: string, transformedKeys: string[
301
319
  }
302
320
  }
303
321
 
322
+ /**
323
+ * 标题式隔行值键行(`## 键` 单独成行):键行属登记变换被消费进规范头部,
324
+ * 配对值行自身经上方 includes 对账在场
325
+ */
326
+ const aloneKey = /^##\s*([^::]{1,10})\s*$/.exec(trimmed);
327
+
328
+ if (aloneKey !== null && HEADING_ALONE_METADATA_KEYS.has(aloneKey[1]?.trim() ?? '')) {
329
+ continue;
330
+ }
331
+
332
+ /**
333
+ * 标题式隔行值配对值行:前非空行是 `## 键` 且键在集合内时按字段值对账
334
+ * (状态词规范化会重组值文本——键属已变换集合或清理值在场即通过)
335
+ */
336
+ let prevIndex = lineIndex - 1;
337
+
338
+ while (prevIndex >= 0 && (rawLines[prevIndex] ?? '').trim() === '') {
339
+ prevIndex -= 1;
340
+ }
341
+
342
+ /**
343
+ * 前非空行的标题式键形态
344
+ */
345
+ const prevAlone = /^##\s*([^::]{1,10})\s*$/.exec((rawLines[prevIndex] ?? '').trim());
346
+
347
+ if (prevAlone !== null && HEADING_ALONE_METADATA_KEYS.has(prevAlone[1]?.trim() ?? '')) {
348
+ /**
349
+ * 配对键
350
+ */
351
+ const prevKey = (prevAlone[1] ?? '').trim();
352
+
353
+ if (transformedKeys.includes(prevKey) || migrated.includes(cleanValue(trimmed))) {
354
+ continue;
355
+ }
356
+ }
357
+
304
358
  lost.push(trimmed.slice(0, 60));
305
359
  }
306
360
 
@@ -389,9 +443,13 @@ export function migrateLegacyFile(input: MigrateLegacyFileInput): MigrateLegacyF
389
443
  }
390
444
 
391
445
  /**
392
- * 零丢失守卫(已变换字段键集合:状态/日期别名/优先级/H1
446
+ * 零丢失守卫(已变换字段键集合:状态/日期别名/优先级/H1;来源让位时加来源)
393
447
  */
394
- const lost = findLostContent(raw, migrated.content, ['状态', '日期', '优先级']);
448
+ const lost = findLostContent(
449
+ raw,
450
+ migrated.content,
451
+ migrated.sourceReplaced ? ['状态', '日期', '优先级', '来源'] : ['状态', '日期', '优先级'],
452
+ );
395
453
 
396
454
  if (lost.length > 0) {
397
455
  return {