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.
@@ -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
- **Both coverage formats are read** (auto-detected, no parameters needed):
140
+ **Three coverage formats are read** (auto-detected, no parameters needed):
140
141
 
141
142
  | Format | Source | Notes |
142
143
  |---|---|---|
143
144
  | c8 / v8-to-istanbul `coverage-final.json` | JS projects (c8 lands at `coverage/coverage-final.json` by default) | Per-file `fnMap` + `f` (invocation-count array) |
144
145
  | coverage.py JSON (format 3) | Python projects (produced by `coverage json`) | Top-level `{meta, files}`; in `functions`, "function name → list of executed lines", **no invocation counts**. Functions never called are still in the list with empty `executed_lines` |
146
+ | Go `-coverprofile` (text) | Go projects (produced by `go test -coverprofile`) | A `mode:` header plus one line per block (`path:startL.startC,endL.endC stmts count`), **no function names**. `--source <module root>` is required: the producer reads top-level `func` declarations in the source to map line ranges back to functions |
147
+
148
+ The Go production commands (Go targets):
149
+
150
+ ```bash
151
+ go test ./<package>/ -coverprofile=<absolute path>.out -run <test name>
152
+ node <euthyna repo>/bin/euthyna.js coverage `
153
+ --coverage <absolute path>.out --symbol <symbol> --source <Go module root>
154
+ ```
155
+
156
+ > Go-specific notes (measured in the act case study):
157
+ > 1. **A file absent from the profile is not "never executed"** — its package may not have
158
+ > been compiled into the test binary at all. The producer reports this as "not evaluated",
159
+ > never as "not covered". To measure a package, use `-coverpkg=<package>` or run that
160
+ > package's tests directly.
161
+ > 2. Profile paths are module import paths (`module/pkg/file.go`); point `--source` at the
162
+ > module root containing go.mod, and the producer maps import paths back to disk files.
163
+ > 3. `mode: set` is enough — the two-state semantics (invoked / never invoked) do not need
164
+ > the exact counts of `count`/`atomic`.
145
165
 
146
166
  The coverage.py production commands (Python targets):
147
167
 
@@ -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 Python one — the pattern-matched "vulnerabilities" were mostly
35
- false: **5 out of 5** refuted at the gates on the first run; **2 out of 3** refuted,
36
- the third unresolved (INCONCLUSIVE, supply-chain-dependent) on the second. See the
34
+ JavaScript one, a Python one, 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 either project. See
302
- [`docs/case-study-crewai.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-crewai.md) and
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.4.1",
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
@@ -1,5 +1,5 @@
1
1
  name: euthyna
2
- version: 0.4.1
2
+ version: 0.6.0
3
3
  description: euthyna audit discipline — slash commands that drive the euthyna fact-producing CLI (history / coverage / deps / gate)
4
4
  license: Apache-2.0
5
5
  homepage: https://github.com/slow-stack/euthyna
package/src/cli.js CHANGED
@@ -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
@@ -17,11 +17,10 @@
17
17
  * There is no code path here that emits "executed". That is the design, not an
18
18
  * omission. See docs/fact-contract-zh.md section 6.2.
19
19
  */
20
- import { readFile } from 'node:fs/promises';
20
+ import { readFile, readdir } from 'node:fs/promises';
21
21
  import path from 'node:path';
22
22
  import { makeFact, notEvaluated as notEvaluatedEntry, KIND, STATUS, shellQuote } from '../contract.js';
23
23
  import { T, DEFAULT_LANG } from '../lang.js';
24
-
25
24
  /** c8's default exclusions. A production file matching one of these vanishes from the report. */
