euthyna 0.7.0 → 0.9.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/.agents/skills/euthyna/SKILL.md +18 -1
- package/.agents/skills/euthyna/references/fact-producers.md +57 -1
- package/.agents/skills/euthyna-en/SKILL.md +22 -1
- package/.agents/skills/euthyna-en/references/fact-producers.md +37 -1
- package/.claude/commands/euthyna.md +2 -2
- package/.cursor/commands/euthyna.md +1 -1
- package/.github/prompts/euthyna.prompt.md +1 -1
- package/.opencode/commands/euthyna.md +4 -2
- package/README.md +22 -5
- package/package.json +2 -2
- package/plugin.yaml +1 -1
- package/src/cli.js +125 -37
- package/src/contract.js +6 -1
- package/src/facts/coverage.js +504 -1
- package/src/git.js +16 -0
- package/src/graph.js +73 -0
package/src/facts/coverage.js
CHANGED
|
@@ -63,7 +63,7 @@ function reproduceCommand({ coverageFile, symbol, file, source }) {
|
|
|
63
63
|
/**
|
|
64
64
|
* Read a coverage report.
|
|
65
65
|
*
|
|
66
|
-
*
|
|
66
|
+
* Five formats are accepted, all identified by shape rather than by filename:
|
|
67
67
|
*
|
|
68
68
|
* 1. c8 / v8-to-istanbul `coverage-final.json` — per-file keys are
|
|
69
69
|
* path/all/statementMap/s/branchMap/b/fnMap/f. There is no istanbul `hash`,
|
|
@@ -88,6 +88,12 @@ function reproduceCommand({ coverageFile, symbol, file, source }) {
|
|
|
88
88
|
* parsed from the gofmt'd source under --source (module root). A file
|
|
89
89
|
* absent from the profile is *not* evidence of "never executed" (the
|
|
90
90
|
* package may not be in the test binary), so absence is notEvaluated.
|
|
91
|
+
* 5. lcov tracefiles (`llvm-cov export --format=lcov`, gcov `.info`,
|
|
92
|
+
* cargo-llvm-cov) — text records, one per source file, opened by `SF:` and
|
|
93
|
+
* closed by `end_of_record`, with `FN:<line>,<name>` / `FNDA:<count>,<name>`
|
|
94
|
+
* pairs inside. Function names **are** present, but mangled; see
|
|
95
|
+
* parseLcov() for how a query is aligned with them and why `--source` is
|
|
96
|
+
* not needed on this path.
|
|
91
97
|
*
|
|
92
98
|
* Anything that matches none of these shapes is **not a coverage report this
|
|
93
99
|
* producer can read**, and is reported as notEvaluated — never fed through the
|
|
@@ -105,6 +111,17 @@ export async function loadCoverage(coverageFile) {
|
|
|
105
111
|
}
|
|
106
112
|
return parsed;
|
|
107
113
|
}
|
|
114
|
+
if (isLcovTracefile(raw)) {
|
|
115
|
+
const parsed = parseLcov(raw);
|
|
116
|
+
if (!parsed) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
'the file looks like an lcov tracefile ("SF:" records closed by "end_of_record") but holds a line that is not ' +
|
|
119
|
+
'an lcov "key:value" line, a function line outside any record, an unclosed record, or an FN value that is not ' +
|
|
120
|
+
'"<line>,<name>" — refusing to interpret it as coverage'
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return parsed;
|
|
124
|
+
}
|
|
108
125
|
return JSON.parse(raw);
|
|
109
126
|
}
|
|
110
127
|
|
|
@@ -249,6 +266,485 @@ export function goFunctionRanges(text) {
|
|
|
249
266
|
return funcs;
|
|
250
267
|
}
|
|
251
268
|
|
|
269
|
+
/**
|
|
270
|
+
* lcov tracefiles (`llvm-cov export --format=lcov`, gcov `.info`,
|
|
271
|
+
* cargo-llvm-cov) are text records, one per source file:
|
|
272
|
+
*
|
|
273
|
+
* SF:<source file>
|
|
274
|
+
* FN:<line>,<mangled name>
|
|
275
|
+
* FNDA:<count>,<mangled name>
|
|
276
|
+
* FNF:12
|
|
277
|
+
* FNH:11
|
|
278
|
+
* DA:<line>,<count>
|
|
279
|
+
* BRF:0
|
|
280
|
+
* BRH:0
|
|
281
|
+
* LF:52
|
|
282
|
+
* LH:46
|
|
283
|
+
* end_of_record
|
|
284
|
+
*
|
|
285
|
+
* Unlike a Go profile these records **do** carry function names, so a symbol
|
|
286
|
+
* query needs no source tree — but the names are mangled, which is what
|
|
287
|
+
* lcovSymbolMatches() is for.
|
|
288
|
+
*
|
|
289
|
+
* Two things this reader refuses rather than guesses:
|
|
290
|
+
*
|
|
291
|
+
* - an `FN:` with no matching `FNDA:` is "no count", not "count 0". Defaulting
|
|
292
|
+
* it to zero would manufacture a "never invoked" out of a missing line,
|
|
293
|
+
* which is the one direction this producer must never get wrong.
|
|
294
|
+
* - the newer `FNL:` / `FNA:` / `FNAB:` encodings are recognised and rejected.
|
|
295
|
+
* No sample of them could be produced on this machine, and a function table
|
|
296
|
+
* read out of a shape nobody has seen is the same failure
|
|
297
|
+
* detectCoverageFormat() refuses — half a shape treated as coverage. (Their
|
|
298
|
+
* spelling is taken from the lcov specification, not from an observed file.)
|
|
299
|
+
*
|
|
300
|
+
* Three spellings the specification allows beyond the shape measured above, and
|
|
301
|
+
* that are therefore read instead of refused:
|
|
302
|
+
*
|
|
303
|
+
* - **comment records** (`#…` in column 0) may appear "anywhere — inside a
|
|
304
|
+
* section or outside one" and are ignored when a tracefile is read.
|
|
305
|
+
* `geninfo --comment` writes one at the very top by default, so refusing them
|
|
306
|
+
* refused a whole class of real files — and, because the first line was then
|
|
307
|
+
* not an SF/TN line, reported the file as "not valid JSON", which points the
|
|
308
|
+
* reader at the wrong problem entirely. The `#` must be in column 0: that is
|
|
309
|
+
* the spec's comment form, and an indented `#` stays an unaccounted-for line.
|
|
310
|
+
* - **`KF:` is a synonym for `SF:`** — stated outright in the specification,
|
|
311
|
+
* and lcov's own reader matches `/^[SK]F:/`.
|
|
312
|
+
* - **`FN:<start>,<end>,<name>`**: the end line is optional and is emitted by
|
|
313
|
+
* gcc 9+ and other recent toolchains, which puts it inside the range this
|
|
314
|
+
* path advertises (gcov `.info` files). The end line is discarded — the fact
|
|
315
|
+
* model is function-level and evidence quotes the start line — but it must be
|
|
316
|
+
* *consumed*: otherwise `14,` becomes part of the symbol name, the one
|
|
317
|
+
* function splits into two records (one with a line and no count, one with a
|
|
318
|
+
* count and no line), and the count-less twin reads as "an FN: with no FNDA:"
|
|
319
|
+
* and manufactures a not-evaluated for a function whose count is right there.
|
|
320
|
+
*/
|
|
321
|
+
function isLcovTracefile(raw) {
|
|
322
|
+
const first = String(raw)
|
|
323
|
+
.split(/\r?\n/)
|
|
324
|
+
.map(line => line.trim())
|
|
325
|
+
.find(line => line && !line.startsWith('#'));
|
|
326
|
+
return /^(SF|KF|TN):/.test(first ?? '');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const LCOV_ENTRY_RE = /^([A-Za-z_]+):(.*)$/;
|
|
330
|
+
// FN alone takes the optional end line, mirroring lcovutil.pm's
|
|
331
|
+
// /^FN:(\d+),((\d+),)?(.+)$/. FNDA keeps `<count>,<name>` — a name is free to
|
|
332
|
+
// contain a comma, so letting FNDA accept the wider shape would eat the first
|
|
333
|
+
// segment of such a name as an "end line" that means nothing there.
|
|
334
|
+
const LCOV_FN_RE = /^(\d+),((\d+),)?(.+)$/;
|
|
335
|
+
const LCOV_FNDA_RE = /^(\d+),(.+)$/;
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Parse an lcov tracefile into
|
|
339
|
+
* `{ __lcov, files: Map<path, { functions: Map<mangled, { line, count }> }> }`,
|
|
340
|
+
* or null if any line is not one this reader can account for.
|
|
341
|
+
*
|
|
342
|
+
* `count` is null when a function is declared without an FNDA line: the parser
|
|
343
|
+
* never substitutes a number for a count that is not in the file.
|
|
344
|
+
*
|
|
345
|
+
* Two records for the same `SF:` (a split tracefile, or one file instrumented
|
|
346
|
+
* twice) are merged by summing counts. Summing is the one merge that cannot
|
|
347
|
+
* turn a nonzero count into zero, and zero is the only conclusion this producer
|
|
348
|
+
* draws — so the merge is safe in exactly the direction that matters. A name
|
|
349
|
+
* whose count is missing on either side stays null.
|
|
350
|
+
*
|
|
351
|
+
* DA/BRDA/FNF/FNH/LF/LH/BRF/BRH/TN/VER are recognised and ignored: the fact
|
|
352
|
+
* model here is function-level, and refusing a file over a line we never read
|
|
353
|
+
* would refuse files that answer the query correctly.
|
|
354
|
+
*/
|
|
355
|
+
export function parseLcov(raw) {
|
|
356
|
+
const lines = String(raw).split(/\r?\n/);
|
|
357
|
+
const files = new Map();
|
|
358
|
+
let fnLines = null;
|
|
359
|
+
let fnda = null;
|
|
360
|
+
let currentPath = null;
|
|
361
|
+
|
|
362
|
+
const closeRecord = () => {
|
|
363
|
+
const functions = new Map();
|
|
364
|
+
for (const [symbol, line] of fnLines) {
|
|
365
|
+
functions.set(symbol, { line, count: fnda.has(symbol) ? fnda.get(symbol) : null });
|
|
366
|
+
}
|
|
367
|
+
// An FNDA without an FN still carries a count, and a count is enough to say
|
|
368
|
+
// "never invoked"; only its line is unknown.
|
|
369
|
+
for (const [symbol, count] of fnda) {
|
|
370
|
+
if (!functions.has(symbol)) functions.set(symbol, { line: null, count });
|
|
371
|
+
}
|
|
372
|
+
const previous = files.get(currentPath);
|
|
373
|
+
if (previous) {
|
|
374
|
+
for (const [symbol, fn] of functions) {
|
|
375
|
+
const prev = previous.functions.get(symbol);
|
|
376
|
+
if (!prev) {
|
|
377
|
+
previous.functions.set(symbol, fn);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
previous.functions.set(symbol, {
|
|
381
|
+
line: prev.line ?? fn.line,
|
|
382
|
+
count: prev.count === null || fn.count === null ? null : prev.count + fn.count
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
} else {
|
|
386
|
+
files.set(currentPath, { functions });
|
|
387
|
+
}
|
|
388
|
+
fnLines = null;
|
|
389
|
+
fnda = null;
|
|
390
|
+
currentPath = null;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
for (const line of lines) {
|
|
394
|
+
if (!line.trim()) continue;
|
|
395
|
+
// Comment records are legal anywhere and mean nothing, so they are dropped
|
|
396
|
+
// before the key:value test rather than counted as an unknown key.
|
|
397
|
+
if (line.startsWith('#')) continue;
|
|
398
|
+
if (line.trim() === 'end_of_record') {
|
|
399
|
+
if (currentPath === null) return null;
|
|
400
|
+
closeRecord();
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const entry = LCOV_ENTRY_RE.exec(line);
|
|
404
|
+
if (!entry) return null;
|
|
405
|
+
const [, key, value] = entry;
|
|
406
|
+
|
|
407
|
+
// `TN:` names the test run and legally precedes a record (it is also what
|
|
408
|
+
// isLcovTracefile keys on when a file has no SF line yet), so it is ignored
|
|
409
|
+
// anywhere rather than refused outside a record.
|
|
410
|
+
if (key === 'TN') continue;
|
|
411
|
+
|
|
412
|
+
// KF: is the specification's synonym for SF:, not a second record type.
|
|
413
|
+
if (key === 'SF' || key === 'KF') {
|
|
414
|
+
if (currentPath !== null) return null; // a record was never closed
|
|
415
|
+
currentPath = value;
|
|
416
|
+
fnLines = new Map();
|
|
417
|
+
fnda = new Map();
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (currentPath === null) return null; // any other key outside a record
|
|
421
|
+
if (key === 'FN') {
|
|
422
|
+
const pair = LCOV_FN_RE.exec(value);
|
|
423
|
+
if (!pair) return null;
|
|
424
|
+
// Group 2 is the end line when present; it is deliberately dropped.
|
|
425
|
+
const [, start, , , symbol] = pair;
|
|
426
|
+
if (!fnLines.has(symbol)) fnLines.set(symbol, Number(start));
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (key === 'FNDA') {
|
|
430
|
+
const pair = LCOV_FNDA_RE.exec(value);
|
|
431
|
+
if (!pair) return null;
|
|
432
|
+
const [, count, symbol] = pair;
|
|
433
|
+
fnda.set(symbol, (fnda.get(symbol) ?? 0) + Number(count));
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (key === 'FNL' || key === 'FNA' || key === 'FNAB') return null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (currentPath !== null) return null; // unclosed record at end of file
|
|
440
|
+
if (files.size === 0) return null; // no SF: record at all
|
|
441
|
+
return { __lcov: true, files };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The needle for one query segment: Rust's v0 mangling and Itanium both
|
|
446
|
+
* encode an identifier as `<decimal length><the identifier>`, so `dup` is
|
|
447
|
+
* looked up as `3dup`.
|
|
448
|
+
*/
|
|
449
|
+
export function lcovSymbolNeedle(segments) {
|
|
450
|
+
return segments.map(segment => `${Buffer.byteLength(segment, 'utf8')}${segment}`).join('');
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* True when `name.slice(k, end)` is a complete `<length><value>` pair — the
|
|
455
|
+
* proof that `end` is where a fresh path segment begins.
|
|
456
|
+
*/
|
|
457
|
+
function lcovPairEndsAt(name, end) {
|
|
458
|
+
for (let k = 0; k < end; k++) {
|
|
459
|
+
if (!/\d/.test(name[k])) continue;
|
|
460
|
+
if (k > 0 && /\d/.test(name[k - 1])) continue; // only the start of a digit run
|
|
461
|
+
let digits = k;
|
|
462
|
+
while (digits < end && /\d/.test(name[digits])) digits++;
|
|
463
|
+
if (digits >= end) continue;
|
|
464
|
+
if (digits + Number(name.slice(k, digits)) === end) return true;
|
|
465
|
+
}
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* True when the identifier `name.slice(idStart, idEnd)` sits strictly inside a
|
|
471
|
+
* longer, complete `<length><value>` pair — a reading of the name in which the
|
|
472
|
+
* identifier is part of a *longer* one, so a needle landing there is not
|
|
473
|
+
* standing at a segment boundary.
|
|
474
|
+
*
|
|
475
|
+
* Measured, and the reason this exists: in
|
|
476
|
+
* `_RNvCsi8SMnIpCoEi_13collide_probe8cfg_3get` the character before `3get` is
|
|
477
|
+
* `_`, which the separator rule accepts, but `8cfg_3get` is itself a complete
|
|
478
|
+
* pair (`8` is the length of `cfg_3get`), so `3get` is inside an identifier and
|
|
479
|
+
* a query for `get` must not answer for it.
|
|
480
|
+
*/
|
|
481
|
+
function lcovInsideLongerPair(name, idStart, idEnd) {
|
|
482
|
+
for (let k = 0; k < idStart; k++) {
|
|
483
|
+
if (!/\d/.test(name[k])) continue;
|
|
484
|
+
if (k > 0 && /\d/.test(name[k - 1])) continue; // only the start of a digit run
|
|
485
|
+
let digits = k;
|
|
486
|
+
while (digits < name.length && /\d/.test(name[digits])) digits++;
|
|
487
|
+
if (digits >= name.length) continue;
|
|
488
|
+
const start = digits;
|
|
489
|
+
const end = start + Number(name.slice(k, digits));
|
|
490
|
+
if (start >= idEnd || end > name.length) continue;
|
|
491
|
+
if (start < idStart && end >= idEnd) return true;
|
|
492
|
+
}
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Does a mangled name answer for the query path `segments`?
|
|
498
|
+
*
|
|
499
|
+
* `true` when every segment appears, in order, each one anchored: at the start
|
|
500
|
+
* of the name, right after the mangling's own separators (`_`, as in
|
|
501
|
+
* `NtB2_7Counter4bump`), or at a proven `<length><value>` pair boundary — and
|
|
502
|
+
* not strictly inside a longer pair (see lcovInsideLongerPair). The segments are
|
|
503
|
+
* matched independently rather than as one concatenated needle because a
|
|
504
|
+
* mangling may insert both a disambiguator and further path components between
|
|
505
|
+
* them: a `#[cfg(test)]` module comes out as `...5testss_14exercises_some`, and
|
|
506
|
+
* the path of a trait method carries its `Self` type
|
|
507
|
+
* (`NtB4_4GearNtB4_6Widget5label`), so a concatenated needle would miss both
|
|
508
|
+
* `tests::exercises_some` and `Gear::label` outright — measured, by running the
|
|
509
|
+
* contiguous form against the real tracefiles.
|
|
510
|
+
*
|
|
511
|
+
* What this buys: the digits before an identifier are its *byte* length, so
|
|
512
|
+
* requiring the digits to be there, and to be a real length prefix, is what
|
|
513
|
+
* keeps a query for `dup` from matching a function whose own name merely ends
|
|
514
|
+
* with those characters (`7xyz3dup` is one segment, and the character before
|
|
515
|
+
* `3dup` is `z`, which proves no pair ends there).
|
|
516
|
+
*
|
|
517
|
+
* The boundary test's fourth alternative — any character outside `[A-Za-z0-9_]`
|
|
518
|
+
* — is broader than anything measured needs: every character of the 15 `FN:`
|
|
519
|
+
* symbols in the probe tracefile is inside that class, so the mangling's own
|
|
520
|
+
* `_` separator and the length prefixes are the only boundaries the measurement
|
|
521
|
+
* actually exercises. It stays permissive on purpose: the observed names give no
|
|
522
|
+
* reason to narrow it, and a narrower rule invented without the mangling's
|
|
523
|
+
* grammar could reject a legal boundary it cannot recognise.
|
|
524
|
+
*
|
|
525
|
+
* One residual over-inclusion, recorded rather than hidden: a *qualified* query
|
|
526
|
+
* is a subsequence test, not a path-exact one, so `lcov_probe::dup` also
|
|
527
|
+
* answers for `lcov_probe::nested::dup`. Each fact names the symbol it is about
|
|
528
|
+
* (see collectLcovCoverageFacts), so the reader can tell them apart; narrowing
|
|
529
|
+
* the match further would need the mangling's grammar.
|
|
530
|
+
*
|
|
531
|
+
* A query that is already mangled (`_R`/`_Z`) is matched exactly and never
|
|
532
|
+
* decoded: this producer has no demangler, and half-decoding one would be a
|
|
533
|
+
* guess about which symbol was asked for.
|
|
534
|
+
*/
|
|
535
|
+
export function lcovSymbolMatches(segments, name) {
|
|
536
|
+
if (segments.length === 0) return false;
|
|
537
|
+
if (segments.length === 1 && /^_[RZ]/.test(segments[0])) return name === segments[0];
|
|
538
|
+
|
|
539
|
+
let from = 0;
|
|
540
|
+
for (const segment of segments) {
|
|
541
|
+
const needle = lcovSymbolNeedle([segment]);
|
|
542
|
+
const idDigits = String(Buffer.byteLength(segment, 'utf8')).length;
|
|
543
|
+
let found = -1;
|
|
544
|
+
for (let at = name.indexOf(needle, from); at !== -1; at = name.indexOf(needle, at + 1)) {
|
|
545
|
+
const boundary =
|
|
546
|
+
at === 0 || name[at - 1] === '_' || !/[A-Za-z0-9_]/.test(name[at - 1]) || lcovPairEndsAt(name, at);
|
|
547
|
+
if (boundary && !lcovInsideLongerPair(name, at + idDigits, at + needle.length)) {
|
|
548
|
+
found = at;
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (found === -1) return false;
|
|
553
|
+
from = found + needle.length;
|
|
554
|
+
}
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* A query answered from the lcov function table.
|
|
560
|
+
*
|
|
561
|
+
* No `--source`: the tracefile carries function names, so this path answers the
|
|
562
|
+
* question Go's profile cannot answer without the source tree. The trade is
|
|
563
|
+
* that the names are mangled and the matching is done on the mangled form (see
|
|
564
|
+
* lcovSymbolMatches), and that a symbol which never made it into the
|
|
565
|
+
* instrumented build has no source-tree search to fall back on — it comes out
|
|
566
|
+
* as "not located" (unknown), never as clean.
|
|
567
|
+
*/
|
|
568
|
+
async function collectLcovCoverageFacts({ coverage, coverageFile, targets, lang }) {
|
|
569
|
+
const t = T(lang);
|
|
570
|
+
const facts = [];
|
|
571
|
+
const evaluated = [];
|
|
572
|
+
const notEvaluated = [];
|
|
573
|
+
let counter = 0;
|
|
574
|
+
|
|
575
|
+
const entries = [...coverage.files.entries()];
|
|
576
|
+
|
|
577
|
+
if (!entries.some(([, record]) => record.functions.size > 0)) {
|
|
578
|
+
// Line coverage cannot answer a symbol question: which lines belong to a
|
|
579
|
+
// function is in the source, not in the tracefile.
|
|
580
|
+
notEvaluated.push(
|
|
581
|
+
notEvaluatedEntry(
|
|
582
|
+
KIND.TEST_COVERAGE,
|
|
583
|
+
t(
|
|
584
|
+
'lcov tracefile 里没有任何 FN: 行 —— 只有文件与行数据,没有函数名。符号级问题(这个符号被调用过吗)' +
|
|
585
|
+
'需要 FN:/FNDA: 记录才能回答,行覆盖回答不了它',
|
|
586
|
+
'The lcov tracefile carries no FN: lines — file and line records only, no function names. ' +
|
|
587
|
+
'A symbol-level question (was this symbol invoked) needs FN:/FNDA: records; line coverage cannot answer it'
|
|
588
|
+
)
|
|
589
|
+
)
|
|
590
|
+
);
|
|
591
|
+
return { facts, evaluated, notEvaluated, measured: false };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (targets.length === 0) {
|
|
595
|
+
notEvaluated.push(
|
|
596
|
+
notEvaluatedEntry(
|
|
597
|
+
KIND.TEST_COVERAGE,
|
|
598
|
+
t(
|
|
599
|
+
'没有指定要查询的符号(--symbol)',
|
|
600
|
+
'No symbol was given to look up (--symbol)'
|
|
601
|
+
)
|
|
602
|
+
)
|
|
603
|
+
);
|
|
604
|
+
return { facts, evaluated, notEvaluated, measured: false };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
for (const target of targets) {
|
|
608
|
+
const scoped = target.file
|
|
609
|
+
? entries.filter(([entryPath]) => matchesTarget(entryPath, target.file))
|
|
610
|
+
: entries;
|
|
611
|
+
|
|
612
|
+
if (target.file && scoped.length === 0) {
|
|
613
|
+
// Deliberately the opposite of the c8 path above: a c8 report lists the
|
|
614
|
+
// files it instrumented, so absence there means "not loaded", while
|
|
615
|
+
// llvm-cov and gcov emit records only for what was instrumented and
|
|
616
|
+
// --sources filters further — absence here proves nothing about execution.
|
|
617
|
+
notEvaluated.push(
|
|
618
|
+
notEvaluatedEntry(
|
|
619
|
+
KIND.TEST_COVERAGE,
|
|
620
|
+
t(
|
|
621
|
+
`${target.file} 没有出现在 lcov tracefile 中 —— llvm-cov/gcov 只为被插桩的目标产记录(--sources 还会再过滤一层),` +
|
|
622
|
+
'所以「未出现」不能当「未执行」',
|
|
623
|
+
`${target.file} does not appear in the lcov tracefile — llvm-cov/gcov emit a record only for what was instrumented ` +
|
|
624
|
+
'(and --sources filters further), so "absent" cannot be read as "never executed"'
|
|
625
|
+
)
|
|
626
|
+
)
|
|
627
|
+
);
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const segments = target.symbol
|
|
632
|
+
.split('::')
|
|
633
|
+
.map(segment => segment.trim())
|
|
634
|
+
.filter(Boolean);
|
|
635
|
+
const matches = [];
|
|
636
|
+
for (const [entryPath, record] of scoped) {
|
|
637
|
+
for (const [mangled, fn] of record.functions) {
|
|
638
|
+
if (lcovSymbolMatches(segments, mangled)) matches.push({ entryPath, mangled, ...fn });
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (matches.length === 0) {
|
|
643
|
+
const needle = lcovSymbolNeedle(segments);
|
|
644
|
+
facts.push(
|
|
645
|
+
makeFact({
|
|
646
|
+
id: `coverage-${++counter}`,
|
|
647
|
+
kind: KIND.TEST_COVERAGE,
|
|
648
|
+
statement:
|
|
649
|
+
t(
|
|
650
|
+
`未能在覆盖率数据中定位符号 ${target.symbol}`,
|
|
651
|
+
`Could not locate symbol ${target.symbol} in the coverage data`
|
|
652
|
+
) +
|
|
653
|
+
(target.file ? t(`(限定文件 ${target.file})`, ` (restricted to file ${target.file})`) : '') +
|
|
654
|
+
t(
|
|
655
|
+
` —— lcov 的 FN: 名是 mangled 的,本产出器按段边界匹配 "${needle}";` +
|
|
656
|
+
'泛型实例、被内联的克隆,或写法不同的路径(crate:: 前缀、以 . 分隔)都可能匹配不上,' +
|
|
657
|
+
'也可能是它根本没被编译进这次插桩构建',
|
|
658
|
+
` — lcov FN: names are mangled and this producer matches them by segment boundaries against "${needle}"; ` +
|
|
659
|
+
'a generic instantiation, an inlined clone, or a differently spelled path (a crate:: prefix, a "." separator) may fail to match, ' +
|
|
660
|
+
'and the symbol may simply not have been compiled into this instrumented build'
|
|
661
|
+
),
|
|
662
|
+
status: STATUS.UNKNOWN,
|
|
663
|
+
evidence: { file: target.file ?? t('(未限定文件)', '(unrestricted file)') },
|
|
664
|
+
method: 'static',
|
|
665
|
+
detail: { symbol: target.symbol, reason: 'symbol_not_located_in_fnmap' }
|
|
666
|
+
})
|
|
667
|
+
);
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// A query can legitimately hit several symbols — a bare name repeated in two
|
|
672
|
+
// modules, or one generic function instantiated twice at the same line. Each
|
|
673
|
+
// is reported on its own so no fact claims more than the symbol it names: a
|
|
674
|
+
// bare name is a symbol name and can be stated as given, while a `::` path is
|
|
675
|
+
// a pattern, so the statement names the symbol the match actually resolved to.
|
|
676
|
+
const pathQuery = segments.length > 1;
|
|
677
|
+
for (const hit of matches) {
|
|
678
|
+
const subject = pathQuery ? hit.mangled : target.symbol;
|
|
679
|
+
const qualifier = matches.length > 1 && !pathQuery
|
|
680
|
+
? t(
|
|
681
|
+
`(本次查询匹配到 ${matches.length} 个符号;本事实针对 ${hit.mangled})`,
|
|
682
|
+
` (${matches.length} symbols matched this query; this fact is about ${hit.mangled})`
|
|
683
|
+
)
|
|
684
|
+
: '';
|
|
685
|
+
|
|
686
|
+
if (hit.count === null) {
|
|
687
|
+
notEvaluated.push(
|
|
688
|
+
notEvaluatedEntry(
|
|
689
|
+
KIND.TEST_COVERAGE,
|
|
690
|
+
t(
|
|
691
|
+
`${hit.mangled} 在 tracefile 里有 FN: 行却没有任何 FNDA: 计数 —— 缺失的计数不是 0,这条记录答不了` +
|
|
692
|
+
`「${subject} 是否被调用过」`,
|
|
693
|
+
`${hit.mangled} has an FN: line in the tracefile but no FNDA: count at all — a missing count is not a zero, ` +
|
|
694
|
+
`so this record cannot answer whether ${subject} was invoked`
|
|
695
|
+
)
|
|
696
|
+
)
|
|
697
|
+
);
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const zero = hit.count === 0;
|
|
702
|
+
facts.push(
|
|
703
|
+
makeFact({
|
|
704
|
+
id: `coverage-${++counter}`,
|
|
705
|
+
kind: KIND.TEST_COVERAGE,
|
|
706
|
+
statement: zero
|
|
707
|
+
? t(
|
|
708
|
+
`符号 ${subject} 在本次测试运行中一次都没有被调用(调用计数为 0)—— 任何依赖它的行为都没有被执行验证`,
|
|
709
|
+
`Symbol ${subject} was not invoked even once in this test run (invocation count 0) — ` +
|
|
710
|
+
'no behaviour depending on it was exercised'
|
|
711
|
+
) + qualifier
|
|
712
|
+
: t(
|
|
713
|
+
`符号 ${subject} 在本次测试运行中至少被调用过 ${hit.count} 次,但被调用**不能**证明任何特定调用点执行过 —— ` +
|
|
714
|
+
'本事实只能证伪,不能证实',
|
|
715
|
+
`Symbol ${subject} was invoked at least ${hit.count} time(s) in this test run, but being invoked ` +
|
|
716
|
+
'**cannot** prove that any particular call site executed — this fact can refute, not confirm'
|
|
717
|
+
) + qualifier,
|
|
718
|
+
status: zero ? STATUS.ESTABLISHED : STATUS.UNKNOWN,
|
|
719
|
+
evidence: hit.line === null ? { file: hit.entryPath } : { file: hit.entryPath, line: hit.line },
|
|
720
|
+
method: 'command',
|
|
721
|
+
command: reproduceCommand({ coverageFile, symbol: target.symbol, file: hit.entryPath }),
|
|
722
|
+
detail: {
|
|
723
|
+
symbol: target.symbol,
|
|
724
|
+
mangled: hit.mangled,
|
|
725
|
+
invocationCount: hit.count,
|
|
726
|
+
reason: zero ? 'invocation_count_zero' : 'nonzero_count_cannot_prove_call_site_execution'
|
|
727
|
+
}
|
|
728
|
+
})
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Same rule as the Go path: a query answered only by refusals was not answered
|
|
734
|
+
// at all, and "measured" must not let CI read that as clean.
|
|
735
|
+
const measured = facts.length > 0;
|
|
736
|
+
if (measured) {
|
|
737
|
+
evaluated.push({
|
|
738
|
+
kind: KIND.TEST_COVERAGE,
|
|
739
|
+
producer: 'euthyna-coverage',
|
|
740
|
+
format: 'lcov',
|
|
741
|
+
count: facts.length
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
return { facts, evaluated, notEvaluated, measured };
|
|
746
|
+
}
|
|
747
|
+
|
|
252
748
|
/**
|
|
253
749
|
* True for coverage.py's `{"meta": {...}, "files": {...}}` shape.
|
|
254
750
|
* A c8 report never has a top-level `meta` key.
|
|
@@ -273,6 +769,7 @@ function isCoveragePyReport(coverage) {
|
|
|
273
769
|
*/
|
|
274
770
|
export function detectCoverageFormat(coverage) {
|
|
275
771
|
if (coverage && typeof coverage === 'object' && coverage.__go) return 'go';
|
|
772
|
+
if (coverage && typeof coverage === 'object' && coverage.__lcov) return 'lcov';
|
|
276
773
|
if (isCoveragePyReport(coverage)) return 'coverage.py';
|
|
277
774
|
|
|
278
775
|
const entries = Object.entries(coverage).filter(([, v]) => v && typeof v === 'object');
|
|
@@ -405,6 +902,12 @@ export async function collectCoverageFacts({ coverageFile, targets = [], source,
|
|
|
405
902
|
return collectGoCoverageFacts({ coverage, coverageFile, targets, source, lang });
|
|
406
903
|
}
|
|
407
904
|
|
|
905
|
+
// lcov tracefiles carry mangled function names, so symbol location works on
|
|
906
|
+
// the function table and needs no source tree — the opposite trade from Go.
|
|
907
|
+
if (coverage.__lcov) {
|
|
908
|
+
return collectLcovCoverageFacts({ coverage, coverageFile, targets, lang });
|
|
909
|
+
}
|
|
910
|
+
|
|
408
911
|
// c8: entries are the report's file keys directly.
|
|
409
912
|
// coverage.py: the file entries live under `files`.
|
|
410
913
|
const entries = Object.entries(isPy ? (coverage.files ?? {}) : coverage).filter(
|
package/src/git.js
CHANGED
|
@@ -81,6 +81,22 @@ export async function revParse(cwd, rev) {
|
|
|
81
81
|
return out.trim() || null;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The fork point between two revisions, or null when git cannot name one.
|
|
86
|
+
*
|
|
87
|
+
* `from` is passed in rather than assumed to be the checked-out HEAD: a run that
|
|
88
|
+
* was asked about another commit must fork from *that* commit, or the range it
|
|
89
|
+
* reports was never the one requested.
|
|
90
|
+
*/
|
|
91
|
+
export async function mergeBase(cwd, from, rev) {
|
|
92
|
+
const out = await git(['merge-base', from, rev], { cwd, allowFailure: true });
|
|
93
|
+
const sha = out.trim();
|
|
94
|
+
// git prints the hash alone on success; anything else (an error text that got
|
|
95
|
+
// past allowFailure, an empty result) is not a fork point. A repository picks
|
|
96
|
+
// its own object format, so both the 40-char and the 64-char id are valid.
|
|
97
|
+
return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(sha) ? sha : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
84
100
|
/**
|
|
85
101
|
* One-line summaries for a set of commits, as a Map of hash -> {subject, author, date}.
|
|
86
102
|
* Uses a single `git log` invocation so the cost does not scale per commit.
|
package/src/graph.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { KIND, safeText } from './contract.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A Mermaid view over the provenance facts `euthyna history` already emits.
|
|
5
|
+
*
|
|
6
|
+
* It measures nothing. A commit subject is whatever an author wrote, and it
|
|
7
|
+
* lands inside a `"..."` label, so every label goes through the same control-byte
|
|
8
|
+
* escape the terminal boundary uses before it reaches the graph source.
|
|
9
|
+
*
|
|
10
|
+
* Line counts live on the edges: one commit can be attributed twice (the last
|
|
11
|
+
* modifier of some lines, the origin of others), and a Mermaid node keeps only
|
|
12
|
+
* the last label written for an id — a count in the node label would silently
|
|
13
|
+
* delete the other one.
|
|
14
|
+
*/
|
|
15
|
+
export function provenanceGraph(report) {
|
|
16
|
+
const { subject, facts } = report;
|
|
17
|
+
const short = (hash) => (hash ?? '').slice(0, 8);
|
|
18
|
+
// The id is the full hash: two commits that share a prefix are two commits,
|
|
19
|
+
// and a merged node would say otherwise without saying anything.
|
|
20
|
+
const id = (hash) => `C_${hash ?? ''}`;
|
|
21
|
+
// `&` first, so the entities added next are not doubled into nothing; then `#`,
|
|
22
|
+
// because Mermaid reads `#123;` and `#name;` inside a label as an entity.
|
|
23
|
+
const escapeLabel = (text) =>
|
|
24
|
+
text.replace(/&/g, '&').replace(/#/g, '#35;').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
25
|
+
|
|
26
|
+
// `detail.commitSubject` belongs to `evidence.commit`, which is the origin
|
|
27
|
+
// commit on a pickaxe fact and the owning commit on a blame fact. Only
|
|
28
|
+
// deleted-line attribution counts: `audit` merges dependency facts in here too.
|
|
29
|
+
const subjects = new Map();
|
|
30
|
+
for (const fact of facts) {
|
|
31
|
+
if (fact.kind === KIND.HISTORY && fact.detail?.commitSubject) subjects.set(fact.evidence?.commit, fact.detail.commitSubject);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const declared = new Set(['R']);
|
|
35
|
+
const node = (hash, byFile) => {
|
|
36
|
+
const key = id(hash);
|
|
37
|
+
if (declared.has(key)) return key;
|
|
38
|
+
declared.add(key);
|
|
39
|
+
const text = subjects.get(hash) ?? '';
|
|
40
|
+
const rest = byFile.length > 1 ? ` +${byFile.length - 1}` : '';
|
|
41
|
+
const where = byFile.length ? ` ${byFile[0].file}${rest}` : '';
|
|
42
|
+
// One pass over the whole label: the path is as much the repository's own
|
|
43
|
+
// text as the commit subject is, and it may contain `"` or a control byte.
|
|
44
|
+
return `${key}["${escapeLabel(safeText(`${short(hash)}${where}${text ? ` ${text}` : ''}`))}"]`;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const range = `${subject.base ?? '?'}..${subject.head ?? 'HEAD'}`;
|
|
48
|
+
const out = ['graph TD', ` R["${escapeLabel(safeText(range))}"]`];
|
|
49
|
+
|
|
50
|
+
for (const fact of facts) {
|
|
51
|
+
// A dependency fact has no commit: drawing it would emit a node with an empty
|
|
52
|
+
// id that reads as "a commit with no lines".
|
|
53
|
+
if (fact.kind !== KIND.HISTORY) continue;
|
|
54
|
+
const detail = fact.detail ?? {};
|
|
55
|
+
const origin = detail.originMethod === 'pickaxe' ? detail.originCommit : null;
|
|
56
|
+
// The deleted lines hang off whoever last touched them inside the audited
|
|
57
|
+
// range; the origin commit is where the content came from.
|
|
58
|
+
const blamed = origin ? detail.blameCommit : fact.evidence?.commit;
|
|
59
|
+
const byFile = detail.byFile ?? [];
|
|
60
|
+
const count = detail.blamedLines ?? 0;
|
|
61
|
+
|
|
62
|
+
out.push(` R -->|${count}| ${node(blamed, byFile)}`);
|
|
63
|
+
if (origin) out.push(` ${id(blamed)} -->|${count}| ${node(origin, byFile)}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Dotted, and named by kind: `nothing here was deleted` and `this could not be
|
|
67
|
+
// measured` look the same once the facts are gone.
|
|
68
|
+
for (const [n, item] of (report.coverage?.notEvaluated ?? []).entries()) {
|
|
69
|
+
out.push(` R -.-> U_${n}["${escapeLabel(safeText(`${item.kind}: ${item.reason}`))}"]`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return out.join('\n');
|
|
73
|
+
}
|