euthyna 0.4.1 → 0.6.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/fact-producers.md +19 -2
- package/.agents/skills/euthyna/references/verification-gates.md +5 -0
- package/.agents/skills/euthyna-en/references/fact-producers.md +21 -1
- package/.agents/skills/euthyna-en/references/verification-gates.md +5 -0
- package/.cursor/commands/euthyna.md +14 -0
- package/.github/prompts/euthyna.prompt.md +16 -0
- package/README.md +19 -8
- package/package.json +3 -1
- package/plugin.yaml +1 -1
- package/src/cli.js +10 -3
- package/src/facts/coverage.js +485 -5
- package/src/facts/deps.js +2 -2
|
@@ -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
|
|
@@ -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 直接证否。覆盖率对这类论证既不增加也不减少,本条不适用。「这条路径没有触发」永远不等于「这条路径不可能触发」。
|
|
@@ -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
|
|
|
@@ -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".
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# /euthyna — 代码安全审计(判定纪律)
|
|
2
|
+
|
|
3
|
+
你在 Cursor 中调用本命令。**把审计目标(文件、目录、PR 描述或 diff 范围)用 @ 引用或直接写在命令后面。**
|
|
4
|
+
|
|
5
|
+
按 euthyna 技能执行一次完整审计:
|
|
6
|
+
|
|
7
|
+
1. **先加载纪律**:找到 euthyna 技能目录(`~/.cursor/skills/euthyna/`、项目 `.cursor/skills/euthyna/`,或 npm 全局包内的 `.agents/skills/euthyna/`),读 `SKILL.md`,需要时再读 `references/`。找不到就明说「技能缺失」,不要凭印象执行。
|
|
8
|
+
2. **按入口决策树走**:单条漏洞结论 → 阶段 C;一次变更 → 阶段 B;依赖面 → 阶段 A;整体审计 → 阶段 B 为主。
|
|
9
|
+
3. **先测量再读码**:第一条命令跑 `euthyna audit --base <分叉点> --repo <被审计仓库>`(history + deps 一次跑完);谈测试覆盖再跑 `coverage`(操作手册在 `references/fact-producers.md`)。
|
|
10
|
+
4. **每条结论过六道门禁**:证据到 `文件:行号`、可复现命令、影响说明;拿不出证据的降级为「观察」。
|
|
11
|
+
5. **三态裁定**:`BUG #N TRUE POSITIVE / FALSE POSITIVE / INCONCLUSIVE`。
|
|
12
|
+
6. **报告落盘**:`<项目>_EUTHYNA_AUDIT_<日期>.md`,并跑 `euthyna gate <报告>` 校验(退出码 0 才算过)。
|
|
13
|
+
|
|
14
|
+
CLI 未安装时先 `npm install -g euthyna`,或每次用 `npx euthyna <子命令> …`。
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: 代码安全审计:按 euthyna 纪律审一段代码、一次变更或一条漏洞结论,产出带证据的报告
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
你在 GitHub Copilot(VS Code / CLI)中调用本命令。**把审计目标(文件、目录、PR 描述或 diff 范围)用 #file / #codebase 引用或直接写在命令后面。**
|
|
6
|
+
|
|
7
|
+
按 euthyna 技能执行一次完整审计:
|
|
8
|
+
|
|
9
|
+
1. **先加载纪律**:找到 euthyna 技能目录(项目 `.github/skills/euthyna/` 或 `.agents/skills/euthyna/`,个人 `~/.copilot/skills/euthyna/`),读 `SKILL.md`,需要时再读 `references/`。找不到就明说「技能缺失」,不要凭印象执行。
|
|
10
|
+
2. **按入口决策树走**:单条漏洞结论 → 阶段 C;一次变更 → 阶段 B;依赖面 → 阶段 A;整体审计 → 阶段 B 为主。
|
|
11
|
+
3. **先测量再读码**:第一条命令跑 `euthyna audit --base <分叉点> --repo <被审计仓库>`(history + deps 一次跑完);谈测试覆盖再跑 `coverage`(操作手册在 `references/fact-producers.md`)。
|
|
12
|
+
4. **每条结论过六道门禁**:证据到 `文件:行号`、可复现命令、影响说明;拿不出证据的降级为「观察」。复现命令执行前须向用户报备(见技能「执行纪律」节)。
|
|
13
|
+
5. **三态裁定**:`BUG #N TRUE POSITIVE / FALSE POSITIVE / INCONCLUSIVE`。
|
|
14
|
+
6. **报告落盘**:`<项目>_EUTHYNA_AUDIT_<日期>.md`,并跑 `euthyna gate <报告>` 校验(退出码 0 才算过)。
|
|
15
|
+
|
|
16
|
+
CLI 未安装时先 `npm install -g euthyna`,或每次用 `npx euthyna <子命令> …`。
|
package/README.md
CHANGED
|
@@ -31,12 +31,14 @@ 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; **2 out of 3**
|
|
36
|
-
the third unresolved (INCONCLUSIVE, supply-chain-dependent) on the second
|
|
34
|
+
JavaScript one, a Python one, and a Go one — the pattern-matched "vulnerabilities" were
|
|
35
|
+
mostly false: **5 out of 5** refuted at the gates on the first run; **2 out of 3**
|
|
36
|
+
refuted, the third unresolved (INCONCLUSIVE, supply-chain-dependent) on the second;
|
|
37
|
+
**4 out of 4** refuted on the third. See the
|
|
37
38
|
[case studies](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-crewai.md)
|
|
38
39
|
([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)
|
|
40
|
+
[JavaScript/axe-core](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-axe-core.md),
|
|
41
|
+
[Go/nektos-act](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-act.md)).
|
|
40
42
|
2. **Its reassurances cannot be checked.** "I'm done." "The tests cover this." "It's
|
|
41
43
|
safe now." These are assertions. You cannot tell a done-claim from a done-deal.
|
|
42
44
|
|
|
@@ -105,7 +107,9 @@ the project is deliberately zero-dependency.
|
|
|
105
107
|
| **Codex** | The same Markdown layer works; packaging goes through Codex's plugin/marketplace format | Same |
|
|
106
108
|
| **Hermes** | Copy the same folder into `~/.hermes/skills/` under a category folder, or install from a repo with `hermes skills install` — and for the slash command, install the repo as a plugin (see below) | Same |
|
|
107
109
|
| **OpenCode** | Copy the same folder into `~/.agents/skills/` or `~/.config/opencode/skills/` (OpenCode loads both; unknown frontmatter fields are ignored). For a `/euthyna` slash command, copy `.opencode/commands/euthyna.md` into `~/.config/opencode/commands/` — it is a prompt template that tells the model to run the CLI | Same |
|
|
108
|
-
| **OpenClaw** | Copy the same folder into `~/.agents/skills/` (or `<workspace>/.agents/skills/`) — OpenClaw exposes every `user-invocable` skill as a slash command natively, so `/euthyna` works with no extra file | Same |
|
|
110
|
+
| **OpenClaw** | Copy the same folder into `~/.agents/skills/` (or `<workspace>/.agents/skills/`) — OpenClaw exposes every `user-invocable` skill as a slash command natively, so `/euthyna` works with no extra file. Published on ClawHub as [`@modusensus/euthyna`](https://clawhub.ai/modusensus/skills/euthyna) (zh) and [`@modusensus/euthyna-en`](https://clawhub.ai/modusensus/skills/euthyna-en) (en): `clawhub install modusensus/euthyna` | Same |
|
|
111
|
+
| **GitHub Copilot** | Copy the same folder into `.github/skills/` (project), `~/.copilot/skills/` or `~/.agents/skills/` (personal) — Copilot reads all three. For a `/euthyna` prompt, copy `.github/prompts/euthyna.prompt.md` into the same-named folder of your repo or profile. `gh skill` can also install skills from this repository | Same |
|
|
112
|
+
| **Cursor** | Copy the same folder into `.cursor/skills/` (project), `~/.cursor/skills/` (global) — Cursor also reads `.claude/skills/` for compatibility. For a `/euthyna` command, copy `.cursor/commands/euthyna.md` into the matching `commands/` folder (plain Markdown, no frontmatter) | Same |
|
|
109
113
|
| **Any terminal** | — | `npm install -g euthyna`, then `euthyna …` |
|
|
110
114
|
|
|
111
115
|
The skill ships in two languages. The directory above is the Chinese original (`euthyna`);
|
|
@@ -154,6 +158,9 @@ replying in chat.
|
|
|
154
158
|
| Codex | — | Codex's custom-prompt slash commands (`~/.codex/prompts/`) are deprecated upstream; the same Markdown can still be placed there manually |
|
|
155
159
|
| DSH | `/euthyna` | installed with the plugin; after a restart the command prints the usage manual (DSH slash commands run without reaching the model) |
|
|
156
160
|
| Hermes | `/euthyna <subcommand> <args…>` | install this repository as a Hermes plugin: `hermes plugins install slow-stack/euthyna` then `hermes plugins enable euthyna` (needs Node >= 20 on PATH; the repo root carries `plugin.yaml` + `__init__.py`). The command forwards to the CLI and prints its output without reaching the model. The skill itself can still be installed separately with `hermes skills install slow-stack/euthyna/.agents/skills/euthyna` |
|
|
161
|
+
| OpenCode | `/euthyna <args…>` | copy `.opencode/commands/euthyna.md` into `~/.config/opencode/commands/` or `.opencode/commands/` |
|
|
162
|
+
| GitHub Copilot | `/euthyna <target>` | copy `.github/prompts/euthyna.prompt.md` into `.github/prompts/` (project) or the prompt folder of your profile; available in VS Code and Copilot CLI |
|
|
163
|
+
| Cursor | `/euthyna <target>` | copy `.cursor/commands/euthyna.md` into `.cursor/commands/` (project) or `~/.cursor/commands/` (global); plain Markdown, no frontmatter |
|
|
157
164
|
|
|
158
165
|
### How to tell it was euthyna that audited
|
|
159
166
|
|
|
@@ -298,9 +305,13 @@ This project tries to be explicit about the difference. Current state:
|
|
|
298
305
|
*declared* requirement, not the resolved build version; and the producer never maps a version
|
|
299
306
|
to a CVE — that mapping is deliberately left to the adjudication layer.
|
|
300
307
|
- **Anything about the case-study targets' security** — the runs found nothing to endorse or
|
|
301
|
-
condemn; that is not a statement about
|
|
302
|
-
[`docs/case-study-crewai.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-crewai.md)
|
|
303
|
-
[`docs/case-study-axe-core.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-axe-core.md)
|
|
308
|
+
condemn; that is not a statement about any of the projects. See
|
|
309
|
+
[`docs/case-study-crewai.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-crewai.md),
|
|
310
|
+
[`docs/case-study-axe-core.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-axe-core.md),
|
|
311
|
+
and [`docs/case-study-act.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-act.md).
|
|
312
|
+
- **A CI recipe, by running it on GitHub.** The exit-code gate and the workflow wiring are
|
|
313
|
+
documented in [`docs/ci-integration.md`](https://github.com/slow-stack/euthyna/blob/main/docs/ci-integration.md);
|
|
314
|
+
the workflow snippet has not been exercised as a live Actions run.
|
|
304
315
|
|
|
305
316
|
---
|
|
306
317
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "euthyna",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
".agents/skills/euthyna-en/",
|
|
19
19
|
".claude/commands/",
|
|
20
20
|
".opencode/commands/",
|
|
21
|
+
".cursor/commands/",
|
|
22
|
+
".github/prompts/",
|
|
21
23
|
"assets/euthyna.png",
|
|
22
24
|
"NOTICE.md"
|
|
23
25
|
],
|
package/plugin.yaml
CHANGED
package/src/cli.js
CHANGED
|
@@ -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
|
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,
|