euthyna 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/euthyna/SKILL.md +18 -1
- package/.agents/skills/euthyna/references/fact-producers.md +9 -0
- package/.agents/skills/euthyna-en/SKILL.md +22 -1
- package/.agents/skills/euthyna-en/references/fact-producers.md +7 -0
- package/.claude/commands/euthyna.md +2 -2
- package/.cursor/commands/euthyna.md +1 -1
- package/.github/prompts/euthyna.prompt.md +1 -1
- package/.opencode/commands/euthyna.md +4 -2
- package/README.md +10 -0
- package/package.json +1 -1
- package/plugin.yaml +1 -1
- package/src/cli.js +118 -33
- package/src/contract.js +6 -1
- package/src/git.js +16 -0
- package/src/graph.js +73 -0
|
@@ -92,6 +92,23 @@ license: Apache-2.0
|
|
|
92
92
|
**可以做多个阶段,但阶段之间不许互相替代。**
|
|
93
93
|
特别是:**阶段 B 报出的疑似发现,必须逐个走阶段 C 验证**,不许直接写进报告当结论。
|
|
94
94
|
|
|
95
|
+
### 模式词(斜杠命令)
|
|
96
|
+
|
|
97
|
+
用户用斜杠命令进来时,按下表把模式映射到阶段与第一条命令。**词表只在这里维护**,
|
|
98
|
+
各宿主命令文件(Cursor / OpenCode / Copilot / Hermes)指向本节,不各自重抄一份。
|
|
99
|
+
|
|
100
|
+
| 你打 | 走哪条支路 | 第一条命令 |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `/euthyna pr` | 阶段 B 变更面 | `euthyna audit`(省略 `--base` 即按分叉点推断并声明) |
|
|
103
|
+
| `/euthyna graph` | 阶段 B 的来源谱系视图 | `euthyna audit --graph`(同样可省略 `--base`) |
|
|
104
|
+
| `/euthyna repo` | 阶段 A 依赖面 | `euthyna deps --all` |
|
|
105
|
+
| `/euthyna claim <结论>` | 阶段 C 结论面 | `euthyna coverage --symbol <名>` |
|
|
106
|
+
| `/euthyna gate <报告>` | 校验一份已写的报告 | `euthyna gate <报告>` |
|
|
107
|
+
| `/euthyna help` | 只打印这张表 | 不跑命令,不改变状态 |
|
|
108
|
+
|
|
109
|
+
`/euthyna graph` 不是新测量:`--graph` 只是把 `history` 已经给出的归属画成 Mermaid。
|
|
110
|
+
**图上没有第二条边不等于内容没被移过**,边界见 references/fact-producers.md。
|
|
111
|
+
|
|
95
112
|
---
|
|
96
113
|
|
|
97
114
|
## 本技能自带的两个确定性测量
|
|
@@ -106,7 +123,7 @@ license: Apache-2.0
|
|
|
106
123
|
| `coverage` | 某个符号在测试运行中**到底有没有被调用过**? | 报告里要说「测过 / 没测过」时(阶段 B / C) |
|
|
107
124
|
|
|
108
125
|
```powershell
|
|
109
|
-
#
|
|
126
|
+
# --base 可以省略:省略时按 merge-base(HEAD, origin/main) 推断,并在报告里声明为 range-inferred
|
|
110
127
|
node <euthyna 仓库>/bin/euthyna.js audit --base <改前版本> --repo <被审计的仓库>
|
|
111
128
|
|
|
112
129
|
# history / coverage 按需单独跑:
|
|
@@ -72,6 +72,10 @@ node <euthyna 仓库>/bin/euthyna.js history --base <改前版本> --no-origins
|
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
`--base` 是**改前版本**(PR 的分叉点),`--head` 默认 `HEAD`。
|
|
75
|
+
省略 `--base` 时按 `merge-base(<被审的 head>, origin/main)` 推断,并在「范围」那一行标出「推断」、在
|
|
76
|
+
「未评估的判据」里加一条 `range-inferred`。推断出的范围**只到分叉点**,看不到更早的历史;
|
|
77
|
+
两个引用都解析不出来时报用法错误,不猜 `HEAD~1`。斜杠命令免参数进来靠的就是这条,所以
|
|
78
|
+
**看到 `range-inferred` 第一步是确认分叉点对不对**,而不是接受它。
|
|
75
79
|
分叉点不确定时用 `git merge-base <目标分支> HEAD` 的结果。
|
|
76
80
|
|
|
77
81
|
### 归属语义(两个固有近似,已确证≠精确)
|
|
@@ -338,6 +342,11 @@ node <euthyna 仓库>/bin/euthyna.js gate <报告文件> --verify --cwd <被审
|
|
|
338
342
|
- `history` 的归属**默认追「最初引入」**(`git log -S`,有探针上限,只对 ≥ 12 字符的行做追溯);
|
|
339
343
|
要退回 blame 的「最后修改」语义用 `--no-origins`(更快,输出会写明)。两个近似都写在本文件「归属语义」一节。
|
|
340
344
|
- `history` 只覆盖**被删除的行**。新增的代码它不管(`--pickaxe` 除外)。
|
|
345
|
+
- `--graph` 只把上面这些归属**画出来**(Mermaid 源码,不是图片):范围 → 最后修改者 → 最初引入者。
|
|
346
|
+
它不新增任何测量,也不画严重性——图上只有已测到的状态与行数。这也是契约,不是漏写。
|
|
347
|
+
- 图上**没有第二条边不等于内容没被移过**。追溯结果被丢下有两条路:短行(`}`、`return x;`)与
|
|
348
|
+
`--no-origins` 会写成虚线的 `history-origins`;而「来源提交本身没被判为安全相关」是**静默**丢弃,
|
|
349
|
+
图上没有那条边,也没有任何说明。看图谱时把「没有边」当成中性事实,不是清白。
|
|
341
350
|
- `coverage` **只能证伪**。见上文。
|
|
342
351
|
- `coverage` 依赖调用计数,**不处理动态派发、反射、字符串调用**。
|
|
343
352
|
计数为 0 的结论在存在这些机制时要打折扣。
|
|
@@ -102,6 +102,26 @@ facts from the producers decide where the manual reading must be directed.
|
|
|
102
102
|
In particular: every suspected finding reported by Stage B must go through Stage C one by
|
|
103
103
|
one — never write it straight into the report as a conclusion.
|
|
104
104
|
|
|
105
|
+
### Modes (slash commands)
|
|
106
|
+
|
|
107
|
+
When a user arrives through a slash command, map the mode to the stage and the first
|
|
108
|
+
command using this table. **The table is maintained only here**; the host command files
|
|
109
|
+
(Cursor / OpenCode / Copilot / Hermes) point at this section instead of each keeping their
|
|
110
|
+
own copy.
|
|
111
|
+
|
|
112
|
+
| You type | Which branch | First command |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| `/euthyna pr` | Stage B, the change surface | `euthyna audit` (omit `--base` and the fork point is inferred and declared) |
|
|
115
|
+
| `/euthyna graph` | Stage B, the provenance view | `euthyna audit --graph` (`--base` may be omitted here too) |
|
|
116
|
+
| `/euthyna repo` | Stage A, the dependency surface | `euthyna deps --all` |
|
|
117
|
+
| `/euthyna claim <assertion>` | Stage C, the claim surface | `euthyna coverage --symbol <name>` |
|
|
118
|
+
| `/euthyna gate <report>` | Validating a written report | `euthyna gate <report>` |
|
|
119
|
+
| `/euthyna help` | Print this table only | run nothing, change no state |
|
|
120
|
+
|
|
121
|
+
`/euthyna graph` is not a new measurement: `--graph` only draws the attribution `history`
|
|
122
|
+
already emits, as Mermaid. **A missing second edge does not mean the content never moved** —
|
|
123
|
+
the boundaries are in references/fact-producers.md.
|
|
124
|
+
|
|
105
125
|
---
|
|
106
126
|
|
|
107
127
|
## The two deterministic measurements this skill ships
|
|
@@ -117,7 +137,8 @@ reliably — are shipped by this skill as a zero-dependency CLI:
|
|
|
117
137
|
| `coverage` | Was this symbol **ever actually invoked** by a test run? | when the report claims "tested / not tested" (Stage B / C) |
|
|
118
138
|
|
|
119
139
|
```powershell
|
|
120
|
-
#
|
|
140
|
+
# --base may be omitted: then the fork point is inferred as merge-base(HEAD, origin/main) and
|
|
141
|
+
# declared as range-inferred in the report. When given, it is used exactly as given:
|
|
121
142
|
node <euthyna repo>/bin/euthyna.js audit --base <pre-change revision> --repo <audited repo>
|
|
122
143
|
|
|
123
144
|
# history / coverage run alone when needed:
|
|
@@ -72,6 +72,11 @@ node <euthyna repo>/bin/euthyna.js history --base <pre-change revision> --no-ori
|
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
`--base` is the **pre-change revision** (the PR's fork point); `--head` defaults to `HEAD`.
|
|
75
|
+
Omitting `--base` infers it as `merge-base(<the audited head>, origin/main)`, marks the range line as inferred
|
|
76
|
+
and adds a `range-inferred` entry to the criteria not evaluated. An inferred range reaches **the
|
|
77
|
+
fork point only** — nothing earlier; when neither ref resolves the run stops with a usage error
|
|
78
|
+
instead of guessing `HEAD~1`. This is what lets a slash command arrive with no arguments, so
|
|
79
|
+
**the first thing to do with `range-inferred` is check the fork point**, not accept it.
|
|
75
80
|
When the fork point is uncertain, use the result of `git merge-base <target branch> HEAD`.
|
|
76
81
|
|
|
77
82
|
### Attribution semantics (two inherent approximations; established ≠ exact)
|
|
@@ -307,6 +312,8 @@ that a conclusion without evidence is not handed over wearing the face of "estab
|
|
|
307
312
|
- `history`'s classifier is **biased wide**: missing a security commit is more dangerous than over-reporting, so it would rather report `fix` too. The classification is a **lead** — read the commit message yourself.
|
|
308
313
|
- `history`'s attribution **traces the "original introducer" by default** (`git log -S`, probe-capped, only lines ≥ 12 characters); to fall back to blame's "last modified" semantics use `--no-origins` (faster; the output states this). Both approximations are documented in this file's "Attribution semantics" section.
|
|
309
314
|
- `history` only covers **deleted lines**. It does not handle newly added code (except with `--pickaxe`).
|
|
315
|
+
- `--graph` only **draws** the attributions above (Mermaid source, not an image): the range → the last modifier → the commit that first introduced the content. It measures nothing new and draws no severity — a graph carries measured states and line counts, and that is the contract too, not an omission.
|
|
316
|
+
- **The absence of the second edge does not mean the content never moved.** A traced origin is dropped two ways: a generic short line (`}`, `return x;`) or `--no-origins` is written as the dotted `history-origins` edge, while an origin commit the classifier does not read as security-relevant is dropped **silently** — no edge and no note. Read a missing edge as a neutral fact, not as a clearance.
|
|
310
317
|
- `coverage` **can only falsify**. See above.
|
|
311
318
|
- `coverage` relies on invocation counts and **does not handle dynamic dispatch, reflection, or string-based calls**. Conclusions of count 0 must be discounted when such mechanisms exist.
|
|
312
319
|
- The two halves of `audit` **do not read the same thing**: `history` takes the commits named by `--base`/`--head`, while `deps` takes the manifest **currently checked out at the root of the repository** `--repo` points into (manifests are located from the root only). So the criterion is **content**: when the manifest measured differs from the one at `--head`, or does not exist in that commit at all (a gitignored lockfile is exactly that), *Criteria not evaluated* gains an entry naming the file and `--head`. The `code` (`manifest-not-head` / `manifest-unverified`) appears only on the `--json` channel. **Seeing one means checking out an end of the range and re-running** — the declaration keeps the trap from being silent; it does not measure the fact for you.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: 代码安全审计:按 euthyna 纪律审一段代码、一次变更或一条漏洞结论,产出带证据的报告
|
|
3
|
-
argument-hint: "
|
|
3
|
+
argument-hint: "<模式词 或 路径 / diff 范围>"
|
|
4
4
|
allowed-tools: Read, Grep, Glob, Bash, Task
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -11,7 +11,7 @@ allowed-tools: Read, Grep, Glob, Bash, Task
|
|
|
11
11
|
按 euthyna 技能执行一次完整审计:
|
|
12
12
|
|
|
13
13
|
1. **先加载纪律**:找到 euthyna 技能目录(`~/.claude/skills/euthyna/`、项目 `.claude/skills/euthyna/` 或 `.agents/skills/euthyna/`),读 `SKILL.md`,需要时再读 `references/`。找不到就明说「技能缺失」,不要凭印象执行。
|
|
14
|
-
2. **按入口决策树走**:单条漏洞结论 → 阶段 C;一次变更 → 阶段 B;依赖面 → 阶段 A;整体审计 → 阶段 B
|
|
14
|
+
2. **按入口决策树走**:单条漏洞结论 → 阶段 C;一次变更 → 阶段 B;依赖面 → 阶段 A;整体审计 → 阶段 B 为主。**模式词优先于下面全部步骤**:参数命中 `SKILL.md`「模式词(斜杠命令)」表里的模式时,只执行该模式映射的那一条命令并按其结果收口——`help` 只打印那张表,不跑任何命令、不做审计、不写报告;未命中模式词时,才走下面的通用审计步骤,其余文字按审计目标处理。
|
|
15
15
|
3. **先测量再读码**:diff 有删除行先跑 `history`;谈测试覆盖先跑 `coverage`;依赖声明先跑 `deps`(操作手册在 `references/fact-producers.md`)。
|
|
16
16
|
4. **每条结论过六道门禁**:证据到 `文件:行号`、可复现命令、影响说明;拿不出证据的降级为「观察」。
|
|
17
17
|
5. **三态裁定**:`BUG #N TRUE POSITIVE / FALSE POSITIVE / INCONCLUSIVE`。
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
按 euthyna 技能执行一次完整审计:
|
|
6
6
|
|
|
7
7
|
1. **先加载纪律**:找到 euthyna 技能目录(`~/.cursor/skills/euthyna/`、项目 `.cursor/skills/euthyna/`,或 npm 全局包内的 `.agents/skills/euthyna/`),读 `SKILL.md`,需要时再读 `references/`。找不到就明说「技能缺失」,不要凭印象执行。
|
|
8
|
-
2. **按入口决策树走**:单条漏洞结论 → 阶段 C;一次变更 → 阶段 B;依赖面 → 阶段 A;整体审计 → 阶段 B
|
|
8
|
+
2. **按入口决策树走**:单条漏洞结论 → 阶段 C;一次变更 → 阶段 B;依赖面 → 阶段 A;整体审计 → 阶段 B 为主。**模式词优先于下面全部步骤**:参数命中 `SKILL.md`「模式词(斜杠命令)」表里的模式时,只执行该模式映射的那一条命令并按其结果收口——`help` 只打印那张表,不跑任何命令、不做审计、不写报告;未命中模式词时,才走下面的通用审计步骤,其余文字按审计目标处理。
|
|
9
9
|
3. **先测量再读码**:第一条命令跑 `euthyna audit --base <分叉点> --repo <被审计仓库>`(history + deps 一次跑完);谈测试覆盖再跑 `coverage`(操作手册在 `references/fact-producers.md`)。
|
|
10
10
|
4. **每条结论过六道门禁**:证据到 `文件:行号`、可复现命令、影响说明;拿不出证据的降级为「观察」。
|
|
11
11
|
5. **三态裁定**:`BUG #N TRUE POSITIVE / FALSE POSITIVE / INCONCLUSIVE`。
|
|
@@ -7,7 +7,7 @@ description: 代码安全审计:按 euthyna 纪律审一段代码、一次变
|
|
|
7
7
|
按 euthyna 技能执行一次完整审计:
|
|
8
8
|
|
|
9
9
|
1. **先加载纪律**:找到 euthyna 技能目录(项目 `.github/skills/euthyna/` 或 `.agents/skills/euthyna/`,个人 `~/.copilot/skills/euthyna/`),读 `SKILL.md`,需要时再读 `references/`。找不到就明说「技能缺失」,不要凭印象执行。
|
|
10
|
-
2. **按入口决策树走**:单条漏洞结论 → 阶段 C;一次变更 → 阶段 B;依赖面 → 阶段 A;整体审计 → 阶段 B
|
|
10
|
+
2. **按入口决策树走**:单条漏洞结论 → 阶段 C;一次变更 → 阶段 B;依赖面 → 阶段 A;整体审计 → 阶段 B 为主。**模式词优先于下面全部步骤**:参数命中 `SKILL.md`「模式词(斜杠命令)」表里的模式时,只执行该模式映射的那一条命令并按其结果收口——`help` 只打印那张表,不跑任何命令、不做审计、不写报告;未命中模式词时,才走下面的通用审计步骤,其余文字按审计目标处理。
|
|
11
11
|
3. **先测量再读码**:第一条命令跑 `euthyna audit --base <分叉点> --repo <被审计仓库>`(history + deps 一次跑完);谈测试覆盖再跑 `coverage`(操作手册在 `references/fact-producers.md`)。
|
|
12
12
|
4. **每条结论过六道门禁**:证据到 `文件:行号`、可复现命令、影响说明;拿不出证据的降级为「观察」。复现命令执行前须向用户报备(见技能「执行纪律」节)。
|
|
13
13
|
5. **三态裁定**:`BUG #N TRUE POSITIVE / FALSE POSITIVE / INCONCLUSIVE`。
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Run the euthyna code security audit CLI
|
|
3
3
|
---
|
|
4
|
-
|
|
4
|
+
If the arguments match a mode word in the euthyna skill’s "Modes (slash commands)" table,
|
|
5
|
+
translate them per that table and run the mapped command; the table is maintained in the
|
|
6
|
+
skill, not here. Otherwise run the CLI with the arguments as given:
|
|
5
7
|
|
|
6
8
|
euthyna $ARGUMENTS
|
|
7
9
|
|
|
8
|
-
If `euthyna` is not installed,
|
|
10
|
+
If `euthyna` is not installed, run the **same mapped arguments** through `npx -y euthyna …` — a mode word resolves through the table first, so the fallback never executes `euthyna <mode>` literally, and `help` still runs nothing. Quote the command's output verbatim, exit code included — never estimate or re-derive numbers. If the euthyna skill is installed (`.agents/skills/euthyna/` or `~/.agents/skills/euthyna/`), read its SKILL.md and interpret the output under the six-gate adjudication format; claims without evidence are downgraded to observations.
|
package/README.md
CHANGED
|
@@ -180,6 +180,7 @@ replying in chat.
|
|
|
180
180
|
```sh
|
|
181
181
|
npm install -g euthyna
|
|
182
182
|
euthyna audit --repo <path> --base main --head HEAD # history + deps in one run
|
|
183
|
+
euthyna audit --graph # the same attribution, drawn (see below)
|
|
183
184
|
euthyna coverage --coverage coverage/coverage-final.json --symbol <name>
|
|
184
185
|
euthyna deps --repo <path> --all # or --dep <name> for a single dep
|
|
185
186
|
euthyna gate <adjudication-report.md> --verify --cwd <repo> # mechanically check the six gates
|
|
@@ -212,6 +213,15 @@ removed and are now being added back. Origin tracing is on by default (deleted l
|
|
|
212
213
|
are attributed to the commit that first introduced their content); `--no-origins`
|
|
213
214
|
falls back to blame's last-modifier attribution, which is faster.
|
|
214
215
|
|
|
216
|
+
`--graph` on `audit` or `history` draws what `history` already computed — the range, the commit
|
|
217
|
+
that last modified each deleted line, and the commit that first introduced its content — as
|
|
218
|
+
Mermaid source, renderable straight into a GitHub comment. It measures nothing new and never
|
|
219
|
+
colours a node by risk; a missing second edge is a neutral fact, not a clearance. `--base` may
|
|
220
|
+
be left out entirely: the fork point is then inferred as `merge-base(<the audited head>, origin/main)` and
|
|
221
|
+
said three times over — on the range line, among the criteria not evaluated (`range-inferred`),
|
|
222
|
+
and as a `code` on the `--json` channel — because an inferred range reaches the fork point only,
|
|
223
|
+
and a quietly smaller history is the one thing this output must not be able to imply.
|
|
224
|
+
|
|
215
225
|
### Exit codes are part of the contract
|
|
216
226
|
|
|
217
227
|
Surveying eight measurement plugins in this ecosystem found **none of them publishes a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "euthyna",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Deterministic fact producers for security auditing: which deleted code came from a security fix, and which changed symbols no test ever executed.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./plugin/index.js",
|
package/plugin.yaml
CHANGED
package/src/cli.js
CHANGED
|
@@ -21,7 +21,8 @@ import { collectCoverageFacts } from './facts/coverage.js';
|
|
|
21
21
|
import { collectDependencyFacts, listDependencies, detectManifest, manifestTypeForFile } from './facts/deps.js';
|
|
22
22
|
import { parseGateReport, validateFindings, verifyFinding, renderGateReport } from './gate.js';
|
|
23
23
|
import { makeReport, renderReport, safeTextLines, KIND } from './contract.js';
|
|
24
|
-
import {
|
|
24
|
+
import { provenanceGraph } from './graph.js';
|
|
25
|
+
import { repoToplevel, revParse, mergeBase, git } from './git.js';
|
|
25
26
|
import { resolveLang, T, isLang, DEFAULT_LANG } from './lang.js';
|
|
26
27
|
|
|
27
28
|
export const EXIT = Object.freeze({
|
|
@@ -71,8 +72,8 @@ euthyna —— 给 AI 编码 agent 用的确定性事实产出器
|
|
|
71
72
|
它不是扫描器。它只回答两个模型算不准的问题,并把答案写成带证据的事实。
|
|
72
73
|
|
|
73
74
|
用法:
|
|
74
|
-
euthyna audit --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json]
|
|
75
|
-
euthyna history --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json]
|
|
75
|
+
euthyna audit [--base <rev>] [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json] [--graph]
|
|
76
|
+
euthyna history [--base <rev>] [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json] [--graph]
|
|
76
77
|
euthyna coverage --coverage <file> --symbol <name> [--file <path>] [--source <dir>] [--json]
|
|
77
78
|
euthyna deps [--repo <dir>] [--lockfile <file>] (--dep <name> | --all) [--json]
|
|
78
79
|
euthyna gate <报告文件> [--verify] [--cwd <dir>] [--json]
|
|
@@ -83,11 +84,17 @@ euthyna —— 给 AI 编码 agent 用的确定性事实产出器
|
|
|
83
84
|
audit 一次跑完变更面审计:history + deps(枚举 lockfile 全部依赖),合并报告
|
|
84
85
|
这是技能文档默认推荐入口;coverage 因需要覆盖率文件和符号名不在其中
|
|
85
86
|
history 本次变更删掉了哪些代码、它们分别由哪个提交引入、该提交是不是安全修复
|
|
86
|
-
--base
|
|
87
|
+
--base 可选:省略时按 merge-base(HEAD, origin/main) 推断分叉点。
|
|
88
|
+
推断会写在「范围」那一行,并在「未评估的判据」里加一条 range-inferred:
|
|
89
|
+
推断出的范围只到分叉点,不是被审历史的全貌。两个引用都解析不出来时报错,
|
|
90
|
+
不退回去猜 HEAD~1
|
|
87
91
|
--head 可选,默认 HEAD
|
|
88
92
|
--pickaxe 额外检查「曾被移除又加回来」的新增行(有探针上限)
|
|
89
93
|
--no-origins 关闭来源追溯;默认用 git log -S 把删除行归属到「最初引入」
|
|
90
94
|
该内容的提交(有探针上限,比纯 blame 慢)
|
|
95
|
+
--graph 输出 Mermaid 来源谱系图:范围 → 最后修改这些行的提交 → 最初引入
|
|
96
|
+
其内容的提交(第二条边来自 -S 追溯,--no-origins 时没有)。
|
|
97
|
+
图只画测到的状态与行数,不画严重性;与 --json 互斥,只适用于 history 与 audit
|
|
91
98
|
coverage 某个符号在测试运行中到底有没有被调用过
|
|
92
99
|
--coverage 覆盖率数据文件,按形状自动识别 c8 / istanbul、
|
|
93
100
|
coverage.py JSON、Go -coverprofile 文本、lcov tracefile
|
|
@@ -124,8 +131,8 @@ It is not a scanner. It answers only the two questions a model cannot compute
|
|
|
124
131
|
reliably, and writes the answers as facts with evidence.
|
|
125
132
|
|
|
126
133
|
Usage:
|
|
127
|
-
euthyna audit --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json]
|
|
128
|
-
euthyna history --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json]
|
|
134
|
+
euthyna audit [--base <rev>] [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json] [--graph]
|
|
135
|
+
euthyna history [--base <rev>] [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json] [--graph]
|
|
129
136
|
euthyna coverage --coverage <file> --symbol <name> [--file <path>] [--source <dir>] [--json]
|
|
130
137
|
euthyna deps [--repo <dir>] [--lockfile <file>] (--dep <name> | --all) [--json]
|
|
131
138
|
euthyna gate <report file> [--verify] [--cwd <dir>] [--json]
|
|
@@ -140,12 +147,21 @@ Commands:
|
|
|
140
147
|
and symbol names only the adjudicator knows)
|
|
141
148
|
history which lines did this change delete, which commit introduced them,
|
|
142
149
|
and was that commit a security fix?
|
|
143
|
-
--base
|
|
150
|
+
--base optional: omitted means the fork point is inferred as
|
|
151
|
+
merge-base(HEAD, origin/main). The inference is stated on the range
|
|
152
|
+
line and declared as range-inferred among the criteria not evaluated —
|
|
153
|
+
an inferred range reaches the fork point, not the whole history. When
|
|
154
|
+
neither ref resolves the run stops rather than guessing HEAD~1
|
|
144
155
|
--head optional, defaults to HEAD
|
|
145
156
|
--pickaxe also check added lines that were once removed and now return (probe-capped)
|
|
146
157
|
--no-origins disable origin tracing; by default git log -S attributes deleted
|
|
147
158
|
lines to the commit that FIRST introduced their content
|
|
148
159
|
(probe-capped, slower than plain blame)
|
|
160
|
+
--graph print a Mermaid provenance graph: the range → the commit that last
|
|
161
|
+
modified the deleted lines → the commit that first introduced their
|
|
162
|
+
content (the second edge comes from -S tracing; --no-origins has
|
|
163
|
+
none). It draws measured states and line counts, never severity;
|
|
164
|
+
mutually exclusive with --json, history and audit only
|
|
149
165
|
coverage was this symbol ever actually invoked by a test run?
|
|
150
166
|
--coverage the coverage data file; identified by shape as c8 / istanbul,
|
|
151
167
|
coverage.py JSON, a Go -coverprofile, or an lcov tracefile
|
|
@@ -254,18 +270,6 @@ async function runHistory(flags, lang) {
|
|
|
254
270
|
};
|
|
255
271
|
}
|
|
256
272
|
|
|
257
|
-
if (!flags.base) {
|
|
258
|
-
return { exit: EXIT.USAGE, error: t('history 需要 --base <rev>', 'history requires --base <rev>') };
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
const base = await revParse(toplevel, String(flags.base));
|
|
262
|
-
if (!base) {
|
|
263
|
-
return {
|
|
264
|
-
exit: EXIT.USAGE,
|
|
265
|
-
error: t(`无法解析 --base "${flags.base}" 为一个提交`, `cannot resolve --base "${flags.base}" to a commit`)
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
|
|
269
273
|
const head = await revParse(toplevel, String(flags.head ?? 'HEAD'));
|
|
270
274
|
if (!head) {
|
|
271
275
|
return {
|
|
@@ -274,6 +278,12 @@ async function runHistory(flags, lang) {
|
|
|
274
278
|
};
|
|
275
279
|
}
|
|
276
280
|
|
|
281
|
+
const range = await resolveRangeBase({ flags, cwd: toplevel, head, t });
|
|
282
|
+
if (range.error) {
|
|
283
|
+
return { exit: EXIT.USAGE, error: range.error };
|
|
284
|
+
}
|
|
285
|
+
const base = range.base;
|
|
286
|
+
|
|
277
287
|
// Origin tracing is the default (see runAudit); --no-origins opts out.
|
|
278
288
|
const origins = flags['no-origins'] !== true;
|
|
279
289
|
|
|
@@ -286,9 +296,13 @@ async function runHistory(flags, lang) {
|
|
|
286
296
|
lang
|
|
287
297
|
});
|
|
288
298
|
|
|
299
|
+
if (range.ref) {
|
|
300
|
+
notEvaluated.push(rangeInferenceDeclaration({ ref: range.ref, head }, t));
|
|
301
|
+
}
|
|
302
|
+
|
|
289
303
|
const report = makeReport({
|
|
290
304
|
producer: producerFor(HISTORY_PRODUCER, lang),
|
|
291
|
-
subject: { repo: toplevel, base, head },
|
|
305
|
+
subject: { repo: toplevel, base, head, ...(range.ref ? { rangeSource: range.ref } : {}) },
|
|
292
306
|
facts,
|
|
293
307
|
evaluated,
|
|
294
308
|
notEvaluated
|
|
@@ -412,6 +426,56 @@ async function runDeps(flags, lang) {
|
|
|
412
426
|
* symbol names only the adjudicator knows, so there is nothing honest to
|
|
413
427
|
* auto-measure there.
|
|
414
428
|
*/
|
|
429
|
+
/**
|
|
430
|
+
* Declare the range the run inferred, in the shape every other declaration uses:
|
|
431
|
+
* a `notEvaluated` entry with a `code`, so the JSON channel carries it and a
|
|
432
|
+
* reader of the terminal output meets it in the caveat list.
|
|
433
|
+
*
|
|
434
|
+
* Pure, so the wording is testable in both languages without building a repository.
|
|
435
|
+
*/
|
|
436
|
+
export function rangeInferenceDeclaration({ ref, head }, t) {
|
|
437
|
+
const short = (sha) => (sha ?? '').slice(0, 10);
|
|
438
|
+
return {
|
|
439
|
+
kind: KIND.HISTORY,
|
|
440
|
+
code: 'range-inferred',
|
|
441
|
+
reason: t(
|
|
442
|
+
`未给 --base,比较范围按 merge-base(${short(head)}, ${ref}) 推断——这不是被审历史的全貌,换范围请显式传 --base <rev>`,
|
|
443
|
+
`no --base given: the range was inferred as merge-base(${short(head)}, ${ref}). That is not the whole history under review — pass --base <rev> to audit another range`
|
|
444
|
+
)
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Resolve `--base`, or infer the fork point when the caller gave none.
|
|
450
|
+
*
|
|
451
|
+
* The inference is where an argument-free run could quietly narrow what is
|
|
452
|
+
* audited — a smaller range deletes edges and reads as cleaner — so the rule used
|
|
453
|
+
* comes back with the value and the caller must declare it. It forks from the
|
|
454
|
+
* resolved `--head`, not from whatever happens to be checked out: asking about
|
|
455
|
+
* another commit and getting this commit's ancestor would report a range nobody
|
|
456
|
+
* requested. There is no fallback to HEAD~1: with no remote-tracking branch to
|
|
457
|
+
* fork from, this says so and the run does not happen.
|
|
458
|
+
*/
|
|
459
|
+
export async function resolveRangeBase({ flags, cwd, head, t }) {
|
|
460
|
+
if (flags.base !== undefined) {
|
|
461
|
+
const base = await revParse(cwd, String(flags.base));
|
|
462
|
+
if (!base) {
|
|
463
|
+
return { error: t(`无法解析 --base "${flags.base}" 为一个提交`, `cannot resolve --base "${flags.base}" to a commit`) };
|
|
464
|
+
}
|
|
465
|
+
return { base };
|
|
466
|
+
}
|
|
467
|
+
for (const ref of ['origin/main', 'origin/HEAD']) {
|
|
468
|
+
const base = await mergeBase(cwd, head, ref);
|
|
469
|
+
if (base) return { base, ref };
|
|
470
|
+
}
|
|
471
|
+
return {
|
|
472
|
+
error: t(
|
|
473
|
+
'未给 --base,且 origin/main 与 origin/HEAD 都无法解析出分叉点——请显式传 --base <rev>',
|
|
474
|
+
'no --base given, and neither origin/main nor origin/HEAD resolves to a fork point — pass --base <rev>'
|
|
475
|
+
)
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
415
479
|
/**
|
|
416
480
|
* Decide whether the manifest `audit` measured is the manifest --head holds.
|
|
417
481
|
*
|
|
@@ -460,10 +524,6 @@ async function runAudit(flags, lang) {
|
|
|
460
524
|
const t = T(lang);
|
|
461
525
|
const cwd = path.resolve(flags.repo ? String(flags.repo) : process.cwd());
|
|
462
526
|
|
|
463
|
-
if (!flags.base) {
|
|
464
|
-
return { exit: EXIT.USAGE, error: t('audit 需要 --base <rev>', 'audit requires --base <rev>') };
|
|
465
|
-
}
|
|
466
|
-
|
|
467
527
|
// --- history half (mirrors runHistory's repo/base/head resolution) ---
|
|
468
528
|
const toplevel = await repoToplevel(cwd);
|
|
469
529
|
if (!toplevel) {
|
|
@@ -486,14 +546,6 @@ async function runAudit(flags, lang) {
|
|
|
486
546
|
};
|
|
487
547
|
}
|
|
488
548
|
|
|
489
|
-
const base = await revParse(toplevel, String(flags.base));
|
|
490
|
-
if (!base) {
|
|
491
|
-
return {
|
|
492
|
-
exit: EXIT.USAGE,
|
|
493
|
-
error: t(`无法解析 --base "${flags.base}" 为一个提交`, `cannot resolve --base "${flags.base}" to a commit`)
|
|
494
|
-
};
|
|
495
|
-
}
|
|
496
|
-
|
|
497
549
|
const head = await revParse(toplevel, String(flags.head ?? 'HEAD'));
|
|
498
550
|
if (!head) {
|
|
499
551
|
return {
|
|
@@ -502,6 +554,12 @@ async function runAudit(flags, lang) {
|
|
|
502
554
|
};
|
|
503
555
|
}
|
|
504
556
|
|
|
557
|
+
const range = await resolveRangeBase({ flags, cwd: toplevel, head, t });
|
|
558
|
+
if (range.error) {
|
|
559
|
+
return { exit: EXIT.USAGE, error: range.error };
|
|
560
|
+
}
|
|
561
|
+
const base = range.base;
|
|
562
|
+
|
|
505
563
|
const origins = flags['no-origins'] !== true;
|
|
506
564
|
const history = await collectHistoryFacts({
|
|
507
565
|
cwd: toplevel,
|
|
@@ -558,10 +616,13 @@ async function runAudit(flags, lang) {
|
|
|
558
616
|
const facts = [...history.facts, ...depFacts];
|
|
559
617
|
const evaluated = [...history.evaluated, ...depEvaluated];
|
|
560
618
|
const notEvaluated = [...history.notEvaluated, ...depNotEvaluated];
|
|
619
|
+
if (range.ref) {
|
|
620
|
+
notEvaluated.push(rangeInferenceDeclaration({ ref: range.ref, head }, t));
|
|
621
|
+
}
|
|
561
622
|
|
|
562
623
|
const report = makeReport({
|
|
563
624
|
producer: producerFor(AUDIT_PRODUCER, lang),
|
|
564
|
-
subject: { repo: toplevel, base, head },
|
|
625
|
+
subject: { repo: toplevel, base, head, ...(range.ref ? { rangeSource: range.ref } : {}) },
|
|
565
626
|
facts,
|
|
566
627
|
evaluated,
|
|
567
628
|
notEvaluated
|
|
@@ -729,11 +790,31 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
729
790
|
const lang = resolveLang({ flag: flagLang });
|
|
730
791
|
const t = T(lang);
|
|
731
792
|
|
|
793
|
+
// `--graph` is a render mode, not an extra channel: a graph with a JSON
|
|
794
|
+
// document glued to it on one stdout makes a CI step that parses either one
|
|
795
|
+
// fail without saying which.
|
|
796
|
+
if (flags.graph && flags.json) {
|
|
797
|
+
process.stderr.write(
|
|
798
|
+
safeTextLines(t('--graph 与 --json 互斥,一次只选一种输出', '--graph and --json are mutually exclusive; choose one output') + '\n')
|
|
799
|
+
);
|
|
800
|
+
return EXIT.USAGE;
|
|
801
|
+
}
|
|
802
|
+
|
|
732
803
|
if (!command || command === 'help' || flags.help) {
|
|
733
804
|
process.stdout.write(helpText(lang));
|
|
734
805
|
return EXIT.CLEAN;
|
|
735
806
|
}
|
|
736
807
|
|
|
808
|
+
// Only `history` and `audit` produce deleted-line attribution. Drawing the
|
|
809
|
+
// provenance graph for another command prints a range node with no edges, which
|
|
810
|
+
// reads as "no provenance found" — the one thing this must not imply.
|
|
811
|
+
if (flags.graph && command !== 'history' && command !== 'audit') {
|
|
812
|
+
process.stderr.write(
|
|
813
|
+
safeTextLines(t('--graph 只适用于 history 与 audit', '--graph applies to history and audit only') + '\n')
|
|
814
|
+
);
|
|
815
|
+
return EXIT.USAGE;
|
|
816
|
+
}
|
|
817
|
+
|
|
737
818
|
let result;
|
|
738
819
|
if (command === 'history') {
|
|
739
820
|
result = await runHistory(flags, lang);
|
|
@@ -767,6 +848,10 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
767
848
|
|
|
768
849
|
if (flags.json) {
|
|
769
850
|
process.stdout.write(`${JSON.stringify(result.report, null, 2)}\n`);
|
|
851
|
+
} else if (flags.graph && result.report) {
|
|
852
|
+
// The graph is terminal output like every other render: safeTextLines keeps
|
|
853
|
+
// its line structure and neutralises control bytes a repo path could carry.
|
|
854
|
+
process.stdout.write(safeTextLines(`${provenanceGraph(result.report)}\n`));
|
|
770
855
|
} else if (result.report) {
|
|
771
856
|
renderReport(result.report, { lang });
|
|
772
857
|
}
|
package/src/contract.js
CHANGED
|
@@ -206,7 +206,12 @@ export function renderReport(report, { write: rawWrite = console.log, lang = DEF
|
|
|
206
206
|
write(`euthyna ${producer.name} — ${producer.purpose || ''}`.trim());
|
|
207
207
|
write(`${t('目标: ', 'Target: ')}${subject.repo ?? subject.coverageFile ?? t('(未指定)', '(unspecified)')}`);
|
|
208
208
|
if (subject.base || subject.head) {
|
|
209
|
-
write(
|
|
209
|
+
write(
|
|
210
|
+
`${t('范围: ', 'Range: ')}${subject.base ?? '?'}..${subject.head ?? 'HEAD'}` +
|
|
211
|
+
// An inferred range is a decision the tool made on the reader's behalf, so
|
|
212
|
+
// it is said on the line that states the range — not only in the caveat list.
|
|
213
|
+
(subject.rangeSource ? t(`(推断:与 ${subject.rangeSource} 的分叉点)`, ` (inferred: the fork point from ${subject.rangeSource})`) : '')
|
|
214
|
+
);
|
|
210
215
|
}
|
|
211
216
|
write('');
|
|
212
217
|
|
package/src/git.js
CHANGED
|
@@ -81,6 +81,22 @@ export async function revParse(cwd, rev) {
|
|
|
81
81
|
return out.trim() || null;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The fork point between two revisions, or null when git cannot name one.
|
|
86
|
+
*
|
|
87
|
+
* `from` is passed in rather than assumed to be the checked-out HEAD: a run that
|
|
88
|
+
* was asked about another commit must fork from *that* commit, or the range it
|
|
89
|
+
* reports was never the one requested.
|
|
90
|
+
*/
|
|
91
|
+
export async function mergeBase(cwd, from, rev) {
|
|
92
|
+
const out = await git(['merge-base', from, rev], { cwd, allowFailure: true });
|
|
93
|
+
const sha = out.trim();
|
|
94
|
+
// git prints the hash alone on success; anything else (an error text that got
|
|
95
|
+
// past allowFailure, an empty result) is not a fork point. A repository picks
|
|
96
|
+
// its own object format, so both the 40-char and the 64-char id are valid.
|
|
97
|
+
return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(sha) ? sha : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
84
100
|
/**
|
|
85
101
|
* One-line summaries for a set of commits, as a Map of hash -> {subject, author, date}.
|
|
86
102
|
* Uses a single `git log` invocation so the cost does not scale per commit.
|
package/src/graph.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { KIND, safeText } from './contract.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A Mermaid view over the provenance facts `euthyna history` already emits.
|
|
5
|
+
*
|
|
6
|
+
* It measures nothing. A commit subject is whatever an author wrote, and it
|
|
7
|
+
* lands inside a `"..."` label, so every label goes through the same control-byte
|
|
8
|
+
* escape the terminal boundary uses before it reaches the graph source.
|
|
9
|
+
*
|
|
10
|
+
* Line counts live on the edges: one commit can be attributed twice (the last
|
|
11
|
+
* modifier of some lines, the origin of others), and a Mermaid node keeps only
|
|
12
|
+
* the last label written for an id — a count in the node label would silently
|
|
13
|
+
* delete the other one.
|
|
14
|
+
*/
|
|
15
|
+
export function provenanceGraph(report) {
|
|
16
|
+
const { subject, facts } = report;
|
|
17
|
+
const short = (hash) => (hash ?? '').slice(0, 8);
|
|
18
|
+
// The id is the full hash: two commits that share a prefix are two commits,
|
|
19
|
+
// and a merged node would say otherwise without saying anything.
|
|
20
|
+
const id = (hash) => `C_${hash ?? ''}`;
|
|
21
|
+
// `&` first, so the entities added next are not doubled into nothing; then `#`,
|
|
22
|
+
// because Mermaid reads `#123;` and `#name;` inside a label as an entity.
|
|
23
|
+
const escapeLabel = (text) =>
|
|
24
|
+
text.replace(/&/g, '&').replace(/#/g, '#35;').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
25
|
+
|
|
26
|
+
// `detail.commitSubject` belongs to `evidence.commit`, which is the origin
|
|
27
|
+
// commit on a pickaxe fact and the owning commit on a blame fact. Only
|
|
28
|
+
// deleted-line attribution counts: `audit` merges dependency facts in here too.
|
|
29
|
+
const subjects = new Map();
|
|
30
|
+
for (const fact of facts) {
|
|
31
|
+
if (fact.kind === KIND.HISTORY && fact.detail?.commitSubject) subjects.set(fact.evidence?.commit, fact.detail.commitSubject);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const declared = new Set(['R']);
|
|
35
|
+
const node = (hash, byFile) => {
|
|
36
|
+
const key = id(hash);
|
|
37
|
+
if (declared.has(key)) return key;
|
|
38
|
+
declared.add(key);
|
|
39
|
+
const text = subjects.get(hash) ?? '';
|
|
40
|
+
const rest = byFile.length > 1 ? ` +${byFile.length - 1}` : '';
|
|
41
|
+
const where = byFile.length ? ` ${byFile[0].file}${rest}` : '';
|
|
42
|
+
// One pass over the whole label: the path is as much the repository's own
|
|
43
|
+
// text as the commit subject is, and it may contain `"` or a control byte.
|
|
44
|
+
return `${key}["${escapeLabel(safeText(`${short(hash)}${where}${text ? ` ${text}` : ''}`))}"]`;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const range = `${subject.base ?? '?'}..${subject.head ?? 'HEAD'}`;
|
|
48
|
+
const out = ['graph TD', ` R["${escapeLabel(safeText(range))}"]`];
|
|
49
|
+
|
|
50
|
+
for (const fact of facts) {
|
|
51
|
+
// A dependency fact has no commit: drawing it would emit a node with an empty
|
|
52
|
+
// id that reads as "a commit with no lines".
|
|
53
|
+
if (fact.kind !== KIND.HISTORY) continue;
|
|
54
|
+
const detail = fact.detail ?? {};
|
|
55
|
+
const origin = detail.originMethod === 'pickaxe' ? detail.originCommit : null;
|
|
56
|
+
// The deleted lines hang off whoever last touched them inside the audited
|
|
57
|
+
// range; the origin commit is where the content came from.
|
|
58
|
+
const blamed = origin ? detail.blameCommit : fact.evidence?.commit;
|
|
59
|
+
const byFile = detail.byFile ?? [];
|
|
60
|
+
const count = detail.blamedLines ?? 0;
|
|
61
|
+
|
|
62
|
+
out.push(` R -->|${count}| ${node(blamed, byFile)}`);
|
|
63
|
+
if (origin) out.push(` ${id(blamed)} -->|${count}| ${node(origin, byFile)}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Dotted, and named by kind: `nothing here was deleted` and `this could not be
|
|
67
|
+
// measured` look the same once the facts are gone.
|
|
68
|
+
for (const [n, item] of (report.coverage?.notEvaluated ?? []).entries()) {
|
|
69
|
+
out.push(` R -.-> U_${n}["${escapeLabel(safeText(`${item.kind}: ${item.reason}`))}"]`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return out.join('\n');
|
|
73
|
+
}
|