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.
- package/LICENSE +202 -0
- package/README.md +94 -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,307 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
const { compareValues, squash, unknownUnitConflict } = require('./normalize');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 声明 ↔ 产出物 解析器。
|
|
8
|
+
*
|
|
9
|
+
* 五级结果,严格遵守「信号 vs 判决分离」:
|
|
10
|
+
* verified 命中产出物,数值一致
|
|
11
|
+
* drift 命中同形状产出物,数值不一致 —— 本工具唯一关心的红
|
|
12
|
+
* ambiguous 形状不同或候选冲突(如 5/5 vs 36/36 是规模不同,不是数值漂移)—— 交人工
|
|
13
|
+
* unbound 产出物里没有可比条目 —— 可能文档自称,也可能只是没导出
|
|
14
|
+
* ref-found / ref-missing 引用型产出物的存在性
|
|
15
|
+
*
|
|
16
|
+
* 关键纪律:**不可比的东西不判红**。比率对标量、字节对毫秒、规模不同的比率,
|
|
17
|
+
* 一律降级为 ambiguous / unbound,绝不产出假的 drift。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function overlapCoefficient(a, b) {
|
|
21
|
+
if (!a.length || !b.length) return 0;
|
|
22
|
+
const setB = new Set(b);
|
|
23
|
+
let hit = 0;
|
|
24
|
+
for (const t of a) if (setB.has(t)) hit++;
|
|
25
|
+
return hit / Math.min(a.length, b.length);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function candidatesFor(claim, index) {
|
|
29
|
+
const out = [];
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
const consider = (arr) => {
|
|
32
|
+
if (!arr) return;
|
|
33
|
+
for (const e of arr) { if (!seen.has(e)) { seen.add(e); out.push(e); } }
|
|
34
|
+
};
|
|
35
|
+
for (const t of claim.keyTokens) consider(index.byToken.get(t));
|
|
36
|
+
if (claim.primaryToken && claim.keyTokens.indexOf(claim.primaryToken) < 0) {
|
|
37
|
+
consider(index.byToken.get(claim.primaryToken));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 模块是一道硬约束:同模块的候选存在时,绝不去碰别的模块
|
|
41
|
+
if (claim.module) {
|
|
42
|
+
const same = out.filter((e) => e.module === claim.module);
|
|
43
|
+
if (same.length) return same;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function score(claim, entry) {
|
|
49
|
+
const core = overlapCoefficient(claim.keyTokens, entry.keyTokens);
|
|
50
|
+
const specific = entry.keyTokens.indexOf(claim.primaryToken) >= 0 ? 1 : 0;
|
|
51
|
+
const moduleMatch = (claim.module && entry.module && claim.module === entry.module) ? 1 : 0;
|
|
52
|
+
let unitMatch = 0;
|
|
53
|
+
const cv = claim.value, ev = entry.value;
|
|
54
|
+
if (cv && ev && cv.type === ev.type) {
|
|
55
|
+
if (cv.dim && ev.dim && cv.dim === ev.dim) unitMatch = 1;
|
|
56
|
+
else if (!cv.dim && !ev.dim) unitMatch = 0.5;
|
|
57
|
+
}
|
|
58
|
+
return 0.45 * core + 0.25 * specific + 0.2 * moduleMatch + 0.1 * unitMatch;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveRefClaim(claim, index) {
|
|
62
|
+
const ref = claim.value.ref.replace(/[^a-z0-9]/g, '');
|
|
63
|
+
if (!ref) return { status: 'unbound', reason: 'empty_ref' };
|
|
64
|
+
if (claim.subtype === 'commit') {
|
|
65
|
+
return { status: 'unbound', reason: 'resolution_disabled', note: 'commit 需对比 git 对象,当前版本不做此判定' };
|
|
66
|
+
}
|
|
67
|
+
if (claim.subtype === 'serial') {
|
|
68
|
+
return { status: 'unbound', reason: 'resolution_disabled', note: 'TSR serial 需解析 DER 并与 TSA 回填比对,当前版本不做此判定' };
|
|
69
|
+
}
|
|
70
|
+
for (const rel of index.presence) {
|
|
71
|
+
if (squash(rel).indexOf(ref) >= 0) {
|
|
72
|
+
return { status: 'ref-found', best: { source: rel, path: 'existence', value: { type: 'ref', raw: claim.value.ref } }, score: 1 };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { status: 'ref-missing', reason: 'ref_not_found' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 值形状是否可比:类型一致且量纲相容(或都无量纲)。形状不同不许判红。 */
|
|
79
|
+
function comparableShape(cmp) {
|
|
80
|
+
return cmp && cmp.reason !== 'type_mismatch' && cmp.reason !== 'non_numeric' && cmp.reason !== 'missing_value' && cmp.reason !== 'unparsable';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 弱证据过滤:一侧有量纲、另一侧是无单位小整数时,几乎肯定是「键同名但度量不同」
|
|
85
|
+
* (例:文档 `23957ms` 对上 `assessment.ms.usageCount = 1`)。
|
|
86
|
+
* 这种候选不参与绑定,直接丢弃。
|
|
87
|
+
*/
|
|
88
|
+
function isWeakEvidence(claimValue, entryValue) {
|
|
89
|
+
if (!claimValue || !entryValue) return true;
|
|
90
|
+
if (claimValue.type !== entryValue.type) return true;
|
|
91
|
+
if (claimValue.type === 'ratio') {
|
|
92
|
+
// 比率的一侧分子为小整数、另一侧分子是大数时同样视为弱证据
|
|
93
|
+
const a = claimValue.numerator, b = entryValue.numerator;
|
|
94
|
+
if (a == null || b == null) return false;
|
|
95
|
+
const small = (x) => Number.isInteger(x) && Math.abs(x) <= 50;
|
|
96
|
+
if (small(a) !== small(b)) {
|
|
97
|
+
return claimValue.denominator != null && entryValue.denominator != null &&
|
|
98
|
+
claimValue.denominator !== entryValue.denominator;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
const cd = claimValue.dim, ed = entryValue.dim;
|
|
103
|
+
if (cd === ed) return false;
|
|
104
|
+
const smallInt = (v) => v.dim == null && v.number != null && Number.isInteger(v.number) && Math.abs(v.number) <= 50;
|
|
105
|
+
if (smallInt(claimValue) && ed) return true;
|
|
106
|
+
if (smallInt(entryValue) && cd) return true;
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 数量级守卫:两侧都是标量、量纲可通约(或都无量纲),但数值相差 1000 倍以上时,
|
|
112
|
+
* 几乎肯定是「键同名但不是同一个量」(文档的 `max=8504497693` 对上 `max_score=100`)。
|
|
113
|
+
* 这种候选丢弃,不参与比对。
|
|
114
|
+
*/
|
|
115
|
+
const MAGNITUDE_RATIO = 1000;
|
|
116
|
+
|
|
117
|
+
function magnitudeGuard(claimValue, entryValue) {
|
|
118
|
+
if (!claimValue || !entryValue) return false;
|
|
119
|
+
if (claimValue.type !== 'scalar' || entryValue.type !== 'scalar') return false;
|
|
120
|
+
const a = claimValue.number, b = entryValue.number;
|
|
121
|
+
if (a == null || b == null) return false;
|
|
122
|
+
const cd = claimValue.dim, ed = entryValue.dim;
|
|
123
|
+
if (cd && ed && cd !== ed) return false; // 量纲不同交给 unknownUnitConflict 处理
|
|
124
|
+
const x = Math.abs(a), y = Math.abs(b);
|
|
125
|
+
if (x === 0 || y === 0) return x !== y;
|
|
126
|
+
const ratio = x > y ? x / y : y / x;
|
|
127
|
+
return ratio > MAGNITUDE_RATIO;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 行数型声明:「自研纯 JS(654 行)」—— 核验对象是真文件的行数,不是产出物里的数值。
|
|
132
|
+
* 匹配策略:声明所在句子里出现某个已扫描文件的文件名主干(≥6 字符,防误匹配),
|
|
133
|
+
* 该文件的实测行数就是证据。容差默认 ±5%(文档与代码的正常编辑漂移)。
|
|
134
|
+
*/
|
|
135
|
+
function resolveLocClaim(claim, index, opts) {
|
|
136
|
+
const tolerance = (opts && opts.locTolerance) != null ? opts.locTolerance : 0.05;
|
|
137
|
+
// 只看数字前的窗口(locPrefix),且文件名主干必须出现在窗口尾部 60 字符内:
|
|
138
|
+
// 「文件名(654 行)」可信;「N 行 —— 文件名」或远距离提及不绑。
|
|
139
|
+
const hay = squash(claim.locPrefix != null ? claim.locPrefix : (claim.sentence || ''));
|
|
140
|
+
const cands = [];
|
|
141
|
+
for (const pair of index.fileLines || []) {
|
|
142
|
+
const rel = pair[0], lines = pair[1];
|
|
143
|
+
const base = String(rel).split('/').pop();
|
|
144
|
+
const dot = base.lastIndexOf('.');
|
|
145
|
+
const stem = dot > 0 ? base.slice(0, dot) : base;
|
|
146
|
+
const sq = squash(stem);
|
|
147
|
+
if (sq.length < 6) continue;
|
|
148
|
+
const pos = hay.lastIndexOf(sq);
|
|
149
|
+
if (pos < 0) continue;
|
|
150
|
+
if (pos + sq.length < hay.length - 60) continue; // 距数字太远,不算指向
|
|
151
|
+
cands.push({ source: rel, lines });
|
|
152
|
+
}
|
|
153
|
+
const mkVal = (n) => ({ type: 'scalar', raw: String(n), number: n, unit: '行', dim: 'loc', base: n });
|
|
154
|
+
if (!cands.length) return { status: 'unbound', reason: 'loc_file_not_in_scope' };
|
|
155
|
+
const num = claim.value.number;
|
|
156
|
+
const near = cands.filter((c) => Math.abs(c.lines - num) / c.lines <= tolerance);
|
|
157
|
+
if (near.length) {
|
|
158
|
+
return {
|
|
159
|
+
status: 'verified',
|
|
160
|
+
best: { source: near[0].source, path: 'line-count', value: mkVal(near[0].lines) },
|
|
161
|
+
score: 1,
|
|
162
|
+
note: '行数容差 ±' + Math.round(tolerance * 100) + '%'
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (cands.length === 1) {
|
|
166
|
+
return {
|
|
167
|
+
status: 'drift',
|
|
168
|
+
best: { source: cands[0].source, path: 'line-count', value: mkVal(cands[0].lines) },
|
|
169
|
+
score: 1,
|
|
170
|
+
cmp: { equal: false, reason: 'loc_mismatch', expected: num, actual: cands[0].lines }
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
status: 'ambiguous',
|
|
175
|
+
reason: 'loc_candidate_conflict',
|
|
176
|
+
candidates: cands.slice(0, 5).map((c) => ({ source: c.source, path: 'line-count', value: mkVal(c.lines) }))
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function resolveClaim(claim, index, opts) {
|
|
181
|
+
const cfg = opts || {};
|
|
182
|
+
const minScore = cfg.minScore != null ? cfg.minScore : 0.45;
|
|
183
|
+
const cmpOpts = { relTolerance: cfg.relTolerance, absTolerance: cfg.absTolerance };
|
|
184
|
+
|
|
185
|
+
if (claim.kind === 'ref') return resolveRefClaim(claim, index);
|
|
186
|
+
if (claim.kind === 'loc') return resolveLocClaim(claim, index, cfg);
|
|
187
|
+
|
|
188
|
+
const all = candidatesFor(claim, index);
|
|
189
|
+
if (!all.length) return { status: 'unbound', reason: 'no_artifact_entry' };
|
|
190
|
+
|
|
191
|
+
const belowNotes = [];
|
|
192
|
+
const scored = [];
|
|
193
|
+
let weak = 0;
|
|
194
|
+
for (const e of all) {
|
|
195
|
+
if (isWeakEvidence(claim.value, e.value)) { weak++; continue; }
|
|
196
|
+
if (magnitudeGuard(claim.value, e.value)) { weak++; continue; }
|
|
197
|
+
const s = score(claim, e);
|
|
198
|
+
if (s < minScore) { belowNotes.push(s); continue; }
|
|
199
|
+
if (isWeakEvidence(claim.value, e.value)) { weak++; continue; }
|
|
200
|
+
if (unknownUnitConflict(claim.value, e.value)) continue; // 10,000 rounds vs 6 tests
|
|
201
|
+
const cmp = compareValues(claim.value, e.value, cmpOpts);
|
|
202
|
+
if (!comparableShape(cmp)) continue; // 形状不可比 → 直接丢弃候选
|
|
203
|
+
scored.push({ entry: e, score: s, cmp });
|
|
204
|
+
}
|
|
205
|
+
if (!scored.length) {
|
|
206
|
+
return {
|
|
207
|
+
status: 'unbound',
|
|
208
|
+
reason: belowNotes.length ? 'below_score_threshold' : 'no_comparable_shape',
|
|
209
|
+
weakEvidence: weak,
|
|
210
|
+
candidates: all.length
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
scored.sort((a, b) => b.score - a.score);
|
|
215
|
+
const best = scored[0];
|
|
216
|
+
|
|
217
|
+
// 只要有候选能对上,就算核验通过——其余候选的差异作为待复核备选列出。
|
|
218
|
+
// 只有当「高分候选全都对不上」时,才有意义去谈 conflict / drift。
|
|
219
|
+
const equalHit = scored.find((s) => s.cmp.equal);
|
|
220
|
+
if (equalHit) {
|
|
221
|
+
return {
|
|
222
|
+
status: 'verified',
|
|
223
|
+
best: equalHit.entry,
|
|
224
|
+
score: equalHit.score,
|
|
225
|
+
cmp: equalHit.cmp,
|
|
226
|
+
alternatives: scored.filter((s) => s !== equalHit).slice(0, 3).map((s) => ({
|
|
227
|
+
source: s.entry.source, path: s.entry.path, score: Number(s.score.toFixed(3)), value: s.entry.value
|
|
228
|
+
}))
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 高分候选给不出一致答案:绑定关系本身存疑 —— 交人工,不判红
|
|
233
|
+
const topBand = scored.filter((s) => s.score >= best.score - 0.08);
|
|
234
|
+
const distinct = new Set(topBand.map((s) => JSON.stringify(s.entry.value)));
|
|
235
|
+
if (distinct.size > 1) {
|
|
236
|
+
return {
|
|
237
|
+
status: 'ambiguous',
|
|
238
|
+
best: best.entry,
|
|
239
|
+
score: best.score,
|
|
240
|
+
cmp: best.cmp,
|
|
241
|
+
reason: 'candidate_conflict',
|
|
242
|
+
alternatives: topBand.slice(0, 5).map((s) => ({
|
|
243
|
+
source: s.entry.source, path: s.entry.path, score: Number(s.score.toFixed(3)), value: s.entry.value
|
|
244
|
+
}))
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 分母不同 = 测试规模不同,不是数值漂移
|
|
249
|
+
if (best.cmp.reason === 'denominator_differs') {
|
|
250
|
+
return {
|
|
251
|
+
status: 'ambiguous',
|
|
252
|
+
best: best.entry,
|
|
253
|
+
score: best.score,
|
|
254
|
+
cmp: best.cmp,
|
|
255
|
+
reason: 'different_denominator_scope',
|
|
256
|
+
alternatives: scored.slice(1, 4).map((s) => ({
|
|
257
|
+
source: s.entry.source, path: s.entry.path, score: Number(s.score.toFixed(3)), value: s.entry.value
|
|
258
|
+
}))
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const mkAlts = () => scored.slice(1, 4).map((s) => ({
|
|
263
|
+
source: s.entry.source, path: s.entry.path, score: Number(s.score.toFixed(3)), value: s.entry.value
|
|
264
|
+
}));
|
|
265
|
+
|
|
266
|
+
// 跨模块绑定(文档说 ML-DSA、产出物条目属于 SM2):同键不同对象,不作 drift 判决
|
|
267
|
+
if (claim.module && best.entry.module && claim.module !== best.entry.module) {
|
|
268
|
+
return {
|
|
269
|
+
status: 'ambiguous',
|
|
270
|
+
best: best.entry,
|
|
271
|
+
score: best.score,
|
|
272
|
+
cmp: best.cmp,
|
|
273
|
+
reason: 'cross_module_binding',
|
|
274
|
+
alternatives: mkAlts()
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
status: 'drift',
|
|
280
|
+
best: best.entry,
|
|
281
|
+
score: best.score,
|
|
282
|
+
cmp: best.cmp,
|
|
283
|
+
alternatives: mkAlts()
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function explain(res) {
|
|
288
|
+
switch (res.status) {
|
|
289
|
+
case 'verified': return '产出物中存在同键目且数值一致';
|
|
290
|
+
case 'drift': return '产出物中存在同形状同键目,但数值不一致';
|
|
291
|
+
case 'ambiguous':
|
|
292
|
+
if (res.reason === 'different_denominator_scope') return '分母不同:测试规模不一致,无法判定为数值漂移,需人工确认口径';
|
|
293
|
+
if (res.reason === 'candidate_conflict') return '多个候选产出物给出不同数值,需人工判定绑定关系';
|
|
294
|
+
if (res.reason === 'cross_module_binding') return '文档标的是某个模块,产出物条目属于另一个模块:同键不同对象,需人工确认绑定';
|
|
295
|
+
if (res.reason === 'loc_candidate_conflict') return '多个同名文件候选且行数对不上,需人工确认声明指向哪个文件';
|
|
296
|
+
return '候选存在冲突,需人工判定';
|
|
297
|
+
case 'unbound':
|
|
298
|
+
if (res.reason === 'no_comparable_shape') return '产出物中存在同名条目但值形状不可比(比率 vs 标量 / 量纲不同)';
|
|
299
|
+
if (res.reason === 'loc_file_not_in_scope') return '声明指向的文件不在扫描范围内,无法核实行数';
|
|
300
|
+
return '产出物中未找到可比对条目(可能是文档自称,也可能只是未导出)';
|
|
301
|
+
case 'ref-found': return '引用到的产出物文件确实存在';
|
|
302
|
+
case 'ref-missing': return '引用到的产出物在扫描范围内不存在';
|
|
303
|
+
default: return res.reason || '';
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
module.exports = { resolveClaim, score, overlapCoefficient, candidatesFor, explain, comparableShape };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 内容寻址的声明身份(stableKey)。
|
|
8
|
+
*
|
|
9
|
+
* 为什么不用 `id` 当身份:`id` 含行号(`claims.js` 里 `<file>:L<line>:<kind>:<key>`)。
|
|
10
|
+
* 在声明上方插入一行注释,整份文件的 id 全部改名,drift 历史会把纯排版改动读成
|
|
11
|
+
* 大规模迁移(issue #96)。行号制至少是「诚实的错」—— 而一个错误的稳定键会让两条
|
|
12
|
+
* 不同的声明共用同一个身份,所以这里的每条归一化规则都必须能说清理由。
|
|
13
|
+
*
|
|
14
|
+
* stableKey = <file>:<kind>:<key>:<sha256(payload) 前 12 位>
|
|
15
|
+
* payload = file \0 kind \0 key \0 value.raw \0 normalizeContext(sentence)
|
|
16
|
+
*
|
|
17
|
+
* 归一化只做三件事,且都是「同一份内容的不同排版」:
|
|
18
|
+
* 1. CRLF -> LF(Windows 本地跑与 Actions Linux 跑必须得到同一个 hash)
|
|
19
|
+
* 2. 剥掉 HTML 注释(否则在行尾加 disposition 注释就会改变身份)
|
|
20
|
+
* 3. 连续空白压成一个空格并去首尾(缩进变化不该改变身份)
|
|
21
|
+
*
|
|
22
|
+
* 不做的事:不猜「文件 + kind + key 相同就算同一条」。同一文件里完全可能有两行
|
|
23
|
+
* 一模一样的声明;那两条必须有两个身份,按出现顺序追加 `#2`、`#3`,并且报告要
|
|
24
|
+
* 告诉作者「你自己写了两行一样的」。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const HASH_LEN = 12;
|
|
28
|
+
|
|
29
|
+
function normalizeContext(sentence) {
|
|
30
|
+
return String(sentence == null ? '' : sentence)
|
|
31
|
+
.replace(/\r\n?/g, '\n')
|
|
32
|
+
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
33
|
+
.replace(/\s+/g, ' ')
|
|
34
|
+
.trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function valueRaw(claim) {
|
|
38
|
+
const v = claim && claim.value;
|
|
39
|
+
if (!v || v.raw == null) return '';
|
|
40
|
+
return String(v.raw);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function payload(claim) {
|
|
44
|
+
const c = claim || {};
|
|
45
|
+
return [
|
|
46
|
+
String(c.file == null ? '' : c.file),
|
|
47
|
+
String(c.kind == null ? '' : c.kind),
|
|
48
|
+
String(c.key == null ? '' : c.key),
|
|
49
|
+
valueRaw(c),
|
|
50
|
+
normalizeContext(c.sentence)
|
|
51
|
+
].join('\u0000');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hashClaim(claim) {
|
|
55
|
+
return crypto.createHash('sha256').update(payload(claim), 'utf8').digest('hex').slice(0, HASH_LEN);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function baseKeyOf(claim) {
|
|
59
|
+
const c = claim || {};
|
|
60
|
+
return String(c.file) + ':' + String(c.kind) + ':' + String(c.key) + ':' + hashClaim(c);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 给一批声明写 stableKey。必须按抽取顺序调用(顺序决定 `#n` 后缀)。
|
|
65
|
+
*
|
|
66
|
+
* 同一 baseKey 的第 1 条不带后缀,第 2 条起带 `#2`、`#3`…… 这样在无关行插入时,
|
|
67
|
+
* 计数器既不会错位,也不会因为「后出现的覆盖前面的」把两条声明合并成一条。
|
|
68
|
+
*
|
|
69
|
+
* @returns {{duplicates: Array<{stableKey:string,file:string,ids:string[],count:number}>}}
|
|
70
|
+
*/
|
|
71
|
+
function assignStableKeys(claims) {
|
|
72
|
+
const seen = new Map();
|
|
73
|
+
const groups = new Map();
|
|
74
|
+
for (const c of claims) {
|
|
75
|
+
const base = baseKeyOf(c);
|
|
76
|
+
const n = (seen.get(base) || 0) + 1;
|
|
77
|
+
seen.set(base, n);
|
|
78
|
+
c.stableKeyIndex = n;
|
|
79
|
+
c.stableKey = n === 1 ? base : base + '#' + n;
|
|
80
|
+
if (!groups.has(base)) groups.set(base, []);
|
|
81
|
+
groups.get(base).push(c);
|
|
82
|
+
}
|
|
83
|
+
const duplicates = [];
|
|
84
|
+
for (const [stableKey, list] of groups) {
|
|
85
|
+
if (list.length < 2) continue;
|
|
86
|
+
for (const c of list) c.stableKeyGroupSize = list.length;
|
|
87
|
+
duplicates.push({
|
|
88
|
+
stableKey,
|
|
89
|
+
file: list[0].file,
|
|
90
|
+
ids: list.map((c) => c.id),
|
|
91
|
+
count: list.length
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return { duplicates };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { normalizeContext, hashClaim, baseKeyOf, assignStableKeys, HASH_LEN };
|
package/src/core/walk.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
/** 极简 glob → 正则(支持 ** / * / ?),够用即可,避免引入外部依赖。 */
|
|
8
|
+
function globToRegExp(glob) {
|
|
9
|
+
let re = '';
|
|
10
|
+
for (let i = 0; i < glob.length; i++) {
|
|
11
|
+
const c = glob[i];
|
|
12
|
+
if (c === '*') {
|
|
13
|
+
// "**/" 语义是「零层或多层目录」,必须能匹配根目录下的文件
|
|
14
|
+
if (glob[i + 1] === '*') {
|
|
15
|
+
if (glob[i + 2] === '/') {
|
|
16
|
+
re += '(?:(?:[^/]+/)*)?';
|
|
17
|
+
i += 2; // i 落在 '/' 上,交给外层 i++ 消化
|
|
18
|
+
} else {
|
|
19
|
+
re += '.*';
|
|
20
|
+
i += 1; // 跳过第二个 '*'
|
|
21
|
+
}
|
|
22
|
+
} else {
|
|
23
|
+
re += '[^/]*';
|
|
24
|
+
}
|
|
25
|
+
} else if (c === '?') {
|
|
26
|
+
re += '[^/]';
|
|
27
|
+
} else if ('\\^$.|+()[]{}'.indexOf(c) >= 0) {
|
|
28
|
+
re += '\\' + c;
|
|
29
|
+
} else {
|
|
30
|
+
re += c;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return new RegExp('^' + re + '$');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function compilePatterns(list) {
|
|
37
|
+
return (list || []).map(globToRegExp);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function matchesAny(rel, compiled) {
|
|
41
|
+
for (const re of compiled) if (re.test(rel)) return true;
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_EXCLUDE = [
|
|
46
|
+
'**/node_modules/**', '**/.git/**', '**/target/**', '**/.verifact/**',
|
|
47
|
+
'**/coverage/**', '**/*.map', '**/*.min.js'
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 迭代式遍历目录(不用递归,避免深目录爆栈)。
|
|
52
|
+
* @returns {Iterator<{full:string, rel:string, size:number}>}
|
|
53
|
+
*/
|
|
54
|
+
function* iterate(root, opts) {
|
|
55
|
+
const exclude = compilePatterns(opts.exclude && opts.exclude.length ? opts.exclude : DEFAULT_EXCLUDE);
|
|
56
|
+
const rootAbs = path.resolve(root);
|
|
57
|
+
const stack = [rootAbs];
|
|
58
|
+
let emitted = 0;
|
|
59
|
+
|
|
60
|
+
while (stack.length) {
|
|
61
|
+
const cur = stack.pop();
|
|
62
|
+
let ents;
|
|
63
|
+
try { ents = fs.readdirSync(cur, { withFileTypes: true }); } catch (_) { continue; }
|
|
64
|
+
for (const e of ents) {
|
|
65
|
+
const full = path.join(cur, e.name);
|
|
66
|
+
const rel = path.relative(rootAbs, full).split(path.sep).join('/');
|
|
67
|
+
if (e.isDirectory()) {
|
|
68
|
+
if (matchesAny(rel + '/', exclude)) continue;
|
|
69
|
+
if (opts.maxDepth && rel.split('/').length > opts.maxDepth) continue;
|
|
70
|
+
stack.push(full);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!e.isFile()) continue;
|
|
74
|
+
if (matchesAny(rel, exclude)) continue;
|
|
75
|
+
// include 语义为 OR,由调用方通过 matchInclude 提供
|
|
76
|
+
if (opts.matchInclude && !opts.matchInclude(rel)) continue;
|
|
77
|
+
let size = 0;
|
|
78
|
+
try { size = fs.statSync(full).size; } catch (_) { continue; }
|
|
79
|
+
if (opts.maxFileBytes && size > opts.maxFileBytes) continue;
|
|
80
|
+
if (opts.maxFiles && emitted >= opts.maxFiles) return;
|
|
81
|
+
emitted++;
|
|
82
|
+
yield { full, rel, size };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function listFiles(root, opts) {
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const f of iterate(root, opts)) out.push(f);
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readText(full, maxBytes) {
|
|
94
|
+
const buf = fs.readFileSync(full);
|
|
95
|
+
if (maxBytes && buf.length > maxBytes) throw new Error('file_too_large');
|
|
96
|
+
return buf.toString('utf8');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { globToRegExp, compilePatterns, matchesAny, iterate, listFiles, readText, DEFAULT_EXCLUDE };
|