euthyna 0.3.1 → 0.4.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.
@@ -76,8 +76,9 @@ license: Apache-2.0
76
76
  │
77
77
  ├─ 一次变更(PR / diff / commit)────► 阶段 B:变更面审计
78
78
  │ (「这个 PR 安全吗」) 读 references/change-audit.md
79
- │ ★ diff 里有删除行 → 先跑 history
80
- │ ★ 要谈测试覆盖 → 先跑 coverage
79
+ │ ★ 第一条命令:euthyna audit --base <分叉点>
80
+ │ (history + deps 一次跑完,见下节)
81
+ │ ★ 要谈测试覆盖 → 再跑 coverage
81
82
  │
82
83
  ├─ 一个仓库 / 依赖面 ────────────────► 阶段 A:依赖面审计
83
84
  │ (「依赖有没有问题」) 读 references/dependency-audit.md
@@ -100,10 +101,15 @@ license: Apache-2.0
100
101
 
101
102
  | 测量 | 回答的问题 | 什么时候**必须**跑 |
102
103
  |---|---|---|
103
- | `history` | 这次变更**删掉**的代码来自哪个提交?那个提交是不是安全修复? | diff 里有删除行时(阶段 B) |
104
+ | `audit` | 上面两个问题**一次跑完**(history 默认追溯最初引入者 + deps 枚举 lockfile 全部依赖),合并一份报告 | **每次审计的第一条命令**(阶段 B 开场) |
105
+ | `history` | 这次变更**删掉**的代码来自哪个提交?那个提交是不是安全修复? | 需要单独重跑 history 时(如换 `--base`) |
104
106
  | `coverage` | 某个符号在测试运行中**到底有没有被调用过**? | 报告里要说「测过 / 没测过」时(阶段 B / C) |
105
107
 
106
108
  ```powershell
109
+ # 唯一必需的参数是 --base(改前版本 / PR 分叉点):
110
+ node <euthyna 仓库>/bin/euthyna.js audit --base <改前版本> --repo <被审计的仓库>
111
+
112
+ # history / coverage 按需单独跑:
107
113
  node <euthyna 仓库>/bin/euthyna.js history --base <改前版本> --repo <被审计的仓库>
108
114
  node <euthyna 仓库>/bin/euthyna.js coverage --coverage <覆盖率文件绝对路径> --symbol <符号名>
109
115
  ```
@@ -206,7 +212,7 @@ node <euthyna 仓库>/bin/euthyna.js gate <报告文件> --verify --cwd <被审
206
212
  ## 交付前的自检(逐条打勾,缺一条不许说「审完了」)
207
213
 
208
214
  - [ ] 每个疑似发现都走了**完整的**阶段 C,不是「看一眼觉得是假的」
209
- - [ ] **diff 有删除行时跑过 `history`**;删自安全修复提交的代码已按最高风险处理
215
+ - [ ] **跑过 `audit`(或 diff 有删除行时跑过 `history`)**;删自安全修复提交的代码已按最高风险处理
210
216
  - [ ] 报告里每句「测过 / 没测过」都有 `coverage` 输出支撑,或已标为「不可评估」
211
217
  - [ ] **退出码为 `2` 的判据没有出现在「干净」一栏**
212
218
  - [ ] 每条结论都带 `file:line`
@@ -14,8 +14,8 @@ CLI **不在技能目录里**,它是本项目的独立程序。按可用性从
14
14
 
15
15
  | 情况 | 怎么写 | 备注 |
16
16
  |---|---|---|
17
- | 已安装 | `euthyna history …` | `euthyna` 是包声明的命令名 |
18
- | 有本项目检出 | `node <euthyna 仓库>/bin/euthyna.js history …` | 最常用的形式 |
17
+ | 已安装 | `euthyna audit …` | `euthyna` 是包声明的命令名 |
18
+ | 有本项目检出 | `node <euthyna 仓库>/bin/euthyna.js audit …` | 最常用的形式 |
19
19
  | 两者都没有 | —— | **把判据标为「不可评估」**,不要手工估算顶上 |
20
20
 
21
21
  ### ⚠️ 一个真实的坑:相对路径只在 euthyna 仓库里成立
@@ -54,17 +54,21 @@ node <euthyna 仓库>/bin/euthyna.js history --base <rev> --repo <被审计的
54
54
  **只要 diff 里有删除行,就先跑它。** 这不是可选步骤:一个校验被删掉而看着「很干净」的 diff,
55
55
  正是安全回归最常见的样子,而模型无法对几百行被删代码逐行做 blame 再回溯提交历史。
56
56
 
57
+ **通常不必单独跑它** —— `euthyna audit`(见第一节)已经把 history(默认开来源追溯)
58
+ 和 deps 一次跑完。单独跑 `history` 的场景:换一个 `--base` 重新对比、只要 history 不要 deps。
59
+
57
60
  ### 命令
58
61
 
59
62
  ```powershell
60
63
  # 基础:本次变更删掉的代码来自哪些提交、那些提交像不像安全修复
64
+ # (来源追溯默认开启:删除行归属到「最初引入」该内容的提交)
61
65
  node <euthyna 仓库>/bin/euthyna.js history --base <改前版本> --repo <被审计的仓库>
62
66
 
63
67
  # 加测「曾被移除又加回来」的行(有探针上限,比基础版慢)
64
68
  node <euthyna 仓库>/bin/euthyna.js history --base <改前版本> --pickaxe --repo <被审计的仓库>
65
69
 
66
- # 把删除行归属到「最初引入」该内容的提交(而非 blame 的「最后修改者」,有探针上限)
67
- node <euthyna 仓库>/bin/euthyna.js history --base <改前版本> --origins --repo <被审计的仓库>
70
+ # 关闭来源追溯,退回 blame 的「最后修改」语义(更快;输出会写明这一点)
71
+ node <euthyna 仓库>/bin/euthyna.js history --base <改前版本> --no-origins --repo <被审计的仓库>
68
72
  ```
