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 +65 -17
- package/package.json +1 -1
- package/src/cli.js +157 -6
- package/src/contract.js +2 -1
- package/src/facts/coverage.js +55 -2
- package/src/facts/deps.js +366 -0
- package/src/facts/history.js +322 -25
- package/src/gate.js +381 -0
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
|
-
|
|
50
|
-
|
|
51
|
-
- **`history`** — for every line a change deletes, it
|
|
52
|
-
|
|
53
|
-
|
|
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 #
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
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. */
|
package/src/facts/coverage.js
CHANGED
|
@@ -62,14 +62,18 @@ function reproduceCommand({ coverageFile, symbol, file }) {
|
|
|
62
62
|
/**
|
|
63
63
|
* Read a coverage report.
|
|
64
64
|
*
|
|
65
|
-
*
|
|
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.
|
|
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
|
|