euthyna 0.5.0 → 0.7.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`。
@@ -146,14 +146,31 @@ node <euthyna 仓库>/bin/euthyna.js coverage `
146
146
  --symbol <符号名> --symbol <符号名>
147
147
  ```
148
148
 
149
- `--symbol` 可重复,一次查多个。加 `--file` 可限定到某个文件。
149
+ `--symbol` 可重复,一次查多个。加 `--file` 可限定到某个文件。Go profile 另需 `--source`
150
+ (见下)。
150
151
 
151
- **两种覆盖率格式都读**(自动识别,无需参数):
152
+ **三种覆盖率格式都读**(自动识别,无需参数):
152
153
 
153
154
  | 格式 | 来源 | 说明 |
154
155
  |---|---|---|
155
156
  | c8 / v8-to-istanbul `coverage-final.json` | JS 项目(c8 默认落在 `coverage/coverage-final.json`) | 每文件有 `fnMap` + `f`(调用计数数组) |
156
157
  | coverage.py JSON(format 3) | Python 项目(`coverage json` 产出) | 顶层 `{meta, files}`;`functions` 里「函数名 → 已执行行列表」,**无调用计数**。从未调用的函数仍在列表中且 `executed_lines` 为空 |
158
+ | Go `-coverprofile`(文本) | Go 项目(`go test -coverprofile` 产出) | `mode:` 头 + 每区块一行 `路径:起始行.列,结束行.列 语句数 命中数`,**无函数名**。必须加 `--source <模块根目录>`:产出器读源码里的顶层 `func` 把行区间映射回函数 |
159
+
160
+ Go 的产出命令(Go 目标):
161
+ ```bash
162
+ go test ./包路径/ -coverprofile=<绝对路径>.out -run <测试名>
163
+ node <euthyna 仓库>/bin/euthyna.js coverage `
164
+ --coverage <绝对路径>.out --symbol <符号名> --source <Go 模块根目录>
165
+ ```
166
+
167
+ > Go 专属注意事项(在 act 案例研究中实测):
168
+ > 1. **文件不在 profile 里 ≠ 未执行**——该文件所在的包可能根本没被编译进测试二进制。
169
+ > 产出器把这种情况报为「未评估」,绝不当「未被覆盖」。要测到某个包,用
170
+ > `-coverpkg=<包>` 或直接运行对应包的测试。
171
+ > 2. profile 路径是模块导入路径(`module/pkg/file.go`);`--source` 指向含 go.mod 的
172
+ > 模块根,产出器据此把导入路径映射回磁盘文件。
173
+ > 3. `mode: set` 就够用——两态语义(调用过/从未调用)不需要 `count`/`atomic` 的精确计数。
157
174
 
158
175
  coverage.py 的产出命令(Python 目标):
159
176
  ```powershell
@@ -277,4 +294,9 @@ node <euthyna 仓库>/bin/euthyna.js gate <报告文件> --verify --cwd <被审
277
294
  - `coverage` **只能证伪**。见上文。
278
295
  - `coverage` 依赖调用计数,**不处理动态派发、反射、字符串调用**。
279
296
  计数为 0 的结论在存在这些机制时要打折扣。
297
+ - `audit` 的两个半边**读的不是同一个东西**:`history` 取 `--base`/`--head` 命名的提交,`deps` 取
298
+ `--repo` 所在**仓库根**当前检出的那份清单(清单只按仓库根查找)。所以判据是**内容**:测到的清单与 `--head` 那份不一致、或在那个提交里根本
299
+ 不存在(被 gitignore 的锁文件就是这种),「未评估的判据」里会多一条写明文件名与 `--head` 的理由;
300
+ `code`(`manifest-not-head` / `manifest-unverified`)只在 `--json` 通道上。**看到它就检出到范围的一端再重跑**
301
+ ——声明只保证你不静默踩坑,不会替你把事实测对。
280
302
  - 两者都**不产出分数、严重性、修复建议**。这是契约,不是遗漏。
@@ -291,3 +291,8 @@ Gate Review(被 P5 阻塞): 6 门禁评估 → 裁定
291
291
  2. TRUE POSITIVE 列表:每条附简要漏洞描述
292
292
  3. FALSE POSITIVE 列表:每条附简要拒绝理由
293
293
  4. INCONCLUSIVE 列表:**每条附缺什么、为什么缺**
294
+
295
+ 5. **可触发性不能当驳回的承担者。** 靠「这条路径走不到/不会被执行/是死代码」来排除一条断言时:
296
+ - coverage 的两个出口都承担不了这个论证。**ESTABLISHED(从未被调用,含「文件未出现在覆盖率数据里」这一出口)只记录「本次运行未执行」**——可以写进证据栏作佐证,但它证明不了「不可能触发」;**UNKNOWN「符号未定位」等于没拿到覆盖事实**。
297
+ - 没有覆盖事实时,支撑该论证的那道门禁记**未评估**,原因写 `coverage=not-measured`。未评估是这道门禁的**完成态**,不是跳过:其余门禁照常全部评完。此后按折叠走——任一其他门禁 FAIL, FALSE POSITIVE 由那道 FAIL 承担(其论证不得依赖可触发性);没有其他 FAIL,就是 INCONCLUSIVE。
298
+ - **因此依赖可触发性的 FALSE POSITIVE 只有一种合法形态**:另有一条不依赖可触发性的证伪在支撑它——数据流上根本不可能(参数硬编码、无攻击者可控输入),或 PoC 直接证否。覆盖率对这类论证既不增加也不减少,本条不适用。「这条路径没有触发」永远不等于「这条路径不可能触发」。
@@ -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`.
@@ -135,13 +135,33 @@ node <euthyna repo>/bin/euthyna.js coverage `
135
135
  ```
136
136
 
137
137
  `--symbol` is repeatable — query several at once. Add `--file` to restrict to a single file.
138
+ Go profiles additionally need `--source` (see below).
138
139
 
139
- **Both coverage formats are read** (auto-detected, no parameters needed):
140
+ **Three coverage formats are read** (auto-detected, no parameters needed):
140
141
 
141
142
  | Format | Source | Notes |
142
143
  |---|---|---|
143
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) |
144
145
  | 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
