fix(議題解析): upsertLineInSection 限定在目標段落內

三個實際重現過的污染情境:

- 圍欄裡的 `## 關聯` 被當成真標題,估算插進圍欄後面。本檔的共同前提是「圍欄裡的
  東西不是內容」,這支卻自己用 indexOf 找標題,繞過了那個判斷。
- `## 關聯度說明` 被 indexOf 當成 `## 關聯` 命中,改到別人的段落。
- 「這一行是否已存在」用整份 body 比對,於是別的段落剛好有 `估算人天:` 時被改掉,
  真正的關聯段落反而一直拿不到值。

改成沿用同檔的 eachLine 走行、標題要完全相同、既有那一行只在段落範圍內找。
弄錯的代價是靜靜改壞別人的內容,所以三道判斷都收緊。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 06:06:09 +00:00
co-authored by Claude Opus 5
parent 257642cb1c
commit e494bd534e
+37 -15
View File
@@ -146,6 +146,12 @@ function isSeparator(cells) {
* 在指定段落裡就地更新(或補上)一行「前綴+值」。 * 在指定段落裡就地更新(或補上)一行「前綴+值」。
* *
* 用於人天估算這種「議題上只該有一行、重跑要覆蓋而不是累積」的欄位。 * 用於人天估算這種「議題上只該有一行、重跑要覆蓋而不是累積」的欄位。
*
* 三件事都限定在目標段落之內,因為弄錯的代價是靜靜改壞別人的內容:
* - 標題要完全相同才算數,`## 關聯度說明` 不是 `## 關聯`。
* - 圍欄裡的假標題不算標題,沿用本檔共同的圍欄判斷。
* - 找既有那一行只在段落範圍內找,別的段落剛好有同前綴時不會被改掉。
*
* 段落不存在時補在 body 結尾——寧可放錯位置,也不要讓值靜靜消失。 * 段落不存在時補在 body 結尾——寧可放錯位置,也不要讓值靜靜消失。
* *
* @param {string} body 議題 body * @param {string} body 議題 body
@@ -155,26 +161,42 @@ function isSeparator(cells) {
*/ */
export function upsertLineInSection(body, section, line) { export function upsertLineInSection(body, section, line) {
const prefix = line.slice(0, line.indexOf(':') + 1); const prefix = line.slice(0, line.indexOf(':') + 1);
const existing = new RegExp(`^${escapeRegExp(prefix)}.*$`, 'm'); const rows = [...eachLine(body)];
const { start, end } = sectionBounds(rows, section);
if (existing.test(body)) {
const updated = body.replace(existing, line);
return updated;
}
const heading = `## ${section}`;
const start = body.indexOf(heading);
if (start === -1) { if (start === -1) {
return `${body.replace(/\n*$/, '')}\n\n${line}\n`; return `${body.replace(/\n*$/, '')}\n\n${line}\n`;
} }
// 段落的結尾是下一個標題之前,或整份 body 的結尾 const text = rows.map((row) => row.line);
const after = body.indexOf('\n## ', start + heading.length); for (let i = start + 1; i < end; i += 1) {
const end = after === -1 ? body.length : after; if (rows[i].inFence || !text[i].startsWith(prefix)) continue;
const head = body.slice(0, end).replace(/\n*$/, ''); if (text[i] === line) return body;
return `${head}\n${line}\n${body.slice(end).replace(/^\n+/, '\n')}`; text[i] = line;
return text.join('\n');
}
// 插在段落內容的結尾,跳過段落與段落之間的空行
let insertAt = end;
while (insertAt > start + 1 && text[insertAt - 1].trim() === '') insertAt -= 1;
text.splice(insertAt, 0, line);
return text.join('\n');
} }
function escapeRegExp(text) { /** 找出段落的起訖行號;start 為標題那一行,end 為下一個標題(或結尾) */
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); function sectionBounds(rows, section) {
let start = -1;
for (let i = 0; i < rows.length; i += 1) {
if (rows[i].inFence) continue;
const heading = rows[i].line.match(/^##\s+(.+?)\s*$/);
if (!heading) continue;
if (start === -1) {
if (heading[1] === section) start = i;
continue;
}
return { start, end: i };
}
return { start, end: rows.length };
} }