euthyna 0.7.0 → 0.8.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.
|
@@ -149,13 +149,15 @@ node <euthyna 仓库>/bin/euthyna.js coverage `
|
|
|
149
149
|
`--symbol` 可重复,一次查多个。加 `--file` 可限定到某个文件。Go profile 另需 `--source`
|
|
150
150
|
(见下)。
|
|
151
151
|
|
|
152
|
-
|
|
152
|
+
**五种覆盖率格式都读**(自动识别,无需参数):
|
|
153
153
|
|
|
154
154
|
| 格式 | 来源 | 说明 |
|
|
155
155
|
|---|---|---|
|
|
156
156
|
| c8 / v8-to-istanbul `coverage-final.json` | JS 项目(c8 默认落在 `coverage/coverage-final.json`) | 每文件有 `fnMap` + `f`(调用计数数组) |
|
|
157
|
+
| classic istanbul `coverage-final.json` | jest 默认 provider、nyc | 同样的 `fnMap` + `f`,另多一个 `hash` 字段(c8 没有 `hash`),两者走同一条代码路径 |
|
|
157
158
|
| coverage.py JSON(format 3) | Python 项目(`coverage json` 产出) | 顶层 `{meta, files}`;`functions` 里「函数名 → 已执行行列表」,**无调用计数**。从未调用的函数仍在列表中且 `executed_lines` 为空 |
|
|
158
159
|
| Go `-coverprofile`(文本) | Go 项目(`go test -coverprofile` 产出) | `mode:` 头 + 每区块一行 `路径:起始行.列,结束行.列 语句数 命中数`,**无函数名**。必须加 `--source <模块根目录>`:产出器读源码里的顶层 `func` 把行区间映射回函数 |
|
|
160
|
+
| lcov tracefile(文本) | Rust 项目(cargo-llvm-cov / cargo-tarpaulin 产出),gcov / llvm-cov 也产这个格式 | `SF:` 开记录、`end_of_record` 收尾,`FN:<行>,<符号>` 与 `FNDA:<计数>,<符号>` 给出函数名与调用计数(**两段分开出现,不是相邻配对**);**符号名是 mangled 的**,产出器按 Rust v0 mangling 的 `<十进制长度><名字>` 尾段锚定匹配你写的 `模块::函数` 路径,且绝不落在更长的 `<长度><名字>` 对内——查 `get` 不会答到 `cfg_3get` |
|
|
159
161
|
|
|
160
162
|
Go 的产出命令(Go 目标):
|
|
161
163
|
```bash
|
|
@@ -189,6 +191,51 @@ uv run --with coverage python -m coverage json -o <输出路径>.json
|
|
|
189
191
|
覆盖率文件从哪来:项目自己产出就用它。项目不产出覆盖率时,**要么用项目自己的方式跑一次
|
|
190
192
|
测试产出它,要么把该判据标为「不可评估」**。
|
|
191
193
|
|
|
194
|
+
lcov 的产出命令(Rust 目标,本机实测):
|
|
195
|
+
|
|
196
|
+
```powershell
|
|
197
|
+
# 0. llvm-cov / llvm-profdata 不在 PATH 上,先定位 rustc 自带的那一份
|
|
198
|
+
$bin = (rustc --print sysroot) + "\lib\rustlib\x86_64-pc-windows-msvc\bin"
|
|
199
|
+
# 1. 插桩构建 + 跑测试
|
|
200
|
+
$env:RUSTFLAGS = "-C instrument-coverage"
|
|
201
|
+
$env:LLVM_PROFILE_FILE = "<绝对路径>\prof\probe-%p-%m.profraw"
|
|
202
|
+
cargo test
|
|
203
|
+
# 2. 合并 profraw,再从测试二进制导出 lcov
|
|
204
|
+
& "$bin\llvm-profdata.exe" merge -sparse <绝对路径>\prof\*.profraw -o <绝对路径>\probe.profdata
|
|
205
|
+
& "$bin\llvm-cov.exe" export --format=lcov --instr-profile=<绝对路径>\probe.profdata `
|
|
206
|
+
"<target\debug\deps\<crate>-<hash>.exe>" --sources src > <绝对路径>\lcov.info
|
|
207
|
+
# 3. 查询(lcov 不需要 --source:函数名就在 FN: 行里)
|
|
208
|
+
node <euthyna 仓库>/bin/euthyna.js coverage `
|
|
209
|
+
--coverage <绝对路径>\lcov.info --symbol <模块::函数> --file <绝对路径>\src\lib.rs
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
> lcov 专属注意事项(在探针 crate 上实测):
|
|
213
|
+
> 1. **符号名是 mangled 的,本产出器不做 demangle**(本机没有可用的 demangler:
|
|
214
|
+
> `llvm-cov --Xdemangler` 试过 `rust` / `rustfilt` / `llvm-cxxfilt` / `c++filt`
|
|
215
|
+
> 四个名字全部报 `could not find the demangler`)。匹配靠 **Rust v0 mangling**
|
|
216
|
+
> (`-C instrument-coverage` 蕴含的版本)的 `<十进制长度><名字>` 尾段编码,
|
|
217
|
+
> 所以查询写 `模块::函数`(`split('::')` 后逐段匹配)。每段的名字必须从段边界起算——
|
|
218
|
+
> 名字开头、mangling 自己的 `_` 分隔符之后、或已证明的 pair 边界——且绝不落在更长的
|
|
219
|
+
> pair 内部:查 `get` 不会答到 `cfg_3get`。带限定的查询是子序列判定而非路径精确判定。
|
|
220
|
+
> `nested::dup` 只命中 `6nested3dup`,裸 `dup` 会同时命中外层模块里的同名函数——
|
|
221
|
+
> 每个命中各自报一条事实并带上自己的 mangled 名。已经 mangled 的查询(`_R`/`_Z` 开头)
|
|
222
|
+
> 只做精确匹配,**绝不半解码**。
|
|
223
|
+
> 2. **同一个文件可能有多条 `FN:` 指向同一行**(泛型单态化、闭包),这不是重复记录。
|
|
224
|
+
> 一个泛型函数实例化两次就是两条事实,各自带自己的 mangled 名。
|
|
225
|
+
> 3. `FN:` 行存在而 `FNDA:` 缺失时报「未评估」,**不默认成 0**——凭空造出「从未调用」
|
|
226
|
+
> 是这个产出器最不能犯的方向。
|
|
227
|
+
> 4. 同一 `SF:` 出现多条记录(或同名多条 `FNDA:`)时**计数求和**;求和不会把非零变零,
|
|
228
|
+
> 对唯一可行动的结论(计数为 0)安全。
|
|
229
|
+
> 5. 文件不在 tracefile 里报**「未评估」**(llvm-cov / gcov 只为被插桩的目标产记录,
|
|
230
|
+
> `--sources` 还会再过滤一层)——这与 c8 的「文件不在数据里 = 从未执行」刻意相反。
|
|
231
|
+
> 6. 认较新的 `FNL:`/`FNA:` 编码时会**明确拒绝**而不是按猜的形状读;`SF:` 路径是绝对
|
|
232
|
+
> 路径(Windows 上是带反斜杠的)。
|
|
233
|
+
> 7. 规范允许的三种写法照读而不是拒掉:**注释记录**(顶格 `#`)可以在任何位置,而
|
|
234
|
+
> `geninfo --comment` **默认**在顶部写一条;**`KF:` 是 `SF:` 的同义词**;
|
|
235
|
+
> **`FN:<起>,<止>,<名>` 是 gcc 9+ 的形状**,止行丢弃但必须消费掉——否则 `14,` 会粘进
|
|
236
|
+
> 符号名,把一个函数拆成「有行号无计数」和「有计数无行号」两条记录,前者随即误报未评估。
|
|
237
|
+
> 缩进的 `#` 仍被拒绝。
|
|
238
|
+
|
|
192
239
|
### 输出长什么样(真实捕获,一次查询命中两种结果)
|
|
193
240
|
|
|
194
241
|
```
|
|
@@ -137,13 +137,15 @@ node <euthyna repo>/bin/euthyna.js coverage `
|
|
|
137
137
|
`--symbol` is repeatable — query several at once. Add `--file` to restrict to a single file.
|
|
138
138
|
Go profiles additionally need `--source` (see below).
|
|
139
139
|
|
|
140
|
-
**
|
|
140
|
+
**Five coverage formats are read** (auto-detected, no parameters needed):
|
|
141
141
|
|
|
142
142
|
| Format | Source | Notes |
|
|
143
143
|
|---|---|---|
|
|
144
144
|
| c8 / v8-to-istanbul `coverage-final.json` | JS projects (c8 lands at `coverage/coverage-final.json` by default) | Per-file `fnMap` + `f` (invocation-count array) |
|
|
145
|
+
| classic istanbul `coverage-final.json` | jest's default provider, nyc | The same `fnMap` + `f` plus a `hash` field (c8 has no `hash`); both resolve through the same code path |
|
|
145
146
|
| coverage.py JSON (format 3) | Python projects (produced by `coverage json`) | Top-level `{meta, files}`; in `functions`, "function name → list of executed lines", **no invocation counts**. Functions never called are still in the list with empty `executed_lines` |
|
|
146
147
|
| Go `-coverprofile` (text) | Go projects (produced by `go test -coverprofile`) | A `mode:` header plus one line per block (`path:startL.startC,endL.endC stmts count`), **no function names**. `--source <module root>` is required: the producer reads top-level `func` declarations in the source to map line ranges back to functions |
|
|
148
|
+
| lcov tracefile (text) | Rust projects (produced by cargo-llvm-cov / cargo-tarpaulin); gcov / llvm-cov produce the format too | `SF:` opens a record, `end_of_record` closes it; `FN:<line>,<symbol>` and `FNDA:<count>,<symbol>` give a function name and its invocation count (**two separate runs, not adjacent pairs**); **the symbol names are mangled**, and the producer anchors the `module::function` path you write against the `<decimal length><name>` tail segments of Rust v0 mangling, never inside a longer `<length><name>` pair — a query for `get` does not answer for `cfg_3get` |
|
|
147
149
|
|
|
148
150
|
The Go production commands (Go targets):
|
|
149
151
|
|
|
@@ -180,6 +182,33 @@ uv run --with coverage python -m coverage json -o <output path>.json
|
|
|
180
182
|
|
|
181
183
|
Where the coverage file comes from: if the project produces one, use it. When the project does not, **either run its tests once with the project's own tooling to produce it, or mark the criterion as "not evaluable"**.
|
|
182
184
|
|
|
185
|
+
The lcov production commands (Rust targets, measured on this machine):
|
|
186
|
+
|
|
187
|
+
```powershell
|
|
188
|
+
# 0. llvm-cov / llvm-profdata are not on PATH; locate rustc's own copies first
|
|
189
|
+
$bin = (rustc --print sysroot) + "\lib\rustlib\x86_64-pc-windows-msvc\bin"
|
|
190
|
+
# 1. instrumented build + run the tests
|
|
191
|
+
$env:RUSTFLAGS = "-C instrument-coverage"
|
|
192
|
+
$env:LLVM_PROFILE_FILE = "<absolute path>\prof\probe-%p-%m.profraw"
|
|
193
|
+
cargo test
|
|
194
|
+
# 2. merge the profraw files, then export lcov from the test binary
|
|
195
|
+
& "$bin\llvm-profdata.exe" merge -sparse <absolute path>\prof\*.profraw -o <absolute path>\probe.profdata
|
|
196
|
+
& "$bin\llvm-cov.exe" export --format=lcov --instr-profile=<absolute path>\probe.profdata `
|
|
197
|
+
"<target\debug\deps\<crate>-<hash>.exe>" --sources src > <absolute path>\lcov.info
|
|
198
|
+
# 3. query (lcov needs no --source: the function names are in the FN: lines)
|
|
199
|
+
node <euthyna repo>/bin/euthyna.js coverage `
|
|
200
|
+
--coverage <absolute path>\lcov.info --symbol <module::function> --file <absolute path>\src\lib.rs
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
> lcov-specific caveats (measured on a probe crate):
|
|
204
|
+
> 1. **The symbol names are mangled and this producer does not demangle them** (no usable demangler on this machine: `llvm-cov --Xdemangler` was tried with `rust` / `rustfilt` / `llvm-cxxfilt` / `c++filt`, and all four report `could not find the demangler`). Matching relies on the `<decimal length><name>` tail segments of **Rust v0 mangling** (what `-C instrument-coverage` implies), so write the query as `module::function` (split on `::`, matched per segment). Each segment's name must start at a boundary — the start of the name, right after the mangling's own `_` separator, or at a proven pair boundary — and never strictly inside a longer pair: a query for `get` does not answer for `cfg_3get`. A qualified query is a subsequence test, not a path-exact one. `nested::dup` matches only `6nested3dup`, while a bare `dup` also matches the same-named function in an outer module — each hit is reported as its own fact carrying its own mangled name. An already-mangled query (starting with `_R`/`_Z`) is matched exactly and **never half-decoded**.
|
|
205
|
+
> 2. **One file may carry several `FN:` lines pointing at the same line** (generic monomorphisation, closures). That is not a duplicate record. A generic function instantiated twice yields two facts, each carrying its own mangled name.
|
|
206
|
+
> 3. When an `FN:` line exists and its `FNDA:` is missing, the result is "not evaluated", **never a default of 0** — inventing a "never invoked" out of thin air is the one direction this producer must not fail in.
|
|
207
|
+
> 4. When one `SF:` appears in several records (or several same-named `FNDA:` lines exist), the counts are **summed**; summing can never turn a non-zero into zero, which is what keeps the only actionable conclusion (a count of 0) safe.
|
|
208
|
+
> 5. A file absent from the tracefile is reported as **"not evaluated"** (llvm-cov / gcov only emit records for instrumented targets, and `--sources` filters further) — deliberately the opposite of c8's "file absent from the data = never executed".
|
|
209
|
+
> 6. The newer `FNL:`/`FNA:` encoding is **explicitly refused** rather than read out of a guessed shape; `SF:` paths are absolute (backslash-separated on Windows).
|
|
210
|
+
> 7. Three spellings the specification allows are read rather than refused: **comment records** (`#` in column 0) may appear anywhere, and `geninfo --comment` writes one at the top by default; **`KF:` is a synonym for `SF:`**; and **`FN:<start>,<end>,<name>`** is the shape gcc 9+ emits — the end line is discarded but must be *consumed*, or `14,` sticks to the symbol name and splits one function into a record with a line and no count plus one with a count and no line, the first of which then reports a spurious not-evaluated. An indented `#` is still refused.
|
|
211
|
+
|
|
183
212
|
### What the output looks like (real capture; one query hitting both result kinds)
|
|
184
213
|
|
|
185
214
|
```
|
package/README.md
CHANGED
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
<a href="https://www.npmjs.com/package/euthyna"><img src="https://img.shields.io/npm/dm/euthyna?style=flat&color=007EC6" alt="npm downloads"></a>
|
|
15
15
|
<a href="https://www.npmjs.com/package/euthyna"><img src="https://img.shields.io/npm/l/euthyna?style=flat&color=007EC6" alt="license: Apache-2.0"></a>
|
|
16
16
|
<a href="https://search.sigstore.dev/?logIndex=2915999059"><img src="https://img.shields.io/badge/npm%20provenance-verified-007EC6?style=flat" alt="npm provenance verified"></a>
|
|
17
|
-
<a href="https://www.skills.sh/slow-stack/euthyna"><img src="https://skills.sh/b/slow-stack/euthyna.svg" alt="Agent skills installs"></a>
|
|
18
17
|
<a href="https://awesome-dsh-plugin.com/p/slow-stack/euthyna/"><img src="https://img.shields.io/badge/DSH%20plugin-listed-blue" alt="Listed in the awesome-dsh-plugin catalog"></a>
|
|
19
18
|
</p>
|
|
20
19
|
|
|
@@ -254,10 +253,18 @@ This project tries to be explicit about the difference. Current state:
|
|
|
254
253
|
unchanged on a Go repository ([nektos/act](https://github.com/nektos/act), where the deleted
|
|
255
254
|
lines of a security fix were traced back to the commit that introduced them) and on a Rust one
|
|
256
255
|
([rustls](https://github.com/rustls/rustls), whose fix range yielded 43 provenance facts).
|
|
257
|
-
- **`coverage` on real output in
|
|
258
|
-
fnMap/f shape)
|
|
259
|
-
|
|
260
|
-
|
|
256
|
+
- **`coverage` on real output in five formats** — c8/V8 JSON, classic istanbul (jest/nyc, same
|
|
257
|
+
fnMap/f shape), coverage.py JSON (format 3), Go's text `-coverprofile`, and lcov tracefiles
|
|
258
|
+
(`llvm-cov export --format=lcov`, which is how cargo-llvm-cov and cargo-tarpaulin emit Rust
|
|
259
|
+
coverage) — distinguishing all three
|
|
260
|
+
states correctly, and refusing anything that is not a recognizable coverage report instead of
|
|
261
|
+
answering "symbol not located" against it. The lcov path was measured against a probe crate
|
|
262
|
+
built with `-C instrument-coverage`: an uncalled function reports a count of 0, a called one
|
|
263
|
+
reports a count above it, and inlining was measured **not** to flatten a called function to 0.
|
|
264
|
+
What it cannot do is demangle — this machine has no working demangler, so a query is matched
|
|
265
|
+
against the `<byte length><name>` tail segments of the mangled name; a bare `dup` matches
|
|
266
|
+
every same-named function in the file, each reported separately, and a query never answers for
|
|
267
|
+
a function that merely contains the queried name (`get` does not match `cfg_3get`).
|
|
261
268
|
- **`deps` against real manifests** — resolved versions reported with a line-level evidence
|
|
262
269
|
pointer into the lockfile, and absent dependencies reported as established absences rather
|
|
263
270
|
than silent skips. Verified against a populated npm v3 lockfile, a real `go.mod` (the *declared*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "euthyna",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Deterministic fact producers for security auditing: which deleted code came from a security fix, and which changed symbols no test ever executed.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./plugin/index.js",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
}
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
|
-
"@deepseek-ai/dsh-skill-filesystem": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0"
|
|
60
|
+
"@deepseek-ai/dsh-skill-filesystem": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0 || >=0.1.7-0 <0.2.0 || >=0.2.0-0 <0.2.1-0 || >=0.2.1-0 <0.3.0-0"
|
|
61
61
|
},
|
|
62
62
|
"peerDependenciesMeta": {
|
|
63
63
|
"@deepseek-ai/dsh-skill-filesystem": {
|
package/plugin.yaml
CHANGED
package/src/cli.js
CHANGED
|
@@ -89,11 +89,12 @@ euthyna —— 给 AI 编码 agent 用的确定性事实产出器
|
|
|
89
89
|
--no-origins 关闭来源追溯;默认用 git log -S 把删除行归属到「最初引入」
|
|
90
90
|
该内容的提交(有探针上限,比纯 blame 慢)
|
|
91
91
|
coverage 某个符号在测试运行中到底有没有被调用过
|
|
92
|
-
--coverage
|
|
92
|
+
--coverage 覆盖率数据文件,按形状自动识别 c8 / istanbul、
|
|
93
|
+
coverage.py JSON、Go -coverprofile 文本、lcov tracefile
|
|
93
94
|
--symbol 要查询的符号名,可重复
|
|
94
95
|
--file 可选,限定到某个文件
|
|
95
96
|
--source 可选,Go profile(go test -coverprofile)必需:模块根目录,
|
|
96
|
-
用于把行区间映射回函数(profile
|
|
97
|
+
用于把行区间映射回函数(profile 不含函数名);其余格式不需要
|
|
97
98
|
deps 某个依赖在 lockfile 里被锁定/声明成什么版本(供应链声明的裁决依据)
|
|
98
99
|
--repo 可选,依赖清单所在目录(默认当前目录,自动检测)
|
|
99
100
|
--lockfile 可选,显式指定清单文件(支持 package-lock.json / Cargo.lock / go.mod)
|
|
@@ -146,12 +147,14 @@ Commands:
|
|
|
146
147
|
lines to the commit that FIRST introduced their content
|
|
147
148
|
(probe-capped, slower than plain blame)
|
|
148
149
|
coverage was this symbol ever actually invoked by a test run?
|
|
149
|
-
--coverage the coverage data file
|
|
150
|
+
--coverage the coverage data file; identified by shape as c8 / istanbul,
|
|
151
|
+
coverage.py JSON, a Go -coverprofile, or an lcov tracefile
|
|
150
152
|
--symbol symbol name(s) to query, repeatable
|
|
151
153
|
--file optional, restrict to one file
|
|
152
154
|
--source optional, required for Go profiles (go test -coverprofile):
|
|
153
155
|
the module root, used to map line ranges back to
|
|
154
|
-
functions (the profile carries no function names)
|
|
156
|
+
functions (the profile carries no function names); other
|
|
157
|
+
formats do not need it
|
|
155
158
|
deps what version does the lockfile pin or declare for a dependency? (the basis for supply-chain claims)
|
|
156
159
|
--repo optional, directory containing the manifest (default cwd, auto-detected)
|
|
157
160
|
--lockfile optional, explicit manifest path (package-lock.json / Cargo.lock / go.mod)
|
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(
|