euthyna 0.6.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.
@@ -48,6 +48,11 @@
48
48
  **目标目录必须有** `package.json` / `pyproject.toml` / `requirements*.txt` / `go.mod` 之一。
49
49
  没有就**直接说没有并停止**,不要为一个采集器不解析的生态即兴审计。
50
50
 
51
+ **清单只按仓库根查找。**多包仓库里子包各自的 lock 不会被 `euthyna audit` 或 `euthyna deps --repo` 测到
52
+ (🧪 实测 2026-10-10:根 `package-lock.json` 锁 `root-a`,子包 `packages/app/package-lock.json` 锁 `sub-b`/`sub-c`,
53
+ 输出只有 `root-a`,**并且没有任何声明**)。要管子包就逐包跑 `euthyna deps --lockfile <路径>`;
54
+ 不跑就明说没测——别把根清单当成整个 monorepo 的事实。
55
+
51
56
  **能精确解析版本的**:`package-lock.json`、`npm-shrinkwrap.json`、`uv.lock`、Go 1.17+ 的 `go.mod`。
52
57
 
53
58
  **不能的**:`yarn.lock`、`pnpm-lock.yaml`、`poetry.lock`。
@@ -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
  ```
@@ -294,4 +341,9 @@ node <euthyna 仓库>/bin/euthyna.js gate <报告文件> --verify --cwd <被审
294
341
  - `coverage` **只能证伪**。见上文。
295
342
  - `coverage` 依赖调用计数,**不处理动态派发、反射、字符串调用**。
296
343
  计数为 0 的结论在存在这些机制时要打折扣。
344
+ - `audit` 的两个半边**读的不是同一个东西**:`history` 取 `--base`/`--head` 命名的提交,`deps` 取
345
+ `--repo` 所在**仓库根**当前检出的那份清单(清单只按仓库根查找)。所以判据是**内容**:测到的清单与 `--head` 那份不一致、或在那个提交里根本
346
+ 不存在(被 gitignore 的锁文件就是这种),「未评估的判据」里会多一条写明文件名与 `--head` 的理由;
347
+ `code`(`manifest-not-head` / `manifest-unverified`)只在 `--json` 通道上。**看到它就检出到范围的一端再重跑**
348
+ ——声明只保证你不静默踩坑,不会替你把事实测对。
297
349
  - 两者都**不产出分数、严重性、修复建议**。这是契约,不是遗漏。
@@ -48,6 +48,12 @@ Available discovery sources (use whichever is installed — **audit the source b
48
48
  **The target directory must have one of** `package.json` / `pyproject.toml` / `requirements*.txt` / `go.mod`.
49
49
  If none exists, **say so and stop** — do not improvise an audit for an ecosystem the collector does not parse.
50
50
 
51
+ **Manifests are located from the repository root only.** A multi-package repository's per-package locks are not measured by
52
+ `euthyna audit` or `euthyna deps --repo` (🧪 measured 2026-10-10: the root `package-lock.json` pins `root-a` while
53
+ `packages/app/package-lock.json` pins `sub-b` and `sub-c`, and the output reports only `root-a`, **with no declaration at
54
+ all**). Measuring a sub-package means running `euthyna deps --lockfile <path>` per package; if that is not done, say the
55
+ sub-packages were not measured — do not treat the root manifest's facts as facts about the whole monorepo.
56
+
51
57
  **Exactly resolvable**: `package-lock.json`, `npm-shrinkwrap.json`, `uv.lock`, `go.mod` for Go 1.17+.
52
58
 
53
59
  **Not resolvable**: `yarn.lock`, `pnpm-lock.yaml`, `poetry.lock`.
@@ -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
- **Three coverage formats are read** (auto-detected, no parameters needed):
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
  ```
@@ -280,4 +309,5 @@ that a conclusion without evidence is not handed over wearing the face of "estab
280
309
  - `history` only covers **deleted lines**. It does not handle newly added code (except with `--pickaxe`).
281
310
  - `coverage` **can only falsify**. See above.
282
311
  - `coverage` relies on invocation counts and **does not handle dynamic dispatch, reflection, or string-based calls**. Conclusions of count 0 must be discounted when such mechanisms exist.
312
+ - The two halves of `audit` **do not read the same thing**: `history` takes the commits named by `--base`/`--head`, while `deps` takes the manifest **currently checked out at the root of the repository** `--repo` points into (manifests are located from the root only). So the criterion is **content**: when the manifest measured differs from the one at `--head`, or does not exist in that commit at all (a gitignored lockfile is exactly that), *Criteria not evaluated* gains an entry naming the file and `--head`. The `code` (`manifest-not-head` / `manifest-unverified`) appears only on the `--json` channel. **Seeing one means checking out an end of the range and re-running** — the declaration keeps the trap from being silent; it does not measure the fact for you.
283
313
  - Neither **produces scores, severity, or fix suggestions**. That is the contract, not an omission.
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
 
