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.
@@ -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
+ }