69
73
 
70
74
  `--base` 是**改前版本**(PR 的分叉点),`--head` 默认 `HEAD`。
@@ -74,13 +78,14 @@ node <euthyna 仓库>/bin/euthyna.js history --base <改前版本> --origins --r
74
78
 
75
79
  `history` 的归属和分类各有一个**写死的近似**,读输出前先知道它们:
76
80
 
77
- 1. **归属:blame 是「最后修改」,不是「最初引入」。** 一条安全修复行如果之后被
78
- 格式化/重构碰过(移动、复制),blame 会把来源归到最后碰它的那个提交。
79
- `--origins` 用 `git log -S <行内容>` 定位**最初引入**该内容的提交;两者不一致时
80
- 事实会同时写出两个提交(`detail.blameCommit` = 最后修改、`evidence.commit` =
81
- 最初引入),并说明「最初引入」。
82
- `--origins` 只对长度 ≥ 12 字符的行做追溯(太通用的行,-S 会指向整个历史的
83
- 第一个提交,那不是来源);未启用时输出会在「未评估的判据」里写明这一点。
81
+ 1. **归属:默认用 `git log -S` 追「最初引入」;blame 的「最后修改」只是回退。** 一条安全
82
+ 修复行如果之后被格式化/重构碰过(移动、复制),blame 会把来源归到最后碰它的那个提交
83
+ ——追引入者正是为了不中这个招。来源追溯用 `git log -S <行内容>` 定位**最初引入**该内容
84
+ 的提交;与 blame 不一致时,事实会同时写出两个提交(`detail.blameCommit` = 最后修改、
85
+ `evidence.commit` = 最初引入),并说明「最初引入」。
86
+ 追溯只对长度 ≥ 12 字符的行做(太通用的行,-S 会指向整个历史的第一个提交,那不是来源);
87
+ 被追溯的行有探针上限,触顶时剩余行保留 blame 归属并在「未评估的判据」里写明;
88
+ `--no-origins` 显式关闭时同样在「未评估的判据」里写明用的是 blame 语义。
84
89
  2. **分类:先看提交信息,再看提交 diff,再看被删行本身。** 消息含糊
85
90
  ("update utils")但 diff 动了危险 API、或被删行本身就是安全代码(如
86
91
  `if (!authorized) return`)时,都会标 `security`——依据写在
@@ -226,12 +231,13 @@ euthyna euthyna-coverage — 符号调用计数(只能证伪)
226
231
 
227
232
  ```
228
233
  阶段 B(变更面审计)
229
- ├─ 有删除行 ──────► history → 结果决定风险是否拉到最高
230
- ├─ 涉及回归判断 ──► history --pickaxe → 结果决定是否按「回归」处理
231
- └─ 要谈测试覆盖 ──► coverage → 只证伪;计数非零不构成证据
234
+ ├─ 第一条命令 ────► audit → history + deps 一次跑完
235
+ │ → 有删除行:结果决定风险是否拉到最高
236
+ ├─ 涉及回归判断 ──► history --pickaxe → 结果决定是否按「回归」处理
237
+ └─ 要谈测试覆盖 ──► coverage → 只证伪;计数非零不构成证据
232
238
 
233
239
  阶段 C(结论面验证)
234
- └─ 结论依赖「测过没有」──► coverage → 门禁 4(演示)的真假由它约束
240
+ └─ 结论依赖「测过没有」──► coverage → 门禁 4(演示)的真假由它约束
235
241
  ```
236
242
 
237
243
  **产出器给的是事实,不是裁定。** 它说「这行来自一个安全修复提交」,
@@ -265,8 +271,8 @@ node <euthyna 仓库>/bin/euthyna.js gate <报告文件> --verify --cwd <被审
265
271
 
266
272
  - `history` 的分类器**偏向宽**:漏掉安全提交比多报更危险,所以它宁可把 `fix` 也报出来。
267
273
  分类结果是**线索**,提交信息要自己读。
268
- - `history` 的归属默认是 blame 的「最后修改」语义;要「最初引入」用 `--origins`
269
- (有探针上限,且只对 ≥ 12 字符的行做追溯)。两个近似都写在本文件「归属语义」一节。
274
+ - `history` 的归属**默认追「最初引入」**(`git log -S`,有探针上限,只对 ≥ 12 字符的行做追溯);
275
+ 要退回 blame 的「最后修改」语义用 `--no-origins`(更快,输出会写明)。两个近似都写在本文件「归属语义」一节。
270
276
  - `history` 只覆盖**被删除的行**。新增的代码它不管(`--pickaxe` 除外)。
271
277
  - `coverage` **只能证伪**。见上文。
272
278
  - `coverage` 依赖调用计数,**不处理动态派发、反射、字符串调用**。
@@ -85,8 +85,9 @@ What did the user give?
85
85
  │
86
86
  ├─ A change (PR / diff / commit) ───────────────► Stage B: change audit
87
87
  │ ("is this PR safe") read references/change-audit.md
88
- │ ★ deleted lines in the diff → run history first
89
- │ ★ if test coverage matters → run coverage first
88
+ │ ★ first command: euthyna audit --base <fork point>
89
+ │ (history + deps in one run, see below)
90
+ │ ★ if test coverage matters → then run coverage
90
91
  │
91
92
  ├─ A repository / dependency surface ───────────► Stage A: dependency audit
92
93
  │ ("do the dependencies have problems") read references/dependency-audit.md
@@ -111,10 +112,15 @@ reliably — are shipped by this skill as a zero-dependency CLI:
111
112
 
112
113
  | Measurement | Question it answers | When it is **mandatory** |
113
114
  |---|---|---|
