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
package/src/engine.js
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { partitionRoots, buildModules } = require('./config');
|
|
7
|
+
const { iterate, compilePatterns } = require('./core/walk');
|
|
8
|
+
const { collectArtifacts, buildIndex } = require('./core/artifacts');
|
|
9
|
+
const { resolveClaim, explain } = require('./core/resolve');
|
|
10
|
+
const { readArtifactCache, writeArtifactCache } = require('./core/cache');
|
|
11
|
+
const { compareValues, valueFromString, normKey } = require('./core/normalize');
|
|
12
|
+
const { scanFiles } = require('./pooled-scan');
|
|
13
|
+
const { appendRun, readRuns, HISTORY_SCHEMA } = require('./history');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 服务层:编排「扫文档 → 收产出物 → 绑定 → 判定 → 统计 → 门禁」。
|
|
17
|
+
* 不依赖 http / cli 任何类型。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function labelFor(rootAbs, rel, isFileRoot) {
|
|
21
|
+
if (isFileRoot) return rel;
|
|
22
|
+
const base = path.basename(rootAbs);
|
|
23
|
+
return base ? base + '/' + rel : rel;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function listDocFiles(cfg) {
|
|
27
|
+
const { dirs, files, missing } = partitionRoots(cfg.docs.roots, cfg.__baseDir || cfg.__cwd);
|
|
28
|
+
const includeRe = compilePatterns(cfg.docs.include);
|
|
29
|
+
const out = [];
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
|
|
32
|
+
for (const f of files) {
|
|
33
|
+
const key = path.resolve(f.abs);
|
|
34
|
+
if (seen.has(key)) continue;
|
|
35
|
+
seen.add(key);
|
|
36
|
+
out.push({ full: f.abs, rel: f.rel, size: fs.statSync(f.abs).size });
|
|
37
|
+
}
|
|
38
|
+
for (const d of dirs) {
|
|
39
|
+
for (const f of iterate(d.abs, {
|
|
40
|
+
exclude: cfg.docs.exclude,
|
|
41
|
+
matchInclude: (rel) => includeRe.some((re) => re.test(rel)),
|
|
42
|
+
maxFileBytes: cfg.docs.maxFileBytes,
|
|
43
|
+
maxFiles: cfg.docs.maxFiles,
|
|
44
|
+
maxDepth: cfg.docs.maxDepth
|
|
45
|
+
})) {
|
|
46
|
+
const key = path.resolve(f.full);
|
|
47
|
+
if (seen.has(key)) continue;
|
|
48
|
+
seen.add(key);
|
|
49
|
+
out.push({ full: f.full, rel: labelFor(d.abs, f.rel, false), size: f.size });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { files: out, missing };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function matchesFile(value, pattern) {
|
|
56
|
+
if (!pattern) return true;
|
|
57
|
+
const re = compilePatterns([pattern]);
|
|
58
|
+
return re.some((r) => r.test(value));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function applyBinding(claim, cfg) {
|
|
62
|
+
for (const b of cfg.bindings || []) {
|
|
63
|
+
if (b.key && normKey(b.key) !== normKey(claim.key)) continue;
|
|
64
|
+
if (b.module && claim.module !== b.module) continue;
|
|
65
|
+
if (b.kind && b.kind !== claim.kind) continue;
|
|
66
|
+
if (b.file && !matchesFile(claim.file, b.file)) continue;
|
|
67
|
+
return b;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function matchRule(list, claim) {
|
|
73
|
+
for (const r of list || []) {
|
|
74
|
+
if (r.key && normKey(r.key) !== normKey(claim.key)) continue;
|
|
75
|
+
if (r.kind && r.kind !== claim.kind) continue;
|
|
76
|
+
if (r.file && !matchesFile(claim.file, r.file)) continue;
|
|
77
|
+
// module: null 是有效写法 =「没识别出模块的行」;不写 module 则不限制
|
|
78
|
+
if (r.module !== undefined && (claim.module || null) !== r.module) continue;
|
|
79
|
+
return r;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function denyRule(claim, cfg) {
|
|
85
|
+
return matchRule(cfg.deny, claim);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isDenied(claim, cfg) {
|
|
89
|
+
return !!denyRule(claim, cfg);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isIgnored(claim, cfg) {
|
|
93
|
+
return !!matchRule(cfg.ignore, claim);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 哈希存证目录(sealed):返回命中的那条 glob,未命中返回 null。
|
|
98
|
+
*
|
|
99
|
+
* 存在理由:`www/docs/tsa/**` 里的文件本身就是 TSA 时间戳的对象,
|
|
100
|
+
* 插一行 `<!-- verifact: ... -->` 就改变哈希、破坏存证。disposition 要改文件,
|
|
101
|
+
* 所以这条路走不通;ignore/deny 又只能按 key/kind 逐条写、治不了「整个目录」。
|
|
102
|
+
* sealed 是目录级的「连碰都不碰」声明。
|
|
103
|
+
*/
|
|
104
|
+
function sealedPattern(claim, cfg) {
|
|
105
|
+
for (const p of cfg.sealed || []) {
|
|
106
|
+
if (matchesFile(claim.file, p)) return p;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function evaluateGate(stats, policy) {
|
|
112
|
+
const failOn = policy || 'drift';
|
|
113
|
+
if (failOn === 'none') return { failed: false, policy: failOn };
|
|
114
|
+
if (failOn === 'review') {
|
|
115
|
+
return { failed: stats.drift + stats.ambiguous + stats.unbound + stats.refMissing > 0, policy: failOn };
|
|
116
|
+
}
|
|
117
|
+
if (failOn === 'lint-high') {
|
|
118
|
+
return { failed: stats.lintHigh > 0, policy: failOn };
|
|
119
|
+
}
|
|
120
|
+
return { failed: stats.drift > 0, policy: 'drift' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function run(cfg, opts) {
|
|
124
|
+
const options = opts || {};
|
|
125
|
+
const startedAt = Date.now();
|
|
126
|
+
const log = options.logger || require('./logger').create({ quiet: true });
|
|
127
|
+
const modules = buildModules(cfg.modules);
|
|
128
|
+
|
|
129
|
+
// 1) 文档清单 + 并行抽取
|
|
130
|
+
const listed = listDocFiles(cfg);
|
|
131
|
+
// options.scanFiles 是给测试与内嵌调用用的替换点(默认就是真扫描器)。
|
|
132
|
+
const scan = await (options.scanFiles || scanFiles)(listed.files, {
|
|
133
|
+
modules,
|
|
134
|
+
deep: !!options.deep,
|
|
135
|
+
maxBytes: cfg.docs.maxFileBytes,
|
|
136
|
+
doLint: cfg.lint.enabled,
|
|
137
|
+
lintOpts: cfg.lint,
|
|
138
|
+
concurrency: options.concurrency
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// 两类失败都会让这份文档的声明静默消失,报告少了一整片数字却仍然绿色 ——
|
|
142
|
+
// 那正是本工具反对的假绿(#95):
|
|
143
|
+
// 1. 处置声明写错 —— 作者错误,任何参数下都不容忍;
|
|
144
|
+
// 2. 抽取/读取崩溃 —— 默认不容忍,只有显式 --allow-scan-errors 才继续。
|
|
145
|
+
// too_large 是有意跳过,不算崩溃,永远非致命。
|
|
146
|
+
const fatal = scan.errors.filter((e) => e && e.fatal);
|
|
147
|
+
const dispositionFatal = fatal.filter((e) => e.reason === 'disposition_error');
|
|
148
|
+
const tolerate = !!options.allowScanErrors;
|
|
149
|
+
const tolerated = tolerate ? fatal.filter((e) => e.reason !== 'disposition_error') : [];
|
|
150
|
+
|
|
151
|
+
if (dispositionFatal.length) {
|
|
152
|
+
const lines = dispositionFatal.map((e) => (e.detail ? e.detail : e.rel + ': ' + e.reason));
|
|
153
|
+
const err = new Error('处置声明解析失败(' + dispositionFatal.length + ' 处):\n ' + lines.join('\n '));
|
|
154
|
+
err.name = 'DispositionError';
|
|
155
|
+
err.fatal = dispositionFatal;
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const scanFatal = fatal.filter((e) => e.reason !== 'disposition_error');
|
|
160
|
+
if (scanFatal.length && !tolerate) {
|
|
161
|
+
const lines = scanFatal.map((e) => e.rel + ': ' + e.reason);
|
|
162
|
+
const err = new Error('文档扫描失败(' + scanFatal.length + ' 个文件的声明未纳入统计):\n ' +
|
|
163
|
+
lines.join('\n ') + '\n 如果确认这些文件可以跳过,用 --allow-scan-errors 重跑,报告会把它们逐条列出。');
|
|
164
|
+
err.name = 'ScanError';
|
|
165
|
+
err.fatal = scanFatal;
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const claims = [];
|
|
170
|
+
const lintFindings = [];
|
|
171
|
+
for (const r of scan.results) {
|
|
172
|
+
for (const c of r.claims) claims.push(c);
|
|
173
|
+
for (const l of r.lint) lintFindings.push(l);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 2) 产出物(带指纹增量缓存)
|
|
177
|
+
const artifactRoots = partitionRoots(cfg.artifacts.roots, cfg.__baseDir || cfg.__cwd);
|
|
178
|
+
const absRoots = artifactRoots.dirs.map((d) => d.abs).concat(artifactRoots.files.map((f) => f.abs));
|
|
179
|
+
const cached = (cfg.cache.enabled && !options.noCache)
|
|
180
|
+
? readArtifactCache(cfg, cfg.__cwd, absRoots)
|
|
181
|
+
: { hit: false, reason: 'disabled' };
|
|
182
|
+
|
|
183
|
+
let artifacts;
|
|
184
|
+
let cacheState = cached.hit ? 'hit' : 'miss';
|
|
185
|
+
if (cached.hit) {
|
|
186
|
+
artifacts = { entries: cached.entries, presence: cached.presence, scanned: cached.scanned, skipped: cached.skipped, fileLines: cached.fileLines || [] };
|
|
187
|
+
} else {
|
|
188
|
+
const t0 = Date.now();
|
|
189
|
+
artifacts = collectArtifacts(cfg, modules);
|
|
190
|
+
writeArtifactCache(cfg, cfg.__cwd, absRoots, artifacts);
|
|
191
|
+
log.info('artifact_index_rebuilt', { ms: Date.now() - t0, entries: artifacts.entries.length });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 自证清洗:既是文档又是产出物的文件必须踢出去,否则会出现「README 自己给自己作证」
|
|
195
|
+
let selfRemoved = 0;
|
|
196
|
+
const docAbs = new Set(listed.files.map((f) => path.resolve(f.full)));
|
|
197
|
+
if (!options.allowSelfEvidence) {
|
|
198
|
+
const before = artifacts.entries.length;
|
|
199
|
+
artifacts.entries = artifacts.entries.filter((e) => !e.abs || !docAbs.has(e.abs));
|
|
200
|
+
selfRemoved = before - artifacts.entries.length;
|
|
201
|
+
if (selfRemoved) log.info('self_evidence_filtered', { removed: selfRemoved });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const index = buildIndex(artifacts);
|
|
205
|
+
|
|
206
|
+
// 3) 逐条判定
|
|
207
|
+
const results = [];
|
|
208
|
+
const stats = {
|
|
209
|
+
docs: listed.files.length,
|
|
210
|
+
artifacts: artifacts.entries.length,
|
|
211
|
+
// claims 仍是「进入判定」的条数(code-artifact 且未被 ignore/deny)——语义不变。
|
|
212
|
+
claims: 0, verified: 0, drift: 0, ambiguous: 0, unbound: 0,
|
|
213
|
+
refFound: 0, refMissing: 0, ignored: 0, denied: 0,
|
|
214
|
+
// 新增维度:处置分类。claimsTotal = 抽到的全部声明(含 ignored/denied),
|
|
215
|
+
// dispositioned = 因处置分类而未进入判定的条数。
|
|
216
|
+
claimsTotal: claims.length, dispositioned: 0, dispositionCounts: {},
|
|
217
|
+
standardRefs: 0, sealed: 0
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// sealed 规则命中率:一条规则零命中就是「配了但没生效」,
|
|
221
|
+
// 滴漏地让人以为存证目录已受保护——必须报出来。
|
|
222
|
+
const sealedHits = new Map((cfg.sealed || []).map((p) => [p, 0]));
|
|
223
|
+
|
|
224
|
+
for (const claim of claims) {
|
|
225
|
+
// 标准编号引用(`FIPS 203/205`、`GB/T 32918/32905`):形状是比值,语义不是。
|
|
226
|
+
// 提取层已标记,这里单列计数:不进判定、不进覆盖率分母,但必须能在报告里被看见。
|
|
227
|
+
// 放在 deny 之前——它是抽取层的真相,不该被配置层的规则再摆一道。
|
|
228
|
+
if (claim.notMeasurement) {
|
|
229
|
+
stats.standardRefs++;
|
|
230
|
+
results.push(Object.assign({}, claim, {
|
|
231
|
+
status: 'reference',
|
|
232
|
+
note: '标准编号引用(' + claim.notMeasurement + '):不按比值解析'
|
|
233
|
+
}));
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 哈希存证目录:连碰都不碰。放在 deny 之前——目录级保护比逐条规则更强。
|
|
238
|
+
const sealedBy = sealedPattern(claim, cfg);
|
|
239
|
+
if (sealedBy) {
|
|
240
|
+
stats.sealed++;
|
|
241
|
+
sealedHits.set(sealedBy, (sealedHits.get(sealedBy) || 0) + 1);
|
|
242
|
+
results.push(Object.assign({}, claim, {
|
|
243
|
+
status: 'sealed',
|
|
244
|
+
note: '哈希存证目录(sealed: ' + sealedBy + '):不能插注释,不参与判定'
|
|
245
|
+
}));
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 优先级:deny > disposition > ignore > 正常判定(RFC §3.5)。
|
|
250
|
+
// deny 是结构性的(对照组不能比),优先于任何作者声明。
|
|
251
|
+
if (isDenied(claim, cfg)) {
|
|
252
|
+
stats.denied++;
|
|
253
|
+
const rule = denyRule(claim, cfg);
|
|
254
|
+
results.push(Object.assign({}, claim, {
|
|
255
|
+
status: 'denied',
|
|
256
|
+
note: '拒绝自动绑定' + (rule && rule.note ? ':' + rule.note : '')
|
|
257
|
+
}));
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const disposition = claim.disposition || 'code-artifact';
|
|
262
|
+
const judgedDisposition = claim.disposition && claim.disposition !== 'code-artifact';
|
|
263
|
+
if (judgedDisposition) {
|
|
264
|
+
// 声明者已表态:这条数字不是产物属性。它不进判定,也不进覆盖率分母,
|
|
265
|
+
// 但仍然逐条留在报告里(status=disposition),不允许静默丢弃。
|
|
266
|
+
stats.dispositioned++;
|
|
267
|
+
results.push(Object.assign({}, claim, {
|
|
268
|
+
status: 'disposition',
|
|
269
|
+
note: '处置分类:' + disposition + (claim.dispositionArgs ? '(' + Object.keys(claim.dispositionArgs).map((k) => k + '=' + claim.dispositionArgs[k]).join(' ') + ')' : '')
|
|
270
|
+
}));
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (isIgnored(claim, cfg)) {
|
|
275
|
+
stats.ignored++;
|
|
276
|
+
const rule = matchRule(cfg.ignore, claim);
|
|
277
|
+
// why 不只用于校验:它要跟着声明一起进报告,否则「为什么忽略」就只活在配置里。
|
|
278
|
+
results.push(Object.assign({}, claim, {
|
|
279
|
+
status: 'ignored',
|
|
280
|
+
note: '命中 ignore 规则' + (rule && rule.why ? ':' + rule.why : '')
|
|
281
|
+
}));
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
stats.claims++;
|
|
285
|
+
|
|
286
|
+
const binding = applyBinding(claim, cfg);
|
|
287
|
+
if (binding && binding.expect != null) {
|
|
288
|
+
const expect = valueFromString(String(binding.expect));
|
|
289
|
+
const cmp = compareValues(claim.value, expect, cfg.resolve);
|
|
290
|
+
if (cmp.equal) stats.verified++; else stats.drift++;
|
|
291
|
+
results.push(Object.assign({}, claim, {
|
|
292
|
+
status: cmp.equal ? 'verified' : 'drift',
|
|
293
|
+
score: 1, cmp, note: '人工显式绑定',
|
|
294
|
+
best: { source: '<binding>', path: binding.key || claim.key, value: expect }
|
|
295
|
+
}));
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const res = resolveClaim(claim, index, cfg.resolve);
|
|
300
|
+
if (res.status === 'verified') stats.verified++;
|
|
301
|
+
else if (res.status === 'drift') stats.drift++;
|
|
302
|
+
else if (res.status === 'ambiguous') stats.ambiguous++;
|
|
303
|
+
else if (res.status === 'ref-found') stats.refFound++;
|
|
304
|
+
else if (res.status === 'ref-missing') stats.refMissing++;
|
|
305
|
+
else stats.unbound++;
|
|
306
|
+
|
|
307
|
+
results.push(Object.assign({}, claim, res, { note: explain(res) }));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 处置分布:只统计「作者表过态」的声明。ignored 与 denied 都不是作者的分类判断,
|
|
311
|
+
// 各自单独计数、不重复计入这里。denied 若混进来,会以默认值 code-artifact 落进桶里,
|
|
312
|
+
// 报告就出现「处置分类 code-artifact 247」而「分母 246」(1 条拒绑的差,#102)。
|
|
313
|
+
stats.dispositionCounts = { 'code-artifact': 0, 'measured-once': 0, progress: 0, narrative: 0 };
|
|
314
|
+
for (const c of results) {
|
|
315
|
+
if (c.status === 'ignored' || c.status === 'denied' || c.status === 'reference' || c.status === 'sealed') continue;
|
|
316
|
+
const key = c.disposition || 'code-artifact';
|
|
317
|
+
if (stats.dispositionCounts[key] == null) stats.dispositionCounts[key] = 0;
|
|
318
|
+
stats.dispositionCounts[key]++;
|
|
319
|
+
}
|
|
320
|
+
// code-artifact 桶与覆盖率分母必须同源:进入判定的条数只从一个地方来,
|
|
321
|
+
// 不允许两处各算一次(那正是 #102 的成因)。
|
|
322
|
+
const codeArtifactClaims = stats.claims;
|
|
323
|
+
|
|
324
|
+
// 反洗白警示:整份文件一条 code-artifact 都没有 → 单列出来的文件级声明;
|
|
325
|
+
// 全报告非 code-artifact 占比 > 50% → 顶部警示。没有这两条,整文件标注就成了
|
|
326
|
+
// 「把分母搬走」的合法外衣。
|
|
327
|
+
const perFile = new Map();
|
|
328
|
+
for (const c of results) {
|
|
329
|
+
if (c.status === 'ignored' || c.status === 'reference' || c.status === 'sealed') continue;
|
|
330
|
+
if (!perFile.has(c.file)) perFile.set(c.file, []);
|
|
331
|
+
perFile.get(c.file).push(c);
|
|
332
|
+
}
|
|
333
|
+
const fullyDispositionedFiles = [];
|
|
334
|
+
for (const [file, list] of perFile) {
|
|
335
|
+
if (!list.length) continue;
|
|
336
|
+
if (list.every((c) => (c.disposition || 'code-artifact') !== 'code-artifact')) {
|
|
337
|
+
fullyDispositionedFiles.push({ file, claims: list.length });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const judgedTotal = codeArtifactClaims + stats.dispositioned;
|
|
341
|
+
// 同一文件里完全相同的两条声明(同一行写了两次同一个数,或复制粘贴的残骸):
|
|
342
|
+
// 它们有两个身份,但作者大概不知道。报告要指出来,否则「重复」永远没人收拾。
|
|
343
|
+
const dupGroups = new Map();
|
|
344
|
+
for (const c of results) {
|
|
345
|
+
if (!(c.stableKeyGroupSize > 1)) continue;
|
|
346
|
+
const base = String(c.stableKey).replace(/#\d+$/, '');
|
|
347
|
+
if (!dupGroups.has(base)) dupGroups.set(base, []);
|
|
348
|
+
dupGroups.get(base).push(c);
|
|
349
|
+
}
|
|
350
|
+
const duplicateClaims = [];
|
|
351
|
+
for (const [stableKey, list] of dupGroups) {
|
|
352
|
+
duplicateClaims.push({
|
|
353
|
+
stableKey,
|
|
354
|
+
file: list[0].file,
|
|
355
|
+
key: list[0].key,
|
|
356
|
+
kind: list[0].kind,
|
|
357
|
+
value: list[0].value ? list[0].value.raw : null,
|
|
358
|
+
lines: list.map((c) => c.line),
|
|
359
|
+
ids: list.map((c) => c.id)
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
stats.duplicateClaimGroups = duplicateClaims.length;
|
|
363
|
+
|
|
364
|
+
stats.dispositionWarnings = {
|
|
365
|
+
fullyDispositionedFiles,
|
|
366
|
+
share: judgedTotal ? stats.dispositioned / judgedTotal : 0,
|
|
367
|
+
shareExceedsHalf: judgedTotal > 0 && stats.dispositioned / judgedTotal > 0.5
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
// sealed 规则零命中 = 规则写了但没生效(路径写错、或目录被改名)。
|
|
371
|
+
// 不说的话,存证目录就处在「以为被保护、实际在裸奔」的状态。
|
|
372
|
+
stats.sealedWarnings = { emptyPatterns: [...sealedHits.entries()].filter(([, n]) => n === 0).map(([p]) => p) };
|
|
373
|
+
|
|
374
|
+
// 跨文档互证:同一个键在不同文档里给了不同的值(没有产出物可锚时的第二层信号)
|
|
375
|
+
let crossDoc = [];
|
|
376
|
+
if (!cfg.crossDoc || cfg.crossDoc.enabled !== false) {
|
|
377
|
+
crossDoc = require('./core/crossdoc').crossDocCheck(results, cfg.crossDoc || {});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const lintCounts = { high: 0, medium: 0, low: 0 };
|
|
381
|
+
for (const f of lintFindings) if (lintCounts[f.severity] != null) lintCounts[f.severity]++;
|
|
382
|
+
|
|
383
|
+
const withGate = Object.assign({ lintHigh: lintCounts.high }, stats);
|
|
384
|
+
const gate = evaluateGate(withGate, cfg.gate.failOn);
|
|
385
|
+
|
|
386
|
+
const report = {
|
|
387
|
+
schema: 'verifact-report/2',
|
|
388
|
+
generatedAt: new Date().toISOString(),
|
|
389
|
+
durationMs: Date.now() - startedAt,
|
|
390
|
+
config: {
|
|
391
|
+
configFile: cfg.__configFile,
|
|
392
|
+
cwd: cfg.__cwd,
|
|
393
|
+
baseDir: cfg.__baseDir,
|
|
394
|
+
docsRoots: cfg.docs.roots,
|
|
395
|
+
artifactRoots: cfg.artifacts.roots,
|
|
396
|
+
minScore: cfg.resolve.minScore,
|
|
397
|
+
relTolerance: cfg.resolve.relTolerance,
|
|
398
|
+
modules: modules.length,
|
|
399
|
+
failOn: cfg.gate.failOn
|
|
400
|
+
},
|
|
401
|
+
meta: {
|
|
402
|
+
cache: cacheState,
|
|
403
|
+
cacheReason: cached.reason || null,
|
|
404
|
+
workers: scan.workers || 0,
|
|
405
|
+
pooled: !!scan.pooled,
|
|
406
|
+
docsMissing: listed.missing,
|
|
407
|
+
docScanErrors: scan.errors.length,
|
|
408
|
+
docScanFatal: fatal.length,
|
|
409
|
+
// 逐条列出「没进统计」的文件。默认路径下只有 too_large;开了 --allow-scan-errors
|
|
410
|
+
// 之后,被容忍的崩溃也列在这里,报告不能只说"跳过 1 个"。
|
|
411
|
+
skippedFiles: scan.errors.map((e) => ({
|
|
412
|
+
file: e.rel,
|
|
413
|
+
reason: e.reason,
|
|
414
|
+
detail: e.detail || null,
|
|
415
|
+
bytes: e.bytes != null ? e.bytes : null,
|
|
416
|
+
limit: e.limit != null ? e.limit : null,
|
|
417
|
+
tolerated: e.fatal === true
|
|
418
|
+
})),
|
|
419
|
+
toleratedScanErrors: tolerated.length,
|
|
420
|
+
artifactsScanned: artifacts.scanned,
|
|
421
|
+
artifactsSkipped: artifacts.skipped,
|
|
422
|
+
selfEvidenceRemoved: selfRemoved
|
|
423
|
+
},
|
|
424
|
+
counts: Object.assign({
|
|
425
|
+
lint: lintFindings.length,
|
|
426
|
+
crossDoc: crossDoc.length,
|
|
427
|
+
crossDocWarn: crossDoc.filter((x) => x.severity === 'warn').length,
|
|
428
|
+
// 双分母:覆盖率必须能同时说出「以 code-artifact 为分母」和「以全部受判声明为分母」。
|
|
429
|
+
// 受判 = 进入判定的 + 作者声明非产物属性的 + 被拒绑的(拒绑也是判定结论,只是不绑定)。
|
|
430
|
+
codeArtifactClaims,
|
|
431
|
+
allJudgedClaims: codeArtifactClaims + stats.dispositioned + stats.denied
|
|
432
|
+
}, stats),
|
|
433
|
+
lintCounts,
|
|
434
|
+
gate,
|
|
435
|
+
claims: results,
|
|
436
|
+
duplicateClaims,
|
|
437
|
+
crossDoc,
|
|
438
|
+
lint: lintFindings
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
if (cfg.cache.enabled && !options.noCache) {
|
|
442
|
+
// state map 只收录非 unbound 的声明:unbound 占比高且没有迁移信息量。
|
|
443
|
+
// 键是 stableKey(内容寻址),不是含行号的 id —— 否则在声明上方插一行注释,
|
|
444
|
+
// 整份文件的身份全部改名,drift 历史会把排版改动读成大规模迁移(#96)。
|
|
445
|
+
const states = {};
|
|
446
|
+
for (const c of results) if (c.status !== 'unbound') states[c.stableKey] = c.status;
|
|
447
|
+
// 先读后写:读出来的 legacyDropped 说明有多少条 schema 1 记录被丢弃,
|
|
448
|
+
// 用户需要知道「这次比较的基准为什么变短了」,否则会以为工具丢了数据。
|
|
449
|
+
const prevRuns = readRuns(cfg, cfg.__cwd, 1);
|
|
450
|
+
const legacyDropped = (prevRuns && prevRuns.legacyDropped) || 0;
|
|
451
|
+
const appended = appendRun(cfg, cfg.__cwd, {
|
|
452
|
+
schema: HISTORY_SCHEMA,
|
|
453
|
+
generatedAt: report.generatedAt,
|
|
454
|
+
durationMs: report.durationMs,
|
|
455
|
+
counts: report.counts,
|
|
456
|
+
lintCounts: report.lintCounts,
|
|
457
|
+
gate: report.gate,
|
|
458
|
+
cache: cacheState,
|
|
459
|
+
states
|
|
460
|
+
});
|
|
461
|
+
if (appended) report.meta.history = { schema: HISTORY_SCHEMA, legacyDropped };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return report;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
module.exports = { run, evaluateGate, listDocFiles, applyBinding, isIgnored, isDenied, denyRule, sealedPattern, labelFor };
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 类型化错误体系。客户端(CLI / HTTP)只看到 code + message + details,
|
|
6
|
+
* 绝不泄漏堆栈或内部路径细节。
|
|
7
|
+
*/
|
|
8
|
+
class VerifactError extends Error {
|
|
9
|
+
constructor(code, message, details) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'VerifactError';
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.details = details || {};
|
|
14
|
+
}
|
|
15
|
+
toJSON() {
|
|
16
|
+
const out = { error: this.code, message: this.message };
|
|
17
|
+
if (Object.keys(this.details).length) out.details = this.details;
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class ConfigError extends VerifactError {
|
|
23
|
+
constructor(message, details) { super('config_error', message, details); }
|
|
24
|
+
}
|
|
25
|
+
class UsageError extends VerifactError {
|
|
26
|
+
constructor(message, details) { super('usage_error', message, details); }
|
|
27
|
+
}
|
|
28
|
+
class ResolveError extends VerifactError {
|
|
29
|
+
constructor(message, details) { super('resolve_error', message, details); }
|
|
30
|
+
}
|
|
31
|
+
class GateError extends VerifactError {
|
|
32
|
+
constructor(message, details) { super('gate_failed', message, details); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { VerifactError, ConfigError, UsageError, ResolveError, GateError };
|
package/src/history.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { cachePaths, ensureDir } = require('./core/cache');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 追加式历史记录(JSONL)。用途:看某个声明的状态是否在两次运行之间发生变化,
|
|
9
|
+
* 让 drift 能被追溯「什么时候开始漂的」。
|
|
10
|
+
*
|
|
11
|
+
* schema 2(#96):state map 的键从行号 id 换成内容寻址的 stableKey。
|
|
12
|
+
* 旧 schema 1 的记录不迁移、不猜:行号制的记录里没保存上下文,任何
|
|
13
|
+
* 「file + kind + key 相同就算同一条」的启发式都会在多条声明共用 key 时误判,
|
|
14
|
+
* 而且会把「插了一行」读成迁移。宁可让比较基准断一次,也不制造假迁移。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const HISTORY_SCHEMA = 2;
|
|
18
|
+
|
|
19
|
+
function appendRun(cfg, cwd, summary) {
|
|
20
|
+
if (!cfg.cache.enabled) return false;
|
|
21
|
+
try {
|
|
22
|
+
const p = cachePaths(cfg, cwd);
|
|
23
|
+
ensureDir(p.dir);
|
|
24
|
+
fs.appendFileSync(p.history, JSON.stringify(summary) + '\n', 'utf8');
|
|
25
|
+
trim(p.history, cfg.cache.maxHistory || 200);
|
|
26
|
+
return true;
|
|
27
|
+
} catch (_) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function trim(file, keep) {
|
|
33
|
+
try {
|
|
34
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
35
|
+
if (lines.length <= keep) return;
|
|
36
|
+
fs.writeFileSync(file, lines.slice(-keep).join('\n') + '\n', 'utf8');
|
|
37
|
+
} catch (_) { /* noop */ }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readRuns(cfg, cwd, limit) {
|
|
41
|
+
const out = [];
|
|
42
|
+
let legacy = 0;
|
|
43
|
+
try {
|
|
44
|
+
const p = cachePaths(cfg, cwd);
|
|
45
|
+
if (!fs.existsSync(p.history)) return out;
|
|
46
|
+
const lines = fs.readFileSync(p.history, 'utf8').split('\n').filter(Boolean);
|
|
47
|
+
for (const l of lines) {
|
|
48
|
+
let r;
|
|
49
|
+
try { r = JSON.parse(l); } catch (_) { continue; }
|
|
50
|
+
if (!r || r.schema !== HISTORY_SCHEMA) { legacy++; continue; }
|
|
51
|
+
out.push(r);
|
|
52
|
+
}
|
|
53
|
+
// 读取侧的 legacyDropped 让调用方能说明「这次比较的基准为什么变短了」。
|
|
54
|
+
// 注意必须挂在 slice 之后的数组上:slice 会丢掉原数组上的自定义属性。
|
|
55
|
+
const kept = out.slice(-(limit || 50));
|
|
56
|
+
if (legacy) Object.defineProperty(kept, 'legacyDropped', { value: legacy, enumerable: false });
|
|
57
|
+
return kept;
|
|
58
|
+
} catch (_) {
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 两次运行之间的状态迁移(键为 stableKey,见 src/core/stable-key.js)。
|
|
65
|
+
* state map 只收录非 unbound 的声明(unbound 数量级太大,且没有迁移信息量),
|
|
66
|
+
* 因此「缺席」一律按 unbound 解释。
|
|
67
|
+
*/
|
|
68
|
+
function diffRuns(prev, curr) {
|
|
69
|
+
const a = (prev && prev.states) || {};
|
|
70
|
+
const b = (curr && curr.states) || {};
|
|
71
|
+
const ids = new Set(Object.keys(a).concat(Object.keys(b)));
|
|
72
|
+
const changes = [];
|
|
73
|
+
for (const id of ids) {
|
|
74
|
+
const from = a[id] || 'unbound';
|
|
75
|
+
const to = b[id] || 'unbound';
|
|
76
|
+
if (from === to) continue;
|
|
77
|
+
changes.push({ key: id, from, to });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const pick = (fn) => changes.filter(fn);
|
|
81
|
+
return {
|
|
82
|
+
all: changes,
|
|
83
|
+
newDrift: pick((c) => c.to === 'drift' && c.from !== 'drift'),
|
|
84
|
+
resolvedDrift: pick((c) => c.from === 'drift' && c.to !== 'drift'),
|
|
85
|
+
newVerified: pick((c) => c.to === 'verified' && c.from !== 'verified'),
|
|
86
|
+
lostVerified: pick((c) => c.from === 'verified' && c.to !== 'verified'),
|
|
87
|
+
newMissingRef: pick((c) => c.to === 'ref-missing' && c.from !== 'ref-missing')
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { appendRun, readRuns, diffRuns, HISTORY_SCHEMA };
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
let seq = 0;
|
|
5
|
+
function requestId() {
|
|
6
|
+
seq = (seq + 1) % 1000000;
|
|
7
|
+
return Date.now().toString(36) + '-' + seq.toString(36);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** 结构化 JSON 日志。禁止记录令牌、密钥、隐私正文。 */
|
|
11
|
+
function emit(level, msg, fields) {
|
|
12
|
+
const rec = { ts: new Date().toISOString(), level, msg };
|
|
13
|
+
if (fields && typeof fields === 'object') Object.assign(rec, fields);
|
|
14
|
+
process.stderr.write(JSON.stringify(rec) + '\n');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function create(opts) {
|
|
18
|
+
const quiet = !!(opts && opts.quiet);
|
|
19
|
+
const base = (opts && opts.base) || {};
|
|
20
|
+
return {
|
|
21
|
+
requestId,
|
|
22
|
+
debug: (m, f) => { if (!quiet) emit('debug', m, Object.assign({}, base, f)); },
|
|
23
|
+
info: (m, f) => { if (!quiet) emit('info', m, Object.assign({}, base, f)); },
|
|
24
|
+
warn: (m, f) => emit('warn', m, Object.assign({}, base, f)),
|
|
25
|
+
error: (m, f) => emit('error', m, Object.assign({}, base, f))
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { create, requestId, emit };
|