@xulthekl/team-flow 0.54.0 → 0.55.0
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/.claude/always/phase-guard.md +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/marketplace.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.github/plugin/marketplace.json +2 -2
- package/CHANGELOG.md +63 -1
- package/GEMINI.md +1 -1
- package/INSTALL.md +1 -1
- package/README.md +1 -1
- package/agents/prototype-env-scout.md +4 -4
- package/dist/parsing/requirement-blocks.d.ts +26 -0
- package/dist/parsing/requirement-blocks.js +33 -5
- package/dist/validation/validator.js +8 -1
- package/docs/README_en.md +1 -1
- package/gemini-extension.json +1 -1
- package/hooks/session-start +19 -2
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/check-project-config.mjs +84 -0
- package/scripts/design-system-clone.mjs +150 -0
- package/scripts/design-system-import.mjs +102 -14
- package/scripts/gen-primer.mjs +65 -13
- package/scripts/guard/checks/tasks-complete.mjs +9 -4
- package/scripts/guard/design-token-guard.mjs +136 -117
- package/scripts/infer-workflow.mjs +10 -1
- package/scripts/lib/arch-merge.mjs +20 -6
- package/scripts/lib/arch-parse.mjs +5 -11
- package/scripts/lib/ds-inputs.mjs +125 -0
- package/scripts/lib/ds-parse.mjs +124 -12
- package/scripts/lib/execution-recommendation.mjs +10 -1
- package/scripts/lib/glaf4-delegation.mjs +14 -3
- package/scripts/lib/hash.mjs +18 -2
- package/scripts/lib/md-normalize.mjs +108 -0
- package/scripts/lib/prototype-sync.mjs +19 -1
- package/scripts/lib/sdd-overlay.mjs +15 -3
- package/scripts/lib/solutions-promote.mjs +11 -4
- package/scripts/lib/spec-merge.mjs +46 -11
- package/scripts/lib/state-loader.mjs +4 -1
- package/scripts/token-extract.mjs +101 -9
- package/skills/design-system/SKILL.md +46 -23
- package/skills/design-system/references/agents/design-system-architect.md +25 -12
- package/skills/design-system/references/creation-flow.md +17 -0
- package/skills/design-system/references/creation-modes.md +171 -0
- package/skills/design-system/references/showcase-board-b-end.md +50 -36
- package/skills/design-system/references/showcase-board-c-end.md +59 -36
- package/skills/design-system/references/variant-schema.md +21 -1
- package/skills/prototype/SKILL.md +4 -0
- package/skills/prototype/references/builder-methodology.md +11 -4
- package/skills/prototype/references/layouts.md +10 -0
- package/skills/prototype/references/orchestration-flow.md +8 -0
- package/skills/workflow-bootstrap/SKILL.md +15 -5
- package/src/parsing/requirement-blocks.ts +34 -5
- package/src/validation/validator.ts +8 -1
package/scripts/lib/ds-parse.mjs
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
// ds-parse.mjs — 设计系统文档解析(公共模块,v0.
|
|
1
|
+
// ds-parse.mjs — 设计系统文档解析(公共模块,v0.55.0)
|
|
2
2
|
//
|
|
3
|
-
// 供 design-token-guard.mjs / gen-primer.mjs
|
|
3
|
+
// 供 design-token-guard.mjs / gen-primer.mjs / design-system-import.mjs 复用。
|
|
4
4
|
// 纯函数,无副作用。
|
|
5
|
+
//
|
|
6
|
+
// v0.55.0(FB-4 修复):全部解析点接入 md-normalize 共享层。
|
|
7
|
+
// 原实现对 Markdown 强调语法脆弱且**失败静默**——`- **contract**: v1` 解析不出值,
|
|
8
|
+
// 被判 unset → guard 降级 + primer 头部错报,无任何提示。根因与同类复发史见
|
|
9
|
+
// md-normalize.mjs 头注。本文件另提供 *Diag 函数,把"段存在但解析不出"变成**显式告警**
|
|
10
|
+
// (纯函数不打印,由调用方决定输出通道)。
|
|
11
|
+
|
|
12
|
+
import { normalizeInline, stripInlineEmphasis, matchKeyValue } from './md-normalize.mjs';
|
|
5
13
|
|
|
6
14
|
// 组件契约表分组规则(§4.1.1):类型 → states 下限(null = 豁免)
|
|
7
15
|
export const COMPONENT_TYPE_MIN_STATES = { '交互': 3, '轻量': 2, '豁免': null };
|
|
@@ -40,16 +48,50 @@ export function sectionBodyRaw(markdown, section) {
|
|
|
40
48
|
return body.join('\n');
|
|
41
49
|
}
|
|
42
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Return the raw body of an **H3 subsection** inside a section (v0.55.0 新增).
|
|
53
|
+
*
|
|
54
|
+
* 动机:`layout` 段同时承载 栅格/断点 + 容器骨架 + 页面范式 三块(设计 §8.2.2)。
|
|
55
|
+
* L4b 若要按"页面类型表行数"判定,必须**只在 `### 页面范式` 子块内计数** ——
|
|
56
|
+
* 按整段计数会把容器骨架表/断点表的行也数进去,产生**假 PASS**
|
|
57
|
+
* (实测:现场 b-end.md 的 layout 段已有 2 张表 8 行,8 ≥ 3 直接误判达标)。
|
|
58
|
+
*
|
|
59
|
+
* 切片语义:从 `### <sub>` 起,到下一个 `###` 或 `##` 或 EOF 止;
|
|
60
|
+
* `#### ` 不截断(`/^###?\s+/` 不匹配 `####`——回溯后 `\s+` 无法匹配 `#`)。
|
|
61
|
+
*/
|
|
62
|
+
export function subsectionBodyRaw(markdown, section, sub) {
|
|
63
|
+
const sectionRaw = sectionBodyRaw(markdown, section);
|
|
64
|
+
if (sectionRaw === null) return null;
|
|
65
|
+
const lines = sectionRaw.split('\n');
|
|
66
|
+
const startIdx = lines.findIndex(line => {
|
|
67
|
+
const m = /^###\s+(.*)$/.exec(line);
|
|
68
|
+
return m && m[1].trim().toLowerCase().includes(sub);
|
|
69
|
+
});
|
|
70
|
+
if (startIdx === -1) return null;
|
|
71
|
+
const body = [];
|
|
72
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
73
|
+
if (/^###?\s+/.test(lines[i]) && !/^####/.test(lines[i])) break;
|
|
74
|
+
body.push(lines[i]);
|
|
75
|
+
}
|
|
76
|
+
return body.join('\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
43
79
|
// Lower-cased section body.
|
|
44
80
|
export function sectionBody(markdown, section) {
|
|
45
81
|
const raw = sectionBodyRaw(markdown, section);
|
|
46
82
|
return raw === null ? null : raw.toLowerCase();
|
|
47
83
|
}
|
|
48
84
|
|
|
49
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Parse a markdown table row into trimmed cells; null when not a data row.
|
|
87
|
+
*
|
|
88
|
+
* v0.55.0:接入共享归一化层(去反引号 + 剥强调)——与 arch-parse.mjs 的
|
|
89
|
+
* 同型实现统一。此前本函数不剥强调,导致 `| **交互** |` 这类单元格无法匹配类型表,
|
|
90
|
+
* **整行静默漏计**(FB-4 实测复现)。
|
|
91
|
+
*/
|
|
50
92
|
export function parseTableRow(line) {
|
|
51
93
|
if (!/^\s*\|/.test(line)) return null;
|
|
52
|
-
const cells = line.split('|').slice(1, -1).map(c => c.trim());
|
|
94
|
+
const cells = line.split('|').slice(1, -1).map(c => normalizeInline(c.trim()));
|
|
53
95
|
if (cells.length === 0) return null;
|
|
54
96
|
if (cells.every(c => /^:?-{2,}:?$/.test(c))) return null;
|
|
55
97
|
return cells;
|
|
@@ -90,35 +132,105 @@ export function parseComponentsTable(markdown) {
|
|
|
90
132
|
return rows.length > 0 ? { rows } : null;
|
|
91
133
|
}
|
|
92
134
|
|
|
135
|
+
/** contract 取值的接受形态(v0.55.0 放宽):`v1` / `v2` / … / `legacy`。 */
|
|
136
|
+
const CONTRACT_VALUE_RE = /^(v\d+|legacy)$/i;
|
|
137
|
+
|
|
93
138
|
// Parse governance section for the `contract` marker (§4.1.3).
|
|
94
|
-
// Returns 'v1' | 'legacy' | null
|
|
139
|
+
// Returns 形如 'v1' | 'legacy' | 'v2' | null(**未知值不再静默归 null**)。
|
|
140
|
+
//
|
|
141
|
+
// v0.55.0:容忍 `**contract**:` / `` `contract`: `` / 全角冒号 / 表格形态;
|
|
142
|
+
// 值域由 `v1|legacy` 放宽为任意 `v\d+`,未知值交由 guard 按最严档处理 + WARN。
|
|
95
143
|
export function parseContract(markdown) {
|
|
96
144
|
const body = sectionBodyRaw(markdown, 'governance');
|
|
97
145
|
if (!body) return null;
|
|
98
|
-
|
|
99
|
-
|
|
146
|
+
// 形态 1:key: value(含各种格式变体)
|
|
147
|
+
const kv = matchKeyValue(body, 'contract');
|
|
148
|
+
if (kv) {
|
|
149
|
+
const v = kv.match(CONTRACT_VALUE_RE);
|
|
150
|
+
if (v) return v[1].toLowerCase();
|
|
151
|
+
}
|
|
152
|
+
// 形态 2:表格 `| contract | v1 |`
|
|
153
|
+
for (const line of body.split('\n')) {
|
|
154
|
+
const cells = parseTableRow(line);
|
|
155
|
+
if (!cells || cells.length < 2) continue;
|
|
156
|
+
if (!/^contract$/i.test(cells[0])) continue;
|
|
157
|
+
const v = cells[1].match(CONTRACT_VALUE_RE);
|
|
158
|
+
if (v) return v[1].toLowerCase();
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 诊断:governance 段**存在且提到 contract**,但解析不出取值。
|
|
165
|
+
* 返回警告文本或 null(纯函数——由调用方决定输出到 stderr/stdout)。
|
|
166
|
+
*
|
|
167
|
+
* 动机:FB-4 的核心痛点是**静默**——用户"已知自己写的是 v1 却显示 unset",
|
|
168
|
+
* 靠对照才发现是格式问题。本函数把这种情形变成显式信号。
|
|
169
|
+
*/
|
|
170
|
+
export function contractFormatWarning(markdown) {
|
|
171
|
+
const body = sectionBodyRaw(markdown, 'governance');
|
|
172
|
+
if (!body) return null; // 段缺失 → 另一类问题,不在此报
|
|
173
|
+
if (parseContract(markdown)) return null; // 解析成功 → 无警告
|
|
174
|
+
if (!/contract/i.test(body)) return null; // 未提及 → 无警告
|
|
175
|
+
return '检测到 governance 段含 contract 字样但未能解析出取值,请检查格式(应为 `contract: v1`;'
|
|
176
|
+
+ '容忍 `**contract**:` / 反引号 / 全角冒号)';
|
|
100
177
|
}
|
|
101
178
|
|
|
102
179
|
// Parse the A1 identity token block from a token/spec section (for primer 速查).
|
|
103
180
|
// Looks for `--token: value` pairs anywhere in the markdown.
|
|
181
|
+
// v0.55.0:接入共享层(容忍 `**--accent**:` / 反引号 等写法)。
|
|
104
182
|
export function parseA1Tokens(markdown) {
|
|
105
183
|
const names = ['--bg', '--surface', '--fg', '--muted', '--border', '--accent', '--font-display', '--font-body'];
|
|
106
184
|
const out = {};
|
|
107
185
|
for (const name of names) {
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
if (m) out[name] = m[1].trim().replace(/\s+$/, '');
|
|
186
|
+
const v = matchKeyValue(markdown, name);
|
|
187
|
+
if (v) out[name] = v.trim().replace(/\s+$/, '');
|
|
111
188
|
}
|
|
112
189
|
return out;
|
|
113
190
|
}
|
|
114
191
|
|
|
115
|
-
|
|
192
|
+
/**
|
|
193
|
+
* 解析**页面范式声明**(v0.55.0,设计 §8.2.2 / §8.2.5)。
|
|
194
|
+
*
|
|
195
|
+
* 承载点:`layout` 段内的 `### 页面范式` 子块 —— 选 `layout` 是因为它是**必填段**,
|
|
196
|
+
* 单文件模式(转换器产物)也必然含它,不依赖变体合并(原方案新增独立段在单文件模式下
|
|
197
|
+
* 永远不可见且静默)。
|
|
198
|
+
*
|
|
199
|
+
* ### 页面范式
|
|
200
|
+
* **页面范式来源**:引用内置 | 项目自有 | 同 <端>
|
|
201
|
+
* | 页面类型 | 骨架/容器组合 | 参考 |
|
|
202
|
+
*
|
|
203
|
+
* **只在子块内计数表行**(表行数 = 页面类型数):按 layout 段整段计数会把
|
|
204
|
+
* 容器骨架表 / 断点表的行也数进去——现场实测该段已有 2 表 8 行,8 ≥ 3 会**假 PASS**。
|
|
205
|
+
*
|
|
206
|
+
* @returns {{declared: boolean, source: string|null, tableRows: number, subPresent: boolean}}
|
|
207
|
+
*/
|
|
208
|
+
export function parsePageArchetype(markdown) {
|
|
209
|
+
const sub = subsectionBodyRaw(markdown, 'layout', '页面范式');
|
|
210
|
+
if (sub === null) return { declared: false, source: null, tableRows: 0, subPresent: false };
|
|
211
|
+
const raw = matchKeyValue(sub, '页面范式来源');
|
|
212
|
+
// 表格行数 = **数据行**(排除表头)。parseTableRow 已滤掉分隔行(`|---|`),
|
|
213
|
+
// 但表头行仍会被匹配 → 子块内**首个表格行视为表头**跳过。
|
|
214
|
+
const tableLines = sub.split('\n').map(parseTableRow).filter(Boolean);
|
|
215
|
+
const tableRows = Math.max(0, tableLines.length - (tableLines.length > 0 ? 1 : 0));
|
|
216
|
+
return {
|
|
217
|
+
declared: Boolean(raw),
|
|
218
|
+
source: raw ? raw.replace(/\s+/g, '') : null,
|
|
219
|
+
tableRows,
|
|
220
|
+
subPresent: true,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Extract the anti-patterns section as a list of bullet/numbered items.
|
|
226
|
+
* v0.55.0:容忍整行被强调包裹(`**- item**`)——先剥行内强调再匹配列表符。
|
|
227
|
+
*/
|
|
116
228
|
export function parseAntiPatterns(markdown) {
|
|
117
229
|
const body = sectionBodyRaw(markdown, 'anti-patterns');
|
|
118
230
|
if (!body) return [];
|
|
119
231
|
return body
|
|
120
232
|
.split('\n')
|
|
121
|
-
.map(l => l.trim())
|
|
233
|
+
.map(l => stripInlineEmphasis(l.trim()))
|
|
122
234
|
.filter(l => /^([-*]|\d+[.、)])\s+\S/.test(l))
|
|
123
235
|
.map(l => l.replace(/^([-*]|\d+[.、)])\s+/, ''));
|
|
124
236
|
}
|
|
@@ -8,6 +8,7 @@ import { computeArtifactsHash, computeContractHash, hashObject, stableJson } fro
|
|
|
8
8
|
import { getOverlayPaths } from './sdd-overlay.mjs';
|
|
9
9
|
import { readState } from './state-loader.mjs';
|
|
10
10
|
import { EXECUTION_MODES } from './execution-plan.mjs';
|
|
11
|
+
import { parseTaskLine } from './md-normalize.mjs';
|
|
11
12
|
|
|
12
13
|
// v2.1 §6.6(二轮评审 HIGH):recommend 链补全
|
|
13
14
|
// - GLAF4 change 的 recommend 恒为"推荐 inline + available_modes 含 glaf4-delegation"
|
|
@@ -151,10 +152,18 @@ export function validateRecommendationReceipt(changeDir, receipt, waves = [], ex
|
|
|
151
152
|
return failures;
|
|
152
153
|
}
|
|
153
154
|
|
|
155
|
+
/**
|
|
156
|
+
* tasks.md 中已记录的 task 数(DP-4 推荐的事实输入之一)。
|
|
157
|
+
*
|
|
158
|
+
* v0.55.0 §8.4.3 横展(FB-4):识别走共享原语 `parseTaskLine`——原正则要求
|
|
159
|
+
* `- [x] `(`]` 后必须跟空格)且不容缩进,`*` bullet / 缩进行 / `- [x]正文` 全部漏计。
|
|
160
|
+
* **计数语义**(约束③:会改变 inline / batch-inline / sdd 的推荐结果)。
|
|
161
|
+
*/
|
|
154
162
|
function countDocumentedTasks(changeDir) {
|
|
155
163
|
const tasksPath = join(changeDir, 'tasks.md');
|
|
156
164
|
if (!existsSync(tasksPath)) return null;
|
|
157
|
-
return
|
|
165
|
+
return readFileSync(tasksPath, 'utf8').split('\n')
|
|
166
|
+
.map(line => parseTaskLine(line)).filter(Boolean).length;
|
|
158
167
|
}
|
|
159
168
|
|
|
160
169
|
function result(availableModes, mode, reasons, facts) {
|
|
@@ -33,6 +33,7 @@ import { getOverlayPaths } from './sdd-overlay.mjs';
|
|
|
33
33
|
import { detectGlaf4Delegation, detectTechStack, writeGlaf4DevConfig } from './conventions-generator.mjs';
|
|
34
34
|
import { detectWorkspaceRoot, getGitRoot, resolveCodeRepos } from './git-utils.mjs';
|
|
35
35
|
import { loadConfig } from './config-loader.mjs';
|
|
36
|
+
import { parseTaskLine, stripInlineEmphasis } from './md-normalize.mjs';
|
|
36
37
|
|
|
37
38
|
// DP-4 委托模式合法枚举(对应 glaf4-dev 七模式中可委托 change 的六种)
|
|
38
39
|
export const GLAF4_DELEGATION_MODES = [
|
|
@@ -372,8 +373,18 @@ export function recordPartialWrites(changeDir, runDir) {
|
|
|
372
373
|
return { ok: true, partialWrites, count: partialWrites.length };
|
|
373
374
|
}
|
|
374
375
|
|
|
376
|
+
/**
|
|
377
|
+
* `· targets:` 注解判定(`·` 是结构标记,不放宽;容忍 `**targets**` 加粗与全角冒号)。
|
|
378
|
+
*/
|
|
379
|
+
function hasTargetsAnnotation(text) {
|
|
380
|
+
return /·\s*targets\s*[::]/.test(stripInlineEmphasis(text));
|
|
381
|
+
}
|
|
382
|
+
|
|
375
383
|
/**
|
|
376
384
|
* task 完成度对账(审计输出):tasks.md targets 注解统计
|
|
385
|
+
*
|
|
386
|
+
* v0.55.0 §8.4.3 横展(FB-4):识别走共享原语 `parseTaskLine`——容忍 `*` bullet、
|
|
387
|
+
* 制表符分隔与缩进(原本 `^- \[` 不容缩进)。**计数语义**(约束③:派生数字会变)。
|
|
377
388
|
* @param {string} changeDir change 目录
|
|
378
389
|
* @returns {object} { ok, totalTasks, doneTasks, annotatedTasks, missingAnnotation }
|
|
379
390
|
*/
|
|
@@ -383,10 +394,10 @@ export function verifyTasks(changeDir) {
|
|
|
383
394
|
return { ok: false, totalTasks: 0, doneTasks: 0, annotatedTasks: 0, missingAnnotation: 0 };
|
|
384
395
|
}
|
|
385
396
|
const content = readFileSync(tasksPath, 'utf-8');
|
|
386
|
-
const taskLines = content.split('\n').
|
|
397
|
+
const taskLines = content.split('\n').map(line => parseTaskLine(line)).filter(Boolean);
|
|
387
398
|
const totalTasks = taskLines.length;
|
|
388
|
-
const doneTasks = taskLines.filter(
|
|
389
|
-
const annotatedTasks = taskLines.filter(
|
|
399
|
+
const doneTasks = taskLines.filter(task => task.checked).length;
|
|
400
|
+
const annotatedTasks = taskLines.filter(task => hasTargetsAnnotation(task.text)).length;
|
|
390
401
|
|
|
391
402
|
return {
|
|
392
403
|
ok: doneTasks === totalTasks && totalTasks > 0,
|
package/scripts/lib/hash.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { findCanonicalSpecFiles } from './spec-paths.mjs';
|
|
6
|
+
import { parseTaskLine } from './md-normalize.mjs';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Normalize tasks.md checkbox state: `- [x]` / `- [X]` → `- [ ]`.
|
|
@@ -12,9 +13,20 @@ import { findCanonicalSpecFiles } from './spec-paths.mjs';
|
|
|
12
13
|
* 勾选状态属执行进度、不属规划内容 —— 归一化后入 hash,使"执行期勾选"不再使 plan
|
|
13
14
|
* 过期(否则勾选后 recordReview 因 plan stale 抛错,形成 closing 死锁)。
|
|
14
15
|
* 任务文本、数量与结构的变更仍会改变 hash。
|
|
16
|
+
*
|
|
17
|
+
* v0.55.0 §8.4.3 横展(FB-4):识别改用共享原语 `parseTaskLine`(容忍 `*` bullet /
|
|
18
|
+
* 缩进 / 制表符等纯格式变体),但**语义保持"替换式"**——只把复选框标记改回 `[ ]`,
|
|
19
|
+
* 行的其余字符(缩进、bullet 符号、分隔空白、正文、行尾 `\r`)**逐字节保留**,
|
|
20
|
+
* 因此该函数**不是**计数函数、不重建行。
|
|
15
21
|
*/
|
|
16
22
|
export function normalizeCheckboxes(content) {
|
|
17
|
-
return content.
|
|
23
|
+
return content.split('\n').map((line) => {
|
|
24
|
+
const task = parseTaskLine(line);
|
|
25
|
+
if (!task || !task.checked) return line;
|
|
26
|
+
const at = line.indexOf('[');
|
|
27
|
+
if (at === -1) return line;
|
|
28
|
+
return `${line.slice(0, at + 1)} ${line.slice(at + 2)}`;
|
|
29
|
+
}).join('\n');
|
|
18
30
|
}
|
|
19
31
|
|
|
20
32
|
/**
|
|
@@ -130,9 +142,13 @@ export function isContractFresh(changeDir, stateLoader) {
|
|
|
130
142
|
return storedHash === currentHash;
|
|
131
143
|
}
|
|
132
144
|
|
|
145
|
+
// v0.55.0 §8.4.3 横展(FB-4):容忍全角冒号(`artifacts_hash:sha256:…`)。
|
|
146
|
+
// 这是 `isContractFresh` 的判据——解析不出即判 stale,静默且后果重(closing 死锁)。
|
|
147
|
+
// 仅放宽冒号形态(与 `md-normalize.matchKeyValue` 的全角冒号约定一致),
|
|
148
|
+
// 不引入缩进等结构语义容忍(约束①)。
|
|
133
149
|
function extractYamlField(content, field) {
|
|
134
150
|
for (const line of content.split('\n')) {
|
|
135
|
-
const match = line.match(new RegExp(`^${field}
|
|
151
|
+
const match = line.match(new RegExp(`^${field}[::]\\s*(.*)`));
|
|
136
152
|
if (match) {
|
|
137
153
|
const val = match[1].trim();
|
|
138
154
|
return val === 'null' || val === '' ? null : val;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// md-normalize.mjs — 手写 Markdown 的解析容错共享层(v0.55.0)
|
|
2
|
+
//
|
|
3
|
+
// ── 为什么存在(根因,workflow-feedback 2026-09-11 FB-4)──────────────────────
|
|
4
|
+
// 解析层默认"用户会按规范格式书写",对最常见的 Markdown 强调语法(`**x**`、
|
|
5
|
+
// 反引号、表格)脆弱,且**失败静默**。实测:`- **contract**: v1` 与 `` `contract`: v1 ``
|
|
6
|
+
// 都解析不出值,被判 unset → guard 降级、primer 头部错报,全程无任何提示。
|
|
7
|
+
//
|
|
8
|
+
// 该缺陷是**复发型**,不是孤立疏漏——同型 bug 已修过两次,且两次都未横展:
|
|
9
|
+
// - arch-parse.mjs:58-66(v0.53.0 §105.2.2):表格单元格缺 stripEmphasis
|
|
10
|
+
// → 新增端点 `update/status` / `reset/secret` 整行漏提取
|
|
11
|
+
// - test-merge.mjs:79-89(feedback 2026-08-05):正则未容忍 `\*{0,2}`
|
|
12
|
+
// → 手写加粗致 Total cases 计数归零
|
|
13
|
+
//
|
|
14
|
+
// 本模块是**全仓库唯一的 Markdown 归一化约定**(单一真相源)。新解析点一律复用,
|
|
15
|
+
// 不再各写各的正则——避免第三次复发。
|
|
16
|
+
//
|
|
17
|
+
// ── 三条安全约束(设计 §8.4.3,D-16「全修」决策的配套)───────────────────────
|
|
18
|
+
// ① 宽容 ≠ 放宽语义:只容忍**纯格式变体**(`**`/反引号/全角冒号/冒号前空格),
|
|
19
|
+
// **不改变结构语义**。例:YAML 缩进是语义,不得在此层容忍。
|
|
20
|
+
// ② 宽容 + 校验配对,但缺字段一律 WARN(不报错)——避免打断被测试断言的既有契约。
|
|
21
|
+
// ③ 派生数字变更须先出对照表(计数型解析点的宽容会改变判定,见 parseTaskLine 注)。
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 强调标记剥离:`**x**` / `__x__` / `*x*` / `_x_` → `x`(**首尾成对**才剥离;长的优先)。
|
|
25
|
+
*
|
|
26
|
+
* 实现自 arch-parse.mjs(v0.53.0 §105.2.2),此处提为共享层。
|
|
27
|
+
* 适用于**整段/整格**的首尾包裹场景(如 `**\`/path\`**`)。
|
|
28
|
+
*/
|
|
29
|
+
export function stripEmphasis(s) {
|
|
30
|
+
let out = s;
|
|
31
|
+
for (const [open, close] of [['**', '**'], ['__', '__'], ['*', '*'], ['_', '_']]) {
|
|
32
|
+
if (out.length > open.length + close.length && out.startsWith(open) && out.endsWith(close)) {
|
|
33
|
+
out = out.slice(open.length, -close.length).trim();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 行内强调剥离(**全局**,非首尾):`- **contract**: v1` → `- contract: v1`。
|
|
41
|
+
*
|
|
42
|
+
* 与 {@link stripEmphasis} 的分工:后者只处理"整段被一对标记包裹",本函数处理
|
|
43
|
+
* "一行内嵌着一对标记"——这是 key/value 行的常见写法,也是 FB-4 的实际触发形态。
|
|
44
|
+
*
|
|
45
|
+
* **只剥离双符号对(`**` / `__`)**,单符号(`*` / `_`)刻意不动:
|
|
46
|
+
* `*` 可能是列表符、`_` 可能是 snake_case 标识符——剥离它们是"改语义"而非"容格式",
|
|
47
|
+
* 违反安全约束 ①。
|
|
48
|
+
*/
|
|
49
|
+
export function stripInlineEmphasis(text) {
|
|
50
|
+
if (typeof text !== 'string') return '';
|
|
51
|
+
return text.replace(/\*\*(.+?)\*\*/g, '$1').replace(/__(.+?)__/g, '$1');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 行内归一化:去反引号 + 剥强调 + trim。用于单元格与 key/value 两侧。
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeInline(s) {
|
|
58
|
+
if (typeof s !== 'string') return '';
|
|
59
|
+
return stripEmphasis(stripInlineEmphasis(s.replace(/`/g, '')).trim()).trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 宽容匹配 `key: value`,返回首个捕获值或 null。
|
|
64
|
+
*
|
|
65
|
+
* 容忍的写法(FB-4 的 5 种格式变体):
|
|
66
|
+
* a. `**key**: value` —— 加粗(**本函数的主要动机**)
|
|
67
|
+
* b. `key:value` —— 全角冒号
|
|
68
|
+
* c. `` `key`: value `` —— 反引号
|
|
69
|
+
* d. `key : value` —— 冒号前空格
|
|
70
|
+
* (e. 表格形态 `| key | value |` 不在此处理——用 ds-parse 的表格路径)
|
|
71
|
+
*
|
|
72
|
+
* @param {string} text 待搜索文本(整段或整行均可)
|
|
73
|
+
* @param {string} key 键名(可含 `-`,如 `--bg`)
|
|
74
|
+
*/
|
|
75
|
+
export function matchKeyValue(text, key) {
|
|
76
|
+
if (typeof text !== 'string' || !key) return null;
|
|
77
|
+
const normalized = stripInlineEmphasis(text).replace(/`/g, '');
|
|
78
|
+
const escaped = key.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
79
|
+
const m = normalized.match(new RegExp(`${escaped}\\s*[::]\\s*([^;\\n|]+)`, 'i'));
|
|
80
|
+
return m ? m[1].trim() : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 任务行解析**原语**(复选框):`- [ ] x` / `* [x] x` / 缩进子项 → `{ indent, checked, text }`;
|
|
85
|
+
* 非任务行返回 null。
|
|
86
|
+
*
|
|
87
|
+
* **本函数不做语义判定** —— 全仓库 5 处复选框解析语义并不同(原方案误判为"同语义"):
|
|
88
|
+
* - `hash.mjs` 的 `normalizeCheckboxes` 是**替换式归一化**(保证勾选不改变 artifacts_hash)
|
|
89
|
+
* - `tasks-complete.mjs` / `glaf4-delegation.mjs` / `execution-recommendation.mjs` /
|
|
90
|
+
* `infer-workflow.mjs` 是**计数/存在性判定**
|
|
91
|
+
* 故只共享"识别",语义由调用方决定。
|
|
92
|
+
*
|
|
93
|
+
* **安全约束 ③ 提醒**:把本函数接入计数型解析点会**改变派生数字** →
|
|
94
|
+
* `infer-workflow.mjs` 的 `taskCount` 直接决定 hotfix(≤2) / tweak(≤4) / full 档位。
|
|
95
|
+
* 接入前须先出**新旧计数对照表**(构造的格式变体集,见 tests/lib/checkbox-consistency.test.mjs)。
|
|
96
|
+
*
|
|
97
|
+
* **CRLF 容错(`\r?$`,v0.55.0 接入期实测补充)**:接入前,各处用逐行正则计数时
|
|
98
|
+
* `\r` 被当作普通字符(`[ \t]*- \[ \]` 无 `$` 锚)→ CRLF 的 tasks.md 正常计数;
|
|
99
|
+
* 若本函数不容忍行尾 `\r`,接入后这些行会**全部解析失败** → 计数归零/guard 静默放行
|
|
100
|
+
* (比现状更差)。故此处容忍行尾 `\r`(纯格式变体),返回的 `text` 不含 `\r`。
|
|
101
|
+
* 调用方若需**逐字节保留原行**(如 hash 归一化),请自行持有原行、不要用本返回值重建。
|
|
102
|
+
*/
|
|
103
|
+
export function parseTaskLine(line) {
|
|
104
|
+
if (typeof line !== 'string') return null;
|
|
105
|
+
const m = /^([ \t]*)([-*])\s+\[([ xX])\]\s?(.*)\r?$/.exec(line);
|
|
106
|
+
if (!m) return null;
|
|
107
|
+
return { indent: m[1].length, checked: m[3].toLowerCase() === 'x', text: m[4] };
|
|
108
|
+
}
|
|
@@ -19,6 +19,19 @@ import { readFileSync, writeFileSync, existsSync, cpSync, mkdirSync } from 'node
|
|
|
19
19
|
import { join, basename, relative, sep, resolve } from 'node:path';
|
|
20
20
|
import { pathToFileURL } from 'node:url';
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* UX 增量标题(v0.55.0 §8.4.3 横展 FB-4):在 `## UX 增量` 基础上容忍 `**加粗**`
|
|
24
|
+
* 与行尾空白。**不锚行首是既有行为**——`### UX 增量` / `#### **UX 增量**` 修复前
|
|
25
|
+
* 也能命中且正文提取正确,故此处不收紧(收紧会让既有契约文本失同步)。
|
|
26
|
+
*/
|
|
27
|
+
const UX_HEADING_RE = /##[ \t]+\**[ \t]*(?:UX 增量|Prototype Changes|UX Delta)\**[ \t]*\r?\n([\s\S]*?)(?=\n## |\n---|\n$)/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 形似 UX 增量标题但上面未命中(实测为 H1 `# UX 增量`,正则要求两个 `#` 起)——
|
|
31
|
+
* 用于给出不静默的 WARN,避免"整段 UX 增量丢失且只说 nothing to sync"。
|
|
32
|
+
*/
|
|
33
|
+
const UX_HEADING_LIKE_RE = /^#{1,6}[ \t]*\**[ \t]*(?:UX 增量|Prototype Changes|UX Delta)/m;
|
|
34
|
+
|
|
22
35
|
/**
|
|
23
36
|
* 解析 CLI 参数数组为结构化对象
|
|
24
37
|
* @param {string[]} argv - 原始参数数组,如 ['changes/my-change/', '--source', 'delta.md']
|
|
@@ -110,10 +123,15 @@ export function run(args = {}) {
|
|
|
110
123
|
if (existsSync(contractPath)) {
|
|
111
124
|
const contract = readFileSync(contractPath, 'utf-8');
|
|
112
125
|
// 提取 UX 相关章节(## UX 增量 或 ## Prototype Changes)
|
|
113
|
-
|
|
126
|
+
// v0.55.0 §8.4.3 横展(FB-4):修复前 `## **UX 增量**`(加粗)静默降级成
|
|
127
|
+
// "No UX delta found, nothing to sync",整段 UX 增量丢失且无任何提示。
|
|
128
|
+
const uxMatch = contract.match(UX_HEADING_RE);
|
|
114
129
|
if (uxMatch) {
|
|
115
130
|
uxDeltaContent = uxMatch[1].trim();
|
|
116
131
|
uxDeltaSource = contractPath;
|
|
132
|
+
} else if (UX_HEADING_LIKE_RE.test(contract)) {
|
|
133
|
+
// 含关键词但正则未命中(如 H1 `# UX 增量`)→ 显式提示,不再静默
|
|
134
|
+
console.warn(' [WARN] prototype-sync: execution-contract.md 含 UX 增量字样但标题形态不匹配(期望 `## UX 增量`),本次未同步');
|
|
117
135
|
}
|
|
118
136
|
}
|
|
119
137
|
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
} from 'node:fs';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
6
|
import { computeArtifactsHash, normalizeCheckboxes } from './hash.mjs';
|
|
7
|
+
import { parseTaskLine } from './md-normalize.mjs';
|
|
7
8
|
import { readState } from './state-loader.mjs';
|
|
8
9
|
|
|
9
10
|
export const HANDOFF_TYPES = new Set(['prototype', 'research', 'experiment']);
|
|
@@ -28,10 +29,21 @@ export function getOverlayPaths(changeDir) {
|
|
|
28
29
|
export function computeTaskHash(changeDir, taskId) {
|
|
29
30
|
const tasks = readFileSync(join(changeDir, 'tasks.md'), 'utf8');
|
|
30
31
|
const escaped = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
// 任务正文须以 id 开头且后面还有内容(与原 `^...\\s+.+$` 同判据)
|
|
33
|
+
const idRe = new RegExp(`^${escaped}\\s+[\\s\\S]`);
|
|
34
|
+
// v0.55.0 §8.4.3 横展(FB-4):行定位走共享原语 `parseTaskLine`(容忍 `*` bullet /
|
|
35
|
+
// 制表符分隔 / 缩进等纯格式变体)。**本函数保持"找不到即抛错"的硬失败语义**——
|
|
36
|
+
// 定位失败会让 checkpoint 无法落盘,静默放行比抛错危险。
|
|
37
|
+
// hash 仍取**原始行**(仅去行尾 `\\r`,与旧 `match[0]` 一致)→ 既有 checkpoint 不失效。
|
|
38
|
+
const matched = tasks.split('\n')
|
|
39
|
+
.map(line => line.replace(/\r$/, ''))
|
|
40
|
+
.find((line) => {
|
|
41
|
+
const task = parseTaskLine(line);
|
|
42
|
+
return task ? idRe.test(task.text) : false;
|
|
43
|
+
});
|
|
44
|
+
if (matched === undefined) throw new Error(`Task '${taskId}' was not found in tasks.md`);
|
|
33
45
|
// v0.49.0 §83.3.1:勾选状态归一化 —— 勾选是执行进度,不应使 checkpoint 变 stale
|
|
34
|
-
return `sha256:${createHash('sha256').update(normalizeCheckboxes(
|
|
46
|
+
return `sha256:${createHash('sha256').update(normalizeCheckboxes(matched)).digest('hex')}`;
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
export function saveCheckpoint(changeDir, input) {
|
|
@@ -22,6 +22,7 @@ import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } fr
|
|
|
22
22
|
import { join, basename } from 'node:path';
|
|
23
23
|
import { pathToFileURL } from 'node:url';
|
|
24
24
|
import { meetsMinSeverity, nextSeverity } from './severity.mjs';
|
|
25
|
+
import { normalizeInline } from './md-normalize.mjs';
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* 剥离 YAML 行尾注释(空格 + `#` 起始)——v0.23 §91 硬化:
|
|
@@ -39,9 +40,12 @@ function parseFrontmatter(content) {
|
|
|
39
40
|
if (!match) return null;
|
|
40
41
|
const fm = {};
|
|
41
42
|
for (const line of match[1].split('\n')) {
|
|
42
|
-
|
|
43
|
+
// v0.55.0 §8.4.3 横展(FB-4):容忍全角冒号、`**key**` 加粗与反引号
|
|
44
|
+
// (走共享层 `normalizeInline`)。原实现只认半角 `:` + 裸键名,
|
|
45
|
+
// `severity:high` / `**severity**: high` 会整键丢失 → 该条静默不晋升。
|
|
46
|
+
const idx = line.search(/[::]/);
|
|
43
47
|
if (idx > 0) {
|
|
44
|
-
fm[line.slice(0, idx)
|
|
48
|
+
fm[normalizeInline(line.slice(0, idx))] = normalizeInline(stripInlineComment(line.slice(idx + 1)));
|
|
45
49
|
}
|
|
46
50
|
}
|
|
47
51
|
return fm;
|
|
@@ -100,10 +104,13 @@ function markEntryConfirmed(filePath) {
|
|
|
100
104
|
const content = readFileSync(filePath, 'utf-8');
|
|
101
105
|
const block = content.match(/^---\n([\s\S]*?)\n---/);
|
|
102
106
|
if (!block) return;
|
|
103
|
-
|
|
107
|
+
// v0.55.0 §8.4.3 横展(FB-4):容忍全角冒号与 `**confirmed**` 加粗。
|
|
108
|
+
// 放宽前这类行匹配不到 → 走追加分支 → frontmatter 出现**重复 confirmed 键**
|
|
109
|
+
// (计数错乱且静默)。命中后统一改回规范形态 `confirmed: N`。
|
|
110
|
+
const counter = block[1].match(/^\**confirmed\**\s*[::]\s*(\d+)\s*$/m);
|
|
104
111
|
const next = counter ? parseInt(counter[1], 10) + 1 : 2;
|
|
105
112
|
const frontmatter = counter
|
|
106
|
-
? block[1].replace(
|
|
113
|
+
? block[1].replace(/^\**confirmed\**\s*[::]\s*\d+\s*$/m, `confirmed: ${next}`)
|
|
107
114
|
: `${block[1]}\nconfirmed: ${next}`;
|
|
108
115
|
writeFileSync(filePath, content.replace(block[0], `---\n${frontmatter}\n---`), 'utf-8');
|
|
109
116
|
}
|
|
@@ -14,8 +14,40 @@
|
|
|
14
14
|
// 5. 主基格式不做规范化:按 `### Requirement:` 为块边界解析,兼容既有主基的
|
|
15
15
|
// `## ADDED Requirements` 容器形态(不改写既有段落结构)。
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
import { stripInlineEmphasis } from './md-normalize.mjs';
|
|
18
|
+
|
|
19
|
+
// v0.55.0 §8.4.3 横展(FB-4):标题行先过 `stripInlineEmphasis` 再匹配——容忍
|
|
20
|
+
// `### **Requirement**: X` 这类手写加粗;同时容忍全角冒号与冒号前空格
|
|
21
|
+
// (`md-normalize.matchKeyValue` 的同一约定)。**不放宽层级**:仍是恰好 `###`。
|
|
22
|
+
const REQ_RE = /^###\s*Requirement\s*[::]\s*(.+?)\s*$/;
|
|
18
23
|
const H2_RE = /^##\s+/;
|
|
24
|
+
/** 形似 Requirement 标题(带分隔符)——用于「解析失败不静默」告警。 */
|
|
25
|
+
const REQUIREMENT_LIKE_RE = /^#{1,6}\s*\**\s*Requirement\s*\**\s*[::]/i;
|
|
26
|
+
|
|
27
|
+
/** 归一化后匹配 requirement 标题:容忍加粗等纯格式变体(FB-4 横展)。 */
|
|
28
|
+
function matchRequirement(line) {
|
|
29
|
+
return stripInlineEmphasis(line).match(REQ_RE);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 解析失败不静默(约束②):形似 requirement 标题但未被接受的 → WARN(不报错)。 */
|
|
33
|
+
function warnUnparsedRequirementHeadings(content) {
|
|
34
|
+
const lines = content.split('\n');
|
|
35
|
+
for (let i = 0; i < lines.length; i++) {
|
|
36
|
+
if (!REQUIREMENT_LIKE_RE.test(lines[i]) || matchRequirement(lines[i])) continue;
|
|
37
|
+
console.warn(` [WARN] spec-merge: 第 ${i + 1} 行形似 Requirement 标题但无法解析(按非标题处理):${lines[i].trim().slice(0, 80)}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 名称比较键:容忍加粗/反引号/首尾空白等**纯格式**差异。
|
|
43
|
+
*
|
|
44
|
+
* 必要性:delta 侧解析器(`src/parsing/requirement-blocks.ts` 的
|
|
45
|
+
* `normalizeRequirementName`)**只做 trim**,主基侧若单边归一化,`**Foo**` 与 `Foo`
|
|
46
|
+
* 会被判为不同名 → ADDED 静默追加重复块 / MODIFIED 误报「不存在」。故比较时两侧同键。
|
|
47
|
+
*/
|
|
48
|
+
function nameKey(name) {
|
|
49
|
+
return stripInlineEmphasis(String(name ?? '').replace(/`/g, '')).trim();
|
|
50
|
+
}
|
|
19
51
|
|
|
20
52
|
/** dist 解析器惰性加载(与 cmd-sync 导入 Validator 同源)。 */
|
|
21
53
|
let _parseDeltaSpec = null;
|
|
@@ -39,7 +71,7 @@ function blockBody(blockLines) {
|
|
|
39
71
|
|
|
40
72
|
/** 切掉 `#### Previous version` 子节(及其后内容)——用于幂等比对与旧内容提取。 */
|
|
41
73
|
function stripPreviousVersion(body) {
|
|
42
|
-
const idx = body.search(/^####\s
|
|
74
|
+
const idx = body.search(/^####\s+\**\s*Previous version\b/m);
|
|
43
75
|
return idx === -1 ? body : body.slice(0, idx).replace(/\s+$/, '');
|
|
44
76
|
}
|
|
45
77
|
|
|
@@ -53,8 +85,8 @@ function escapeRe(s) {
|
|
|
53
85
|
* `v1-C3-session-governance` 的注记)。
|
|
54
86
|
*/
|
|
55
87
|
function hasMergedNote(body, changeName) {
|
|
56
|
-
const re = new RegExp(`^####\\s
|
|
57
|
-
return re.test(body);
|
|
88
|
+
const re = new RegExp(`^####\\s+\\**\\s*Previous version\\b.*(?<![\\w-])${escapeRe(changeName)}(?![\\w-])`, 'm');
|
|
89
|
+
return re.test(stripInlineEmphasis(body));
|
|
58
90
|
}
|
|
59
91
|
|
|
60
92
|
function renameNote(from, changeName) {
|
|
@@ -62,7 +94,7 @@ function renameNote(from, changeName) {
|
|
|
62
94
|
}
|
|
63
95
|
|
|
64
96
|
function isRemovedSection(section) {
|
|
65
|
-
return typeof section === 'string' && /^Removed\b/i.test(section.trim());
|
|
97
|
+
return typeof section === 'string' && /^Removed\b/i.test(stripInlineEmphasis(section).trim());
|
|
66
98
|
}
|
|
67
99
|
|
|
68
100
|
/**
|
|
@@ -81,14 +113,14 @@ function parseMain(content) {
|
|
|
81
113
|
i++;
|
|
82
114
|
continue;
|
|
83
115
|
}
|
|
84
|
-
const m = lines[i]
|
|
116
|
+
const m = matchRequirement(lines[i]);
|
|
85
117
|
if (!m) {
|
|
86
118
|
i++;
|
|
87
119
|
continue;
|
|
88
120
|
}
|
|
89
121
|
let end = lines.length;
|
|
90
122
|
for (let j = i + 1; j < lines.length; j++) {
|
|
91
|
-
if (
|
|
123
|
+
if (matchRequirement(lines[j]) || H2_RE.test(lines[j])) {
|
|
92
124
|
end = j;
|
|
93
125
|
break;
|
|
94
126
|
}
|
|
@@ -99,9 +131,10 @@ function parseMain(content) {
|
|
|
99
131
|
return { lines, blocks };
|
|
100
132
|
}
|
|
101
133
|
|
|
102
|
-
/** 在正常区(非 `## Removed` 段)按名查找 requirement
|
|
134
|
+
/** 在正常区(非 `## Removed` 段)按名查找 requirement 块(名称按 `nameKey` 宽松比较)。 */
|
|
103
135
|
function findBlock(parsed, name) {
|
|
104
|
-
|
|
136
|
+
const key = nameKey(name);
|
|
137
|
+
return parsed.blocks.find(b => nameKey(b.name) === key && !isRemovedSection(b.section)) || null;
|
|
105
138
|
}
|
|
106
139
|
|
|
107
140
|
/**
|
|
@@ -122,7 +155,7 @@ function findAppendIndex(parsed) {
|
|
|
122
155
|
|
|
123
156
|
/** 定位 `## Removed` 段的 [start, end)(end = 下一个二级标题或文件末尾);无则 -1。 */
|
|
124
157
|
function findRemovedSectionRange(parsed) {
|
|
125
|
-
const start = parsed.lines.findIndex(l => /^##\s
|
|
158
|
+
const start = parsed.lines.findIndex(l => /^##\s+\**\s*Removed\**\s*$/i.test(stripInlineEmphasis(l)));
|
|
126
159
|
if (start === -1) return { start: -1, end: -1 };
|
|
127
160
|
let end = parsed.lines.length;
|
|
128
161
|
for (let j = start + 1; j < parsed.lines.length; j++) {
|
|
@@ -235,7 +268,8 @@ function applyRemoved(content, plan, changeName, today, report) {
|
|
|
235
268
|
|
|
236
269
|
if (!target) {
|
|
237
270
|
// 幂等:该名已出现在 `## Removed` 段
|
|
238
|
-
const
|
|
271
|
+
const key = nameKey(name);
|
|
272
|
+
const inRemoved = parsed.blocks.some(b => nameKey(b.name) === key && isRemovedSection(b.section));
|
|
239
273
|
if (inRemoved) {
|
|
240
274
|
report.skipped++;
|
|
241
275
|
continue;
|
|
@@ -300,6 +334,7 @@ export async function mergeMainSpec(mainContent, deltaContent, changeName, optio
|
|
|
300
334
|
}
|
|
301
335
|
|
|
302
336
|
const report = { renamed: 0, modified: 0, added: 0, removed: 0, skipped: 0, noOps: false };
|
|
337
|
+
warnUnparsedRequirementHeadings(mainContent);
|
|
303
338
|
let content = mainContent;
|
|
304
339
|
content = applyRenamed(content, plan, changeName, report);
|
|
305
340
|
content = applyModified(content, plan, changeName, today, report);
|