114
- | `history` | Which commit does the code this change **deleted** come from? Was that commit a security fix? | when the diff has deleted lines (Stage B) |
115
+ | `audit` | **Both questions below in one run** (history traces original introducers by default + deps enumerates every lockfile dependency), one merged report | **the first command of every audit** (opening Stage B) |
116
+ | `history` | Which commit does the code this change **deleted** come from? Was that commit a security fix? | when re-running history alone (e.g. a different `--base`) |
115
117
  | `coverage` | Was this symbol **ever actually invoked** by a test run? | when the report claims "tested / not tested" (Stage B / C) |
116
118
 
117
119
  ```powershell
120
+ # the only required argument is --base (pre-change revision / PR fork point):
121
+ node <euthyna repo>/bin/euthyna.js audit --base <pre-change revision> --repo <audited repo>
122
+
123
+ # history / coverage run alone when needed:
118
124
  node <euthyna repo>/bin/euthyna.js history --base <pre-change revision> --repo <audited repo>
119
125
  node <euthyna repo>/bin/euthyna.js coverage --coverage <absolute path to coverage file> --symbol <symbol name>
120
126
  ```
@@ -229,7 +235,7 @@ adjudication, it only guarantees that a conclusion without evidence cannot leave
229
235
  ## Pre-delivery self-check (tick every item; without all of them you may not say "audit done")
230
236
 
231
237
  - [ ] Every suspected finding went through **complete** Stage C, not "looked at it and it seems false"
232
- - [ ] **Ran `history` when the diff has deleted lines**; code deleted from a security-fix commit handled at highest risk
238
+ - [ ] **Ran `audit` (or `history` when the diff has deleted lines)**; code deleted from a security-fix commit handled at highest risk
233
239
  - [ ] Every "tested / not tested" sentence in the report is backed by `coverage` output, or marked "not evaluable"
234
240
  - [ ] **No criterion with exit code `2` appears in a "clean" column**
235
241
  - [ ] Every conclusion carries a `file:line`
@@ -14,8 +14,8 @@ The CLI is **not in the skill directory** — it is a separate program of this p
14
14
 
15
15
  | Situation | How to write it | Notes |
16
16
  |---|---|---|
17
- | Installed | `euthyna history …` | `euthyna` is the command name declared by the package |
18
- | Project checkout available | `node <euthyna repo>/bin/euthyna.js history …` | the most common form |
17
+ | Installed | `euthyna audit …` | `euthyna` is the command name declared by the package |
18
+ | Project checkout available | `node <euthyna repo>/bin/euthyna.js audit …` | the most common form |
19
19
  | Neither | — | **mark the criterion as "not evaluable"**; do not substitute a manual estimate |
20
20
 
21
21
  ### ⚠️ A real pitfall: relative paths only work inside the euthyna repository
@@ -54,17 +54,21 @@ Both commands were verified: run from `D:\` with `--repo` pointing at another re
54
54
  **If the diff contains deleted lines, run it first.** This is not optional: a diff that looks "very clean" because a check was deleted
55
55
  is exactly what a security regression usually looks like, and a model cannot blame hundreds of deleted lines one by one and then trace back through commit history.
56
56
 
57
+ **Usually you do not need to run it alone** — `euthyna audit` (section 1) already runs history (with origin tracing on by default)
58
+ and deps in one pass. Run `history` alone to compare against a different `--base`, or when you want history without deps.
59
+
57
60
  ### Commands
58
61
 
59
62
  ```powershell
60
63
  # basic: which commits do this change's deleted lines come from, and do those commits look like security fixes
64
+ # (origin tracing is on by default: deleted lines are attributed to the commit that "originally introduced" the content)
61
65
  node <euthyna repo>/bin/euthyna.js history --base <pre-change revision> --repo <audited repo>
62
66
 
63
67
  # additionally check for "removed and added back" lines (has a probe cap; slower than the basic version)
64
68
  node <euthyna repo>/bin/euthyna.js history --base <pre-change revision> --pickaxe --repo <audited repo>
65
69
 
66
- # attribute deleted lines to the commit that "originally introduced" the content (not blame's "last modifier"; has a probe cap)
67
- node <euthyna repo>/bin/euthyna.js history --base <pre-change revision> --origins --repo <audited repo>
70
+ # disable origin tracing and fall back to blame's "last modified" semantics (faster; the output states this)
71
+ node <euthyna repo>/bin/euthyna.js history --base <pre-change revision> --no-origins --repo <audited repo>
68
72
  ```
69
73
 
70
74
  `--base` is the **pre-change revision** (the PR's fork point); `--head` defaults to `HEAD`.
@@ -74,7 +78,7 @@ When the fork point is uncertain, use the result of `git merge-base <target bran
74
78
 
75
79
  `history`'s attribution and classification each carry a **hard-coded approximation** — know them before reading the output:
76
80
 
77
- 1. **Attribution: blame is "last modified", not "originally introduced".** If a security-fix line was later touched by formatting/refactoring (moved, copied), blame attributes the origin to the last commit that touched it. `--origins` uses `git log -S <line content>` to locate the commit that **originally introduced** the content; when the two differ, the fact writes out both commits (`detail.blameCommit` = last modified, `evidence.commit` = originally introduced) and states so. `--origins` only traces lines ≥ 12 characters long (for overly generic lines, `-S` would point at the first commit of the entire history, which is not the origin); when it is not enabled, the output states this under "criteria not evaluated".
81
+ 1. **Attribution: `git log -S` traces the "original introducer" by default; blame's "last modified" is the fallback.** If a security-fix line was later touched by formatting/refactoring (moved, copied), blame attributes the origin to the last commit that touched it — tracing the introducer exists precisely to avoid that trap. Origin tracing uses `git log -S <line content>` to locate the commit that **originally introduced** the content; when it differs from blame, the fact writes out both commits (`detail.blameCommit` = last modified, `evidence.commit` = originally introduced) and states so. Tracing applies only to lines ≥ 12 characters long (for overly generic lines, `-S` would point at the first commit of the entire history, which is not the origin); traced lines are probe-capped, and when the cap is hit the remaining lines keep their blame attribution, stated under "criteria not evaluated"; disabling it explicitly with `--no-origins` is likewise stated there.
78
82
  2. **Classification: look at the commit message first, then the commit diff, then the deleted line itself.** When the message is vague ("update utils") but the diff touches a dangerous API, or the deleted line is itself security code (e.g. `if (!authorized) return`), it is marked `security` — the basis is recorded in `detail.classificationBasis` (`message` / `diff` / `deleted-line`). The deliberately wide bias is intentional: missing a security commit is more dangerous than over-reporting.