@@ -31,14 +30,19 @@ When an AI coding agent touches security, it fails in two specific ways:
31
30
 
32
31
  1. **It reports things that are not real.** Code that *looks* dangerous gets called a
33
32
  vulnerability, without tracing the data. In validation runs on real codebases — a
34
- JavaScript one, a Python one, and a Go one — the pattern-matched "vulnerabilities" were
35
- mostly false: **5 out of 5** refuted at the gates on the first run; **2 out of 3**
36
- refuted, the third unresolved (INCONCLUSIVE, supply-chain-dependent) on the second;
37
- **4 out of 4** refuted on the third. See the
33
+ JavaScript one, a Python one, a Go one, and a Rust one — the pattern-matched
34
+ "vulnerabilities" were mostly false: **5 out of 5** refuted at the gates on the first run;
35
+ **2 out of 3** refuted, the third unresolved (INCONCLUSIVE, supply-chain-dependent) on the
36
+ second; **4 out of 4** refuted on the third. The fourth run is the one that tests the other
37
+ half: it faced a real vulnerability, and the gates let it through — **1 TRUE POSITIVE**
38
+ (fourth gate evidenced by a PoC run against both the fixed and the unfixed release), 3
39
+ refuted, 1 INCONCLUSIVE. A sieve that only ever says no would have refuted that one too. See
40
+ the
38
41
  [case studies](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-crewai.md)
39
42
  ([Python/crewAI](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-crewai.md),
40
43
  [JavaScript/axe-core](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-axe-core.md),
41
- [Go/nektos-act](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-act.md)).
44
+ [Go/nektos-act](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-act.md),
45
+ [Rust/rustls](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-rust.md)).
42
46
  2. **Its reassurances cannot be checked.** "I'm done." "The tests cover this." "It's
43
47
  safe now." These are assertions. You cannot tell a done-claim from a done-deal.
44
48
 
@@ -187,7 +191,7 @@ From a checkout instead (development):
187
191
 
188
192
  ```sh
189
193
  git clone https://github.com/slow-stack/euthyna
190
- cd euthyna && npm test # 207 tests; no install step exists
194
+ cd euthyna && npm test # the whole suite; no install step exists
191
195
  node bin/euthyna.js audit --repo <path> --base main --head HEAD
192
196
  ```
193
197
 
@@ -241,19 +245,31 @@ This project tries to be explicit about the difference. Current state:
241
245
 
242
246
  ### Verified
243
247
 
