@longzai-intelligence-issues/ledger 0.0.1 → 0.0.3
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/.turbo/turbo-build.log +5 -0
- package/.turbo/turbo-lint.log +7 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +28 -0
- package/dist/index.d.ts +384 -11
- package/dist/index.js +23 -22
- package/package.json +4 -8
- package/src/__tests__/close/verify-close.commands.test.ts +57 -0
- package/src/__tests__/lint/lint.core.test.ts +47 -0
- package/src/__tests__/migrate/migrate-legacy.commands.test.ts +737 -0
- package/src/__tests__/parser/registry.parser.test.ts +15 -0
- package/src/__tests__/template/issue-template.renderer.test.ts +10 -0
- package/src/index.ts +8 -0
- package/src/lint/lint.core.ts +63 -1
- package/src/migrate/migrate-legacy.backfill.ts +149 -0
- package/src/migrate/migrate-legacy.commands.ts +475 -0
- package/src/migrate/migrate-legacy.converge.ts +384 -0
- package/src/migrate/migrate-legacy.core.ts +800 -0
- package/src/migrate/migrate-legacy.vocab.ts +242 -0
- package/src/normalize/normalize-header.commands.ts +249 -16
- package/src/numbering/numbering.core.ts +3 -3
- package/src/parser/format.utils.ts +76 -0
- package/src/parser/registry.parser.ts +8 -0
- package/src/template/issue-template.renderer.ts +15 -4
- package/tsconfig/.cache/build.tsbuildinfo +1 -1
- package/tsconfig/.cache/node.tsbuildinfo +1 -1
- package/tsconfig/.cache/test.tsbuildinfo +1 -1
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* migrate-legacy 词表与纯规范化函数(无 IO,自 migrate-legacy.core 拆出守文件行上限)
|
|
3
|
+
*
|
|
4
|
+
* 字段值粗体清理、pre-emoji 状态词表匹配与五态规范化、优先级档位映射、H1 规范化。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { LEGACY_STATUS_VOCABULARY } from '@/parser/format.utils';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 状态词匹配产物
|
|
11
|
+
*/
|
|
12
|
+
type StatusWordMatch = {
|
|
13
|
+
/**
|
|
14
|
+
* 词表档位
|
|
15
|
+
*/
|
|
16
|
+
tier: 'green' | 'red' | 'deferred' | 'canceled' | 'inProgress' | 'frozen';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 命中词
|
|
20
|
+
*/
|
|
21
|
+
word: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 状态值规范化产物
|
|
26
|
+
*/
|
|
27
|
+
type CanonicalStatusValue = {
|
|
28
|
+
/**
|
|
29
|
+
* 规范状态值(五态灯位 + 溯源括注)
|
|
30
|
+
*/
|
|
31
|
+
value: string;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 词表档位
|
|
35
|
+
*/
|
|
36
|
+
tier: StatusWordMatch['tier'];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 命中词(原词溯源)
|
|
40
|
+
*/
|
|
41
|
+
word: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 清理字段值(去首尾空白与粗体星号——覆盖三种历史形态:整体包裹 `**值**`、
|
|
46
|
+
* 前导无尾随 `**值`、词中包裹 `**词**(括注)`)
|
|
47
|
+
*
|
|
48
|
+
* @param value - 原值
|
|
49
|
+
* @returns 清理后值
|
|
50
|
+
*/
|
|
51
|
+
export function cleanValue(value: string): string {
|
|
52
|
+
return value
|
|
53
|
+
.trim()
|
|
54
|
+
.replace(/^\*\*/, '')
|
|
55
|
+
.replace(/\*\*$/, '')
|
|
56
|
+
.replace(/\*\*(?=[((])/, '')
|
|
57
|
+
.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 在状态值中匹配 pre-emoji 词表
|
|
62
|
+
*
|
|
63
|
+
* @param value - 清理后的状态值
|
|
64
|
+
* @returns 命中档位与词;未命中为 null
|
|
65
|
+
*/
|
|
66
|
+
function matchStatusWord(value: string): StatusWordMatch | null {
|
|
67
|
+
/**
|
|
68
|
+
* 档位遍历序(frozen 最先——冻结态优先识别并拒绝迁移)
|
|
69
|
+
*/
|
|
70
|
+
const tiers: StatusWordMatch['tier'][] = [
|
|
71
|
+
'frozen',
|
|
72
|
+
'green',
|
|
73
|
+
'deferred',
|
|
74
|
+
'canceled',
|
|
75
|
+
'inProgress',
|
|
76
|
+
'red',
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
for (const tier of tiers) {
|
|
80
|
+
for (const word of LEGACY_STATUS_VOCABULARY[tier]) {
|
|
81
|
+
if (value.startsWith(word)) {
|
|
82
|
+
return { tier, word };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 状态值切分产物(首个括注内容与词后剩余文本)
|
|
92
|
+
*/
|
|
93
|
+
type StatusValueSplit = {
|
|
94
|
+
/**
|
|
95
|
+
* 首个括注内容(无括注为空串)
|
|
96
|
+
*/
|
|
97
|
+
paren: string;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 括注后的剩余文本
|
|
101
|
+
*/
|
|
102
|
+
rest: string;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 提取状态值中的首个括注与其余文本
|
|
107
|
+
*
|
|
108
|
+
* @param value - 清理后的状态值
|
|
109
|
+
* @param word - 命中词
|
|
110
|
+
* @returns 括注内容与词后剩余文本
|
|
111
|
+
*/
|
|
112
|
+
function splitStatusValue(value: string, word: string): StatusValueSplit {
|
|
113
|
+
/**
|
|
114
|
+
* 词后文本
|
|
115
|
+
*/
|
|
116
|
+
const afterWord = value.slice(word.length).trim();
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 首个括注匹配
|
|
120
|
+
*/
|
|
121
|
+
const parenMatch = /^[((]([^))]*)[))]/.exec(afterWord);
|
|
122
|
+
|
|
123
|
+
if (parenMatch === null) {
|
|
124
|
+
return { paren: '', rest: afterWord };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
paren: (parenMatch[1] ?? '').trim(),
|
|
129
|
+
rest: afterWord.slice((parenMatch[0] ?? '').length).trim(),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 规范化状态值(pre-emoji 词 → 五态灯位;原词与括注零丢失保留)
|
|
135
|
+
*
|
|
136
|
+
* @param value - 清理后的状态值
|
|
137
|
+
* @returns 规范值与档位;不可机械迁移为 null(须人工)
|
|
138
|
+
*/
|
|
139
|
+
export function canonicalizeStatusValue(value: string): CanonicalStatusValue | null {
|
|
140
|
+
/**
|
|
141
|
+
* 词表命中
|
|
142
|
+
*/
|
|
143
|
+
const match = matchStatusWord(value);
|
|
144
|
+
|
|
145
|
+
if (match === null) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 括注与剩余文本
|
|
151
|
+
*/
|
|
152
|
+
const { paren, rest } = splitStatusValue(value, match.word);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 括注段收集器(顺序:括注 → 剩余 → 原词溯源)
|
|
156
|
+
*/
|
|
157
|
+
const parts = [paren, rest, `原「${match.word}」`].filter((part) => part !== '');
|
|
158
|
+
|
|
159
|
+
switch (match.tier) {
|
|
160
|
+
case 'frozen':
|
|
161
|
+
return null;
|
|
162
|
+
|
|
163
|
+
case 'green':
|
|
164
|
+
return { value: `🟢 已处置(${parts.join(';')})`, tier: match.tier, word: match.word };
|
|
165
|
+
|
|
166
|
+
case 'red':
|
|
167
|
+
return { value: `🔴 待处理(${parts.join(';')})`, tier: match.tier, word: match.word };
|
|
168
|
+
|
|
169
|
+
case 'deferred':
|
|
170
|
+
return {
|
|
171
|
+
value: `🔴 待处理(${[paren === '' ? '延期' : paren, `原「${match.word}」`].join(';')})`,
|
|
172
|
+
tier: match.tier,
|
|
173
|
+
word: match.word,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
case 'canceled':
|
|
177
|
+
return paren === '' || paren.length < 4
|
|
178
|
+
? null
|
|
179
|
+
: { value: `⚫ 已取消(${parts.join(';')})`, tier: match.tier, word: match.word };
|
|
180
|
+
|
|
181
|
+
case 'inProgress':
|
|
182
|
+
return paren.length < 4
|
|
183
|
+
? null
|
|
184
|
+
: { value: `🟡 处理中(${paren})`, tier: match.tier, word: match.word };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 严重程度/中文优先级 → P 档位映射
|
|
190
|
+
*
|
|
191
|
+
* @param value - 原值(高/中/低或 P1/P2/P3)
|
|
192
|
+
* @returns P 档位;不可映射为 null
|
|
193
|
+
*/
|
|
194
|
+
export function mapPriorityValue(value: string): 'P1' | 'P2' | 'P3' | null {
|
|
195
|
+
/**
|
|
196
|
+
* 严重程度中文档位与 P 档位的等价映射表(P 档位原样透传)
|
|
197
|
+
*/
|
|
198
|
+
const table: Record<string, 'P1' | 'P2' | 'P3'> = {
|
|
199
|
+
高: 'P1',
|
|
200
|
+
中: 'P2',
|
|
201
|
+
低: 'P3',
|
|
202
|
+
P1: 'P1',
|
|
203
|
+
P2: 'P2',
|
|
204
|
+
P3: 'P3',
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
return table[cleanValue(value)] ?? null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* H1 规范化产物
|
|
212
|
+
*/
|
|
213
|
+
type CanonicalH1 = {
|
|
214
|
+
/**
|
|
215
|
+
* 规范标题行(`# NNNN - 标题核心`)
|
|
216
|
+
*/
|
|
217
|
+
line: string;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 标题核心(剥离既有编号与分隔符变体后的标题文本)
|
|
221
|
+
*/
|
|
222
|
+
titleCore: string;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* H1 标题规范化:`# NNNN - 标题`(剥离既有编号与分隔符变体)
|
|
227
|
+
*
|
|
228
|
+
* @param h1 - 原一级标题行
|
|
229
|
+
* @param number - 文件名编号
|
|
230
|
+
* @returns 规范标题行与标题核心
|
|
231
|
+
*/
|
|
232
|
+
export function canonicalizeH1(h1: string, number: string): CanonicalH1 {
|
|
233
|
+
/**
|
|
234
|
+
* 标题核心(去行首 `# ` 与既有编号 + 分隔符变体——含 em-dash 与叠用形态)
|
|
235
|
+
*/
|
|
236
|
+
const titleCore = h1
|
|
237
|
+
.replace(/^#\s+/, '')
|
|
238
|
+
.replace(new RegExp(`^${number}\\s*(?:[-—―·..::-]+\\s*)?`), '')
|
|
239
|
+
.trim();
|
|
240
|
+
|
|
241
|
+
return { line: `# ${number} - ${titleCore}`, titleCore };
|
|
242
|
+
}
|
|
@@ -1,15 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* normalize-header(方言头部迁移)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* 五种历史方言(单行管道引用行 / 粗体标签行 / `## 键: 值` 标题式 / `- ` 列表式 /
|
|
5
|
+
* `| 键 | 值 |` 表格)与两类复合形态(引用行内粗体键 `> **键**: 值`、标题式隔行值
|
|
6
|
+
* `## 键` + 下一非空独立行)解析后重排为裁定格式:粗体标签逐行 `**键**: 值`;字段
|
|
7
|
+
* 规范序(FIELD_RANK);「主题」问题陈述迁正文首个节标题后;residual 原样保留。
|
|
8
|
+
* 零丢失守卫:每字段必须在产物文本在场否则 failed 不落盘;幂等(产物 === 原文 →
|
|
9
|
+
* unchanged)。
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
11
13
|
import { join } from 'node:path';
|
|
12
14
|
|
|
15
|
+
import { CANONICAL_STATUS_LABELS } from '@/parser/format.utils';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 标题式隔行值认定键集(`## 键` + 下一非空独立行为值的复合方言)
|
|
19
|
+
*
|
|
20
|
+
* 键限集合防误吞:非元信息键的 `## 节` 仍按正文边界终止头部;值行另有独立
|
|
21
|
+
* 短行约束(见 parseHeaderDialect 形态七),流水段落首行不受影响。
|
|
22
|
+
*/
|
|
23
|
+
export const HEADING_ALONE_METADATA_KEYS: ReadonlySet<string> = new Set([
|
|
24
|
+
'优先级',
|
|
25
|
+
'状态',
|
|
26
|
+
'立案日期',
|
|
27
|
+
'日期',
|
|
28
|
+
'所属域',
|
|
29
|
+
'来源',
|
|
30
|
+
'严重程度',
|
|
31
|
+
'严重度',
|
|
32
|
+
]);
|
|
33
|
+
|
|
13
34
|
/**
|
|
14
35
|
* 字段规范序(排名值越小越靠前;未入表字段按首现序殿后)
|
|
15
36
|
*/
|
|
@@ -35,6 +56,12 @@ export type NormalizeHeaderFileInput = {
|
|
|
35
56
|
* issue 文件仓库相对路径
|
|
36
57
|
*/
|
|
37
58
|
file: string;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 无可识别状态行时的显式灯位(--status 语义;frozen 不在此通道——冻结唯一合法
|
|
62
|
+
* 写入路径是 freeze 命令,其裁定语硬门不可绕过)
|
|
63
|
+
*/
|
|
64
|
+
status?: 'red' | 'yellow' | 'green' | 'canceled';
|
|
38
65
|
};
|
|
39
66
|
|
|
40
67
|
/**
|
|
@@ -60,7 +87,7 @@ export type NormalizeHeaderResult = {
|
|
|
60
87
|
/**
|
|
61
88
|
* 头部解析产物
|
|
62
89
|
*/
|
|
63
|
-
type ParsedHeader = {
|
|
90
|
+
export type ParsedHeader = {
|
|
64
91
|
/**
|
|
65
92
|
* 字段键值对(保序)
|
|
66
93
|
*/
|
|
@@ -78,12 +105,13 @@ type ParsedHeader = {
|
|
|
78
105
|
};
|
|
79
106
|
|
|
80
107
|
/**
|
|
81
|
-
*
|
|
108
|
+
* 解析头部方言(五种形态逐行尝试:粗体标签 / 列表式 / 标题式 / 管道引用行 / 表格;
|
|
109
|
+
* 另含两类复合形态:引用行内粗体键、标题式隔行值)
|
|
82
110
|
*
|
|
83
111
|
* @param lines - 文档行数组
|
|
84
112
|
* @returns 头部解析产物
|
|
85
113
|
*/
|
|
86
|
-
function parseHeaderDialect(lines: string[]): ParsedHeader {
|
|
114
|
+
export function parseHeaderDialect(lines: string[]): ParsedHeader {
|
|
87
115
|
/**
|
|
88
116
|
* 字段收集器
|
|
89
117
|
*/
|
|
@@ -105,6 +133,71 @@ function parseHeaderDialect(lines: string[]): ParsedHeader {
|
|
|
105
133
|
*/
|
|
106
134
|
const line = lines[index] ?? '';
|
|
107
135
|
|
|
136
|
+
/**
|
|
137
|
+
* 形态七:标题式隔行值(`## 键` 单独成行 + 下一非空独立行为值)
|
|
138
|
+
*
|
|
139
|
+
* 值行须为独立短行:非结构前缀(# > * | ` -)、非键值形态自身、长度上限
|
|
140
|
+
* 40——三者合力防吞流水段落首行;键限 HEADING_ALONE_METADATA_KEYS。
|
|
141
|
+
*/
|
|
142
|
+
const aloneMatch = /^##\s+([^::]{1,10})\s*$/.exec(line);
|
|
143
|
+
|
|
144
|
+
if (aloneMatch !== null && HEADING_ALONE_METADATA_KEYS.has(aloneMatch[1]?.trim() ?? '')) {
|
|
145
|
+
/**
|
|
146
|
+
* 值行下标(跳过标题行与值行之间的空行)
|
|
147
|
+
*/
|
|
148
|
+
let valueIndex = index + 1;
|
|
149
|
+
|
|
150
|
+
while (valueIndex < lines.length && (lines[valueIndex] ?? '').trim() === '') {
|
|
151
|
+
valueIndex += 1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 候选值行(去空白)
|
|
156
|
+
*/
|
|
157
|
+
const valueLine = (lines[valueIndex] ?? '').trim();
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 独立短行判定(非结构前缀 / 非键值形态 / 长度上限)
|
|
161
|
+
*/
|
|
162
|
+
const isBareValue =
|
|
163
|
+
valueLine !== '' &&
|
|
164
|
+
!/^[#>*|`-]/.test(valueLine) &&
|
|
165
|
+
!/^[^::]{1,8}[::]/.test(valueLine) &&
|
|
166
|
+
valueLine.length <= 40;
|
|
167
|
+
|
|
168
|
+
if (isBareValue) {
|
|
169
|
+
fields.push({ key: aloneMatch[1]?.trim() ?? '', value: valueLine });
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 值后空行下标(跳过值行与下一非空行之间的空行)
|
|
173
|
+
*/
|
|
174
|
+
let afterBlanks = valueIndex + 1;
|
|
175
|
+
|
|
176
|
+
while (afterBlanks < lines.length && (lines[afterBlanks] ?? '').trim() === '') {
|
|
177
|
+
afterBlanks += 1;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 后继非空行(判定是否又一标题式隔行值对——是则越过空行续扫,否则
|
|
182
|
+
* 空行照常终止头部区)
|
|
183
|
+
*/
|
|
184
|
+
const nextLine = lines[afterBlanks] ?? '';
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 后继行的标题式隔行值匹配(连续元信息对判定)
|
|
188
|
+
*/
|
|
189
|
+
const nextAloneMatch = /^##\s+([^::]{1,10})\s*$/.exec(nextLine);
|
|
190
|
+
|
|
191
|
+
index =
|
|
192
|
+
nextAloneMatch !== null &&
|
|
193
|
+
HEADING_ALONE_METADATA_KEYS.has(nextAloneMatch[1]?.trim() ?? '')
|
|
194
|
+
? afterBlanks - 1
|
|
195
|
+
: valueIndex;
|
|
196
|
+
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
108
201
|
if (line.startsWith('## ') || (line === '' && fields.length > 0 && index > 4)) {
|
|
109
202
|
bodyStartIndex = index;
|
|
110
203
|
|
|
@@ -127,9 +220,9 @@ function parseHeaderDialect(lines: string[]): ParsedHeader {
|
|
|
127
220
|
}
|
|
128
221
|
|
|
129
222
|
/**
|
|
130
|
-
*
|
|
223
|
+
* 形态二/四/六:粗体标签行 / 列表式 / 列表+粗体复合(`- **键**: 值`)
|
|
131
224
|
*/
|
|
132
|
-
const labeledMatch = /^(?:\*\*|- )([^*::]{1,8})(?:\*\*)?\s*[::]\s*(.+)$/.exec(line);
|
|
225
|
+
const labeledMatch = /^(?:\*\*|- \*\*|- )([^*::]{1,8})(?:\*\*)?\s*[::]\s*(.+)$/.exec(line);
|
|
133
226
|
|
|
134
227
|
if (labeledMatch !== null) {
|
|
135
228
|
fields.push({ key: labeledMatch[1]?.trim() ?? '', value: labeledMatch[2]?.trim() ?? '' });
|
|
@@ -137,6 +230,60 @@ function parseHeaderDialect(lines: string[]): ParsedHeader {
|
|
|
137
230
|
continue;
|
|
138
231
|
}
|
|
139
232
|
|
|
233
|
+
/**
|
|
234
|
+
* 形态七:多行元信息(`- 键:` 空值 + 缩进子项——子项文本以「;」并入单行值)
|
|
235
|
+
*/
|
|
236
|
+
const multiLineKeyMatch = /^(?:\*\*|- \*\*|- )([^*::]{1,8})(?:\*\*)?\s*[::]\s*$/.exec(line);
|
|
237
|
+
|
|
238
|
+
if (multiLineKeyMatch !== null) {
|
|
239
|
+
/**
|
|
240
|
+
* 子项行收集器(缩进深于键行者;子项文本去列表标记后原样保留)
|
|
241
|
+
*/
|
|
242
|
+
const childTexts: string[] = [];
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 键行缩进宽度
|
|
246
|
+
*/
|
|
247
|
+
const keyIndent = line.length - line.trimStart().length;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 子项游标
|
|
251
|
+
*/
|
|
252
|
+
let childIndex = index + 1;
|
|
253
|
+
|
|
254
|
+
while (childIndex < lines.length) {
|
|
255
|
+
/**
|
|
256
|
+
* 候选子项行
|
|
257
|
+
*/
|
|
258
|
+
const childLine = lines[childIndex] ?? '';
|
|
259
|
+
|
|
260
|
+
if (childLine.trim() === '') {
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 子项缩进
|
|
266
|
+
*/
|
|
267
|
+
const childIndent = childLine.length - childLine.trimStart().length;
|
|
268
|
+
|
|
269
|
+
if (childIndent <= keyIndent) {
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
childTexts.push(childLine.trim().replace(/^-\s*/, ''));
|
|
274
|
+
|
|
275
|
+
childIndex += 1;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (childTexts.length > 0) {
|
|
279
|
+
fields.push({ key: multiLineKeyMatch[1]?.trim() ?? '', value: childTexts.join(';') });
|
|
280
|
+
|
|
281
|
+
index = childIndex - 1;
|
|
282
|
+
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
140
287
|
/**
|
|
141
288
|
* 形态一:单行管道引用行(`> 优先级 | 状态 | …`)
|
|
142
289
|
*/
|
|
@@ -149,19 +296,87 @@ function parseHeaderDialect(lines: string[]): ParsedHeader {
|
|
|
149
296
|
.split(/[||]/)
|
|
150
297
|
.map((segment) => segment.trim());
|
|
151
298
|
|
|
299
|
+
/**
|
|
300
|
+
* 未命中键值的段(零段命中时整行原样入 residual——保 `> ` 前缀原貌)
|
|
301
|
+
*/
|
|
302
|
+
const unmatched: string[] = [];
|
|
303
|
+
|
|
152
304
|
for (const segment of segments) {
|
|
153
305
|
/**
|
|
154
|
-
*
|
|
306
|
+
* 段内键值切分(键清理粗体包裹——`> **键**: 值` 复合形态与粗体标签行同口径)
|
|
155
307
|
*/
|
|
156
308
|
const pairMatch = /^([^::]{1,8})\s*[::]\s*(.+)$/.exec(segment);
|
|
157
309
|
|
|
158
310
|
if (pairMatch !== null) {
|
|
159
|
-
fields.push({
|
|
311
|
+
fields.push({
|
|
312
|
+
key: (pairMatch[1] ?? '').replace(/^\*+|\*+$/g, '').trim(),
|
|
313
|
+
value: pairMatch[2]?.trim() ?? '',
|
|
314
|
+
});
|
|
160
315
|
} else {
|
|
161
|
-
|
|
316
|
+
unmatched.push(segment);
|
|
162
317
|
}
|
|
163
318
|
}
|
|
164
319
|
|
|
320
|
+
if (unmatched.length === segments.length) {
|
|
321
|
+
residuals.push(line);
|
|
322
|
+
} else {
|
|
323
|
+
residuals.push(...unmatched);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* 形态五:表格方言(两列表 `| 键 | 值 |`;表头行与分隔行为表格脚手架,键值
|
|
331
|
+
* 提取后丢弃——脚手架无独立语义,保留会在规范头部产出破碎表格残片)
|
|
332
|
+
*/
|
|
333
|
+
const tableMatch = /^\|([^|]*)\|([^|]*)\|$/.exec(line.trim());
|
|
334
|
+
|
|
335
|
+
if (tableMatch !== null) {
|
|
336
|
+
/**
|
|
337
|
+
* 键单元格 / 值单元格
|
|
338
|
+
*/
|
|
339
|
+
const keyCell = (tableMatch[1] ?? '').trim();
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* 值单元格(数据行切键值的值侧)
|
|
343
|
+
*/
|
|
344
|
+
const valueCell = (tableMatch[2] ?? '').trim();
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* 分隔行形态(单元格仅 dashes/colons——`| --- | --- |`)
|
|
348
|
+
*/
|
|
349
|
+
const separatorCellPattern = /^:?-{2,}:?$/;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* 本行是否分隔行
|
|
353
|
+
*/
|
|
354
|
+
const isSeparator =
|
|
355
|
+
separatorCellPattern.test(keyCell) && separatorCellPattern.test(valueCell);
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 次行(表头行判定:分隔行紧前的一行是表头)
|
|
359
|
+
*/
|
|
360
|
+
const nextMatch = /^\|([^|]*)\|([^|]*)\|$/.exec((lines[index + 1] ?? '').trim());
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* 次行是否表格分隔行(当前行为表头行的判定依据)
|
|
364
|
+
*/
|
|
365
|
+
const nextIsTableSeparator =
|
|
366
|
+
nextMatch !== null &&
|
|
367
|
+
separatorCellPattern.test((nextMatch[1] ?? '').trim()) &&
|
|
368
|
+
separatorCellPattern.test((nextMatch[2] ?? '').trim());
|
|
369
|
+
|
|
370
|
+
if (isSeparator || (nextIsTableSeparator && keyCell !== '')) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (keyCell !== '') {
|
|
375
|
+
fields.push({ key: keyCell, value: valueCell });
|
|
376
|
+
} else {
|
|
377
|
+
residuals.push(line);
|
|
378
|
+
}
|
|
379
|
+
|
|
165
380
|
continue;
|
|
166
381
|
}
|
|
167
382
|
|
|
@@ -187,6 +402,16 @@ function fieldSortKey(key: string, firstSeenRank: number): number {
|
|
|
187
402
|
*/
|
|
188
403
|
export type NormalizeOutcome = 'rewritten' | 'unchanged' | 'skipped';
|
|
189
404
|
|
|
405
|
+
/**
|
|
406
|
+
* normalizeHeader 迁移选项(无可识别状态行时的显式灯位注入)
|
|
407
|
+
*/
|
|
408
|
+
export type NormalizeHeaderOptions = {
|
|
409
|
+
/**
|
|
410
|
+
* 显式灯位(--status 语义;frozen 不在此通道——冻结唯一合法写入路径是 freeze)
|
|
411
|
+
*/
|
|
412
|
+
status?: NormalizeHeaderFileInput['status'];
|
|
413
|
+
};
|
|
414
|
+
|
|
190
415
|
/**
|
|
191
416
|
* 迁移产物
|
|
192
417
|
*/
|
|
@@ -206,9 +431,10 @@ export type NormalizeResult = {
|
|
|
206
431
|
* 迁移单份文档头部
|
|
207
432
|
*
|
|
208
433
|
* @param raw - 文档全文
|
|
434
|
+
* @param options - 迁移选项(无可识别状态行时的显式灯位注入)
|
|
209
435
|
* @returns 迁移产物
|
|
210
436
|
*/
|
|
211
|
-
export function normalizeHeader(raw: string): NormalizeResult {
|
|
437
|
+
export function normalizeHeader(raw: string, options?: NormalizeHeaderOptions): NormalizeResult {
|
|
212
438
|
/**
|
|
213
439
|
* 文档行数组
|
|
214
440
|
*/
|
|
@@ -220,12 +446,19 @@ export function normalizeHeader(raw: string): NormalizeResult {
|
|
|
220
446
|
const parsed = parseHeaderDialect(lines);
|
|
221
447
|
|
|
222
448
|
/**
|
|
223
|
-
*
|
|
449
|
+
* 「状态」字段定位(无状态行且未给 --status 则 skipped——灯位属语义判断,不猜灯)
|
|
224
450
|
*/
|
|
225
451
|
const hasStatus = parsed.fields.some((field) => field.key === '状态');
|
|
226
452
|
|
|
227
453
|
if (!hasStatus) {
|
|
228
|
-
|
|
454
|
+
if (options?.status === undefined) {
|
|
455
|
+
return { content: raw, outcome: 'skipped' };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* 显式灯位注入(--status):以规范标签落状态行,排入 FIELD_RANK 规范序
|
|
460
|
+
*/
|
|
461
|
+
parsed.fields.push({ key: '状态', value: CANONICAL_STATUS_LABELS[options.status] });
|
|
229
462
|
}
|
|
230
463
|
|
|
231
464
|
/**
|
|
@@ -328,7 +561,7 @@ export function normalizeHeaderFile(input: NormalizeHeaderFileInput): NormalizeH
|
|
|
328
561
|
/**
|
|
329
562
|
* 迁移产物
|
|
330
563
|
*/
|
|
331
|
-
const normalized = normalizeHeader(raw);
|
|
564
|
+
const normalized = normalizeHeader(raw, { status: input.status });
|
|
332
565
|
|
|
333
566
|
if (normalized.outcome === 'skipped') {
|
|
334
567
|
return {
|
|
@@ -325,9 +325,9 @@ export type ScopeScanInput = {
|
|
|
325
325
|
allowlist: readonly NumberingAllowlistEntry[];
|
|
326
326
|
|
|
327
327
|
/**
|
|
328
|
-
* slug 数字策略('forbid'
|
|
328
|
+
* slug 数字策略('forbid' 启用判定;'allow' 整域放行跳过判定)
|
|
329
329
|
*/
|
|
330
|
-
slugDigitPolicy?: 'forbid';
|
|
330
|
+
slugDigitPolicy?: 'forbid' | 'allow';
|
|
331
331
|
|
|
332
332
|
/**
|
|
333
333
|
* slug 数字白名单 glob(仓库相对路径命中豁免)
|
|
@@ -503,7 +503,7 @@ export function scanScopeForFindings(input: ScopeScanInput): NumberingFinding[]
|
|
|
503
503
|
});
|
|
504
504
|
});
|
|
505
505
|
|
|
506
|
-
if (input.slugDigitPolicy
|
|
506
|
+
if (input.slugDigitPolicy !== 'allow') {
|
|
507
507
|
/**
|
|
508
508
|
* slug 白名单命中集合(全量档失效对账用)
|
|
509
509
|
*/
|