euthyna 0.1.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/NOTICE.md +119 -0
- package/README.md +246 -0
- package/assets/euthyna.png +0 -0
- package/bin/euthyna.js +25 -0
- package/package.json +40 -0
- package/src/cli.js +238 -0
- package/src/contract.js +249 -0
- package/src/facts/coverage.js +338 -0
- package/src/facts/history.js +399 -0
- package/src/git.js +107 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* euthyna CLI.
|
|
3
|
+
*
|
|
4
|
+
* Exit code contract. The positioning work found that none of the eight
|
|
5
|
+
* measurement plugins in this ecosystem publish a process exit code, so their
|
|
6
|
+
* output cannot gate anything in CI. This one does:
|
|
7
|
+
*
|
|
8
|
+
* 0 measured; nothing security-classified found
|
|
9
|
+
* 10 measured; at least one security-classified fact found
|
|
10
|
+
* 1 usage error
|
|
11
|
+
* 2 could not measure at all (no facts and a recorded reason)
|
|
12
|
+
*
|
|
13
|
+
* 2 is deliberately distinct from 0: a run that could not measure must never be
|
|
14
|
+
* read as a clean run.
|
|
15
|
+
*/
|
|
16
|
+
import process from 'node:process';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { collectHistoryFacts } from './facts/history.js';
|
|
19
|
+
import { collectCoverageFacts } from './facts/coverage.js';
|
|
20
|
+
import { makeReport, renderReport, safeTextLines, KIND } from './contract.js';
|
|
21
|
+
import { repoToplevel, revParse } from './git.js';
|
|
22
|
+
|
|
23
|
+
export const EXIT = Object.freeze({
|
|
24
|
+
CLEAN: 0,
|
|
25
|
+
FLAGGED: 10,
|
|
26
|
+
USAGE: 1,
|
|
27
|
+
UNMEASURED: 2
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const HISTORY_PRODUCER = {
|
|
31
|
+
name: 'euthyna-history',
|
|
32
|
+
version: '0.1.0',
|
|
33
|
+
purpose: '被删除代码的来源归属与安全分类'
|
|
34
|
+
};
|
|
35
|
+
const COVERAGE_PRODUCER = {
|
|
36
|
+
name: 'euthyna-coverage',
|
|
37
|
+
version: '0.1.0',
|
|
38
|
+
purpose: '符号调用计数(只能证伪)'
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const HELP = `
|
|
42
|
+
euthyna —— 给 AI 编码 agent 用的确定性事实产出器
|
|
43
|
+
|
|
44
|
+
它不是扫描器。它只回答两个模型算不准的问题,并把答案写成带证据的事实。
|
|
45
|
+
|
|
46
|
+
用法:
|
|
47
|
+
euthyna history --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--json]
|
|
48
|
+
euthyna coverage --coverage <file> --symbol <name> [--file <path>] [--json]
|
|
49
|
+
|
|
50
|
+
命令:
|
|
51
|
+
history 本次变更删掉了哪些代码、它们分别由哪个提交引入、该提交是不是安全修复
|
|
52
|
+
--base 必填,比较的基线版本(如 main、HEAD~5、某个 commit)
|
|
53
|
+
--head 可选,默认 HEAD
|
|
54
|
+
--pickaxe 额外检查「曾被移除又加回来」的新增行(有探针上限)
|
|
55
|
+
coverage 某个符号在测试运行中到底有没有被调用过
|
|
56
|
+
--coverage 覆盖率数据文件,c8 的 coverage-final.json
|
|
57
|
+
--symbol 要查询的符号名,可重复
|
|
58
|
+
--file 可选,限定到某个文件
|
|
59
|
+
|
|
60
|
+
退出码:
|
|
61
|
+
0 已测量,没有安全相关的发现
|
|
62
|
+
10 已测量,且存在被分类为 security 的事实
|
|
63
|
+
1 用法错误
|
|
64
|
+
2 完全无法测量(此时**不得**当作干净)
|
|
65
|
+
|
|
66
|
+
注意: 缺数据不等于干净。无法测量的判据会列在输出的「未评估的判据」一节。
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Minimal argv parser: `--key value`, `--flag`, and repeated `--key` flags.
|
|
71
|
+
*
|
|
72
|
+
* A repeated flag accumulates into an array. Overwriting instead would silently
|
|
73
|
+
* drop all but the last value, which for `--symbol` means reporting on one
|
|
74
|
+
* symbol while the user asked about several - a wrong answer delivered
|
|
75
|
+
* confidently, which is the failure mode this project exists to remove.
|
|
76
|
+
*/
|
|
77
|
+
export function parseArgs(argv) {
|
|
78
|
+
const positional = [];
|
|
79
|
+
const flags = {};
|
|
80
|
+
|
|
81
|
+
const record = (key, value) => {
|
|
82
|
+
if (key in flags) {
|
|
83
|
+
flags[key] = Array.isArray(flags[key]) ? [...flags[key], value] : [flags[key], value];
|
|
84
|
+
} else {
|
|
85
|
+
flags[key] = value;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
for (let i = 0; i < argv.length; i++) {
|
|
90
|
+
const token = argv[i];
|
|
91
|
+
if (!token.startsWith('--')) {
|
|
92
|
+
positional.push(token);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const key = token.slice(2);
|
|
96
|
+
const next = argv[i + 1];
|
|
97
|
+
if (next === undefined || next.startsWith('--')) {
|
|
98
|
+
record(key, true);
|
|
99
|
+
} else {
|
|
100
|
+
record(key, next);
|
|
101
|
+
i++;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { positional, flags };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Collect repeatable flags into an array. */
|
|
108
|
+
function asArray(value) {
|
|
109
|
+
if (value === undefined) return [];
|
|
110
|
+
return Array.isArray(value) ? value : [value];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function runHistory(flags) {
|
|
114
|
+
const cwd = path.resolve(flags.repo ? String(flags.repo) : process.cwd());
|
|
115
|
+
|
|
116
|
+
const toplevel = await repoToplevel(cwd);
|
|
117
|
+
if (!toplevel) {
|
|
118
|
+
return {
|
|
119
|
+
exit: EXIT.UNMEASURED,
|
|
120
|
+
report: makeReport({
|
|
121
|
+
producer: HISTORY_PRODUCER,
|
|
122
|
+
subject: { repo: cwd },
|
|
123
|
+
facts: [],
|
|
124
|
+
notEvaluated: [
|
|
125
|
+
{ kind: KIND.HISTORY, reason: `${cwd} 不在任何 git 仓库内,无法测量历史` }
|
|
126
|
+
]
|
|
127
|
+
})
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!flags.base) {
|
|
132
|
+
return { exit: EXIT.USAGE, error: 'history 需要 --base <rev>' };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const base = await revParse(toplevel, String(flags.base));
|
|
136
|
+
if (!base) {
|
|
137
|
+
return { exit: EXIT.USAGE, error: `无法解析 --base "${flags.base}" 为一个提交` };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const head = await revParse(toplevel, String(flags.head ?? 'HEAD'));
|
|
141
|
+
if (!head) {
|
|
142
|
+
return { exit: EXIT.USAGE, error: `无法解析 --head "${flags.head ?? 'HEAD'}" 为一个提交` };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const { facts, evaluated, notEvaluated, measured } = await collectHistoryFacts({
|
|
146
|
+
cwd: toplevel,
|
|
147
|
+
base,
|
|
148
|
+
head,
|
|
149
|
+
pickaxe: flags.pickaxe === true
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const report = makeReport({
|
|
153
|
+
producer: HISTORY_PRODUCER,
|
|
154
|
+
subject: { repo: toplevel, base, head },
|
|
155
|
+
facts,
|
|
156
|
+
evaluated,
|
|
157
|
+
notEvaluated
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const flagged = facts.some(
|
|
161
|
+
f => f.kind === KIND.HISTORY && f.detail && f.detail.classification === 'security'
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// "Nothing was deleted" is a completed measurement whose answer is empty, not
|
|
165
|
+
// a measurement failure. Only `measured === false` means we could not answer.
|
|
166
|
+
return {
|
|
167
|
+
exit: !measured ? EXIT.UNMEASURED : flagged ? EXIT.FLAGGED : EXIT.CLEAN,
|
|
168
|
+
report
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function runCoverage(flags) {
|
|
173
|
+
const coverageFile = path.resolve(
|
|
174
|
+
String(flags.coverage ?? 'coverage/coverage-final.json')
|
|
175
|
+
);
|
|
176
|
+
const symbols = asArray(flags.symbol).map(String);
|
|
177
|
+
const file = flags.file ? String(flags.file) : undefined;
|
|
178
|
+
|
|
179
|
+
// Asking for nothing is a caller mistake, not a measurement failure.
|
|
180
|
+
if (symbols.length === 0) {
|
|
181
|
+
return { exit: EXIT.USAGE, error: 'coverage 需要至少一个 --symbol <name>' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const targets = symbols.map(symbol => ({ symbol, ...(file ? { file } : {}) }));
|
|
185
|
+
const { facts, evaluated, notEvaluated, measured } = await collectCoverageFacts({
|
|
186
|
+
coverageFile,
|
|
187
|
+
targets
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const report = makeReport({
|
|
191
|
+
producer: COVERAGE_PRODUCER,
|
|
192
|
+
subject: { coverageFile, symbols, file },
|
|
193
|
+
facts,
|
|
194
|
+
evaluated,
|
|
195
|
+
notEvaluated
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return { exit: measured ? EXIT.CLEAN : EXIT.UNMEASURED, report };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Entry point. Returns the process exit code. */
|
|
202
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
203
|
+
const { positional, flags } = parseArgs(argv);
|
|
204
|
+
const command = positional[0];
|
|
205
|
+
|
|
206
|
+
if (!command || command === 'help' || flags.help) {
|
|
207
|
+
process.stdout.write(HELP);
|
|
208
|
+
return EXIT.CLEAN;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let result;
|
|
212
|
+
if (command === 'history') {
|
|
213
|
+
result = await runHistory(flags);
|
|
214
|
+
} else if (command === 'coverage') {
|
|
215
|
+
result = await runCoverage(flags);
|
|
216
|
+
} else {
|
|
217
|
+
// stderr boundary, mirroring the render boundary in contract.js: text that
|
|
218
|
+
// reaches the error channel may carry user or repo-controlled bytes (an
|
|
219
|
+
// unknown command echoes argv; a usage error embeds flag values), so it is
|
|
220
|
+
// made terminal-safe once, here. HELP is multi-line and static; safeTextLines
|
|
221
|
+
// preserves its line structure and is a no-op on its plain text.
|
|
222
|
+
process.stderr.write(safeTextLines(`未知命令: ${command}\n${HELP}`));
|
|
223
|
+
return EXIT.USAGE;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (result.error) {
|
|
227
|
+
process.stderr.write(safeTextLines(`${result.error}\n`));
|
|
228
|
+
return result.exit;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (flags.json) {
|
|
232
|
+
process.stdout.write(`${JSON.stringify(result.report, null, 2)}\n`);
|
|
233
|
+
} else {
|
|
234
|
+
renderReport(result.report);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return result.exit;
|
|
238
|
+
}
|
package/src/contract.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fact contract, in code.
|
|
3
|
+
*
|
|
4
|
+
* Facts produced here are consumed by an adjudication layer. Two properties are
|
|
5
|
+
* enforced mechanically rather than by convention, because a convention is what
|
|
6
|
+
* erodes first:
|
|
7
|
+
*
|
|
8
|
+
* 1. Facts carry no verdict. No score, no severity, no recommendation. A
|
|
9
|
+
* measurement layer that emits "risk: high" has decided for the
|
|
10
|
+
* adjudicator, and the decision is not reproducible.
|
|
11
|
+
* 2. Every fact is three-state. "Established", "refuted" and "unknown" are
|
|
12
|
+
* distinct, and a missing measurement is never silently reported as clean.
|
|
13
|
+
*
|
|
14
|
+
* See docs/fact-contract-zh.md.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
18
|
+
|
|
19
|
+
export const SCHEMA_VERSION = '0.1';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Quote a value for pasting into a POSIX shell.
|
|
23
|
+
*
|
|
24
|
+
* Single quotes make every byte literal except the single quote itself, which
|
|
25
|
+
* the `'\''` idiom escapes. JSON.stringify is not enough here: inside double
|
|
26
|
+
* quotes a backtick or $( ) still executes, so a repo-controlled filename or
|
|
27
|
+
* symbol would survive it as code, not data.
|
|
28
|
+
*/
|
|
29
|
+
export function shellQuote(value) {
|
|
30
|
+
return `'${String(value).replaceAll("'", `'\\''`)}'`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Render repo-controlled text safely for the terminal: every C0 control and
|
|
35
|
+
* DEL becomes a visible escape (\n, \r, \t, else \xHH). Nothing is preserved
|
|
36
|
+
* raw — a literal \r would let a filename overwrite the report line on screen
|
|
37
|
+
* — and nothing is deleted silently — a command whose BEL was dropped would
|
|
38
|
+
* no longer reproduce the fact when pasted. stripVTControlCharacters removes
|
|
39
|
+
* complete CSI/OSC sequences first; the regex catches the stragglers.
|
|
40
|
+
*/
|
|
41
|
+
export function safeText(value) {
|
|
42
|
+
return stripVTControlCharacters(String(value)).replace(/[\x00-\x1F\x7F]/g, (ch) => {
|
|
43
|
+
if (ch === '\n') return '\\n';
|
|
44
|
+
if (ch === '\r') return '\\r';
|
|
45
|
+
if (ch === '\t') return '\\t';
|
|
46
|
+
return `\\x${ch.charCodeAt(0).toString(16).padStart(2, '0')}`;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* safeText for multi-line text: every line is made terminal-safe, real newlines
|
|
52
|
+
* are preserved as structure. The render boundary never sees a newline
|
|
53
|
+
* (renderReport writes line by line), but the error path does — a stack trace
|
|
54
|
+
* flattened into one line of \n escapes would be unreadable. What must never
|
|
55
|
+
* survive is a control that acts on the terminal: CR (line overwrite), BEL,
|
|
56
|
+
* ESC, every other C0 and DEL are still made visible, per line.
|
|
57
|
+
*/
|
|
58
|
+
export function safeTextLines(value) {
|
|
59
|
+
return String(value)
|
|
60
|
+
.split('\n')
|
|
61
|
+
.map(safeText)
|
|
62
|
+
.join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Fact kinds. Kept explicit so an unknown kind fails loudly instead of passing. */
|
|
66
|
+
export const KIND = Object.freeze({
|
|
67
|
+
HISTORY: 'history',
|
|
68
|
+
REINTRODUCTION: 'reintroduction',
|
|
69
|
+
TEST_COVERAGE: 'test_coverage'
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** Fact status. `unknown` is a first-class value, not an error. */
|
|
73
|
+
export const STATUS = Object.freeze({
|
|
74
|
+
ESTABLISHED: 'established',
|
|
75
|
+
REFUTED: 'refuted',
|
|
76
|
+
UNKNOWN: 'unknown'
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Keys that must never appear on a fact: they belong to the adjudication layer.
|
|
81
|
+
* Checked recursively so a nested object cannot smuggle one in.
|
|
82
|
+
*/
|
|
83
|
+
const FORBIDDEN_KEYS = [
|
|
84
|
+
'severity',
|
|
85
|
+
'score',
|
|
86
|
+
'risk',
|
|
87
|
+
'confidence_score',
|
|
88
|
+
'recommendation',
|
|
89
|
+
'remediation',
|
|
90
|
+
'fix',
|
|
91
|
+
'verdict',
|
|
92
|
+
'exploitable',
|
|
93
|
+
'priority'
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Throw if a value contains a key reserved for the adjudication layer.
|
|
98
|
+
* A measurement layer that can set these has stopped being a measurement layer.
|
|
99
|
+
*/
|
|
100
|
+
export function assertNoVerdictFields(value, path = 'fact') {
|
|
101
|
+
if (value === null || typeof value !== 'object') return;
|
|
102
|
+
if (Array.isArray(value)) {
|
|
103
|
+
value.forEach((item, index) => assertNoVerdictFields(item, `${path}[${index}]`));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
for (const [key, child] of Object.entries(value)) {
|
|
107
|
+
if (FORBIDDEN_KEYS.includes(key.toLowerCase())) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`fact contract violation: "${path}.${key}" is reserved for the adjudication layer. ` +
|
|
110
|
+
`The measurement layer must not emit ${key}.`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
assertNoVerdictFields(child, `${path}.${key}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build one fact. Throws when a required field is missing, so a malformed fact
|
|
119
|
+
* cannot reach the adjudication layer and be read as a weaker claim than intended.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} input
|
|
122
|
+
* @param {string} input.kind one of KIND
|
|
123
|
+
* @param {string} input.statement one sentence a human can check
|
|
124
|
+
* @param {string} input.status one of STATUS
|
|
125
|
+
* @param {object} input.evidence {file, line?, commit?, snippet?}
|
|
126
|
+
* @param {string} input.method 'command' | 'static' | 'tool'
|
|
127
|
+
* @param {string} [input.command] required when method is 'command'
|
|
128
|
+
* @param {'exact'|'approximate'} [input.confidence]
|
|
129
|
+
* @param {object} [input.detail] kind-specific structured payload
|
|
130
|
+
* @param {string} input.id
|
|
131
|
+
*/
|
|
132
|
+
export function makeFact(input) {
|
|
133
|
+
const { id, kind, statement, status, evidence, method, command, confidence = 'exact', detail } = input;
|
|
134
|
+
|
|
135
|
+
if (!id) throw new Error('fact requires an id');
|
|
136
|
+
if (!Object.values(KIND).includes(kind)) throw new Error(`fact ${id}: unknown kind "${kind}"`);
|
|
137
|
+
if (!Object.values(STATUS).includes(status)) throw new Error(`fact ${id}: unknown status "${status}"`);
|
|
138
|
+
if (!statement) throw new Error(`fact ${id}: statement is required`);
|
|
139
|
+
if (!evidence || !evidence.file) throw new Error(`fact ${id}: evidence.file is required`);
|
|
140
|
+
if (method === 'command' && !command) {
|
|
141
|
+
throw new Error(`fact ${id}: method "command" requires the reproducible command`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const fact = {
|
|
145
|
+
id,
|
|
146
|
+
kind,
|
|
147
|
+
statement,
|
|
148
|
+
status,
|
|
149
|
+
evidence,
|
|
150
|
+
method,
|
|
151
|
+
...(command ? { command } : {}),
|
|
152
|
+
confidence,
|
|
153
|
+
...(detail ? { detail } : {})
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
assertNoVerdictFields(fact);
|
|
157
|
+
return fact;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build the report envelope.
|
|
162
|
+
*
|
|
163
|
+
* `notEvaluated` is the honesty surface: anything the producer could not
|
|
164
|
+
* measure must be listed with a reason. An empty `notEvaluated` alongside zero
|
|
165
|
+
* facts means "measured, nothing found"; a populated one means "do not read
|
|
166
|
+
* this as clean".
|
|
167
|
+
*/
|
|
168
|
+
export function makeReport({ producer, subject, facts, evaluated = [], notEvaluated = [] }) {
|
|
169
|
+
for (const fact of facts) assertNoVerdictFields(fact);
|
|
170
|
+
return {
|
|
171
|
+
schemaVersion: SCHEMA_VERSION,
|
|
172
|
+
producer,
|
|
173
|
+
subject,
|
|
174
|
+
facts,
|
|
175
|
+
coverage: { evaluated, notEvaluated }
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Convenience: an entry for the coverage.notEvaluated list. */
|
|
180
|
+
export function notEvaluated(kind, reason) {
|
|
181
|
+
if (!reason) throw new Error(`notEvaluated("${kind}") requires a specific reason`);
|
|
182
|
+
return { kind, reason };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Render a report for a terminal reader.
|
|
187
|
+
*
|
|
188
|
+
* Ordering is deliberate: what was NOT measured comes last but is never
|
|
189
|
+
* omitted, so a reader cannot finish the output without seeing it.
|
|
190
|
+
*/
|
|
191
|
+
export function renderReport(report, { write: rawWrite = console.log } = {}) {
|
|
192
|
+
// Render boundary: every repo-controlled string below passes through safeText
|
|
193
|
+
// exactly once, here, rather than at each producer. The JSON channel
|
|
194
|
+
// (cli.js) needs no equivalent — JSON.stringify escapes control bytes.
|
|
195
|
+
const write = (line) => rawWrite(safeText(line));
|
|
196
|
+
const { producer, subject, facts, coverage } = report;
|
|
197
|
+
|
|
198
|
+
const established = facts.filter(f => f.status === STATUS.ESTABLISHED);
|
|
199
|
+
const refuted = facts.filter(f => f.status === STATUS.REFUTED);
|
|
200
|
+
const unknown = facts.filter(f => f.status === STATUS.UNKNOWN);
|
|
201
|
+
|
|
202
|
+
write('');
|
|
203
|
+
write(`euthyna ${producer.name} — ${producer.purpose || ''}`.trim());
|
|
204
|
+
write(`目标: ${subject.repo ?? subject.coverageFile ?? '(未指定)'}`);
|
|
205
|
+
if (subject.base || subject.head) {
|
|
206
|
+
write(`范围: ${subject.base ?? '?'}..${subject.head ?? 'HEAD'}`);
|
|
207
|
+
}
|
|
208
|
+
write('');
|
|
209
|
+
|
|
210
|
+
const section = (title, list) => {
|
|
211
|
+
if (list.length === 0) return;
|
|
212
|
+
write(`${title} (${list.length})`);
|
|
213
|
+
for (const fact of list) {
|
|
214
|
+
write(` • ${fact.statement}`);
|
|
215
|
+
const where = [fact.evidence.file, fact.evidence.line].filter(Boolean).join(':');
|
|
216
|
+
write(` 证据: ${where}${fact.evidence.commit ? ` (${fact.evidence.commit.slice(0, 10)})` : ''}`);
|
|
217
|
+
if (fact.command) write(` 复现: ${fact.command}`);
|
|
218
|
+
if (fact.confidence === 'approximate') {
|
|
219
|
+
write(' ⚠ approximate —— 不得用于门禁判定');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
write('');
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
section('已确证', established);
|
|
226
|
+
section('已证伪', refuted);
|
|
227
|
+
section('无法判定', unknown);
|
|
228
|
+
|
|
229
|
+
if (facts.length === 0) {
|
|
230
|
+
write(' 本次运行没有产出任何事实。');
|
|
231
|
+
write('');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (coverage.notEvaluated.length > 0) {
|
|
235
|
+
write('⚠ 未评估的判据(缺数据不等于干净):');
|
|
236
|
+
for (const item of coverage.notEvaluated) {
|
|
237
|
+
write(` • ${item.kind}: ${item.reason}`);
|
|
238
|
+
}
|
|
239
|
+
write('');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (coverage.evaluated.length > 0) {
|
|
243
|
+
write('已评估的判据:');
|
|
244
|
+
for (const item of coverage.evaluated) {
|
|
245
|
+
write(` • ${item.kind}: ${item.count} 条 (${item.producer})`);
|
|
246
|
+
}
|
|
247
|
+
write('');
|
|
248
|
+
}
|
|
249
|
+
}
|