26
25
  const C8_DEFAULT_EXCLUDES = [
27
26
  /(^|\/)node_modules\//,
@@ -52,11 +51,12 @@ function matchesTarget(entryPath, target) {
52
51
  * break on any path containing a quote, and the point of the field is that a
53
52
  * reader can re-derive the claim without trusting this report.
54
53
  */
55
- function reproduceCommand({ coverageFile, symbol, file }) {
54
+ function reproduceCommand({ coverageFile, symbol, file, source }) {
56
55
  return (
57
56
  `euthyna coverage --coverage ${shellQuote(coverageFile)} ` +
58
57
  `--symbol ${shellQuote(symbol)}` +
59
- (file ? ` --file ${shellQuote(file)}` : '')
58
+ (file ? ` --file ${shellQuote(file)}` : '') +
59
+ (source ? ` --source ${shellQuote(source)}` : '')
60
60
  );
61
61
  }
62
62
 
@@ -82,6 +82,12 @@ function reproduceCommand({ coverageFile, symbol, file }) {
82
82
  * evidence that the function was never entered. coverage.py reports
83
83
  * functions that were never called as long as their module was loaded,
84
84
  * which is exactly what makes "never invoked" an established fact.
85
+ * 4. Go coverage profiles (`go test -coverprofile`) — text, `mode:` header
86
+ * plus one line per block (`path:startL.startC,endL.endC stmts count`).
87
+ * No function names: the two-state mapping runs through function ranges
88
+ * parsed from the gofmt'd source under --source (module root). A file
89
+ * absent from the profile is *not* evidence of "never executed" (the
90
+ * package may not be in the test binary), so absence is notEvaluated.
85
91
  *
86
92
  * Anything that matches none of these shapes is **not a coverage report this
87
93
  * producer can read**, and is reported as notEvaluated — never fed through the
@@ -89,9 +95,160 @@ function reproduceCommand({ coverageFile, symbol, file }) {
89
95
  */
90
96
  export async function loadCoverage(coverageFile) {
91
97
  const raw = await readFile(coverageFile, 'utf8');
98
+ if (isGoProfileText(raw)) {
99
+ const parsed = parseGoProfile(raw);
100
+ if (!parsed) {
101
+ throw new Error(
102
+ 'the file starts like a Go coverage profile ("mode:" header) but the body has lines that do not match ' +
103
+ '"file:startLine.startCol,endLine.endCol numStatements count" — refusing to interpret it as coverage'
104
+ );
105
+ }
106
+ return parsed;
107
+ }
92
108
  return JSON.parse(raw);
93
109
  }
94
110
 
111
+ /**
112
+ * Go coverage profiles (`go test -coverprofile`) are text, not JSON:
113
+ *
114
+ * mode: set
115
+ * github.com/nektos/act/pkg/artifacts/artifact.pb.go:42.2,43.29 2 0
116
+ *
117
+ * One line per *block* — a line range with a statement count and a hit count.
118
+ * There are **no function names**, so a symbol query needs the source tree
119
+ * (via --source, the module root) to map line ranges back to functions:
120
+ * goFunctionRanges() reads the gofmt'd source, whose top-level `func`
121
+ * declarations start at column 0 and end at a `}` at column 0.
122
+ */
123
+ function isGoProfileText(raw) {
124
+ const first = String(raw).split(/\r?\n/, 1)[0];
125
+ return /^mode:\s*(set|count|atomic)\s*$/.test(first);
126
+ }
127
+
128
+ const GO_BLOCK_RE = /^(.+):(\d+)\.(\d+),(\d+)\.(\d+)\s+(\d+)\s+(\d+)$/;
129
+
130
+ /** Parse a Go profile into `{ __go, mode, files: Map<path, blocks[]> }`, or null if malformed. */
131
+ export function parseGoProfile(raw) {
132
+ const lines = String(raw).split(/\r?\n/);
133
+ const mode = /^mode:\s*(set|count|atomic)\s*$/.exec((lines[0] ?? '').trim())?.[1];
134
+ if (!mode) return null;
135
+
136
+ const files = new Map();
137
+ for (let i = 1; i < lines.length; i++) {
138
+ const line = lines[i];
139
+ if (!line.trim()) continue;
140
+ const m = GO_BLOCK_RE.exec(line);
141
+ if (!m) return null;
142
+ const [, file, startLine, , endLine, , statements, count] = m;
143
+ let blocks = files.get(file);
144
+ if (!blocks) {
145
+ blocks = [];
146
+ files.set(file, blocks);
147
+ }
148
+ blocks.push({
149
+ startLine: Number(startLine),
150
+ endLine: Number(endLine),
151
+ statements: Number(statements),
152
+ count: Number(count)
153
+ });
154
+ }
155
+ return { __go: true, mode, files };
156
+ }
157
+
158
+ /**
159
+ * The code visible on each line: comments and raw-string contents blanked out,
160
+ * with multi-line `/*...*​/` and `...` regions tracked across lines.
161
+ *
162
+ * Without this, a commented-out function, a `}` at column 0 inside a block
163
+ * comment, or a raw string with a column-0 brace would create phantom function
164
+ * ranges or split real ones — and coverage blocks would be assigned to the
165
+ * wrong function, which is a false coverage result.
166
+ */
167
+ function goSignificantLines(text) {
168
+ const out = [];
169
+ let inBlock = false;
170
+ let inRaw = false;
171
+ for (const line of String(text).split(/\r?\n/)) {
172
+ let sig = '';
173
+ let i = 0;
174
+ while (i < line.length) {
175
+ const ch = line[i];
176
+ if (inBlock) {
177
+ if (ch === '*' && line[i + 1] === '/') {
178
+ inBlock = false;
179
+ i += 2;
180
+ continue;
181
+ }
182
+ i++;
183
+ continue;
184
+ }
185
+ if (inRaw) {
186
+ if (ch === '`') inRaw = false;
187
+ i++;
188
+ continue;
189
+ }
190
+ if (ch === '`') {
191
+ inRaw = true;
192
+ i++;
193
+ continue;
194
+ }
195
+ if (ch === '/' && line[i + 1] === '/') break; // rest of the line is a comment
196
+ if (ch === '/' && line[i + 1] === '*') {
197
+ inBlock = true;
198
+ i += 2;
199
+ continue;
200
+ }
201
+ sig += ch;
202
+ i++;
203
+ }
204
+ out.push(sig);
205
+ }
206
+ return out;
207
+ }
208
+
209
+ /**
210
+ * Top-level function ranges of a gofmt'd Go source file.
211
+ *
212
+ * Only `func` at column 0 of *significant* code matches: nested (indented)
213
+ * declarations are left alone, which is the point — a closure inside a
214
+ * function is part of that function's range. A function ends at the next `}`
215
+ * at column 0, or on its own declaration line when the whole function sits on
216
+ * that line (`func f() {}`), so a later package-level block is not absorbed.
217
+ * Methods are reported under their bare method name (two types may share it;
218
+ * every match is reported, mirroring how c8 reports every fnMap entry with a
219
+ * given name).
220
+ */
221
+ export function goFunctionRanges(text) {
222
+ const lines = goSignificantLines(text);
223
+ const funcs = [];
224
+ let current = null;
225
+ const declRe = /^func\s*(?:\([^)]*\))?\s*([A-Za-z_]\w*)/;
226
+
227
+ for (let i = 0; i < lines.length; i++) {
228
+ const line = lines[i];
229
+ if (current && /^}/.test(line)) {
230
+ funcs.push({ ...current, endLine: i + 1 });
231
+ current = null;
232
+ continue;
233
+ }
234
+ if (/^func\s/.test(line)) {
235
+ // a new decl with no closing brace in between: close the previous range
236
+ // at the previous line rather than swallowing the new declaration
237
+ if (current) funcs.push({ ...current, endLine: i });
238
+ const name = declRe.exec(line)?.[1];
239
+ current = name ? { name, startLine: i + 1 } : null;
240
+ // the whole function on one line (gofmt keeps empty ones there): close
241
+ // it on its own line
242
+ if (current && /\}\s*$/.test(line)) {
243
+ funcs.push({ ...current, endLine: i + 1 });
244
+ current = null;
245
+ }
246
+ }
247
+ }
248
+ if (current) funcs.push({ ...current, endLine: lines.length });
249
+ return funcs;
250
+ }
251
+
95
252
  /**
96
253
  * True for coverage.py's `{"meta": {...}, "files": {...}}` shape.
97
254
  * A c8 report never has a top-level `meta` key.
@@ -115,6 +272,7 @@ function isCoveragePyReport(coverage) {
115
272
  * honestly is still part of the fact, so the distinction is kept.
116
273
  */
117
274
  export function detectCoverageFormat(coverage) {
275
+ if (coverage && typeof coverage === 'object' && coverage.__go) return 'go';
118
276
  if (isCoveragePyReport(coverage)) return 'coverage.py';
119
277
 
120
278
  const entries = Object.entries(coverage).filter(([, v]) => v && typeof v === 'object');
@@ -210,8 +368,10 @@ function locateSymbolInEntry(entry, symbol) {
210
368
  * @param {object} options
211
369
  * @param {string} options.coverageFile
212
370
  * @param {Array<{file?: string, symbol: string}>} options.targets
371
+ * @param {string} [options.source] module root for Go profiles (--source), whose
372
+ * source files are needed to map profile line ranges back to functions
213
373
  */
214
- export async function collectCoverageFacts({ coverageFile, targets = [], lang = DEFAULT_LANG } = {}) {
374
+ export async function collectCoverageFacts({ coverageFile, targets = [], source, lang = DEFAULT_LANG } = {}) {
215
375
  const t = T(lang);
216
376
  const facts = [];
217
377
  const evaluated = [];
@@ -238,6 +398,13 @@ export async function collectCoverageFacts({ coverageFile, targets = [], lang =
238
398
 
239
399
  const isPy = isCoveragePyReport(coverage);
240
400
 
401
+ // Go profiles take a dedicated path: the report is a block list with no
402
+ // function names, so symbol location needs the source tree and the
403
+ // two-state mapping runs through function ranges, not per-file function maps.
404
+ if (coverage.__go) {
405
+ return collectGoCoverageFacts({ coverage, coverageFile, targets, source, lang });
406
+ }
407
+
241
408
  // c8: entries are the report's file keys directly.
242
409
  // coverage.py: the file entries live under `files`.
243
410
  const entries = Object.entries(isPy ? (coverage.files ?? {}) : coverage).filter(
@@ -435,3 +602,316 @@ export async function collectCoverageFacts({ coverageFile, targets = [], lang =
435
602
 
436
603
  return { facts, evaluated, notEvaluated, measured: true };
437
604
  }
605
+
606
+ /**
607
+ * The Go-profile path.
608
+ *
609
+ * Profile lines carry no function names, so the two-state mapping goes:
610
+ * block list -> function ranges from the gofmt'd source -> per-function hit.
611
+ * The module root (--source) is required; profile paths are import paths
612
+ * (`module/dir/file.go`) or absolute paths, so resolution tries go.mod's
613
+ * module prefix and the plain path under the root before giving up — and a
614
+ * file it cannot resolve is reported as notEvaluated, never as uncovered.
615
+ */
616
+ async function collectGoCoverageFacts({ coverage, coverageFile, targets, source, lang }) {
617
+ const t = T(lang);
618
+ const facts = [];
619
+ const evaluated = [];
620
+ const notEvaluated = [];
621
+ let counter = 0;
622
+
623
+ if (coverage.files.size === 0) {
624
+ notEvaluated.push(
625
+ notEvaluatedEntry(
626
+ KIND.TEST_COVERAGE,
627
+ t(
628
+ 'Go coverage profile 为空(只有 mode 头)。测试没有加载到任何被编译的包,' +
629
+ '或 -coverpkg 没有指向被测代码',
630
+ 'The Go coverage profile is empty (mode header only). The test run loaded no compiled package, ' +
631
+ 'or -coverpkg did not point at the code under test'
632
+ )
633
+ )
634
+ );
635
+ return { facts, evaluated, notEvaluated, measured: false };
636
+ }
637
+
638
+ if (targets.length === 0) {
639
+ notEvaluated.push(
640
+ notEvaluatedEntry(
641
+ KIND.TEST_COVERAGE,
642
+ t('没有指定要查询的符号(--symbol)', 'no symbol requested for query (--symbol)')
643
+ )
644
+ );
645
+ return { facts, evaluated, notEvaluated, measured: false };
646
+ }
647
+
648
+ let module = null;
649
+ if (source) {
650
+ try {
651
+ module = modulePrefix(await readFile(path.join(source, 'go.mod'), 'utf8'));
652
+ } catch {
653
+ // no go.mod at the root: relative import paths then cannot be resolved,
654
+ // and the per-file resolution below reports that honestly
655
+ }
656
+ }
657
+
658
+ const sourceCache = new Map();
659
+ async function resolveSource(profilePath) {
660
+ if (sourceCache.has(profilePath)) return sourceCache.get(profilePath);
661
+ let result = null;
662
+ if (source) {
663
+ const candidates = [];
664
+ if (path.isAbsolute(profilePath)) {
665
+ candidates.push(profilePath);
666
+ } else {
667
+ if (module && profilePath.startsWith(module + '/')) {
668
+ candidates.push(path.join(source, profilePath.slice(module.length + 1)));
669
+ }
670
+ candidates.push(path.join(source, profilePath));
671
+ }
672
+ for (const candidate of candidates) {
673
+ try {
674
+ result = { text: await readFile(candidate, 'utf8'), resolved: candidate };
675
+ break;
676
+ } catch {
677
+ // try the next candidate, then report honestly
678
+ }
679
+ }
680
+ }
681
+ sourceCache.set(profilePath, result);
682
+ return result;
683
+ }
684
+
685
+ if (!source) {
686
+ notEvaluated.push(
687
+ notEvaluatedEntry(
688
+ KIND.TEST_COVERAGE,
689
+ t(
690
+ 'Go 的 -coverprofile 是文本格式且不含函数名,函数级裁定需要 --source <模块根目录> ' +
691
+ '把行区间映射回函数。没有源码,符号查询无法进行',
692
+ 'A Go -coverprofile is text and carries no function names; a function-level verdict needs ' +
693
+ '--source <module root> to map line ranges back to functions. Without the source, symbol queries cannot run'
694
+ )
695
+ )
696
+ );
697
+ return { facts, evaluated, notEvaluated, measured: false };
698
+ }
699
+
700
+ const entries = [...coverage.files.entries()];
701
+
702
+ for (const target of targets) {
703
+ const scoped = target.file
704
+ ? entries.filter(([entryPath]) => matchesTarget(entryPath, target.file))
705
+ : entries;
706
+
707
+ if (target.file && scoped.length === 0) {
708
+ // For Go this must NOT read as "never executed": a file absent from the
709
+ // profile may simply not have been compiled into the test binary at all.
710
+ notEvaluated.push(
711
+ notEvaluatedEntry(
712
+ KIND.TEST_COVERAGE,
713
+ t(
714
+ `${target.file} 没有出现在 Go coverage profile 中 —— 该文件所在的包可能根本没被编译进这次测试二进制` +
715
+ '(--coverpkg 或运行对应包才能测到它),「未出现」不能当「未执行」',
716
+ `${target.file} does not appear in the Go coverage profile — its package may not have been compiled into the test binary at all ` +
717
+ '(-coverpkg or running that package is required to measure it), so "absent" must not be read as "never executed"'
718
+ )
719
+ )
720
+ );
721
+ continue;
722
+ }
723
+
724
+ let matched = 0;
725
+ let unresolved = false;
726
+ for (const [entryPath, blocks] of scoped) {
727
+ const src = await resolveSource(entryPath);
728
+ if (!src) {
729
+ unresolved = true;
730
+ notEvaluated.push(
731
+ notEvaluatedEntry(
732
+ KIND.TEST_COVERAGE,
733
+ t(
734
+ `profile 中的 ${entryPath} 无法在 --source ${source} 下定位(go.mod 模块前缀已尝试)—— ` +
735
+ '没有该源文件,行区间无法映射回函数',
736
+ `The profile entry ${entryPath} cannot be located under --source ${source} (the go.mod module prefix was tried) — ` +
737
+ 'without that source file, line ranges cannot be mapped back to functions'
738
+ )
739
+ )
740
+ );
741
+ continue;
742
+ }
743
+
744
+ for (const fn of goFunctionRanges(src.text)) {
745
+ if (fn.name !== target.symbol) continue;
746
+ const inFn = blocks.filter(b => b.startLine >= fn.startLine && b.startLine <= fn.endLine);
747
+ if (inFn.length === 0) continue; // no measured block: cannot answer for this function
748
+ matched++;
749
+ const invoked = inFn.some(b => b.count > 0);
750
+
751
+ if (!invoked) {
752
+ facts.push(
753
+ makeFact({
754
+ id: `coverage-${++counter}`,
755
+ kind: KIND.TEST_COVERAGE,
756
+ statement:
757
+ t(
758
+ `符号 ${target.symbol} 在本次测试运行中一次都没有被调用(函数体内所有覆盖区块的执行计数为 0)—— ` +
759
+ `任何依赖它的行为都没有被执行验证`,
760
+ `Symbol ${target.symbol} was never invoked in this test run (every coverage block inside the function has a hit count of 0) — ` +
761
+ `any behavior depending on it was never exercised`
762
+ ),
763
+ status: STATUS.ESTABLISHED,
764
+ evidence: { file: entryPath, line: fn.startLine },
765
+ method: 'command',
766
+ command: reproduceCommand({ coverageFile, symbol: target.symbol, file: entryPath, source }),
767
+ detail: { symbol: target.symbol, invocationCount: 0, reason: 'invocation_count_zero' }
768
+ })
769
+ );
770
+ } else {
771
+ facts.push(
772
+ makeFact({
773
+ id: `coverage-${++counter}`,
774
+ kind: KIND.TEST_COVERAGE,
775
+ statement:
776
+ t(
777
+ `符号 ${target.symbol} 至少被调用过一次,但被调用**不能**证明任何特定调用点执行过 —— ` +
778
+ `本事实只能证伪,不能证实`,
779
+ `Symbol ${target.symbol} was invoked at least once, but being invoked **cannot** prove any specific call site ran — ` +
780
+ `this fact can only falsify, never confirm`
781
+ ),
782
+ status: STATUS.UNKNOWN,
783
+ evidence: { file: entryPath, line: fn.startLine },
784
+ method: 'command',
785
+ command: reproduceCommand({ coverageFile, symbol: target.symbol, file: entryPath, source }),
786
+ detail: {
787
+ symbol: target.symbol,
788
+ invocationCount: 1,
789
+ reason: 'nonzero_count_cannot_prove_call_site_execution'
790
+ }
791
+ })
792
+ );
793
+ }
794
+ }
795
+ }
796
+
797
+ // "could not locate" implies we searched the data; when the source file
798
+ // needed to even read the data was missing, the notEvaluated entry above
799
+ // is the honest record and no locating claim is made.
800
+ if (matched === 0 && !unresolved) {
801
+ // With --source we can tell "not located" apart from "lives in a file the
802
+ // profile never saw": a symbol declared in a file absent from the profile
803
+ // is a refusal (its package was probably not compiled in), not an answer.
804
+ const elsewhere = await findGoSymbolOutsideProfile(coverage, source, module, target.symbol);
805
+ if (elsewhere) {
806
+ notEvaluated.push(
807
+ notEvaluatedEntry(
808
+ KIND.TEST_COVERAGE,
809
+ t(
810
+ `符号 ${target.symbol} 在 ${elsewhere} 中有顶层声明,但该文件没有出现在 Go coverage profile 里 —— ` +
811
+ '它所在的包多半没被编译进这次测试二进制(-coverpkg 或运行对应包才能测到它)。' +
812
+ '这是「未评估」,既不是「从未调用」也不是「无法定位」',
813
+ `Symbol ${target.symbol} is declared in ${elsewhere}, but that file does not appear in the Go coverage profile — ` +
814
+ 'its package was probably not compiled into this test binary (-coverpkg or running that package is required to measure it). ' +
815
+ 'This is "not evaluated": neither "never invoked" nor "could not locate"'
816
+ )
817
+ )
818
+ );
819
+ continue;
820
+ }
821
+ facts.push(
822
+ makeFact({
823
+ id: `coverage-${++counter}`,
824
+ kind: KIND.TEST_COVERAGE,
825
+ statement:
826
+ t(
827
+ `未能在覆盖率数据中定位符号 ${target.symbol}`,
828
+ `Could not locate symbol ${target.symbol} in the coverage data`
829
+ ) +
830
+ (target.file ? t(`(限定文件 ${target.file})`, ` (restricted to file ${target.file})`) : '') +
831
+ t(
832
+ ' —— 可能是被重命名、被内联,或它不是顶层 func 声明(Go profile 只携带行区间,' +
833
+ '函数边界来自源码中的顶层 func)',
834
+ ' — it may have been renamed, inlined, or it is not a top-level func declaration (a Go profile carries line ranges only; ' +
835
+ 'function boundaries come from top-level funcs in the source)'
836
+ ),
837
+ status: STATUS.UNKNOWN,
838
+ evidence: { file: target.file ?? t('(未限定文件)', '(unrestricted file)') },
839
+ method: 'static',
840
+ detail: { symbol: target.symbol, reason: 'symbol_not_located_in_fnmap' }
841
+ })
842
+ );
843
+ }
844
+ }
845
+
846
+ // A query that produced no fact and only refusals was not answered —
847
+ // "measured" must not let CI read it as clean.
848
+ const measured = facts.length > 0;
849
+ if (measured) {
850
+ evaluated.push({
851
+ kind: KIND.TEST_COVERAGE,
852
+ producer: 'euthyna-coverage',
853
+ format: 'go',
854
+ count: facts.length
855
+ });
856
+ }
857
+
858
+ return { facts, evaluated, notEvaluated, measured };
859
+ }
860
+
861
+ function modulePrefix(goModText) {
862
+ return /^\s*module\s+(\S+)/m.exec(goModText)?.[1] ?? null;
863
+ }
864
+
865
+ const GO_WALK_SKIP = new Set(['vendor', 'testdata', 'node_modules', '.git']);
866
+
867
+ /**
868
+ * Does the symbol exist in a .go file that the profile does not cover?
869
+ *
870
+ * Walks the module root (skipping vendor/testdata and hidden dirs), maps each
871
+ * file to its import path, and reports the first file — relative to the
872
+ * module root — whose top-level functions include the symbol while the file
873
+ * itself is absent from the profile. The whole-tree scan only runs on the
874
+ * rare "matched 0" path, so the common query pays nothing for it.
875
+ */
876
+ async function findGoSymbolOutsideProfile(coverage, source, module, symbol) {
877
+ if (!source) return null;
878
+
879
+ // absolute disk paths the profile already accounts for
880
+ const covered = new Set();
881
+ for (const profilePath of coverage.files.keys()) {
882
+ if (path.isAbsolute(profilePath)) {
883
+ covered.add(path.resolve(profilePath).replace(/\\/g, '/').toLowerCase());
884
+ }
885
+ }
886
+
887
+ async function* walk(dir) {
888
+ let entries;
889
+ try {
890
+ entries = await readdir(dir, { withFileTypes: true });
891
+ } catch {
892
+ return;
893
+ }
894
+ for (const entry of entries) {
895
+ if (entry.name.endsWith('.go')) yield path.join(dir, entry.name);
896
+ else if (entry.isDirectory() && !GO_WALK_SKIP.has(entry.name) && !entry.name.startsWith('.')) {
897
+ yield* walk(path.join(dir, entry.name));
898
+ }
899
+ }
900
+ }
901
+
902
+ for await (const file of walk(source)) {
903
+ const abs = path.resolve(file).replace(/\\/g, '/').toLowerCase();
904
+ if (covered.has(abs)) continue;
905
+ const rel = path.relative(source, file).replace(/\\/g, '/');
906
+ const importPath = module ? `${module}/${rel}` : rel;
907
+ if (coverage.files.has(importPath) || coverage.files.has(rel)) continue;
908
+ let text;
909
+ try {
910
+ text = await readFile(file, 'utf8');
911
+ } catch {
912
+ continue;
913
+ }
914
+ if (goFunctionRanges(text).some(fn => fn.name === symbol)) return rel;
915
+ }
916
+ return null;
917
+ }
package/src/facts/deps.js CHANGED
@@ -425,11 +425,11 @@ export async function collectDependencyFacts({ cwd = process.cwd(), lockfile, de
425
425
  t(
426
426
  `依赖 ${dep} 在 ${listLabel}(${target.type})中被${verb}为版本 ${versions.join(' / ')}` +
427
427
  (isGo
428
- ? ' —— 这是声明的需求版本,非最终解析版本(go.sum 不含版本,无法在此验证解析结果)'
428
+ ? ' —— 这是声明的需求版本,非最终解析版本(选定版本由 MVS 对整个模块图求解,go.sum 记录版本与哈希但不含选定的构建清单,deps 不做模块图解析)'
429
429
  : ''),
430
430
  isGo
431
431
  ? `Dependency ${dep} is declared as version ${versions.join(' / ')} in the ${listLabel} (${target.type})` +
432
- ' — this is the declared requirement version, not the final resolved version (go.sum carries no versions, so the resolution cannot be verified here)'
432
+ ' — this is the declared requirement version, not the final resolved version (the selected version comes from MVS over the whole module graph; go.sum records version-hash pairs but not the selected build list, and this producer does not resolve the module graph)'
433
433
  : `Dependency ${dep} is locked to version ${versions.join(' / ')} in the ${listLabel} (${target.type})`
434
434
  ),
435
435
  status: STATUS.ESTABLISHED,