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.
- package/.agents/skills/euthyna/references/dependency-audit.md +5 -0
- package/.agents/skills/euthyna/references/fact-producers.md +24 -2
- package/.agents/skills/euthyna/references/verification-gates.md +5 -0
- package/.agents/skills/euthyna-en/references/dependency-audit.md +6 -0
- package/.agents/skills/euthyna-en/references/fact-producers.md +22 -1
- package/.agents/skills/euthyna-en/references/verification-gates.md +5 -0
- package/README.md +39 -17
- package/package.json +1 -1
- package/plugin.yaml +1 -1
- package/src/cli.js +87 -4
- package/src/facts/coverage.js +485 -5
- package/src/facts/deps.js +2 -2
|
@@ -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
|
-
**
|
|
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
|
|
35
|
-
false: **5 out of 5** refuted at the gates on the first run;
|
|
36
|
-
the third unresolved (INCONCLUSIVE, supply-chain-dependent) on 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 #
|
|
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
|
|
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
|
|
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
|
|
254
|
-
Cargo.lock
|
|
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
|
-
|
|
293
|
-
|
|
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
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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.
|
|
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
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];
|
package/src/facts/coverage.js
CHANGED
|
@@ -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
|
-
? ' ——
|
|
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
|
|
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,
|