+ | 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 |
147
+
148
+ The Go production commands (Go targets):
149
+
150
+ ```bash
151
+ go test ./<package>/ -coverprofile=<absolute path>.out -run <test name>
152
+ node <euthyna repo>/bin/euthyna.js coverage `
153
+ --coverage <absolute path>.out --symbol <symbol> --source <Go module root>
154
+ ```
155
+
156
+ > Go-specific notes (measured in the act case study):
157
+ > 1. **A file absent from the profile is not "never executed"** — its package may not have
158
+ > been compiled into the test binary at all. The producer reports this as "not evaluated",
159
+ > never as "not covered". To measure a package, use `-coverpkg=<package>` or run that
160
+ > package's tests directly.
161
+ > 2. Profile paths are module import paths (`module/pkg/file.go`); point `--source` at the
162
+ > module root containing go.mod, and the producer maps import paths back to disk files.
163
+ > 3. `mode: set` is enough — the two-state semantics (invoked / never invoked) do not need
164
+ > the exact counts of `count`/`atomic`.
145
165
 
146
166
  The coverage.py production commands (Python targets):
147
167
 
@@ -260,4 +280,5 @@ that a conclusion without evidence is not handed over wearing the face of "estab
260
280
  - `history` only covers **deleted lines**. It does not handle newly added code (except with `--pickaxe`).
261
281
  - `coverage` **can only falsify**. See above.
262
282
  - `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.
283
+ - 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.
263
284
  - Neither **produces scores, severity, or fix suggestions**. That is the contract, not an omission.
@@ -289,3 +289,8 @@ When there are multiple suspected bugs:
289
289
  2. TRUE POSITIVE list: each with a brief vulnerability description
290
290
  3. FALSE POSITIVE list: each with a brief rejection reason
291
291
  4. INCONCLUSIVE list: **each with what is missing and why**
292
+
293
+ 5. **Triggerability cannot carry a dismissal.** When a claim is excluded because "this path cannot be reached / never runs / is dead code":
294
+ - Neither outlet of the coverage producer can carry that argument. **ESTABLISHED (never invoked — including the "file does not appear in the coverage data" outlet) records only "not exercised in this run"**: it may sit in the evidence column as corroboration, but it proves nothing about whether the path *can* fire; **the UNKNOWN "symbol not located" is the absence of a coverage fact**.
295
+ - When no coverage fact was obtained, the gate that would carry the argument is recorded **NOT EVALUATED** with the reason `coverage=not-measured`. Not-evaluated is that gate's **completed state**, not a skipped step: every other gate is still evaluated in full. The verdict then follows the folding — any other gate FAILs and the FALSE POSITIVE is carried by that gate (whose own argument must not rest on triggerability); with no other FAIL, the verdict is INCONCLUSIVE.
296
+ - **So a triggerability-based FALSE POSITIVE has exactly one legal form**: some refutation that does not rest on triggerability carries it — impossibility by data flow (hardcoded arguments, no attacker-controlled input), or a PoC that disproves it directly. Coverage adds nothing to and takes nothing from such an argument, and this rule does not apply to it. "This path did not fire" is never "this path cannot fire".
package/README.md CHANGED
@@ -31,12 +31,19 @@ When an AI coding agent touches security, it fails in two specific ways:
31
31
 
32
32
  1. **It reports things that are not real.** Code that *looks* dangerous gets called a
33
33
  vulnerability, without tracing the data. In validation runs on real codebases — a
34
- JavaScript one and a Python one — the pattern-matched "vulnerabilities" were mostly
35
- false: **5 out of 5** refuted at the gates on the first run; **2 out of 3** refuted,
36
- the third unresolved (INCONCLUSIVE, supply-chain-dependent) on the second. See the
34
+ JavaScript one, a Python one, a Go one, and a Rust one — the pattern-matched
35
+ "vulnerabilities" were mostly false: **5 out of 5** refuted at the gates on the first run;
36
+ **2 out of 3** refuted, the third unresolved (INCONCLUSIVE, supply-chain-dependent) on the
37
+ second; **4 out of 4** refuted on the third. The fourth run is the one that tests the other
38
+ half: it faced a real vulnerability, and the gates let it through — **1 TRUE POSITIVE**
39
+ (fourth gate evidenced by a PoC run against both the fixed and the unfixed release), 3
40
+ refuted, 1 INCONCLUSIVE. A sieve that only ever says no would have refuted that one too. See
41
+ the
37
42
  [case studies](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-crewai.md)
38
43
  ([Python/crewAI](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-crewai.md),
39
- [JavaScript/axe-core](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-axe-core.md)).
44
+ [JavaScript/axe-core](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-axe-core.md),
45
+ [Go/nektos-act](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-act.md),
46
+ [Rust/rustls](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-rust.md)).
40
47
  2. **Its reassurances cannot be checked.** "I'm done." "The tests cover this." "It's
41
48
  safe now." These are assertions. You cannot tell a done-claim from a done-deal.
42
49
 
@@ -185,7 +192,7 @@ From a checkout instead (development):
185
192
 
186
193
  ```sh
187
194
  git clone https://github.com/slow-stack/euthyna
188
- cd euthyna && npm test # 207 tests; no install step exists
195
+ cd euthyna && npm test # the whole suite; no install step exists
189
196
  node bin/euthyna.js audit --repo <path> --base main --head HEAD
190
197
  ```
191
198
 
@@ -239,19 +246,23 @@ This project tries to be explicit about the difference. Current state:
239
246
 
240
247
  ### Verified
241
248
 
