From 26ce9f872fa1d54d4d361e0ac799b6df8e385be8 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Tue, 1 Sep 2026 19:48:06 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(wiki):=20=E6=96=B0=E5=A2=9E=20remark-ma?= =?UTF-8?q?th-sanitize=20=E6=8F=92=E4=BB=B6=E5=87=80=E5=8C=96=20PDF=20?= =?UTF-8?q?=E8=AF=AD=E6=96=99=E7=97=85=E6=80=81=E5=85=AC=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?=E6=B6=88=E9=99=A4=E6=9E=84=E5=BB=BA=E6=9C=9F=20KaTeX=20?= =?UTF-8?q?=E5=91=8A=E8=AD=A6=E4=B8=8E=E6=B8=B2=E6=9F=93=E7=BC=BA=E9=99=B7?= =?UTF-8?q?;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 货币 \$ 误配对(数学体含 CJK)降级回正文文本,消除百余条 unicodeTextInMathMode 告警 - 未转义 % 补 \%,修复 TeX 注释符吞掉渲染内容(11.5%→15.0% 只显示 11.5) - ‖/∥/• 归一为 \Vert/\parallel/\bullet,消除 unknownSymbol 与 tofu 渲染 - display 公式下标裸 CJK 包进 \text{},走文本模式渲染 - 关键路径:mdast-util-math 把公式值存两份(node.value + data.hChildren 内 hast text 副本,层级 inlineMath/display 不同),须递归同步才能到达 KaTeX - 真实 135 篇内容构建复验:KaTeX 告警 164 → 0,产物 HTML 抽查四类修复全部正确 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- .../components/markdown/MarkdownRenderer.tsx | 3 +- .../markdown/remark-math-sanitize.ts | 133 ++++++++++++++++++ .../components/MarkdownRenderer.test.tsx | 86 +++++++++++ 3 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts diff --git a/apps/negentropy-wiki/src/components/markdown/MarkdownRenderer.tsx b/apps/negentropy-wiki/src/components/markdown/MarkdownRenderer.tsx index b27bba93f..dcfba8097 100644 --- a/apps/negentropy-wiki/src/components/markdown/MarkdownRenderer.tsx +++ b/apps/negentropy-wiki/src/components/markdown/MarkdownRenderer.tsx @@ -7,6 +7,7 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import rehypeKatex from "rehype-katex"; import rehypeHighlight from "rehype-highlight"; import { rehypeNotranslate } from "./rehype-notranslate"; +import { remarkMathSanitize } from "./remark-math-sanitize"; import { CodeBlock } from "./CodeBlock"; import { AnchorHeading } from "./AnchorHeading"; import { ResponsiveTable } from "./ResponsiveTable"; @@ -59,7 +60,7 @@ export function MarkdownRenderer({ content }: MarkdownRendererProps) { // Snapshot 锚定 + MutationObserver 自动重应用 + CSS Highlight API 渲染。
{ + if (n.type === "text" && typeof n.value === "string") { + n.value = value; + return; + } + for (const child of n.children ?? []) sync(child); + }; + for (const child of node.data?.hChildren ?? []) sync(child); +} + +const CJK_RE = /[㐀-鿿豈-﫿]/; + +/** 剥离 `\text{...}` / `\textbf{...}` 等文本宏片段,仅在剩余「数学体」部分做判据检验。 */ +function stripTextMacros(src: string): string { + return src.replace(/\\(?:text|textbf|textit|texttt|mathrm|mbox)\s*\{[^{}]*\}/g, ""); +} + +/** 转义数学体中未转义的 `%`(TeX 注释符)。 */ +function escapePercent(src: string): string { + return src.replace(/(? (tree) => { + visit(tree, (node, index, parent) => { + if (!isMathNode(node)) return; + // visit 回调的 parent 经泛型推断为 never(与 rehype-notranslate 的 hast 类型坑同款), + // 经 unknown 最小收窄为可写结构。 + const holder = parent as unknown as ParentLike | undefined; + if (index == null || !holder) return; + + // 1. 货币误配对降级:数学体含 CJK → 还原为正文文本(含字面 `$` 定界符)。 + if (node.type === "inlineMath" && CJK_RE.test(stripTextMacros(node.value))) { + holder.children[index] = { type: "text", value: `$${node.value}$` }; + return; + } + + // 2 & 3. `%` 转义 + 符号归一,均限于数学体(`\text{...}` 内保持原样)。 + const cleaned = replaceOutsideTextMacros(node.value, (s) => + normalizeSymbols(escapePercent(s)), + ); + if (cleaned !== node.value) setMathValue(node, cleaned); + }); +}; + +/** 仅对 `\text{...}` 片段之外的部分应用 transform,文本宏片段原样保留。 */ +function replaceOutsideTextMacros(src: string, transform: (s: string) => string): string { + const TEXT_MACRO_RE = /\\(?:text|textbf|textit|texttt|mathrm|mbox)\s*\{[^{}]*\}/g; + let out = ""; + let last = 0; + for (const m of src.matchAll(TEXT_MACRO_RE)) { + const at = m.index ?? 0; + out += transform(src.slice(last, at)) + m[0]; + last = at + m[0].length; + } + return out + transform(src.slice(last)); +} + +export default remarkMathSanitize; diff --git a/apps/negentropy-wiki/tests/components/MarkdownRenderer.test.tsx b/apps/negentropy-wiki/tests/components/MarkdownRenderer.test.tsx index aceda09c0..0f4dbd17a 100644 --- a/apps/negentropy-wiki/tests/components/MarkdownRenderer.test.tsx +++ b/apps/negentropy-wiki/tests/components/MarkdownRenderer.test.tsx @@ -126,4 +126,90 @@ describe("MarkdownRenderer", () => { expect(katex?.classList.contains("notranslate")).toBe(true); }); }); + + // remark-math-sanitize:净化 PDF 提取语料的病态公式节点(货币误配对/未转义 %/Unicode 符号)。 + describe("remark-math-sanitize", () => { + it("货币 $ 误配对(数学体含 CJK)降级回正文文本,$ 字面可见", () => { + // 真实语料形态:相邻货币 $ 被 remark-math 配成 inlineMath,中间中文全进 math mode。 + const md = "按输入 $3/ 百万 token 、输出 $15/ 百万 token 的示例价格计算"; + const { container } = render(); + + expect(container.querySelector(".katex")).toBeNull(); + const text = container.querySelector(".wiki-markdown-body")?.textContent ?? ""; + expect(text).toContain("$3/ 百万 token 、输出 $15/ 百万 token"); + }); + + it("正常行内公式(数学体无 CJK)不受影响", () => { + const md = "质能方程 $E=mc^2$ 广为人知。"; + const { container } = render(); + + expect(container.querySelector(".katex")).not.toBeNull(); + }); + + it("\\text{中文} 是合法用法,不触发 CJK 误降级", () => { + const md = "向量投影 $\\text{A 在 B 上的投影} = \\frac{\\text{点积}}{\\text{B 的长度}}$"; + const { container } = render(); + + expect(container.querySelector(".katex")).not.toBeNull(); + }); + + it("未转义的 % 被转义,渲染不再吞掉其后内容(commentAtEnd 修复)", () => { + const md = "$11.5%\\to15.0%$"; + const { container } = render(); + + const katex = container.querySelector(".katex"); + expect(katex).not.toBeNull(); + // 修复前 % 后内容被当 TeX 注释吞掉,只剩 11.5;修复后应完整可见。 + expect(katex?.textContent).toContain("15.0"); + expect(katex?.textContent).toContain("%"); + }); + + it("‖ 归一为 \\Vert,公式正常渲染(unknownSymbol 修复)", () => { + const md = "KL 散度不对称:KL $(P‖Q) \\neq$ KL $(Q‖P)$"; + const { container } = render(); + + const maths = container.querySelectorAll(".katex"); + expect(maths.length).toBe(2); + }); + + it("• 归一为 \\bullet,公式正常渲染", () => { + const md = "$•$ Action model: Primary execution model for tool-"; + const { container } = render(); + + expect(container.querySelector(".katex")).not.toBeNull(); + }); + + it("病态公式净化后渲染零 KaTeX 告警(循证验证)", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const md = [ + "三轮调用总共 $0.022 ——看似很便宜。如果完全没有缓存,三轮输入成本约为 $0.029 ,加上输出后合计约 $0.036", + "$11.5%\\to15.0%$", + "KL $(P‖Q) \\neq$ KL $(Q‖P)$", + "$•$ Action model", + "$$\n d_{L2}^2 = \\underbrace{\\|a\\|^2 + \\|b\\|^2}_{常数 2}\n$$", + ].join("\n\n"); + const { container } = render(); + expect(container.querySelector(".wiki-markdown-body")).not.toBeNull(); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it("display 公式下标裸 CJK 包进 \\text{},渲染为文本模式", () => { + // mdast-util-math 对 display 公式把值存两份(node.value + data.hChildren 内 + // code>text 副本),须同步更新才能到达 KaTeX——本用例锁住该路径。 + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const md = "$$\n d_{L2}^2 = \\underbrace{\\|a\\|^2 + \\|b\\|^2}_{常数 2} - 2\\underbrace{(a\\cdot b)}_{\\text{点积/Cos}}\n$$"; + const { container } = render(); + const anns = [...container.querySelectorAll("annotation")].map((a) => a.textContent ?? ""); + const target = anns.find((t) => t.includes("d_{L2}")); + expect(target).toContain("_{\\text{常数 2}}"); + } finally { + warn.mockRestore(); + } + }); + }); }); From 63029b8754d5c53fcbeac96ea314d9919605066c Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Tue, 1 Sep 2026 22:28:54 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(wiki):=20=E4=BF=AE=E6=AD=A3=20remark-ma?= =?UTF-8?q?th-sanitize=20=E5=9B=9B=E5=A4=84=E5=88=A4=E6=8D=AE=E7=BC=BA?= =?UTF-8?q?=E9=99=B7=EF=BC=8C=E8=A1=A5=E9=BD=90=E5=9B=9E=E5=BD=92=E7=94=A8?= =?UTF-8?q?=E4=BE=8B;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依 KaTeX 0.16.47 源码与实机渲染探测,修正插件四处会造成公式丢失或渲染错误的判据: 1. CJK 区间下界字形混淆:`豈`(U+8C48 普通汉字) 误作 `豈`(U+F900 兼容表意文字), 致区间展开为 U+8C48–U+FAFF、误纳谚文音节与代理项码位 U+D800–U+DFFF。 改以转义码位书写并提取 CJK_CLASS 单一常量,消除两处字面量漂移。 2. `%` 转义误豁免文本模式:KaTeX 的 `%` 是 Lexer 构造期设定的 catcode 14, 与数学/文本模式无关,`\text{增长 50%}` 会注释掉闭合 `}` 直接抛 ParseError (实测渲染 .katex-error)。转义改为作用于整串,不再走 replaceOutsideTextMacros。 3. `(? --- .../markdown/remark-math-sanitize.ts | 48 ++++++++++---- .../components/MarkdownRenderer.test.tsx | 62 +++++++++++++++++++ 2 files changed, 98 insertions(+), 12 deletions(-) diff --git a/apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts b/apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts index eb9268ed2..82458984d 100644 --- a/apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts +++ b/apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts @@ -12,13 +12,15 @@ import { visit } from "unist-util-visit"; * 误配对,降级回正文 text 节点(补回字面 `$`)。仅作用于 inlineMath——真实 * display 公式(`$$` 围栏)无货币配对形态,且误降级代价高(丢公式渲染)。 * 2. `%` 未转义:`$11.5%\to15.0%$` 中 `%` 是 TeX 注释符,渲染吞掉其后内容 - * (commentAtEnd 告警即其末态)。处置:未转义的 `%` → `\%`。 + * (commentAtEnd 告警即其末态)。处置:未转义的 `%` → `\%`,作用于整串—— + * `%` 是 KaTeX 词法层 catcode 14,文本模式内不豁免(见 escapePercent)。 * 3. Unicode 符号无度量:`‖`/`∥`/`•` KaTeX 无字形度量(unknownSymbol + No * character metrics,渲染为 tofu)。处置:映射为 LaTeX 等价命令 * `\Vert`/`\parallel`/`\bullet`(Pandoc texmath 同款归一化思路)。 * * 判据均先剥离 `\text{...}` 片段再检验——`\text{中文}` 是合法用法(正文注释进公式), - * 不得触发 CJK 误判,`\text` 内的符号也保持原样(走浏览器字体)。 + * 不得触发 CJK 误判,`\text` 内的 Unicode 符号也保持原样(走浏览器字体)。 + * 例外是 `%`:它在词法层被吞,`\text{}` 内外一律转义。 */ /** 最小 mdast 节点结构(仅用到 type / value / data.hChildren);避免直接依赖 `mdast` 类型包。 */ @@ -67,16 +69,36 @@ function setMathValue(node: MathNode, value: string): void { for (const child of node.data?.hChildren ?? []) sync(child); } -const CJK_RE = /[㐀-鿿豈-﫿]/; +/** + * CJK 字符类源串:统一表意文字扩展 A 起至基本区(U+3400–U+9FFF)+ 兼容表意文字 + * (U+F900–U+FAFF)。以转义码位书写——兼容区 U+F900 与普通汉字 U+8C48 字形相同码位 + * 不同,字面量易混淆致区间误跨谚文段与代理项。下方两处判据共用此串,避免双处漂移。 + */ +const CJK_CLASS = "[\\u3400-\\u9FFF\\uF900-\\uFAFF]"; +const CJK_RE = new RegExp(CJK_CLASS); + +/** + * KaTeX 文本模式宏全集(`src/functions/text.ts` 的 `names`)+ `\mathrm` / `\mbox`。 + * 遗漏项会使其内的 CJK 逃过剥离,被货币误配对分支判为误配对而整条降级丢渲染。 + */ +const TEXT_MACRO_SOURCE = + "\\\\(?:text|textrm|textsf|texttt|textnormal|textbf|textmd|textit|textup|emph|mathrm|mbox)\\s*\\{[^{}]*\\}"; /** 剥离 `\text{...}` / `\textbf{...}` 等文本宏片段,仅在剩余「数学体」部分做判据检验。 */ function stripTextMacros(src: string): string { - return src.replace(/\\(?:text|textbf|textit|texttt|mathrm|mbox)\s*\{[^{}]*\}/g, ""); + return src.replace(new RegExp(TEXT_MACRO_SOURCE, "g"), ""); } -/** 转义数学体中未转义的 `%`(TeX 注释符)。 */ +/** + * 转义未转义的 `%`(TeX 注释符)。按前导反斜杠奇偶判定——`\\%`(换行符紧邻裸 `%`) + * 中的 `%` 实为未转义,单字符回看 `(? + slashes.length % 2 === 0 ? `${slashes}\\%` : `${slashes}%`, + ); } /** 数学体中 Unicode 符号 → LaTeX 等价命令(KaTeX 有度量,消除 tofu)。 */ @@ -87,7 +109,10 @@ function normalizeSymbols(src: string): string { .replace(/•/g, "\\bullet ") // 下标裸 CJK(`\underbrace{…}_{常数 2}` 这类 PDF 提取的标注)包进 `\text{}`, // 消除逐字 unicodeTextInMathMode 告警(KaTeX 对 \text 内 Unicode 走文本模式)。 - .replace(/(_\{|\^\{)([^{}]*[㐀-鿿豈-﫿][^{}]*)\}/g, "$1\\text{$2}}"); + .replace( + new RegExp(`(_\\{|\\^\\{)([^{}]*${CJK_CLASS}[^{}]*)\\}`, "g"), + "$1\\text{$2}}", + ); } /** 可写父节点结构(demote 时按索引替换 child)。 */ @@ -109,17 +134,16 @@ export const remarkMathSanitize: Plugin = () => (tree) => { return; } - // 2 & 3. `%` 转义 + 符号归一,均限于数学体(`\text{...}` 内保持原样)。 - const cleaned = replaceOutsideTextMacros(node.value, (s) => - normalizeSymbols(escapePercent(s)), - ); + // 2. `%` 转义作用于整串(catcode 14 不分模式);3. 符号归一仅限数学体 + // (`\text{...}` 内的 Unicode 符号保持原样,走浏览器字体)。 + const cleaned = escapePercent(replaceOutsideTextMacros(node.value, normalizeSymbols)); if (cleaned !== node.value) setMathValue(node, cleaned); }); }; /** 仅对 `\text{...}` 片段之外的部分应用 transform,文本宏片段原样保留。 */ function replaceOutsideTextMacros(src: string, transform: (s: string) => string): string { - const TEXT_MACRO_RE = /\\(?:text|textbf|textit|texttt|mathrm|mbox)\s*\{[^{}]*\}/g; + const TEXT_MACRO_RE = new RegExp(TEXT_MACRO_SOURCE, "g"); let out = ""; let last = 0; for (const m of src.matchAll(TEXT_MACRO_RE)) { diff --git a/apps/negentropy-wiki/tests/components/MarkdownRenderer.test.tsx b/apps/negentropy-wiki/tests/components/MarkdownRenderer.test.tsx index 0f4dbd17a..ca0a72dde 100644 --- a/apps/negentropy-wiki/tests/components/MarkdownRenderer.test.tsx +++ b/apps/negentropy-wiki/tests/components/MarkdownRenderer.test.tsx @@ -211,5 +211,67 @@ describe("MarkdownRenderer", () => { warn.mockRestore(); } }); + + it("\\text{} 内的裸 % 同样被转义(catcode 14 不分模式,否则抛 ParseError)", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const md = "$\\text{增长 50%}$"; + const { container } = render(); + + const katex = container.querySelector(".katex"); + expect(katex).not.toBeNull(); + // 未转义时 % 会注释掉闭合 },KaTeX 抛错并渲染 .katex-error。 + expect(container.querySelector(".katex-error")).toBeNull(); + // KaTeX 文本模式把空格输出为 NBSP(U+00A0),归一后再比对。 + expect(katex?.textContent?.replace(/ /g, " ")).toContain("增长 50%"); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it("换行符 \\\\ 紧邻的裸 % 被识别为未转义(奇偶判定,非单字符回看)", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + // aligned 环境内 \\ 是合法换行(裸 display 模式下才有 newLineInDisplayMode 告警)。 + const md = "$$\n\\begin{aligned} a \\\\% b \\end{aligned}\n$$"; + const { container } = render(); + + // 单字符回看会把 \\% 的 % 误判为已转义,导致 % 及其后内容被当注释吞掉。 + const ann = container.querySelector("annotation")?.textContent ?? ""; + expect(ann).toContain("\\\\\\%"); + expect(container.querySelector(".katex")?.textContent).toContain("%"); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it("已转义的 \\% 不被二次转义(幂等)", () => { + const md = "$11.5\\% \\to 15.0\\%$"; + const { container } = render(); + + const ann = container.querySelector("annotation")?.textContent ?? ""; + expect(ann).not.toContain("\\\\%"); + expect(container.querySelector(".katex")?.textContent).toContain("11.5%"); + }); + + it("\\textrm 等 KaTeX 文本宏内的 CJK 不触发误降级", () => { + // 文本宏白名单若漏项,其内 CJK 会逃过剥离而被货币误配对分支整条降级。 + const md = "梯度 $g = \\textrm{方向导数}$ 与 $h = \\textnormal{常规}$ 及 $k = \\textsf{无衬线}$"; + const { container } = render(); + + expect(container.querySelectorAll(".katex").length).toBe(3); + }); + + it("下标非 CJK 文本(谚文/emoji)不被包进 \\text{}", () => { + // CJK 区间若因字形混淆误写下界,会跨越谚文段与代理项而误纳非 CJK 字符。 + const md = "$$\nd_{한글} + e_{L2}\n$$"; + const { container } = render(); + + const ann = container.querySelector("annotation")?.textContent ?? ""; + expect(ann).toContain("d_{한글}"); + expect(ann).not.toContain("\\text{한글}"); + }); }); }); From 1c97dcce9922a503326e103ca2722dd5f2cc1cf6 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Tue, 1 Sep 2026 23:18:39 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(ui):=20remark-math-sanitize=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=8C=82=E8=BD=BD=E8=87=B3=20negentropy-ui=20?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E9=93=BE=EF=BC=8C=E6=B6=88=E9=99=A4=E4=B8=A4?= =?UTF-8?q?=E7=AB=AF=E6=B8=B2=E6=9F=93=E4=B8=8D=E5=AF=B9=E7=A7=B0;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit negentropy-ui 与 wiki 渲染同一份 PDF 提取语料(知识库文档详情页 / 静态站), 此前仅 wiki 挂载净化插件,两端行为不对称。本次同步挂载。 挂载范围经循证扩大至全局链 defaultRemarkPlugins(非仅文档渲染处):实测 「输入 $3/ 百万 token ,输出 $15/ 百万 token」在未净化链下正文被打乱重复 (`$15` 段渲染成 `$3` 段、中文空格被吞)并触发 5 次 KaTeX 告警——该缺陷源自 remark-math 对相邻货币符的通用误配对,非 PDF 语料专属,而 LLM 回复中报价文本 极常见,故 MessageBubble 等 5 个消费点均需覆盖。 实现要点: - 补 unified@^11.0.5 / unist-util-visit@^5.0.0 显式声明。二者是 remark-math 的 传递依赖,pnpm 严格隔离下未提升至 negentropy-ui,直接复制插件会 TS2307 + 运行时解析失败(同 @types/hast 未 hoist 之坑)。版本与 wiki 端对齐, lockfile 仅新增 2 条声明、复用既有解析,无新包引入。 - 插件采孪生副本而非提为共享包:agents-chat-core 是 AGUI 协议层专属包(tsup 构建), wiki 无 workspace 依赖且以 GitHub Pages 为出口,为单个插件引入构建链爆炸半径过大 (参照 rehype-notranslate 亦为单端持有)。两端文件头互加交叉标注约束单边漂移。 - 修正两端测试中 NBSP 归一化断言,改用显式   转义(字面空格与 NBSP 形近, 与 CJK 字形混淆同源)。 验证:ui 全量 1006 项(122 文件)通过、lint 与 typecheck/typecheck:test 零错误、 next build 成功且 KaTeX 告警计数为 0;wiki 全量 122 项通过无回归;摘除插件做变异 回退确认 4 项新用例(含 DocumentMarkdownRenderer 端到端护栏)均红灯;孪生副本 代码体经 diff 核对逐字节一致。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- apps/negentropy-ui/package.json | 2 + .../DocumentMarkdownRenderer.test.tsx | 26 +++ .../unit/utils/markdown-plugins.test.tsx | 92 ++++++++++ apps/negentropy-ui/utils/markdown-plugins.ts | 13 +- .../utils/remark-math-sanitize.ts | 163 ++++++++++++++++++ .../markdown/remark-math-sanitize.ts | 4 + .../components/MarkdownRenderer.test.tsx | 2 +- pnpm-lock.yaml | 6 + 8 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 apps/negentropy-ui/tests/unit/utils/markdown-plugins.test.tsx create mode 100644 apps/negentropy-ui/utils/remark-math-sanitize.ts diff --git a/apps/negentropy-ui/package.json b/apps/negentropy-ui/package.json index 6439e2264..b8626f009 100644 --- a/apps/negentropy-ui/package.json +++ b/apps/negentropy-ui/package.json @@ -69,6 +69,8 @@ "sonner": "^2.0.7", "tailwind-merge": "^2.6.1", "three-spritetext": "^1.10.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", "zod": "3.24.4" }, "devDependencies": { diff --git a/apps/negentropy-ui/tests/unit/knowledge/DocumentMarkdownRenderer.test.tsx b/apps/negentropy-ui/tests/unit/knowledge/DocumentMarkdownRenderer.test.tsx index 3d168c9c7..db9607833 100644 --- a/apps/negentropy-ui/tests/unit/knowledge/DocumentMarkdownRenderer.test.tsx +++ b/apps/negentropy-ui/tests/unit/knowledge/DocumentMarkdownRenderer.test.tsx @@ -108,4 +108,30 @@ describe("DocumentMarkdownRenderer", () => { expect(figcaption).not.toBeNull(); expect(figcaption?.textContent).toContain("Figure 1"); }); + + it("PDF 语料的病态公式经 remarkMathSanitize 净化后零 KaTeX 告警", () => { + // 与 wiki 端 MarkdownRenderer 共用同一份提取语料,插件链须对齐(判据见 + // utils/remark-math-sanitize.ts 的孪生副本说明)。 + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const content = [ + "按输入 $3/ 百万 token 、输出 $15/ 百万 token 的示例价格计算", + "$11.5%\\to15.0%$", + "KL $(P‖Q) \\neq$ KL $(Q‖P)$", + "$$\n d_{L2}^2 = \\underbrace{\\|a\\|^2 + \\|b\\|^2}_{常数 2}\n$$", + ].join("\n\n"); + const { container } = render( + , + ); + + expect(container.querySelector(".katex-error")).toBeNull(); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); }); diff --git a/apps/negentropy-ui/tests/unit/utils/markdown-plugins.test.tsx b/apps/negentropy-ui/tests/unit/utils/markdown-plugins.test.tsx new file mode 100644 index 000000000..f05cb6b65 --- /dev/null +++ b/apps/negentropy-ui/tests/unit/utils/markdown-plugins.test.tsx @@ -0,0 +1,92 @@ +import { render } from "@testing-library/react"; +import ReactMarkdown from "react-markdown"; +import { defaultRemarkPlugins, defaultRehypePlugins } from "@/utils/markdown-plugins"; + +/** + * remarkMathSanitize 接入 defaultRemarkPlugins 的回归护栏。 + * + * 该插件与 `apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts` + * 是孪生副本,本文件用例与 wiki 端 MarkdownRenderer.test.tsx 的对应用例同步维护。 + */ +function renderMd(md: string) { + return render( +
+ + {md} + +
, + ); +} + +describe("defaultRemarkPlugins · remarkMathSanitize", () => { + it("相邻货币 $ 误配对降级回正文,文本不被打乱重复", () => { + // 未净化时 remark-math 把两个 $ 配成 inlineMath,实测正文被打乱、 + // `$15` 段渲染成 `$3` 段并触发多次 KaTeX 告警。LLM 回复中报价文本极常见。 + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const { container } = renderMd("输入 $3/ 百万 token ,输出 $15/ 百万 token 。"); + + expect(container.querySelector(".katex")).toBeNull(); + const text = container.textContent ?? ""; + expect(text).toContain("$3/ 百万 token"); + expect(text).toContain("$15/ 百万 token"); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it("正常行内公式不受影响", () => { + const { container } = renderMd("质能方程 $E=mc^2$ 广为人知。"); + + expect(container.querySelector(".katex")).not.toBeNull(); + }); + + it("未转义的 % 被转义,其后内容不被当注释吞掉", () => { + const { container } = renderMd("$11.5%\\to15.0%$"); + + const katex = container.querySelector(".katex"); + expect(katex).not.toBeNull(); + expect(katex?.textContent).toContain("15.0"); + expect(katex?.textContent).toContain("%"); + }); + + it("\\text{} 内的裸 % 同样被转义(catcode 14 不分模式)", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const { container } = renderMd("$\\text{增长 50%}$"); + + expect(container.querySelector(".katex")).not.toBeNull(); + expect(container.querySelector(".katex-error")).toBeNull(); + // KaTeX 文本模式把空格输出为 NBSP(U+00A0),归一后再比对。 + expect(container.querySelector(".katex")?.textContent?.replace(/\u00A0/g, " ")) + .toContain("增长 50%"); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it("‖ 归一为 \\Vert,公式正常渲染", () => { + const { container } = renderMd("KL 散度不对称:KL $(P‖Q) \\neq$ KL $(Q‖P)$"); + + expect(container.querySelectorAll(".katex").length).toBe(2); + }); + + it("\\textrm 等 KaTeX 文本宏内的 CJK 不触发误降级", () => { + const { container } = renderMd( + "梯度 $g = \\textrm{方向导数}$ 与 $h = \\textnormal{常规}$ 及 $k = \\textsf{无衬线}$", + ); + + expect(container.querySelectorAll(".katex").length).toBe(3); + }); + + it("下标裸 CJK 包进 \\text{},非 CJK(谚文)不被误包", () => { + const { container } = renderMd("$$\nd_{常数 2} + e_{한글}\n$$"); + + const ann = container.querySelector("annotation")?.textContent ?? ""; + expect(ann).toContain("_{\\text{常数 2}}"); + expect(ann).toContain("e_{한글}"); + expect(ann).not.toContain("\\text{한글}"); + }); +}); diff --git a/apps/negentropy-ui/utils/markdown-plugins.ts b/apps/negentropy-ui/utils/markdown-plugins.ts index fb5936d1d..0bc1fd6c8 100644 --- a/apps/negentropy-ui/utils/markdown-plugins.ts +++ b/apps/negentropy-ui/utils/markdown-plugins.ts @@ -7,9 +7,18 @@ import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; +import { remarkMathSanitize } from "./remark-math-sanitize"; -/** remark 插件链:GFM 扩展 + 数学公式语法解析 */ -export const defaultRemarkPlugins = [remarkGfm, remarkMath]; +/** + * remark 插件链:GFM 扩展 + 数学公式语法解析 + 病态公式净化。 + * + * remarkMathSanitize 须紧随 remarkMath 之后。它修复 remark-math 的通用缺陷, + * 不限 PDF 语料:形如「输入 $3/ 百万 token ,输出 $15/ 百万 token」的相邻货币符 + * 会被配成 inlineMath,实测正文被打乱重复(`$15` 段渲染成 `$3` 段)并触发 5 次 + * KaTeX 告警——LLM 回复中报价文本极常见,故挂在全局链而非仅文档渲染处。 + * 与 wiki 端 MarkdownRenderer 的插件链保持一致。 + */ +export const defaultRemarkPlugins = [remarkGfm, remarkMath, remarkMathSanitize]; /** rehype 插件链:KaTeX 渲染 */ export const defaultRehypePlugins = [rehypeKatex]; diff --git a/apps/negentropy-ui/utils/remark-math-sanitize.ts b/apps/negentropy-ui/utils/remark-math-sanitize.ts new file mode 100644 index 000000000..1225aa1b2 --- /dev/null +++ b/apps/negentropy-ui/utils/remark-math-sanitize.ts @@ -0,0 +1,163 @@ +import type { Plugin } from "unified"; +import { visit } from "unist-util-visit"; + +/** + * remarkMathSanitize —— 净化 PDF 提取语料中 remark-math 产生的病态公式节点, + * 消除 rehype-katex 构建期告警与实际渲染缺陷。须挂在 `remarkMath` 之后。 + * + * ⚠️ 本文件与 `apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts` + * 是**逐字节孪生副本**。两端渲染同一份 PDF 提取语料(wiki 静态站 / UI 知识库文档详情页), + * 判据一旦单边漂移即产生渲染不对称。未提为共享包是因 wiki 无 workspace 依赖且以 + * GitHub Pages 为出口,为单个插件引入构建链的爆炸半径过大(参照 rehype-notranslate + * 亦为单端持有)。**改动此文件时必须同步另一端,并同步两端回归用例。** + * + * 三类病灶与处置: + * 1. 货币 `$` 误配对:正文「按输入 $3/ 百万 token 、输出 $15/ 百万 token」的相邻 + * 货币符被 remark-math 配成 inlineMath,中间中文全进 math mode(KaTeX 逐字告警 + * unicodeTextInMathMode)。处置:值内含 `\text{...}` 之外的 CJK 字符 → 判定 + * 误配对,降级回正文 text 节点(补回字面 `$`)。仅作用于 inlineMath——真实 + * display 公式(`$$` 围栏)无货币配对形态,且误降级代价高(丢公式渲染)。 + * 2. `%` 未转义:`$11.5%\to15.0%$` 中 `%` 是 TeX 注释符,渲染吞掉其后内容 + * (commentAtEnd 告警即其末态)。处置:未转义的 `%` → `\%`,作用于整串—— + * `%` 是 KaTeX 词法层 catcode 14,文本模式内不豁免(见 escapePercent)。 + * 3. Unicode 符号无度量:`‖`/`∥`/`•` KaTeX 无字形度量(unknownSymbol + No + * character metrics,渲染为 tofu)。处置:映射为 LaTeX 等价命令 + * `\Vert`/`\parallel`/`\bullet`(Pandoc texmath 同款归一化思路)。 + * + * 判据均先剥离 `\text{...}` 片段再检验——`\text{中文}` 是合法用法(正文注释进公式), + * 不得触发 CJK 误判,`\text` 内的 Unicode 符号也保持原样(走浏览器字体)。 + * 例外是 `%`:它在词法层被吞,`\text{}` 内外一律转义。 + */ + +/** 最小 mdast 节点结构(仅用到 type / value / data.hChildren);避免直接依赖 `mdast` 类型包。 */ +interface MathNode { + type: "inlineMath" | "math"; + value: string; + data?: { + hChildren?: HastLike[]; + [key: string]: unknown; + }; +} + +function isMathNode(node: unknown): node is MathNode { + return ( + typeof node === "object" && + node !== null && + ((node as { type?: unknown }).type === "inlineMath" || + (node as { type?: unknown }).type === "math") && + typeof (node as { value?: unknown }).value === "string" + ); +} + +/** 最小 hast 内容节点(text 或 element,后者含 children)。 */ +interface HastLike { + type?: string; + value?: unknown; + children?: HastLike[]; +} + +/** + * 同步写入公式值:`node.value` 与 `node.data.hChildren` 中的 hast text 副本须一并 + * 更新——mdast-util-math 解析时把值存了两份,remark-rehype 转 hast 走 `data.hChildren` + * (rehype-katex 从 hast 取文本),只改 `node.value` 不会到达 KaTeX。副本层级不定 + * (inlineMath 是 `hChildren[0].value`,display 是 `hChildren[0](code).children[0].value`), + * 递归替换其中的 text 节点。 + */ +function setMathValue(node: MathNode, value: string): void { + node.value = value; + const sync = (n: HastLike): void => { + if (n.type === "text" && typeof n.value === "string") { + n.value = value; + return; + } + for (const child of n.children ?? []) sync(child); + }; + for (const child of node.data?.hChildren ?? []) sync(child); +} + +/** + * CJK 字符类源串:统一表意文字扩展 A 起至基本区(U+3400–U+9FFF)+ 兼容表意文字 + * (U+F900–U+FAFF)。以转义码位书写——兼容区 U+F900 与普通汉字 U+8C48 字形相同码位 + * 不同,字面量易混淆致区间误跨谚文段与代理项。下方两处判据共用此串,避免双处漂移。 + */ +const CJK_CLASS = "[\\u3400-\\u9FFF\\uF900-\\uFAFF]"; +const CJK_RE = new RegExp(CJK_CLASS); + +/** + * KaTeX 文本模式宏全集(`src/functions/text.ts` 的 `names`)+ `\mathrm` / `\mbox`。 + * 遗漏项会使其内的 CJK 逃过剥离,被货币误配对分支判为误配对而整条降级丢渲染。 + */ +const TEXT_MACRO_SOURCE = + "\\\\(?:text|textrm|textsf|texttt|textnormal|textbf|textmd|textit|textup|emph|mathrm|mbox)\\s*\\{[^{}]*\\}"; + +/** 剥离 `\text{...}` / `\textbf{...}` 等文本宏片段,仅在剩余「数学体」部分做判据检验。 */ +function stripTextMacros(src: string): string { + return src.replace(new RegExp(TEXT_MACRO_SOURCE, "g"), ""); +} + +/** + * 转义未转义的 `%`(TeX 注释符)。按前导反斜杠奇偶判定——`\\%`(换行符紧邻裸 `%`) + * 中的 `%` 实为未转义,单字符回看 `(? + slashes.length % 2 === 0 ? `${slashes}\\%` : `${slashes}%`, + ); +} + +/** 数学体中 Unicode 符号 → LaTeX 等价命令(KaTeX 有度量,消除 tofu)。 */ +function normalizeSymbols(src: string): string { + return src + .replace(/‖/g, "\\Vert ") + .replace(/∥/g, "\\parallel ") + .replace(/•/g, "\\bullet ") + // 下标裸 CJK(`\underbrace{…}_{常数 2}` 这类 PDF 提取的标注)包进 `\text{}`, + // 消除逐字 unicodeTextInMathMode 告警(KaTeX 对 \text 内 Unicode 走文本模式)。 + .replace( + new RegExp(`(_\\{|\\^\\{)([^{}]*${CJK_CLASS}[^{}]*)\\}`, "g"), + "$1\\text{$2}}", + ); +} + +/** 可写父节点结构(demote 时按索引替换 child)。 */ +interface ParentLike { + children: { type?: string; value?: string }[]; +} + +export const remarkMathSanitize: Plugin = () => (tree) => { + visit(tree, (node, index, parent) => { + if (!isMathNode(node)) return; + // visit 回调的 parent 经泛型推断为 never(与 rehype-notranslate 的 hast 类型坑同款), + // 经 unknown 最小收窄为可写结构。 + const holder = parent as unknown as ParentLike | undefined; + if (index == null || !holder) return; + + // 1. 货币误配对降级:数学体含 CJK → 还原为正文文本(含字面 `$` 定界符)。 + if (node.type === "inlineMath" && CJK_RE.test(stripTextMacros(node.value))) { + holder.children[index] = { type: "text", value: `$${node.value}$` }; + return; + } + + // 2. `%` 转义作用于整串(catcode 14 不分模式);3. 符号归一仅限数学体 + // (`\text{...}` 内的 Unicode 符号保持原样,走浏览器字体)。 + const cleaned = escapePercent(replaceOutsideTextMacros(node.value, normalizeSymbols)); + if (cleaned !== node.value) setMathValue(node, cleaned); + }); +}; + +/** 仅对 `\text{...}` 片段之外的部分应用 transform,文本宏片段原样保留。 */ +function replaceOutsideTextMacros(src: string, transform: (s: string) => string): string { + const TEXT_MACRO_RE = new RegExp(TEXT_MACRO_SOURCE, "g"); + let out = ""; + let last = 0; + for (const m of src.matchAll(TEXT_MACRO_RE)) { + const at = m.index ?? 0; + out += transform(src.slice(last, at)) + m[0]; + last = at + m[0].length; + } + return out + transform(src.slice(last)); +} + +export default remarkMathSanitize; diff --git a/apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts b/apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts index 82458984d..0d32ace0c 100644 --- a/apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts +++ b/apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts @@ -5,6 +5,10 @@ import { visit } from "unist-util-visit"; * remarkMathSanitize —— 净化 PDF 提取语料中 remark-math 产生的病态公式节点, * 消除 rehype-katex 构建期告警与实际渲染缺陷。须挂在 `remarkMath` 之后。 * + * ⚠️ 本文件与 `apps/negentropy-ui/utils/remark-math-sanitize.ts` 是**逐字节孪生副本**。 + * 两端渲染同一份 PDF 提取语料(wiki 静态站 / UI 知识库文档详情页),判据一旦单边漂移 + * 即产生渲染不对称。**改动此文件时必须同步另一端,并同步两端回归用例。** + * * 三类病灶与处置: * 1. 货币 `$` 误配对:正文「按输入 $3/ 百万 token 、输出 $15/ 百万 token」的相邻 * 货币符被 remark-math 配成 inlineMath,中间中文全进 math mode(KaTeX 逐字告警 diff --git a/apps/negentropy-wiki/tests/components/MarkdownRenderer.test.tsx b/apps/negentropy-wiki/tests/components/MarkdownRenderer.test.tsx index ca0a72dde..88c504bde 100644 --- a/apps/negentropy-wiki/tests/components/MarkdownRenderer.test.tsx +++ b/apps/negentropy-wiki/tests/components/MarkdownRenderer.test.tsx @@ -223,7 +223,7 @@ describe("MarkdownRenderer", () => { // 未转义时 % 会注释掉闭合 },KaTeX 抛错并渲染 .katex-error。 expect(container.querySelector(".katex-error")).toBeNull(); // KaTeX 文本模式把空格输出为 NBSP(U+00A0),归一后再比对。 - expect(katex?.textContent?.replace(/ /g, " ")).toContain("增长 50%"); + expect(katex?.textContent?.replace(/\u00A0/g, " ")).toContain("增长 50%"); expect(warn).not.toHaveBeenCalled(); } finally { warn.mockRestore(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68378a36c..f9bc4c7ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -411,6 +411,12 @@ importers: three-spritetext: specifier: ^1.10.0 version: 1.10.0(three@0.184.0) + unified: + specifier: ^11.0.5 + version: 11.0.5 + unist-util-visit: + specifier: ^5.0.0 + version: 5.1.0 zod: specifier: 3.24.4 version: 3.24.4 From 39709a1b0bba0bd6f1a1cb3e08198ed78e7d6a1a Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Tue, 1 Sep 2026 23:56:58 +0800 Subject: [PATCH 4/4] =?UTF-8?q?ci(twin-files):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=AD=AA=E7=94=9F=E6=96=87=E4=BB=B6=E4=B8=80=E8=87=B4=E6=80=A7?= =?UTF-8?q?=E6=89=A7=E6=B3=95=EF=BC=8C=E6=8A=8A=E5=89=AF=E6=9C=AC=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E4=BB=8E=E4=BA=BA=E5=B7=A5=E7=BA=AA=E5=BE=8B=E5=8D=87?= =?UTF-8?q?=E6=A0=BC=E4=B8=BA=E6=9C=BA=E5=99=A8=E4=BF=9D=E8=AF=81;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remark-math-sanitize 因构建边界无法提为共享包,以逐字节副本形式在 wiki 与 ui 两处持有。副本的固有风险是单边漂移:改了一处忘了另一处,缺陷从此静默分叉。 此前仅靠文件头注释约束,本次补上机器执法。 实现(照仓库既有 version-sync-check 三件套范式): - scripts/check_twin_files.py:PEP723 内联依赖脚本,登记表显式列出每组副本 (不用 glob 或内容嗅探猜测),精确字节相等比对,漂移时打印统一 diff 指明 行号与同步方向。只读不自动同步——哪份权威取决于改动意图,机器不该代猜。 - 两端副本的交叉标注改写为对称形式(同时列出全部路径、文字一致),使两文件 逐字节相同。校验遂退化为精确相等,零启发式;连「注释说 A 代码做 B」这类 最危险的漂移也在射程内(启发式剥离注释会放过它)。 - pre-commit 钩子 + CI workflow 双侧执法(后者兜底 --no-verify 绕过的提交)。 - 脚本自检触发覆盖:登记路径若未出现在 pre-commit 的 files 正则与 CI 的 paths 里则报错。二者是独立于登记表的第二/三份清单,漏配会使执法对新增副本组静默 失效,此自检把该风险闭合在脚本内。 验证:四态实测——通过 exit 0;代码漂移 exit 1 并输出精确 diff;副本缺失 exit 1; 纯注释漂移亦被捕获。覆盖自检注入未登记副本组后 exit 1 且点名两份触发清单。 pre-commit 全量 18 钩子通过(含新增 Twin files consistency),逐条暂存验证钩子 Passed 而非 Skipped;wiki 122 项、ui 1006 项测试无回归。 注:曾试图顺带给 scripts/ 加 ruff 门,因该目录不在任何 app 的 [tool.ruff] 覆盖内 会吃默认行宽 88(仓库约定 120),且会对既有 sync_versions.py 提出 3 项无关改动, 已撤回以守最小干预;新脚本自身按 120 行宽通过 ruff lint 与 format。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- .github/workflows/twin-files-consistency.yml | 43 +++++ .pre-commit-config.yaml | 18 ++ .../utils/remark-math-sanitize.ts | 13 +- .../markdown/remark-math-sanitize.ts | 9 +- scripts/check_twin_files.py | 157 ++++++++++++++++++ 5 files changed, 233 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/twin-files-consistency.yml create mode 100644 scripts/check_twin_files.py diff --git a/.github/workflows/twin-files-consistency.yml b/.github/workflows/twin-files-consistency.yml new file mode 100644 index 000000000..dba900bc7 --- /dev/null +++ b/.github/workflows/twin-files-consistency.yml @@ -0,0 +1,43 @@ +name: Twin Files Consistency + +# 孪生文件(逐字节副本)一致性校验 —— 横切关注点,正交于各 app CI。 +# +# 某些模块因构建边界无法提为共享包,只能以逐字节副本形式在多处持有。 +# 副本的固有风险是单边漂移:改了一处忘了另一处,缺陷从此静默分叉。 +# 本 job 校验登记表内每组副本精确字节相等,漂移时打印统一 diff 指明同步方向。 +# +# 登记表与判据详见 scripts/check_twin_files.py。pre-commit 侧有同名钩子做本地前置拦截; +# CI 侧是兜底 —— 未装 hooks 或 --no-verify 绕过的提交仍会在此被拦下。 +# +# fail-only:检测到漂移即阻塞合并,不自动同步(哪份权威取决于改动意图,机器不该代猜)。 + +on: + pull_request: + paths: + - "scripts/check_twin_files.py" + - "apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts" + - "apps/negentropy-ui/utils/remark-math-sanitize.ts" + - ".github/workflows/twin-files-consistency.yml" + push: + branches: [master, main] + paths: + - "scripts/check_twin_files.py" + - "apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts" + - "apps/negentropy-ui/utils/remark-math-sanitize.ts" + +permissions: + contents: read + +jobs: + twin-files-consistency: + name: Twin Files Check + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + + - name: Check twin files are byte-identical + run: uv run --no-project scripts/check_twin_files.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f47806c7a..3082ec1de 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -122,6 +122,24 @@ repos: files: ^(VERSION|scripts/sync_versions\.py|package\.json|apps/negentropy/(pyproject\.toml|uv\.lock)|apps/negentropy-ui/package\.json|apps/negentropy-wiki/package\.json|apps/negentropy-perceives/(pyproject\.toml|uv\.lock)|packages/agents-chat-core/package\.json)$ pass_filenames: false + # ── 孪生文件(逐字节副本)一致性执法 ───────────────────────────────────── + # 某些模块因构建边界无法提为共享包,只能以逐字节副本形式多处持有(登记表见脚本)。 + # 本钩子把「改一处必须同步另一处」从人工纪律升格为机器保证,漂移时打印统一 diff。 + # ⚠️ files: 覆盖登记表内全部路径 + 脚本自身;新增副本组时须同步扩充此正则, + # 否则钩子对新组**静默失效**(pre-commit 跳过不报错)。改动后须验证: + # 暂存一处副本改动,确认钩子 Passed 而非 Skipped。 + # 注:pre-commit 会 stash 未暂存改动、仅对暂存内容执行,故同组副本**必须整组 + # 一同 git add**;只暂存一侧会因另一侧仍是 HEAD 版本而判定漂移 —— 这正是 + # 期望语义:它连「部分同步的提交」也一并拦住。 + - repo: local + hooks: + - id: twin-files-consistency + name: Twin files consistency + language: system + entry: uv run --no-project scripts/check_twin_files.py + files: ^(scripts/check_twin_files\.py|apps/negentropy-wiki/src/components/markdown/remark-math-sanitize\.ts|apps/negentropy-ui/utils/remark-math-sanitize\.ts)$ + pass_filenames: false + # ── 科普视频系列一致性(series.json 执法) ─────────────────────────────── # 规则:口播反串线(自身标题排除)/多标题顺序/序号绑定/清单完整性/相对链接死链。 # 注意:挂在内容修复之后落地;顺序调整类变更须先改 series.json 再动散文。 diff --git a/apps/negentropy-ui/utils/remark-math-sanitize.ts b/apps/negentropy-ui/utils/remark-math-sanitize.ts index 1225aa1b2..bbcf09891 100644 --- a/apps/negentropy-ui/utils/remark-math-sanitize.ts +++ b/apps/negentropy-ui/utils/remark-math-sanitize.ts @@ -5,11 +5,14 @@ import { visit } from "unist-util-visit"; * remarkMathSanitize —— 净化 PDF 提取语料中 remark-math 产生的病态公式节点, * 消除 rehype-katex 构建期告警与实际渲染缺陷。须挂在 `remarkMath` 之后。 * - * ⚠️ 本文件与 `apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts` - * 是**逐字节孪生副本**。两端渲染同一份 PDF 提取语料(wiki 静态站 / UI 知识库文档详情页), - * 判据一旦单边漂移即产生渲染不对称。未提为共享包是因 wiki 无 workspace 依赖且以 - * GitHub Pages 为出口,为单个插件引入构建链的爆炸半径过大(参照 rehype-notranslate - * 亦为单端持有)。**改动此文件时必须同步另一端,并同步两端回归用例。** + * ⚠️ 本文件是**逐字节孪生副本**,同时存在于: + * - `apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts` + * - `apps/negentropy-ui/utils/remark-math-sanitize.ts` + * 两端渲染同一份 PDF 提取语料(wiki 静态站 / UI 知识库文档详情页),判据一旦单边漂移 + * 即产生渲染不对称。未提为共享包是因 wiki 无 workspace 依赖且以 GitHub Pages 为出口, + * 为单个插件引入构建链的爆炸半径过大(参照 rehype-notranslate 亦为单端持有)。 + * **改动任一份时必须同步另一份,并同步两端回归用例。** + * 一致性由 `scripts/check_twin_files.py` 在 pre-commit 与 CI 双侧执法(精确字节相等)。 * * 三类病灶与处置: * 1. 货币 `$` 误配对:正文「按输入 $3/ 百万 token 、输出 $15/ 百万 token」的相邻 diff --git a/apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts b/apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts index 0d32ace0c..bbcf09891 100644 --- a/apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts +++ b/apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts @@ -5,9 +5,14 @@ import { visit } from "unist-util-visit"; * remarkMathSanitize —— 净化 PDF 提取语料中 remark-math 产生的病态公式节点, * 消除 rehype-katex 构建期告警与实际渲染缺陷。须挂在 `remarkMath` 之后。 * - * ⚠️ 本文件与 `apps/negentropy-ui/utils/remark-math-sanitize.ts` 是**逐字节孪生副本**。 + * ⚠️ 本文件是**逐字节孪生副本**,同时存在于: + * - `apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts` + * - `apps/negentropy-ui/utils/remark-math-sanitize.ts` * 两端渲染同一份 PDF 提取语料(wiki 静态站 / UI 知识库文档详情页),判据一旦单边漂移 - * 即产生渲染不对称。**改动此文件时必须同步另一端,并同步两端回归用例。** + * 即产生渲染不对称。未提为共享包是因 wiki 无 workspace 依赖且以 GitHub Pages 为出口, + * 为单个插件引入构建链的爆炸半径过大(参照 rehype-notranslate 亦为单端持有)。 + * **改动任一份时必须同步另一份,并同步两端回归用例。** + * 一致性由 `scripts/check_twin_files.py` 在 pre-commit 与 CI 双侧执法(精确字节相等)。 * * 三类病灶与处置: * 1. 货币 `$` 误配对:正文「按输入 $3/ 百万 token 、输出 $15/ 百万 token」的相邻 diff --git a/scripts/check_twin_files.py b/scripts/check_twin_files.py new file mode 100644 index 000000000..298e77a76 --- /dev/null +++ b/scripts/check_twin_files.py @@ -0,0 +1,157 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +"""孪生文件(逐字节副本)一致性校验。 + +某些模块因构建边界无法提为共享包,只能以「逐字节副本」形式在多处持有。 +副本的固有风险是**单边漂移**:改了一处忘了另一处,缺陷从此静默分叉。 +本脚本把「必须同步」从人工纪律升格为机器保证。 + +设计要点(贴合 AGENTS.md「单一事实源 + 最小干预 + 正交分解」): + +* **登记表显式列出** —— 不用 glob 或内容嗅探猜测谁是副本,避免误纳与漏纳。 + 新增一组副本只需往 ``TWIN_GROUPS`` 加一条,正交于校验逻辑本身。 +* **精确字节相等** —— 不做注释剥离、空白归一等启发式处理。副本的交叉标注写成 + 两端对称(同时列出全部路径、文字一致),故连文档注释的漂移也在射程内。 + 启发式比对会放过「注释说 A 代码做 B」这类最危险的漂移。 +* **可诊断输出** —— 漂移时打印统一 diff(含行号)而非仅「哈希不一致」, + 使开发者无需自行 diff 即知何处分叉、往哪个方向同步。 +* **只读** —— 不自动同步。哪份是权威取决于改动意图,机器不该替开发者猜; + 自动覆盖有静默丢失改动的风险。 + +用法:: + + uv run --no-project scripts/check_twin_files.py # 任一组漂移则 exit 1 + +供 pre-commit 钩子与 CI(.github/workflows/twin-files-consistency.yml)双侧调用。 +""" + +from __future__ import annotations + +import difflib +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# ── 孪生文件登记表 ────────────────────────────────────────────────────────── +# 每组为 (简称, 无法共享的理由, 必须逐字节相同的仓库相对路径列表[≥2])。 +# 简称用于通过态的单行输出;理由仅在漂移时打印,供后来者判断该不该合并成共享包。 +TWIN_GROUPS: list[tuple[str, str, list[str]]] = [ + ( + "remark-math-sanitize", + ( + "wiki 与 ui 渲染同一份 PDF 提取语料,净化判据单边漂移即产生渲染不对称。" + "wiki 无 workspace 依赖且以 GitHub Pages 为出口,为单个插件引入构建链的" + "爆炸半径过大,故采副本而非共享包。" + ), + [ + "apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts", + "apps/negentropy-ui/utils/remark-math-sanitize.ts", + ], + ), +] + + +def check_group(rel_paths: list[str]) -> list[str]: + """校验一组孪生文件,返回问题描述列表(空表示该组通过)。""" + problems: list[str] = [] + + missing = [p for p in rel_paths if not (REPO_ROOT / p).is_file()] + if missing: + # 副本被删除/改名亦是漂移的一种 —— 报错而非静默跳过。 + problems.append(f"以下路径不存在(副本被删除或改名?):{', '.join(missing)}") + return problems + + # 以第一条为比对基准(仅作 diff 的左侧,不含「它更权威」的语义)。 + base_rel = rel_paths[0] + base_bytes = (REPO_ROOT / base_rel).read_bytes() + + for other_rel in rel_paths[1:]: + other_bytes = (REPO_ROOT / other_rel).read_bytes() + if other_bytes == base_bytes: + continue + + diff = "".join( + difflib.unified_diff( + base_bytes.decode("utf-8", errors="replace").splitlines(keepends=True), + other_bytes.decode("utf-8", errors="replace").splitlines(keepends=True), + fromfile=base_rel, + tofile=other_rel, + ) + ) + problems.append(f"{base_rel}\n ≠ {other_rel}\n\n{diff}") + + return problems + + +def check_trigger_coverage() -> list[str]: + """自检:登记表内每条路径都须出现在 pre-commit 的 files 正则与 CI 的 paths 里。 + + 二者是独立于登记表的第二/第三份清单,漏配会使执法对新增副本组**静默失效** + (pre-commit 跳过、CI 不触发,均不报错)。此自检把该风险闭合在本脚本内。 + """ + problems: list[str] = [] + watchers = { + ".pre-commit-config.yaml": REPO_ROOT / ".pre-commit-config.yaml", + ".github/workflows/twin-files-consistency.yml": (REPO_ROOT / ".github/workflows/twin-files-consistency.yml"), + } + + for label, path in watchers.items(): + if not path.is_file(): + problems.append(f"{label} 不存在,无法确认触发覆盖") + continue + text = path.read_text(encoding="utf-8") + for _name, _rationale, rel_paths in TWIN_GROUPS: + for rel in rel_paths: + # pre-commit 正则里 `.` 写作 `\.`,故两种写法均视为已覆盖。 + if rel not in text and rel.replace(".", r"\.") not in text: + problems.append(f"{label} 未覆盖登记路径:{rel}") + + return problems + + +def main() -> int: + failed = False + + for name, rationale, rel_paths in TWIN_GROUPS: + if len(rel_paths) < 2: + print(f"[配置错误] 分组「{name}」少于 2 条路径,无从比对", file=sys.stderr) + failed = True + continue + + problems = check_group(rel_paths) + if not problems: + print(f"✓ {name}:{len(rel_paths)} 份副本逐字节一致") + continue + + failed = True + print(f"\n✗ {name}:孪生文件漂移", file=sys.stderr) + print(f" 副本存在的理由:{rationale}", file=sys.stderr) + for problem in problems: + print(f"\n {problem}", file=sys.stderr) + + if failed: + print( + "\n孪生文件出现漂移。请判断哪份体现了本次改动的意图," + "将其同步到同组其余路径(含文档注释),并同步两端回归用例。", + file=sys.stderr, + ) + + coverage_problems = check_trigger_coverage() + if coverage_problems: + failed = True + print("\n✗ 执法触发覆盖不全(新增副本组后忘了扩充触发清单?):", file=sys.stderr) + for problem in coverage_problems: + print(f" - {problem}", file=sys.stderr) + print( + " 未覆盖的路径不会触发 pre-commit 钩子 / CI job —— 执法对其静默失效。", + file=sys.stderr, + ) + + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main())