79
83
 
80
84
  ### What the output looks like (real capture)
@@ -213,7 +217,8 @@ The difference between `2` and `0` is the essence of why this contract exists: *
213
217
 
214
218
  ```
215
219
  Stage B (change-surface audit)
216
- ├─ deleted lines present ────► history → the result decides whether risk is raised to maximum
220
+ ├─ first command ───────────► audit → history + deps in one pass
221
+ │ → deleted lines present: the result decides whether risk is raised to maximum
217
222
  ├─ regression judgment ──────► history --pickaxe → the result decides whether to treat it as a "regression"
218
223
  └─ test coverage to discuss ─► coverage → can only falsify; a non-zero count is not evidence
219
224
 
@@ -251,7 +256,7 @@ that a conclusion without evidence is not handed over wearing the face of "estab
251
256
  ## 7. Known limits (do not use beyond them)
252
257
 
253
258
  - `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.
254
- - `history`'s attribution defaults to blame's "last modified" semantics; use `--origins` for "originally introduced" (has a probe cap, and only traces lines ≥ 12 characters). Both approximations are documented in this file's "Attribution semantics" section.
259
+ - `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.
255
260
  - `history` only covers **deleted lines**. It does not handle newly added code (except with `--pickaxe`).
256
261
  - `coverage` **can only falsify**. See above.
257
262
  - `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.
package/README.md CHANGED
@@ -10,6 +10,10 @@
10
10
 
11
11
  <p align="center">
12
12
  <a href="https://github.com/slow-stack/euthyna/actions/workflows/ci.yml"><img src="https://github.com/slow-stack/euthyna/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
13
+ <a href="https://www.npmjs.com/package/euthyna"><img src="https://img.shields.io/npm/v/euthyna?style=flat&color=007EC6" alt="npm version"></a>
14
+ <a href="https://www.npmjs.com/package/euthyna"><img src="https://img.shields.io/npm/dm/euthyna?style=flat&color=007EC6" alt="npm downloads"></a>
15
+ <a href="https://www.npmjs.com/package/euthyna"><img src="https://img.shields.io/npm/l/euthyna?style=flat&color=007EC6" alt="license: Apache-2.0"></a>
16
+ <a href="https://search.sigstore.dev/?logIndex=2915999059"><img src="https://img.shields.io/badge/npm%20provenance-verified-007EC6?style=flat" alt="npm provenance verified"></a>
13
17
  <a href="https://www.skills.sh/slow-stack/euthyna"><img src="https://skills.sh/b/slow-stack/euthyna.svg" alt="Agent skills installs"></a>
14
18
  <a href="https://awesome-dsh-plugin.com/p/slow-stack/euthyna/"><img src="https://img.shields.io/badge/DSH%20plugin-listed-blue" alt="Listed in the awesome-dsh-plugin catalog"></a>
15
19
  </p>
@@ -53,10 +57,11 @@ Three fact producers — a zero-dependency Node CLI:
53
57
  - **`history`** — for every line a change deletes, it attributes the line to a commit
54
58
  and classifies that commit from its message, its own diff, and the deleted line's
55
59
  content (a deleted `if (!authorized)` is flagged even under a "tweaks" subject). By
56
- default the attribution is blame's "last touched"; `--origins` digs for the commit
57
- that *first introduced* the content with `git log -S`. If the deleted code came from
58
- a security fix, that is flagged. This is git archaeology no model can do from
59
- reading a diff.
60
+ default the attribution traces the commit that *first introduced* the content with
61
+ `git log -S` (`--no-origins` falls back to blame's "last touched", which is faster
62
+ but mis-attributes a security line later moved by a formatting commit). If the
63
+ deleted code came from a security fix, that is flagged. This is git archaeology no
64
+ model can do from reading a diff.
60
65
  - **`coverage`** — was this symbol *ever actually invoked* by a test? It has exactly two
61
66
  answers: never invoked (established), or entered but that proves nothing about any
62
67
  specific call site (unknown). **It never reports "executed"** — V8 coverage marks
@@ -163,20 +168,20 @@ replying in chat.
163
168
 
164
169
  ```sh
165
170
  npm install -g euthyna
166
- euthyna history --repo <path> --base main --head HEAD # add --origins to chase the first introducer
171
+ euthyna audit --repo <path> --base main --head HEAD # history + deps in one run
167
172
  euthyna coverage --coverage coverage/coverage-final.json --symbol <name>
168
- euthyna deps --repo <path> --dep <name>
173
+ euthyna deps --repo <path> --all # or --dep <name> for a single dep
169
174
  euthyna gate <adjudication-report.md> --verify --cwd <repo> # mechanically check the six gates
170
175
  ```
171
176
 
172
- Or without a global install: `npx euthyna history --repo <path> --base main`.
177
+ Or without a global install: `npx euthyna audit --repo <path> --base main`.
173
178
 
174
179
  From a checkout instead (development):
175
180
 
176
181
  ```sh
177
182
  git clone https://github.com/slow-stack/euthyna
178
- cd euthyna && npm test # 178 tests; no install step exists
179
- node bin/euthyna.js history --repo <path> --base main --head HEAD
183
+ cd euthyna && npm test # 207 tests; no install step exists
184
+ node bin/euthyna.js audit --repo <path> --base main --head HEAD
180
185
  ```
181
186
 
182
187
  What `history` reports looks like this:
@@ -191,9 +196,10 @@ What `history` reports looks like this:
191
196
 