244
- - **`history` attribution against real repositories — in two languages.** Run against
248
+ - **`history` attribution against real repositories — in four languages.** Run against
245
249
  [axe-core](https://github.com/dequelabs/axe-core) (JavaScript) and
246
250
  [crewAI](https://github.com/crewAIInc/crewAI) (Python); deleted lines attributed to the
247
251
  commits that introduced them, then checked **by hand** against `git blame`. The checks
248
- developed for that comparison now run as regression tests in the suite.
249
- - **`coverage` on real output in three formats** — c8/V8 JSON, classic istanbul (jest/nyc, same
250
- fnMap/f shape) and coverage.py JSON (format 3) — distinguishing all three states correctly, and
251
- refusing anything that is not a recognizable coverage report instead of answering "symbol not
252
- located" against it.
253
- - **`deps` against a real lockfile** — resolved versions reported with a line-level evidence
252
+ developed for that comparison now run as regression tests in the suite. The producer ran
253
+ unchanged on a Go repository ([nektos/act](https://github.com/nektos/act), where the deleted
254
+ lines of a security fix were traced back to the commit that introduced them) and on a Rust one
255
+ ([rustls](https://github.com/rustls/rustls), whose fix range yielded 43 provenance facts).
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`).
268
+ - **`deps` against real manifests** — resolved versions reported with a line-level evidence
254
269
  pointer into the lockfile, and absent dependencies reported as established absences rather
255
- than silent skips. Verified against a populated npm v3 lockfile and fixture lockfiles for
256
- Cargo.lock and go.mod.
270
+ than silent skips. Verified against a populated npm v3 lockfile, a real `go.mod` (the *declared*
271
+ requirement, not a resolved build graph) and a real `Cargo.lock` (366 pinned-version facts on
272
+ the rustls run), plus fixture lockfiles for npm v1/v2/v3, Cargo.lock and go.mod in the suite.
257
273
  - **Adjudication recall and specificity**, measured blind: **10/10 cases**, 4 real
258
274
  vulnerabilities all caught, 6 non-vulnerabilities all correctly cleared, no abstentions.
259
275
  Round 2 repeated every case three times — **30 adjudications, zero flips**, four of them
@@ -290,9 +306,12 @@ This project tries to be explicit about the difference. Current state:
290
306
  discipline reaches the right verdict *on a claim*. It does not measure whether the claims would
291
307
  be found in the first place. The cases are deliberately constructed. The crewAI case study
292
308
  surfaced one INCONCLUSIVE (pickle deserialization, supply-chain-dependent) and refuted the
293
- rest — a directional signal, not a rate.
294
- - **Recall in the field.** The benchmark's real-bug cases are constructed; whether the
295
- discipline helps on code nobody staged for it is unmeasured.
309
+ rest — a directional signal, not a rate. The rustls run reached a TRUE POSITIVE on a real
310
+ flaw, but the range was chosen *from* the published advisory, so it shows the gates let a
311
+ genuine finding through; it does not show the coarse screen would have found it unaided.
312
+ - **Recall in the field.** The benchmark's real-bug cases are constructed, and the real-repository
313
+ runs so far aimed at ranges already known to contain a fix. Whether the discipline helps on code
314
+ nobody staged for it is unmeasured.
296
315
  - **Source maps, bundlers, monorepos** for the coverage producer. Untested.
297
316
  - **A real model round on CI.** The golden round is deterministic plumbing, not an
298
317
  adjudication: it writes the ground truth into the reports by design. A model round needs
@@ -304,11 +323,15 @@ This project tries to be explicit about the difference. Current state:
304
323
  detected but not parsed (reported as *not evaluated*, never guessed at); go.mod reports the
305
324
  *declared* requirement, not the resolved build version; and the producer never maps a version
306
325
  to a CVE — that mapping is deliberately left to the adjudication layer.
307
- - **Anything about the case-study targets' security** — the runs found nothing to endorse or
308
- condemn; that is not a statement about any of the projects. See
326
+ - **Anything about the case-study targets' security.** These are method runs, not assessments.
327
+ Three of them found nothing to report. The fourth confirmed a real flaw in rustls 0.23.44, but
328
+ one already published and already fixed upstream (RUSTSEC-2026-0285 / GHSA-2mjx-qc3c-rqvc) which
329
+ the run re-verified rather than discovered, so none of them is a statement about any project's
330
+ security posture. See
309
331
  [`docs/case-study-crewai.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-crewai.md),
310
332
  [`docs/case-study-axe-core.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-axe-core.md),
311
- and [`docs/case-study-act.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-act.md).
333
+ [`docs/case-study-act.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-act.md),
334
+ and [`docs/case-study-rust.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-rust.md).
312
335
  - **A CI recipe, by running it on GitHub.** The exit-code gate and the workflow wiring are
313
336
  documented in [`docs/ci-integration.md`](https://github.com/slow-stack/euthyna/blob/main/docs/ci-integration.md);
314
337
  the workflow snippet has not been exercised as a live Actions run.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euthyna",
3
- "version": "0.6.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
@@ -1,5 +1,5 @@
1
1
  name: euthyna
2
- version: 0.6.0
2
+ version: 0.8.0
3
3
  description: euthyna audit discipline — slash commands that drive the euthyna fact-producing CLI (history / coverage / deps / gate)
4
4
  license: Apache-2.0
5
5
  homepage: https://github.com/slow-stack/euthyna
package/src/cli.js CHANGED
@@ -21,7 +21,7 @@ import { collectCoverageFacts } from './facts/coverage.js';
21
21
  import { collectDependencyFacts, listDependencies, detectManifest, manifestTypeForFile } from './facts/deps.js';
22
22
  import { parseGateReport, validateFindings, verifyFinding, renderGateReport } from './gate.js';
23
23
  import { makeReport, renderReport, safeTextLines, KIND } from './contract.js';
24
- import { repoToplevel, revParse } from './git.js';
24
+ import { repoToplevel, revParse, git } from './git.js';
25
25
  import { resolveLang, T, isLang, DEFAULT_LANG } from './lang.js';
26
26
 
27
27
  export const EXIT = Object.freeze({
@@ -89,11 +89,12 @@ euthyna —— 给 AI 编码 agent 用的确定性事实产出器
89
89
  --no-origins 关闭来源追溯;默认用 git log -S 把删除行归属到「最初引入」
90
90
  该内容的提交(有探针上限,比纯 blame 慢)
91
91
  coverage 某个符号在测试运行中到底有没有被调用过
92
- --coverage 覆盖率数据文件,c8 的 coverage-final.json
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, c8's coverage-final.json
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)
@@ -409,6 +412,50 @@ async function runDeps(flags, lang) {
409
412
  * symbol names only the adjudicator knows, so there is nothing honest to
410
413
  * auto-measure there.
411
414
  */
415
+ /**
416
+ * Decide whether the manifest `audit` measured is the manifest --head holds.
417
+ *
418
+ * Pure, so that the fail-closed branch — a probe that could not run — is reachable
419
+ * from a test. Nothing else here can make git fail on demand.
420
+ */
421
+ export function manifestVersionGap({ probeFailed, existsAtHead, differsFromHead, head, worktree, manifest }, t) {
422
+ const short = (sha) => (sha ?? '').slice(0, 10);
423
+ const where = worktree && worktree !== head ? `(工作区检出在 ${short(worktree)})` : '';
424
+ const whereEn = worktree && worktree !== head ? ` (the worktree is at ${short(worktree)})` : '';
425
+
426
+ if (probeFailed) {
427
+ return {
428
+ kind: KIND.DEPENDENCY,
429
+ code: 'manifest-unverified',
430
+ reason: t(
431
+ `无法确认依赖清单 ${manifest} 与 --head ${short(head)} 的关系:git 探测失败,这不是匹配的证据`,
432
+ `the relationship between the manifest ${manifest} and --head ${short(head)} could not be probed: git failed, which is not evidence of a match`
433
+ )
434
+ };
435
+ }
436
+ if (!existsAtHead) {
437
+ return {
438
+ kind: KIND.DEPENDENCY,
439
+ code: 'manifest-not-head',
440
+ reason: t(
441
+ `测到的依赖清单 ${manifest} 在 --head ${short(head)} 里不存在,它不属于任何被审的提交${where}`,
442
+ `the dependency manifest ${manifest} measured here does not exist at --head ${short(head)}; it belongs to no reviewed commit${whereEn}`
443
+ )
444
+ };
445
+ }
446
+ if (differsFromHead) {
447
+ return {
448
+ kind: KIND.DEPENDENCY,
449
+ code: 'manifest-not-head',
450
+ reason: t(
451
+ `测到的依赖清单 ${manifest} 与 --head ${short(head)} 那份内容不一致${where},这些事实不是该提交的声明版本`,
452
+ `the dependency manifest ${manifest} measured here differs in content from the one at --head ${short(head)}${whereEn}, so these facts are not that commit's declared versions`
453
+ )
454
+ };
455
+ }
456
+ return null;
457
+ }
458
+
412
459
  async function runAudit(flags, lang) {
413
460
  const t = T(lang);
414
461
  const cwd = path.resolve(flags.repo ? String(flags.repo) : process.cwd());
@@ -476,6 +523,38 @@ async function runAudit(flags, lang) {
476
523
  depNotEvaluated.push({ kind: KIND.DEPENDENCY, reason: depResult.error });
477
524
  }
478
525
 
526
+ // history takes the commits named by --base/--head; deps takes the manifest
527
+ // checked out in --repo. The honest question is therefore about CONTENT: is the
528
+ // file that was measured the file --head holds? Comparing commit shas instead was
529
+ // measured to be wrong in both directions — it fired on a PR merge commit whose
530
+ // lockfile is byte-identical to --head's (which, per fact-contract rule 1, forces
531
+ // every finding citing a dependency fact to not_evaluated), and stayed silent on a
532
+ // gitignored lockfile that belongs to no commit at all.
533
+ const manifest = typeof depResult.report?.subject?.lockfile === 'string'
534
+ ? depResult.report.subject.lockfile
535
+ : undefined;
536
+ if (manifest) {
537
+ const probe = { probeFailed: false, existsAtHead: false, differsFromHead: true };
538
+ if (path.isAbsolute(manifest)) {
539
+ const rel = path.relative(toplevel, manifest).split(path.sep).join('/');
540
+ try {
541
+ probe.existsAtHead = (await git(['ls-tree', head, '--', rel], { cwd: toplevel })).trim() !== '';
542
+ probe.differsFromHead = !probe.existsAtHead ||
543
+ (await git(['diff', '--name-only', head, '--', rel], { cwd: toplevel })).trim() !== '';
544
+ } catch {
545
+ // A probe that could not run is not evidence of a match.
546
+ probe.probeFailed = true;
547
+ }
548
+ } else {
549
+ probe.probeFailed = true;
550
+ }
551
+ const versionGap = manifestVersionGap(
552
+ { ...probe, head, worktree: await revParse(toplevel, 'HEAD'), manifest: path.basename(manifest) },
553
+ t
554
+ );
555
+ if (versionGap) depNotEvaluated.push(versionGap);
556
+ }
557
+
479
558
  const facts = [...history.facts, ...depFacts];
480
559
  const evaluated = [...history.evaluated, ...depEvaluated];
481
560
  const notEvaluated = [...history.notEvaluated, ...depNotEvaluated];
@@ -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(