euthyna 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -46,11 +46,14 @@ between you and the agent:
46
46
 
47
47
  ### 1. It measures what a model cannot
48
48
 
49
- Two fact producers — a zero-dependency Node CLI:
50
-
51
- - **`history`** — for every line a change deletes, it finds the commit that introduced
52
- that line and classifies that commit from its own message. If the deleted code came
53
- from a security fix, that is flagged. This is git archaeology no model can do from
49
+ Three fact producers — a zero-dependency Node CLI:
50
+
51
+ - **`history`** — for every line a change deletes, it attributes the line to a commit
52
+ and classifies that commit from its message, its own diff, and the deleted line's
53
+ content (a deleted `if (!authorized)` is flagged even under a "tweaks" subject). By
54
+ default the attribution is blame's "last touched"; `--origins` digs for the commit
55
+ that *first introduced* the content with `git log -S`. If the deleted code came from
56
+ a security fix, that is flagged. This is git archaeology no model can do from
54
57
  reading a diff.
55
58
  - **`coverage`** — was this symbol *ever actually invoked* by a test? It has exactly two
56
59
  answers: never invoked (established), or entered but that proves nothing about any
@@ -58,6 +61,12 @@ Two fact producers — a zero-dependency Node CLI:
58
61
  unreachable code as covered, and "line covered → call ran" is wrong in exactly the
59
62
  direction an audit cannot afford. The reasoning is in