192
197
  Every deleted line is blamed back to a commit, and the `复现`
193
198
  (Reproduce) command lets you re-derive the claim yourself without trusting the report.
194
- Add `--json` for the structured fact report, `--pickaxe` to detect lines that were
195
- removed and are now being added back, and `--origins` to attribute deleted lines to the
196
- commit that first introduced their content rather than to blame's last modifier.
199
+ Add `--json` for the structured fact report and `--pickaxe` to detect lines that were
200
+ removed and are now being added back. Origin tracing is on by default (deleted lines
201
+ are attributed to the commit that first introduced their content); `--no-origins`
202
+ falls back to blame's last-modifier attribution, which is faster.
197
203
 
198
204
  ### Exit codes are part of the contract
199
205
 
@@ -319,8 +325,9 @@ euthyna/
319
325
 
320
326
  ## 🧭 Status
321
327
 
322
- Early, and honest about it. Working: the two fact producers, the skill, the benchmark harness.
323
- Not yet built: wiring the skill to the CLI so an agent uses them without being told, and the
328
+ Early, and honest about it. Working: the fact producers (`euthyna audit` runs history
329
+ and the full dependency surface in one command — the skill documents it as the first
330
+ command of every audit), the skill, the benchmark harness. Not yet built: the
324
331
  git-history / coverage work needed to close the remaining recall gap.
325
332
 
326
333
  The design notes are available in English and Chinese; the `-zh` files are the originals.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euthyna",
3
- "version": "0.3.1",
3
+ "version": "0.4.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
@@ -1,5 +1,5 @@
1
1
  name: euthyna
2
- version: 0.3.1
2
+ version: 0.4.0
3
3
  description: euthyna audit discipline — slash commands that drive the euthyna fact-producing CLI (history / coverage / deps / gate)
4
4
  license: Apache-2.0
5
5
  homepage: https://github.com/slow-stack/euthyna
package/src/cli.js CHANGED
@@ -18,7 +18,7 @@ import path from 'node:path';
18
18
  import { readFile } from 'node:fs/promises';
19
19
  import { collectHistoryFacts } from './facts/history.js';
20
20
  import { collectCoverageFacts } from './facts/coverage.js';
21
- import { collectDependencyFacts } from './facts/deps.js';
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
24
  import { repoToplevel, revParse } from './git.js';
@@ -49,6 +49,12 @@ const DEPS_PRODUCER = {
49
49
  purpose: '依赖锁定版本(读取 lockfile,不含漏洞判定)',
50
50
  purposeEn: 'dependency pinned versions (reads lockfiles, no vulnerability verdict)'
51
51
  };
52
+ const AUDIT_PRODUCER = {
53
+ name: 'euthyna-audit',
54
+ version: '0.1.0',
55
+ purpose: '变更面审计:删除代码来源 + 依赖锁定版本,一次测量',
56
+ purposeEn: 'change-surface audit: deleted-code provenance + dependency pins, one run'
57
+ };
52
58
 
53
59
  /** The producer object that goes into a report, with its purpose in the active language. */
54
60
  function producerFor(producer, lang) {
@@ -65,20 +71,23 @@ euthyna —— 给 AI 编码 agent 用的确定性事实产出器
65
71
  它不是扫描器。它只回答两个模型算不准的问题,并把答案写成带证据的事实。
66
72
 
67
73
  用法:
68
- euthyna history --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--origins] [--json]
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]
69
76
  euthyna coverage --coverage <file> --symbol <name> [--file <path>] [--json]
70
- euthyna deps [--repo <dir>] [--lockfile <file>] --dep <name> [--dep <name>] [--json]
77
+ euthyna deps [--repo <dir>] [--lockfile <file>] (--dep <name> | --all) [--json]
71
78
  euthyna gate <报告文件> [--verify] [--cwd <dir>] [--json]
72
79
 
73
80
  语言: 默认中文。加 --lang en 或设环境变量 EUTHYNA_LANG=en 切换英文输出。
74
81
 
75
82
  命令:
83
+ audit 一次跑完变更面审计:history + deps(枚举 lockfile 全部依赖),合并报告
84
+ 这是技能文档默认推荐入口;coverage 因需要覆盖率文件和符号名不在其中
76
85
  history 本次变更删掉了哪些代码、它们分别由哪个提交引入、该提交是不是安全修复
77
- --base 必填,比较的基线版本(如 main、HEAD~5、某个 commit)
78
- --head 可选,默认 HEAD
79
- --pickaxe 额外检查「曾被移除又加回来」的新增行(有探针上限)
80
- --origins 用 git log -S 把删除行归属到「最初引入」该内容的提交,
81
- 而非 blame 的「最后修改者」(有探针上限,比默认慢)
86
+ --base 必填,比较的基线版本(如 main、HEAD~5、某个 commit)
87
+ --head 可选,默认 HEAD
88
+ --pickaxe 额外检查「曾被移除又加回来」的新增行(有探针上限)
89
+ --no-origins 关闭来源追溯;默认用 git log -S 把删除行归属到「最初引入」
90
+ 该内容的提交(有探针上限,比纯 blame 慢)
82
91
  coverage 某个符号在测试运行中到底有没有被调用过
83
92
  --coverage 覆盖率数据文件,c8 的 coverage-final.json
84
93
  --symbol 要查询的符号名,可重复
@@ -86,7 +95,7 @@ euthyna —— 给 AI 编码 agent 用的确定性事实产出器
86
95
  deps 某个依赖在 lockfile 里被锁定/声明成什么版本(供应链声明的裁决依据)
87
96
  --repo 可选,依赖清单所在目录(默认当前目录,自动检测)
88
97
  --lockfile 可选,显式指定清单文件(支持 package-lock.json / Cargo.lock / go.mod)
89
- --dep 要查询的依赖名,可重复
98
+ --dep 要查询的依赖名,可重复;--all 查询清单里全部依赖
90
99
  注:只报版本事实,不判「是否含漏洞」——版本到 CVE 的映射归判定层