242
- - **`history` attribution against real repositories — in two languages.** Run against
249
+ - **`history` attribution against real repositories — in four languages.** Run against
243
250
  [axe-core](https://github.com/dequelabs/axe-core) (JavaScript) and
244
251
  [crewAI](https://github.com/crewAIInc/crewAI) (Python); deleted lines attributed to the
245
252
  commits that introduced them, then checked **by hand** against `git blame`. The checks
246
- developed for that comparison now run as regression tests in the suite.
253
+ developed for that comparison now run as regression tests in the suite. The producer ran
254
+ unchanged on a Go repository ([nektos/act](https://github.com/nektos/act), where the deleted
255
+ lines of a security fix were traced back to the commit that introduced them) and on a Rust one
256
+ ([rustls](https://github.com/rustls/rustls), whose fix range yielded 43 provenance facts).
247
257
  - **`coverage` on real output in three formats** — c8/V8 JSON, classic istanbul (jest/nyc, same
248
258
  fnMap/f shape) and coverage.py JSON (format 3) — distinguishing all three states correctly, and
249
259
  refusing anything that is not a recognizable coverage report instead of answering "symbol not
250
260
  located" against it.
251
- - **`deps` against a real lockfile** — resolved versions reported with a line-level evidence
261
+ - **`deps` against real manifests** — resolved versions reported with a line-level evidence
252
262
  pointer into the lockfile, and absent dependencies reported as established absences rather
253
- than silent skips. Verified against a populated npm v3 lockfile and fixture lockfiles for
254
- Cargo.lock and go.mod.
263
+ than silent skips. Verified against a populated npm v3 lockfile, a real `go.mod` (the *declared*
264
+ requirement, not a resolved build graph) and a real `Cargo.lock` (366 pinned-version facts on
265
+ the rustls run), plus fixture lockfiles for npm v1/v2/v3, Cargo.lock and go.mod in the suite.
255
266
  - **Adjudication recall and specificity**, measured blind: **10/10 cases**, 4 real
256
267
  vulnerabilities all caught, 6 non-vulnerabilities all correctly cleared, no abstentions.
257
268
  Round 2 repeated every case three times — **30 adjudications, zero flips**, four of them
@@ -288,9 +299,12 @@ This project tries to be explicit about the difference. Current state:
288
299
  discipline reaches the right verdict *on a claim*. It does not measure whether the claims would
289
300
  be found in the first place. The cases are deliberately constructed. The crewAI case study
290
301
  surfaced one INCONCLUSIVE (pickle deserialization, supply-chain-dependent) and refuted the
291
- rest — a directional signal, not a rate.
292
- - **Recall in the field.** The benchmark's real-bug cases are constructed; whether the
293
- discipline helps on code nobody staged for it is unmeasured.
302
+ rest — a directional signal, not a rate. The rustls run reached a TRUE POSITIVE on a real
303
+ flaw, but the range was chosen *from* the published advisory, so it shows the gates let a
304
+ genuine finding through; it does not show the coarse screen would have found it unaided.
305
+ - **Recall in the field.** The benchmark's real-bug cases are constructed, and the real-repository
306
+ runs so far aimed at ranges already known to contain a fix. Whether the discipline helps on code
307
+ nobody staged for it is unmeasured.
294
308
  - **Source maps, bundlers, monorepos** for the coverage producer. Untested.
295
309
  - **A real model round on CI.** The golden round is deterministic plumbing, not an
296
310
  adjudication: it writes the ground truth into the reports by design. A model round needs
@@ -302,10 +316,18 @@ This project tries to be explicit about the difference. Current state:
302
316
  detected but not parsed (reported as *not evaluated*, never guessed at); go.mod reports the
303
317
  *declared* requirement, not the resolved build version; and the producer never maps a version
304
318
  to a CVE — that mapping is deliberately left to the adjudication layer.
305
- - **Anything about the case-study targets' security** — the runs found nothing to endorse or
306
- condemn; that is not a statement about either project. See
307
- [`docs/case-study-crewai.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-crewai.md) and
308
- [`docs/case-study-axe-core.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-axe-core.md).
319
+ - **Anything about the case-study targets' security.** These are method runs, not assessments.
320
+ Three of them found nothing to report. The fourth confirmed a real flaw in rustls 0.23.44, but
321
+ one already published and already fixed upstream (RUSTSEC-2026-0285 / GHSA-2mjx-qc3c-rqvc) which
322
+ the run re-verified rather than discovered, so none of them is a statement about any project's
323
+ security posture. See
324
+ [`docs/case-study-crewai.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-crewai.md),
325
+ [`docs/case-study-axe-core.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-axe-core.md),
326
+ [`docs/case-study-act.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-act.md),
327
+ and [`docs/case-study-rust.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-rust.md).
328
+ - **A CI recipe, by running it on GitHub.** The exit-code gate and the workflow wiring are
329
+ documented in [`docs/ci-integration.md`](https://github.com/slow-stack/euthyna/blob/main/docs/ci-integration.md);
330
+ the workflow snippet has not been exercised as a live Actions run.
309
331
 
310
332
  ---
311
333
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euthyna",
3
- "version": "0.5.0",
3
+ "version": "0.7.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",
package/plugin.yaml CHANGED
@@ -1,5 +1,5 @@
1
1
  name: euthyna
2
- version: 0.5.0
2
+ version: 0.7.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({
@@ -73,7 +73,7 @@ euthyna —— 给 AI 编码 agent 用的确定性事实产出器
73
73
  用法:
74
74
  euthyna audit --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json]
75
75
  euthyna history --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json]
76
- euthyna coverage --coverage <file> --symbol <name> [--file <path>] [--json]
76
+ euthyna coverage --coverage <file> --symbol <name> [--file <path>] [--source <dir>] [--json]
77
77
  euthyna deps [--repo <dir>] [--lockfile <file>] (--dep <name> | --all) [--json]
78
78
  euthyna gate <报告文件> [--verify] [--cwd <dir>] [--json]
79
79
 
@@ -92,6 +92,8 @@ euthyna —— 给 AI 编码 agent 用的确定性事实产出器
92
92
  --coverage 覆盖率数据文件,c8 的 coverage-final.json
93
93
  --symbol 要查询的符号名,可重复
94
94
  --file 可选,限定到某个文件
95
+ --source 可选,Go profile(go test -coverprofile)必需:模块根目录,
96
+ 用于把行区间映射回函数(profile 不含函数名)
95
97
  deps 某个依赖在 lockfile 里被锁定/声明成什么版本(供应链声明的裁决依据)
96
98
  --repo 可选,依赖清单所在目录(默认当前目录,自动检测)
97
99
  --lockfile 可选,显式指定清单文件(支持 package-lock.json / Cargo.lock / go.mod)
@@ -123,7 +125,7 @@ reliably, and writes the answers as facts with evidence.
123
125
  Usage:
124
126
  euthyna audit --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json]
125
127
  euthyna history --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json]
126
- euthyna coverage --coverage <file> --symbol <name> [--file <path>] [--json]
128
+ euthyna coverage --coverage <file> --symbol <name> [--file <path>] [--source <dir>] [--json]
127
129
  euthyna deps [--repo <dir>] [--lockfile <file>] (--dep <name> | --all) [--json]
128
130
  euthyna gate <report file> [--verify] [--cwd <dir>] [--json]
129
131
 
@@ -147,6 +149,9 @@ Commands:
147
149
  --coverage the coverage data file, c8's coverage-final.json
148
150
  --symbol symbol name(s) to query, repeatable
149
151
  --file optional, restrict to one file
152
+ --source optional, required for Go profiles (go test -coverprofile):
153
+ the module root, used to map line ranges back to
154
+ functions (the profile carries no function names)
150
155
  deps what version does the lockfile pin or declare for a dependency? (the basis for supply-chain claims)
151
156
  --repo optional, directory containing the manifest (default cwd, auto-detected)
152
157
  --lockfile optional, explicit manifest path (package-lock.json / Cargo.lock / go.mod)
@@ -305,6 +310,7 @@ async function runCoverage(flags, lang) {
305
310
  );
306
311
  const symbols = asArray(flags.symbol).map(String);
307
312
  const file = flags.file ? String(flags.file) : undefined;
313
+ const source = flags.source ? path.resolve(String(flags.source)) : undefined;
308
314
 
309
315
  // Asking for nothing is a caller mistake, not a measurement failure.
310
316
  if (symbols.length === 0) {
@@ -318,12 +324,13 @@ async function runCoverage(flags, lang) {
318
324
  const { facts, evaluated, notEvaluated, measured } = await collectCoverageFacts({
319
325
  coverageFile,
320
326
  targets,
327
+ source,
321
328
  lang
322
329
  });
323
330
 
324
331
  const report = makeReport({
325
332
  producer: producerFor(COVERAGE_PRODUCER, lang),
326
- subject: { coverageFile, symbols, file },
333
+ subject: { coverageFile, symbols, file, ...(source ? { source } : {}) },
327
334
  facts,
328
335
  evaluated,
329
336
  notEvaluated
@@ -402,6 +409,50 @@ async function runDeps(flags, lang) {
402
409
  * symbol names only the adjudicator knows, so there is nothing honest to
403
410
  * auto-measure there.
404
411
  */
412
+ /**
413
+ * Decide whether the manifest `audit` measured is the manifest --head holds.
414
+ *
415
+ * Pure, so that the fail-closed branch — a probe that could not run — is reachable
416
+ * from a test. Nothing else here can make git fail on demand.
417
+ */
418
+ export function manifestVersionGap({ probeFailed, existsAtHead, differsFromHead, head, worktree, manifest }, t) {
419
+ const short = (sha) => (sha ?? '').slice(0, 10);
420
+ const where = worktree && worktree !== head ? `(工作区检出在 ${short(worktree)})` : '';
421
+ const whereEn = worktree && worktree !== head ? ` (the worktree is at ${short(worktree)})` : '';
422
+
423
+ if (probeFailed) {
424
+ return {
425
+ kind: KIND.DEPENDENCY,
426
+ code: 'manifest-unverified',
427
+ reason: t(
428
+ `无法确认依赖清单 ${manifest} 与 --head ${short(head)} 的关系:git 探测失败,这不是匹配的证据`,
429
+ `the relationship between the manifest ${manifest} and --head ${short(head)} could not be probed: git failed, which is not evidence of a match`
430
+ )
431
+ };
432
+ }
433
+ if (!existsAtHead) {
434
+ return {
435
+ kind: KIND.DEPENDENCY,
436
+ code: 'manifest-not-head',
437
+ reason: t(
438
+ `测到的依赖清单 ${manifest} 在 --head ${short(head)} 里不存在,它不属于任何被审的提交${where}`,
439
+ `the dependency manifest ${manifest} measured here does not exist at --head ${short(head)}; it belongs to no reviewed commit${whereEn}`
440
+ )
441
+ };
442
+ }
443
+ if (differsFromHead) {
444
+ return {
445
+ kind: KIND.DEPENDENCY,
446
+ code: 'manifest-not-head',
447
+ reason: t(
448
+ `测到的依赖清单 ${manifest} 与 --head ${short(head)} 那份内容不一致${where},这些事实不是该提交的声明版本`,
449
+ `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`
450
+ )
451
+ };
452
+ }
453
+ return null;
454
+ }
455
+
405
456
  async function runAudit(flags, lang) {
406
457
  const t = T(lang);
407
458
  const cwd = path.resolve(flags.repo ? String(flags.repo) : process.cwd());
@@ -469,6 +520,38 @@ async function runAudit(flags, lang) {
469
520
  depNotEvaluated.push({ kind: KIND.DEPENDENCY, reason: depResult.error });
470
521
  }
471
522
 
523
+ // history takes the commits named by --base/--head; deps takes the manifest
524
+ // checked out in --repo. The honest question is therefore about CONTENT: is the
525
+ // file that was measured the file --head holds? Comparing commit shas instead was
526
+ // measured to be wrong in both directions — it fired on a PR merge commit whose
527
+ // lockfile is byte-identical to --head's (which, per fact-contract rule 1, forces
528
+ // every finding citing a dependency fact to not_evaluated), and stayed silent on a
529
+ // gitignored lockfile that belongs to no commit at all.
530
+ const manifest = typeof depResult.report?.subject?.lockfile === 'string'
531
+ ? depResult.report.subject.lockfile
532
+ : undefined;
533
+ if (manifest) {
534
+ const probe = { probeFailed: false, existsAtHead: false, differsFromHead: true };
535
+ if (path.isAbsolute(manifest)) {
536
+ const rel = path.relative(toplevel, manifest).split(path.sep).join('/');
537
+ try {
538
+ probe.existsAtHead = (await git(['ls-tree', head, '--', rel], { cwd: toplevel })).trim() !== '';
539
+ probe.differsFromHead = !probe.existsAtHead ||
540
+ (await git(['diff', '--name-only', head, '--', rel], { cwd: toplevel })).trim() !== '';
541
+ } catch {
542
+ // A probe that could not run is not evidence of a match.
543
+ probe.probeFailed = true;
544
+ }
545
+ } else {
546
+ probe.probeFailed = true;
547
+ }
548
+ const versionGap = manifestVersionGap(
549
+ { ...probe, head, worktree: await revParse(toplevel, 'HEAD'), manifest: path.basename(manifest) },
550
+ t
551
+ );
552
+ if (versionGap) depNotEvaluated.push(versionGap);
553
+ }
554
+
472
555
  const facts = [...history.facts, ...depFacts];
473
556
  const evaluated = [...history.evaluated, ...depEvaluated];
474
557
  const notEvaluated = [...history.notEvaluated, ...depNotEvaluated];
@@ -17,11 +17,10 @@
17
17
  * There is no code path here that emits "executed". That is the design, not an
18
18
  * omission. See docs/fact-contract-zh.md section 6.2.
19
19
  */
20
- import { readFile } from 'node:fs/promises';
20
+ import { readFile, readdir } from 'node:fs/promises';
21
21
  import path from 'node:path';
22
22
  import { makeFact, notEvaluated as notEvaluatedEntry, KIND, STATUS, shellQuote } from '../contract.js';
23
23
  import { T, DEFAULT_LANG } from '../lang.js';
24
-
25
24
  /** c8's default exclusions. A production file matching one of these vanishes from the report. */
26
25
  const C8_DEFAULT_EXCLUDES = [
27
26
  /(^|\/)node_modules\//,
@@ -52,11 +51,12 @@ function matchesTarget(entryPath, target) {
52
51
  * break on any path containing a quote, and the point of the field is that a
53
52
  * reader can re-derive the claim without trusting this report.
54
53
  */
55
- function reproduceCommand({ coverageFile, symbol, file }) {
54
+ function reproduceCommand({ coverageFile, symbol, file, source }) {
56
55
  return (
57
56
  `euthyna coverage --coverage ${shellQuote(coverageFile)} ` +
58
57
  `--symbol ${shellQuote(symbol)}` +
59
- (file ? ` --file ${shellQuote(file)}` : '')
58
+ (file ? ` --file ${shellQuote(file)}` : '') +
59
+ (source ? ` --source ${shellQuote(source)}` : '')
60
60
  );
61
61
  }
62
62
 
@@ -82,6 +82,12 @@ function reproduceCommand({ coverageFile, symbol, file }) {
82
82
  * evidence that the function was never entered. coverage.py reports
83
83
  * functions that were never called as long as their module was loaded,
84
84
  * which is exactly what makes "never invoked" an established fact.
85
+ * 4. Go coverage profiles (`go test -coverprofile`) — text, `mode:` header
86
+ * plus one line per block (`path:startL.startC,endL.endC stmts count`).
87
+ * No function names: the two-state mapping runs through function ranges
88
+ * parsed from the gofmt'd source under --source (module root). A file
89
+ * absent from the profile is *not* evidence of "never executed" (the
90
+ * package may not be in the test binary), so absence is notEvaluated.
85
91
  *
86
92
  * Anything that matches none of these shapes is **not a coverage report this
87
93
  * producer can read**, and is reported as notEvaluated — never fed through the
@@ -89,9 +95,160 @@ function reproduceCommand({ coverageFile, symbol, file }) {
89
95
  */
90
96
  export async function loadCoverage(coverageFile) {
91
97
  const raw = await readFile(coverageFile, 'utf8');
98
+ if (isGoProfileText(raw)) {
99
+ const parsed = parseGoProfile(raw);
100
+ if (!parsed) {
101
+ throw new Error(
102
+ 'the file starts like a Go coverage profile ("mode:" header) but the body has lines that do not match ' +
103
+ '"file:startLine.startCol,endLine.endCol numStatements count" — refusing to interpret it as coverage'
104
+ );
105
+ }
106
+ return parsed;
107
+ }
92
108
  return JSON.parse(raw);
93
109
  }
94
110
 
111
+ /**
112
+ * Go coverage profiles (`go test -coverprofile`) are text, not JSON:
113
+ *
114
+ * mode: set
115
+ * github.com/nektos/act/pkg/artifacts/artifact.pb.go:42.2,43.29 2 0
116
+ *
117
+ * One line per *block* — a line range with a statement count and a hit count.
118
+ * There are **no function names**, so a symbol query needs the source tree
119
+ * (via --source, the module root) to map line ranges back to functions:
120
+ * goFunctionRanges() reads the gofmt'd source, whose top-level `func`
121
+ * declarations start at column 0 and end at a `}` at column 0.
122
+ */
123
+ function isGoProfileText(raw) {
124
+ const first = String(raw).split(/\r?\n/, 1)[0];
125
+ return /^mode:\s*(set|count|atomic)\s*$/.test(first);
126
+ }
127
+
128
+ const GO_BLOCK_RE = /^(.+):(\d+)\.(\d+),(\d+)\.(\d+)\s+(\d+)\s+(\d+)$/;
129
+
130
+ /** Parse a Go profile into `{ __go, mode, files: Map<path, blocks[]> }`, or null if malformed. */
131
+ export function parseGoProfile(raw) {
132
+ const lines = String(raw).split(/\r?\n/);
133
+ const mode = /^mode:\s*(set|count|atomic)\s*$/.exec((lines[0] ?? '').trim())?.[1];
134
+ if (!mode) return null;
135
+
136
+ const files = new Map();
137
+ for (let i = 1; i < lines.length; i++) {
138
+ const line = lines[i];
139
+ if (!line.trim()) continue;
140
+ const m = GO_BLOCK_RE.exec(line);
141
+ if (!m) return null;
142
+ const [, file, startLine, , endLine, , statements, count] = m;
143
+ let blocks = files.get(file);
144
+ if (!blocks) {
145
+ blocks = [];
146
+ files.set(file, blocks);
147
+ }
148
+ blocks.push({
149
+ startLine: Number(startLine),
150
+ endLine: Number(endLine),
151
+ statements: Number(statements),
152
+ count: Number(count)
153
+ });
154
+ }
155
+ return { __go: true, mode, files };
156
+ }
157
+
158
+ /**
159
+ * The code visible on each line: comments and raw-string contents blanked out,
160
+ * with multi-line `/*...*​/` and `...` regions tracked across lines.
161
+ *
162
+ * Without this, a commented-out function, a `}` at column 0 inside a block
163
+ * comment, or a raw string with a column-0 brace would create phantom function
164
+ * ranges or split real ones — and coverage blocks would be assigned to the
165
+ * wrong function, which is a false coverage result.
166
+ */
167
+ function goSignificantLines(text) {
168
+ const out = [];
169
+ let inBlock = false;
170
+ let inRaw = false;
171
+ for (const line of String(text).split(/\r?\n/)) {
172
+ let sig = '';
173
+ let i = 0;
174
+ while (i < line.length) {
175
+ const ch = line[i];
176
+ if (inBlock) {
177
+ if (ch === '*' && line[i + 1] === '/') {
178
+ inBlock = false;
179
+ i += 2;
180
+ continue;
181
+ }
182
+ i++;
183
+ continue;
184
+ }
185
+ if (inRaw) {
186
+ if (ch === '`') inRaw = false;
187
+ i++;
188
+ continue;
189
+ }
190
+ if (ch === '`') {
191
+ inRaw = true;
192
+ i++;
193
+ continue;
194
+ }
195
+ if (ch === '/' && line[i + 1] === '/') break; // rest of the line is a comment
196
+ if (ch === '/' && line[i + 1] === '*') {
197
+ inBlock = true;
198
+ i += 2;
199
+ continue;
200
+ }
201
+ sig += ch;
202
+ i++;
203
+ }
204
+ out.push(sig);
205
+ }
206
+ return out;
207
+ }
208
+
209
+ /**
210
+ * Top-level function ranges of a gofmt'd Go source file.
211
+ *
212
+ * Only `func` at column 0 of *significant* code matches: nested (indented)
213
+ * declarations are left alone, which is the point — a closure inside a
214
+ * function is part of that function's range. A function ends at the next `}`
215
+ * at column 0, or on its own declaration line when the whole function sits on
216
+ * that line (`func f() {}`), so a later package-level block is not absorbed.
217
+ * Methods are reported under their bare method name (two types may share it;
218
+ * every match is reported, mirroring how c8 reports every fnMap entry with a
219
+ * given name).
220
+ */
221
+ export function goFunctionRanges(text) {
222
+ const lines = goSignificantLines(text);
223
+ const funcs = [];
224
+ let current = null;
225
+ const declRe = /^func\s*(?:\([^)]*\))?\s*([A-Za-z_]\w*)/;
226
+
227
+ for (let i = 0; i < lines.length; i++) {
228
+ const line = lines[i];
229
+ if (current && /^}/.test(line)) {
230
+ funcs.push({ ...current, endLine: i + 1 });
231
+ current = null;
232
+ continue;
233
+ }
234
+ if (/^func\s/.test(line)) {
235
+ // a new decl with no closing brace in between: close the previous range
236
+ // at the previous line rather than swallowing the new declaration
237
+ if (current) funcs.push({ ...current, endLine: i });
238
+ const name = declRe.exec(line)?.[1];
239
+ current = name ? { name, startLine: i + 1 } : null;
240
+ // the whole function on one line (gofmt keeps empty ones there): close
241
+ // it on its own line
242
+ if (current && /\}\s*$/.test(line)) {
243
+ funcs.push({ ...current, endLine: i + 1 });
244
+ current = null;
245
+ }
246
+ }
247
+ }
248
+ if (current) funcs.push({ ...current, endLine: lines.length });
249
+ return funcs;
250
+ }
251
+
95
252
  /**
96
253
  * True for coverage.py's `{"meta": {...}, "files": {...}}` shape.
97
254
  * A c8 report never has a top-level `meta` key.
@@ -115,6 +272,7 @@ function isCoveragePyReport(coverage) {
115
272
  * honestly is still part of the fact, so the distinction is kept.
116
273
  */
117
274
  export function detectCoverageFormat(coverage) {
275
+ if (coverage && typeof coverage === 'object' && coverage.__go) return 'go';
118
276
  if (isCoveragePyReport(coverage)) return 'coverage.py';
119
277
 
120
278
  const entries = Object.entries(coverage).filter(([, v]) => v && typeof v === 'object');
@@ -210,8 +368,10 @@ function locateSymbolInEntry(entry, symbol) {
210
368
  * @param {object} options
211
369
  * @param {string} options.coverageFile
212
370
  * @param {Array<{file?: string, symbol: string}>} options.targets
371
+ * @param {string} [options.source] module root for Go profiles (--source), whose
372
+ * source files are needed to map profile line ranges back to functions
213
373
  */
214
- export async function collectCoverageFacts({ coverageFile, targets = [], lang = DEFAULT_LANG } = {}) {
374
+ export async function collectCoverageFacts({ coverageFile, targets = [], source, lang = DEFAULT_LANG } = {}) {
215
375
  const t = T(lang);
216
376
  const facts = [];
217
377
  const evaluated = [];
@@ -238,6 +398,13 @@ export async function collectCoverageFacts({ coverageFile, targets = [], lang =
238
398
 
239
399
  const isPy = isCoveragePyReport(coverage);
240
400
 
401
+ // Go profiles take a dedicated path: the report is a block list with no
402
+ // function names, so symbol location needs the source tree and the
403
+ // two-state mapping runs through function ranges, not per-file function maps.
404
+ if (coverage.__go) {
405
+ return collectGoCoverageFacts({ coverage, coverageFile, targets, source, lang });
406
+ }
407
+
241
408
  // c8: entries are the report's file keys directly.
242
409
  // coverage.py: the file entries live under `files`.
243
410
  const entries = Object.entries(isPy ? (coverage.files ?? {}) : coverage).filter(
@@ -435,3 +602,316 @@ export async function collectCoverageFacts({ coverageFile, targets = [], lang =
435
602
 
436
603
  return { facts, evaluated, notEvaluated, measured: true };
437
604
  }
605
+
606
+ /**
607
+ * The Go-profile path.
608
+ *
609
+ * Profile lines carry no function names, so the two-state mapping goes:
610
+ * block list -> function ranges from the gofmt'd source -> per-function hit.
611
+ * The module root (--source) is required; profile paths are import paths
612
+ * (`module/dir/file.go`) or absolute paths, so resolution tries go.mod's
613
+ * module prefix and the plain path under the root before giving up — and a
614
+ * file it cannot resolve is reported as notEvaluated, never as uncovered.
615
+ */
616
+ async function collectGoCoverageFacts({ coverage, coverageFile, targets, source, lang }) {
617
+ const t = T(lang);
618
+ const facts = [];
619
+ const evaluated = [];
620
+ const notEvaluated = [];
621
+ let counter = 0;
622
+
623
+ if (coverage.files.size === 0) {
624
+ notEvaluated.push(
625
+ notEvaluatedEntry(
626
+ KIND.TEST_COVERAGE,
627
+ t(
628
+ 'Go coverage profile 为空(只有 mode 头)。测试没有加载到任何被编译的包,' +
629
+ '或 -coverpkg 没有指向被测代码',
630
+ 'The Go coverage profile is empty (mode header only). The test run loaded no compiled package, ' +
631
+ 'or -coverpkg did not point at the code under test'
632
+ )
633
+ )
634
+ );
635
+ return { facts, evaluated, notEvaluated, measured: false };
636
+ }
637
+
638
+ if (targets.length === 0) {
639
+ notEvaluated.push(
640
+ notEvaluatedEntry(
641
+ KIND.TEST_COVERAGE,
642
+ t('没有指定要查询的符号(--symbol)', 'no symbol requested for query (--symbol)')
643
+ )
644
+ );
645
+ return { facts, evaluated, notEvaluated, measured: false };
646
+ }
647
+
648
+ let module = null;
649
+ if (source) {
650
+ try {
651
+ module = modulePrefix(await readFile(path.join(source, 'go.mod'), 'utf8'));
652
+ } catch {
653
+ // no go.mod at the root: relative import paths then cannot be resolved,
654
+ // and the per-file resolution below reports that honestly
655
+ }
656
+ }
657
+
658
+ const sourceCache = new Map();
659
+ async function resolveSource(profilePath) {
660
+ if (sourceCache.has(profilePath)) return sourceCache.get(profilePath);
661
+ let result = null;
662
+ if (source) {
663
+ const candidates = [];
664
+ if (path.isAbsolute(profilePath)) {
665
+ candidates.push(profilePath);
666
+ } else {
667
+ if (module && profilePath.startsWith(module + '/')) {
668
+ candidates.push(path.join(source, profilePath.slice(module.length + 1)));
669
+ }
670
+ candidates.push(path.join(source, profilePath));
671
+ }
672
+ for (const candidate of candidates) {
673
+ try {
674
+ result = { text: await readFile(candidate, 'utf8'), resolved: candidate };
675
+ break;
676
+ } catch {
677
+ // try the next candidate, then report honestly
678
+ }
679
+ }
680
+ }
681
+ sourceCache.set(profilePath, result);
682
+ return result;
683
+ }
684
+
685
+ if (!source) {
686
+ notEvaluated.push(
687
+ notEvaluatedEntry(
688
+ KIND.TEST_COVERAGE,
689
+ t(
690
+ 'Go 的 -coverprofile 是文本格式且不含函数名,函数级裁定需要 --source <模块根目录> ' +
691
+ '把行区间映射回函数。没有源码,符号查询无法进行',
692
+ 'A Go -coverprofile is text and carries no function names; a function-level verdict needs ' +
693
+ '--source <module root> to map line ranges back to functions. Without the source, symbol queries cannot run'
694
+ )
695
+ )
696
+ );
697
+ return { facts, evaluated, notEvaluated, measured: false };
698
+ }
699
+
700
+ const entries = [...coverage.files.entries()];
701
+
702
+ for (const target of targets) {
703
+ const scoped = target.file
704
+ ? entries.filter(([entryPath]) => matchesTarget(entryPath, target.file))
705
+ : entries;
706
+
707
+ if (target.file && scoped.length === 0) {
708
+ // For Go this must NOT read as "never executed": a file absent from the
709
+ // profile may simply not have been compiled into the test binary at all.
710
+ notEvaluated.push(
711
+ notEvaluatedEntry(
712
+ KIND.TEST_COVERAGE,
713
+ t(
714
+ `${target.file} 没有出现在 Go coverage profile 中 —— 该文件所在的包可能根本没被编译进这次测试二进制` +
715
+ '(--coverpkg 或运行对应包才能测到它),「未出现」不能当「未执行」',
716
+ `${target.file} does not appear in the Go coverage profile — its package may not have been compiled into the test binary at all ` +
717
+ '(-coverpkg or running that package is required to measure it), so "absent" must not be read as "never executed"'
718
+ )
719
+ )
720
+ );
721
+ continue;
722
+ }
723
+
724
+ let matched = 0;
725
+ let unresolved = false;
726
+ for (const [entryPath, blocks] of scoped) {
727
+ const src = await resolveSource(entryPath);
728
+ if (!src) {
729
+ unresolved = true;
730
+ notEvaluated.push(
731
+ notEvaluatedEntry(
732
+ KIND.TEST_COVERAGE,
733
+ t(
734
+ `profile 中的 ${entryPath} 无法在 --source ${source} 下定位(go.mod 模块前缀已尝试)—— ` +
735
+ '没有该源文件,行区间无法映射回函数',
736
+ `The profile entry ${entryPath} cannot be located under --source ${source} (the go.mod module prefix was tried) — ` +
737
+ 'without that source file, line ranges cannot be mapped back to functions'
738
+ )
739
+ )
740
+ );
741
+ continue;
742
+ }
743
+
744
+ for (const fn of goFunctionRanges(src.text)) {
745
+ if (fn.name !== target.symbol) continue;
746
+ const inFn = blocks.filter(b => b.startLine >= fn.startLine && b.startLine <= fn.endLine);
747
+ if (inFn.length === 0) continue; // no measured block: cannot answer for this function
748
+ matched++;
749
+ const invoked = inFn.some(b => b.count > 0);
750
+
751
+ if (!invoked) {
752
+ facts.push(
753
+ makeFact({
754
+ id: `coverage-${++counter}`,
755
+ kind: KIND.TEST_COVERAGE,
756
+ statement:
757
+ t(
758
+ `符号 ${target.symbol} 在本次测试运行中一次都没有被调用(函数体内所有覆盖区块的执行计数为 0)—— ` +
759
+ `任何依赖它的行为都没有被执行验证`,
760
+ `Symbol ${target.symbol} was never invoked in this test run (every coverage block inside the function has a hit count of 0) — ` +
761
+ `any behavior depending on it was never exercised`
762
+ ),
763
+ status: STATUS.ESTABLISHED,
764
+ evidence: { file: entryPath, line: fn.startLine },
765
+ method: 'command',
766
+ command: reproduceCommand({ coverageFile, symbol: target.symbol, file: entryPath, source }),
767
+ detail: { symbol: target.symbol, invocationCount: 0, reason: 'invocation_count_zero' }
768
+ })
769
+ );
770
+ } else {
771
+ facts.push(
772
+ makeFact({
773
+ id: `coverage-${++counter}`,
774
+ kind: KIND.TEST_COVERAGE,
775
+ statement:
776
+ t(
777
+ `符号 ${target.symbol} 至少被调用过一次,但被调用**不能**证明任何特定调用点执行过 —— ` +
778
+ `本事实只能证伪,不能证实`,
779
+ `Symbol ${target.symbol} was invoked at least once, but being invoked **cannot** prove any specific call site ran — ` +
780
+ `this fact can only falsify, never confirm`
781
+ ),
782
+ status: STATUS.UNKNOWN,
783
+ evidence: { file: entryPath, line: fn.startLine },
784
+ method: 'command',
785
+ command: reproduceCommand({ coverageFile, symbol: target.symbol, file: entryPath, source }),
786
+ detail: {
787
+ symbol: target.symbol,
788
+ invocationCount: 1,
789
+ reason: 'nonzero_count_cannot_prove_call_site_execution'
790
+ }
791
+ })
792
+ );
793
+ }
794
+ }
795
+ }
796
+
797
+ // "could not locate" implies we searched the data; when the source file
798
+ // needed to even read the data was missing, the notEvaluated entry above
799
+ // is the honest record and no locating claim is made.
800
+ if (matched === 0 && !unresolved) {
801
+ // With --source we can tell "not located" apart from "lives in a file the
802
+ // profile never saw": a symbol declared in a file absent from the profile
803
+ // is a refusal (its package was probably not compiled in), not an answer.
804
+ const elsewhere = await findGoSymbolOutsideProfile(coverage, source, module, target.symbol);
805
+ if (elsewhere) {
806
+ notEvaluated.push(
807
+ notEvaluatedEntry(
808
+ KIND.TEST_COVERAGE,
809
+ t(
810
+ `符号 ${target.symbol} 在 ${elsewhere} 中有顶层声明,但该文件没有出现在 Go coverage profile 里 —— ` +
811
+ '它所在的包多半没被编译进这次测试二进制(-coverpkg 或运行对应包才能测到它)。' +
812
+ '这是「未评估」,既不是「从未调用」也不是「无法定位」',
813
+ `Symbol ${target.symbol} is declared in ${elsewhere}, but that file does not appear in the Go coverage profile — ` +
814
+ 'its package was probably not compiled into this test binary (-coverpkg or running that package is required to measure it). ' +
815
+ 'This is "not evaluated": neither "never invoked" nor "could not locate"'
816
+ )
817
+ )
818
+ );
819
+ continue;
820
+ }
821
+ facts.push(
822
+ makeFact({
823
+ id: `coverage-${++counter}`,
824
+ kind: KIND.TEST_COVERAGE,
825
+ statement:
826
+ t(
827
+ `未能在覆盖率数据中定位符号 ${target.symbol}`,
828
+ `Could not locate symbol ${target.symbol} in the coverage data`
829
+ ) +
830
+ (target.file ? t(`(限定文件 ${target.file})`, ` (restricted to file ${target.file})`) : '') +
831
+ t(
832
+ ' —— 可能是被重命名、被内联,或它不是顶层 func 声明(Go profile 只携带行区间,' +
833
+ '函数边界来自源码中的顶层 func)',
834
+ ' — it may have been renamed, inlined, or it is not a top-level func declaration (a Go profile carries line ranges only; ' +
835
+ 'function boundaries come from top-level funcs in the source)'
836
+ ),
837
+ status: STATUS.UNKNOWN,
838
+ evidence: { file: target.file ?? t('(未限定文件)', '(unrestricted file)') },
839
+ method: 'static',
840
+ detail: { symbol: target.symbol, reason: 'symbol_not_located_in_fnmap' }
841
+ })
842
+ );
843
+ }
844
+ }
845
+
846
+ // A query that produced no fact and only refusals was not answered —
847
+ // "measured" must not let CI read it as clean.
848
+ const measured = facts.length > 0;
849
+ if (measured) {
850
+ evaluated.push({
851
+ kind: KIND.TEST_COVERAGE,
852
+ producer: 'euthyna-coverage',
853
+ format: 'go',
854
+ count: facts.length
855
+ });
856
+ }
857
+
858
+ return { facts, evaluated, notEvaluated, measured };
859
+ }
860
+
861
+ function modulePrefix(goModText) {
862
+ return /^\s*module\s+(\S+)/m.exec(goModText)?.[1] ?? null;
863
+ }
864
+
865
+ const GO_WALK_SKIP = new Set(['vendor', 'testdata', 'node_modules', '.git']);
866
+
867
+ /**
868
+ * Does the symbol exist in a .go file that the profile does not cover?
869
+ *
870
+ * Walks the module root (skipping vendor/testdata and hidden dirs), maps each
871
+ * file to its import path, and reports the first file — relative to the
872
+ * module root — whose top-level functions include the symbol while the file
873
+ * itself is absent from the profile. The whole-tree scan only runs on the
874
+ * rare "matched 0" path, so the common query pays nothing for it.
875
+ */
876
+ async function findGoSymbolOutsideProfile(coverage, source, module, symbol) {
877
+ if (!source) return null;
878
+
879
+ // absolute disk paths the profile already accounts for
880
+ const covered = new Set();
881
+ for (const profilePath of coverage.files.keys()) {
882
+ if (path.isAbsolute(profilePath)) {
883
+ covered.add(path.resolve(profilePath).replace(/\\/g, '/').toLowerCase());
884
+ }
885
+ }
886
+
887
+ async function* walk(dir) {
888
+ let entries;
889
+ try {
890
+ entries = await readdir(dir, { withFileTypes: true });
891
+ } catch {
892
+ return;
893
+ }
894
+ for (const entry of entries) {
895
+ if (entry.name.endsWith('.go')) yield path.join(dir, entry.name);
896
+ else if (entry.isDirectory() && !GO_WALK_SKIP.has(entry.name) && !entry.name.startsWith('.')) {
897
+ yield* walk(path.join(dir, entry.name));
898
+ }
899
+ }
900
+ }
901
+
902
+ for await (const file of walk(source)) {
903
+ const abs = path.resolve(file).replace(/\\/g, '/').toLowerCase();
904
+ if (covered.has(abs)) continue;
905
+ const rel = path.relative(source, file).replace(/\\/g, '/');
906
+ const importPath = module ? `${module}/${rel}` : rel;
907
+ if (coverage.files.has(importPath) || coverage.files.has(rel)) continue;
908
+ let text;
909
+ try {
910
+ text = await readFile(file, 'utf8');
911
+ } catch {
912
+ continue;
913
+ }
914
+ if (goFunctionRanges(text).some(fn => fn.name === symbol)) return rel;
915
+ }
916
+ return null;
917
+ }
package/src/facts/deps.js CHANGED
@@ -425,11 +425,11 @@ export async function collectDependencyFacts({ cwd = process.cwd(), lockfile, de
425
425
  t(
426
426
  `依赖 ${dep} 在 ${listLabel}(${target.type})中被${verb}为版本 ${versions.join(' / ')}` +
427
427
  (isGo
428
- ? ' —— 这是声明的需求版本,非最终解析版本(go.sum 不含版本,无法在此验证解析结果)'
428
+ ? ' —— 这是声明的需求版本,非最终解析版本(选定版本由 MVS 对整个模块图求解,go.sum 记录版本与哈希但不含选定的构建清单,deps 不做模块图解析)'
429
429
  : ''),
430
430
  isGo
431
431
  ? `Dependency ${dep} is declared as version ${versions.join(' / ')} in the ${listLabel} (${target.type})` +
432
- ' — this is the declared requirement version, not the final resolved version (go.sum carries no versions, so the resolution cannot be verified here)'
432
+ ' — this is the declared requirement version, not the final resolved version (the selected version comes from MVS over the whole module graph; go.sum records version-hash pairs but not the selected build list, and this producer does not resolve the module graph)'
433
433
  : `Dependency ${dep} is locked to version ${versions.join(' / ')} in the ${listLabel} (${target.type})`
434
434
  ),
435
435
  status: STATUS.ESTABLISHED,