Python 交给 Node 的引用位置,怎样避免截出半个 emoji
固定原始文本快照,让两种运行时验收同一段引用。
1. Python 找到的引用,到了 Node 为什么只剩半个 emoji
一个 Agent 服务用 Python 检索文本,把引用起止位置交给 Node 网关核验。两端拿到相同的 JSON 数字,也拿到相同的原文,截出的文字却可能不同。Python 的字符串索引按 Unicode 码点计数,JavaScript 按 UTF-16 码元计数。对含有补充平面字符的文本,直接交换原生字符串下标会留下一个很具体的故障:原本引用 😀,接收端只取到了它的一半。
本文用两个真实进程复现这个问题,再把引用改为原始 UTF-8 快照上的字节半开区间。两端共同检查源哈希、范围、Unicode 标量有效性以及引用逐字相等。交付的固定夹具有 20 条,Python 和 Node 各验收一次,共 40 次判定;另有一次 Python 生成、Node 独立重算、Python 回验的进程往返。所有输入都是文内合成文字,没有调用模型、网络服务或用户文件。
读者需要本机已有 Python 与 Node,示例只用标准库,无需 pip 或 npm 安装。本次运行环境为 Python 3.12.14(Unicode 数据 15.0.0)与 Node v24.19.0(ICU 78.3,Unicode 17.0)。全文源码和固定输入在后面展开,可以复制到空目录复现。
2. 先把四种长度写清楚
实验字符串写作 A中😀e\u0301👩\u200d💻Z,其中 e 后是单独的组合锐音符,两个 emoji 之间有零宽连接符。运行后得到 9 个 Python 码点、12 个 JavaScript UTF-16 码元、23 个 UTF-8 字节。本例 Node 的 Intl.Segmenter 返回 6 个扩展字素簇。字素簇用于文本分段,不等同于模型 token,也不保证恰好对应一个独立显示的字形。
Python 的字符串文档把 str 定义为 Unicode 码点序列;ECMAScript String 类型使用 16 位无符号整数序列。字符串内容相同,不会自动统一这两套索引单位。
😀 在 Python 中是 [2, 3),在 JavaScript 中是 [2, 4),在 UTF-8 字节中是 [4, 8)。较长的引用 e\u0301👩\u200d💻 对应三组范围 [3, 8)、[4, 11)、[8, 22)。下标应与单位一起传递,不能只给两个名叫 start 与 end 的数字。
3. 负控保留原生切片的错误结果
probe.py 故意把 Python 的 [2, 3) 用到 JavaScript 的 slice(2, 3)。Node 返回 \ud83d,这是一个孤立的高代理项。输出采用 JSON 转义,避免终端字体或编码替换掩盖问题。对较长引用照搬 Python 的 [3, 8),结果同样不等于预期原文。这个负控直接检验默认行为,后面的验收器则检验应用约定。
Python JSON 文档提示了未配对代理项的兼容性问题。实验中 json.loads 与 JSON.parse 都能解析 "\ud800"。Python 随后的严格 UTF-8 编码抛出 UnicodeEncodeError;JavaScript 的 TextEncoder 产生 EF BF BD,即 U+FFFD。若先编码再比较,这次替换可能已经消除了错误线索。
Encoding Standard规定 TextEncoder 接收 USVString;Web IDL 的转换规则解释了孤立代理项为何会变成替代字符。本例 Node 在编码前调用 isWellFormed,Python 用严格编码确认 quote 可编码。不要把 toWellFormed、替换错误或 NFC 归一化藏在验收过程里。原始文本中合法存在的 U+FFFD 仍然允许,夹具专门保留这一情况。
4. 引用合同绑定原始 UTF-8 快照
候选对象包含 version=1、unit="utf8-byte"、source_sha256、start、end 和 quote。start 包含在切片内,end 不包含;范围要求非空。source_sha256 对原始字节计算,包括 BOM、换行和组合字符的具体编码,不对显示结果或归一化后的字符串计算。宿主应以二进制方式一次读取并保留快照,随后哈希、解码和提取都使用它,避免中途再次读取文件造成漂移。
Python 拒绝 bool,要求 type(offset) is int;Node 要求 Number.isSafeInteger。虽然两端整数能力不同,此例的源大小上限为 100000 字节,合法偏移都落在共同的安全范围内。这里验证的是解析后的引用对象,未实现原始 JSON 数字 token、重复键或完整模式检查。Python 解析 1.0 后得到 float,Node 则得到可通过整数检查的 Number,因此 start:1.0 或 version:1.0 可能出现 Python 拒收、Node 接受。本文的固定生产者输出整数写法,20 条夹具没有覆盖任意 JSON 数字词法的一致性;若入口接受任意 JSON,需另行约定和校验数字表示。
先验证原始字节哈希,再严格解码完整源,然后检查范围与 quote,最后严格解码选中切片并逐字比较。完整源有效且选中切片也能严格解码,才可接受对应的 UTF-8 标量边界。只验证切片会漏掉源的其他位置存在非法编码的情况。Python 把异常处理限定在严格编码、解码语句,只捕获对应的 UnicodeEncodeError 或 UnicodeDecodeError。Node 只把严格解码器明确报告的 ERR_ENCODING_INVALID_ENCODED_DATA 视为数据拒收;缺少运行时方法等其他异常继续抛出,避免把环境或程序故障记录成普通不合法引用。
Node 使用 TextDecoder("utf-8", {fatal:true, ignoreBOM:true})。fatal 让解码错误抛出异常;ignoreBOM:true 在这里让初始 BOM 作为 U+FEFF 保留,而非被解码器去除。Python 的 utf-8 严格解码同样保留它。选项名称容易误读,文中的 BOM 夹具通过实际字符串相等验证这个行为。Node v24.19.0 文档还注明禁用 ICU 的构建不支持 fatal;实测结论只覆盖这里使用的二进制。
5. 哈希改变之后,旧坐标也要重新计算
把原文的 e 加组合锐音符转换为 NFC 后,源字节改变。沿用旧 source_sha256 会被拒绝;即使重算哈希,沿用旧字节区间和 quote 仍会被拒绝。归一化可以成为上游明确的转换步骤,但转换产物必须作为新快照重新绑定哈希和坐标。需要回到原文高亮时,还要保存转换映射;本例没有实现该映射。
组合锐音符本身位于字节 [9, 11),这一范围会通过合同。它是合法的 Unicode 标量切片,却没有包含前面的 e。若界面只允许完整字素簇高亮,需要另加分段策略,并对运行时的 Unicode 版本建立约定。Unicode 文本分段规范可作为这一步的依据,本例没有要求 Python 与 Node 的字素分段结果跨版本同步。
哈希绑定只有在宿主独立取得获准源快照时才有意义。若候选方可以同时替换 source 和 hash,二者相等不能证明来源真实。本文的 source_hex 只是固定合成夹具和进程间实验的运输格式,生产接口不应照搬成“提交任意来源并自行声明哈希”。通过合同也不证明引用支持某个结论,更不授予执行动作的权限。
6. 从空目录跑完两端验收
把下面七个文件按标题保存到同一目录:validator.py、validator.cjs、roundtrip.cjs、test_contract.py、probe.py、golden-fixtures.json、expected.json。使用已有的 Python 与 Node 执行 python test_contract.py,再执行 python probe.py。测试文件以自身目录定位依赖,因此也可从其他工作目录启动。不要用 python -O 运行,测试依靠 assert 给出失败退出。
expected.json 是独立保存的固定判定表,test_contract.py 只读取它,不会调用验收器生成期望,也不会重建 fixtures。每个运行时都先与固定期望比对,然后再比较两个运行时的结果。只检查两端一致,会让两个同样错误的实现一起通过。
7. validator.py 完整文件
"""Parsed citation contract; source bytes must come from a trusted host binding."""
import hashlib
def validate(source, candidate):
c = candidate
required = {"version", "unit", "source_sha256", "start", "end", "quote"}
if type(c) is not dict or not required <= c.keys():
return {"ok": False}
if type(source) is not bytes or len(source) > 100000:
return {"ok": False}
if type(c["version"]) is not int or c["version"] != 1:
return {"ok": False}
if c["unit"] != "utf8-byte":
return {"ok": False}
if c["source_sha256"] != hashlib.sha256(source).hexdigest():
return {"ok": False}
try:
source.decode("utf-8", "strict")
except UnicodeDecodeError:
return {"ok": False}
start, end = c["start"], c["end"]
if type(start) is not int or type(end) is not int:
return {"ok": False}
if not 0 <= start < end <= len(source):
return {"ok": False}
if type(c["quote"]) is not str:
return {"ok": False}
try:
c["quote"].encode("utf-8", "strict")
except UnicodeEncodeError:
return {"ok": False}
try:
quote = source[start:end].decode("utf-8", "strict")
except UnicodeDecodeError:
return {"ok": False}
if quote != c["quote"]:
return {"ok": False}
return {"ok": True, "quote": quote}
8. validator.cjs 完整文件
"use strict";
const fs = require("node:fs");
const crypto = require("node:crypto");
const decode = bytes => new TextDecoder("utf-8", {
fatal: true, ignoreBOM: true
}).decode(bytes);
function decodeChecked(bytes) {
try { return {ok:true, text:decode(bytes)}; }
catch (error) {
if (error.code === "ERR_ENCODING_INVALID_ENCODED_DATA") return {ok:false};
throw error;
}
}
function validate(source, c) {
if (c === null || typeof c !== "object" || Array.isArray(c)) return {ok:false};
if (!Buffer.isBuffer(source) || source.length > 100000) return {ok:false};
if (c.version !== 1 || c.unit !== "utf8-byte") return {ok:false};
const hash = crypto.createHash("sha256").update(source).digest("hex");
if (c.source_sha256 !== hash) return {ok:false};
if (!decodeChecked(source).ok) return {ok:false};
if (!Number.isSafeInteger(c.start) || !Number.isSafeInteger(c.end) ||
c.start < 0 || c.start >= c.end || c.end > source.length) return {ok:false};
if (typeof c.quote !== "string" || !c.quote.isWellFormed()) return {ok:false};
const selected = decodeChecked(source.subarray(c.start, c.end));
if (!selected.ok) return {ok:false};
const quote = selected.text;
if (quote !== c.quote) return {ok:false};
return {ok:true, quote};
}
// source_hex is trusted synthetic fixture transport, not a network input API.
if (require.main === module) {
const rows = JSON.parse(fs.readFileSync(0, "utf8"));
process.stdout.write(JSON.stringify(rows.map(f =>
validate(Buffer.from(f.source_hex, "hex"), f.candidate))));
}
module.exports = {validate, decode};
9. roundtrip.cjs 完整文件
"use strict";
const fs = require("node:fs"), crypto = require("node:crypto");
const {validate, decode} = require("./validator.cjs");
const f = JSON.parse(fs.readFileSync(0, "utf8"));
const source = Buffer.from(f.source_hex, "hex");
if (!validate(source, f.candidate).ok) throw Error("receive failed");
const text = decode(source), quote = f.candidate.quote;
const start = text.indexOf(quote), end = start + quote.length;
// This synthetic quote is unique; indexOf is not production disambiguation.
if (start < 0 || text.indexOf(quote, start + 1) !== -1) throw Error("not unique");
for (const s of [text, quote, text.slice(0, start), text.slice(0, end)]) {
if (!s.isWellFormed()) throw Error("unpaired surrogate");
}
const bytes = s => new TextEncoder().encode(s);
f.candidate = {
version:1, unit:"utf8-byte",
source_sha256:crypto.createHash("sha256").update(bytes(text)).digest("hex"),
start:bytes(text.slice(0, start)).length,
end:bytes(text.slice(0, end)).length, quote
};
process.stdout.write(JSON.stringify(f));
10. test_contract.py 完整文件
"""Read fixed fixtures and independent expected decisions; never regenerate them."""
import hashlib
import json
import subprocess
from pathlib import Path
from validator import validate
ROOT = Path(__file__).resolve().parent
fixtures = json.loads((ROOT / "golden-fixtures.json").read_text())
expected = json.loads((ROOT / "expected.json").read_text())
names = [f["name"] for f in fixtures]
assert len(names) == len(set(names)) == 20
assert set(names) == set(expected)
py = [validate(bytes.fromhex(f["source_hex"]), f["candidate"]) for f in fixtures]
js = json.loads(subprocess.check_output(
["node", str(ROOT / "validator.cjs")],
input=json.dumps(fixtures, ensure_ascii=True).encode("ascii")))
assert len(js) == len(py)
for f, p, n in zip(fixtures, py, js):
assert p["ok"] is expected[f["name"]], ("Python", f["name"], p)
assert n["ok"] is expected[f["name"]], ("Node", f["name"], n)
assert p == n, ("disagreement", f["name"])
print("20 fixed fixtures; 40 acceptance checks passed; 6 accepts / 14 rejects per runtime")
text = "A中😀e\u0301👩\u200d💻Z"
quote = "e\u0301👩\u200d💻"
source = text.encode("utf-8", "strict")
a = text.index(quote)
z = a + len(quote)
candidate = {
"version": 1, "unit": "utf8-byte",
"source_sha256": hashlib.sha256(source).hexdigest(),
"start": len(text[:a].encode("utf-8", "strict")),
"end": len(text[:z].encode("utf-8", "strict")), "quote": quote}
envelope = {"source_hex": source.hex(), "candidate": candidate}
returned = json.loads(subprocess.check_output(
["node", str(ROOT / "roundtrip.cjs")],
input=json.dumps(envelope, ensure_ascii=True).encode("ascii")))
assert returned == envelope
assert validate(source, returned["candidate"]) == {"ok": True, "quote": quote}
assert (candidate["start"], candidate["end"]) == (8, 22)
print("Python producer -> Node validator -> Node producer -> Python validator: PASS")
11. probe.py 完整文件
import json, subprocess, sys, unicodedata
text = "A中😀e\u0301👩\u200d💻Z"
try:
json.loads('"\\ud800"').encode("utf-8", "strict")
except UnicodeEncodeError:
python_surrogate = "UnicodeEncodeError"
js = 'const s=\'A中😀e\\u0301👩\\u200d💻Z\'; console.log(JSON.stringify({node:process.version,icu:process.versions.icu,unicode:process.versions.unicode,utf16:s.length,codepoints:[...s].length,utf8:new TextEncoder().encode(s).length,graphemes:[...new Intl.Segmenter(\'und\',{granularity:\'grapheme\'}).segment(s)].map(x=>({segment:x.segment,index:x.index})),pythonEmojiSpanAppliedToJS:s.slice(2,3),pythonLongSpanAppliedToJS:s.slice(3,8),unpairedJSON:JSON.parse(\'"\\\\ud800"\'),unpairedEncoded:[...new TextEncoder().encode(\'\\ud800\')],malformedDefaultDecoder:new TextDecoder().decode(Uint8Array.from([0xed,0xa0,0x80]))}));'
node = json.loads(subprocess.check_output(["node", "-e", js]))
assert node["pythonEmojiSpanAppliedToJS"] == "\ud83d"
assert node["unpairedEncoded"] == [239, 191, 189]
assert node["utf16"] == 12 and node["utf8"] == 23
print(json.dumps({"python": sys.version.split()[0],
"python_unicode": unicodedata.unidata_version,
"python_codepoints": len(text), "python_surrogate_encode": python_surrogate,
"node": node}, ensure_ascii=True, indent=2))
12. golden-fixtures.json 完整文件
[
{
"name": "chinese",
"source_hex": "41e4b8adf09f988065cc81f09f91a9e2808df09f92bb5a",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "db1fda0a46292352d98160a07d02601330ad006ed399c4e05f7736417a56ac1b",
"start": 1,
"end": 4,
"quote": "\u4e2d"
}
},
{
"name": "emoji",
"source_hex": "41e4b8adf09f988065cc81f09f91a9e2808df09f92bb5a",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "db1fda0a46292352d98160a07d02601330ad006ed399c4e05f7736417a56ac1b",
"start": 4,
"end": 8,
"quote": "\ud83d\ude00"
}
},
{
"name": "combining-plus-zwj",
"source_hex": "41e4b8adf09f988065cc81f09f91a9e2808df09f92bb5a",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "db1fda0a46292352d98160a07d02601330ad006ed399c4e05f7736417a56ac1b",
"start": 8,
"end": 22,
"quote": "e\u0301\ud83d\udc69\u200d\ud83d\udcbb"
}
},
{
"name": "combining-mark-alone-valid-scalar",
"source_hex": "41e4b8adf09f988065cc81f09f91a9e2808df09f92bb5a",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "db1fda0a46292352d98160a07d02601330ad006ed399c4e05f7736417a56ac1b",
"start": 9,
"end": 11,
"quote": "\u0301"
}
},
{
"name": "bom-preserved",
"source_hex": "efbbbfe4b8ad",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "ca6e8bda5232638ff662c58837a849ebb275161e3748b1feecc1f153eeb3bc6a",
"start": 0,
"end": 3,
"quote": "\ufeff"
}
},
{
"name": "literal-replacement-valid",
"source_hex": "78efbfbd79",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "0b37743370331e33808c0dd0167563798a3c324504cf0055d2b0ac81578d2c58",
"start": 1,
"end": 4,
"quote": "\ufffd"
}
},
{
"name": "codepoint-offsets-mislabeled-byte",
"source_hex": "41e4b8adf09f988065cc81f09f91a9e2808df09f92bb5a",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "db1fda0a46292352d98160a07d02601330ad006ed399c4e05f7736417a56ac1b",
"start": 2,
"end": 3,
"quote": "\ud83d\ude00"
}
},
{
"name": "utf16-offsets-mislabeled-byte",
"source_hex": "41e4b8adf09f988065cc81f09f91a9e2808df09f92bb5a",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "db1fda0a46292352d98160a07d02601330ad006ed399c4e05f7736417a56ac1b",
"start": 2,
"end": 4,
"quote": "\ud83d\ude00"
}
},
{
"name": "split-emoji-start",
"source_hex": "41e4b8adf09f988065cc81f09f91a9e2808df09f92bb5a",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "db1fda0a46292352d98160a07d02601330ad006ed399c4e05f7736417a56ac1b",
"start": 5,
"end": 8,
"quote": "\ud83d\ude00"
}
},
{
"name": "split-emoji-end",
"source_hex": "41e4b8adf09f988065cc81f09f91a9e2808df09f92bb5a",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "db1fda0a46292352d98160a07d02601330ad006ed399c4e05f7736417a56ac1b",
"start": 4,
"end": 7,
"quote": "\ud83d\ude00"
}
},
{
"name": "negative",
"source_hex": "41e4b8adf09f988065cc81f09f91a9e2808df09f92bb5a",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "db1fda0a46292352d98160a07d02601330ad006ed399c4e05f7736417a56ac1b",
"start": -1,
"end": 4,
"quote": "\u4e2d"
}
},
{
"name": "out-of-range",
"source_hex": "41e4b8adf09f988065cc81f09f91a9e2808df09f92bb5a",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "db1fda0a46292352d98160a07d02601330ad006ed399c4e05f7736417a56ac1b",
"start": 1,
"end": 99,
"quote": "\u4e2d"
}
},
{
"name": "empty",
"source_hex": "41e4b8adf09f988065cc81f09f91a9e2808df09f92bb5a",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "db1fda0a46292352d98160a07d02601330ad006ed399c4e05f7736417a56ac1b",
"start": 1,
"end": 1,
"quote": ""
}
},
{
"name": "boolean",
"source_hex": "41e4b8adf09f988065cc81f09f91a9e2808df09f92bb5a",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "db1fda0a46292352d98160a07d02601330ad006ed399c4e05f7736417a56ac1b",
"start": true,
"end": 4,
"quote": "\u4e2d"
}
},
{
"name": "quote-mismatch",
"source_hex": "41e4b8adf09f988065cc81f09f91a9e2808df09f92bb5a",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "db1fda0a46292352d98160a07d02601330ad006ed399c4e05f7736417a56ac1b",
"start": 1,
"end": 4,
"quote": "\u6587"
}
},
{
"name": "unpaired-quote",
"source_hex": "41e4b8adf09f988065cc81f09f91a9e2808df09f92bb5a",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "db1fda0a46292352d98160a07d02601330ad006ed399c4e05f7736417a56ac1b",
"start": 4,
"end": 8,
"quote": "\ud83d"
}
},
{
"name": "nfc-source-stale-hash",
"source_hex": "41e4b8adf09f9880c3a9f09f91a9e2808df09f92bb5a",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "db1fda0a46292352d98160a07d02601330ad006ed399c4e05f7736417a56ac1b",
"start": 8,
"end": 22,
"quote": "e\u0301\ud83d\udc69\u200d\ud83d\udcbb"
}
},
{
"name": "nfc-source-new-hash-old-span",
"source_hex": "41e4b8adf09f9880c3a9f09f91a9e2808df09f92bb5a",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "9ae4f94c9694ae0a153544d985b8450749b6fbaee89d36ab9b514cca8156fdd8",
"start": 8,
"end": 22,
"quote": "e\u0301\ud83d\udc69\u200d\ud83d\udcbb"
}
},
{
"name": "malformed-source-utf8",
"source_hex": "41eda080",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "9a32f9b135a7e554c9fa4d95095ee0dc64c64f5e6291ea98fd7e8bba3897e5f0",
"start": 0,
"end": 1,
"quote": "A"
}
},
{
"name": "wrong-unit",
"source_hex": "41e4b8adf09f988065cc81f09f91a9e2808df09f92bb5a",
"candidate": {
"version": 1,
"unit": "codepoint",
"source_sha256": "db1fda0a46292352d98160a07d02601330ad006ed399c4e05f7736417a56ac1b",
"start": 4,
"end": 8,
"quote": "\ud83d\ude00"
}
}
]
13. expected.json 完整文件
{
"chinese": true,
"emoji": true,
"combining-plus-zwj": true,
"combining-mark-alone-valid-scalar": true,
"bom-preserved": true,
"literal-replacement-valid": true,
"codepoint-offsets-mislabeled-byte": false,
"utf16-offsets-mislabeled-byte": false,
"split-emoji-start": false,
"split-emoji-end": false,
"negative": false,
"out-of-range": false,
"empty": false,
"boolean": false,
"quote-mismatch": false,
"unpaired-quote": false,
"nfc-source-stale-hash": false,
"nfc-source-new-hash-old-span": false,
"malformed-source-utf8": false,
"wrong-unit": false
}
14. 观察真实输出,并保留失败样本
本次 test_contract.py 输出:20 fixed fixtures; 40 acceptance checks passed; 6 accepts / 14 rejects per runtime,随后输出 Python producer -> Node validator -> Node producer -> Python validator: PASS。这 40 次包括 20 条样本各自的两端判定,不能写成 40 个独立场景。
6 条接受样本覆盖中文、补充平面 emoji、组合字符加 ZWJ 序列、独立组合附加符、保留 BOM 和合法 U+FFFD。14 条拒绝样本覆盖错误标注的码点与 UTF-16 下标、两种截断 emoji、负数、越界、空范围、布尔偏移、quote 不符、孤立代理项、两种 NFC 漂移、非法源 UTF-8 和错误单位。NFC 沿用旧哈希的样本同时验证了哈希不符拒收。非法源样本使用 41 ED A0 80:选中的 [0, 1) 是合法的 A,但未引用部分包含非法 UTF-8,因此完整源检查仍须拒收。
往返测试先由 Python 使用原生码点位置计算 UTF-8 前缀长度;Node 接收后验收,再用自己的 UTF-16 索引重新算前缀字节长度并重新计算哈希,最后经 stdout 回传。Python 重新解析并比较完整 envelope,再对保留的原始 source 字节执行验收。测试中的 quote 只出现一次;真实文档可能多次出现同一句话,不能用 indexOf 找到的第一处替换提交者原有坐标。
probe.py 的输出保留两端与 Unicode 数据版本,也保留默认解码器面对 ED A0 80 时的替代结果。严格验收器面对同一源字节拒收。运行时输出是可检查的合成实验结果,未测试真实模型能否找到正确引用,也不能据此声称引用准确率提高。
15. 接到真实文档流程前还缺什么
实际接入时,先由宿主根据已授权的文档身份取得文本快照,再把候选引用交给 validate(source, candidate)。PDF、OCR 与 HTML 提取结果应同时绑定原文档身份、提取器版本和文本快照,不能把 PDF 文件字节下标当成提取文本的坐标。本文没有实现文档提取或访问控制。
两个入口读取完整 stdin 后才处理对象;100000 字节是验收器的教学上限,不能作为流式内存或资源耗尽防护。示例未实现通用 JSON 解析器、完整字段模式、恶意十六进制输入约束、模糊测试或安全服务。非法 UTF-8 源以合法 ASCII 十六进制夹具运输,未测试网络上非法 UTF-8 JSON 传输的处理一致性。
本次仅验证所列 Python 与 Node 版本,没有多版本矩阵、全 Unicode 覆盖、字素分段同步、浏览器集成或真实模型 API。图示为原创解释图,HTML 做静态结构与源码一致性检查,未做浏览器像素验收。示例适合把跨运行时引用边界加入 CI,再根据业务补齐来源授权、重复引用消歧和语义审查。
16. 官方资料与版本边界
资料核对日期为 2026 年 10 月 7 日。在线规范会更新:Python /3.12 页面当前标注 3.12.15,TC39 在线版标注 ECMAScript 2027;Unicode TR29 当前页为 Unicode 18.0.0 / revision 49。资料页面与本次实测解释器、引擎版本分别记录,不据页面版本推断本机完整实现。
Python Unicode HOWTO:编码、解码与错误处理。unicodedata.normalize:Unicode 归一化形式。String.prototype.isWellFormed:识别未配对代理项。TextDecoder:fatal 与 ignoreBOM 选项。其余关键规则已在相应解释段落链接至官方来源。
17. 补充实验:引用通过后,怎样展示完整字素簇
前面的合同允许引用单独的组合锐音符。对同一份原文 A中😀e\u0301👩\u200d💻Z,字节 [9, 11) 与 quote \u0301 完全一致,因此验收通过。若界面要连同前面的 e 一起展示,便需要另一组范围:本机分段得到的显示包络为 UTF-8 [8, 11)、UTF-16 [4, 6),文字是 e\u0301。
2026-10-13 的补充实验把这一层写成 displayEnvelope(source, candidate)。函数先复用原合同验收,再返回 citation 和 display 两个对象。citation 保留原来的 hash、start、end、quote;display 另存展示范围、UTF-16 位置与文字。显示多出来的 e 只是上下文,不能写回引用证据,也不能拿扩大后的文字声称它支持原先的结论。
这里的“显示包络”是覆盖引用的最小完整字素簇区间。范围计算依赖完整源,不能只检查被引用的几个码点。下面先用区域指示符复现一个会因裁剪而改变分段的例子,再给出完整代码、固定输入与失败测试。所有文字均为合成夹具,实验没有调用模型或操作浏览器。
18. 先分完整源,再决定展示哪一段
用 A、B、C 简写三个区域指示符 U+1F1E6、U+1F1E7、U+1F1E8。每个符号都是 4 个 UTF-8 字节、2 个 UTF-16 码元。在本次 Node 环境中,完整源 ABC 分成 AB 和 C。引用 B 的字节区间 [4, 8) 合法,它所在的完整字素簇却是 AB,所以展示范围应为 [0, 8)。
只把 quote B 交给 Segmenter,会得到 B;先裁掉 A,再把后缀 BC 交给 Segmenter,会得到 BC。两种局部结果都不能代表 B 在原快照中的分段。UAX #29 revision 47 的 GB12/GB13按前方连续区域指示符的奇偶配对,说明了这里为何需要保留上文。A、B、C 只是序列标签,不依赖字体把它们画成哪面旗帜。
完整源 ABC:UTF-16 边界 [0, 4, 6];UTF-8 边界 [0, 8, 12]
只分 B: [B]
裁后缀 BC: [BC]
完整源分段:[AB, C]
引用 B: citation [4, 8);display [0, 8)按码点遍历也解决不了组合字符。Array.from 能避免把补充平面字符切成两个代理项,但对原例取第 4 个零基码点,仍然只有 \u0301,没有前面的 e。测试保留这三种错误策略的实际输出,以免把“没有半个 emoji”误当成已经采用完整字素簇。
19. 把 UTF-16 分段边界映回原始字节
display-envelope.cjs 继续调用原样保留的 validator.cjs。引用不通过时直接返回 invalid_citation,不产生 display;通过后才严格解码完整快照并分段。ECMA-402 的分段结果以字符串码元索引表示 index,不能把它直接标成 UTF-8 字节位置。
boundaries 先确认输入是良构字符串,再按 Unicode 标量遍历。每走过一个标量,同时累加它的 UTF-16 长度和 UTF-8 字节数,形成两种坐标的查表关系。随后对完整字符串执行 Segmenter,把所有分段起点转换成字节坐标,并补上 text.length 这个终点。空字符串的两张边界表都为 [0]。
包络左端取不大于 citation.start 的最后一个边界,右端取不小于 citation.end 的第一个边界。两处比较符号不能随意改:引用终点恰好落在簇边界时,右端必须停在那里。比如 e\u0301Z 的 [0, 3) 已经完整,不能顺便把 Z 纳入展示。
"use strict";
// Local, parsed-object experiment. Source bytes are supplied by the trusted host.
const {validate, decode} = require("./validator.cjs");
const segmenter = new Intl.Segmenter("en", {granularity:"grapheme"});
function boundaries(text) {
if (typeof text !== "string" || !text.isWellFormed()) {
throw new TypeError("boundary input must be a well-formed string");
}
const byteAt16 = new Map([[0, 0]]);
let units = 0, bytes = 0;
for (const scalar of text) {
units += scalar.length;
bytes += Buffer.byteLength(scalar, "utf8");
byteAt16.set(units, bytes);
}
// Segment the complete source, never only the quote or a cropped suffix.
const utf16 = [...segmenter.segment(text)].map(x => x.index);
utf16.push(text.length); // Also produces [0] for the empty source.
const utf8 = utf16.map(index => {
if (!byteAt16.has(index)) throw new Error("non-scalar segment boundary");
return byteAt16.get(index);
});
return {utf16, utf8};
}
function displayEnvelope(source, candidate) {
if (!validate(source, candidate).ok) return {ok:false, reason:"invalid_citation"};
const text = decode(source);
const b = boundaries(text);
let left = 0;
while (left + 1 < b.utf8.length && b.utf8[left + 1] <= candidate.start) left++;
let right = left;
while (right < b.utf8.length && b.utf8[right] < candidate.end) right++;
if (right === b.utf8.length || left >= right) throw new Error("missing envelope");
// Never write the expanded range or display text back into the evidence.
return {
ok:true,
citation:{...candidate},
display:{
unit:"utf8-byte", start:b.utf8[left], end:b.utf8[right],
utf16_start:b.utf16[left], utf16_end:b.utf16[right],
text:text.slice(b.utf16[left], b.utf16[right])
}
};
}
module.exports = {boundaries, displayEnvelope, segmenter};
函数不改传入的 Buffer 或 candidate;返回的 citation 用展开语法复制。这个副本没有被递归冻结,也不是防篡改存储。测试用冻结的普通输入对象检查调用中没有改写字段,并比较调用前后的完整源字节。生产系统仍应由宿主掌握快照和已验收引用的生命周期,不能允许 UI 把 display 覆盖回证据字段。
本例每次调用都会扫描并分段完整源。面对同一快照的大量引用,可以另外设计边界表缓存,但缓存键必须包含快照身份和分段配置,并验收失效行为。这里没有实现缓存或裁剪窗口优化,原来的 100000 字节教学上限也不等于完整的资源保护。
20. 固定十四种接受结果,包含换行与源末尾
表中的 citation 与 display 都使用字节半开区间,最后一列专门给出 display 的 UTF-16 半开区间。完整文字和所有边界数组保存在下面的 display-expected.json;测试逐字段比较,不能只检查扩大后的范围“包含原范围”。
| 场景 | 源 → quote | citation UTF-8 | display UTF-8 | display UTF-16 |
|---|---|---|---|---|
| 普通 ASCII | abc → b | [1, 2) | [1, 2) | [1, 2) |
| 组合附加符 | 原例 → \u0301 | [9, 11) | [8, 11) | [4, 6) |
| ZWJ | 👩\u200d💻 → \u200d | [4, 7) | [0, 11) | [0, 5) |
| 肤色修饰符 | 👍🏽 → 🏽 | [4, 8) | [0, 8) | [0, 4) |
| 变体选择符 | ✈\ufe0f → \ufe0f | [3, 6) | [0, 6) | [0, 2) |
| 三个 RI 中的 B | ABC → B | [4, 8) | [0, 8) | [0, 4) |
| 三个 RI 中的 C | ABC → C | [8, 12) | [8, 12) | [4, 6) |
| 四个 RI 中的 D | ABCD → D | [12, 16) | [8, 16) | [4, 8) |
| CRLF 中的 LF | x\r\ny → \n | [2, 3) | [1, 3) | [1, 3) |
| 终点已经对齐 | e\u0301Z → e\u0301 | [0, 3) | [0, 3) | [0, 2) |
| 跨两个簇 | e\u0301👩\u200d💻x → \u0301👩\u200d | [1, 10) | [0, 14) | [0, 7) |
| 保留 BOM | \ufeffe\u0301 → \u0301 | [4, 6) | [3, 6) | [1, 3) |
| 开头附加符 | \u0301x → \u0301 | [0, 2) | [0, 2) | [0, 1) |
| 源的最后字符 | 😀Z → Z | [4, 5) | [4, 5) | [2, 3) |
CRLF 案例尤其容易被遗漏:原合同允许单独引用 LF,而完整源分段把 CR 与 LF 保留在一起,显示包络因此从 [2, 3) 扩成 [1, 3)。这不授权把快照中的换行改为 LF。BOM 案例同样保留前导 U+FEFF,显示范围从字节 3 开始,没有丢弃 BOM 后重算位置。
原文开头若本来就是组合附加符,也没有更早的基字符可以补齐;本例的包络仍是 [0, 2)。因此“完整字素簇”并不承诺视觉上总有一个常见的完整字形。跨两个簇的测试则覆盖了左右两端都落在簇内部的情况。
21. 拒绝的输入不能靠扩大范围修好
新增的 12 条拒绝输入覆盖空引用、空源、截在 UTF-8 内部、quote 不匹配、hash 不匹配、孤立高代理项、孤立低代理项、完整源其他位置的非法 UTF-8、越界、布尔偏移、小数偏移和反向区间。它们都必须先被原合同挡住,并且返回对象中没有 display。
零长度范围继续拒绝,即使它位于一个合法字素边界;这里实现的是非空引用,不是插入光标。如果产品另需 caret 坐标,应该另定对象类型和左右贴靠策略。boundaries("") 仍能返回 [0],这只是空文本边界函数的行为,不表示空引用被接受。
孤立代理项还有第二层检查:直接调用 boundaries 时,\ud800、\udc00、a\ud800b 都抛 TypeError,不能先用 TextEncoder 或 Buffer 编码成 U+FFFD 再继续。引用中的孤立代理项由旧验收器拒绝;非法源字节则严格解码失败。两条入口的预期不同,测试分别核对。
hash 不符时应找回原来绑定的快照,或按新快照重新生成引用并验收;不得仅覆盖哈希让旧坐标继续用。UTF-8 中间切断、quote 不符也应回到生产者修正单位和位置,不能向两边“吸附”后伪装成合法证据。
22. 完整输入、固定预期与可复制测试
手工复制时,先保存原文七个文件,再把本节的 display-envelope.cjs、display-fixtures.json、display-expected.json、test-display.cjs、check-mutants.cjs 和 mutations.json 放在同一目录。sample-output.json 保存这次完整运行输出,sample-mutants.json 保存定向变异结果。公开离线包同时提供这些文件、原文、图示与运行说明;不需要密钥、模型服务或网络请求。
下载完整离线实验包(ZIP,45546 字节)。包内有 15 个源码、夹具与结果文件,另附运行说明、离线文章和四张图示;源码与本页代码块一致。ZIP 的 SHA-256:dfdc7a0a24a63ac06aa8b24e9367751eab27438378a1e39937c51a97aa6c956b。解压后进入 source 目录执行下面的命令;不需要安装依赖。
# 解压公开包后进入 source;本例只用标准库,无需安装依赖
cd source
python -B test_contract.py
python -B probe.py
node test-display.cjs > display-results.json
node check-mutants.cjs
# display-results.json 应与包内 sample-output.json 相同
python -c "from pathlib import Path; assert Path('display-results.json').read_bytes() == Path('sample-output.json').read_bytes()"display-fixtures.json 中每条输入给出完整字符串,非法 UTF-8 案例则给出 source_hex。测试在本机计算合成源哈希,构成与原文一致的候选对象;bad_hash 案例故意改用全零哈希。这个夹具构造过程没有验证真实来源,接入系统时仍须由可信宿主独立取得获准快照。
display-fixtures.json · 完整输入
[
{"name":"ascii","text":"abc","start":1,"end":2,"quote":"b"},
{"name":"combining_only","text":"A中😀e\u0301👩\u200d💻Z","start":9,"end":11,"quote":"\u0301"},
{"name":"zwj_only","text":"👩\u200d💻","start":4,"end":7,"quote":"\u200d"},
{"name":"modifier_only","text":"👍🏽","start":4,"end":8,"quote":"🏽"},
{"name":"variation_selector","text":"✈\ufe0f","start":3,"end":6,"quote":"\ufe0f"},
{"name":"ri_second","text":"🇦🇧🇨","start":4,"end":8,"quote":"🇧"},
{"name":"ri_third","text":"🇦🇧🇨","start":8,"end":12,"quote":"🇨"},
{"name":"ri_fourth","text":"🇦🇧🇨🇩","start":12,"end":16,"quote":"🇩"},
{"name":"crlf_lf","text":"x\r\ny","start":2,"end":3,"quote":"\n"},
{"name":"exact_endpoint","text":"e\u0301Z","start":0,"end":3,"quote":"e\u0301"},
{"name":"cross_two_clusters","text":"e\u0301👩\u200d💻x","start":1,"end":10,"quote":"\u0301👩\u200d"},
{"name":"bom_retained","text":"\ufeffe\u0301","start":4,"end":6,"quote":"\u0301"},
{"name":"leading_combining","text":"\u0301x","start":0,"end":2,"quote":"\u0301"},
{"name":"last_scalar","text":"😀Z","start":4,"end":5,"quote":"Z"},
{"name":"empty_range","text":"e\u0301","start":1,"end":1,"quote":""},
{"name":"empty_source","text":"","start":0,"end":0,"quote":""},
{"name":"middle_of_utf8","text":"😀","start":1,"end":4,"quote":"😀"},
{"name":"quote_mismatch","text":"abc","start":0,"end":1,"quote":"b"},
{"name":"hash_mismatch","text":"abc","start":0,"end":1,"quote":"a","bad_hash":true},
{"name":"high_surrogate_quote","text":"abc","start":0,"end":1,"quote":"\ud800"},
{"name":"low_surrogate_quote","text":"abc","start":0,"end":1,"quote":"\udc00"},
{"name":"invalid_utf8_source","source_hex":"61eda080","start":0,"end":1,"quote":"a"},
{"name":"out_of_bounds","text":"abc","start":0,"end":4,"quote":"abc"},
{"name":"boolean_offset","text":"abc","start":true,"end":2,"quote":"b"},
{"name":"fractional_offset","text":"abc","start":1.5,"end":2,"quote":"b"},
{"name":"reversed_range","text":"abc","start":2,"end":1,"quote":"b"}
]
display-expected.json 的范围、显示文字、完整边界数组和拒绝名称是事先写定的值。测试只读取,不会用被测函数生成期望。运行时也要逐字段等于这里的固定版本;版本不符会在场景运行前以断言失败退出,提醒读者重新核对分段行为。
display-expected.json · 完整固定预期
{
"runtime":{"node":"v24.19.0","icu":"78.3","unicode":"17.0","locale":"en","granularity":"grapheme"},
"accepts":{
"ascii":{"range":[1,2],"utf16":[1,2],"text":"b","boundaries16":[0,1,2,3],"boundaries8":[0,1,2,3]},
"combining_only":{"range":[8,11],"utf16":[4,6],"text":"e\u0301","boundaries16":[0,1,2,4,6,11,12],"boundaries8":[0,1,4,8,11,22,23]},
"zwj_only":{"range":[0,11],"utf16":[0,5],"text":"👩\u200d💻","boundaries16":[0,5],"boundaries8":[0,11]},
"modifier_only":{"range":[0,8],"utf16":[0,4],"text":"👍🏽","boundaries16":[0,4],"boundaries8":[0,8]},
"variation_selector":{"range":[0,6],"utf16":[0,2],"text":"✈\ufe0f","boundaries16":[0,2],"boundaries8":[0,6]},
"ri_second":{"range":[0,8],"utf16":[0,4],"text":"🇦🇧","boundaries16":[0,4,6],"boundaries8":[0,8,12]},
"ri_third":{"range":[8,12],"utf16":[4,6],"text":"🇨","boundaries16":[0,4,6],"boundaries8":[0,8,12]},
"ri_fourth":{"range":[8,16],"utf16":[4,8],"text":"🇨🇩","boundaries16":[0,4,8],"boundaries8":[0,8,16]},
"crlf_lf":{"range":[1,3],"utf16":[1,3],"text":"\r\n","boundaries16":[0,1,3,4],"boundaries8":[0,1,3,4]},
"exact_endpoint":{"range":[0,3],"utf16":[0,2],"text":"e\u0301","boundaries16":[0,2,3],"boundaries8":[0,3,4]},
"cross_two_clusters":{"range":[0,14],"utf16":[0,7],"text":"e\u0301👩\u200d💻","boundaries16":[0,2,7,8],"boundaries8":[0,3,14,15]},
"bom_retained":{"range":[3,6],"utf16":[1,3],"text":"e\u0301","boundaries16":[0,1,3],"boundaries8":[0,3,6]},
"leading_combining":{"range":[0,2],"utf16":[0,1],"text":"\u0301","boundaries16":[0,1,2],"boundaries8":[0,2,3]},
"last_scalar":{"range":[4,5],"utf16":[2,3],"text":"Z","boundaries16":[0,2,3],"boundaries8":[0,4,5]}
},
"rejects":["empty_range","empty_source","middle_of_utf8","quote_mismatch","hash_mismatch","high_surrogate_quote","low_surrogate_quote","invalid_utf8_source","out_of_bounds","boolean_offset","fractional_offset","reversed_range"],
"negative_controls":{
"codepoint_only":"\u0301",
"slice_only_ri":["🇧"],
"cropped_suffix_ri":["🇧🇨"],
"full_source_ri":["🇦🇧","🇨"]
}
}
test-display.cjs 核对 26 个唯一案例名称和两组预期名称完全对应。14 个接受案例比较完整返回对象、完整边界表,以及按 UTF-8 和 UTF-16 两种坐标取出的展示文字;12 个拒绝案例检查拒绝结果且没有 display。它还检查输入未变、空边界表、三个非法 UTF-16 输入和三种错误策略。
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
const {boundaries, displayEnvelope, segmenter} = require("./display-envelope.cjs");
const read = name => JSON.parse(fs.readFileSync(path.join(__dirname, name), "utf8"));
const fixtures = read("display-fixtures.json");
// Handwritten expected decisions and complete boundary arrays, never generated here.
const expected = read("display-expected.json");
const sha = bytes => crypto.createHash("sha256").update(bytes).digest("hex");
const runtime = {node:process.version, icu:process.versions.icu,
unicode:process.versions.unicode, ...segmenter.resolvedOptions()};
assert.deepEqual(runtime, expected.runtime, "Different runtime: review version before running this pinned regression");
assert.equal(fixtures.length, 26);
assert.equal(new Set(fixtures.map(f => f.name)).size, 26);
assert.deepEqual(fixtures.map(f => f.name).sort(),
[...Object.keys(expected.accepts), ...expected.rejects].sort());
const results = [];
for (const f of fixtures) {
if (Object.hasOwn(f, "text")) assert.ok(f.text.isWellFormed());
const source = Object.hasOwn(f, "source_hex") ? Buffer.from(f.source_hex,"hex") : Buffer.from(f.text,"utf8");
const originalBytes = Buffer.from(source);
const candidate = Object.freeze({version:1, unit:"utf8-byte",
source_sha256:f.bad_hash ? "0".repeat(64) : sha(source),
start:f.start, end:f.end, quote:f.quote});
const originalCitation = JSON.stringify(candidate);
const actual = displayEnvelope(source, candidate);
assert.deepEqual(source, originalBytes, f.name + ": source bytes mutated");
assert.equal(JSON.stringify(candidate), originalCitation, f.name + ": candidate mutated");
const e = expected.accepts[f.name];
if (e) {
assert.deepEqual(actual, {ok:true, citation:{...candidate}, display:{
unit:"utf8-byte", start:e.range[0], end:e.range[1],
utf16_start:e.utf16[0], utf16_end:e.utf16[1], text:e.text}}, f.name);
assert.deepEqual(boundaries(f.text), {utf16:e.boundaries16, utf8:e.boundaries8}, f.name + ": boundary table");
assert.ok(actual.display.start <= candidate.start && candidate.end <= actual.display.end);
assert.equal(source.subarray(actual.display.start, actual.display.end).toString("utf8"), e.text);
assert.equal(f.text.slice(actual.display.utf16_start, actual.display.utf16_end), e.text);
} else {
assert.ok(expected.rejects.includes(f.name));
assert.deepEqual(actual, {ok:false, reason:"invalid_citation"}, f.name);
assert.equal(Object.hasOwn(actual,"display"), false);
}
results.push({name:f.name, input:{source_hex:source.toString("hex"), candidate}, actual});
}
assert.deepEqual(boundaries(""), {utf16:[0], utf8:[0]});
for (const bad of ["\ud800", "\udc00", "a\ud800b"]) {
assert.throws(() => boundaries(bad), {name:"TypeError"});
}
const parts = text => [...segmenter.segment(text)].map(s => s.segment);
const negatives = {
codepoint_only:Array.from("A中😀e\u0301👩\u200d💻Z").slice(4,5).join(""),
slice_only_ri:parts("🇧"),
cropped_suffix_ri:parts("🇦🇧🇨".slice(2)),
full_source_ri:parts("🇦🇧🇨")
};
assert.deepEqual(negatives, expected.negative_controls);
assert.notEqual(negatives.codepoint_only, expected.accepts.combining_only.text);
assert.notEqual(negatives.slice_only_ri[0], expected.accepts.ri_second.text);
assert.notEqual(negatives.cropped_suffix_ri[0], expected.accepts.ri_second.text);
const report = {scope:"Synthetic local Node segmentation and ranges only; no browser rendering or model calls",
runtime, cases:results.length, accepts:Object.keys(expected.accepts).length,
rejects:expected.rejects.length, source_and_citation_unchanged:true,
empty_boundaries_checked:true, invalid_utf16_boundary_inputs:3,
negative_controls:negatives, results};
// Stdout is the complete evidence; callers choose whether to save it.
process.stdout.write(JSON.stringify(report,null,2) + "\n");
成功时 stdout 输出完整报告,失败时 Node 断言会使进程非零退出。原 test_contract.py 继续依靠 Python assert,不能用 -O 跳过检查。新增 Node 测试没有替换或重算原来的 20 条夹具和 40 次两端判定。
23. 让错误实现真的触发失败
check-mutants.cjs 在临时目录复制所需文件,先证明原实现能通过,再分别制造 12 个单处错误。每个错误版本都运行同一份固定测试,并要求出现预定诊断;冻结输入被改写时须出现指定 TypeError,其余错误须出现指定断言。语法错误、缺少文件或导入失败不能算作检出。临时副本在 finally 中清理,不改交付源码。
"use strict";
// Each mutant changes one exact source fragment in a disposable local copy.
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {spawnSync} = require("node:child_process");
const files = ["validator.cjs", "display-envelope.cjs", "test-display.cjs",
"display-fixtures.json", "display-expected.json"];
const read = name => fs.readFileSync(path.join(__dirname, name), "utf8");
const mutations = JSON.parse(read("mutations.json"));
const base = read("display-envelope.cjs");
function node(args) {
const r = spawnSync(process.execPath, args, {
encoding:"utf8", timeout:10000, maxBuffer:4 * 1024 * 1024
});
if (r.error) throw r.error;
assert.equal(r.signal, null, "process killed by signal");
return r;
}
const baseline = node([path.join(__dirname, "test-display.cjs")]);
assert.equal(baseline.status, 0, "unmodified baseline failed: " + baseline.stderr);
assert.equal(mutations.length, 12);
assert.equal(new Set(mutations.map(m => m.name)).size, 12);
const results = [];
const root = fs.mkdtempSync(path.join(os.tmpdir(), "citation-mutants-"));
try {
for (const m of mutations) {
assert.equal(base.split(m.before).length - 1, 1, m.name + ": ambiguous anchor");
const dir = path.join(root, m.name);
fs.mkdirSync(dir);
for (const name of files) fs.copyFileSync(path.join(__dirname, name), path.join(dir, name));
const target = path.join(dir, "display-envelope.cjs");
fs.writeFileSync(target, base.replace(m.before, m.after));
const syntax = node(["--check", target]);
assert.equal(syntax.status, 0, m.name + ": syntax error is not a detected behavior bug");
const run = node([path.join(dir, "test-display.cjs")]);
assert.equal(run.status, 1, m.name + ": mutant survived or failed unexpectedly");
assert.ok(run.stderr.includes(m.diagnostic), m.name + ": wrong failure: " + run.stderr);
results.push({name:m.name, killed:true, diagnostic:m.diagnostic});
}
} finally {
fs.rmSync(root, {recursive:true, force:true});
}
process.stdout.write(JSON.stringify({baseline_passed:true, mutants:results.length, results}, null, 2) + "\n");
mutations.json · 十二个源码变异及指定失败
[
{
"name": "codepoints_as_graphemes",
"before": "[...segmenter.segment(text)].map(x => x.index)",
"after": "[...byteAt16.keys()].slice(0, -1)",
"diagnostic": "AssertionError [ERR_ASSERTION]: combining_only"
},
{
"name": "omit_final_endpoint",
"before": "utf16.push(text.length);",
"after": "/* missing terminal endpoint */",
"diagnostic": "AssertionError [ERR_ASSERTION]: ascii: boundary table"
},
{
"name": "left_strict_instead_of_inclusive",
"before": "b.utf8[left + 1] <= candidate.start",
"after": "b.utf8[left + 1] < candidate.start",
"diagnostic": "AssertionError [ERR_ASSERTION]: ascii"
},
{
"name": "right_inclusive_instead_of_strict",
"before": "b.utf8[right] < candidate.end",
"after": "b.utf8[right] <= candidate.end",
"diagnostic": "AssertionError [ERR_ASSERTION]: ascii"
},
{
"name": "utf16_as_byte_offsets",
"before": "return byteAt16.get(index);",
"after": "return index;",
"diagnostic": "AssertionError [ERR_ASSERTION]: combining_only"
},
{
"name": "allow_unpaired_utf16",
"before": "typeof text !== \"string\" || !text.isWellFormed()",
"after": "typeof text !== \"string\"",
"diagnostic": "AssertionError [ERR_ASSERTION]: Missing expected exception (TypeError)."
},
{
"name": "overwrite_input_citation",
"before": "citation:{...candidate},",
"after": "citation:Object.assign(candidate, {start:b.utf8[left], end:b.utf8[right], quote:text.slice(b.utf16[left], b.utf16[right])}),",
"diagnostic": "TypeError: Cannot assign to read only property 'start' of object '#<Object>'"
},
{
"name": "expand_evidence_copy",
"before": "citation:{...candidate},",
"after": "citation:{...candidate, start:b.utf8[left], end:b.utf8[right], quote:text.slice(b.utf16[left], b.utf16[right])},",
"diagnostic": "AssertionError [ERR_ASSERTION]: combining_only"
},
{
"name": "skip_citation_validation",
"before": "if (!validate(source, candidate).ok) return {ok:false, reason:\"invalid_citation\"};",
"after": "/* erroneously skip citation validation */",
"diagnostic": "AssertionError [ERR_ASSERTION]: empty_range"
},
{
"name": "normalize_before_display",
"before": "const text = decode(source);",
"after": "const text = decode(source).normalize(\"NFC\");",
"diagnostic": "AssertionError [ERR_ASSERTION]: combining_only"
},
{
"name": "quote_only_segmentation",
"before": "const b = boundaries(text);",
"after": "const local = boundaries(candidate.quote);\n const prefix16 = decode(source.subarray(0,candidate.start)).length;\n const b = {utf8:local.utf8.map(x => x+candidate.start), utf16:local.utf16.map(x => x+prefix16)};",
"diagnostic": "AssertionError [ERR_ASSERTION]: combining_only"
},
{
"name": "cropped_suffix_segmentation",
"before": "const b = boundaries(text);",
"after": "const local = boundaries(decode(source.subarray(candidate.start)));\n const prefix16 = decode(source.subarray(0,candidate.start)).length;\n const b = {utf8:local.utf8.map(x => x+candidate.start), utf16:local.utf16.map(x => x+prefix16)};",
"diagnostic": "AssertionError [ERR_ASSERTION]: combining_only"
}
]
sample-mutants.json · 十二个变异的固定结果
{
"baseline_passed": true,
"mutants": 12,
"results": [
{
"name": "codepoints_as_graphemes",
"killed": true,
"diagnostic": "AssertionError [ERR_ASSERTION]: combining_only"
},
{
"name": "omit_final_endpoint",
"killed": true,
"diagnostic": "AssertionError [ERR_ASSERTION]: ascii: boundary table"
},
{
"name": "left_strict_instead_of_inclusive",
"killed": true,
"diagnostic": "AssertionError [ERR_ASSERTION]: ascii"
},
{
"name": "right_inclusive_instead_of_strict",
"killed": true,
"diagnostic": "AssertionError [ERR_ASSERTION]: ascii"
},
{
"name": "utf16_as_byte_offsets",
"killed": true,
"diagnostic": "AssertionError [ERR_ASSERTION]: combining_only"
},
{
"name": "allow_unpaired_utf16",
"killed": true,
"diagnostic": "AssertionError [ERR_ASSERTION]: Missing expected exception (TypeError)."
},
{
"name": "overwrite_input_citation",
"killed": true,
"diagnostic": "TypeError: Cannot assign to read only property 'start' of object '#<Object>'"
},
{
"name": "expand_evidence_copy",
"killed": true,
"diagnostic": "AssertionError [ERR_ASSERTION]: combining_only"
},
{
"name": "skip_citation_validation",
"killed": true,
"diagnostic": "AssertionError [ERR_ASSERTION]: empty_range"
},
{
"name": "normalize_before_display",
"killed": true,
"diagnostic": "AssertionError [ERR_ASSERTION]: combining_only"
},
{
"name": "quote_only_segmentation",
"killed": true,
"diagnostic": "AssertionError [ERR_ASSERTION]: combining_only"
},
{
"name": "cropped_suffix_segmentation",
"killed": true,
"diagnostic": "AssertionError [ERR_ASSERTION]: combining_only"
}
]
}
这些定向变异用于确认关键断言会响,不代表穷尽所有错误程序。独立固定预期仍是主判据;只证明两个实现给出相同结果,并不能排除两者一起算错。
24. 复跑结果与运行时边界
本次原合同仍输出 20 条固定夹具、40 次验收判定通过,并通过 Python 生产 → Node 验收 → Node 重算 → Python 回验的真实进程往返。新增测试输出 26 条案例,其中 14 条接受、12 条拒绝;三个非法 UTF-16 输入和错误策略负控另计,不把它们混称为 26 条引用案例。完整报告如下。
{
"scope": "Synthetic local Node segmentation and ranges only; no browser rendering or model calls",
"runtime": {
"node": "v24.19.0",
"icu": "78.3",
"unicode": "17.0",
"locale": "en",
"granularity": "grapheme"
},
"cases": 26,
"accepts": 14,
"rejects": 12,
"source_and_citation_unchanged": true,
"empty_boundaries_checked": true,
"invalid_utf16_boundary_inputs": 3,
"negative_controls": {
"codepoint_only": "́",
"slice_only_ri": [
"🇧"
],
"cropped_suffix_ri": [
"🇧🇨"
],
"full_source_ri": [
"🇦🇧",
"🇨"
]
}
}sample-output.json · 本次完整输入与实际结果
{
"scope": "Synthetic local Node segmentation and ranges only; no browser rendering or model calls",
"runtime": {
"node": "v24.19.0",
"icu": "78.3",
"unicode": "17.0",
"locale": "en",
"granularity": "grapheme"
},
"cases": 26,
"accepts": 14,
"rejects": 12,
"source_and_citation_unchanged": true,
"empty_boundaries_checked": true,
"invalid_utf16_boundary_inputs": 3,
"negative_controls": {
"codepoint_only": "́",
"slice_only_ri": [
"🇧"
],
"cropped_suffix_ri": [
"🇧🇨"
],
"full_source_ri": [
"🇦🇧",
"🇨"
]
},
"results": [
{
"name": "ascii",
"input": {
"source_hex": "616263",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
"start": 1,
"end": 2,
"quote": "b"
}
},
"actual": {
"ok": true,
"citation": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
"start": 1,
"end": 2,
"quote": "b"
},
"display": {
"unit": "utf8-byte",
"start": 1,
"end": 2,
"utf16_start": 1,
"utf16_end": 2,
"text": "b"
}
}
},
{
"name": "combining_only",
"input": {
"source_hex": "41e4b8adf09f988065cc81f09f91a9e2808df09f92bb5a",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "db1fda0a46292352d98160a07d02601330ad006ed399c4e05f7736417a56ac1b",
"start": 9,
"end": 11,
"quote": "́"
}
},
"actual": {
"ok": true,
"citation": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "db1fda0a46292352d98160a07d02601330ad006ed399c4e05f7736417a56ac1b",
"start": 9,
"end": 11,
"quote": "́"
},
"display": {
"unit": "utf8-byte",
"start": 8,
"end": 11,
"utf16_start": 4,
"utf16_end": 6,
"text": "é"
}
}
},
{
"name": "zwj_only",
"input": {
"source_hex": "f09f91a9e2808df09f92bb",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "427274538f1f24ef137872891551ffb4263f6edd90c80c533534578e8a5a9893",
"start": 4,
"end": 7,
"quote": ""
}
},
"actual": {
"ok": true,
"citation": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "427274538f1f24ef137872891551ffb4263f6edd90c80c533534578e8a5a9893",
"start": 4,
"end": 7,
"quote": ""
},
"display": {
"unit": "utf8-byte",
"start": 0,
"end": 11,
"utf16_start": 0,
"utf16_end": 5,
"text": "👩💻"
}
}
},
{
"name": "modifier_only",
"input": {
"source_hex": "f09f918df09f8fbd",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "01a5f86b6044eee022d2ee109e38bd9d845e7a650fb9f4a413e60aeb858ecae4",
"start": 4,
"end": 8,
"quote": "🏽"
}
},
"actual": {
"ok": true,
"citation": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "01a5f86b6044eee022d2ee109e38bd9d845e7a650fb9f4a413e60aeb858ecae4",
"start": 4,
"end": 8,
"quote": "🏽"
},
"display": {
"unit": "utf8-byte",
"start": 0,
"end": 8,
"utf16_start": 0,
"utf16_end": 4,
"text": "👍🏽"
}
}
},
{
"name": "variation_selector",
"input": {
"source_hex": "e29c88efb88f",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "185beb968bd1a81d07ebcf82376642f7b29f1b5594b21fe9edee714efbdcaa44",
"start": 3,
"end": 6,
"quote": "️"
}
},
"actual": {
"ok": true,
"citation": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "185beb968bd1a81d07ebcf82376642f7b29f1b5594b21fe9edee714efbdcaa44",
"start": 3,
"end": 6,
"quote": "️"
},
"display": {
"unit": "utf8-byte",
"start": 0,
"end": 6,
"utf16_start": 0,
"utf16_end": 2,
"text": "✈️"
}
}
},
{
"name": "ri_second",
"input": {
"source_hex": "f09f87a6f09f87a7f09f87a8",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "dfb867388d6368cc1f990a6105993de4744b24f349440d14fa491842e21d2516",
"start": 4,
"end": 8,
"quote": "🇧"
}
},
"actual": {
"ok": true,
"citation": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "dfb867388d6368cc1f990a6105993de4744b24f349440d14fa491842e21d2516",
"start": 4,
"end": 8,
"quote": "🇧"
},
"display": {
"unit": "utf8-byte",
"start": 0,
"end": 8,
"utf16_start": 0,
"utf16_end": 4,
"text": "🇦🇧"
}
}
},
{
"name": "ri_third",
"input": {
"source_hex": "f09f87a6f09f87a7f09f87a8",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "dfb867388d6368cc1f990a6105993de4744b24f349440d14fa491842e21d2516",
"start": 8,
"end": 12,
"quote": "🇨"
}
},
"actual": {
"ok": true,
"citation": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "dfb867388d6368cc1f990a6105993de4744b24f349440d14fa491842e21d2516",
"start": 8,
"end": 12,
"quote": "🇨"
},
"display": {
"unit": "utf8-byte",
"start": 8,
"end": 12,
"utf16_start": 4,
"utf16_end": 6,
"text": "🇨"
}
}
},
{
"name": "ri_fourth",
"input": {
"source_hex": "f09f87a6f09f87a7f09f87a8f09f87a9",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "91e878a684801cfed9d845f76f2f53e81c2dfd87f26661f648996682e204a0af",
"start": 12,
"end": 16,
"quote": "🇩"
}
},
"actual": {
"ok": true,
"citation": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "91e878a684801cfed9d845f76f2f53e81c2dfd87f26661f648996682e204a0af",
"start": 12,
"end": 16,
"quote": "🇩"
},
"display": {
"unit": "utf8-byte",
"start": 8,
"end": 16,
"utf16_start": 4,
"utf16_end": 8,
"text": "🇨🇩"
}
}
},
{
"name": "crlf_lf",
"input": {
"source_hex": "780d0a79",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "b81d54de3d39c210c9579f8b7f4c0cf68d4394a068c150ae6d2556371675b32d",
"start": 2,
"end": 3,
"quote": "\n"
}
},
"actual": {
"ok": true,
"citation": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "b81d54de3d39c210c9579f8b7f4c0cf68d4394a068c150ae6d2556371675b32d",
"start": 2,
"end": 3,
"quote": "\n"
},
"display": {
"unit": "utf8-byte",
"start": 1,
"end": 3,
"utf16_start": 1,
"utf16_end": 3,
"text": "\r\n"
}
}
},
{
"name": "exact_endpoint",
"input": {
"source_hex": "65cc815a",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "f89c3c1420c486009f48648d3dc2840230ce0148206de22b167416ee41466712",
"start": 0,
"end": 3,
"quote": "é"
}
},
"actual": {
"ok": true,
"citation": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "f89c3c1420c486009f48648d3dc2840230ce0148206de22b167416ee41466712",
"start": 0,
"end": 3,
"quote": "é"
},
"display": {
"unit": "utf8-byte",
"start": 0,
"end": 3,
"utf16_start": 0,
"utf16_end": 2,
"text": "é"
}
}
},
{
"name": "cross_two_clusters",
"input": {
"source_hex": "65cc81f09f91a9e2808df09f92bb78",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "0a35492689b688a78dae093d45ce187184367a758c2e16b3fd93c00d8abbb137",
"start": 1,
"end": 10,
"quote": "́👩"
}
},
"actual": {
"ok": true,
"citation": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "0a35492689b688a78dae093d45ce187184367a758c2e16b3fd93c00d8abbb137",
"start": 1,
"end": 10,
"quote": "́👩"
},
"display": {
"unit": "utf8-byte",
"start": 0,
"end": 14,
"utf16_start": 0,
"utf16_end": 7,
"text": "é👩💻"
}
}
},
{
"name": "bom_retained",
"input": {
"source_hex": "efbbbf65cc81",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "891b14e2aaf57527c79f9d96159fe1967a6140064d91c166521cbda1cd61556e",
"start": 4,
"end": 6,
"quote": "́"
}
},
"actual": {
"ok": true,
"citation": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "891b14e2aaf57527c79f9d96159fe1967a6140064d91c166521cbda1cd61556e",
"start": 4,
"end": 6,
"quote": "́"
},
"display": {
"unit": "utf8-byte",
"start": 3,
"end": 6,
"utf16_start": 1,
"utf16_end": 3,
"text": "é"
}
}
},
{
"name": "leading_combining",
"input": {
"source_hex": "cc8178",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "2f9e1ecca62f443d014d82c29b10c86d34a1ecfa79e9285bc0a3e0fb29b731da",
"start": 0,
"end": 2,
"quote": "́"
}
},
"actual": {
"ok": true,
"citation": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "2f9e1ecca62f443d014d82c29b10c86d34a1ecfa79e9285bc0a3e0fb29b731da",
"start": 0,
"end": 2,
"quote": "́"
},
"display": {
"unit": "utf8-byte",
"start": 0,
"end": 2,
"utf16_start": 0,
"utf16_end": 1,
"text": "́"
}
}
},
{
"name": "last_scalar",
"input": {
"source_hex": "f09f98805a",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "ac1e3834fa959756b195de0adc687625bbb4b19ef634b71b06e846ceb3e81519",
"start": 4,
"end": 5,
"quote": "Z"
}
},
"actual": {
"ok": true,
"citation": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "ac1e3834fa959756b195de0adc687625bbb4b19ef634b71b06e846ceb3e81519",
"start": 4,
"end": 5,
"quote": "Z"
},
"display": {
"unit": "utf8-byte",
"start": 4,
"end": 5,
"utf16_start": 2,
"utf16_end": 3,
"text": "Z"
}
}
},
{
"name": "empty_range",
"input": {
"source_hex": "65cc81",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "bf12767b0f2a56b2190075bae8169f656e3ce8d6357d4aff184bc6c7ea48f9f6",
"start": 1,
"end": 1,
"quote": ""
}
},
"actual": {
"ok": false,
"reason": "invalid_citation"
}
},
{
"name": "empty_source",
"input": {
"source_hex": "",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"start": 0,
"end": 0,
"quote": ""
}
},
"actual": {
"ok": false,
"reason": "invalid_citation"
}
},
{
"name": "middle_of_utf8",
"input": {
"source_hex": "f09f9880",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "f0443a342c5ef54783a111b51ba56c938e474c32324d90c3a60c9c8e3a37e2d9",
"start": 1,
"end": 4,
"quote": "😀"
}
},
"actual": {
"ok": false,
"reason": "invalid_citation"
}
},
{
"name": "quote_mismatch",
"input": {
"source_hex": "616263",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
"start": 0,
"end": 1,
"quote": "b"
}
},
"actual": {
"ok": false,
"reason": "invalid_citation"
}
},
{
"name": "hash_mismatch",
"input": {
"source_hex": "616263",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"start": 0,
"end": 1,
"quote": "a"
}
},
"actual": {
"ok": false,
"reason": "invalid_citation"
}
},
{
"name": "high_surrogate_quote",
"input": {
"source_hex": "616263",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
"start": 0,
"end": 1,
"quote": "\ud800"
}
},
"actual": {
"ok": false,
"reason": "invalid_citation"
}
},
{
"name": "low_surrogate_quote",
"input": {
"source_hex": "616263",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
"start": 0,
"end": 1,
"quote": "\udc00"
}
},
"actual": {
"ok": false,
"reason": "invalid_citation"
}
},
{
"name": "invalid_utf8_source",
"input": {
"source_hex": "61eda080",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "25819b9b43d499092eb2be7b6f27ae28439eee434cea4490191ab4ccb8f3409c",
"start": 0,
"end": 1,
"quote": "a"
}
},
"actual": {
"ok": false,
"reason": "invalid_citation"
}
},
{
"name": "out_of_bounds",
"input": {
"source_hex": "616263",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
"start": 0,
"end": 4,
"quote": "abc"
}
},
"actual": {
"ok": false,
"reason": "invalid_citation"
}
},
{
"name": "boolean_offset",
"input": {
"source_hex": "616263",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
"start": true,
"end": 2,
"quote": "b"
}
},
"actual": {
"ok": false,
"reason": "invalid_citation"
}
},
{
"name": "fractional_offset",
"input": {
"source_hex": "616263",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
"start": 1.5,
"end": 2,
"quote": "b"
}
},
"actual": {
"ok": false,
"reason": "invalid_citation"
}
},
{
"name": "reversed_range",
"input": {
"source_hex": "616263",
"candidate": {
"version": 1,
"unit": "utf8-byte",
"source_sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
"start": 2,
"end": 1,
"quote": "b"
}
},
"actual": {
"ok": false,
"reason": "invalid_citation"
}
}
]
}
代码源文件、从文章代码块重新提取的文件,以及公开 ZIP 解压后的文件分别复跑原回归、新测试和定向变异。新测试连续两次输出逐字节相同。图 4 按 1120 × 820 原始尺寸渲染检查;HTML 验收包括静态结构、内联文件一致性和图像尺寸,没有启动浏览器做排版或像素验收。
实测配置固定为 Node v24.19.0、ICU 78.3、Unicode 17.0,Segmenter 请求并解析为 locale=en、granularity=grapheme。Python 3.12.14 只运行原合同与往返测试,没有与 Node 对齐一套 Python 字素分段器。相同版本字符串也不能代替对自定义构建和业务输入的检查。
升级运行时导致固定预期失败时,先保留失败输出和版本信息,检查官方规则与变化样本,再决定是否更新应用分段合同和预期。不要删掉版本断言、自动重生成期望,或把环境错误记成普通 invalid_citation。显示计算失败时保留已经验收的证据,明确该次展示计算未完成;缺少 Intl.Segmenter 或其他程序异常应向调用者抛出。
25. 分段资料与接入前需要补齐的部分
补充资料核对日期为 2026-10-13。规则引用固定在 Unicode 17.0.0 的 UAX #29 revision 47,其中 GB3、GB9、GB11 与 GB12/GB13 分别解释本节的 CRLF、延续字符、指定 emoji ZWJ 序列和区域指示符案例。本文的输入是手写合成夹具,没有运行 Unicode 全量一致性测试。
ECMA-402 FindBoundary允许实现相关的边界行为;Node v24.19.0 的国际化文档说明 ICU 构建和数据配置可能不同。本文记录本机实际值,不能从 Node 主版本推断所有机器都相同,也不能据此承诺浏览器分段一致。
本节仍只接收已解析的普通对象和受控合成夹具,没有新增原始 JSON 数字词法、重复键、代理对象或恶意十六进制输入验证。原文说明的 1.0 解析差异与来源授权限制继续存在。额外字段的浅复制也不能当作任意对象的安全隔离。
包络只提供逻辑坐标;它不决定字体、字形、双向文字排版、屏幕宽度或 DOM 文本节点映射。真实高亮组件还要把这一份未变快照映到页面实际文本,并明确展示附加上下文的样式。若经过 NFC、OCR 修正、HTML 清理或换行改写,则须保存转换映射或建立新快照,不能直接套用这里的坐标。引用是否支持结论、文档是否获准使用,仍需各自独立判断。