91
100
  gate 检查一份审计报告是否符合 6 门禁契约(不测量,只核对报告的自我声明)
92
101
  <报告文件> 报告的 markdown 文件,裁定格式见技能 SKILL.md
@@ -112,31 +121,36 @@ It is not a scanner. It answers only the two questions a model cannot compute
112
121
  reliably, and writes the answers as facts with evidence.
113
122
 
114
123
  Usage:
115
- euthyna history --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--origins] [--json]
124
+ euthyna audit --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json]
125
+ euthyna history --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json]
116
126
  euthyna coverage --coverage <file> --symbol <name> [--file <path>] [--json]
117
- euthyna deps [--repo <dir>] [--lockfile <file>] --dep <name> [--dep <name>] [--json]
127
+ euthyna deps [--repo <dir>] [--lockfile <file>] (--dep <name> | --all) [--json]
118
128
  euthyna gate <report file> [--verify] [--cwd <dir>] [--json]
119
129
 
120
130
  Language: Chinese by default. Pass --lang en or set the environment variable
121
131
  EUTHYNA_LANG=en for English output.
122
132
 
123
133
  Commands:
124
- history Which lines did this change delete, which commit introduced them,
134
+ audit the change-surface audit in one run: history + deps (every lockfile
135
+ dependency enumerated), one merged report. The default entry point
136
+ the skill documents; coverage stays separate (needs a coverage file
137
+ and symbol names only the adjudicator knows)
138
+ history which lines did this change delete, which commit introduced them,
125
139
  and was that commit a security fix?
126
- --base required, the baseline revision to compare against (main, HEAD~5, a commit)
127
- --head optional, defaults to HEAD
128
- --pickaxe additionally check added lines that were once removed and now return (probe-capped)
129
- --origins attribute deleted lines to the commit that FIRST introduced
130
- their content with git log -S, instead of blame's last modifier
131
- (probe-capped, slower than the default)
132
- coverage Was this symbol ever actually invoked by a test run?
140
+ --base required, the baseline revision to compare against
141
+ --head optional, defaults to HEAD
142
+ --pickaxe also check added lines that were once removed and now return (probe-capped)
143
+ --no-origins disable origin tracing; by default git log -S attributes deleted
144
+ lines to the commit that FIRST introduced their content
145
+ (probe-capped, slower than plain blame)
146
+ coverage was this symbol ever actually invoked by a test run?
133
147
  --coverage the coverage data file, c8's coverage-final.json
134
148
  --symbol symbol name(s) to query, repeatable
135
149
  --file optional, restrict to one file
136
- deps What version does the lockfile pin or declare for a dependency? (the basis for supply-chain claims)
150
+ deps what version does the lockfile pin or declare for a dependency? (the basis for supply-chain claims)
137
151
  --repo optional, directory containing the manifest (default cwd, auto-detected)
138
152
  --lockfile optional, explicit manifest path (package-lock.json / Cargo.lock / go.mod)
139
- --dep dependency name(s) to query, repeatable
153
+ --dep dependency name(s) to query, repeatable; --all queries every dependency in the manifest
140
154
  Note: reports version facts only, never "is vulnerable" — version-to-CVE mapping
141
155
  belongs to the adjudication layer
142
156
  gate Check an audit report against the six-gate contract (no measurement; only the report's self-declared claims)
@@ -252,12 +266,15 @@ async function runHistory(flags, lang) {
252
266
  };
253
267
  }
254
268
 
269
+ // Origin tracing is the default (see runAudit); --no-origins opts out.
270
+ const origins = flags['no-origins'] !== true;
271
+
255
272
  const { facts, evaluated, notEvaluated, measured } = await collectHistoryFacts({
256
273
  cwd: toplevel,
257
274
  base,
258
275
  head,
259
276
  pickaxe: flags.pickaxe === true,
260
- origins: flags.origins === true,
277
+ origins,
261
278
  lang
262
279
  });
263
280
 
