verifact 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,292 @@
1
+ 'use strict';
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * 处置分类(dispositions)解析器。
6
+ *
7
+ * 设计来源:docs/rfc-verifact-dispositions.md(本仓,2026-10-26 合并)。
8
+ *
9
+ * 三条不可协商的规则:
10
+ * 1. 非 code-artifact 的声明必须指名「这个数字由谁拥有」(artifact / owner / why),
11
+ * 缺失即**报错**,不是静默通过 —— 豁免必须付费。
12
+ * 2. 只有 owner 可以从所在区域继承;artifact / since / why 必须就地声明,
13
+ * 否则一个区域头就成了几十条声明的共同借口。
14
+ * 3. 禁止推断:本模块只读声明,绝不根据数值形状或上下文猜语义。
15
+ *
16
+ * 语法(HTML 注释,GitHub 渲染不可见):
17
+ * <!-- verifact: disposition=narrative why="..." --> 文件级(前 5 行)
18
+ * <!-- verifact: begin disposition=progress owner=docs/CHECKLIST.md --> ... <!-- verifact: end -->
19
+ * | 合计 | 199/199 | <!-- verifact: disposition=progress --> 单条级(区域里可继承 owner)
20
+ */
21
+
22
+ const FILE_LEVEL_MAX_LINE = 5;
23
+
24
+ const DISPOSITIONS = ['code-artifact', 'measured-once', 'progress', 'narrative'];
25
+
26
+ const REQUIRED_ARGS = {
27
+ 'code-artifact': [],
28
+ 'measured-once': ['artifact'],
29
+ progress: ['owner'],
30
+ narrative: ['why']
31
+ };
32
+
33
+ // 只有 owner 可继承:其余参数逐条不同,继承等于把「每条都有出处」变成「整段共用一句借口」。
34
+ const INHERITABLE = new Set(['owner']);
35
+
36
+ const ANNOTATION_RE = /<!--\s*verifact:\s*([\s\S]*?)-->/;
37
+ const OWN_LINE_RE = /^\s*<!--\s*verifact:[\s\S]*?-->\s*$/;
38
+
39
+ /** 等长空白替换,保留换行,行号不变。 */
40
+ function blank(text) {
41
+ return String(text).replace(/[^\n]/g, ' ');
42
+ }
43
+
44
+ /**
45
+ * 遮蔽围栏代码块(``` / ~~~),保留行号。
46
+ *
47
+ * 理由:文档里的示例声明写在围栏里,那是**示例**,不是生效声明。
48
+ * 本仓 claims 抽取早已这么做了(claims.js stripNoise),dispositions 之前漏了这一步:
49
+ * `docs/rfc-verifact-dispositions.md:192` 举例 `external-snapshot`(未实现的第五类),
50
+ * 解析器把示例当真 → 整份仓的门禁以 disposition_error 崩掉。
51
+ * 未闭合的围栏按 CommonMark 语义一直遮到文件末尾。
52
+ */
53
+ function blankFencedBlocks(text) {
54
+ const lines = String(text).split('\n');
55
+ let fence = null; // { marker: '`' | '~', len: number }
56
+ for (let i = 0; i < lines.length; i++) {
57
+ const line = lines[i];
58
+ if (fence) {
59
+ lines[i] = blank(line);
60
+ const close = new RegExp('^[ \\t]{0,3}' + fence.marker + '{' + fence.len + ',}[ \\t]*$');
61
+ if (close.test(line)) fence = null;
62
+ continue;
63
+ }
64
+ const open = /^[ \t]{0,3}(`{3,}|~{3,})[^\n]*$/.exec(line);
65
+ if (open) {
66
+ fence = { marker: open[1][0], len: open[1].length };
67
+ lines[i] = blank(line);
68
+ }
69
+ }
70
+ return lines.join('\n');
71
+ }
72
+
73
+ /**
74
+ * 不可执行的散文之外:围栏代码块 + 行内代码。与 claims.js stripNoise 一致。
75
+ * RFC 里 `- 逐行扫描时优先匹配 `<!-- verifact: ... -->`;` 这种行内示例也是示例。
76
+ */
77
+ function maskNonProse(text) {
78
+ return blankFencedBlocks(text).replace(/`[^`\n]*`/g, (m) => blank(m));
79
+ }
80
+
81
+ class DispositionError extends Error {
82
+ constructor(fileRef, line, message) {
83
+ super(fileRef + ':' + line + ': ' + message);
84
+ this.name = 'DispositionError';
85
+ this.file = String(fileRef);
86
+ this.line = line;
87
+ this.reason = 'disposition_error';
88
+ }
89
+ }
90
+
91
+ /** 按空白切分,但保留引号内的空格(why="a b c")。 */
92
+ function splitArgs(text) {
93
+ const out = [];
94
+ let cur = '';
95
+ let quote = null;
96
+ for (const ch of String(text)) {
97
+ if (quote) {
98
+ if (ch === quote) quote = null;
99
+ else cur += ch;
100
+ continue;
101
+ }
102
+ if (ch === '"' || ch === "'") { quote = ch; continue; }
103
+ if (/\s/.test(ch)) {
104
+ if (cur) { out.push(cur); cur = ''; }
105
+ continue;
106
+ }
107
+ cur += ch;
108
+ }
109
+ if (quote) throw new Error('引号未闭合');
110
+ if (cur) out.push(cur);
111
+ return out;
112
+ }
113
+
114
+ function parseDirective(inner, fileRef, lineNo) {
115
+ let parts;
116
+ try {
117
+ parts = splitArgs(inner);
118
+ } catch (e) {
119
+ throw new DispositionError(fileRef, lineNo, '处置声明无法解析:' + e.message);
120
+ }
121
+ if (!parts.length) throw new DispositionError(fileRef, lineNo, '处置声明为空');
122
+
123
+ let kind = 'single';
124
+ if (parts[0] === 'begin' || parts[0] === 'end') { kind = parts.shift(); }
125
+
126
+ if (kind === 'end') {
127
+ if (parts.length) {
128
+ throw new DispositionError(fileRef, lineNo, 'end 不接受参数(多余:' + parts.join(' ') + ')');
129
+ }
130
+ return { kind, line: lineNo };
131
+ }
132
+
133
+ let disposition = null;
134
+ const args = {};
135
+ for (const p of parts) {
136
+ const eq = p.indexOf('=');
137
+ if (eq < 0) {
138
+ throw new DispositionError(fileRef, lineNo, '参数缺少 "=":' + p + '(期望 key=value)');
139
+ }
140
+ const k = p.slice(0, eq).trim();
141
+ const v = p.slice(eq + 1).trim();
142
+ if (!k) throw new DispositionError(fileRef, lineNo, '参数名为空:' + p);
143
+ if (!v) throw new DispositionError(fileRef, lineNo, '参数值为空:' + k);
144
+ if (k === 'disposition') disposition = v;
145
+ else args[k] = v;
146
+ }
147
+
148
+ if (!disposition) {
149
+ throw new DispositionError(fileRef, lineNo, '缺少 disposition=(' + DISPOSITIONS.join(' | ') + ')');
150
+ }
151
+ if (DISPOSITIONS.indexOf(disposition) < 0) {
152
+ // fail closed:拼错的 narrativ 不允许退化成默认值,也不允许被猜成 narrative
153
+ throw new DispositionError(fileRef, lineNo,
154
+ '未知 disposition="' + disposition + '"(允许:' + DISPOSITIONS.join(' | ') + ')');
155
+ }
156
+
157
+ const extra = Object.keys(args);
158
+ if (disposition === 'code-artifact' && extra.length) {
159
+ throw new DispositionError(fileRef, lineNo, 'code-artifact 不接受参数(多余:' + extra.join(', ') + ')');
160
+ }
161
+
162
+ return { kind, disposition, args, line: lineNo };
163
+ }
164
+
165
+ /**
166
+ * 必填参数校验。inherited 只提供区域里**允许继承**的那几项(当前只有 owner),
167
+ * 其余缺失一律报错 —— 这是「豁免必须指名拥有者」的执行点。
168
+ */
169
+ function requireArgs(directive, inherited, fileRef, lineNo) {
170
+ const need = REQUIRED_ARGS[directive.disposition] || [];
171
+ const args = Object.assign({}, directive.args);
172
+ for (const key of need) {
173
+ if (args[key]) continue;
174
+ if (inherited && INHERITABLE.has(key) && inherited[key]) {
175
+ args[key] = inherited[key];
176
+ continue;
177
+ }
178
+ throw new DispositionError(fileRef, lineNo,
179
+ directive.disposition + ' 必须声明 ' + key + '=(否则豁免就是无主数字)');
180
+ }
181
+ return Object.assign({}, directive, { args });
182
+ }
183
+
184
+ /**
185
+ * 扫描整篇文本,建立行号 -> 处置 的映射。
186
+ * 返回 { resolve(lineNo), regions, fileLevel, hasAny }。
187
+ */
188
+ function buildDispositionScopes(text, fileRef) {
189
+ const normalized = String(text).replace(/\r\n?/g, '\n');
190
+ // 围栏 / 行内代码里的示例声明不生效(与 claims 抽取的遮蔽口径一致)
191
+ const masked = maskNonProse(normalized);
192
+ const lines = masked.split('\n');
193
+
194
+ const regions = [];
195
+ const sameLine = new Map(); // 行号 -> 声明(写在代码行尾)
196
+ const appliesToNext = new Map(); // 行号 -> 声明(独立成行,作用于下一行)
197
+ let fileLevel = null;
198
+ const stack = [];
199
+
200
+ const DEFAULT = { disposition: 'code-artifact', args: {}, source: 'default', at: null };
201
+ const hasAny = /<!--\s*verifact:/.test(masked);
202
+
203
+ for (let i = 0; i < lines.length; i++) {
204
+ const raw = lines[i];
205
+ const m = ANNOTATION_RE.exec(raw);
206
+ if (!m) continue;
207
+
208
+ const lineNo = i + 1;
209
+ const own = OWN_LINE_RE.test(raw);
210
+ const parsed = parseDirective(m[1], fileRef, lineNo);
211
+ const top = stack.length ? stack[stack.length - 1] : null;
212
+
213
+ if (parsed.kind === 'begin') {
214
+ if (top && top.disposition !== parsed.disposition) {
215
+ throw new DispositionError(fileRef, lineNo,
216
+ '不允许嵌套不同 disposition(外层 ' + top.disposition + ',内层 ' + parsed.disposition + ')');
217
+ }
218
+ const d = requireArgs(parsed, null, fileRef, lineNo);
219
+ const region = { from: lineNo, to: null, disposition: d.disposition, args: d.args, at: lineNo };
220
+ regions.push(region);
221
+ stack.push(region);
222
+ continue;
223
+ }
224
+
225
+ if (parsed.kind === 'end') {
226
+ if (!top) throw new DispositionError(fileRef, lineNo, 'end 没有对应的 begin');
227
+ stack.pop().to = lineNo;
228
+ continue;
229
+ }
230
+
231
+ // 单条级:允许出现在区域内,但 disposition 必须与区域一致(否则是歧义,报错而不是让某一方静默获胜);
232
+ // 必填参数里只有 owner 能向区域借,其余必须就地写。
233
+ if (top && top.disposition !== parsed.disposition) {
234
+ throw new DispositionError(fileRef, lineNo,
235
+ '单条处置与所在区域冲突(区域 ' + top.disposition + ',本条 ' + parsed.disposition + ')');
236
+ }
237
+ const d = requireArgs(parsed, top ? top.args : null, fileRef, lineNo);
238
+ const entry = { disposition: d.disposition, args: d.args, at: lineNo };
239
+
240
+ if (own) {
241
+ if (lineNo <= FILE_LEVEL_MAX_LINE) {
242
+ if (fileLevel) {
243
+ throw new DispositionError(fileRef, lineNo,
244
+ '文件级处置重复声明(前 ' + FILE_LEVEL_MAX_LINE + ' 行只允许一条)');
245
+ }
246
+ fileLevel = entry;
247
+ } else {
248
+ appliesToNext.set(lineNo + 1, entry);
249
+ }
250
+ } else {
251
+ sameLine.set(lineNo, entry);
252
+ }
253
+ }
254
+
255
+ if (stack.length) {
256
+ const open = stack[stack.length - 1];
257
+ throw new DispositionError(fileRef, open.at,
258
+ 'begin 没有配对的 end(区域从第 ' + open.at + ' 行开始)');
259
+ }
260
+
261
+ function resolve(lineNo) {
262
+ const same = sameLine.get(lineNo);
263
+ const prev = appliesToNext.get(lineNo);
264
+ let region = null;
265
+ for (let r = regions.length - 1; r >= 0; r--) {
266
+ if (lineNo > regions[r].from && lineNo < regions[r].to) { region = regions[r]; break; }
267
+ }
268
+
269
+ // 区域 vs 单条:单条更具体,优先(单条的 disposition 已校验与区域一致)
270
+ if (same) return { disposition: same.disposition, args: same.args, source: 'inline', at: same.at };
271
+ if (prev) return { disposition: prev.disposition, args: prev.args, source: 'inline', at: prev.at };
272
+ if (region) return { disposition: region.disposition, args: region.args, source: 'region', at: region.at };
273
+ if (fileLevel) return { disposition: fileLevel.disposition, args: fileLevel.args, source: 'file', at: fileLevel.at };
274
+ return Object.assign({}, DEFAULT);
275
+ }
276
+
277
+ return { resolve, regions, fileLevel, hasAny };
278
+ }
279
+
280
+ module.exports = {
281
+ DISPOSITIONS,
282
+ REQUIRED_ARGS,
283
+ INHERITABLE,
284
+ FILE_LEVEL_MAX_LINE,
285
+ DispositionError,
286
+ blankFencedBlocks,
287
+ maskNonProse,
288
+ buildDispositionScopes,
289
+ parseDirective,
290
+ requireArgs,
291
+ splitArgs
292
+ };
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * 用语 / 绝对化检测。规则来自 FIBEMATE 自有投稿纪律,默认全开、可逐条关闭。
6
+ * 输出的是「需人工复核的写作信号」,不是漏洞,也不是定稿判决。
7
+ */
8
+
9
+ const RULES = [
10
+ {
11
+ id: 'V-A001', severity: 'high', title: '绝对化全球性主张',
12
+ re: /全球(首个|第一|唯一|最先|领先)|世界(首个|第一|唯一)|业界(首个|唯一)|国际上首个/g,
13
+ hint: '绝对化范围主张需要可核验来源;若无法核验,改写为限定范围的表述'
14
+ },
15
+ {
16
+ id: 'V-A002', severity: 'high', title: '安全性绝对断言',
17
+ re: /(绝对安全|完全安全|不可能被破解|无法被破解|不可被攻破|零风险|100%\s*安全|never\s+be\s+broken|unbreakable)/gi,
18
+ hint: '密码学结论不存在绝对性,改为给出攻击模型与边界条件'
19
+ },
20
+ {
21
+ id: 'V-A003', severity: 'medium', title: '未标注依据的最高级/唯一性表述',
22
+ re: /(最强|最快|最安全|最领先|唯一能够|唯一实现|首个实现|首创)/g,
23
+ hint: '最高级需绑到具体对比对象与指标;无依据时降级为陈述'
24
+ },
25
+ {
26
+ id: 'V-S001', severity: 'medium', title: '禁用词',
27
+ re: /(坑|其实|值得一提|恰恰|有点|挺|蛮|话说回来)/g,
28
+ hint: 'FIBEMATE 稿规:虚词与口语直接删'
29
+ },
30
+ {
31
+ id: 'V-S002', severity: 'medium', title: '禁用「值得X」句式',
32
+ re: /值得(?!注意|警惕|说明的)(\S{0,4})/g,
33
+ hint: '稿规:动词直接接宾语,去掉「值得」'
34
+ },
35
+ {
36
+ id: 'V-D001', severity: 'low', title: '未完成占位',
37
+ re: /(TODO|FIXME|XXX|<\s*待补[^>]*>|\?\?\?)/g,
38
+ hint: '发稿前清除占位标记'
39
+ }
40
+ ];
41
+
42
+ function lintText(text, fileRef, opts) {
43
+ const options = opts || {};
44
+ const disabled = new Set((options.lint && options.lint.disable) || []);
45
+ const extra = (options.lint && options.lint.extra) || [];
46
+ const active = RULES.filter((r) => !disabled.has(r.id)).concat(extra);
47
+ const lines = String(text).replace(/\r\n?/g, '\n').split('\n');
48
+ const findings = [];
49
+
50
+ // 命中词出现在「不要 / 并非 / 幻觉」这类否定或讨论语境里时,是论证对象而非主张本身:
51
+ // 降级为 low,避免把自查文档自己打成高危。
52
+ const NEGATION_CUE = /(不要|不能|并非|绝非|不代表|不等同|避免|警惕|幻觉|而非|并非的)/;
53
+ for (let i = 0; i < lines.length; i++) {
54
+ const line = lines[i];
55
+ for (const rule of active) {
56
+ rule.re.lastIndex = 0;
57
+ let m;
58
+ while ((m = rule.re.exec(line)) !== null) {
59
+ if (!m[0]) { rule.re.lastIndex++; continue; }
60
+ const ahead = line.slice(Math.max(0, m.index - 14), m.index);
61
+ const quoted = /[「“"']\s*$/.test(line.slice(Math.max(0, m.index - 2), m.index));
62
+ const negated = NEGATION_CUE.test(ahead);
63
+ findings.push({
64
+ id: (String(fileRef).split('/').pop()) + ':L' + (i + 1) + ':' + rule.id,
65
+ file: fileRef,
66
+ line: i + 1,
67
+ ruleId: rule.id,
68
+ severity: negated ? 'low' : rule.severity,
69
+ baseSeverity: rule.severity,
70
+ downgraded: negated,
71
+ quoted,
72
+ title: rule.title,
73
+ matched: m[0],
74
+ sentence: line.trim().slice(0, 200),
75
+ hint: negated ? '命中词处于否定/讨论语境(' + (NEGATION_CUE.exec(ahead) || [''])[0] + '),已降级为 low:确认它不是主张本身' : rule.hint
76
+ });
77
+ }
78
+ }
79
+ }
80
+ return findings;
81
+ }
82
+
83
+ /** 代码块需要跳过:把 ``` 段落按行号记录,供上层过滤。 */
84
+ function codeLineRanges(text) {
85
+ const lines = String(text).replace(/\r\n?/g, '\n').split('\n');
86
+ const ranges = [];
87
+ let open = false, start = 0;
88
+ for (let i = 0; i < lines.length; i++) {
89
+ const t = lines[i].trim();
90
+ if (/^(```|~~~)/.test(t)) {
91
+ if (!open) { open = true; start = i + 1; }
92
+ else { ranges.push([start, i + 1]); open = false; }
93
+ }
94
+ }
95
+ if (open) ranges.push([start, lines.length]);
96
+ return ranges;
97
+ }
98
+
99
+ function inRanges(n, ranges) {
100
+ for (const r of ranges) if (n >= r[0] && n <= r[1]) return true;
101
+ return false;
102
+ }
103
+
104
+ function lintDoc(text, fileRef, opts) {
105
+ const raw = lintText(text, fileRef, opts);
106
+ if (opts && opts.lint && opts.lint.includeCodeBlocks) return raw;
107
+ const ranges = codeLineRanges(text);
108
+ return raw.filter((f) => !inRanges(f.line, ranges));
109
+ }
110
+
111
+ module.exports = { RULES, lintDoc, lintText, codeLineRanges };
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // 单位表:同一 dim 内可比,base = number * factor。
5
+ // 未知单位或未标注单位 → dim=null,仅按裸数值比对(并在报告里标 degradedComparability)。
6
+ const UNITS = {
7
+ ns: { dim: 'time', factor: 1 },
8
+ nsec: { dim: 'time', factor: 1 },
9
+ us: { dim: 'time', factor: 1e3 },
10
+ 'µs': { dim: 'time', factor: 1e3 },
11
+ 'μs': { dim: 'time', factor: 1e3 },
12
+ ms: { dim: 'time', factor: 1e6 },
13
+ msec: { dim: 'time', factor: 1e6 },
14
+ s: { dim: 'time', factor: 1e9 },
15
+ sec: { dim: 'time', factor: 1e9 },
16
+ b: { dim: 'size', factor: 1 },
17
+ byte: { dim: 'size', factor: 1 },
18
+ bytes: { dim: 'size', factor: 1 },
19
+ kb: { dim: 'size', factor: 1024 },
20
+ kib: { dim: 'size', factor: 1024 },
21
+ mb: { dim: 'size', factor: 1048576 },
22
+ mib: { dim: 'size', factor: 1048576 },
23
+ gb: { dim: 'size', factor: 1073741824 },
24
+ gib: { dim: 'size', factor: 1073741824 },
25
+ pct: { dim: 'pct', factor: 1 },
26
+ states: { dim: 'count', factor: 1 },
27
+ cycles: { dim: 'count', factor: 1 },
28
+ x: { dim: 'ratio', factor: 1 }
29
+ };
30
+
31
+ const PCT_TOKEN = '%';
32
+
33
+ function squash(s) {
34
+ return String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]+/g, '');
35
+ }
36
+
37
+ // CJK 参与 key 上下文(文档多为中英混排),最大 CJK 连续段算一个 token。
38
+ const TOKEN_SPLIT = /[^a-z0-9\u4e00-\u9fff]+/;
39
+
40
+ function squashTokens(s) {
41
+ return String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, '');
42
+ }
43
+
44
+ function tokens(s) {
45
+ const parts = String(s == null ? '' : s).toLowerCase().split(TOKEN_SPLIT);
46
+ const out = [];
47
+ for (const p of parts) {
48
+ if (!p) continue;
49
+ // 过长的中文连续段信息量低,丢弃避免污染相似度
50
+ if (/^[\u4e00-\u9fff]+$/.test(p) && p.length > 4) continue;
51
+ out.push(p);
52
+ }
53
+ return out;
54
+ }
55
+
56
+ function normKey(s) {
57
+ return tokens(s).join('-');
58
+ }
59
+
60
+ /** 把 "ML-KEM-768" 变成 "mlkem768",用于在归一化路径里做包含判断。 */
61
+ function dekey(s) {
62
+ const parts = String(s == null ? '' : s).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
63
+ return parts.join('');
64
+ }
65
+
66
+ function parseNum(raw) {
67
+ if (raw == null) return null;
68
+ const cleaned = String(raw).replace(/[,\s_]/g, '');
69
+ if (!/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(cleaned)) return null;
70
+ const n = Number(cleaned);
71
+ return Number.isFinite(n) ? n : null;
72
+ }
73
+
74
+ function unitInfo(unit) {
75
+ if (!unit) return null;
76
+ const u = String(unit).toLowerCase();
77
+ if (u === PCT_TOKEN) return UNITS.pct;
78
+ return UNITS[u] || null;
79
+ }
80
+
81
+ /**
82
+ * 数值封装。ratio 型单独用 {type:'ratio'} 表示。
83
+ * @returns {{type:string, raw:string, number:number|null, unit:string|null, dim:string|null, base:number|null}}
84
+ */
85
+ function mkValue(numberText, unit) {
86
+ const number = parseNum(numberText);
87
+ const info = unitInfo(unit);
88
+ return {
89
+ type: 'scalar',
90
+ raw: String(numberText) + (unit ? String(unit) : ''),
91
+ number,
92
+ unit: unit ? String(unit) : null,
93
+ dim: info ? info.dim : null,
94
+ base: info && number != null ? number * info.factor : null
95
+ };
96
+ }
97
+
98
+ function mkRatio(numeratorText, denominatorText) {
99
+ const a = parseNum(numeratorText);
100
+ const b = parseNum(denominatorText);
101
+ return {
102
+ type: 'ratio',
103
+ raw: numeratorText + '/' + denominatorText,
104
+ numerator: a,
105
+ denominator: b,
106
+ ratio: a != null && b ? a / b : null
107
+ };
108
+ }
109
+
110
+ /**
111
+ * 比对两个值。单位维度一致时按归一化 base 比;维度不一致或缺失时退化为裸数值比。
112
+ * @returns {{equal:boolean, comparable:boolean, reason:string}}
113
+ */
114
+ function compareValues(a, b, opts) {
115
+ const rel = (opts && opts.relTolerance != null) ? opts.relTolerance : 0.005;
116
+ const abs = (opts && opts.absTolerance != null) ? opts.absTolerance : 0;
117
+ if (!a || !b) return { equal: false, comparable: false, reason: 'missing_value' };
118
+ if (a.type !== b.type) return { equal: false, comparable: false, reason: 'type_mismatch' };
119
+
120
+ if (a.type === 'ratio') {
121
+ if (a.numerator == null || b.numerator == null) return { equal: false, comparable: false, reason: 'unparsable' };
122
+ if (a.denominator != null && b.denominator != null && a.denominator !== b.denominator) {
123
+ return { equal: false, comparable: true, reason: 'denominator_differs' };
124
+ }
125
+ if (a.numerator !== b.numerator) return { equal: false, comparable: true, reason: 'numerator_differs' };
126
+ return { equal: true, comparable: true, reason: 'exact' };
127
+ }
128
+
129
+ if (a.number == null || b.number == null) {
130
+ return { equal: String(a.raw) === String(b.raw), comparable: false, reason: 'non_numeric' };
131
+ }
132
+
133
+ let comparable = false;
134
+ let x, y;
135
+ if (a.dim && b.dim && a.dim === b.dim && a.base != null && b.base != null) {
136
+ x = a.base; y = b.base; comparable = true;
137
+ } else if (!a.dim && !b.dim) {
138
+ x = a.number; y = b.number; comparable = false;
139
+ } else {
140
+ x = a.number; y = b.number; comparable = false;
141
+ }
142
+ if (x === y) return { equal: true, comparable, reason: 'exact' };
143
+ const tol = Math.max(abs, rel * Math.abs(x), rel * Math.abs(y));
144
+ if (Math.abs(x - y) <= tol) return { equal: true, comparable, reason: 'within_tolerance' };
145
+ return { equal: false, comparable, reason: 'value_differs' };
146
+ }
147
+
148
+ /** 从单个标量字符串里尝试构造出水印条目用的值对象(供 JSON 叶子服务)。 */
149
+ function valueFromString(text) {
150
+ const t = String(text).trim();
151
+ let m = t.match(/^(\d[\d,]*)\s*\/\s*(\d[\d,]*)$/);
152
+ if (m) return mkRatio(m[1], m[2]);
153
+ m = t.match(/^(-?\d[\d,]*(?:\.\d+)?)\s*(ns|us|µs|μs|ms|s|sec|%|bytes|byte|b|kb|mb|gb|kib|mib|gib)$/i);
154
+ if (m) return mkValue(m[1], m[2]);
155
+ const maybe = parseNum(t);
156
+ if (maybe != null) return { type: 'scalar', raw: t, number: maybe, unit: null, dim: null, base: null };
157
+ return null;
158
+ }
159
+
160
+ /**
161
+ * 两边都带了「工具不认识的单位」且单位名不同 → 度量的东西多半不是一回事
162
+ * (例:文档 10,000 rounds vs 产出物 6 tests)。这种不做数值比较。
163
+ */
164
+ function unknownUnitConflict(a, b) {
165
+ if (a.type !== 'ratio' && b.type === 'ratio') return false;
166
+ if (a.type === 'ratio' || b.type === 'ratio') return false;
167
+ if (!a.unit || !b.unit) return false;
168
+ const ua = String(a.unit).toLowerCase(), ub = String(b.unit).toLowerCase();
169
+ if (ua === ub) return false;
170
+ const ia = unitInfo(ua), ib = unitInfo(ub);
171
+ if (!ia || !ib) return true; // 至少一边单位未知 → 不可比
172
+ return ia.dim !== ib.dim; // 两边都已知但量纲不同 → 不可比
173
+ }
174
+
175
+ module.exports = {
176
+ UNITS, tokens, normKey, squash, dekey, parseNum, unitInfo,
177
+ mkValue, mkRatio, compareValues, valueFromString, unknownUnitConflict
178
+ };