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
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage facts: was this symbol ever actually invoked?
|
|
3
|
+
*
|
|
4
|
+
* Scoped deliberately. V8 block coverage cannot prove a call site ran, and the
|
|
5
|
+
* inverse of that limitation is dangerous: it *does* report unreachable code as
|
|
6
|
+
* covered (nodejs/node#57435), so a naive "the line is covered, therefore the
|
|
7
|
+
* call ran" join reports a call site that never executed as executed. That is
|
|
8
|
+
* wrong in the one direction a security audit cannot afford, because it hides a
|
|
9
|
+
* real gap and manufactures confidence at the same time.
|
|
10
|
+
*
|
|
11
|
+
* So this producer emits exactly two outcomes and no third one:
|
|
12
|
+
*
|
|
13
|
+
* count === 0 -> established: the symbol was never invoked
|
|
14
|
+
* count > 0 -> unknown: the symbol was entered, but that says nothing
|
|
15
|
+
* about whether any particular call site reached it
|
|
16
|
+
*
|
|
17
|
+
* There is no code path here that emits "executed". That is the design, not an
|
|
18
|
+
* omission. See docs/fact-contract-zh.md section 6.2.
|
|
19
|
+
*/
|
|
20
|
+
import { readFile } from 'node:fs/promises';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { makeFact, notEvaluated as notEvaluatedEntry, KIND, STATUS, shellQuote } from '../contract.js';
|
|
23
|
+
|
|
24
|
+
/** c8's default exclusions. A production file matching one of these vanishes from the report. */
|
|
25
|
+
const C8_DEFAULT_EXCLUDES = [
|
|
26
|
+
/(^|\/)node_modules\//,
|
|
27
|
+
/(^|\/)test\//,
|
|
28
|
+
/(^|\/)tests\//,
|
|
29
|
+
/(^|\/)__tests__\//,
|
|
30
|
+
/\.test\.[cm]?[jt]sx?$/,
|
|
31
|
+
/\.spec\.[cm]?[jt]sx?$/,
|
|
32
|
+
/\.d\.ts$/
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** Normalize for comparison: coverage keys are absolute, callers may pass relative paths. */
|
|
36
|
+
function normalize(p) {
|
|
37
|
+
return path.resolve(p).replace(/\\/g, '/').toLowerCase();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function matchesTarget(entryPath, target) {
|
|
41
|
+
const a = normalize(entryPath);
|
|
42
|
+
const b = normalize(target);
|
|
43
|
+
return a === b || a.endsWith(b) || b.endsWith(a);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The reproducible command for a coverage fact.
|
|
48
|
+
*
|
|
49
|
+
* Self-referential on purpose: re-running this producer with the same arguments
|
|
50
|
+
* reproduces the fact. Embedding a language-specific one-liner instead would
|
|
51
|
+
* break on any path containing a quote, and the point of the field is that a
|
|
52
|
+
* reader can re-derive the claim without trusting this report.
|
|
53
|
+
*/
|
|
54
|
+
function reproduceCommand({ coverageFile, symbol, file }) {
|
|
55
|
+
return (
|
|
56
|
+
`euthyna coverage --coverage ${shellQuote(coverageFile)} ` +
|
|
57
|
+
`--symbol ${shellQuote(symbol)}` +
|
|
58
|
+
(file ? ` --file ${shellQuote(file)}` : '')
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read a coverage report.
|
|
64
|
+
*
|
|
65
|
+
* Two formats are accepted:
|
|
66
|
+
*
|
|
67
|
+
* 1. c8 / v8-to-istanbul `coverage-final.json` — per-file keys are
|
|
68
|
+
* path/all/statementMap/s/branchMap/b/fnMap/f. There is no istanbul `hash`,
|
|
69
|
+
* and `branchMap` is a relabelled V8 block range rather than an if/else
|
|
70
|
+
* model, so a consumer written against classic istanbul field lists reads
|
|
71
|
+
* the wrong thing.
|
|
72
|
+
* 2. coverage.py JSON (`coverage json`, format 3) — top level is
|
|
73
|
+
* `{meta, files}`, and per-file `functions` maps a function name
|
|
74
|
+
* (`name`, or `Class.method` for methods) to
|
|
75
|
+
* `{executed_lines, missing_lines, start_line, ...}`. There is **no
|
|
76
|
+
* invocation counter**: an empty `executed_lines` array is the only
|
|
77
|
+
* evidence that the function was never entered. coverage.py reports
|
|
78
|
+
* functions that were never called as long as their module was loaded,
|
|
79
|
+
* which is exactly what makes "never invoked" an established fact.
|
|
80
|
+
*/
|
|
81
|
+
export async function loadCoverage(coverageFile) {
|
|
82
|
+
const raw = await readFile(coverageFile, 'utf8');
|
|
83
|
+
return JSON.parse(raw);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* True for coverage.py's `{"meta": {...}, "files": {...}}` shape.
|
|
88
|
+
* A c8 report never has a top-level `meta` key.
|
|
89
|
+
*/
|
|
90
|
+
function isCoveragePyReport(coverage) {
|
|
91
|
+
return (
|
|
92
|
+
coverage &&
|
|
93
|
+
typeof coverage === 'object' &&
|
|
94
|
+
coverage.meta &&
|
|
95
|
+
typeof coverage.meta === 'object' &&
|
|
96
|
+
coverage.files &&
|
|
97
|
+
typeof coverage.files === 'object'
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Locate a symbol inside one file entry and normalise the hit.
|
|
103
|
+
*
|
|
104
|
+
* c8: `fnMap` holds `{name, decl, loc}` and `f` the invocation count per
|
|
105
|
+
* index. `line` comes from decl/loc start.
|
|
106
|
+
* coverage.py: `functions` holds `name -> {executed_lines, ..., start_line}`.
|
|
107
|
+
* A method is named `Class.method`; the bare method name is accepted too,
|
|
108
|
+
* so a user asking about `method_called` finds `Guard.method_called`.
|
|
109
|
+
*
|
|
110
|
+
* Returns an array of `{ name, line, count, invoked }` where `count` is the
|
|
111
|
+
* c8 invocation counter (coverage.py has none, so it is 0/1) and `invoked` is
|
|
112
|
+
* `true` iff there is evidence the function was entered at least once.
|
|
113
|
+
*/
|
|
114
|
+
function locateSymbolInEntry(entry, symbol) {
|
|
115
|
+
const hits = [];
|
|
116
|
+
|
|
117
|
+
const fnMap = entry.fnMap;
|
|
118
|
+
const counts = entry.f;
|
|
119
|
+
if (fnMap && typeof fnMap === 'object') {
|
|
120
|
+
for (const [index, meta] of Object.entries(fnMap)) {
|
|
121
|
+
if (!meta || meta.name !== symbol) continue;
|
|
122
|
+
const count = Number(counts?.[index] ?? 0);
|
|
123
|
+
hits.push({
|
|
124
|
+
name: meta.name,
|
|
125
|
+
line: meta.decl?.start?.line ?? meta.loc?.start?.line ?? meta.line ?? null,
|
|
126
|
+
count,
|
|
127
|
+
invoked: count > 0
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return hits;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const functions = entry.functions;
|
|
134
|
+
if (functions && typeof functions === 'object') {
|
|
135
|
+
for (const [name, meta] of Object.entries(functions)) {
|
|
136
|
+
if (!meta || typeof meta !== 'object') continue;
|
|
137
|
+
if (name === symbol) {
|
|
138
|
+
// module-level code has the empty name; it is not a callable symbol
|
|
139
|
+
if (name === '') continue;
|
|
140
|
+
const invoked = Array.isArray(meta.executed_lines) && meta.executed_lines.length > 0;
|
|
141
|
+
hits.push({
|
|
142
|
+
name,
|
|
143
|
+
line: meta.start_line ?? null,
|
|
144
|
+
count: invoked ? 1 : 0,
|
|
145
|
+
invoked
|
|
146
|
+
});
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (name.includes('.') && name.endsWith('.' + symbol)) {
|
|
150
|
+
const invoked = Array.isArray(meta.executed_lines) && meta.executed_lines.length > 0;
|
|
151
|
+
hits.push({
|
|
152
|
+
name,
|
|
153
|
+
line: meta.start_line ?? null,
|
|
154
|
+
count: invoked ? 1 : 0,
|
|
155
|
+
invoked
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return hits;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return hits;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Collect coverage facts.
|
|
167
|
+
*
|
|
168
|
+
* `measured` distinguishes "the question was answered and the answer is empty"
|
|
169
|
+
* from "the question could not be answered". Only the second is a measurement
|
|
170
|
+
* failure, and conflating them would let a failed run look like a clean one.
|
|
171
|
+
*
|
|
172
|
+
* @param {object} options
|
|
173
|
+
* @param {string} options.coverageFile
|
|
174
|
+
* @param {Array<{file?: string, symbol: string}>} options.targets
|
|
175
|
+
*/
|
|
176
|
+
export async function collectCoverageFacts({ coverageFile, targets = [] } = {}) {
|
|
177
|
+
const facts = [];
|
|
178
|
+
const evaluated = [];
|
|
179
|
+
const notEvaluated = [];
|
|
180
|
+
let counter = 0;
|
|
181
|
+
|
|
182
|
+
let coverage;
|
|
183
|
+
try {
|
|
184
|
+
coverage = await loadCoverage(coverageFile);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
notEvaluated.push(
|
|
187
|
+
notEvaluatedEntry(
|
|
188
|
+
KIND.TEST_COVERAGE,
|
|
189
|
+
`无法读取覆盖率数据 ${coverageFile}: ${error.code ?? error.message}。` +
|
|
190
|
+
'未运行测试或测试未产出覆盖率时,这属于「未评估」,不是「未被覆盖」'
|
|
191
|
+
)
|
|
192
|
+
);
|
|
193
|
+
return { facts, evaluated, notEvaluated, measured: false };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const isPy = isCoveragePyReport(coverage);
|
|
197
|
+
|
|
198
|
+
// c8: entries are the report's file keys directly.
|
|
199
|
+
// coverage.py: the file entries live under `files`.
|
|
200
|
+
const entries = Object.entries(isPy ? (coverage.files ?? {}) : coverage).filter(
|
|
201
|
+
([, v]) => v && typeof v === 'object'
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
if (entries.length === 0) {
|
|
205
|
+
// An empty report is what c8 produces when the tests never loaded the code
|
|
206
|
+
// under test, and what coverage.py produces with an empty `files`. Reporting
|
|
207
|
+
// "no facts" would read as clean, so say so explicitly.
|
|
208
|
+
notEvaluated.push(
|
|
209
|
+
notEvaluatedEntry(
|
|
210
|
+
KIND.TEST_COVERAGE,
|
|
211
|
+
'覆盖率数据为空对象。这通常意味着测试运行没有加载到被测代码(常见于缺少 --all 或 --source),' +
|
|
212
|
+
'因此任何「未在数据中」的文件都必须按未覆盖处理,而这里连文件清单都没有'
|
|
213
|
+
)
|
|
214
|
+
);
|
|
215
|
+
return { facts, evaluated, notEvaluated, measured: false };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (targets.length === 0) {
|
|
219
|
+
notEvaluated.push(notEvaluatedEntry(KIND.TEST_COVERAGE, '没有指定要查询的符号(--symbol)'));
|
|
220
|
+
return { facts, evaluated, notEvaluated, measured: false };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const target of targets) {
|
|
224
|
+
if (target.file && C8_DEFAULT_EXCLUDES.some(re => re.test(target.file.replace(/\\/g, '/')))) {
|
|
225
|
+
notEvaluated.push(
|
|
226
|
+
notEvaluatedEntry(
|
|
227
|
+
KIND.TEST_COVERAGE,
|
|
228
|
+
`${target.file} 命中默认排除规则(c8 排除 test/ 等目录),很可能根本不在覆盖率数据里。` +
|
|
229
|
+
'需要显式 --all(c8)或 --source(coverage.py)或调整 exclude 才能测到它'
|
|
230
|
+
)
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const scoped = target.file
|
|
235
|
+
? entries.filter(([entryPath]) => matchesTarget(entryPath, target.file))
|
|
236
|
+
: entries;
|
|
237
|
+
|
|
238
|
+
if (target.file && scoped.length === 0) {
|
|
239
|
+
facts.push(
|
|
240
|
+
makeFact({
|
|
241
|
+
id: `coverage-${++counter}`,
|
|
242
|
+
kind: KIND.TEST_COVERAGE,
|
|
243
|
+
statement:
|
|
244
|
+
`${target.file} 完全没有出现在覆盖率数据中 —— 测试运行期间该文件未被加载,` +
|
|
245
|
+
`因此其中的符号 ${target.symbol} 未曾被执行`,
|
|
246
|
+
status: STATUS.ESTABLISHED,
|
|
247
|
+
evidence: { file: target.file },
|
|
248
|
+
method: 'command',
|
|
249
|
+
command: isPy
|
|
250
|
+
? 'coverage run --source=<package> <test-command> && coverage json'
|
|
251
|
+
: 'npx c8 --all --reporter=json <test-command>',
|
|
252
|
+
detail: { symbol: target.symbol, reason: 'file_absent_from_coverage' }
|
|
253
|
+
})
|
|
254
|
+
);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let matched = 0;
|
|
259
|
+
for (const [entryPath, entry] of scoped) {
|
|
260
|
+
const hits = locateSymbolInEntry(entry, target.symbol);
|
|
261
|
+
|
|
262
|
+
for (const hit of hits) {
|
|
263
|
+
matched++;
|
|
264
|
+
const line = hit.line;
|
|
265
|
+
const count = hit.count;
|
|
266
|
+
|
|
267
|
+
if (!hit.invoked) {
|
|
268
|
+
facts.push(
|
|
269
|
+
makeFact({
|
|
270
|
+
id: `coverage-${++counter}`,
|
|
271
|
+
kind: KIND.TEST_COVERAGE,
|
|
272
|
+
statement:
|
|
273
|
+
`符号 ${target.symbol} 在本次测试运行中一次都没有被调用(调用计数为 0)—— ` +
|
|
274
|
+
`任何依赖它的行为都没有被执行验证`,
|
|
275
|
+
status: STATUS.ESTABLISHED,
|
|
276
|
+
evidence: { file: entryPath, line },
|
|
277
|
+
method: 'command',
|
|
278
|
+
command: reproduceCommand({ coverageFile, symbol: target.symbol, file: entryPath }),
|
|
279
|
+
detail: { symbol: target.symbol, invocationCount: 0, reason: 'invocation_count_zero' }
|
|
280
|
+
})
|
|
281
|
+
);
|
|
282
|
+
} else {
|
|
283
|
+
// The tempting next step is to call this "executed". It is not:
|
|
284
|
+
// entering a function says nothing about which call sites reached it,
|
|
285
|
+
// and V8 reports some unreachable code as covered.
|
|
286
|
+
facts.push(
|
|
287
|
+
makeFact({
|
|
288
|
+
id: `coverage-${++counter}`,
|
|
289
|
+
kind: KIND.TEST_COVERAGE,
|
|
290
|
+
statement:
|
|
291
|
+
isPy
|
|
292
|
+
? `符号 ${target.symbol} 至少被调用过一次,但被调用**不能**证明任何特定调用点执行过 —— ` +
|
|
293
|
+
`本事实只能证伪,不能证实`
|
|
294
|
+
: `符号 ${target.symbol} 被调用了 ${count} 次,但调用计数非零**不能**证明任何特定调用点执行过 —— ` +
|
|
295
|
+
`本事实只能证伪,不能证实`,
|
|
296
|
+
status: STATUS.UNKNOWN,
|
|
297
|
+
evidence: { file: entryPath, line },
|
|
298
|
+
method: 'command',
|
|
299
|
+
command: reproduceCommand({ coverageFile, symbol: target.symbol, file: entryPath }),
|
|
300
|
+
detail: {
|
|
301
|
+
symbol: target.symbol,
|
|
302
|
+
invocationCount: count,
|
|
303
|
+
reason: 'nonzero_count_cannot_prove_call_site_execution'
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (matched === 0) {
|
|
312
|
+
facts.push(
|
|
313
|
+
makeFact({
|
|
314
|
+
id: `coverage-${++counter}`,
|
|
315
|
+
kind: KIND.TEST_COVERAGE,
|
|
316
|
+
statement:
|
|
317
|
+
`未能在覆盖率数据中定位符号 ${target.symbol}` +
|
|
318
|
+
(target.file ? `(限定文件 ${target.file})` : '') +
|
|
319
|
+
(isPy
|
|
320
|
+
? ' —— 可能是被重命名、被内联,或它只在模块顶层出现'
|
|
321
|
+
: ' —— 可能是被重命名、被内联,或它只在模块顶层出现(c8 把这类调用点放在 branchMap 而非 fnMap)'),
|
|
322
|
+
status: STATUS.UNKNOWN,
|
|
323
|
+
evidence: { file: target.file ?? '(未限定文件)' },
|
|
324
|
+
method: 'static',
|
|
325
|
+
detail: { symbol: target.symbol, reason: 'symbol_not_located_in_fnmap' }
|
|
326
|
+
})
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
evaluated.push({
|
|
332
|
+
kind: KIND.TEST_COVERAGE,
|
|
333
|
+
producer: 'euthyna-coverage',
|
|
334
|
+
count: facts.length
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
return { facts, evaluated, notEvaluated, measured: true };
|
|
338
|
+
}
|