@@ -318,12 +335,42 @@ async function runCoverage(flags, lang) {
318
335
  async function runDeps(flags, lang) {
319
336
  const t = T(lang);
320
337
  const cwd = path.resolve(flags.repo ? String(flags.repo) : process.cwd());
321
- const deps = asArray(flags.dep).map(String);
322
- const lockfile = flags.lockfile ? String(flags.lockfile) : undefined;
338
+ let lockfile = flags.lockfile ? String(flags.lockfile) : undefined;
339
+ let deps = asArray(flags.dep).map(String);
340
+
341
+ // --all: query every dependency the manifest mentions. Used by `euthyna
342
+ // audit`; also available directly. The manifest is located first so the
343
+ // names come from the same file the per-dep resolver will read.
344
+ if (flags.all === true) {
345
+ const target = lockfile
346
+ ? { path: path.resolve(lockfile), type: manifestTypeForFile(path.basename(path.resolve(lockfile))) }
347
+ : await detectManifest(cwd);
348
+ if (!target?.type) {
349
+ return {
350
+ exit: EXIT.UNMEASURED,
351
+ error: t(
352
+ '--all 找不到受支持的依赖清单(package-lock.json / Cargo.lock / go.mod)',
353
+ '--all found no supported dependency manifest (package-lock.json / Cargo.lock / go.mod)'
354
+ )
355
+ };
356
+ }
357
+ const text = await readFile(target.path, 'utf8');
358
+ deps = listDependencies(target.type, text);
359
+ lockfile = target.path;
360
+ if (deps.length === 0) {
361
+ return {
362
+ exit: EXIT.UNMEASURED,
363
+ error: t(
364
+ `${target.path} 中没有枚举出任何依赖`,
365
+ `${target.path} enumerates no dependencies`
366
+ )
367
+ };
368
+ }
369
+ }
323
370
 
324
371
  // Asking for nothing is a caller mistake, not a measurement failure.
325
372
  if (deps.length === 0) {
326
- return { exit: EXIT.USAGE, error: t('deps 需要至少一个 --dep <name>', 'deps requires at least one --dep <name>') };
373
+ return { exit: EXIT.USAGE, error: t('deps 需要至少一个 --dep <name>(或 --all)', 'deps requires at least one --dep <name> (or --all)') };
327
374
  }
328
375
 
329
376
  const { facts, evaluated, notEvaluated, measured } = await collectDependencyFacts({
@@ -343,6 +390,109 @@ async function runDeps(flags, lang) {
343
390
 
344
391
  return { exit: measured ? EXIT.CLEAN : EXIT.UNMEASURED, report };
345
392
  }
393
+ /**
394
+ * `euthyna audit` — the change-surface measurement in one command.
395
+ *
396
+ * history (deleted-code provenance) + deps (every lockfile dependency), one
397
+ * combined report, exit code = the worse of the two. This is the wiring the
398
+ * README called "not yet built": an agent loads the skill and runs ONE command
399
+ * instead of being told which producers to invoke in which order.
400
+ *
401
+ * coverage is deliberately not part of audit: it needs a coverage data file and
402
+ * symbol names only the adjudicator knows, so there is nothing honest to
403
+ * auto-measure there.
404
+ */
405
+ async function runAudit(flags, lang) {
406
+ const t = T(lang);
407
+ const cwd = path.resolve(flags.repo ? String(flags.repo) : process.cwd());
408
+
409
+ if (!flags.base) {
410
+ return { exit: EXIT.USAGE, error: t('audit 需要 --base <rev>', 'audit requires --base <rev>') };
411
+ }
412
+
413
+ // --- history half (mirrors runHistory's repo/base/head resolution) ---
414
+ const toplevel = await repoToplevel(cwd);
415
+ if (!toplevel) {
416
+ return {
417
+ exit: EXIT.UNMEASURED,
418
+ report: makeReport({
419
+ producer: producerFor(AUDIT_PRODUCER, lang),
420
+ subject: { repo: cwd },
421
+ facts: [],
422
+ notEvaluated: [
423
+ {
424
+ kind: KIND.HISTORY,
425
+ reason: t(
426
+ `${cwd} 不在任何 git 仓库内,无法测量历史`,
427
+ `${cwd} is not inside any git repository; history cannot be measured`
428
+ )
429
+ }
430
+ ]
431
+ })
432
+ };
433
+ }
434
+
435
+ const base = await revParse(toplevel, String(flags.base));
436
+ if (!base) {
437
+ return {
438
+ exit: EXIT.USAGE,
439
+ error: t(`无法解析 --base "${flags.base}" 为一个提交`, `cannot resolve --base "${flags.base}" to a commit`)
440
+ };
441
+ }
442
+
443
+ const head = await revParse(toplevel, String(flags.head ?? 'HEAD'));
444
+ if (!head) {
445
+ return {
446
+ exit: EXIT.USAGE,
447
+ error: t(`无法解析 --head "${flags.head ?? 'HEAD'}" 为一个提交`, `cannot resolve --head "${flags.head ?? 'HEAD'}" to a commit`)
448
+ };
449
+ }
450
+
451
+ const origins = flags['no-origins'] !== true;
452
+ const history = await collectHistoryFacts({
453
+ cwd: toplevel,
454
+ base,
455
+ head,
456
+ pickaxe: flags.pickaxe === true,
457
+ origins,
458
+ lang
459
+ });
460
+
461
+ // --- deps half: measure EVERY dependency the manifest pins, not a hand-picked list ---
462
+ // runDeps reports "no manifest / manifest unreadable" through {error}; that is
463
+ // a not-evaluated criterion here, not something to swallow silently.
464
+ const depResult = await runDeps({ repo: toplevel, all: true }, lang);
465
+ const depFacts = depResult.report?.facts ?? [];
466
+ const depEvaluated = depResult.report?.coverage?.evaluated ?? [];
467
+ const depNotEvaluated = depResult.report?.coverage?.notEvaluated ?? [];
468
+ if (depResult.error) {
469
+ depNotEvaluated.push({ kind: KIND.DEPENDENCY, reason: depResult.error });
470
+ }
471
+
472
+ const facts = [...history.facts, ...depFacts];
473
+ const evaluated = [...history.evaluated, ...depEvaluated];
474
+ const notEvaluated = [...history.notEvaluated, ...depNotEvaluated];
475
+
476
+ const report = makeReport({
477
+ producer: producerFor(AUDIT_PRODUCER, lang),
478
+ subject: { repo: toplevel, base, head },
479
+ facts,
480
+ evaluated,
481
+ notEvaluated
482
+ });
483
+
484
+ // Exit = the worse of the two halves. A missing lockfile keeps its
485
+ // notEvaluated reason (missing data is not clean) but cannot fail a run the
486
+ // history half answered.
487
+ const flagged = facts.some(
488
+ f => (f.kind === KIND.HISTORY || f.kind === KIND.REINTRODUCTION) &&
489
+ f.detail && f.detail.classification === 'security'
490
+ );
491
+ const exit = !history.measured ? EXIT.UNMEASURED : flagged ? EXIT.FLAGGED : EXIT.CLEAN;
492
+
493
+ return { exit, report };
494
+ }
495
+
346
496
  async function runGate(flags, positional, lang) {
347
497
  const t = T(lang);
348
498
  const file = positional[1];
@@ -501,6 +651,8 @@ export async function main(argv = process.argv.slice(2)) {
501
651
  let result;
502
652
  if (command === 'history') {
503
653
  result = await runHistory(flags, lang);
654
+ } else if (command === 'audit') {
655
+ result = await runAudit(flags, lang);
504
656
  } else if (command === 'coverage') {
505
657
  result = await runCoverage(flags, lang);
506
658
  } else if (command === 'deps') {
package/src/facts/deps.js CHANGED
@@ -200,6 +200,61 @@ function typeForFile(name) {
200
200
  return SUPPORTED.find((s) => s.file === name)?.type ?? null;
201
201
  }
202
202
 
203
+ /**
204
+ * Every dependency name a manifest mentions, deduplicated and sorted.
205
+ *
206
+ * Powers `euthyna audit`, which measures the whole dependency surface without
207
+ * anyone typing 200 `--dep` flags. Names only — version facts still come from
208
+ * the same resolver path a per-dep query uses, so audit adds no new parsing.
209
+ */
210
+ export function listDependencies(type, text) {
211
+ const names = new Set();
212
+ if (type === 'npm') {
213
+ const root = JSON.parse(text);
214
+ for (const key of Object.keys(root.packages ?? {})) {
215
+ if (!key.includes('node_modules/')) continue;
216
+ const name = key.split('node_modules/').pop();
217
+ if (name) names.add(name);
218
+ }
219
+ const walk = (tree) => {
220
+ for (const [name, meta] of Object.entries(tree ?? {})) {
221
+ if (!meta || typeof meta !== 'object') continue;
222
+ if (meta.version) names.add(name);
223
+ if (meta.dependencies && typeof meta.dependencies === 'object') walk(meta.dependencies);
224
+ }
225
+ };
226
+ walk(root.dependencies);
227
+ } else if (type === 'cargo') {
228
+ for (const block of text.split('[[package]]')) {
229
+ const name = /^\s*name\s*=\s*"([^"]+)"/m.exec(block);
230
+ if (name) names.add(name[1]);
231
+ }
232
+ } else if (type === 'go') {
233
+ let inBlock = false;
234
+ for (const raw of text.split('\n')) {
235
+ const line = raw.trim();
236
+ if (line.startsWith('require (')) { inBlock = true; continue; }
237
+ if (line === ')') { inBlock = false; continue; }
238
+ let match = /^require\s+(\S+)\s+v[\w.+-]+/.exec(line);
239
+ if (!match && inBlock) match = /^(\S+)\s+v[\w.+-]+/.exec(line);
240
+ if (match) names.add(match[1]);
241
+ }
242
+ }
243
+ return [...names].sort();
244
+ }
245
+
246
+ export { SUPPORTED as SUPPORTED_MANIFESTS, UNSUPPORTED as UNSUPPORTED_MANIFESTS };
247
+
248
+ /** First supported manifest found in cwd, or null: {path, type}. */
249
+ export async function detectManifest(cwd) {
250
+ return (await detectIn(cwd))[0] ?? null;
251
+ }
252
+
253
+ /** Manifest type for a filename ('npm' | 'cargo' | 'go'), or null when unsupported. */
254
+ export function manifestTypeForFile(name) {
255
+ return typeForFile(name);
256
+ }
257
+
203
258
  async function detectIn(cwd) {
204
259
  const found = [];
205
260
  for (const spec of SUPPORTED) {
@@ -187,11 +187,12 @@ export function classifySubject(subject, { securityPattern = SECURITY_PATTERN, f
187
187
  * Attribution semantics, stated explicitly because they are the point of this
188
188
  * function and easy to over-claim:
189
189
  *
190
- * - blame (the default) answers "who last touched the line" — a security-fix
191
- * line that a later formatting/refactor commit moved is attributed to the
192
- * formatter, not the fix. `--origins` answers "who FIRST introduced the
193
- * line's content" by probing `git log -S` and re-attributes the line when
194
- * the introducing commit differs and classifies security or fix.
190
+ * - origins (the default) answers "who FIRST introduced the line's content"
191
+ * by probing `git log -S` and re-attributes the line when the introducing
192
+ * commit differs and classifies security or fix. Pass origins=false for
193
+ * plain blame semantics ("who last touched the line") — a security-fix line
194
+ * that a later formatting commit moved is then attributed to the formatter,
195
+ * not the fix, which is exactly the mis-attribution origins exists to catch.
195
196
  * - classification looks at the commit message first, then at whether the
196
197
  * deleted line content itself is security vocabulary, then at whether the
197
198
  * commit's own diff touched security-vocabulary lines. All three err broad
@@ -204,8 +205,8 @@ export function classifySubject(subject, { securityPattern = SECURITY_PATTERN, f
204
205
  * @param {string} options.head
205
206
  * @param {boolean} [options.pickaxe] also look for reintroduced lines
206
207
  * @param {number} [options.maxPickaxe] cap on pickaxe probes (reported when hit)
207
- * @param {boolean} [options.origins] re-attribute deleted lines to the commit
208
- * that first introduced their content
208
+ * @param {boolean} [options.origins=true] re-attribute deleted lines to the commit
209
+ * that first introduced their content
209
210
  * @param {number} [options.maxOrigins] cap on origin probes (reported when hit)
210
211
  */
211
212
  export async function collectHistoryFacts({
@@ -214,7 +215,7 @@ export async function collectHistoryFacts({
214
215
  head = 'HEAD',
215
216
  pickaxe = false,
216
217
  maxPickaxe = 40,
217
- origins = false,
218
+ origins = true,
218
219
  maxOrigins = 40,
219
220
  securityPattern = SECURITY_PATTERN,
220
221
  fixPattern = FIX_PATTERN,
@@ -407,9 +408,9 @@ export async function collectHistoryFacts({
407
408
  notEvaluatedEntry(
408
409
  'history-origins',
409
410
  t(
410
- '未启用 --origins,删除行的归属基于 git blame 的「最后修改」语义;' +
411
+ '--no-origins 已关闭来源追溯,删除行的归属基于 git blame 的「最后修改」语义;' +
411
412
  '被删行本身或提交 diff 含安全关键词时仍会标为 security',
412
- '--origins not enabled; deleted lines are attributed by git blame\'s "last modified" semantics; ' +
413
+ '--no-origins disabled origin tracing; deleted lines are attributed by git blame\'s "last modified" semantics; ' +
413
414
  'lines whose content or commit diff contains security vocabulary are still classified security'
414
415
  )
415
416
  )