euthyna 0.8.0 → 0.9.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.
@@ -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
- # 唯一必需的参数是 --base(改前版本 / PR 分叉点):
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
  ### 归属语义(两个固有近似,已确证≠精确)
@@ -95,7 +99,7 @@ node <euthyna 仓库>/bin/euthyna.js history --base <改前版本> --no-origins
95
99
  ### 输出长什么样(真实捕获)
96
100
 
97
101
  ```
98
- euthyna euthyna-history — 被删除代码的来源归属与安全分类
102
+ euthyna-history — 被删除代码的来源归属与安全分类
99
103
  目标: D:/axe-core/axe-core
100
104
  范围: 8390a5a9…..397f5fce…
101
105
 
@@ -239,7 +243,7 @@ node <euthyna 仓库>/bin/euthyna.js coverage `
239
243
  ### 输出长什么样(真实捕获,一次查询命中两种结果)
240
244
 
241
245
  ```
242
- euthyna euthyna-coverage — 符号调用计数(只能证伪)
246
+ euthyna-coverage — 符号调用计数(只能证伪)
243
247
 
244
248
  已确证 (1)
245
249
  • 符号 cleanup 在本次测试运行中一次都没有被调用(调用计数为 0)
@@ -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
- # the only required argument is --base (pre-change revision / PR fork point):
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)
@@ -84,7 +89,7 @@ When the fork point is uncertain, use the result of `git merge-base <target bran
84
89
  ### What the output looks like (real capture)
85
90
 
86
91
  ```
87
- euthyna euthyna-history — attribution and security classification of deleted code
92
+ euthyna-history — attribution and security classification of deleted code
88
93
  Target: D:/axe-core/axe-core
89
94
  Range: 8390a5a9…..397f5fce…
90
95
 
@@ -212,7 +217,7 @@ node <euthyna repo>/bin/euthyna.js coverage `
212
217
  ### What the output looks like (real capture; one query hitting both result kinds)
213
218
 