60
63
  [`docs/fact-contract.md`](https://github.com/slow-stack/euthyna/blob/main/docs/fact-contract.md) §6.2.
64
+ - **`deps`** — what version is a dependency *actually* pinned to? It reads the lockfile
65
+ (package-lock.json / Cargo.lock / go.mod) and reports the resolved versions, or that a
66
+ dependency is absent from the tree entirely. This is the fact that resolves
67
+ supply-chain claims ("the app uses a vulnerable version of X"): the version-to-CVE
68
+ mapping is left to the adjudication layer, exactly as the fact contract requires. See
69
+ [`docs/fact-contract.md`](https://github.com/slow-stack/euthyna/blob/main/docs/fact-contract.md) §6.3.
61
70
 
62
71
  ### 2. It gates what the agent claims
63
72
 
@@ -67,6 +76,14 @@ impact, and their counterparts. A claim that cannot produce evidence is **downgr
67
76
  an observation**, not reported as a finding. "I'm done" becomes a package: claims,
68
77
  evidence, and the commands that reproduce both.
69
78
 
79
+ The gates are not only prose. `euthyna gate <report>` reads an adjudication report and
80
+ mechanically checks every finding against its verdict — evidence down to `path:L123`, a
81
+ reproduce command, an impact statement, consistent gate statuses — and downgrades
82
+ whatever does not measure up. `--verify` re-runs the reproduce commands: `git` commands
83
+ by default, interpreter commands (`node`/`npm`/`python`) only with the explicit
84
+ `--allow-exec`, because an interpreter command from a report is arbitrary code and the
85
+ flag is the caller vouching for that report.
86
+
70
87
  ---
71
88
 
72
89
  ## 🖥️ Which tools it works in, and how to install
@@ -79,6 +96,8 @@ the project is deliberately zero-dependency.
79
96
  | **DSH** | Copy `.agents/skills/euthyna/` into `~/.agents/skills/` (user-wide) or `<project>/.agents/skills/`. Markdown hot-reloads; no restart needed. | `npm install -g euthyna` — runs in any terminal |
80
97
  | **Claude Code** | Copy the same folder into `~/.claude/skills/` | Same |
81
98
  | **Codex** | The same Markdown layer works; packaging goes through Codex's plugin/marketplace format | Same |
99
+ | **Hermes** | Copy the same folder into `~/.hermes/skills/` under a category folder (Hermes reads the open skill standard; or install from a repo with `hermes skills install`) | Same |
100
+ | **OpenCode** | Copy the same folder into `~/.agents/skills/` or `~/.config/opencode/skills/` (OpenCode loads both; unknown frontmatter fields are ignored) | Same |
82
101
  | **Any terminal** | — | `npm install -g euthyna`, then `euthyna …` |
83
102
 
84
103
  Two honest notes:
@@ -98,8 +117,10 @@ Two honest notes:
98
117
 
99
118
  ```sh
100
119
  npm install -g euthyna
101
- euthyna history --repo <path> --base main --head HEAD
120
+ euthyna history --repo <path> --base main --head HEAD # add --origins to chase the first introducer
102
121
  euthyna coverage --coverage coverage/coverage-final.json --symbol <name>
122
+ euthyna deps --repo <path> --dep <name>
123
+ euthyna gate <adjudication-report.md> --verify --cwd <repo> # mechanically check the six gates
103
124
  ```
104
125
 
105
126
  Or without a global install: `npx euthyna history --repo <path> --base main`.
@@ -108,7 +129,7 @@ From a checkout instead (development):
108
129
 
109
130
  ```sh
110
131
  git clone https://github.com/slow-stack/euthyna
111
- cd euthyna && npm test # 92 tests; no install step exists
132
+ cd euthyna && npm test # 167 tests; no install step exists
112
133
  node bin/euthyna.js history --repo <path> --base main --head HEAD
113
134
  ```
114
135
 
@@ -122,10 +143,11 @@ What `history` reports looks like this:
122
143
  复现: git blame --porcelain -L 104,104 -L 114,114 <base> -- lib/checks/.../evaluate.js
123
144
  ```
124
145
 
125
- Every deleted line is blamed back to the commit that introduced it, and the `复现`
146
+ Every deleted line is blamed back to a commit, and the `复现`
126
147
  (Reproduce) command lets you re-derive the claim yourself without trusting the report.
127
- Add `--json` for the structured fact report, and `--pickaxe` to detect lines that were
128
- removed and are now being added back.
148
+ Add `--json` for the structured fact report, `--pickaxe` to detect lines that were
149
+ removed and are now being added back, and `--origins` to attribute deleted lines to the
150
+ commit that first introduced their content rather than to blame's last modifier.
129
151
 
130
152
  ### Exit codes are part of the contract
131
153
 
@@ -134,10 +156,10 @@ process exit code**, which makes their output unusable as a CI gate. This one do
134
156
 
135
157
  | Code | Meaning |
136
158
  |---|---|
137
- | `0` | Measured; nothing security-classified found |
138
- | `10` | Measured; at least one `security`-classified fact exists |
159
+ | `0` | Measured; nothing security-classified found — or a `gate` report fully passes |
160
+ | `10` | Measured; at least one `security`-classified fact exists — or a `gate` report has findings downgraded to observations |
139
161
  | `1` | Usage error |
140
- | `2` | **Could not measure at all** — must not be read as clean |
162
+ | `2` | **Could not measure at all** — must not be read as clean (also: a `gate` report that cannot be read or has no findings) |
141
163
 
142
164
  `2` being distinct from `0` is the whole point: *failing to measure* and *measuring and
143
165
  finding nothing* are different things.
@@ -148,9 +170,9 @@ finding nothing* are different things.
148
170
 
149
171
  | Piece | What it is | Status |
150
172
  |---|---|---|
151
- | **Fact producers** | A zero-dependency Node CLI that answers two questions deterministically | Working, tested |
173
+ | **Fact producers** | A zero-dependency Node CLI that answers three questions deterministically | Working, tested |
152
174
  | **The skill** | The audit discipline itself, as loadable Markdown | Working, loadable |
153
- | **The benchmark** | A blind recall measurement for the adjudication layer | Four rounds complete |
175
+ | **The benchmark** | A blind recall measurement for the adjudication layer | Four rounds complete; the loop is one command (`bench/adjudicate.js`), with a deterministic golden round on CI |
154
176
 
155
177
  ---
156
178
 
@@ -165,8 +187,14 @@ This project tries to be explicit about the difference. Current state:
165
187
  [crewAI](https://github.com/crewAIInc/crewAI) (Python); deleted lines attributed to the
166
188
  commits that introduced them, then checked **by hand** against `git blame`. The checks
167
189
  developed for that comparison now run as regression tests in the suite.
168
- - **`coverage` on real output in two formats** — c8/V8 JSON and coverage.py JSON (format 3) —
169
- distinguishing all three states correctly.
190
+ - **`coverage` on real output in three formats** — c8/V8 JSON, classic istanbul (jest/nyc, same
191
+ fnMap/f shape) and coverage.py JSON (format 3) — distinguishing all three states correctly, and
192
+ refusing anything that is not a recognizable coverage report instead of answering "symbol not
193
+ located" against it.
194
+ - **`deps` against a real lockfile** — resolved versions reported with a line-level evidence
195
+ pointer into the lockfile, and absent dependencies reported as established absences rather
196
+ than silent skips. Verified against a populated npm v3 lockfile and fixture lockfiles for
197
+ Cargo.lock and go.mod.
170
198
  - **Adjudication recall and specificity**, measured blind: **10/10 cases**, 4 real
171
199
  vulnerabilities all caught, 6 non-vulnerabilities all correctly cleared, no abstentions.
172
200
  Round 2 repeated every case three times — **30 adjudications, zero flips**, four of them
@@ -186,6 +214,16 @@ This project tries to be explicit about the difference. Current state:
186
214
  and [`bench/RESULTS-round4.md`](https://github.com/slow-stack/euthyna/blob/main/bench/RESULTS-round4.md).
187
215
  - **The delivery-gate mechanism**, by running the real host plugin: blocking works, and the two
188
216
  documented ways of getting it wrong do not. See [`docs/dsh-stop-gate.md`](https://github.com/slow-stack/euthyna/blob/main/docs/dsh-stop-gate.md).
217
+ - **The gate discipline, mechanically.** `euthyna gate` is not a claim in prose: the suite
218
+ pins the contract per verdict (a TRUE POSITIVE without `path:L123` evidence, a reproduce
219
+ command or all six gates passing is downgraded; a FALSE POSITIVE needs a failing gate with
220
+ a reason), and `--verify` is tested to *not* execute an interpreter command without
221
+ `--allow-exec`. `test/skill.test.js` fails CI if the skill text stops declaring the six
222
+ gates, the three verdicts, or the command that enforces them.
223
+ - **The adjudication loop, end to end and on CI.** `bench/adjudicate.js` runs a whole round —
224
+ blind tree, one adjudicator process per case, machine-validated reports, scoring — and a
225
+ deterministic `golden` round runs on every push, so the pipeline (and `score.js` going red
226
+ on a wrong verdict) is checked without a model.
189
227
 
190
228
  ### Not verified
191
229
 
@@ -197,6 +235,16 @@ This project tries to be explicit about the difference. Current state:
197
235
  - **Recall in the field.** The benchmark's real-bug cases are constructed; whether the
198
236
  discipline helps on code nobody staged for it is unmeasured.
199
237
  - **Source maps, bundlers, monorepos** for the coverage producer. Untested.
238
+ - **A real model round on CI.** The golden round is deterministic plumbing, not an
239
+ adjudication: it writes the ground truth into the reports by design. A model round needs
240
+ credentials and is non-deterministic by nature, so it stays a local/manual step
241
+ (`bench/README.md` documents the command) rather than a CI gate that could flake.
242
+ - **`--verify` as a sandbox.** It is not one, and does not claim to be: it executes the
243
+ report's commands with your privileges, `git` only unless `--allow-exec` is passed.
244
+ - **`deps` beyond three formats and version facts only.** pnpm/yarn/poetry lockfiles are
245
+ detected but not parsed (reported as *not evaluated*, never guessed at); go.mod reports the
246
+ *declared* requirement, not the resolved build version; and the producer never maps a version
247
+ to a CVE — that mapping is deliberately left to the adjudication layer.
200
248
  - **Anything about the case-study targets' security** — the runs found nothing to endorse or
201
249
  condemn; that is not a statement about either project. See
202
250
  [`docs/case-study-crewai.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-crewai.md) and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euthyna",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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
  "bin": {
package/src/cli.js CHANGED
@@ -15,8 +15,11 @@
15
15
  */
16
16
  import process from 'node:process';
17
17
  import path from 'node:path';
18
+ import { readFile } from 'node:fs/promises';
18
19
  import { collectHistoryFacts } from './facts/history.js';
19
20
  import { collectCoverageFacts } from './facts/coverage.js';
21
+ import { collectDependencyFacts } from './facts/deps.js';
22
+ import { parseGateReport, validateFindings, verifyFinding, renderGateReport } from './gate.js';
20
23
  import { makeReport, renderReport, safeTextLines, KIND } from './contract.js';
21
24
  import { repoToplevel, revParse } from './git.js';
22
25
 
@@ -37,6 +40,11 @@ const COVERAGE_PRODUCER = {
37
40
  version: '0.1.0',
38
41
  purpose: '符号调用计数(只能证伪)'
39
42
  };
43
+ const DEPS_PRODUCER = {
44
+ name: 'euthyna-deps',
45
+ version: '0.1.0',
46
+ purpose: '依赖锁定版本(读取 lockfile,不含漏洞判定)'
47
+ };
40
48
 
41
49
  const HELP = `
42
50
  euthyna —— 给 AI 编码 agent 用的确定性事实产出器
@@ -44,24 +52,40 @@ euthyna —— 给 AI 编码 agent 用的确定性事实产出器
44
52
  它不是扫描器。它只回答两个模型算不准的问题,并把答案写成带证据的事实。
45
53
 
46
54
  用法:
47
- euthyna history --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--json]
55
+ euthyna history --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--origins] [--json]
48
56
  euthyna coverage --coverage <file> --symbol <name> [--file <path>] [--json]
57
+ euthyna deps [--repo <dir>] [--lockfile <file>] --dep <name> [--dep <name>] [--json]
58
+ euthyna gate <报告文件> [--verify] [--cwd <dir>] [--json]
49
59
 
50
60
  命令:
51
61
  history 本次变更删掉了哪些代码、它们分别由哪个提交引入、该提交是不是安全修复
52
62
  --base 必填,比较的基线版本(如 main、HEAD~5、某个 commit)
53
63
  --head 可选,默认 HEAD
54
64
  --pickaxe 额外检查「曾被移除又加回来」的新增行(有探针上限)
65
+ --origins 用 git log -S 把删除行归属到「最初引入」该内容的提交,
66
+ 而非 blame 的「最后修改者」(有探针上限,比默认慢)
55
67
  coverage 某个符号在测试运行中到底有没有被调用过
56
68
  --coverage 覆盖率数据文件,c8 的 coverage-final.json
57
69
  --symbol 要查询的符号名,可重复
58
70
  --file 可选,限定到某个文件
71
+ deps 某个依赖在 lockfile 里被锁定/声明成什么版本(供应链声明的裁决依据)
72
+ --repo 可选,依赖清单所在目录(默认当前目录,自动检测)
73
+ --lockfile 可选,显式指定清单文件(支持 package-lock.json / Cargo.lock / go.mod)
74
+ --dep 要查询的依赖名,可重复
75
+ 注:只报版本事实,不判「是否含漏洞」——版本到 CVE 的映射归判定层
76
+ gate 检查一份审计报告是否符合 6 门禁契约(不测量,只核对报告的自我声明)
77
+ <报告文件> 报告的 markdown 文件,裁定格式见技能 SKILL.md
78
+ --verify 重跑每条 TRUE POSITIVE 的复现命令(按 argv 执行,不经过 shell)
79
+ 默认只执行 git 命令;⚠ 执行结果以你的权限生效,只对你信任的报告用
80
+ --allow-exec 允许 --verify 执行解释器命令(node/npm/python)——
81
+ 它们能跑报告里的任意代码,加了它就等于你为该报告背书
82
+ --cwd <dir> --verify 的工作目录(默认当前目录)
59
83
 
60
84
  退出码:
61
- 0 已测量,没有安全相关的发现
62
- 10 已测量,且存在被分类为 security 的事实
85
+ 0 已测量,没有安全相关的发现;或 gate 报告全部通过门禁契约
86
+ 10 已测量,且存在被分类为 security 的事实;或 gate 报告有 finding 被降级
63
87
  1 用法错误
64
- 2 完全无法测量(此时**不得**当作干净)
88
+ 2 完全无法测量(此时**不得**当作干净);或 gate 报告无法读取/没有可校验的 finding
65
89
 
66
90
  注意: 缺数据不等于干净。无法测量的判据会列在输出的「未评估的判据」一节。
67
91
  `;
@@ -146,7 +170,8 @@ async function runHistory(flags) {
146
170
  cwd: toplevel,
147
171
  base,
148
172
  head,
149
- pickaxe: flags.pickaxe === true
173
+ pickaxe: flags.pickaxe === true,
174
+ origins: flags.origins === true
150
175
  });
151
176
 
152
177
  const report = makeReport({
@@ -198,6 +223,123 @@ async function runCoverage(flags) {
198
223
  return { exit: measured ? EXIT.CLEAN : EXIT.UNMEASURED, report };
199
224
  }
200
225
 
226
+ async function runDeps(flags) {
227
+ const cwd = path.resolve(flags.repo ? String(flags.repo) : process.cwd());
228
+ const deps = asArray(flags.dep).map(String);
229
+ const lockfile = flags.lockfile ? String(flags.lockfile) : undefined;
230
+
231
+ // Asking for nothing is a caller mistake, not a measurement failure.
232
+ if (deps.length === 0) {
233
+ return { exit: EXIT.USAGE, error: 'deps 需要至少一个 --dep <name>' };
234
+ }
235
+
236
+ const { facts, evaluated, notEvaluated, measured } = await collectDependencyFacts({
237
+ cwd,
238
+ lockfile,
239
+ deps
240
+ });
241
+
242
+ const report = makeReport({
243
+ producer: DEPS_PRODUCER,
244
+ subject: { repo: cwd, lockfile: lockfile ?? '(自动检测)', deps },
245
+ facts,
246
+ evaluated,
247
+ notEvaluated
248
+ });
249
+
250
+ return { exit: measured ? EXIT.CLEAN : EXIT.UNMEASURED, report };
251
+ }
252
+ async function runGate(flags, positional) {
253
+ const file = positional[1];
254
+ if (!file) {
255
+ return { exit: EXIT.USAGE, error: 'gate 需要 <报告文件>(markdown,裁定格式见技能 SKILL.md)' };
256
+ }
257
+
258
+ let text;
259
+ try {
260
+ text = await readFile(path.resolve(file), 'utf8');
261
+ } catch (error) {
262
+ // An unreadable report is not a clean pass: there is nothing to check, and
263
+ // "nothing was checked" must not read as "everything passed".
264
+ return { exit: EXIT.UNMEASURED, error: `无法读取报告 ${file}: ${error.message}` };
265
+ }
266
+
267
+ const { findings, unparseable } = parseGateReport(text);
268
+ if (findings.length === 0 && unparseable.length === 0) {
269
+ return { exit: EXIT.UNMEASURED, error: `报告 ${file} 中没有可校验的 finding(需要 BUG #N <VERDICT> — 说明 形式)` };
270
+ }
271
+
272
+ const validated = validateFindings(findings);
273
+ for (const line of unparseable) {
274
+ validated.push({ unparseableLine: line, violations: ['无法解析的 BUG 行'], downgraded: true });
275
+ }
276
+
277
+ if (flags.verify) {
278
+ const cwd = path.resolve(flags.cwd ? String(flags.cwd) : process.cwd());
279
+ const allowInterpreters = flags['allow-exec'] === true;
280
+ for (const entry of validated) {
281
+ if (entry.unparseableLine) continue;
282
+ entry.verification = await verifyFinding(entry.finding, { cwd, allowInterpreters });
283
+ const v = entry.verification;
284
+ // Every status other than "verified" means the reproduction claim was
285
+ // not actually checked, which is a downgrade: a refused tool, an
286
+ // interpreter awaiting consent, an unparseable command and a failed run
287
+ // are different failures, but none of them is a verified reproduction.
288
+ if (v.status === 'failed') {
289
+ entry.violations.push(`复现命令未通过(exit ${v.exitCode ?? '?'}${v.detail ? `: ${v.detail}` : ''})`);
290
+ entry.downgraded = true;
291
+ } else if (v.status === 'refused') {
292
+ entry.violations.push(`复现命令被拒绝(${v.tool} 不在白名单)——复现未验证`);
293
+ entry.downgraded = true;
294
+ } else if (v.status === 'needs-consent') {
295
+ entry.violations.push(`复现命令是解释器命令(${v.tool}),未执行——复现未验证;信任该报告时加 --allow-exec`);
296
+ entry.downgraded = true;
297
+ } else if (v.status === 'unparseable') {
298
+ entry.violations.push('复现命令无法拆分为 argv——复现未验证');
299
+ entry.downgraded = true;
300
+ }
301
+ // no-command is already a structural violation (a TRUE POSITIVE without a
302
+ // reproduce line), so --verify does not double-report it.
303
+ }
304
+ }
305
+
306
+ const downgraded = validated.filter(e => e.downgraded).length;
307
+ const result = { file, findings: validated, unparseable, downgraded, verify: flags.verify === true };
308
+
309
+ if (flags.json) {
310
+ return {
311
+ exit: downgraded > 0 ? EXIT.FLAGGED : EXIT.CLEAN,
312
+ json: JSON.stringify(
313
+ {
314
+ command: 'gate',
315
+ file,
316
+ findings: validated.map(e => ({
317
+ ...(e.unparseableLine
318
+ ? { unparseable: e.unparseableLine }
319
+ : {
320
+ number: e.finding.number,
321
+ verdict: e.finding.verdict,
322
+ claim: e.finding.claim,
323
+ evidence: e.finding.evidence,
324
+ reproduce: e.finding.reproduce,
325
+ impact: e.finding.impact,
326
+ gates: e.finding.gates
327
+ }),
328
+ violations: e.violations,
329
+ downgraded: e.downgraded
330
+ })),
331
+ downgraded
332
+ },
333
+ null,
334
+ 2
335
+ )
336
+ };
337
+ }
338
+
339
+ renderGateReport(result);
340
+ return { exit: downgraded > 0 ? EXIT.FLAGGED : EXIT.CLEAN, report: null };
341
+ }
342
+
201
343
  /** Entry point. Returns the process exit code. */
202
344
  export async function main(argv = process.argv.slice(2)) {
203
345
  const { positional, flags } = parseArgs(argv);
@@ -213,6 +355,10 @@ export async function main(argv = process.argv.slice(2)) {
213
355
  result = await runHistory(flags);
214
356
  } else if (command === 'coverage') {
215
357
  result = await runCoverage(flags);
358
+ } else if (command === 'deps') {
359
+ result = await runDeps(flags);
360
+ } else if (command === 'gate') {
361
+ result = await runGate(flags, positional);
216
362
  } else {
217
363
  // stderr boundary, mirroring the render boundary in contract.js: text that
218
364
  // reaches the error channel may carry user or repo-controlled bytes (an
@@ -228,9 +374,14 @@ export async function main(argv = process.argv.slice(2)) {
228
374
  return result.exit;
229
375
  }
230
376
 
377
+ if (result.json) {
378
+ process.stdout.write(`${result.json}\n`);
379
+ return result.exit;
380
+ }
381
+
231
382
  if (flags.json) {
232
383
  process.stdout.write(`${JSON.stringify(result.report, null, 2)}\n`);
233
- } else {
384
+ } else if (result.report) {
234
385
  renderReport(result.report);
235
386
  }
236
387
 
package/src/contract.js CHANGED
@@ -66,7 +66,8 @@ export function safeTextLines(value) {
66
66
  export const KIND = Object.freeze({
67
67
  HISTORY: 'history',
68
68
  REINTRODUCTION: 'reintroduction',
69
- TEST_COVERAGE: 'test_coverage'
69
+ TEST_COVERAGE: 'test_coverage',
70
+ DEPENDENCY: 'dependency'
70
71
  });
71
72
 
72
73
  /** Fact status. `unknown` is a first-class value, not an error. */
@@ -62,14 +62,18 @@ function reproduceCommand({ coverageFile, symbol, file }) {
62
62
  /**
63
63
  * Read a coverage report.
64
64
  *
65
- * Two formats are accepted:
65
+ * Three formats are accepted, all identified by shape rather than by filename:
66
66
  *
67
67
  * 1. c8 / v8-to-istanbul `coverage-final.json` — per-file keys are
68
68
  * path/all/statementMap/s/branchMap/b/fnMap/f. There is no istanbul `hash`,
69
69
  * and `branchMap` is a relabelled V8 block range rather than an if/else
70
70
  * model, so a consumer written against classic istanbul field lists reads
71
71
  * the wrong thing.
72
- * 2. coverage.py JSON (`coverage json`, format 3) — top level is
72
+ * 2. classic istanbul (jest's default provider, nyc) `coverage-final.json`
73
+ * the same per-file statementMap/s/branchMap/b/fnMap/f plus a `hash` field.
74
+ * The symbol locator only reads fnMap/f, which classic istanbul and c8 emit
75
+ * in the same shape, so both resolve through the same code path.
76
+ * 3. coverage.py JSON (`coverage json`, format 3) — top level is
73
77
  * `{meta, files}`, and per-file `functions` maps a function name
74
78
  * (`name`, or `Class.method` for methods) to
75
79
  * `{executed_lines, missing_lines, start_line, ...}`. There is **no
@@ -77,6 +81,10 @@ function reproduceCommand({ coverageFile, symbol, file }) {
77
81
  * evidence that the function was never entered. coverage.py reports
78
82
  * functions that were never called as long as their module was loaded,
79
83
  * which is exactly what makes "never invoked" an established fact.
84
+ *
85
+ * Anything that matches none of these shapes is **not a coverage report this
86
+ * producer can read**, and is reported as notEvaluated — never fed through the
87
+ * locator, where it would answer "symbol not located" with a straight face.
80
88
  */
81
89
  export async function loadCoverage(coverageFile) {
82
90
  const raw = await readFile(coverageFile, 'utf8');
@@ -98,6 +106,35 @@ function isCoveragePyReport(coverage) {
98
106
  );
99
107
  }
100
108
 
109
+ /**
110
+ * Identify the coverage format by shape, or null when the file is not a
111
+ * coverage report at all. c8 and classic istanbul share the fnMap/f shape the
112
+ * locator reads; they differ only in `hash` (istanbul has it, c8 does not) and
113
+ * `all` (c8 has it), neither of which the locator reads — but naming the format
114
+ * honestly is still part of the fact, so the distinction is kept.
115
+ */
116
+ export function detectCoverageFormat(coverage) {
117
+ if (isCoveragePyReport(coverage)) return 'coverage.py';
118
+
119
+ const entries = Object.entries(coverage).filter(([, v]) => v && typeof v === 'object');
120
+ if (entries.length === 0) return null;
121
+
122
+ // A real c8/istanbul entry carries both `fnMap` (function metadata) and `f`
123
+ // (per-index invocation counts). Requiring both stops a partial shape — a
124
+ // file with `fnMap` but no `f`, say — from being classified as coverage and
125
+ // then emitting "never invoked" for a count a missing `f` defaults to zero.
126
+ // coverage.py's per-file `functions` is only valid inside a `meta.files`
127
+ // report, which isCoveragePyReport already handled above; a bare `functions`
128
+ // object is not a JS report and must not be accepted here.
129
+ const jsShaped = entries.some(
130
+ ([, e]) =>
131
+ e.fnMap && typeof e.fnMap === 'object' && e.f && typeof e.f === 'object'
132
+ );
133
+ if (!jsShaped) return null;
134
+
135
+ return entries.some(([, e]) => typeof e === 'object' && 'hash' in e) ? 'istanbul' : 'c8';
136
+ }
137
+
101
138
  /**
102
139
  * Locate a symbol inside one file entry and normalise the hit.
103
140
  *
@@ -215,6 +252,21 @@ export async function collectCoverageFacts({ coverageFile, targets = [] } = {})
215
252
  return { facts, evaluated, notEvaluated, measured: false };
216
253
  }
217
254
 
255
+ // A non-empty object that matches no known shape is not a coverage report at
256
+ // all. Feeding it through the locator would answer "symbol not located" with
257
+ // a straight face — the confident wrong answer this producer exists to refuse.
258
+ const format = detectCoverageFormat(coverage);
259
+ if (format === null) {
260
+ notEvaluated.push(
261
+ notEvaluatedEntry(
262
+ KIND.TEST_COVERAGE,
263
+ '覆盖率数据不是可识别的报告形状(既无 c8/istanbul 的 fnMap/f,也无 coverage.py 的 functions)——' +
264
+ '它可能根本不是覆盖率文件,任何「符号未定位/未覆盖」的结论在此都不可信'
265
+ )
266
+ );
267
+ return { facts, evaluated, notEvaluated, measured: false };
268
+ }
269
+
218
270
  if (targets.length === 0) {
219
271
  notEvaluated.push(notEvaluatedEntry(KIND.TEST_COVERAGE, '没有指定要查询的符号(--symbol)'));
220
272
  return { facts, evaluated, notEvaluated, measured: false };
@@ -331,6 +383,7 @@ export async function collectCoverageFacts({ coverageFile, targets = [] } = {})
331
383
  evaluated.push({
332
384
  kind: KIND.TEST_COVERAGE,
333
385
  producer: 'euthyna-coverage',
386
+ format,
334
387
  count: facts.length
335
388
  });
336
389