From a094a984c911515e957ec329ad081344fa246980 Mon Sep 17 00:00:00 2001 From: modusensus Date: Tue, 20 Oct 2026 19:06:36 +0800 Subject: [PATCH 1/5] feat(skills): put the mode words on the slash surface, in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The way this tool is actually used is a slash command, and the entry tree that decides what to run was prose: workable for a model, unusable as something to type. Six modes now map a word to a stage and a first command — pr, graph, repo, claim, gate, help — the last one a card that runs nothing and changes no state. The table lives in the skill and the host files point at it rather than copying CLI usage, which is what they had been doing: `.claude`, `.cursor` and `.github/prompts` each carried their own line for `euthyna audit --base`, and OpenCode forwarded its arguments straight to the CLI — where a mode word is not a subcommand on purpose, so `/euthyna graph` would have reached `euthyna graph` and failed as an unknown command. The mirror guard already covers this without a new checker: a seventh row added to the Chinese half alone turns `skill-mirror` red on the table shape. Measured, not assumed. --- .agents/skills/euthyna-en/SKILL.md | 20 ++++++++++++++++++++ .agents/skills/euthyna/SKILL.md | 17 +++++++++++++++++ .claude/commands/euthyna.md | 4 ++-- .cursor/commands/euthyna.md | 2 +- .github/prompts/euthyna.prompt.md | 2 +- .opencode/commands/euthyna.md | 4 +++- 6 files changed, 44 insertions(+), 5 deletions(-) diff --git a/.agents/skills/euthyna-en/SKILL.md b/.agents/skills/euthyna-en/SKILL.md index c80a8a9..117bf89 100644 --- a/.agents/skills/euthyna-en/SKILL.md +++ b/.agents/skills/euthyna-en/SKILL.md @@ -102,6 +102,26 @@ facts from the producers decide where the manual reading must be directed. In particular: every suspected finding reported by Stage B must go through Stage C one by one — never write it straight into the report as a conclusion. +### Modes (slash commands) + +When a user arrives through a slash command, map the mode to the stage and the first +command using this table. **The table is maintained only here**; the host command files +(Cursor / OpenCode / Copilot / Hermes) point at this section instead of each keeping their +own copy. + +| You type | Which branch | First command | +|---|---|---| +| `/euthyna pr` | Stage B, the change surface | `euthyna audit --base ` | +| `/euthyna graph` | Stage B, the provenance view | `euthyna audit --graph` | +| `/euthyna repo` | Stage A, the dependency surface | `euthyna deps --all` | +| `/euthyna claim ` | Stage C, the claim surface | `euthyna coverage --symbol ` | +| `/euthyna gate ` | Validating a written report | `euthyna gate ` | +| `/euthyna help` | Print this table only | run nothing, change no state | + +`/euthyna graph` is not a new measurement: `--graph` only draws the attribution `history` +already emits, as Mermaid. **A missing second edge does not mean the content never moved** — +the boundaries are in references/fact-producers.md. + --- ## The two deterministic measurements this skill ships diff --git a/.agents/skills/euthyna/SKILL.md b/.agents/skills/euthyna/SKILL.md index 56126ef..5eecd6e 100644 --- a/.agents/skills/euthyna/SKILL.md +++ b/.agents/skills/euthyna/SKILL.md @@ -92,6 +92,23 @@ license: Apache-2.0 **可以做多个阶段,但阶段之间不许互相替代。** 特别是:**阶段 B 报出的疑似发现,必须逐个走阶段 C 验证**,不许直接写进报告当结论。 +### 模式词(斜杠命令) + +用户用斜杠命令进来时,按下表把模式映射到阶段与第一条命令。**词表只在这里维护**, +各宿主命令文件(Cursor / OpenCode / Copilot / Hermes)指向本节,不各自重抄一份。 + +| 你打 | 走哪条支路 | 第一条命令 | +|---|---|---| +| `/euthyna pr` | 阶段 B 变更面 | `euthyna audit --base <分叉点>` | +| `/euthyna graph` | 阶段 B 的来源谱系视图 | `euthyna audit --graph` | +| `/euthyna repo` | 阶段 A 依赖面 | `euthyna deps --all` | +| `/euthyna claim <结论>` | 阶段 C 结论面 | `euthyna coverage --symbol <名>` | +| `/euthyna gate <报告>` | 校验一份已写的报告 | `euthyna gate <报告>` | +| `/euthyna help` | 只打印这张表 | 不跑命令,不改变状态 | + +`/euthyna graph` 不是新测量:`--graph` 只是把 `history` 已经给出的归属画成 Mermaid。 +**图上没有第二条边不等于内容没被移过**,边界见 references/fact-producers.md。 + --- ## 本技能自带的两个确定性测量 diff --git a/.claude/commands/euthyna.md b/.claude/commands/euthyna.md index 648d513..adf8753 100644 --- a/.claude/commands/euthyna.md +++ b/.claude/commands/euthyna.md @@ -1,6 +1,6 @@ --- description: 代码安全审计:按 euthyna 纪律审一段代码、一次变更或一条漏洞结论,产出带证据的报告 -argument-hint: "<路径 或 diff 范围>" +argument-hint: "<模式词 或 路径 / diff 范围>" allowed-tools: Read, Grep, Glob, Bash, Task --- @@ -11,7 +11,7 @@ allowed-tools: Read, Grep, Glob, Bash, Task 按 euthyna 技能执行一次完整审计: 1. **先加载纪律**:找到 euthyna 技能目录(`~/.claude/skills/euthyna/`、项目 `.claude/skills/euthyna/` 或 `.agents/skills/euthyna/`),读 `SKILL.md`,需要时再读 `references/`。找不到就明说「技能缺失」,不要凭印象执行。 -2. **按入口决策树走**:单条漏洞结论 → 阶段 C;一次变更 → 阶段 B;依赖面 → 阶段 A;整体审计 → 阶段 B 为主。 +2. **按入口决策树走**:单条漏洞结论 → 阶段 C;一次变更 → 阶段 B;依赖面 → 阶段 A;整体审计 → 阶段 B 为主。用户以模式词进来时(参数命中技能的模式词表),先按 `SKILL.md` 的「模式词(斜杠命令)」一节映射出阶段与第一条命令,再往下走;其余文字按审计目标处理。 3. **先测量再读码**:diff 有删除行先跑 `history`;谈测试覆盖先跑 `coverage`;依赖声明先跑 `deps`(操作手册在 `references/fact-producers.md`)。 4. **每条结论过六道门禁**:证据到 `文件:行号`、可复现命令、影响说明;拿不出证据的降级为「观察」。 5. **三态裁定**:`BUG #N TRUE POSITIVE / FALSE POSITIVE / INCONCLUSIVE`。 diff --git a/.cursor/commands/euthyna.md b/.cursor/commands/euthyna.md index 080ada4..ebc81c6 100644 --- a/.cursor/commands/euthyna.md +++ b/.cursor/commands/euthyna.md @@ -5,7 +5,7 @@ 按 euthyna 技能执行一次完整审计: 1. **先加载纪律**:找到 euthyna 技能目录(`~/.cursor/skills/euthyna/`、项目 `.cursor/skills/euthyna/`,或 npm 全局包内的 `.agents/skills/euthyna/`),读 `SKILL.md`,需要时再读 `references/`。找不到就明说「技能缺失」,不要凭印象执行。 -2. **按入口决策树走**:单条漏洞结论 → 阶段 C;一次变更 → 阶段 B;依赖面 → 阶段 A;整体审计 → 阶段 B 为主。 +2. **按入口决策树走**:单条漏洞结论 → 阶段 C;一次变更 → 阶段 B;依赖面 → 阶段 A;整体审计 → 阶段 B 为主。用户以模式词进来时(参数命中技能的模式词表),先按 `SKILL.md` 的「模式词(斜杠命令)」一节映射出阶段与第一条命令,再往下走;其余文字按审计目标处理。 3. **先测量再读码**:第一条命令跑 `euthyna audit --base <分叉点> --repo <被审计仓库>`(history + deps 一次跑完);谈测试覆盖再跑 `coverage`(操作手册在 `references/fact-producers.md`)。 4. **每条结论过六道门禁**:证据到 `文件:行号`、可复现命令、影响说明;拿不出证据的降级为「观察」。 5. **三态裁定**:`BUG #N TRUE POSITIVE / FALSE POSITIVE / INCONCLUSIVE`。 diff --git a/.github/prompts/euthyna.prompt.md b/.github/prompts/euthyna.prompt.md index cb389ca..7d09dcb 100644 --- a/.github/prompts/euthyna.prompt.md +++ b/.github/prompts/euthyna.prompt.md @@ -7,7 +7,7 @@ description: 代码安全审计:按 euthyna 纪律审一段代码、一次变 按 euthyna 技能执行一次完整审计: 1. **先加载纪律**:找到 euthyna 技能目录(项目 `.github/skills/euthyna/` 或 `.agents/skills/euthyna/`,个人 `~/.copilot/skills/euthyna/`),读 `SKILL.md`,需要时再读 `references/`。找不到就明说「技能缺失」,不要凭印象执行。 -2. **按入口决策树走**:单条漏洞结论 → 阶段 C;一次变更 → 阶段 B;依赖面 → 阶段 A;整体审计 → 阶段 B 为主。 +2. **按入口决策树走**:单条漏洞结论 → 阶段 C;一次变更 → 阶段 B;依赖面 → 阶段 A;整体审计 → 阶段 B 为主。用户以模式词进来时(参数命中技能的模式词表),先按 `SKILL.md` 的「模式词(斜杠命令)」一节映射出阶段与第一条命令,再往下走;其余文字按审计目标处理。 3. **先测量再读码**:第一条命令跑 `euthyna audit --base <分叉点> --repo <被审计仓库>`(history + deps 一次跑完);谈测试覆盖再跑 `coverage`(操作手册在 `references/fact-producers.md`)。 4. **每条结论过六道门禁**:证据到 `文件:行号`、可复现命令、影响说明;拿不出证据的降级为「观察」。复现命令执行前须向用户报备(见技能「执行纪律」节)。 5. **三态裁定**:`BUG #N TRUE POSITIVE / FALSE POSITIVE / INCONCLUSIVE`。 diff --git a/.opencode/commands/euthyna.md b/.opencode/commands/euthyna.md index 82f56b9..e984531 100644 --- a/.opencode/commands/euthyna.md +++ b/.opencode/commands/euthyna.md @@ -1,7 +1,9 @@ --- description: Run the euthyna code security audit CLI --- -Run the euthyna CLI with these arguments and report the measured result: +If the arguments match a mode word in the euthyna skill’s "Modes (slash commands)" table, +translate them per that table and run the mapped command; the table is maintained in the +skill, not here. Otherwise run the CLI with the arguments as given: euthyna $ARGUMENTS From 50d89784030cd7ed6c0b29f1f417fab8d38255fe Mon Sep 17 00:00:00 2001 From: modusensus Date: Tue, 20 Oct 2026 19:49:54 +0800 Subject: [PATCH 2/5] feat(cli): allow an omitted --base, and declare the inferred range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A slash command with no arguments needs a range, and the dangerous way to give it one is silently: a narrower range deletes edges and reads as cleaner. So the inference is stated where the range is stated — (推断…) on the range line, a range-inferred entry in notEvaluated, its code on the JSON channel — following the shape manifest-not-head already uses. merge-base(HEAD, origin/main), then origin/HEAD; if neither resolves the run stops with a usage error naming both refs, because guessing HEAD~1 would exit like a completed audit. history and audit share one resolveRangeBase helper rather than each growing a version of "what a missing --base means" — the two halves of audit were already the cautionary tale for that. Two existing tests were rewritten because their premise moved: "a missing --base is a usage error" is now true only when nothing resolves, so those cases assert the still-true contract (an unresolvable --base) and the fail-closed branch is covered by its own case against a repository with no remote-tracking branch. Verified by mutation: removing either declaration, the range-line marker, or the reason's ref names turns the suite red — four mutations, four caught. --- .../euthyna-en/references/fact-producers.md | 5 + .../euthyna/references/fact-producers.md | 4 + AGENTS.md | 4 +- docs/ci-integration-zh.md | 4 + docs/ci-integration.md | 5 + src/cli.js | 108 +++++++++++++----- src/contract.js | 7 +- src/git.js | 9 ++ test/cli.test.js | 101 +++++++++++++++- 9 files changed, 209 insertions(+), 38 deletions(-) diff --git a/.agents/skills/euthyna-en/references/fact-producers.md b/.agents/skills/euthyna-en/references/fact-producers.md index f1cc64a..976d60f 100644 --- a/.agents/skills/euthyna-en/references/fact-producers.md +++ b/.agents/skills/euthyna-en/references/fact-producers.md @@ -72,6 +72,11 @@ node /bin/euthyna.js history --base --no-ori ``` `--base` is the **pre-change revision** (the PR's fork point); `--head` defaults to `HEAD`. +Omitting `--base` infers it as `merge-base(HEAD, origin/main)`, marks the range line as inferred +and adds a `range-inferred` entry to the criteria not evaluated. An inferred range reaches **the +fork point only** — nothing earlier; when neither ref resolves the run stops with a usage error +instead of guessing `HEAD~1`. This is what lets a slash command arrive with no arguments, so +**the first thing to do with `range-inferred` is check the fork point**, not accept it. When the fork point is uncertain, use the result of `git merge-base HEAD`. ### Attribution semantics (two inherent approximations; established ≠ exact) diff --git a/.agents/skills/euthyna/references/fact-producers.md b/.agents/skills/euthyna/references/fact-producers.md index fbee6f3..0d94efb 100644 --- a/.agents/skills/euthyna/references/fact-producers.md +++ b/.agents/skills/euthyna/references/fact-producers.md @@ -72,6 +72,10 @@ node /bin/euthyna.js history --base <改前版本> --no-origins ``` `--base` 是**改前版本**(PR 的分叉点),`--head` 默认 `HEAD`。 +省略 `--base` 时按 `merge-base(HEAD, origin/main)` 推断,并在「范围」那一行标出「推断」、在 +「未评估的判据」里加一条 `range-inferred`。推断出的范围**只到分叉点**,看不到更早的历史; +两个引用都解析不出来时报用法错误,不猜 `HEAD~1`。斜杠命令免参数进来靠的就是这条,所以 +**看到 `range-inferred` 第一步是确认分叉点对不对**,而不是接受它。 分叉点不确定时用 `git merge-base <目标分支> HEAD` 的结果。 ### 归属语义(两个固有近似,已确证≠精确) diff --git a/AGENTS.md b/AGENTS.md index 1b47317..08cdedb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -687,8 +687,8 @@ is faithful — that is a reading job, and a mechanical stand-in would only manu | Path | What it is | |---|---| -| `bin/` + `src/` | **Fact producers** (zero-dependency Node CLI). `src/contract.js` is the contract in code; `src/facts/history.js`, `src/facts/coverage.js` and `src/facts/deps.js` are the three measurements (`history` has `--origins` to chase the first introducer and classifies by message + deleted-line + diff, **on by default since `euthyna audit`**; `coverage.js` reads **five** formats — c8/V8 JSON, classic istanbul JSON (jest's default provider, nyc: the same `fnMap`/`f` shape, so the symbol locator shares the c8 code path), coverage.py JSON (format 3), Go's text `-coverprofile` **and lcov tracefiles** (the Go profile needs `--source `: the profile carries no function names, so the producer parses top-level `func` ranges from the gofmt'd source; a file absent from the profile is notEvaluated, never "never executed"; the lcov path needs no `--source` because `FN:` carries the names — they are mangled, and the producer matches a `module::function` query against the `` tail segments of Rust v0 mangling (what `-C instrument-coverage` implies), since no demangler exists on this machine — each segment is anchored at a boundary and never inside a longer `` pair, so a query for `get` does not answer for `cfg_3get`; see `docs/fact-contract.md` §6.2 for the measured record, including that inlining was measured **not** to flatten a called function to 0); `deps.js` reads package-lock.json / Cargo.lock / go.mod, **located from the repository root only** — a monorepo sub-package lock is measured only by `deps --lockfile `, and `audit` does not notice its absence, which `docs/fact-contract.md` §6.3 records with the numbers from the probe). `audit` compares the manifest it measured with the one at `--head` and records `manifest-not-head` when the content differs or the file is absent there. `euthyna audit` (0.4.0, `src/cli.js`) runs history + the full dependency surface in one command — the single entry point the CI recipe and the skill both describe. `src/gate.js` is the six-gate report validator behind `euthyna gate <报告> [--verify] [--allow-exec]` — it parses the skill's 裁定格式, downgrades findings that lack evidence/reproduce or contradict their verdict, and executes nothing but `git` unless the caller consents to interpreters. `src/graph.js` is the Mermaid view behind `--graph` on `euthyna history` and `euthyna audit`: it draws the attribution `history` already emits (the range → the commit that last modified each deleted line → the commit that first introduced its content, plus the `notEvaluated` surface as dotted edges), adds no measurement, and carries states only — colouring a node by risk would make §3.3 of the contract false | -| `test/` | 316 tests via `node --test` (measured 2026-10-20: +20 over the 0.8.0 count — `test/graph.test.js` is 15, `--graph` adds 5 to `test/cli.test.js` including one that drives a real three-commit repository and one that runs `audit --graph` over a merged report), no third-party framework. The pass/skip split is keyed on **tar flavour, not the OS**: `test/bench.test.js` skips `every exploit expectation holds` unless `tar --version` reports bsdtar. Measured on `cdece0f`: ubuntu 245 pass + 1 skip (its log line: *the exploit ground truth is defined against bsdtar, not "tar (GNU tar) 1.35"*), windows 246 pass + 0 skip — and this machine's Git Bash PATH puts `D:\Git\usr\bin\tar.exe` (GNU tar 1.35) ahead of System32's `tar.exe`, so a local run **on `cdece0f`** reported 245 + 1 even though it is Windows (measured again at `4d620cd` for 0.8.0: pwsh 296 pass / 0 skip, Git Bash 295 pass / 1 skip, the difference being that one case — the 245/246 pair above is the `cdece0f` record and 296/295 was the count at `4d620cd`, so read that scope with the number). **The history tests build real git repositories rather than mocking**; the coverage tests carry fixtures shaped like c8, coverage.py, Go profile and lcov tracefile output (`coverage.test.js` + `coverage-go.test.js` + `coverage-lcov.test.js`, the lcov fixtures using mangled names copied from a real `llvm-cov export --format=lcov` run); the deps tests carry fixture lockfiles (npm v1/v2/v3, Cargo.lock, go.mod); the contract tests carry the shell-quoting and render-boundary regressions from PR #1; `git.test.js` carries the error-path quoting and stderr-sanitization regressions from issue #2; `gate.test.js` pins the six-gate validator (including that `--verify` does not run an interpreter command without `--allow-exec`); `cli.test.js` pins the `audit` command, its exit codes, and the manifest-version declaration — content against `--head`, so a PR merge commit whose lockfile is byte-identical to `--head`'s declares nothing while a gitignored lockfile declares itself; the decision itself is an exported pure function so the fail-closed branch (`manifest-unverified`) is reachable from a test; `skill.test.js` pins the written discipline (the six gates, the three verdicts, the `euthyna gate` reference) so weakening the skill turns CI red; `plugin.test.js`, `claude-plugin.test.js` and `hermes-plugin.test.js` pin the three manifest surfaces; `manifest-version.test.js` pins `package.json` against the three plugin manifests (0.5.0 shipped them a release behind and nothing compared them); `i18n.test.js` pins language rendering and report parsing (zh/en); `skill-mirror.test.js` compares the two skill editions against each other — file set, heading sequence (ATX and Setext), fenced blocks, table shapes, and the `euthyna ` / `--flag` identifier sets — so an unmirrored discipline change goes red instead of relying on memory, `docs-pair.test.js` holds `docs/` to the same rule (every document paired, and each pair equal on structure and identifiers — not on prose fidelity), and `markdown-structure.test.js` pins the shared scanner itself with seven crafted-document cases, because a legal Markdown form it cannot read makes any comparison pass on that form (neither guard compares word counts, which a translation legitimately needs room for). **The suite count is written down once, in this row**: README's quickstart carried its own number (207) and was 39 tests stale, so it now says only what `npm test` does, and `package.json` defines that as `node --test` | +| `bin/` + `src/` | **Fact producers** (zero-dependency Node CLI). `src/contract.js` is the contract in code; `src/facts/history.js`, `src/facts/coverage.js` and `src/facts/deps.js` are the three measurements (`history` has `--origins` to chase the first introducer and classifies by message + deleted-line + diff, **on by default since `euthyna audit`**; `coverage.js` reads **five** formats — c8/V8 JSON, classic istanbul JSON (jest's default provider, nyc: the same `fnMap`/`f` shape, so the symbol locator shares the c8 code path), coverage.py JSON (format 3), Go's text `-coverprofile` **and lcov tracefiles** (the Go profile needs `--source `: the profile carries no function names, so the producer parses top-level `func` ranges from the gofmt'd source; a file absent from the profile is notEvaluated, never "never executed"; the lcov path needs no `--source` because `FN:` carries the names — they are mangled, and the producer matches a `module::function` query against the `` tail segments of Rust v0 mangling (what `-C instrument-coverage` implies), since no demangler exists on this machine — each segment is anchored at a boundary and never inside a longer `` pair, so a query for `get` does not answer for `cfg_3get`; see `docs/fact-contract.md` §6.2 for the measured record, including that inlining was measured **not** to flatten a called function to 0); `deps.js` reads package-lock.json / Cargo.lock / go.mod, **located from the repository root only** — a monorepo sub-package lock is measured only by `deps --lockfile `, and `audit` does not notice its absence, which `docs/fact-contract.md` §6.3 records with the numbers from the probe). `audit` compares the manifest it measured with the one at `--head` and records `manifest-not-head` when the content differs or the file is absent there. `history` and `audit` also accept **no `--base` at all**: the range is then inferred as `merge-base(HEAD, origin/main)` and declared three times over — `(推断…)` on the range line, a `range-inferred` entry in `notEvaluated`, and its `code` on the JSON channel — because an inferred range reaches the fork point only and would otherwise read as a smaller, cleaner history; when neither `origin/main` nor `origin/HEAD` resolves the run stops with a usage error rather than guessing `HEAD~1`. `euthyna audit` (0.4.0, `src/cli.js`) runs history + the full dependency surface in one command — the single entry point the CI recipe and the skill both describe. `src/gate.js` is the six-gate report validator behind `euthyna gate <报告> [--verify] [--allow-exec]` — it parses the skill's 裁定格式, downgrades findings that lack evidence/reproduce or contradict their verdict, and executes nothing but `git` unless the caller consents to interpreters. `src/graph.js` is the Mermaid view behind `--graph` on `euthyna history` and `euthyna audit`: it draws the attribution `history` already emits (the range → the commit that last modified each deleted line → the commit that first introduced its content, plus the `notEvaluated` surface as dotted edges), adds no measurement, and carries states only — colouring a node by risk would make §3.3 of the contract false | +| `test/` | 321 tests via `node --test` (measured 2026-10-20: +25 over the 0.8.0 count — `test/graph.test.js` is 15, `--graph` adds 5 to `test/cli.test.js` including one that drives a real three-commit repository and one that runs `audit --graph` over a merged report, `--base` inference adds 5 more including a real clone with an `origin`), no third-party framework. The pass/skip split is keyed on **tar flavour, not the OS**: `test/bench.test.js` skips `every exploit expectation holds` unless `tar --version` reports bsdtar. Measured on `cdece0f`: ubuntu 245 pass + 1 skip (its log line: *the exploit ground truth is defined against bsdtar, not "tar (GNU tar) 1.35"*), windows 246 pass + 0 skip — and this machine's Git Bash PATH puts `D:\Git\usr\bin\tar.exe` (GNU tar 1.35) ahead of System32's `tar.exe`, so a local run **on `cdece0f`** reported 245 + 1 even though it is Windows (measured again at `4d620cd` for 0.8.0: pwsh 296 pass / 0 skip, Git Bash 295 pass / 1 skip, the difference being that one case — the 245/246 pair above is the `cdece0f` record and 296/295 was the count at `4d620cd`, so read that scope with the number). **The history tests build real git repositories rather than mocking**; the coverage tests carry fixtures shaped like c8, coverage.py, Go profile and lcov tracefile output (`coverage.test.js` + `coverage-go.test.js` + `coverage-lcov.test.js`, the lcov fixtures using mangled names copied from a real `llvm-cov export --format=lcov` run); the deps tests carry fixture lockfiles (npm v1/v2/v3, Cargo.lock, go.mod); the contract tests carry the shell-quoting and render-boundary regressions from PR #1; `git.test.js` carries the error-path quoting and stderr-sanitization regressions from issue #2; `gate.test.js` pins the six-gate validator (including that `--verify` does not run an interpreter command without `--allow-exec`); `cli.test.js` pins the `audit` command, its exit codes, and the manifest-version declaration — content against `--head`, so a PR merge commit whose lockfile is byte-identical to `--head`'s declares nothing while a gitignored lockfile declares itself; the decision itself is an exported pure function so the fail-closed branch (`manifest-unverified`) is reachable from a test; `skill.test.js` pins the written discipline (the six gates, the three verdicts, the `euthyna gate` reference) so weakening the skill turns CI red; `plugin.test.js`, `claude-plugin.test.js` and `hermes-plugin.test.js` pin the three manifest surfaces; `manifest-version.test.js` pins `package.json` against the three plugin manifests (0.5.0 shipped them a release behind and nothing compared them); `i18n.test.js` pins language rendering and report parsing (zh/en); `skill-mirror.test.js` compares the two skill editions against each other — file set, heading sequence (ATX and Setext), fenced blocks, table shapes, and the `euthyna ` / `--flag` identifier sets — so an unmirrored discipline change goes red instead of relying on memory, `docs-pair.test.js` holds `docs/` to the same rule (every document paired, and each pair equal on structure and identifiers — not on prose fidelity), and `markdown-structure.test.js` pins the shared scanner itself with seven crafted-document cases, because a legal Markdown form it cannot read makes any comparison pass on that form (neither guard compares word counts, which a translation legitimately needs room for). **The suite count is written down once, in this row**: README's quickstart carried its own number (207) and was 39 tests stale, so it now says only what `npm test` does, and `package.json` defines that as `node --test` | | `.agents/skills/euthyna/` | **The skill.** Doubles as source and as a project skill root (rank 200), so it is live in this workspace without a restart | | `bench/` | **The recall benchmark.** `exploits.js` establishes ground truth by execution (18 cases, 8 near-neighbour pairs); `prepare-blind.js` produces answer-free copies with per-round shuffled ids; `adjudicate.js` closes the loop end to end (blind tree → one adjudicator process per case → `collect-verdicts.js` machine validation → `score.js`), with a deterministic `golden` mode (ground truth written into reports — plumbing self-check only, never a real round) for CI; `collect-verdicts.js` machine-validates reports and extracts verdicts without the orchestrator reading report bodies; `score.js` computes the confusion matrix and the pair view; `perf.js` is the `history` scaling baseline. The verified-platform matrix (bsdtar vs GNU tar, p6 skip) lives in `bench/README.md`. Results: `bench/RESULTS.md` (round 1), `bench/RESULTS-round2.md` (round 2: three independent runs per case, zero flips), `bench/RESULTS-round3.md` (round 3: 18 cases, 54 adjudications, two fixture defects caught by adjudicators), `bench/RESULTS-round4.md` (round 4: 54/54, p6b v3 confirmed by first blind adjudication, cross-model run executed — zero flips across two model families); protocols in `bench/README.md`, design pre-registrations in `bench/DESIGN-round3.md` and `bench/DESIGN-round4.md` | | `docs/positioning.md` + `-zh` | Competitive analysis across the DSH catalog, including three claims that were tested and refuted | diff --git a/docs/ci-integration-zh.md b/docs/ci-integration-zh.md index 76b6b1d..fbf5c6f 100644 --- a/docs/ci-integration-zh.md +++ b/docs/ci-integration-zh.md @@ -9,6 +9,10 @@ > **局限**:history 生产者读 git,所以需要 `fetch-depth: 0`(或至少 `--base` 可达)。 > dependency 生产者读 lockfile;它回答的是 manifest 锁定了什么,而不是被锁版本有没有 > CVE——那个裁定按设计留给 agent 层(见 [`fact-contract-zh.md`](fact-contract-zh.md) §6.3)。 +> +> **`--base` 现在可以省略**——CLI 会按 `merge-base(HEAD, origin/main)` 推断,并把这件事声明成 +> `range-inferred`。本配方仍然显式传 `--base`:在 CI 里 `github.base_ref` 就是权威分叉点, +> 推断是给不带参数就进来的人用的,不是给工作流用的。 ## 门禁,一步版 diff --git a/docs/ci-integration.md b/docs/ci-integration.md index 03f5ea3..c7532fb 100644 --- a/docs/ci-integration.md +++ b/docs/ci-integration.md @@ -10,6 +10,11 @@ English · [中文](ci-integration-zh.md) > `--base` reachable). The dependency producer reads lockfiles; it answers what the manifest > pins, not whether a pinned version has a CVE — that adjudication is left to the agent layer, > by design (see [`fact-contract.md`](fact-contract.md) §6.3). +> +> **`--base` may now be omitted** — the CLI infers it as `merge-base(HEAD, origin/main)` and +> declares the inference as `range-inferred`. This recipe still passes `--base` explicitly: in CI +> `github.base_ref` is the authoritative fork point, and the inference exists for a person +> arriving with no arguments, not for a workflow. ## The gate, one step diff --git a/src/cli.js b/src/cli.js index 0edde9e..2041f11 100644 --- a/src/cli.js +++ b/src/cli.js @@ -22,7 +22,7 @@ import { collectDependencyFacts, listDependencies, detectManifest, manifestTypeF import { parseGateReport, validateFindings, verifyFinding, renderGateReport } from './gate.js'; import { makeReport, renderReport, safeTextLines, KIND } from './contract.js'; import { provenanceGraph } from './graph.js'; -import { repoToplevel, revParse, git } from './git.js'; +import { repoToplevel, revParse, mergeBase, git } from './git.js'; import { resolveLang, T, isLang, DEFAULT_LANG } from './lang.js'; export const EXIT = Object.freeze({ @@ -72,8 +72,8 @@ euthyna —— 给 AI 编码 agent 用的确定性事实产出器 它不是扫描器。它只回答两个模型算不准的问题,并把答案写成带证据的事实。 用法: - euthyna audit --base [--head ] [--repo ] [--pickaxe] [--no-origins] [--json] [--graph] - euthyna history --base [--head ] [--repo ] [--pickaxe] [--no-origins] [--json] [--graph] + euthyna audit [--base ] [--head ] [--repo ] [--pickaxe] [--no-origins] [--json] [--graph] + euthyna history [--base ] [--head ] [--repo ] [--pickaxe] [--no-origins] [--json] [--graph] euthyna coverage --coverage --symbol [--file ] [--source ] [--json] euthyna deps [--repo ] [--lockfile ] (--dep | --all) [--json] euthyna gate <报告文件> [--verify] [--cwd ] [--json] @@ -84,7 +84,10 @@ euthyna —— 给 AI 编码 agent 用的确定性事实产出器 audit 一次跑完变更面审计:history + deps(枚举 lockfile 全部依赖),合并报告 这是技能文档默认推荐入口;coverage 因需要覆盖率文件和符号名不在其中 history 本次变更删掉了哪些代码、它们分别由哪个提交引入、该提交是不是安全修复 - --base 必填,比较的基线版本(如 main、HEAD~5、某个 commit) + --base 可选:省略时按 merge-base(HEAD, origin/main) 推断分叉点。 + 推断会写在「范围」那一行,并在「未评估的判据」里加一条 range-inferred: + 推断出的范围只到分叉点,不是被审历史的全貌。两个引用都解析不出来时报错, + 不退回去猜 HEAD~1 --head 可选,默认 HEAD --pickaxe 额外检查「曾被移除又加回来」的新增行(有探针上限) --no-origins 关闭来源追溯;默认用 git log -S 把删除行归属到「最初引入」 @@ -128,8 +131,8 @@ It is not a scanner. It answers only the two questions a model cannot compute reliably, and writes the answers as facts with evidence. Usage: - euthyna audit --base [--head ] [--repo ] [--pickaxe] [--no-origins] [--json] [--graph] - euthyna history --base [--head ] [--repo ] [--pickaxe] [--no-origins] [--json] [--graph] + euthyna audit [--base ] [--head ] [--repo ] [--pickaxe] [--no-origins] [--json] [--graph] + euthyna history [--base ] [--head ] [--repo ] [--pickaxe] [--no-origins] [--json] [--graph] euthyna coverage --coverage --symbol [--file ] [--source ] [--json] euthyna deps [--repo ] [--lockfile ] (--dep | --all) [--json] euthyna gate [--verify] [--cwd ] [--json] @@ -144,7 +147,11 @@ Commands: and symbol names only the adjudicator knows) history which lines did this change delete, which commit introduced them, and was that commit a security fix? - --base required, the baseline revision to compare against + --base optional: omitted means the fork point is inferred as + merge-base(HEAD, origin/main). The inference is stated on the range + line and declared as range-inferred among the criteria not evaluated — + an inferred range reaches the fork point, not the whole history. When + neither ref resolves the run stops rather than guessing HEAD~1 --head optional, defaults to HEAD --pickaxe also check added lines that were once removed and now return (probe-capped) --no-origins disable origin tracing; by default git log -S attributes deleted @@ -263,17 +270,11 @@ async function runHistory(flags, lang) { }; } - if (!flags.base) { - return { exit: EXIT.USAGE, error: t('history 需要 --base ', 'history requires --base ') }; - } - - const base = await revParse(toplevel, String(flags.base)); - if (!base) { - return { - exit: EXIT.USAGE, - error: t(`无法解析 --base "${flags.base}" 为一个提交`, `cannot resolve --base "${flags.base}" to a commit`) - }; + const range = await resolveRangeBase({ flags, cwd: toplevel, t }); + if (range.error) { + return { exit: EXIT.USAGE, error: range.error }; } + const base = range.base; const head = await revParse(toplevel, String(flags.head ?? 'HEAD')); if (!head) { @@ -295,9 +296,13 @@ async function runHistory(flags, lang) { lang }); + if (range.ref) { + notEvaluated.push(rangeInferenceDeclaration({ ref: range.ref, head }, t)); + } + const report = makeReport({ producer: producerFor(HISTORY_PRODUCER, lang), - subject: { repo: toplevel, base, head }, + subject: { repo: toplevel, base, head, ...(range.ref ? { rangeSource: range.ref } : {}) }, facts, evaluated, notEvaluated @@ -421,6 +426,54 @@ async function runDeps(flags, lang) { * symbol names only the adjudicator knows, so there is nothing honest to * auto-measure there. */ +/** + * Declare the range the run inferred, in the shape every other declaration uses: + * a `notEvaluated` entry with a `code`, so the JSON channel carries it and a + * reader of the terminal output meets it in the caveat list. + * + * Pure, so the wording is testable in both languages without building a repository. + */ +export function rangeInferenceDeclaration({ ref, head }, t) { + const short = (sha) => (sha ?? '').slice(0, 10); + return { + kind: KIND.HISTORY, + code: 'range-inferred', + reason: t( + `未给 --base,比较范围按 merge-base(HEAD, ${ref}) 推断到 ${short(head)} 的分叉点——这不是被审历史的全貌,换范围请显式传 --base `, + `no --base given: the range was inferred as merge-base(HEAD, ${ref}) against ${short(head)}. That is not the whole history under review — pass --base to audit another range` + ) + }; +} + +/** + * Resolve `--base`, or infer the fork point when the caller gave none. + * + * The inference is where an argument-free run could quietly narrow what is + * audited — a smaller range deletes edges and reads as cleaner — so the rule used + * comes back with the value and the caller must declare it. There is no fallback + * to HEAD~1: with no remote-tracking branch to fork from, this says so and the run + * does not happen. + */ +export async function resolveRangeBase({ flags, cwd, t }) { + if (flags.base !== undefined) { + const base = await revParse(cwd, String(flags.base)); + if (!base) { + return { error: t(`无法解析 --base "${flags.base}" 为一个提交`, `cannot resolve --base "${flags.base}" to a commit`) }; + } + return { base }; + } + for (const ref of ['origin/main', 'origin/HEAD']) { + const base = await mergeBase(cwd, ref); + if (base) return { base, ref }; + } + return { + error: t( + '未给 --base,且 origin/main 与 origin/HEAD 都无法解析出分叉点——请显式传 --base ', + 'no --base given, and neither origin/main nor origin/HEAD resolves to a fork point — pass --base ' + ) + }; +} + /** * Decide whether the manifest `audit` measured is the manifest --head holds. * @@ -469,10 +522,6 @@ async function runAudit(flags, lang) { const t = T(lang); const cwd = path.resolve(flags.repo ? String(flags.repo) : process.cwd()); - if (!flags.base) { - return { exit: EXIT.USAGE, error: t('audit 需要 --base ', 'audit requires --base ') }; - } - // --- history half (mirrors runHistory's repo/base/head resolution) --- const toplevel = await repoToplevel(cwd); if (!toplevel) { @@ -495,13 +544,11 @@ async function runAudit(flags, lang) { }; } - const base = await revParse(toplevel, String(flags.base)); - if (!base) { - return { - exit: EXIT.USAGE, - error: t(`无法解析 --base "${flags.base}" 为一个提交`, `cannot resolve --base "${flags.base}" to a commit`) - }; + const range = await resolveRangeBase({ flags, cwd: toplevel, t }); + if (range.error) { + return { exit: EXIT.USAGE, error: range.error }; } + const base = range.base; const head = await revParse(toplevel, String(flags.head ?? 'HEAD')); if (!head) { @@ -567,10 +614,13 @@ async function runAudit(flags, lang) { const facts = [...history.facts, ...depFacts]; const evaluated = [...history.evaluated, ...depEvaluated]; const notEvaluated = [...history.notEvaluated, ...depNotEvaluated]; + if (range.ref) { + notEvaluated.push(rangeInferenceDeclaration({ ref: range.ref, head }, t)); + } const report = makeReport({ producer: producerFor(AUDIT_PRODUCER, lang), - subject: { repo: toplevel, base, head }, + subject: { repo: toplevel, base, head, ...(range.ref ? { rangeSource: range.ref } : {}) }, facts, evaluated, notEvaluated diff --git a/src/contract.js b/src/contract.js index 834075c..7f876dc 100644 --- a/src/contract.js +++ b/src/contract.js @@ -206,7 +206,12 @@ export function renderReport(report, { write: rawWrite = console.log, lang = DEF write(`euthyna ${producer.name} — ${producer.purpose || ''}`.trim()); write(`${t('目标: ', 'Target: ')}${subject.repo ?? subject.coverageFile ?? t('(未指定)', '(unspecified)')}`); if (subject.base || subject.head) { - write(`${t('范围: ', 'Range: ')}${subject.base ?? '?'}..${subject.head ?? 'HEAD'}`); + write( + `${t('范围: ', 'Range: ')}${subject.base ?? '?'}..${subject.head ?? 'HEAD'}` + + // An inferred range is a decision the tool made on the reader's behalf, so + // it is said on the line that states the range — not only in the caveat list. + (subject.rangeSource ? t(`(推断:与 ${subject.rangeSource} 的分叉点)`, ` (inferred: the fork point from ${subject.rangeSource})`) : '') + ); } write(''); diff --git a/src/git.js b/src/git.js index bf9835c..18a28e9 100644 --- a/src/git.js +++ b/src/git.js @@ -81,6 +81,15 @@ export async function revParse(cwd, rev) { return out.trim() || null; } +/** The fork point between HEAD and a revision, or null when git cannot name one. */ +export async function mergeBase(cwd, rev) { + const out = await git(['merge-base', 'HEAD', rev], { cwd, allowFailure: true }); + const sha = out.trim(); + // git prints the hash alone on success; anything else (an error text that got + // past allowFailure, an empty result) is not a fork point. + return /^[0-9a-f]{40}$/.test(sha) ? sha : null; +} + /** * One-line summaries for a set of commits, as a Map of hash -> {subject, author, date}. * Uses a single `git log` invocation so the cost does not scale per commit. diff --git a/test/cli.test.js b/test/cli.test.js index 5a3bd91..ac38109 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -14,7 +14,7 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { fileURLToPath } from 'node:url'; -import { parseArgs, EXIT } from '../src/cli.js'; +import { parseArgs, EXIT, rangeInferenceDeclaration } from '../src/cli.js'; import { makeRepo, commitFiles, git, npmLockfile, edgeTargets } from './helpers.js'; const execFileAsync = promisify(execFile); @@ -23,16 +23,23 @@ const execFileAsync = promisify(execFile); async function capture(argv) { const { main } = await import('../src/cli.js'); const chunks = []; + const errors = []; const original = process.stdout.write.bind(process.stdout); + const originalError = process.stderr.write.bind(process.stderr); process.stdout.write = (chunk) => { chunks.push(String(chunk)); return true; }; + process.stderr.write = (chunk) => { + errors.push(String(chunk)); + return true; + }; try { const code = await main(argv); - return { code, out: chunks.join('') }; + return { code, out: chunks.join(''), err: errors.join('') }; } finally { process.stdout.write = original; + process.stderr.write = originalError; } } @@ -107,9 +114,9 @@ describe('exit code contract', () => { assert.equal(code, EXIT.FLAGGED); }); - test('a missing --base is a usage error', async () => { + test('an unresolvable --base is a usage error', async () => { const { main } = await import('../src/cli.js'); - assert.equal(await main(['history', '--json']), EXIT.USAGE); + assert.equal(await main(['history', '--base', 'no-such-rev', '--json']), EXIT.USAGE); }); test('an unreadable coverage file exits 2, which must not read as clean', async () => { @@ -257,9 +264,9 @@ describe('audit command (history + deps in one run)', () => { assert.equal(code, EXIT.CLEAN); }); - test('a missing --base is a usage error', async () => { + test('an unresolvable --base is a usage error', async () => { const { main } = await import('../src/cli.js'); - assert.equal(await main(['audit', '--json']), EXIT.USAGE); + assert.equal(await main(['audit', '--base', 'no-such-rev', '--json']), EXIT.USAGE); }); }); @@ -495,6 +502,88 @@ describe('crash path (bin entry)', () => { }); }); +describe('--base inference', () => { + // Omitting --base is the whole point of the slash-command surface, and the risk + // it carries is that a guessed range silently narrows what is audited. So the + // range must be declared, and a range that cannot be inferred must fail loudly + // rather than fall back to HEAD~1. + async function repoWithOrigin() { + const origin = await makeRepo(); + await commitFiles(origin, 'feat: add the file', { 'src/a.js': 'export const a = 1;\n' }); + const dir = await mkdtemp(path.join(tmpdir(), 'euthyna-clone-')); + await execFileAsync('git', ['clone', '-q', origin, dir]); + await git(dir, ['config', 'user.email', 'test@example.com']); + await git(dir, ['config', 'user.name', 'Test Author']); + await git(dir, ['config', 'commit.gpgsign', 'false']); + const fork = (await git(dir, ['rev-parse', 'origin/main'])).trim(); + await commitFiles(dir, 'fix: reject expired tokens', { + 'src/a.js': 'export const a = 1;\n if (Date.now() > token.expiresAt) throw new Error("expired token here");\n' + }); + await commitFiles(dir, 'refactor: drop the check', { 'src/a.js': 'export const a = 1;\n' }); + return { dir, fork }; + } + + test('an omitted --base infers the fork point and says so', async () => { + const { dir, fork } = await repoWithOrigin(); + const explicit = await capture(['history', '--repo', dir, '--base', fork, '--json']); + const { code, out } = await capture(['history', '--repo', dir, '--json']); + const report = JSON.parse(out); + + // Asserted as equality with the explicit run, not as a guessed classification: + // whether this range flags anything is the producer's business, and the + // inference must not answer it differently. + assert.equal(code, explicit.code, 'inferring the range changes nothing but the declaration'); + assert.equal(report.subject.base, fork, 'the inferred base is the merge-base with origin/main'); + const declared = report.coverage.notEvaluated.filter((n) => n.code === 'range-inferred'); + assert.equal(declared.length, 1, `the inference is declared: ${JSON.stringify(report.coverage.notEvaluated)}`); + assert.match(declared[0].reason, /origin\/main/, 'and names the rule it used'); + }); + + test('the terminal render marks the inferred range', async () => { + const { dir } = await repoWithOrigin(); + const { out } = await capture(['history', '--repo', dir]); + + assert.match(out, /范围: .*(推断/, `the reader sees the inference without --json:\n${out.split('\n').slice(0, 5).join('\n')}`); + }); + + test('no origin/main is a usage error, never a guess', async () => { + const repo = await makeRepo(); + const fork = await commitFiles(repo, 'feat: add the file', { 'src/a.js': 'export const a = 1;\n' }); + await commitFiles(repo, 'refactor: change it', { 'src/a.js': 'export const a = 2;\n' }); + + const { code, out, err } = await capture(['history', '--repo', repo]); + + assert.equal(code, EXIT.USAGE, 'a range that cannot be established must not be invented'); + assert.match(err, /--base/, 'the error tells the caller what to pass'); + assert.match(err, /origin\/main/, 'and names the refs it looked for, so the failure is not a mystery'); + assert.doesNotMatch(out, /范围:/, 'and nothing is reported as measured'); + assert.notEqual(code, EXIT.CLEAN, `${fork.slice(0, 8)}: falling back to HEAD~1 would exit like a clean audit`); + }); + + test('audit infers and declares the same way', async () => { + // The audit command resolves the range in its own code path, so "shared helper" + // is a claim that needs its own case. + const { dir, fork } = await repoWithOrigin(); + const explicit = await capture(['audit', '--repo', dir, '--base', fork, '--json']); + const { code, out } = await capture(['audit', '--repo', dir, '--json']); + const report = JSON.parse(out); + + assert.equal(code, explicit.code, 'the merged run answers the same question either way'); + assert.equal(report.subject.base, fork, 'audit infers the same fork point'); + assert.match(out, /"code": "range-inferred"/, 'and declares it on the JSON channel'); + }); + + test('the declaration is a pure decision, in both languages', async () => { + const zh = rangeInferenceDeclaration({ ref: 'origin/main', head: 'a'.repeat(40) }, (zhText, enText) => zhText); + const en = rangeInferenceDeclaration({ ref: 'origin/main', head: 'a'.repeat(40) }, (zhText, enText) => enText); + + assert.equal(zh.code, 'range-inferred'); + assert.match(zh.reason, /origin\/main/); + assert.match(en.reason, /origin\/main/); + assert.notEqual(zh.reason, en.reason, 'the two languages are written, not copied'); + }); +}); + describe('--graph', () => { async function securityDeletionRepo() { const repo = await makeRepo(); From 8d02703338c56c4d7207cf7b81bc3f466de7982a Mon Sep 17 00:00:00 2001 From: modusensus Date: Tue, 20 Oct 2026 21:53:05 +0800 Subject: [PATCH 3/5] docs: README and the skill's required-argument line were left behind by two changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--graph` shipped in 0.9-era main with README never mentioning it, and this branch made `--base` omissible while both skill editions still said it was the only required argument. Nothing turned red for either: the pairing guards compare the two language halves of a file with each other, so a statement that is stale on both sides is consistent and passes. Adds the channel that was missing: every flag `euthyna help` advertises must be explained somewhere in README, CONTRIBUTING, docs/ or the skill references. Proved by mutation, twice over — renaming `--graph` out of every file at once turns it red with the flag named; the earlier version of the check passed while README alone was missing it, which is its actual limit: it catches a flag documented nowhere, not a prose surface that lags. The second class is a sweeping job, and this commit is the sweep it forced. --- .agents/skills/euthyna-en/SKILL.md | 3 +- .agents/skills/euthyna/SKILL.md | 2 +- README.md | 10 +++++ test/readme-flags.test.js | 69 ++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 test/readme-flags.test.js diff --git a/.agents/skills/euthyna-en/SKILL.md b/.agents/skills/euthyna-en/SKILL.md index 117bf89..4710acf 100644 --- a/.agents/skills/euthyna-en/SKILL.md +++ b/.agents/skills/euthyna-en/SKILL.md @@ -137,7 +137,8 @@ reliably — are shipped by this skill as a zero-dependency CLI: | `coverage` | Was this symbol **ever actually invoked** by a test run? | when the report claims "tested / not tested" (Stage B / C) | ```powershell -# the only required argument is --base (pre-change revision / PR fork point): +# --base may be omitted: then the fork point is inferred as merge-base(HEAD, origin/main) and +# declared as range-inferred in the report. When given, it is used exactly as given: node /bin/euthyna.js audit --base --repo # history / coverage run alone when needed: diff --git a/.agents/skills/euthyna/SKILL.md b/.agents/skills/euthyna/SKILL.md index 5eecd6e..1d4c539 100644 --- a/.agents/skills/euthyna/SKILL.md +++ b/.agents/skills/euthyna/SKILL.md @@ -123,7 +123,7 @@ license: Apache-2.0 | `coverage` | 某个符号在测试运行中**到底有没有被调用过**? | 报告里要说「测过 / 没测过」时(阶段 B / C) | ```powershell -# 唯一必需的参数是 --base(改前版本 / PR 分叉点): +# --base 可以省略:省略时按 merge-base(HEAD, origin/main) 推断,并在报告里声明为 range-inferred node /bin/euthyna.js audit --base <改前版本> --repo <被审计的仓库> # history / coverage 按需单独跑: diff --git a/README.md b/README.md index 92aa1c6..a2afae5 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,7 @@ replying in chat. ```sh npm install -g euthyna euthyna audit --repo --base main --head HEAD # history + deps in one run +euthyna audit --graph # the same attribution, drawn (see below) euthyna coverage --coverage coverage/coverage-final.json --symbol euthyna deps --repo --all # or --dep for a single dep euthyna gate --verify --cwd # mechanically check the six gates @@ -212,6 +213,15 @@ removed and are now being added back. Origin tracing is on by default (deleted l are attributed to the commit that first introduced their content); `--no-origins` falls back to blame's last-modifier attribution, which is faster. +`--graph` on `audit` or `history` draws what `history` already computed — the range, the commit +that last modified each deleted line, and the commit that first introduced its content — as +Mermaid source, renderable straight into a GitHub comment. It measures nothing new and never +colours a node by risk; a missing second edge is a neutral fact, not a clearance. `--base` may +be left out entirely: the fork point is then inferred as `merge-base(HEAD, origin/main)` and +said three times over — on the range line, among the criteria not evaluated (`range-inferred`), +and as a `code` on the `--json` channel — because an inferred range reaches the fork point only, +and a quietly smaller history is the one thing this output must not be able to imply. + ### Exit codes are part of the contract Surveying eight measurement plugins in this ecosystem found **none of them publishes a diff --git a/test/readme-flags.test.js b/test/readme-flags.test.js new file mode 100644 index 0000000..494309b --- /dev/null +++ b/test/readme-flags.test.js @@ -0,0 +1,69 @@ +/** + * Every flag the CLI advertises in its own help must be documented somewhere. + * + * This guard exists because two shipped changes — `--graph`, and making `--base` + * optional — left README and the skill's own "required argument" line behind, and + * nothing turned red. A claim with no channel is how a repository ends up + * publishing a statement that stopped being true. + */ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '..'); + +// Not a behaviour that needs prose: it is how you get the text that lists the +// behaviours. Every other flag changes what is measured, rendered or executed. +const UNDOCUMENTABLE = new Set(['--help']); + +async function helpFlags() { + const { main } = await import('../src/cli.js'); + const chunks = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = (chunk) => { + chunks.push(String(chunk)); + return true; + }; + try { + await main(['help']); + } finally { + process.stdout.write = original; + } + const help = chunks.join(''); + return new Set([...help.matchAll(/--([a-z][a-z-]*)/g)].map((m) => `--${m[1]}`)); +} + +async function documentedIn() { + const files = ['README.md', 'CONTRIBUTING.md']; + for (const name of await readdir(path.join(ROOT, 'docs'))) { + if (name.endsWith('.md')) files.push(path.join('docs', name)); + } + for (const edition of ['euthyna', 'euthyna-en']) { + const relDir = path.join('.agents', 'skills', edition, 'references'); + for (const name of await readdir(path.join(ROOT, relDir))) { + if (name.endsWith('.md')) files.push(path.join(relDir, name)); + } + } + const text = []; + for (const rel of files) { + text.push(await readFile(path.join(ROOT, rel), 'utf8')); + } + return text.join('\n'); +} + +describe('the CLI help has no undocumented flag', () => { + test('every advertised flag appears in the prose that is supposed to explain it', async () => { + const flags = await helpFlags(); + const documented = await documentedIn(); + + const missing = [...flags].filter((flag) => !UNDOCUMENTABLE.has(flag) && !documented.includes(flag)); + assert.deepEqual( + missing, + [], + `${missing.join(', ')} is advertised by \`euthyna help\` and explained nowhere in README, CONTRIBUTING, docs/ or the skill references` + ); + assert.ok(flags.size >= 12, `the help should list the whole surface; found ${flags.size} flags`); + }); +}); From f4443b812ee623ce56c1252b3ecd7781be45927c Mon Sep 17 00:00:00 2001 From: modusensus Date: Wed, 21 Oct 2026 00:27:52 +0800 Subject: [PATCH 4/5] fix(hosts): let a mode word actually take over, and drop the placeholder from the pr row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review was right on both counts. The clause I added said "map the mode, then continue" while the following steps still told every request to run `euthyna audit --base …` and write a report — so `/euthyna help` in Cursor would have run an audit, and `repo` / `claim` / `gate` were annotated rather than honoured. Modes now override all the later steps: run the mapped command and close out, `help` prints the table and runs nothing, and the general flow is what happens when no mode matches. The `pr` row also kept `--base <分叉点>` from before this branch made the flag optional, which left a placeholder an agent cannot execute in the one row that the inference exists for. It maps to `euthyna audit` now, with the omission stated as the normal path; `graph` says the same. --- .agents/skills/euthyna-en/SKILL.md | 4 ++-- .agents/skills/euthyna/SKILL.md | 4 ++-- .claude/commands/euthyna.md | 2 +- .cursor/commands/euthyna.md | 2 +- .github/prompts/euthyna.prompt.md | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.agents/skills/euthyna-en/SKILL.md b/.agents/skills/euthyna-en/SKILL.md index 4710acf..90b850e 100644 --- a/.agents/skills/euthyna-en/SKILL.md +++ b/.agents/skills/euthyna-en/SKILL.md @@ -111,8 +111,8 @@ own copy. | You type | Which branch | First command | |---|---|---| -| `/euthyna pr` | Stage B, the change surface | `euthyna audit --base ` | -| `/euthyna graph` | Stage B, the provenance view | `euthyna audit --graph` | +| `/euthyna pr` | Stage B, the change surface | `euthyna audit` (omit `--base` and the fork point is inferred and declared) | +| `/euthyna graph` | Stage B, the provenance view | `euthyna audit --graph` (`--base` may be omitted here too) | | `/euthyna repo` | Stage A, the dependency surface | `euthyna deps --all` | | `/euthyna claim ` | Stage C, the claim surface | `euthyna coverage --symbol ` | | `/euthyna gate ` | Validating a written report | `euthyna gate ` | diff --git a/.agents/skills/euthyna/SKILL.md b/.agents/skills/euthyna/SKILL.md index 1d4c539..af2f962 100644 --- a/.agents/skills/euthyna/SKILL.md +++ b/.agents/skills/euthyna/SKILL.md @@ -99,8 +99,8 @@ license: Apache-2.0 | 你打 | 走哪条支路 | 第一条命令 | |---|---|---| -| `/euthyna pr` | 阶段 B 变更面 | `euthyna audit --base <分叉点>` | -| `/euthyna graph` | 阶段 B 的来源谱系视图 | `euthyna audit --graph` | +| `/euthyna pr` | 阶段 B 变更面 | `euthyna audit`(省略 `--base` 即按分叉点推断并声明) | +| `/euthyna graph` | 阶段 B 的来源谱系视图 | `euthyna audit --graph`(同样可省略 `--base`) | | `/euthyna repo` | 阶段 A 依赖面 | `euthyna deps --all` | | `/euthyna claim <结论>` | 阶段 C 结论面 | `euthyna coverage --symbol <名>` | | `/euthyna gate <报告>` | 校验一份已写的报告 | `euthyna gate <报告>` | diff --git a/.claude/commands/euthyna.md b/.claude/commands/euthyna.md index adf8753..b12f02f 100644 --- a/.claude/commands/euthyna.md +++ b/.claude/commands/euthyna.md @@ -11,7 +11,7 @@ allowed-tools: Read, Grep, Glob, Bash, Task 按 euthyna 技能执行一次完整审计: 1. **先加载纪律**:找到 euthyna 技能目录(`~/.claude/skills/euthyna/`、项目 `.claude/skills/euthyna/` 或 `.agents/skills/euthyna/`),读 `SKILL.md`,需要时再读 `references/`。找不到就明说「技能缺失」,不要凭印象执行。 -2. **按入口决策树走**:单条漏洞结论 → 阶段 C;一次变更 → 阶段 B;依赖面 → 阶段 A;整体审计 → 阶段 B 为主。用户以模式词进来时(参数命中技能的模式词表),先按 `SKILL.md` 的「模式词(斜杠命令)」一节映射出阶段与第一条命令,再往下走;其余文字按审计目标处理。 +2. **按入口决策树走**:单条漏洞结论 → 阶段 C;一次变更 → 阶段 B;依赖面 → 阶段 A;整体审计 → 阶段 B 为主。**模式词优先于下面全部步骤**:参数命中 `SKILL.md`「模式词(斜杠命令)」表里的模式时,只执行该模式映射的那一条命令并按其结果收口——`help` 只打印那张表,不跑任何命令、不做审计、不写报告;未命中模式词时,才走下面的通用审计步骤,其余文字按审计目标处理。 3. **先测量再读码**:diff 有删除行先跑 `history`;谈测试覆盖先跑 `coverage`;依赖声明先跑 `deps`(操作手册在 `references/fact-producers.md`)。 4. **每条结论过六道门禁**:证据到 `文件:行号`、可复现命令、影响说明;拿不出证据的降级为「观察」。 5. **三态裁定**:`BUG #N TRUE POSITIVE / FALSE POSITIVE / INCONCLUSIVE`。 diff --git a/.cursor/commands/euthyna.md b/.cursor/commands/euthyna.md index ebc81c6..713e3d9 100644 --- a/.cursor/commands/euthyna.md +++ b/.cursor/commands/euthyna.md @@ -5,7 +5,7 @@ 按 euthyna 技能执行一次完整审计: 1. **先加载纪律**:找到 euthyna 技能目录(`~/.cursor/skills/euthyna/`、项目 `.cursor/skills/euthyna/`,或 npm 全局包内的 `.agents/skills/euthyna/`),读 `SKILL.md`,需要时再读 `references/`。找不到就明说「技能缺失」,不要凭印象执行。 -2. **按入口决策树走**:单条漏洞结论 → 阶段 C;一次变更 → 阶段 B;依赖面 → 阶段 A;整体审计 → 阶段 B 为主。用户以模式词进来时(参数命中技能的模式词表),先按 `SKILL.md` 的「模式词(斜杠命令)」一节映射出阶段与第一条命令,再往下走;其余文字按审计目标处理。 +2. **按入口决策树走**:单条漏洞结论 → 阶段 C;一次变更 → 阶段 B;依赖面 → 阶段 A;整体审计 → 阶段 B 为主。**模式词优先于下面全部步骤**:参数命中 `SKILL.md`「模式词(斜杠命令)」表里的模式时,只执行该模式映射的那一条命令并按其结果收口——`help` 只打印那张表,不跑任何命令、不做审计、不写报告;未命中模式词时,才走下面的通用审计步骤,其余文字按审计目标处理。 3. **先测量再读码**:第一条命令跑 `euthyna audit --base <分叉点> --repo <被审计仓库>`(history + deps 一次跑完);谈测试覆盖再跑 `coverage`(操作手册在 `references/fact-producers.md`)。 4. **每条结论过六道门禁**:证据到 `文件:行号`、可复现命令、影响说明;拿不出证据的降级为「观察」。 5. **三态裁定**:`BUG #N TRUE POSITIVE / FALSE POSITIVE / INCONCLUSIVE`。 diff --git a/.github/prompts/euthyna.prompt.md b/.github/prompts/euthyna.prompt.md index 7d09dcb..8cb34c9 100644 --- a/.github/prompts/euthyna.prompt.md +++ b/.github/prompts/euthyna.prompt.md @@ -7,7 +7,7 @@ description: 代码安全审计:按 euthyna 纪律审一段代码、一次变 按 euthyna 技能执行一次完整审计: 1. **先加载纪律**:找到 euthyna 技能目录(项目 `.github/skills/euthyna/` 或 `.agents/skills/euthyna/`,个人 `~/.copilot/skills/euthyna/`),读 `SKILL.md`,需要时再读 `references/`。找不到就明说「技能缺失」,不要凭印象执行。 -2. **按入口决策树走**:单条漏洞结论 → 阶段 C;一次变更 → 阶段 B;依赖面 → 阶段 A;整体审计 → 阶段 B 为主。用户以模式词进来时(参数命中技能的模式词表),先按 `SKILL.md` 的「模式词(斜杠命令)」一节映射出阶段与第一条命令,再往下走;其余文字按审计目标处理。 +2. **按入口决策树走**:单条漏洞结论 → 阶段 C;一次变更 → 阶段 B;依赖面 → 阶段 A;整体审计 → 阶段 B 为主。**模式词优先于下面全部步骤**:参数命中 `SKILL.md`「模式词(斜杠命令)」表里的模式时,只执行该模式映射的那一条命令并按其结果收口——`help` 只打印那张表,不跑任何命令、不做审计、不写报告;未命中模式词时,才走下面的通用审计步骤,其余文字按审计目标处理。 3. **先测量再读码**:第一条命令跑 `euthyna audit --base <分叉点> --repo <被审计仓库>`(history + deps 一次跑完);谈测试覆盖再跑 `coverage`(操作手册在 `references/fact-producers.md`)。 4. **每条结论过六道门禁**:证据到 `文件:行号`、可复现命令、影响说明;拿不出证据的降级为「观察」。复现命令执行前须向用户报备(见技能「执行纪律」节)。 5. **三态裁定**:`BUG #N TRUE POSITIVE / FALSE POSITIVE / INCONCLUSIVE`。 From b1854801198586ec6d962216989154affa8ccdec Mon Sep 17 00:00:00 2001 From: modusensus Date: Wed, 21 Oct 2026 00:43:19 +0800 Subject: [PATCH 5/5] fix(cli): anchor the inference on the head that was asked for, and accept sha256 ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both findings reproduced before being fixed. `merge-base` ran against the checked-out HEAD, so `--head v1.2.3` with no `--base` measured a range whose start was chosen for a different commit — it can pull in unrelated deletions or drop the ones under review. `--head` is now resolved first and passed into the inference. The ordering moved one existing test: an empty repository now stops at the unresolvable `--head` before the hostile `--base` is ever read, so that case gives the repository a commit; what it pins is the stderr boundary, not which flag fails first. The 40-hex guard on the merge-base result rejected a sha256 repository's real fork point, which surfaced as "neither origin/main nor origin/HEAD resolves" — a fail-closed message that was simply wrong. Measured on a repository created with `git init --object-format=sha256`: the assertion failed with a 64-character id before the fix and passes after. Also from the review: OpenCode's `npx` fallback re-ran the raw arguments, so a mode word reached the CLI as `euthyna repo`; and the CI note called `github.base_ref` the authoritative fork point when it names the pull request's target branch, whose tip need not be where the branch diverged. Both corrected, the second in each language. --- .../euthyna-en/references/fact-producers.md | 2 +- .../euthyna/references/fact-producers.md | 2 +- .opencode/commands/euthyna.md | 2 +- AGENTS.md | 4 +- README.md | 2 +- docs/ci-integration-zh.md | 6 ++- docs/ci-integration.md | 10 +++-- src/cli.js | 40 ++++++++++--------- src/git.js | 17 +++++--- test/cli.test.js | 32 +++++++++++++++ test/git.test.js | 31 +++++++++++++- 11 files changed, 111 insertions(+), 37 deletions(-) diff --git a/.agents/skills/euthyna-en/references/fact-producers.md b/.agents/skills/euthyna-en/references/fact-producers.md index 976d60f..a08c487 100644 --- a/.agents/skills/euthyna-en/references/fact-producers.md +++ b/.agents/skills/euthyna-en/references/fact-producers.md @@ -72,7 +72,7 @@ node /bin/euthyna.js history --base --no-ori ``` `--base` is the **pre-change revision** (the PR's fork point); `--head` defaults to `HEAD`. -Omitting `--base` infers it as `merge-base(HEAD, origin/main)`, marks the range line as inferred +Omitting `--base` infers it as `merge-base(, origin/main)`, marks the range line as inferred and adds a `range-inferred` entry to the criteria not evaluated. An inferred range reaches **the fork point only** — nothing earlier; when neither ref resolves the run stops with a usage error instead of guessing `HEAD~1`. This is what lets a slash command arrive with no arguments, so diff --git a/.agents/skills/euthyna/references/fact-producers.md b/.agents/skills/euthyna/references/fact-producers.md index 0d94efb..e976024 100644 --- a/.agents/skills/euthyna/references/fact-producers.md +++ b/.agents/skills/euthyna/references/fact-producers.md @@ -72,7 +72,7 @@ node /bin/euthyna.js history --base <改前版本> --no-origins ``` `--base` 是**改前版本**(PR 的分叉点),`--head` 默认 `HEAD`。 -省略 `--base` 时按 `merge-base(HEAD, origin/main)` 推断,并在「范围」那一行标出「推断」、在 +省略 `--base` 时按 `merge-base(<被审的 head>, origin/main)` 推断,并在「范围」那一行标出「推断」、在 「未评估的判据」里加一条 `range-inferred`。推断出的范围**只到分叉点**,看不到更早的历史; 两个引用都解析不出来时报用法错误,不猜 `HEAD~1`。斜杠命令免参数进来靠的就是这条,所以 **看到 `range-inferred` 第一步是确认分叉点对不对**,而不是接受它。 diff --git a/.opencode/commands/euthyna.md b/.opencode/commands/euthyna.md index e984531..fe46971 100644 --- a/.opencode/commands/euthyna.md +++ b/.opencode/commands/euthyna.md @@ -7,4 +7,4 @@ skill, not here. Otherwise run the CLI with the arguments as given: euthyna $ARGUMENTS -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. +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 ` 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. diff --git a/AGENTS.md b/AGENTS.md index 08cdedb..438e349 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -687,8 +687,8 @@ is faithful — that is a reading job, and a mechanical stand-in would only manu | Path | What it is | |---|---| -| `bin/` + `src/` | **Fact producers** (zero-dependency Node CLI). `src/contract.js` is the contract in code; `src/facts/history.js`, `src/facts/coverage.js` and `src/facts/deps.js` are the three measurements (`history` has `--origins` to chase the first introducer and classifies by message + deleted-line + diff, **on by default since `euthyna audit`**; `coverage.js` reads **five** formats — c8/V8 JSON, classic istanbul JSON (jest's default provider, nyc: the same `fnMap`/`f` shape, so the symbol locator shares the c8 code path), coverage.py JSON (format 3), Go's text `-coverprofile` **and lcov tracefiles** (the Go profile needs `--source `: the profile carries no function names, so the producer parses top-level `func` ranges from the gofmt'd source; a file absent from the profile is notEvaluated, never "never executed"; the lcov path needs no `--source` because `FN:` carries the names — they are mangled, and the producer matches a `module::function` query against the `` tail segments of Rust v0 mangling (what `-C instrument-coverage` implies), since no demangler exists on this machine — each segment is anchored at a boundary and never inside a longer `` pair, so a query for `get` does not answer for `cfg_3get`; see `docs/fact-contract.md` §6.2 for the measured record, including that inlining was measured **not** to flatten a called function to 0); `deps.js` reads package-lock.json / Cargo.lock / go.mod, **located from the repository root only** — a monorepo sub-package lock is measured only by `deps --lockfile `, and `audit` does not notice its absence, which `docs/fact-contract.md` §6.3 records with the numbers from the probe). `audit` compares the manifest it measured with the one at `--head` and records `manifest-not-head` when the content differs or the file is absent there. `history` and `audit` also accept **no `--base` at all**: the range is then inferred as `merge-base(HEAD, origin/main)` and declared three times over — `(推断…)` on the range line, a `range-inferred` entry in `notEvaluated`, and its `code` on the JSON channel — because an inferred range reaches the fork point only and would otherwise read as a smaller, cleaner history; when neither `origin/main` nor `origin/HEAD` resolves the run stops with a usage error rather than guessing `HEAD~1`. `euthyna audit` (0.4.0, `src/cli.js`) runs history + the full dependency surface in one command — the single entry point the CI recipe and the skill both describe. `src/gate.js` is the six-gate report validator behind `euthyna gate <报告> [--verify] [--allow-exec]` — it parses the skill's 裁定格式, downgrades findings that lack evidence/reproduce or contradict their verdict, and executes nothing but `git` unless the caller consents to interpreters. `src/graph.js` is the Mermaid view behind `--graph` on `euthyna history` and `euthyna audit`: it draws the attribution `history` already emits (the range → the commit that last modified each deleted line → the commit that first introduced its content, plus the `notEvaluated` surface as dotted edges), adds no measurement, and carries states only — colouring a node by risk would make §3.3 of the contract false | -| `test/` | 321 tests via `node --test` (measured 2026-10-20: +25 over the 0.8.0 count — `test/graph.test.js` is 15, `--graph` adds 5 to `test/cli.test.js` including one that drives a real three-commit repository and one that runs `audit --graph` over a merged report, `--base` inference adds 5 more including a real clone with an `origin`), no third-party framework. The pass/skip split is keyed on **tar flavour, not the OS**: `test/bench.test.js` skips `every exploit expectation holds` unless `tar --version` reports bsdtar. Measured on `cdece0f`: ubuntu 245 pass + 1 skip (its log line: *the exploit ground truth is defined against bsdtar, not "tar (GNU tar) 1.35"*), windows 246 pass + 0 skip — and this machine's Git Bash PATH puts `D:\Git\usr\bin\tar.exe` (GNU tar 1.35) ahead of System32's `tar.exe`, so a local run **on `cdece0f`** reported 245 + 1 even though it is Windows (measured again at `4d620cd` for 0.8.0: pwsh 296 pass / 0 skip, Git Bash 295 pass / 1 skip, the difference being that one case — the 245/246 pair above is the `cdece0f` record and 296/295 was the count at `4d620cd`, so read that scope with the number). **The history tests build real git repositories rather than mocking**; the coverage tests carry fixtures shaped like c8, coverage.py, Go profile and lcov tracefile output (`coverage.test.js` + `coverage-go.test.js` + `coverage-lcov.test.js`, the lcov fixtures using mangled names copied from a real `llvm-cov export --format=lcov` run); the deps tests carry fixture lockfiles (npm v1/v2/v3, Cargo.lock, go.mod); the contract tests carry the shell-quoting and render-boundary regressions from PR #1; `git.test.js` carries the error-path quoting and stderr-sanitization regressions from issue #2; `gate.test.js` pins the six-gate validator (including that `--verify` does not run an interpreter command without `--allow-exec`); `cli.test.js` pins the `audit` command, its exit codes, and the manifest-version declaration — content against `--head`, so a PR merge commit whose lockfile is byte-identical to `--head`'s declares nothing while a gitignored lockfile declares itself; the decision itself is an exported pure function so the fail-closed branch (`manifest-unverified`) is reachable from a test; `skill.test.js` pins the written discipline (the six gates, the three verdicts, the `euthyna gate` reference) so weakening the skill turns CI red; `plugin.test.js`, `claude-plugin.test.js` and `hermes-plugin.test.js` pin the three manifest surfaces; `manifest-version.test.js` pins `package.json` against the three plugin manifests (0.5.0 shipped them a release behind and nothing compared them); `i18n.test.js` pins language rendering and report parsing (zh/en); `skill-mirror.test.js` compares the two skill editions against each other — file set, heading sequence (ATX and Setext), fenced blocks, table shapes, and the `euthyna ` / `--flag` identifier sets — so an unmirrored discipline change goes red instead of relying on memory, `docs-pair.test.js` holds `docs/` to the same rule (every document paired, and each pair equal on structure and identifiers — not on prose fidelity), and `markdown-structure.test.js` pins the shared scanner itself with seven crafted-document cases, because a legal Markdown form it cannot read makes any comparison pass on that form (neither guard compares word counts, which a translation legitimately needs room for). **The suite count is written down once, in this row**: README's quickstart carried its own number (207) and was 39 tests stale, so it now says only what `npm test` does, and `package.json` defines that as `node --test` | +| `bin/` + `src/` | **Fact producers** (zero-dependency Node CLI). `src/contract.js` is the contract in code; `src/facts/history.js`, `src/facts/coverage.js` and `src/facts/deps.js` are the three measurements (`history` has `--origins` to chase the first introducer and classifies by message + deleted-line + diff, **on by default since `euthyna audit`**; `coverage.js` reads **five** formats — c8/V8 JSON, classic istanbul JSON (jest's default provider, nyc: the same `fnMap`/`f` shape, so the symbol locator shares the c8 code path), coverage.py JSON (format 3), Go's text `-coverprofile` **and lcov tracefiles** (the Go profile needs `--source `: the profile carries no function names, so the producer parses top-level `func` ranges from the gofmt'd source; a file absent from the profile is notEvaluated, never "never executed"; the lcov path needs no `--source` because `FN:` carries the names — they are mangled, and the producer matches a `module::function` query against the `` tail segments of Rust v0 mangling (what `-C instrument-coverage` implies), since no demangler exists on this machine — each segment is anchored at a boundary and never inside a longer `` pair, so a query for `get` does not answer for `cfg_3get`; see `docs/fact-contract.md` §6.2 for the measured record, including that inlining was measured **not** to flatten a called function to 0); `deps.js` reads package-lock.json / Cargo.lock / go.mod, **located from the repository root only** — a monorepo sub-package lock is measured only by `deps --lockfile `, and `audit` does not notice its absence, which `docs/fact-contract.md` §6.3 records with the numbers from the probe). `audit` compares the manifest it measured with the one at `--head` and records `manifest-not-head` when the content differs or the file is absent there. `history` and `audit` also accept **no `--base` at all**: the range is then inferred as `merge-base(, origin/main)` — against the head that was asked for, not the checked-out commit — and declared three times over — `(推断…)` on the range line, a `range-inferred` entry in `notEvaluated`, and its `code` on the JSON channel — because an inferred range reaches the fork point only and would otherwise read as a smaller, cleaner history; when neither `origin/main` nor `origin/HEAD` resolves the run stops with a usage error rather than guessing `HEAD~1`. `euthyna audit` (0.4.0, `src/cli.js`) runs history + the full dependency surface in one command — the single entry point the CI recipe and the skill both describe. `src/gate.js` is the six-gate report validator behind `euthyna gate <报告> [--verify] [--allow-exec]` — it parses the skill's 裁定格式, downgrades findings that lack evidence/reproduce or contradict their verdict, and executes nothing but `git` unless the caller consents to interpreters. `src/graph.js` is the Mermaid view behind `--graph` on `euthyna history` and `euthyna audit`: it draws the attribution `history` already emits (the range → the commit that last modified each deleted line → the commit that first introduced its content, plus the `notEvaluated` surface as dotted edges), adds no measurement, and carries states only — colouring a node by risk would make §3.3 of the contract false | +| `test/` | 324 tests via `node --test` (measured 2026-10-20: +28 over the 0.8.0 count — `test/graph.test.js` is 15, `--graph` adds 5 to `test/cli.test.js` including one that drives a real three-commit repository and one that runs `audit --graph` over a merged report, `--base` inference adds 6 more including a real clone with an `origin` and one that anchors the inference on `--head`, and `git.test.js` gained a `merge-base` case run against a **sha256** repository), no third-party framework. The pass/skip split is keyed on **tar flavour, not the OS**: `test/bench.test.js` skips `every exploit expectation holds` unless `tar --version` reports bsdtar. Measured on `cdece0f`: ubuntu 245 pass + 1 skip (its log line: *the exploit ground truth is defined against bsdtar, not "tar (GNU tar) 1.35"*), windows 246 pass + 0 skip — and this machine's Git Bash PATH puts `D:\Git\usr\bin\tar.exe` (GNU tar 1.35) ahead of System32's `tar.exe`, so a local run **on `cdece0f`** reported 245 + 1 even though it is Windows (measured again at `4d620cd` for 0.8.0: pwsh 296 pass / 0 skip, Git Bash 295 pass / 1 skip, the difference being that one case — the 245/246 pair above is the `cdece0f` record and 296/295 was the count at `4d620cd`, so read that scope with the number). **The history tests build real git repositories rather than mocking**; the coverage tests carry fixtures shaped like c8, coverage.py, Go profile and lcov tracefile output (`coverage.test.js` + `coverage-go.test.js` + `coverage-lcov.test.js`, the lcov fixtures using mangled names copied from a real `llvm-cov export --format=lcov` run); the deps tests carry fixture lockfiles (npm v1/v2/v3, Cargo.lock, go.mod); the contract tests carry the shell-quoting and render-boundary regressions from PR #1; `git.test.js` carries the error-path quoting and stderr-sanitization regressions from issue #2; `gate.test.js` pins the six-gate validator (including that `--verify` does not run an interpreter command without `--allow-exec`); `cli.test.js` pins the `audit` command, its exit codes, and the manifest-version declaration — content against `--head`, so a PR merge commit whose lockfile is byte-identical to `--head`'s declares nothing while a gitignored lockfile declares itself; the decision itself is an exported pure function so the fail-closed branch (`manifest-unverified`) is reachable from a test; `skill.test.js` pins the written discipline (the six gates, the three verdicts, the `euthyna gate` reference) so weakening the skill turns CI red; `plugin.test.js`, `claude-plugin.test.js` and `hermes-plugin.test.js` pin the three manifest surfaces; `manifest-version.test.js` pins `package.json` against the three plugin manifests (0.5.0 shipped them a release behind and nothing compared them); `i18n.test.js` pins language rendering and report parsing (zh/en); `skill-mirror.test.js` compares the two skill editions against each other — file set, heading sequence (ATX and Setext), fenced blocks, table shapes, and the `euthyna ` / `--flag` identifier sets — so an unmirrored discipline change goes red instead of relying on memory, `docs-pair.test.js` holds `docs/` to the same rule (every document paired, and each pair equal on structure and identifiers — not on prose fidelity), and `markdown-structure.test.js` pins the shared scanner itself with seven crafted-document cases, because a legal Markdown form it cannot read makes any comparison pass on that form (neither guard compares word counts, which a translation legitimately needs room for). **The suite count is written down once, in this row**: README's quickstart carried its own number (207) and was 39 tests stale, so it now says only what `npm test` does, and `package.json` defines that as `node --test` | | `.agents/skills/euthyna/` | **The skill.** Doubles as source and as a project skill root (rank 200), so it is live in this workspace without a restart | | `bench/` | **The recall benchmark.** `exploits.js` establishes ground truth by execution (18 cases, 8 near-neighbour pairs); `prepare-blind.js` produces answer-free copies with per-round shuffled ids; `adjudicate.js` closes the loop end to end (blind tree → one adjudicator process per case → `collect-verdicts.js` machine validation → `score.js`), with a deterministic `golden` mode (ground truth written into reports — plumbing self-check only, never a real round) for CI; `collect-verdicts.js` machine-validates reports and extracts verdicts without the orchestrator reading report bodies; `score.js` computes the confusion matrix and the pair view; `perf.js` is the `history` scaling baseline. The verified-platform matrix (bsdtar vs GNU tar, p6 skip) lives in `bench/README.md`. Results: `bench/RESULTS.md` (round 1), `bench/RESULTS-round2.md` (round 2: three independent runs per case, zero flips), `bench/RESULTS-round3.md` (round 3: 18 cases, 54 adjudications, two fixture defects caught by adjudicators), `bench/RESULTS-round4.md` (round 4: 54/54, p6b v3 confirmed by first blind adjudication, cross-model run executed — zero flips across two model families); protocols in `bench/README.md`, design pre-registrations in `bench/DESIGN-round3.md` and `bench/DESIGN-round4.md` | | `docs/positioning.md` + `-zh` | Competitive analysis across the DSH catalog, including three claims that were tested and refuted | diff --git a/README.md b/README.md index a2afae5..57f027c 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,7 @@ falls back to blame's last-modifier attribution, which is faster. that last modified each deleted line, and the commit that first introduced its content — as Mermaid source, renderable straight into a GitHub comment. It measures nothing new and never colours a node by risk; a missing second edge is a neutral fact, not a clearance. `--base` may -be left out entirely: the fork point is then inferred as `merge-base(HEAD, origin/main)` and +be left out entirely: the fork point is then inferred as `merge-base(, origin/main)` and said three times over — on the range line, among the criteria not evaluated (`range-inferred`), and as a `code` on the `--json` channel — because an inferred range reaches the fork point only, and a quietly smaller history is the one thing this output must not be able to imply. diff --git a/docs/ci-integration-zh.md b/docs/ci-integration-zh.md index fbf5c6f..dd6210c 100644 --- a/docs/ci-integration-zh.md +++ b/docs/ci-integration-zh.md @@ -10,9 +10,11 @@ > dependency 生产者读 lockfile;它回答的是 manifest 锁定了什么,而不是被锁版本有没有 > CVE——那个裁定按设计留给 agent 层(见 [`fact-contract-zh.md`](fact-contract-zh.md) §6.3)。 > -> **`--base` 现在可以省略**——CLI 会按 `merge-base(HEAD, origin/main)` 推断,并把这件事声明成 +> **`--base` 现在可以省略**——CLI 会按 `merge-base(, origin/main)` 推断,并把这件事声明成 > `range-inferred`。本配方仍然显式传 `--base`:在 CI 里 `github.base_ref` 就是权威分叉点, -> 推断是给不带参数就进来的人用的,不是给工作流用的。 +> 推断是给不带参数就进来的人用的,不是给工作流用的:`github.base_ref` 给的是 PR 的**目标分支**, +> 那个分支的最新提交未必就是分叉点——下面这份配方比的就是目标分支的最新提交,这是有意的。要真正的 +> 共同祖先就用 `git merge-base`。 ## 门禁,一步版 diff --git a/docs/ci-integration.md b/docs/ci-integration.md index c7532fb..cf3674e 100644 --- a/docs/ci-integration.md +++ b/docs/ci-integration.md @@ -11,10 +11,12 @@ English · [中文](ci-integration-zh.md) > pins, not whether a pinned version has a CVE — that adjudication is left to the agent layer, > by design (see [`fact-contract.md`](fact-contract.md) §6.3). > -> **`--base` may now be omitted** — the CLI infers it as `merge-base(HEAD, origin/main)` and -> declares the inference as `range-inferred`. This recipe still passes `--base` explicitly: in CI -> `github.base_ref` is the authoritative fork point, and the inference exists for a person -> arriving with no arguments, not for a workflow. +> **`--base` may now be omitted** — the CLI infers it as `merge-base(, origin/main)` and +> declares the inference as `range-inferred`. This recipe still passes `--base` explicitly, because a workflow should say what +> it measured: `github.base_ref` names the pull request’s **target branch**, and the tip of +> that branch is not necessarily where the branch diverged — the comparison below is +> against the target branch tip on purpose. Use `git merge-base` when the actual common +> ancestor is what you want. The inference exists for a person arriving with no arguments. ## The gate, one step diff --git a/src/cli.js b/src/cli.js index 2041f11..e3ca033 100644 --- a/src/cli.js +++ b/src/cli.js @@ -270,12 +270,6 @@ async function runHistory(flags, lang) { }; } - const range = await resolveRangeBase({ flags, cwd: toplevel, t }); - if (range.error) { - return { exit: EXIT.USAGE, error: range.error }; - } - const base = range.base; - const head = await revParse(toplevel, String(flags.head ?? 'HEAD')); if (!head) { return { @@ -284,6 +278,12 @@ async function runHistory(flags, lang) { }; } + const range = await resolveRangeBase({ flags, cwd: toplevel, head, t }); + if (range.error) { + return { exit: EXIT.USAGE, error: range.error }; + } + const base = range.base; + // Origin tracing is the default (see runAudit); --no-origins opts out. const origins = flags['no-origins'] !== true; @@ -439,8 +439,8 @@ export function rangeInferenceDeclaration({ ref, head }, t) { kind: KIND.HISTORY, code: 'range-inferred', reason: t( - `未给 --base,比较范围按 merge-base(HEAD, ${ref}) 推断到 ${short(head)} 的分叉点——这不是被审历史的全貌,换范围请显式传 --base `, - `no --base given: the range was inferred as merge-base(HEAD, ${ref}) against ${short(head)}. That is not the whole history under review — pass --base to audit another range` + `未给 --base,比较范围按 merge-base(${short(head)}, ${ref}) 推断——这不是被审历史的全貌,换范围请显式传 --base `, + `no --base given: the range was inferred as merge-base(${short(head)}, ${ref}). That is not the whole history under review — pass --base to audit another range` ) }; } @@ -450,11 +450,13 @@ export function rangeInferenceDeclaration({ ref, head }, t) { * * The inference is where an argument-free run could quietly narrow what is * audited — a smaller range deletes edges and reads as cleaner — so the rule used - * comes back with the value and the caller must declare it. There is no fallback - * to HEAD~1: with no remote-tracking branch to fork from, this says so and the run - * does not happen. + * comes back with the value and the caller must declare it. It forks from the + * resolved `--head`, not from whatever happens to be checked out: asking about + * another commit and getting this commit's ancestor would report a range nobody + * requested. There is no fallback to HEAD~1: with no remote-tracking branch to + * fork from, this says so and the run does not happen. */ -export async function resolveRangeBase({ flags, cwd, t }) { +export async function resolveRangeBase({ flags, cwd, head, t }) { if (flags.base !== undefined) { const base = await revParse(cwd, String(flags.base)); if (!base) { @@ -463,7 +465,7 @@ export async function resolveRangeBase({ flags, cwd, t }) { return { base }; } for (const ref of ['origin/main', 'origin/HEAD']) { - const base = await mergeBase(cwd, ref); + const base = await mergeBase(cwd, head, ref); if (base) return { base, ref }; } return { @@ -544,12 +546,6 @@ async function runAudit(flags, lang) { }; } - const range = await resolveRangeBase({ flags, cwd: toplevel, t }); - if (range.error) { - return { exit: EXIT.USAGE, error: range.error }; - } - const base = range.base; - const head = await revParse(toplevel, String(flags.head ?? 'HEAD')); if (!head) { return { @@ -558,6 +554,12 @@ async function runAudit(flags, lang) { }; } + const range = await resolveRangeBase({ flags, cwd: toplevel, head, t }); + if (range.error) { + return { exit: EXIT.USAGE, error: range.error }; + } + const base = range.base; + const origins = flags['no-origins'] !== true; const history = await collectHistoryFacts({ cwd: toplevel, diff --git a/src/git.js b/src/git.js index 18a28e9..688259f 100644 --- a/src/git.js +++ b/src/git.js @@ -81,13 +81,20 @@ export async function revParse(cwd, rev) { return out.trim() || null; } -/** The fork point between HEAD and a revision, or null when git cannot name one. */ -export async function mergeBase(cwd, rev) { - const out = await git(['merge-base', 'HEAD', rev], { cwd, allowFailure: true }); +/** + * The fork point between two revisions, or null when git cannot name one. + * + * `from` is passed in rather than assumed to be the checked-out HEAD: a run that + * was asked about another commit must fork from *that* commit, or the range it + * reports was never the one requested. + */ +export async function mergeBase(cwd, from, rev) { + const out = await git(['merge-base', from, rev], { cwd, allowFailure: true }); const sha = out.trim(); // git prints the hash alone on success; anything else (an error text that got - // past allowFailure, an empty result) is not a fork point. - return /^[0-9a-f]{40}$/.test(sha) ? sha : null; + // past allowFailure, an empty result) is not a fork point. A repository picks + // its own object format, so both the 40-char and the 64-char id are valid. + return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(sha) ? sha : null; } /** diff --git a/test/cli.test.js b/test/cli.test.js index ac38109..a572a60 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -168,6 +168,10 @@ describe('stderr boundary', () => { test('a usage error embedding a hostile flag value reaches stderr sanitized', async () => { const { main } = await import('../src/cli.js'); const repo = await makeRepo(); + // The range is resolved against the head, so --head is resolved first; an + // empty repository would stop there and the hostile --base would never be + // reached. One commit is enough to keep this about the flag value. + await commitFiles(repo, 'chore: seed', { 'src/a.js': 'export const a = 1;\n' }); const chunks = []; const original = process.stderr.write.bind(process.stderr); @@ -573,6 +577,34 @@ describe('--base inference', () => { assert.match(out, /"code": "range-inferred"/, 'and declares it on the JSON channel'); }); + test('the inference is anchored on the --head that was asked for', async () => { + // origin/main advanced past the fork while the checkout sits on an older + // branch. Asking about `--head origin/main` must infer the base from *that* + // commit; computing it from the checked-out HEAD measures a range nobody + // asked for — here, it would drag in the very changes under review. + const origin = await makeRepo(); + const seed = await commitFiles(origin, 'chore: seed', { 'src/a.js': 'export const a = 1;\n' }); + await commitFiles(origin, 'fix: reject expired tokens', { + 'src/a.js': 'export const a = 1;\n if (Date.now() > token.expiresAt) throw new Error("expired token here");\n' + }); + const dir = await mkdtemp(path.join(tmpdir(), 'euthyna-clone-')); + await execFileAsync('git', ['clone', '-q', origin, dir]); + await git(dir, ['config', 'user.email', 'test@example.com']); + await git(dir, ['config', 'user.name', 'Test Author']); + const target = (await git(dir, ['rev-parse', 'origin/main'])).trim(); + await git(dir, ['checkout', '-q', '-b', 'older', seed]); + await commitFiles(dir, 'chore: unrelated work here', { 'src/other.js': 'export const other = 1;\n' }); + + const { out } = await capture(['history', '--repo', dir, '--head', 'origin/main', '--json']); + const report = JSON.parse(out); + + assert.equal( + report.subject.base, + target, + 'merge-base is taken against the resolved --head, not the checked-out commit' + ); + }); + test('the declaration is a pure decision, in both languages', async () => { const zh = rangeInferenceDeclaration({ ref: 'origin/main', head: 'a'.repeat(40) }, (zhText, enText) => zhText); const en = rangeInferenceDeclaration({ ref: 'origin/main', head: 'a'.repeat(40) }, (zhText, enText) => enText); diff --git a/test/git.test.js b/test/git.test.js index b0dd636..c6507b5 100644 --- a/test/git.test.js +++ b/test/git.test.js @@ -10,8 +10,11 @@ */ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; -import { git, gitFailureMessage } from '../src/git.js'; +import { git, gitFailureMessage, mergeBase } from '../src/git.js'; import { makeRepo, commitFiles } from './helpers.js'; describe('gitFailureMessage', () => { @@ -95,3 +98,29 @@ describe('git() error path (real git)', () => { assert.equal(out, ''); }); }); + +describe('mergeBase', () => { + const BS = String.fromCharCode(92); + + test('a sha256 repository still yields a merge base', async () => { + // The audited repository chooses its own object format. Accepting only a + // 40-char id reads a valid 64-char fork point as "nothing resolved", which + // turns a working inference into a usage error. + const dir = await mkdtemp(path.join(tmpdir(), 'euthyna-sha256-')); + await git(['init', '-q', '--object-format=sha256', '.'], { cwd: dir }); + await git(['config', 'user.email', 'test@example.com'], { cwd: dir }); + await git(['config', 'user.name', 'Test Author'], { cwd: dir }); + await git(['config', 'commit.gpgsign', 'false'], { cwd: dir }); + await writeFile(path.join(dir, 'a.txt'), 'one' + BS + 'n'); + await git(['add', '-A'], { cwd: dir }); + await git(['commit', '-q', '-m', 'seed'], { cwd: dir }); + const seed = (await git(['rev-parse', 'HEAD'], { cwd: dir })).trim(); + await git(['branch', 'side'], { cwd: dir }); + await writeFile(path.join(dir, 'b.txt'), 'two' + BS + 'n'); + await git(['add', '-A'], { cwd: dir }); + await git(['commit', '-q', '-m', 'main moves on'], { cwd: dir }); + + assert.equal(seed.length, 64, 'the repository really is sha256 here'); + assert.equal(await mergeBase(dir, 'HEAD', 'side'), seed, 'the fork point is returned, not rejected as unparseable'); + }); +});