214
219
  ```
215
- euthyna euthyna-coverage — symbol invocation counts (can only falsify)
220
+ euthyna-coverage — symbol invocation counts (can only falsify)
216
221
 
217
222
  Established (1)
218
223
  • Symbol cleanup was never invoked in this test run (invocation count 0) — any behavior depending on it was never exercised
@@ -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: "<路径 或 diff 范围>"
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
- Run the euthyna CLI with these arguments and report the measured result:
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, use `npx -y euthyna $ARGUMENTS` instead. 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.
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/__init__.py CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The CLI (``bin/euthyna.js``, installed by ``npm install -g euthyna``) produces
4
4
  the deterministic facts; this plugin registers ``/euthyna`` so a user can invoke
5
- the four CLI subcommands from inside a Hermes session without leaving chat. The
5
+ the CLI's subcommands from inside a Hermes session without leaving chat. The
6
6
  handler shells out to the real CLI and returns its output verbatim — it does not
7
7
  reimplement any measurement, and it injects nothing into the conversation.
8
8
 
@@ -15,19 +15,27 @@ CLI prints comes back as the command result.
15
15
  import shutil
16
16
  import subprocess
17
17
 
18
- # Ponytail: one flat dict, not a command framework — there are exactly four
19
- # subcommands and the CLI already owns usage/exit codes.
18
+ # The subcommands /euthyna reaches. This is the single source the guard in
19
+ # test/hermes-plugin.test.js compares against the CLI's own dispatch, so a new
20
+ # command there is red here until it is wired — which is how `audit` (the
21
+ # entry point since 0.4.0) went seven releases unnoticed behind a hand-written
22
+ # list of four.
23
+ _FORWARDS = ("audit", "coverage", "deps", "gate", "help", "history")
24
+
20
25
  _USAGE = """euthyna slash command — usage:
21
26
 
27
+ /euthyna audit <path> [<path>...] history + dependency surface in one run
22
28
  /euthyna history <path> [<path> <rev>...] deleted-code provenance & security classification
23
29
  /euthyna coverage <path> symbol invocation counts from a coverage run
24
30
  /euthyna deps <path> dependency pinned versions from lockfiles
25
31
  /euthyna gate <report> [--verify] validate an audit report against the six gates
32
+ /euthyna help the CLI's own usage, with every flag
26
33
 
27
34
  Args after the subcommand are passed to the CLI unchanged. Requires the CLI:
28
35
  npm install -g euthyna (Node >= 20)."""
29
36
 
30
37
 
38
+
31
39
  def _find_cli():
32
40
  """Return the absolute path of the euthyna CLI, or None if not installed."""
33
41
  for name in ("euthyna", "euthyna.cmd", "euthyna.exe"):
@@ -40,10 +48,13 @@ def _find_cli():
40
48
  def _run(raw_args: str):
41
49
  """Handle /euthyna: forward <subcommand> <args...> to the CLI, return its output."""
42
50
  argv = raw_args.split()
43
- if not argv or argv[0] in ("help", "-h", "--help"):
51
+ # `-h`/`--help` print this command's own quick usage; bare `help` is forwarded
52
+ # so the flag list has exactly one owner — the CLI's help, which the skill and
53
+ # the docs both quote.
54
+ if not argv or argv[0] in ("-h", "--help"):
44
55
  return _USAGE
45
56
  sub, rest = argv[0], argv[1:]
46
- if sub not in ("history", "coverage", "deps", "gate"):
57
+ if sub not in _FORWARDS:
47
58
  return f"unknown subcommand: {sub}\n\n{_USAGE}"
48
59
 
49
60
  cli = _find_cli()
@@ -73,8 +84,8 @@ def register(ctx):
73
84
  ctx.register_command(
74
85
  "euthyna",
75
86
  handler=_run,
76
- description="euthyna security audit: run history/coverage/deps/gate fact producers",
77
- args_hint="<history|coverage|deps|gate> <args…>",
87
+ description="euthyna security audit: drive the fact-producing CLI (audit is the single entry point)",
88
+ args_hint="<audit|history|coverage|deps|gate|help> <args…>",
78
89
  )
79
90
 
80
91
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "euthyna",
3
- "version": "0.8.0",
4
- "description": "Deterministic fact producers for security auditing: which deleted code came from a security fix, and which changed symbols no test ever executed.",
3
+ "version": "0.9.1",
4
+ "description": "Audit framework for AI coding agents: produces the deterministic facts a model cannot compute — deleted-code provenance, symbol invocations, dependency pins — and adjudicates every claim against six gates, downgrading what cannot produce evidence to an observation. Not a scanner.",
5
5
  "type": "module",
6
6
  "main": "./plugin/index.js",
7
7
  "bin": {
package/plugin.yaml CHANGED
@@ -1,6 +1,6 @@
1
1
  name: euthyna
2
- version: 0.8.0
3
- description: euthyna audit discipline — slash commands that drive the euthyna fact-producing CLI (history / coverage / deps / gate)
2
+ version: 0.9.1
3
+ description: euthyna audit discipline — the /euthyna slash command driving the euthyna CLI, whose single entry point is `euthyna audit`
4
4
  license: Apache-2.0
5
5
  homepage: https://github.com/slow-stack/euthyna
6
6
  tags:
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 { repoToplevel, revParse, git } from './git.js';
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 必填,比较的基线版本(如 main、HEAD~5、某个 commit)
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 required, the baseline revision to compare against
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
@@ -203,10 +203,18 @@ export function renderReport(report, { write: rawWrite = console.log, lang = DEF
203
203
  const unknown = facts.filter(f => f.status === STATUS.UNKNOWN);
204
204
 
205
205
  write('');
206
- write(`euthyna ${producer.name} — ${producer.purpose || ''}`.trim());
206
+ // The producer name is `euthyna-history`, `euthyna-audit`, …: it already names
207
+ // the tool, so the header prints it as the contract emits it — the same string
208
+ // the `--json` `producer` field carries.
209
+ write(`${producer.name} — ${producer.purpose || ''}`.trim());
207
210
  write(`${t('目标: ', 'Target: ')}${subject.repo ?? subject.coverageFile ?? t('(未指定)', '(unspecified)')}`);
208
211
  if (subject.base || subject.head) {
209
- write(`${t('范围: ', 'Range: ')}${subject.base ?? '?'}..${subject.head ?? 'HEAD'}`);
212
+ write(
213
+ `${t('范围: ', 'Range: ')}${subject.base ?? '?'}..${subject.head ?? 'HEAD'}` +
214
+ // An inferred range is a decision the tool made on the reader's behalf, so
215
+ // it is said on the line that states the range — not only in the caveat list.
216
+ (subject.rangeSource ? t(`(推断:与 ${subject.rangeSource} 的分叉点)`, ` (inferred: the fork point from ${subject.rangeSource})`) : '')
217
+ );
210
218
  }
211
219
  write('');
212
220
 
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, '&amp;').replace(/#/g, '#35;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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
+ }