verifact 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +101 -2
- package/VERIFACT-DESIGN.md +337 -0
- package/bin/verifact.js +463 -0
- package/package.json +49 -4
- package/src/config.js +198 -0
- package/src/core/artifacts.js +275 -0
- package/src/core/cache.js +87 -0
- package/src/core/claims.js +329 -0
- package/src/core/crossdoc.js +97 -0
- package/src/core/dispositions.js +292 -0
- package/src/core/lint.js +111 -0
- package/src/core/normalize.js +178 -0
- package/src/core/resolve.js +307 -0
- package/src/core/stable-key.js +97 -0
- package/src/core/walk.js +99 -0
- package/src/engine.js +467 -0
- package/src/errors.js +35 -0
- package/src/history.js +91 -0
- package/src/logger.js +29 -0
- package/src/pooled-scan.js +109 -0
- package/src/server/api.js +167 -0
- package/src/server/www/app.js +203 -0
- package/src/server/www/index.html +83 -0
- package/src/server/www/style.css +101 -0
- package/src/worker.js +35 -0
- package/verifact.json.example +92 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
const { tokens, mkValue, mkRatio, parseNum } = require('./normalize');
|
|
5
|
+
const { buildDispositionScopes } = require('./dispositions');
|
|
6
|
+
const { assignStableKeys } = require('./stable-key');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 硬数字声明抽取器。
|
|
10
|
+
*
|
|
11
|
+
* 纪律:
|
|
12
|
+
* - 只做结构抽取,不做语义判断;抽到的是「待核验的声明」,不是结论。
|
|
13
|
+
* - 宁可漏报,不可误报:拿捏不准的模式(裸大数字)默认关闭。
|
|
14
|
+
* - 行号必须准:清洗噪声时用等长空白替换,绝不删字符改变行数。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// 关键前缀词:出现在数字前,直接接管 primaryToken,压过通用上下文猜测。
|
|
18
|
+
const DOMINANT_PREFIX = new Set([
|
|
19
|
+
'kat', 'tvla', 'adla', 'wns', 'tns', 'whs', 'p50', 'p95', 'p99', 'p999',
|
|
20
|
+
'score', 'coverage', 'states', 'cycles', 'mutants', 'invariants', 'ndet', 'ser',
|
|
21
|
+
'fps', 'tps', 'qps', 'latency', 'throughput', 'bram', 'dsp', 'lut', 'ff'
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const STOPWORDS = new Set([
|
|
25
|
+
'the', 'a', 'an', 'of', 'and', 'or', 'in', 'for', 'with', 'is', 'are', 'was', 'were',
|
|
26
|
+
'to', 'by', 'at', 'on', 'as', 'that', 'this', 'it', 'from', 'be', 'been', 'has', 'have',
|
|
27
|
+
'see', 'seealso', 'note', 'TODO', 'total', 'all', 'up', 'down', 'per', 'over', 'under',
|
|
28
|
+
'http', 'https', 'com', 'org', 'net', 'md', 'html', 'npm', 'git',
|
|
29
|
+
'的', '了', '和', '与', '及', '在', '对', '为', '是', '其', '将', '已', '有', '个', '项',
|
|
30
|
+
'条', '次', '组', '共', '即', '中', '到', '从', '并', '也', '均', '各', '该', '被', '把'
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
// 形如 "2026"、"1000" 这类无单位整数几乎总是噪音(年份 / 编号),除非前缀词表明是度量。
|
|
34
|
+
const METRIC_HINTS = new Set([
|
|
35
|
+
'kat', 'tvla', 'score', 'coverage', 'states', 'cycles', 'count', 'total', 'files', 'deps',
|
|
36
|
+
'samples', 'runs', 'tests', 'invariants', 'traces', 'suites', 'rounds', 'bits', 'throughput'
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const PATTERNS = [
|
|
40
|
+
// 引用型:TSR / commit / serial —— 走「产出物是否存在」核验
|
|
41
|
+
{ kind: 'ref', subtype: 'tsr', re: /\bTSR\s+([A-Za-z]{1,4}-?\d{2,4})\b/g },
|
|
42
|
+
{ kind: 'ref', subtype: 'commit', re: /\bcommit\s+([0-9a-f]{7,40})\b/gi },
|
|
43
|
+
{ kind: 'ref', subtype: 'serial', re: /\bserial\s+(0x[0-9a-fA-F]{4,})\b/gi },
|
|
44
|
+
// 比率型:10000/10000、5/5
|
|
45
|
+
{ kind: 'ratio', re: /(\d[\d,]{0,12})\s*\/\s*(\d[\d,]{0,12})(?![/\d.])/g, dpi: true },
|
|
46
|
+
// 键值型:p95=78.5ms、score: 77.3
|
|
47
|
+
{
|
|
48
|
+
kind: 'kv',
|
|
49
|
+
re: /([A-Za-z][A-Za-z0-9_.\-]{0,39}|[\u4e00-\u9fff]{1,6})\s*[==::]\s*(-?\d[\d,]*(?:\.\d+)?)\s*(ns|us|µs|μs|ms|s|sec|%|bytes|byte|b|kb|mb|gb|kib|mib|gib|states|cycles)?\b/gi,
|
|
50
|
+
keyGroup: 1, numGroup: 2, unitGroup: 3
|
|
51
|
+
},
|
|
52
|
+
// 度量型:78.5ms、9.755ns、77.3%(键来自上下文)
|
|
53
|
+
// 数字左侧不得是字母数字或连字符,否则 "SLH-DSA-128s" 会被当成 -128秒
|
|
54
|
+
{ kind: 'measure', re: /(?<![\w-])(-?\d[\d,]*(?:\.\d+)?)\s*(ns|us|µs|μs|ms|s|%|cycles)\b(?![a-z])/gi, numGroup: 1, unitGroup: 2, ctx: true },
|
|
55
|
+
// 计数型:101,467 states、80 bytes、7 invariants
|
|
56
|
+
{ kind: 'count', re: /(\d[\d,]{0,12})\s+(states|cycles|bytes|tests|runs|samples|suites|invariants|traces|files|deps|dimensions|bits|rounds|ciphers)\b/gi, numGroup: 1, unitGroup: 2, ctx: true },
|
|
57
|
+
// 行数型:654 行、800 行代码(「第 45 行」行号引用在提取分支里过滤)
|
|
58
|
+
{ kind: 'loc', re: /(?<![\w第])(\d[\d,]{0,8})\s*行(?=[\))\s,。、;::]|$)/g, numGroup: 1, ctx: true }
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const DEEP_BARE = { kind: 'bare', re: /(?:^|[|\s(])(\d[\d,]{3,})(?=[|\s.,;)\]]|$)/g, numGroup: 1, ctx: true };
|
|
62
|
+
|
|
63
|
+
// 标准编号不是命中率。`FIPS 203/205` 与 `204/205` 形状相同,形状匹配器就会
|
|
64
|
+
// 把两条标准编号绑成一对「文档值 vs 产物值」,于是产生一次从没发生过的漂移
|
|
65
|
+
// (issue #101,lg-075-README.md:171)。这类引用要在提取阶段就排除。
|
|
66
|
+
//
|
|
67
|
+
// 判定必须「贴着数字」:只在数字左侧很短的前缀上找标准名,并且中间只能有空白/点号。
|
|
68
|
+
// 用一个大窗口宽泛地找 `NIST` 会把「NIST 测试集通过 10/10」也误杀——那是真声明。
|
|
69
|
+
const STANDARD_PREFIX_RE = /(?:FIPS|RFC|GB\s*\/?\s*T|GB|ISO\s*\/?\s*IEC|ISO|NIST|ITU-T|IEEE|PKCS\s*#?|SP\s?800-\d+|ePrint|IACR)\.?\s*$/;
|
|
70
|
+
const STANDARD_SUFFIX_RE = /^\s*(?:标准|规范|standards?)\b/i;
|
|
71
|
+
const STANDARD_NEAR_CHARS = 24;
|
|
72
|
+
|
|
73
|
+
/** 返回排除原因,或 null 表示这确实是个比值。 */
|
|
74
|
+
function standardNumberReason(line, start, end, num, den) {
|
|
75
|
+
const before = line.slice(Math.max(0, start - STANDARD_NEAR_CHARS), start);
|
|
76
|
+
if (STANDARD_PREFIX_RE.test(before)) return 'standard-prefix';
|
|
77
|
+
const after = line.slice(end, end + 12);
|
|
78
|
+
if (STANDARD_SUFFIX_RE.test(after)) return 'standard-suffix';
|
|
79
|
+
// 年份区间:两边都是 19xx/20xx,人类不会用这种比值表达通过率
|
|
80
|
+
const n = parseInt(String(num).replace(/,/g, ''), 10);
|
|
81
|
+
const d = parseInt(String(den).replace(/,/g, ''), 10);
|
|
82
|
+
if (n >= 1900 && n <= 2099 && d >= 1900 && d <= 2099) return 'year-pair';
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function blank(match) {
|
|
87
|
+
return match.replace(/[^\n]/g, ' ');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 用等长空白替换噪声片段,保持行号不变。 */
|
|
91
|
+
function stripNoise(text) {
|
|
92
|
+
return String(text)
|
|
93
|
+
.replace(/\r\n?/g, '\n')
|
|
94
|
+
.replace(/```[\s\S]*?```/g, blank)
|
|
95
|
+
.replace(/~~~[\s\S]*?~~~/g, blank)
|
|
96
|
+
.replace(/<!--[\s\S]*?-->/g, blank)
|
|
97
|
+
.replace(/`[^`\n]*`/g, blank)
|
|
98
|
+
.replace(/https?:\/\/[^\s)\]|>]+/g, blank)
|
|
99
|
+
.replace(/<[^>\n]+>/g, blank);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function lineModules(line, modules) {
|
|
103
|
+
const lower = String(line).toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
104
|
+
const hits = [];
|
|
105
|
+
for (const m of modules) {
|
|
106
|
+
if (m.dekey && lower.indexOf(m.dekey) >= 0) hits.push(m.name);
|
|
107
|
+
}
|
|
108
|
+
if (hits.length <= 1) return { module: hits[0] || null, all: hits };
|
|
109
|
+
// 同一个 §.<nospace>串里可能同时命中 "ML-KEM" 与 "ML-KEM-768",取最长(最具体)者
|
|
110
|
+
hits.sort((a, b) => b.length - a.length);
|
|
111
|
+
return { module: hits[0], all: hits };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function cleanTokens(arr) {
|
|
115
|
+
const out = [];
|
|
116
|
+
for (const w of arr) {
|
|
117
|
+
if (!w) continue;
|
|
118
|
+
if (STOPWORDS.has(w)) continue;
|
|
119
|
+
if (/^\d+$/.test(w)) continue;
|
|
120
|
+
if (out.indexOf(w) >= 0) continue;
|
|
121
|
+
out.push(w);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const UNIT_TOKENS = new Set(['s', 'ms', 'us', 'µs', 'μs', 'ns']);
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 前一个单元格的数字+单位不能当前面那一格的键。
|
|
130
|
+
* 「| keygen | 48 µs | ~0.522 ms |」里 µs 会被 token 成 's',
|
|
131
|
+
* 于是第三格得到 key `keygen-s`(而不是 `keygen`)—— 键是错的,绑定也就错。
|
|
132
|
+
* 只在「数字紧跟单位且单位就是 prefix 末尾」时剔除,不影响 “延迟(ms)” 这类表头。
|
|
133
|
+
*/
|
|
134
|
+
function dropForeignUnit(rawPrefix, tokensArr) {
|
|
135
|
+
if (!tokensArr.length) return tokensArr;
|
|
136
|
+
const last = tokensArr[tokensArr.length - 1];
|
|
137
|
+
if (!UNIT_TOKENS.has(String(last).toLowerCase())) return tokensArr;
|
|
138
|
+
if (!/\d(?:[.,]\d+)?\s*(?:µs|μs|us|ms|ns|s)\s*[~≈]?\s*$/i.test(rawPrefix)) return tokensArr;
|
|
139
|
+
return tokensArr.slice(0, -1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function contextKey(line, startIdx, endIdx, unitToken) {
|
|
143
|
+
// 表格行的 "|" 会切断上下文,换成空格(等长,安全)
|
|
144
|
+
const rawPrefix = line.slice(Math.max(0, startIdx - 64), startIdx);
|
|
145
|
+
let prefix = rawPrefix.replace(/\|/g, ' ');
|
|
146
|
+
prefix = prefix.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1');
|
|
147
|
+
const before = dropForeignUnit(prefix, cleanTokens(tokens(prefix)));
|
|
148
|
+
if (!before.length) {
|
|
149
|
+
// 数字在行首时("43/43 cross-validation tests PASS"),用后面的描述词充当键
|
|
150
|
+
let afterRaw0 = '';
|
|
151
|
+
if (endIdx != null) afterRaw0 = line.slice(endIdx, endIdx + 64).replace(/\|/g, ' ');
|
|
152
|
+
const tail = cleanTokens(tokens(afterRaw0)).filter((w) => w !== unitToken).slice(0, 3);
|
|
153
|
+
if (!tail.length) return { keyTokens: [], primaryToken: null, key: '' };
|
|
154
|
+
return { keyTokens: tail.slice(0, 2), primaryToken: tail[0], key: tail[0] };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let primary = null;
|
|
158
|
+
let keyPart = before;
|
|
159
|
+
for (let i = before.length - 1; i >= 0; i--) {
|
|
160
|
+
if (DOMINANT_PREFIX.has(before[i])) {
|
|
161
|
+
keyPart = before.slice(i);
|
|
162
|
+
primary = keyPart[keyPart.length - 1];
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!primary) {
|
|
167
|
+
primary = before[before.length - 1];
|
|
168
|
+
keyPart = before.slice(-2);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 数字后面的描述词同样参与匹配("43/43 cross-validation tests PASS" 的情况)
|
|
172
|
+
let afterRaw = '';
|
|
173
|
+
if (endIdx != null) afterRaw = line.slice(endIdx, endIdx + 48).replace(/\|/g, ' ');
|
|
174
|
+
const after = cleanTokens(tokens(afterRaw)).filter((w) => w !== unitToken).slice(0, 2);
|
|
175
|
+
|
|
176
|
+
const combined = cleanTokens(keyPart.concat(after)).slice(0, 4);
|
|
177
|
+
return { keyTokens: combined, primaryToken: primary, key: keyPart.join('-') };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isNoiseNumber(numText, unit, primaryToken) {
|
|
181
|
+
const n = parseNum(numText);
|
|
182
|
+
if (n == null) return true;
|
|
183
|
+
if (unit) return false;
|
|
184
|
+
if (Number.isInteger(n) && n >= 1000 && n <= 2100 && !(primaryToken && METRIC_HINTS.has(primaryToken))) return true;
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {string} text 原始文本(会先做等长清洗)
|
|
190
|
+
* @param {string} fileRef 展示用的文件引用
|
|
191
|
+
* @param {object} opts { modules:[{name,dekey}], deep:boolean, headingHint:string }
|
|
192
|
+
*/
|
|
193
|
+
function extractClaims(text, fileRef, opts) {
|
|
194
|
+
const options = opts || {};
|
|
195
|
+
const moduleList = options.modules || [];
|
|
196
|
+
const deep = !!options.deep;
|
|
197
|
+
// 处置声明读的是原始文本:stripNoise 会把 HTML 注释抹成空格。
|
|
198
|
+
// opts.dispositions === false 时跳过,供只想看原始抽取的调用方使用。
|
|
199
|
+
const scopes = options.dispositions === false ? null : buildDispositionScopes(text, fileRef);
|
|
200
|
+
const clean = stripNoise(text);
|
|
201
|
+
const lines = clean.split('\n');
|
|
202
|
+
const baseName = String(fileRef).split('/').pop();
|
|
203
|
+
const out = [];
|
|
204
|
+
let heading = options.headingHint || '';
|
|
205
|
+
|
|
206
|
+
for (let i = 0; i < lines.length; i++) {
|
|
207
|
+
const raw = lines[i];
|
|
208
|
+
const line = raw.replace(/\|/g, ' ');
|
|
209
|
+
const trimmedRaw = raw.trim();
|
|
210
|
+
if (/^#{1,6}\s+/.test(trimmedRaw)) { heading = trimmedRaw.replace(/^#{1,6}\s+/, ''); }
|
|
211
|
+
|
|
212
|
+
const spanish = lineModules(line, moduleList);
|
|
213
|
+
const modRes = lineModules(trimmedRaw, moduleList);
|
|
214
|
+
const sectionRes = lineModules(heading, moduleList);
|
|
215
|
+
const moduleOf = (r) => (r && r.module) || null;
|
|
216
|
+
const lineModule = modRes.module || moduleOf(spanish) || sectionRes.module;
|
|
217
|
+
|
|
218
|
+
const taken = [];
|
|
219
|
+
const overlaps = (s, e) => taken.some((t) => !(e <= t[0] || s >= t[1]));
|
|
220
|
+
|
|
221
|
+
const patterns = deep ? PATTERNS.concat([DEEP_BARE]) : PATTERNS;
|
|
222
|
+
for (const p of patterns) {
|
|
223
|
+
p.re.lastIndex = 0;
|
|
224
|
+
let m;
|
|
225
|
+
while ((m = p.re.exec(line)) !== null) {
|
|
226
|
+
if (m[0].length === 0) { p.re.lastIndex++; continue; }
|
|
227
|
+
const start = m.index;
|
|
228
|
+
const end = start + m[0].length;
|
|
229
|
+
if (overlaps(start, end)) continue;
|
|
230
|
+
|
|
231
|
+
let claim = null;
|
|
232
|
+
if (p.kind === 'ref') {
|
|
233
|
+
claim = {
|
|
234
|
+
kind: 'ref', subtype: p.subtype,
|
|
235
|
+
key: p.subtype, keyTokens: [p.subtype], primaryToken: p.subtype,
|
|
236
|
+
value: { type: 'ref', raw: m[0], ref: String(m[1]).toLowerCase() }
|
|
237
|
+
};
|
|
238
|
+
} else if (p.kind === 'ratio') {
|
|
239
|
+
const ctxRaw = contextKey(line, start, end, null);
|
|
240
|
+
const keyTokens = ctxRaw.keyTokens;
|
|
241
|
+
const primaryToken = ctxRaw.primaryToken;
|
|
242
|
+
const numerator = m[1], denominator = m[2];
|
|
243
|
+
const standard = standardNumberReason(line, start, end, numerator, denominator);
|
|
244
|
+
const common = {
|
|
245
|
+
kind: 'ratio',
|
|
246
|
+
key: keyTokens.length ? ctxRaw.key : 'ratio',
|
|
247
|
+
keyTokens: keyTokens.length ? keyTokens : ['ratio'],
|
|
248
|
+
primaryToken: primaryToken || 'ratio',
|
|
249
|
+
value: mkRatio(numerator, denominator)
|
|
250
|
+
};
|
|
251
|
+
if (standard) {
|
|
252
|
+
// 不静默丢掉:留成一条声明,但标上「不是度量」,由引擎单列计数——
|
|
253
|
+
// 不进判定、不进覆盖率分母。直接删掉就变成「看不见」,与假绿同宗。
|
|
254
|
+
claim = Object.assign(common, { notMeasurement: standard });
|
|
255
|
+
} else if (isNoiseNumber(numerator, null, primaryToken) && keyTokens.length === 0) {
|
|
256
|
+
continue;
|
|
257
|
+
} else {
|
|
258
|
+
claim = common;
|
|
259
|
+
}
|
|
260
|
+
} else if (p.kind === 'loc') {
|
|
261
|
+
// 行号引用(第 45 行)不是规模声明,丢弃
|
|
262
|
+
if (/第\s*$/.test(line.slice(Math.max(0, start - 4), start))) { continue; }
|
|
263
|
+
const num = parseNum(m[1]);
|
|
264
|
+
// 行数的合理区间:太小的几乎是行号,太大的几乎不是单文件
|
|
265
|
+
if (num == null || num < 10 || num > 200000) { continue; }
|
|
266
|
+
const c = contextKey(line, start, end, 'loc');
|
|
267
|
+
if (!c.keyTokens.length) { continue; }
|
|
268
|
+
claim = {
|
|
269
|
+
kind: 'loc',
|
|
270
|
+
key: c.key, keyTokens: c.keyTokens, primaryToken: c.primaryToken,
|
|
271
|
+
value: { type: 'scalar', raw: m[0], number: num, unit: '行', dim: 'loc', base: num },
|
|
272
|
+
// 数字前的 80 字符:LOC 核验只在这个窗口里找文件名主干,
|
|
273
|
+
// 防止「句子里远远提到某文件」被当成该行数声明的指向
|
|
274
|
+
locPrefix: line.slice(Math.max(0, start - 80), start)
|
|
275
|
+
};
|
|
276
|
+
} else {
|
|
277
|
+
const keyText = p.keyGroup ? m[p.keyGroup] : null;
|
|
278
|
+
const numText = m[p.numGroup];
|
|
279
|
+
const unit = p.unitGroup ? m[p.unitGroup] : null;
|
|
280
|
+
let keyTokens, primaryToken, key;
|
|
281
|
+
if (keyText) {
|
|
282
|
+
keyTokens = tokens(keyText);
|
|
283
|
+
// 单个 "\" 或 "v" 之类前缀不接受
|
|
284
|
+
primaryToken = keyTokens.length ? keyTokens[keyTokens.length - 1] : null;
|
|
285
|
+
key = keyTokens.join('-');
|
|
286
|
+
} else {
|
|
287
|
+
const unitToken = unit ? String(unit).toLowerCase() : null;
|
|
288
|
+
const c = contextKey(line, start, end, unitToken);
|
|
289
|
+
keyTokens = c.keyTokens; primaryToken = c.primaryToken; key = c.key;
|
|
290
|
+
}
|
|
291
|
+
if (!keyTokens.length) { continue; }
|
|
292
|
+
if (isNoiseNumber(numText, unit, primaryToken)) { continue; }
|
|
293
|
+
// 版本号类数字(1.2.3 / v3.3):前一个字符是 "." 或 key 为 "v" 时丢弃
|
|
294
|
+
if (start > 0 && line[start - 1] === '.') { continue; }
|
|
295
|
+
if (keyTokens.length === 1 && keyTokens[0] === 'v') { continue; }
|
|
296
|
+
claim = {
|
|
297
|
+
kind: p.kind,
|
|
298
|
+
key, keyTokens, primaryToken,
|
|
299
|
+
value: mkValue(numText, unit)
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!claim) continue;
|
|
304
|
+
claim.id = baseName + ':L' + (i + 1) + ':' + claim.kind + ':' + claim.key;
|
|
305
|
+
claim.file = fileRef;
|
|
306
|
+
claim.line = i + 1;
|
|
307
|
+
claim.module = lineModule;
|
|
308
|
+
claim.modulesAll = modRes.all.length ? modRes.all : (sectionRes.all || []);
|
|
309
|
+
claim.heading = heading.slice(0, 120);
|
|
310
|
+
claim.sentence = trimmedRaw.slice(0, 240);
|
|
311
|
+
if (scopes) {
|
|
312
|
+
const d = scopes.resolve(i + 1);
|
|
313
|
+
claim.disposition = d.disposition;
|
|
314
|
+
claim.dispositionSource = d.source;
|
|
315
|
+
claim.dispositionAt = d.at;
|
|
316
|
+
if (d.args && Object.keys(d.args).length) claim.dispositionArgs = Object.assign({}, d.args);
|
|
317
|
+
}
|
|
318
|
+
out.push(claim);
|
|
319
|
+
taken.push([start, end]);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// stableKey 必须按抽取顺序分配:同一 baseKey 的第 2 条起带 #2、#3 后缀。
|
|
324
|
+
// id 仍按行号生成,保留给人看(定位用);身份一律用 stableKey(见 #96)。
|
|
325
|
+
assignStableKeys(out);
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
module.exports = { extractClaims, stripNoise, contextKey, lineModules, PATTERNS };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 跨文档互证:同一个键在不同文档里给出了不同的值。
|
|
6
|
+
*
|
|
7
|
+
* 与 drift 的分工:
|
|
8
|
+
* drift —— 文档 vs 产出物(有证据可锚)
|
|
9
|
+
* crossDoc —— 文档 vs 文档(没有任何产出物时,声明之间互相矛盾)
|
|
10
|
+
*
|
|
11
|
+
* 纪律:只聚类、只列值与出处,不判哪份文档错。
|
|
12
|
+
* 量级差 ≥ warnRatio(默认 10 倍)才标 warn——那种差异通常不是口径问题;
|
|
13
|
+
* 其余记 info,交人判断。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const GENERIC_KEYS = new Set([
|
|
17
|
+
'max', 'min', 'total', 'sum', 'n', 'value', 'count', 'score', 'factor', 'cost',
|
|
18
|
+
'time', 'date', 'len', 'size', 'version', 'id', 'name', 'type', 'level'
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
function shapeOf(v) {
|
|
22
|
+
if (!v) return null;
|
|
23
|
+
if (v.type === 'scalar' && v.number != null) return 'scalar:' + (v.dim || '-');
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function numOf(v) {
|
|
28
|
+
if (v.type !== 'scalar' || v.number == null) return null;
|
|
29
|
+
const x = v.base != null ? v.base : v.number;
|
|
30
|
+
return Math.abs(x);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {Array} claims 已解析完成的声明(含 status)
|
|
35
|
+
* @param {object} opts { warnRatio }
|
|
36
|
+
* @returns {Array} 按量级差降序的不一致簇
|
|
37
|
+
*/
|
|
38
|
+
function crossDocCheck(claims, opts) {
|
|
39
|
+
const warnRatio = (opts && opts.warnRatio) || 10;
|
|
40
|
+
const groups = new Map();
|
|
41
|
+
|
|
42
|
+
for (const c of claims) {
|
|
43
|
+
if (c.kind === 'ref' || c.kind === 'loc' || !c.value) continue;
|
|
44
|
+
const shape = shapeOf(c.value);
|
|
45
|
+
if (!shape) continue;
|
|
46
|
+
if (!c.keyTokens || !c.keyTokens.length) continue;
|
|
47
|
+
if (c.keyTokens.length === 1 && GENERIC_KEYS.has(c.keyTokens[0])) continue;
|
|
48
|
+
// 单 token 且 ≤2 字符(q / k / n / d):在密码学文档里几乎总是不同对象的参数名,噪音大于信号
|
|
49
|
+
if (c.keyTokens.length === 1 && c.keyTokens[0].length <= 2) continue;
|
|
50
|
+
const sig = (c.module || '-') + '|' + c.keyTokens.join('-') + '|' + shape;
|
|
51
|
+
if (!groups.has(sig)) groups.set(sig, []);
|
|
52
|
+
groups.get(sig).push(c);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const [sig, list] of groups) {
|
|
57
|
+
// 按值聚合:同值多处出现只占一格
|
|
58
|
+
const byVal = new Map();
|
|
59
|
+
for (const c of list) {
|
|
60
|
+
const k = String(c.value.raw);
|
|
61
|
+
if (!byVal.has(k)) byVal.set(k, []);
|
|
62
|
+
byVal.get(k).push(c);
|
|
63
|
+
}
|
|
64
|
+
if (byVal.size < 2) continue;
|
|
65
|
+
const files = new Set(list.map((c) => c.file));
|
|
66
|
+
if (files.size < 2) continue;
|
|
67
|
+
|
|
68
|
+
const nums = list.map((c) => numOf(c.value)).filter((x) => x != null && x > 0);
|
|
69
|
+
let ratio = null;
|
|
70
|
+
if (nums.length >= 2) {
|
|
71
|
+
const mx = Math.max.apply(null, nums);
|
|
72
|
+
const mn = Math.min.apply(null, nums);
|
|
73
|
+
ratio = mn > 0 ? mx / mn : null;
|
|
74
|
+
}
|
|
75
|
+
const severity = ratio != null && ratio >= warnRatio ? 'warn' : 'info';
|
|
76
|
+
|
|
77
|
+
out.push({
|
|
78
|
+
sig,
|
|
79
|
+
key: list[0].key,
|
|
80
|
+
module: list[0].module || null,
|
|
81
|
+
dim: list[0].value.dim || null,
|
|
82
|
+
distinctValues: byVal.size,
|
|
83
|
+
files: files.size,
|
|
84
|
+
claims: list.length,
|
|
85
|
+
maxMinRatio: ratio != null ? Number(ratio.toFixed(2)) : null,
|
|
86
|
+
severity,
|
|
87
|
+
values: Array.from(byVal.entries())
|
|
88
|
+
.map(([raw, cs]) => ({ raw, at: cs.slice(0, 4).map((c) => c.file + ':' + c.line), count: cs.length }))
|
|
89
|
+
.slice(0, 8)
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
out.sort((a, b) => (b.maxMinRatio || 0) - (a.maxMinRatio || 0));
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { crossDocCheck };
|