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.
@@ -63,7 +63,7 @@ function reproduceCommand({ coverageFile, symbol, file, source }) {
63
63
  /**
64
64
  * Read a coverage report.
65
65
  *
66
- * Three formats are accepted, all identified by shape rather than by filename:
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, '&amp;').replace(/#/g, '#35;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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
+ }