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,109 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { Worker } = require('worker_threads');
|
|
8
|
+
const { extractClaims } = require('./core/claims');
|
|
9
|
+
const { lintDoc } = require('./core/lint');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* worker_threads 池。文档扫描是 CPU 密集且与 IO 交错的活,
|
|
13
|
+
* 单进程串行在大仓(数千 md)上会明显拖尾,这里按 CPU 数切片并行。
|
|
14
|
+
* worker_threads 不可用时自动退化为串行,不改变结果。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function chunk(items, n) {
|
|
18
|
+
const out = Array.from({ length: n }, () => []);
|
|
19
|
+
let i = 0;
|
|
20
|
+
for (const it of items) { out[i % n].push(it); i++; }
|
|
21
|
+
return out.filter((c) => c.length);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function runInline(files, opts) {
|
|
25
|
+
const results = [];
|
|
26
|
+
const errors = [];
|
|
27
|
+
for (const f of files) {
|
|
28
|
+
try {
|
|
29
|
+
let text = fs.readFileSync(f.full, 'utf8');
|
|
30
|
+
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
|
|
31
|
+
if (opts.maxBytes && Buffer.byteLength(text) > opts.maxBytes) {
|
|
32
|
+
errors.push({ rel: f.rel, reason: 'too_large', fatal: false, bytes: Buffer.byteLength(text), limit: opts.maxBytes });
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const claims = extractClaims(text, f.rel, { modules: opts.modules, deep: !!opts.deep });
|
|
36
|
+
const lint = opts.doLint ? lintDoc(text, f.rel, opts.lintOpts) : [];
|
|
37
|
+
results.push({ rel: f.rel, claims, lint });
|
|
38
|
+
} catch (e) {
|
|
39
|
+
if (e && e.name === 'DispositionError') {
|
|
40
|
+
// 处置声明写错必须让整次运行失败:否则这份文档的声明会静默消失,
|
|
41
|
+
// 报告少了一整片数字却仍然显示绿色 —— 与本工具反对的假绿同类。
|
|
42
|
+
errors.push({ rel: f.rel, reason: 'disposition_error', detail: e.message, fatal: true });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
// 扫描崩溃 = 这份文档的声明没进统计。默认必须让整次运行失败:
|
|
46
|
+
// 报告宁可红,也不能少算一片数字还显示绿色(#95)。
|
|
47
|
+
errors.push({ rel: f.rel, reason: e && e.message ? e.message : 'read_error', fatal: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return Promise.resolve({ results, errors, workers: 0 });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function defaultWorkers(files, max) {
|
|
54
|
+
const cpu = os.cpus ? Math.max(1, os.cpus().length - 1) : 2;
|
|
55
|
+
const cap = max || Math.min(6, cpu);
|
|
56
|
+
return Math.max(1, Math.min(cap, files.length));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scanFiles(files, opts) {
|
|
60
|
+
const options = opts || {};
|
|
61
|
+
if (!files.length) return Promise.resolve({ results: [], errors: [], workers: 0 });
|
|
62
|
+
let WorkerCtor = Worker;
|
|
63
|
+
try {
|
|
64
|
+
if (!WorkerCtor) throw new Error('no_worker_threads');
|
|
65
|
+
} catch (_) {
|
|
66
|
+
WorkerCtor = null;
|
|
67
|
+
}
|
|
68
|
+
if (!WorkerCtor || files.length < 8) return runInline(files, options);
|
|
69
|
+
|
|
70
|
+
const n = defaultWorkers(files, options.concurrency);
|
|
71
|
+
const groups = chunk(files, n);
|
|
72
|
+
const workerPath = path.join(__dirname, 'worker.js');
|
|
73
|
+
|
|
74
|
+
return Promise.all(groups.map((group) => new Promise((resolve) => {
|
|
75
|
+
let w;
|
|
76
|
+
try {
|
|
77
|
+
w = new WorkerCtor(workerPath, {
|
|
78
|
+
workerData: {
|
|
79
|
+
files: group,
|
|
80
|
+
modules: options.modules || [],
|
|
81
|
+
deep: !!options.deep,
|
|
82
|
+
maxBytes: options.maxBytes || 0,
|
|
83
|
+
doLint: !!options.doLint,
|
|
84
|
+
lintOpts: options.lintOpts || {}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
} catch (_) {
|
|
88
|
+
return resolve({ results: [], errors: group.map((f) => ({ rel: f.rel, reason: 'worker_spawn_failed' })), pool: true });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let settled = false;
|
|
92
|
+
const finish = (payload) => { if (!settled) { settled = true; w.terminate().catch(() => {}); resolve(payload); } };
|
|
93
|
+
w.on('message', (msg) => finish({ results: msg.results || [], errors: msg.errors || [], pool: true }));
|
|
94
|
+
w.on('error', () => finish({ results: [], errors: group.map((f) => ({ rel: f.rel, reason: 'worker_error' })), pool: true }));
|
|
95
|
+
w.on('exit', (code) => { if (code !== 0) finish({ results: [], errors: group.map((f) => ({ rel: f.rel, reason: 'worker_exit_' + code })), pool: true }); });
|
|
96
|
+
}))).then((all) => {
|
|
97
|
+
const results = [];
|
|
98
|
+
const errors = [];
|
|
99
|
+
let pooled = true;
|
|
100
|
+
for (const a of all) {
|
|
101
|
+
for (const r of a.results) results.push(r);
|
|
102
|
+
for (const e of a.errors) errors.push(e);
|
|
103
|
+
if (!a.pool) pooled = false;
|
|
104
|
+
}
|
|
105
|
+
return { results, errors, workers: groups.length, pooled };
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { scanFiles, defaultWorkers, chunk };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const url = require('url');
|
|
8
|
+
const { loadConfig } = require('../config');
|
|
9
|
+
const engine = require('../engine');
|
|
10
|
+
const { readRuns } = require('../history');
|
|
11
|
+
const { VerifactError } = require('../errors');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 本地看板 + API。零依赖 node:http。
|
|
15
|
+
* 只监听 127.0.0.1:这是读本机全盘路径的服务,不对外。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MIME = {
|
|
19
|
+
'.html': 'text/html; charset=utf-8',
|
|
20
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
21
|
+
'.css': 'text/css; charset=utf-8',
|
|
22
|
+
'.json': 'application/json; charset=utf-8',
|
|
23
|
+
'.svg': 'image/svg+xml',
|
|
24
|
+
'.ico': 'image/x-icon'
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let LAST_REPORT = null;
|
|
28
|
+
let RUNNING = false;
|
|
29
|
+
|
|
30
|
+
function json(res, code, body) {
|
|
31
|
+
const payload = JSON.stringify(body);
|
|
32
|
+
res.writeHead(code, {
|
|
33
|
+
'content-type': 'application/json; charset=utf-8',
|
|
34
|
+
'content-length': Buffer.byteLength(payload),
|
|
35
|
+
'cache-control': 'no-store',
|
|
36
|
+
'x-content-type-options': 'nosniff'
|
|
37
|
+
});
|
|
38
|
+
res.end(payload);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readBody(req, limitBytes) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
let size = 0;
|
|
44
|
+
const chunks = [];
|
|
45
|
+
req.on('data', (c) => {
|
|
46
|
+
size += c.length;
|
|
47
|
+
if (size > limitBytes) { reject(new VerifactError('payload_too_large', 'body exceeds limit')); req.destroy(); return; }
|
|
48
|
+
chunks.push(c);
|
|
49
|
+
});
|
|
50
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
51
|
+
req.on('error', reject);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function serveStatic(res, rootDir, relPath) {
|
|
56
|
+
const safe = path.normalize(relPath).replace(/^(\.\.[/\\])+/, '');
|
|
57
|
+
const full = path.join(rootDir, safe);
|
|
58
|
+
if (!full.startsWith(path.resolve(rootDir))) { res.writeHead(403); res.end('forbidden'); return; }
|
|
59
|
+
if (!fs.existsSync(full) || !fs.statSync(full).isFile()) { res.writeHead(404); res.end('not found'); return; }
|
|
60
|
+
const body = fs.readFileSync(full);
|
|
61
|
+
res.writeHead(200, {
|
|
62
|
+
'content-type': MIME[path.extname(full).toLowerCase()] || 'application/octet-stream',
|
|
63
|
+
'content-length': body.length,
|
|
64
|
+
'x-content-type-options': 'nosniff'
|
|
65
|
+
});
|
|
66
|
+
res.end(body);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createServer(cfg, log) {
|
|
70
|
+
const wwwDir = path.join(__dirname, 'www');
|
|
71
|
+
|
|
72
|
+
return http.createServer(async (req, res) => {
|
|
73
|
+
const started = Date.now();
|
|
74
|
+
const parsed = url.parse(req.url, true);
|
|
75
|
+
const route = parsed.pathname.replace(/\/+$/, '') || '/';
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
// 健康检查
|
|
79
|
+
if (route === '/api/health') {
|
|
80
|
+
return json(res, 200, {
|
|
81
|
+
status: 'ok',
|
|
82
|
+
busy: RUNNING === true,
|
|
83
|
+
hasReport: !!LAST_REPORT,
|
|
84
|
+
configFile: cfg.__configFile,
|
|
85
|
+
runs: readRuns(cfg, cfg.__cwd, 1).length
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (route === '/api/config') {
|
|
90
|
+
return json(res, 200, {
|
|
91
|
+
docsRoots: cfg.docs.roots,
|
|
92
|
+
artifactRoots: cfg.artifacts.roots,
|
|
93
|
+
minScore: cfg.resolve.minScore,
|
|
94
|
+
failOn: cfg.gate.failOn,
|
|
95
|
+
modules: cfg.modules,
|
|
96
|
+
cacheEnabled: cfg.cache.enabled,
|
|
97
|
+
cwd: cfg.__cwd
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (route === '/api/report' && req.method === 'GET') {
|
|
102
|
+
if (!LAST_REPORT) {
|
|
103
|
+
if (RUNNING) return json(res, 202, { pending: true, message: '一审还在跑' });
|
|
104
|
+
LAST_REPORT = await engine.run(cfg, { logger: log });
|
|
105
|
+
}
|
|
106
|
+
return json(res, 200, LAST_REPORT);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (route === '/api/verify' && req.method === 'POST') {
|
|
110
|
+
if (RUNNING) return json(res, 409, { error: 'busy', message: '上一次核验仍在进行' });
|
|
111
|
+
RUNNING = true;
|
|
112
|
+
const t0 = Date.now();
|
|
113
|
+
try {
|
|
114
|
+
let opts = {};
|
|
115
|
+
const raw = await readBody(req, 64 * 1024);
|
|
116
|
+
if (raw) { try { opts = JSON.parse(raw) || {}; } catch (_) { /* 空 body 合法 */ } }
|
|
117
|
+
const report = await engine.run(cfg, {
|
|
118
|
+
deep: !!opts.deep,
|
|
119
|
+
noCache: !!opts.noCache,
|
|
120
|
+
concurrency: opts.concurrency,
|
|
121
|
+
logger: log
|
|
122
|
+
});
|
|
123
|
+
LAST_REPORT = report;
|
|
124
|
+
log.info('verify_done', { ms: Date.now() - t0, claims: report.counts.claims, drift: report.counts.drift });
|
|
125
|
+
return json(res, 200, report);
|
|
126
|
+
} finally {
|
|
127
|
+
RUNNING = false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (route === '/api/history') {
|
|
132
|
+
const limit = Math.min(200, Number(parsed.query.limit) || 30);
|
|
133
|
+
return json(res, 200, { runs: readRuns(cfg, cfg.__cwd, limit) });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (route === '/' || route === '') {
|
|
137
|
+
return serveStatic(res, wwwDir, 'index.html');
|
|
138
|
+
}
|
|
139
|
+
if (route.startsWith('/assets/')) {
|
|
140
|
+
return serveStatic(res, wwwDir, route.slice('/assets/'.length));
|
|
141
|
+
}
|
|
142
|
+
if (route === '/app.js') return serveStatic(res, wwwDir, 'app.js');
|
|
143
|
+
if (route === '/style.css') return serveStatic(res, wwwDir, 'style.css');
|
|
144
|
+
|
|
145
|
+
return json(res, 404, { error: 'not_found', message: route });
|
|
146
|
+
} catch (e) {
|
|
147
|
+
if (e instanceof VerifactError) return json(res, e.code === 'payload_too_large' ? 413 : 400, e.toJSON());
|
|
148
|
+
log.error('request_failed', { route, ms: Date.now() - started, message: e && e.message });
|
|
149
|
+
return json(res, 500, { error: 'internal_error', message: '内部错误,详情见服务端日志' });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function start(cfg, log) {
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
const server = createServer(cfg, log || require('../logger').create({ quiet: true }));
|
|
157
|
+
server.on('error', reject);
|
|
158
|
+
server.listen(cfg.server.port, cfg.server.host, () => {
|
|
159
|
+
const addr = server.address();
|
|
160
|
+
log.info('server_listening', { host: cfg.server.host, port: addr.port });
|
|
161
|
+
process.stdout.write('verifact dashboard → http://' + cfg.server.host + ':' + addr.port + '\n');
|
|
162
|
+
resolve(server);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { start, createServer, loadConfig };
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
(function () {
|
|
5
|
+
var state = { report: null, filter: 'all', q: '', lintFilter: 'all', expanded: {} };
|
|
6
|
+
|
|
7
|
+
var STATUS_TEXT = {
|
|
8
|
+
verified: '已核验', drift: '漂移', ambiguous: '候选冲突', unbound: '未绑定',
|
|
9
|
+
ignored: '已忽略', denied: '已拒绑', disposition: '已分类', 'ref-found': '产出物存在', 'ref-missing': '产出物缺失',
|
|
10
|
+
reference: '标准编号', sealed: '哈希存证'
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function $(id) { return document.getElementById(id); }
|
|
14
|
+
|
|
15
|
+
function esc(s) {
|
|
16
|
+
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
|
17
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function valText(v) {
|
|
22
|
+
if (!v) return '-';
|
|
23
|
+
if (v.type === 'ref') return v.raw;
|
|
24
|
+
return v.raw;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function toast(msg, ms) {
|
|
28
|
+
var t = $('toast');
|
|
29
|
+
t.textContent = msg;
|
|
30
|
+
t.hidden = false;
|
|
31
|
+
clearTimeout(toast._t);
|
|
32
|
+
toast._t = setTimeout(function () { t.hidden = true; }, ms || 2600);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function api(path, options) {
|
|
36
|
+
var attempts = 0;
|
|
37
|
+
for (;;) {
|
|
38
|
+
attempts++;
|
|
39
|
+
try {
|
|
40
|
+
var res = await fetch(path, options);
|
|
41
|
+
if (res.status === 409) { toast('上一次核验还在进行'); return null; }
|
|
42
|
+
if (res.status >= 500 && attempts < 3) {
|
|
43
|
+
await new Promise(function (r) { setTimeout(r, 400 * attempts); });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
47
|
+
return await res.json();
|
|
48
|
+
} catch (e) {
|
|
49
|
+
if (attempts >= 3) {
|
|
50
|
+
toast('连不上本地服务(' + path + ')。确认 verifact serve 仍在运行。');
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
await new Promise(function (r) { setTimeout(r, 400 * attempts); });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderCards(r) {
|
|
59
|
+
var c = r.counts, l = r.lintCounts;
|
|
60
|
+
var items = [
|
|
61
|
+
{ k: '声明总数', v: c.claims, cls: '' },
|
|
62
|
+
{ k: '已核验', v: c.verified, cls: 'ok' },
|
|
63
|
+
{ k: '漂移', v: c.drift, cls: c.drift ? 'bad' : 'ok' },
|
|
64
|
+
{ k: '候选冲突', v: c.ambiguous, cls: c.ambiguous ? 'warn' : '' },
|
|
65
|
+
{ k: '未绑定', v: c.unbound, cls: '' },
|
|
66
|
+
{ k: '引用缺失', v: c.refMissing, cls: c.refMissing ? 'warn' : '' },
|
|
67
|
+
{ k: '用语 high', v: l.high, cls: l.high ? 'bad' : 'ok' },
|
|
68
|
+
{ k: '耗时 ms', v: r.durationMs, cls: '' }
|
|
69
|
+
];
|
|
70
|
+
$('cards').innerHTML = items.map(function (i) {
|
|
71
|
+
return '<div class="card ' + i.cls + '"><div class="k">' + esc(i.k) + '</div><div class="v">' + esc(i.v) + '</div></div>';
|
|
72
|
+
}).join('');
|
|
73
|
+
$('meta').textContent = 'cache=' + r.meta.cache + ' · workers=' + r.meta.workers +
|
|
74
|
+
' · 产出物条目 ' + r.counts.artifacts + ' · 门禁 ' + r.gate.policy + ' ' + (r.gate.failed ? 'FAILED' : 'PASSED');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function rows(r) {
|
|
78
|
+
var q = state.q.trim().toLowerCase();
|
|
79
|
+
return r.claims.filter(function (c) {
|
|
80
|
+
if (state.filter !== 'all' && c.status !== state.filter) return false;
|
|
81
|
+
if (!q) return true;
|
|
82
|
+
var hay = [c.file, c.module, c.key, c.sentence, valText(c.value)].join(' ').toLowerCase();
|
|
83
|
+
return hay.indexOf(q) >= 0;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderTable() {
|
|
88
|
+
var r = state.report;
|
|
89
|
+
var list = rows(r);
|
|
90
|
+
var tb = $('tbody');
|
|
91
|
+
$('empty').hidden = list.length > 0;
|
|
92
|
+
|
|
93
|
+
tb.innerHTML = list.map(function (c) {
|
|
94
|
+
var open = !!state.expanded[c.id];
|
|
95
|
+
var main = '<tr class="dr" data-id="' + esc(c.id) + '">' +
|
|
96
|
+
'<td><span class="chip ' + esc(c.status) + '">' + esc(STATUS_TEXT[c.status] || c.status) + '</span></td>' +
|
|
97
|
+
'<td class="loc mono">' + esc(c.file) + ':' + c.line + '</td>' +
|
|
98
|
+
'<td>' + esc(c.module || '-') + '</td>' +
|
|
99
|
+
'<td class="mono">' + esc(c.key) + '</td>' +
|
|
100
|
+
'<td class="mono">' + esc(valText(c.value)) + '</td>' +
|
|
101
|
+
'<td class="mono">' + esc(c.best ? valText(c.best.value) : '-') + '</td>' +
|
|
102
|
+
'<td class="mono">' + (c.score != null ? Number(c.score).toFixed(2) : '-') + '</td>' +
|
|
103
|
+
'</tr>';
|
|
104
|
+
if (!open) return main;
|
|
105
|
+
var detail = '<tr class="detail"><td colspan="7">' +
|
|
106
|
+
'<div><strong>原文</strong>:' + esc(c.sentence) + '</div>' +
|
|
107
|
+
'<div><strong>判定依据</strong>:' + esc(c.note || '-') + '</div>' +
|
|
108
|
+
'<div><strong>产出物</strong>:' + esc(c.best ? c.best.source + ' :: ' + c.best.path : '-') + '</div>' +
|
|
109
|
+
(c.alternatives && c.alternatives.length
|
|
110
|
+
? '<div><strong>其他候选</strong>:' + c.alternatives.map(function (a) {
|
|
111
|
+
return esc(a.source + ' :: ' + a.path + ' = ' + valText(a.value) + ' (' + Number(a.score).toFixed(2) + ')');
|
|
112
|
+
}).join(' | ') + '</div>'
|
|
113
|
+
: '') +
|
|
114
|
+
'<div><strong>id</strong>:' + esc(c.id) + '</div>' +
|
|
115
|
+
'</td></tr>';
|
|
116
|
+
return main + detail;
|
|
117
|
+
}).join('');
|
|
118
|
+
|
|
119
|
+
Array.prototype.forEach.call(tb.querySelectorAll('tr.dr'), function (tr) {
|
|
120
|
+
tr.addEventListener('click', function () {
|
|
121
|
+
var id = tr.getAttribute('data-id');
|
|
122
|
+
state.expanded[id] = !state.expanded[id];
|
|
123
|
+
renderTable();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function renderLint() {
|
|
129
|
+
var l = state.report.lint.filter(function (f) {
|
|
130
|
+
return state.lintFilter === 'all' || f.severity === state.lintFilter;
|
|
131
|
+
}).slice(0, 200);
|
|
132
|
+
$('lint').innerHTML = l.length ? l.map(function (f) {
|
|
133
|
+
return '<div class="item"><div class="h">' +
|
|
134
|
+
'<span class="chip ' + (f.severity === 'high' ? 'drift' : f.severity === 'medium' ? 'ambiguous' : 'unbound') + '">' +
|
|
135
|
+
esc(f.severity) + '</span>' + esc(f.ruleId) + ' · ' + esc(f.file) + ':' + f.line +
|
|
136
|
+
' · 命中「' + esc(f.matched) + '」</div>' +
|
|
137
|
+
'<div class="b">' + esc(f.sentence) + '</div>' +
|
|
138
|
+
'<div class="hint">' + esc(f.hint) + '</div></div>';
|
|
139
|
+
}).join('') : '<p class="note">无。</p>';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function renderHistory() {
|
|
143
|
+
var h = await api('/api/history?limit=20');
|
|
144
|
+
var list = (h && h.runs) ? h.runs.slice().reverse() : [];
|
|
145
|
+
$('history').innerHTML = list.length ? list.map(function (r) {
|
|
146
|
+
var c = r.counts || {};
|
|
147
|
+
return '<div class="item"><div class="h">' + esc(String(r.generatedAt).replace('T', ' ').slice(0, 19)) +
|
|
148
|
+
' · ' + r.durationMs + 'ms · ' + esc(r.cache) + '</div>' +
|
|
149
|
+
'<div class="b">声明 ' + esc(c.claims) + ' · 已核验 ' + esc(c.verified) +
|
|
150
|
+
' · <span style="color:' + (c.drift ? 'var(--bad)' : 'inherit') + '">漂移 ' + esc(c.drift) + '</span>' +
|
|
151
|
+
' · 未绑定 ' + esc(c.unbound) + ' · 门禁 ' + esc(r.gate && r.gate.policy) + ' ' +
|
|
152
|
+
(r.gate && r.gate.failed ? 'FAILED' : 'PASSED') + '</div></div>';
|
|
153
|
+
}).join('') : '<p class="note">暂无历史。</p>';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function renderAll(r) {
|
|
157
|
+
state.report = r;
|
|
158
|
+
renderCards(r);
|
|
159
|
+
renderTable();
|
|
160
|
+
renderLint();
|
|
161
|
+
renderHistory();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function load() {
|
|
165
|
+
var r = await api('/api/report');
|
|
166
|
+
if (r) { if (r.pending) { toast('首次核验进行中,稍后刷新'); setTimeout(load, 2500); return; } renderAll(r); }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function doVerify() {
|
|
170
|
+
var btn = $('btn-verify');
|
|
171
|
+
btn.disabled = true;
|
|
172
|
+
btn.textContent = '核验中…';
|
|
173
|
+
try {
|
|
174
|
+
var r = await api('/api/verify', {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: { 'content-type': 'application/json' },
|
|
177
|
+
body: JSON.stringify({ noCache: $('chk-nocache').checked })
|
|
178
|
+
});
|
|
179
|
+
if (r) { renderAll(r); toast('核验完成:漂移 ' + r.counts.drift + ' 条'); }
|
|
180
|
+
} finally {
|
|
181
|
+
btn.disabled = false;
|
|
182
|
+
btn.textContent = '重新核验';
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
187
|
+
$('btn-verify').addEventListener('click', doVerify);
|
|
188
|
+
$('q').addEventListener('input', function (e) { state.q = e.target.value; renderTable(); });
|
|
189
|
+
$('tabs').addEventListener('click', function (e) {
|
|
190
|
+
var b = e.target.closest('button'); if (!b) return;
|
|
191
|
+
state.filter = b.getAttribute('data-s');
|
|
192
|
+
Array.prototype.forEach.call($('tabs').children, function (x) { x.classList.toggle('on', x === b); });
|
|
193
|
+
renderTable();
|
|
194
|
+
});
|
|
195
|
+
$('lint-tabs').addEventListener('click', function (e) {
|
|
196
|
+
var b = e.target.closest('button'); if (!b) return;
|
|
197
|
+
state.lintFilter = b.getAttribute('data-x');
|
|
198
|
+
Array.prototype.forEach.call($('lint-tabs').children, function (x) { x.classList.toggle('on', x === b); });
|
|
199
|
+
renderLint();
|
|
200
|
+
});
|
|
201
|
+
load();
|
|
202
|
+
});
|
|
203
|
+
})();
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>verifact · 事实哨兵</title>
|
|
7
|
+
<link rel="stylesheet" href="/style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header class="topbar">
|
|
11
|
+
<div class="brand">
|
|
12
|
+
<span class="logo">VF</span>
|
|
13
|
+
<div>
|
|
14
|
+
<h1>verifact · 事实哨兵</h1>
|
|
15
|
+
<p class="sub">文档硬数字声明 ↔ 产出物 逐条核验</p>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="actions">
|
|
19
|
+
<button id="btn-verify" class="btn primary">重新核验</button>
|
|
20
|
+
<label class="chk"><input type="checkbox" id="chk-nocache"> 跳过缓存</label>
|
|
21
|
+
<span id="meta" class="meta">—</span>
|
|
22
|
+
</div>
|
|
23
|
+
</header>
|
|
24
|
+
|
|
25
|
+
<main>
|
|
26
|
+
<section class="cards" id="cards"></section>
|
|
27
|
+
|
|
28
|
+
<section class="panel">
|
|
29
|
+
<div class="panel-head">
|
|
30
|
+
<h2>声明</h2>
|
|
31
|
+
<div class="tabs" id="tabs">
|
|
32
|
+
<button data-s="all" class="on">全部</button>
|
|
33
|
+
<button data-s="drift">漂移</button>
|
|
34
|
+
<button data-s="ambiguous">候选冲突</button>
|
|
35
|
+
<button data-s="unbound">未绑定</button>
|
|
36
|
+
<button data-s="ref-missing">产出物缺失</button>
|
|
37
|
+
<button data-s="verified">已核验</button>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
<input id="q" class="search" type="search" placeholder="过滤:文件 / 模块 / 键 / 原文">
|
|
41
|
+
<div class="table-wrap">
|
|
42
|
+
<table id="tbl">
|
|
43
|
+
<thead>
|
|
44
|
+
<tr>
|
|
45
|
+
<th style="width:92px">判定</th>
|
|
46
|
+
<th style="width:220px">位置</th>
|
|
47
|
+
<th style="width:110px">模块</th>
|
|
48
|
+
<th style="width:150px">键</th>
|
|
49
|
+
<th>文档值</th>
|
|
50
|
+
<th>产出物值</th>
|
|
51
|
+
<th style="width:64px">评分</th>
|
|
52
|
+
</tr>
|
|
53
|
+
</thead>
|
|
54
|
+
<tbody id="tbody"></tbody>
|
|
55
|
+
</table>
|
|
56
|
+
</div>
|
|
57
|
+
<p id="empty" class="empty" hidden>没有匹配的声明。</p>
|
|
58
|
+
</section>
|
|
59
|
+
|
|
60
|
+
<div class="grid2">
|
|
61
|
+
<section class="panel">
|
|
62
|
+
<div class="panel-head"><h2>用语 / 绝对化线索</h2><div class="tabs" id="lint-tabs">
|
|
63
|
+
<button data-x="all" class="on">全部</button>
|
|
64
|
+
<button data-x="high">high</button>
|
|
65
|
+
<button data-x="medium">medium</button>
|
|
66
|
+
<button data-x="low">low</button>
|
|
67
|
+
</div></div>
|
|
68
|
+
<div id="lint" class="list"></div>
|
|
69
|
+
<p class="note">输出是「需人工复核的信号」,不是漏洞判决。</p>
|
|
70
|
+
</section>
|
|
71
|
+
|
|
72
|
+
<section class="panel">
|
|
73
|
+
<div class="panel-head"><h2>历史</h2></div>
|
|
74
|
+
<div id="history" class="list"></div>
|
|
75
|
+
<p class="note">每次核验追加一条记录,用于看 drift 是从哪次开始出现的。</p>
|
|
76
|
+
</section>
|
|
77
|
+
</div>
|
|
78
|
+
</main>
|
|
79
|
+
|
|
80
|
+
<div id="toast" class="toast" hidden></div>
|
|
81
|
+
<script src="/app.js"></script>
|
|
82
|
+
</body>
|
|
83
|
+
</html>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #f6f7f9;
|
|
3
|
+
--panel: #ffffff;
|
|
4
|
+
--line: #e3e6ea;
|
|
5
|
+
--text: #1b1f24;
|
|
6
|
+
--muted: #6b7280;
|
|
7
|
+
--accent: #2f6feb;
|
|
8
|
+
--ok: #1a7f45;
|
|
9
|
+
--ok-bg: #e8f5ee;
|
|
10
|
+
--bad: #c2372b;
|
|
11
|
+
--bad-bg: #fdecea;
|
|
12
|
+
--warn: #9a6407;
|
|
13
|
+
--warn-bg: #fdf3e2;
|
|
14
|
+
--gray-bg: #eef0f3;
|
|
15
|
+
--radius: 10px;
|
|
16
|
+
}
|
|
17
|
+
* { box-sizing: border-box; }
|
|
18
|
+
body {
|
|
19
|
+
margin: 0; background: var(--bg); color: var(--text);
|
|
20
|
+
font: 14px/1.5 -apple-system, "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
|
|
21
|
+
}
|
|
22
|
+
.topbar {
|
|
23
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
24
|
+
padding: 14px 22px; background: var(--panel); border-bottom: 1px solid var(--line);
|
|
25
|
+
position: sticky; top: 0; z-index: 5;
|
|
26
|
+
}
|
|
27
|
+
.brand { display: flex; align-items: center; gap: 12px; }
|
|
28
|
+
.logo {
|
|
29
|
+
width: 34px; height: 34px; border-radius: 8px; background: var(--accent);
|
|
30
|
+
color: #fff; display: grid; place-items: center; font-weight: 700; font-size: 13px;
|
|
31
|
+
}
|
|
32
|
+
.topbar h1 { margin: 0; font-size: 16px; font-weight: 650; }
|
|
33
|
+
.topbar .sub { margin: 0; color: var(--muted); font-size: 12px; }
|
|
34
|
+
.actions { display: flex; align-items: center; gap: 12px; }
|
|
35
|
+
.meta { color: var(--muted); font-size: 12px; }
|
|
36
|
+
.btn {
|
|
37
|
+
border: 1px solid var(--line); background: var(--panel); color: var(--text);
|
|
38
|
+
padding: 7px 14px; border-radius: 8px; cursor: pointer; font-size: 13px;
|
|
39
|
+
}
|
|
40
|
+
.btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
41
|
+
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
42
|
+
.btn.primary:hover { filter: brightness(1.06); color: #fff; }
|
|
43
|
+
.btn:disabled { opacity: .55; cursor: default; }
|
|
44
|
+
.chk { color: var(--muted); font-size: 12px; display: flex; align-items: center; gap: 5px; }
|
|
45
|
+
|
|
46
|
+
main { padding: 20px 22px 40px; max-width: 1440px; margin: 0 auto; }
|
|
47
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); gap: 12px; margin-bottom: 18px; }
|
|
48
|
+
.card {
|
|
49
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px;
|
|
50
|
+
}
|
|
51
|
+
.card .k { color: var(--muted); font-size: 12px; }
|
|
52
|
+
.card .v { font-size: 24px; font-weight: 650; margin-top: 3px; letter-spacing: -.02em; }
|
|
53
|
+
.card.ok .v { color: var(--ok); }
|
|
54
|
+
.card.bad .v { color: var(--bad); }
|
|
55
|
+
.card.warn .v { color: var(--warn); }
|
|
56
|
+
|
|
57
|
+
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 18px; }
|
|
58
|
+
.panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; gap: 12px; flex-wrap: wrap; }
|
|
59
|
+
.panel-head h2 { margin: 0; font-size: 14px; font-weight: 650; }
|
|
60
|
+
.tabs { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
61
|
+
.tabs button {
|
|
62
|
+
border: 1px solid var(--line); background: var(--panel); padding: 5px 11px; border-radius: 999px;
|
|
63
|
+
cursor: pointer; font-size: 12px; color: var(--muted);
|
|
64
|
+
}
|
|
65
|
+
.tabs button.on { background: var(--gray-bg); color: var(--text); border-color: #cfd4da; }
|
|
66
|
+
.search {
|
|
67
|
+
width: 100%; padding: 8px 11px; border: 1px solid var(--line); border-radius: 8px;
|
|
68
|
+
margin-bottom: 10px; font-size: 13px; background: #fbfcfd;
|
|
69
|
+
}
|
|
70
|
+
.search:focus { outline: 2px solid #d7e3fb; border-color: var(--accent); }
|
|
71
|
+
|
|
72
|
+
.table-wrap { overflow: auto; max-height: 62vh; border: 1px solid var(--line); border-radius: 8px; }
|
|
73
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
74
|
+
thead th {
|
|
75
|
+
position: sticky; top: 0; background: #fafbfc; text-align: left; padding: 9px 10px;
|
|
76
|
+
border-bottom: 1px solid var(--line); color: var(--muted); font-weight: 600; font-size: 12px; z-index: 1;
|
|
77
|
+
}
|
|
78
|
+
tbody td { padding: 8px 10px; border-bottom: 1px solid #f1f3f5; vertical-align: top; }
|
|
79
|
+
tbody tr:hover { background: #fafbfc; }
|
|
80
|
+
tbody tr.dr { cursor: pointer; }
|
|
81
|
+
.detail td { background: #fbfcfd; color: var(--muted); font-size: 12px; }
|
|
82
|
+
.chip { padding: 2px 8px; border-radius: 6px; font-size: 11px; white-space: nowrap; border: 1px solid transparent; }
|
|
83
|
+
.chip.verified { background: var(--ok-bg); color: var(--ok); border-color: #c6e6d4; }
|
|
84
|
+
.chip.drift { background: var(--bad-bg); color: var(--bad); border-color: #f5c9c3; }
|
|
85
|
+
.chip.ambiguous, .chip.ref-missing { background: var(--warn-bg); color: var(--warn); border-color: #f0dcae; }
|
|
86
|
+
.chip.unbound, .chip.ignored, .chip.ref-found { background: var(--gray-bg); color: var(--muted); border-color: #dfe3e8; }
|
|
87
|
+
.mono { font-family: ui-monospace, Consolas, Menlo, monospace; font-size: 12px; }
|
|
88
|
+
.loc { color: var(--muted); }
|
|
89
|
+
.empty { color: var(--muted); padding: 18px; text-align: center; }
|
|
90
|
+
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
|
91
|
+
@media (max-width: 980px) { .grid2 { grid-template-columns: 1fr; } }
|
|
92
|
+
.list { display: flex; flex-direction: column; gap: 7px; max-height: 330px; overflow: auto; }
|
|
93
|
+
.item { border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; }
|
|
94
|
+
.item .h { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--muted); }
|
|
95
|
+
.item .b { margin-top: 3px; font-size: 13px; }
|
|
96
|
+
.item .hint { margin-top: 3px; font-size: 12px; color: var(--muted); }
|
|
97
|
+
.note { color: var(--muted); font-size: 12px; margin: 10px 0 0; }
|
|
98
|
+
.toast {
|
|
99
|
+
position: fixed; right: 20px; bottom: 20px; background: #1b1f24; color: #fff;
|
|
100
|
+
padding: 10px 16px; border-radius: 8px; font-size: 13px; z-index: 20;
|
|
101
|
+
}
|