diff --git a/.agents/skills/euthyna-en/SKILL.md b/.agents/skills/euthyna-en/SKILL.md index c80a8a9..90b850e 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` (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 ` | +| `/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 @@ -117,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-en/references/fact-producers.md b/.agents/skills/euthyna-en/references/fact-producers.md index f1cc64a..a08c487 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(, 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/SKILL.md b/.agents/skills/euthyna/SKILL.md index 56126ef..af2f962 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`(同样可省略 `--base`) | +| `/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。 + --- ## 本技能自带的两个确定性测量 @@ -106,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/.agents/skills/euthyna/references/fact-producers.md b/.agents/skills/euthyna/references/fact-producers.md index fbee6f3..e976024 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/.claude/commands/euthyna.md b/.claude/commands/euthyna.md index 648d513..b12f02f 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`「模式词(斜杠命令)」表里的模式时,只执行该模式映射的那一条命令并按其结果收口——`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 080ada4..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 为主。 +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 cb389ca..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 为主。 +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/.opencode/commands/euthyna.md b/.opencode/commands/euthyna.md index 82f56b9..fe46971 100644 --- a/.opencode/commands/euthyna.md +++ b/.opencode/commands/euthyna.md @@ -1,8 +1,10 @@ --- 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 -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 1b47317..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. `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(, 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 92aa1c6..57f027c 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(, 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/docs/ci-integration-zh.md b/docs/ci-integration-zh.md index 76b6b1d..dd6210c 100644 --- a/docs/ci-integration-zh.md +++ b/docs/ci-integration-zh.md @@ -9,6 +9,12 @@ > **局限**: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(, 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 03f5ea3..cf3674e 100644 --- a/docs/ci-integration.md +++ b/docs/ci-integration.md @@ -10,6 +10,13 @@ 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(, 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 0edde9e..e3ca033 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,18 +270,6 @@ 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 head = await revParse(toplevel, String(flags.head ?? 'HEAD')); if (!head) { return { @@ -283,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; @@ -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,56 @@ 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(${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` + ) + }; +} + +/** + * 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. 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, head, 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, head, 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 +524,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,14 +546,6 @@ 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 head = await revParse(toplevel, String(flags.head ?? 'HEAD')); if (!head) { return { @@ -511,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, @@ -567,10 +616,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..688259f 100644 --- a/src/git.js +++ b/src/git.js @@ -81,6 +81,22 @@ export async function revParse(cwd, rev) { return out.trim() || null; } +/** + * 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. 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; +} + /** * 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..a572a60 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 () => { @@ -161,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); @@ -257,9 +268,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 +506,116 @@ 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 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); + + 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(); 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'); + }); +}); 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`); + }); +});