Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/euthyna-en/references/fact-producers.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,4 +280,5 @@ that a conclusion without evidence is not handed over wearing the face of "estab
- `history` only covers **deleted lines**. It does not handle newly added code (except with `--pickaxe`).
- `coverage` **can only falsify**. See above.
- `coverage` relies on invocation counts and **does not handle dynamic dispatch, reflection, or string-based calls**. Conclusions of count 0 must be discounted when such mechanisms exist.
- The two halves of `audit` **do not read the same thing**: `history` takes the commits named by `--base`/`--head`, while `deps` takes the manifest **currently checked out at the root of the repository** `--repo` points into (manifests are located from the root only). So the criterion is **content**: when the manifest measured differs from the one at `--head`, or does not exist in that commit at all (a gitignored lockfile is exactly that), *Criteria not evaluated* gains an entry naming the file and `--head`. The `code` (`manifest-not-head` / `manifest-unverified`) appears only on the `--json` channel. **Seeing one means checking out an end of the range and re-running** — the declaration keeps the trap from being silent; it does not measure the fact for you.
- Neither **produces scores, severity, or fix suggestions**. That is the contract, not an omission.
5 changes: 5 additions & 0 deletions .agents/skills/euthyna/references/fact-producers.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,4 +294,9 @@ node <euthyna 仓库>/bin/euthyna.js gate <报告文件> --verify --cwd <被审
- `coverage` **只能证伪**。见上文。
- `coverage` 依赖调用计数,**不处理动态派发、反射、字符串调用**。
计数为 0 的结论在存在这些机制时要打折扣。
- `audit` 的两个半边**读的不是同一个东西**:`history` 取 `--base`/`--head` 命名的提交,`deps` 取
`--repo` 所在**仓库根**当前检出的那份清单(清单只按仓库根查找)。所以判据是**内容**:测到的清单与 `--head` 那份不一致、或在那个提交里根本
不存在(被 gitignore 的锁文件就是这种),「未评估的判据」里会多一条写明文件名与 `--head` 的理由;
`code`(`manifest-not-head` / `manifest-unverified`)只在 `--json` 通道上。**看到它就检出到范围的一端再重跑**
——声明只保证你不静默踩坑,不会替你把事实测对。
- 两者都**不产出分数、严重性、修复建议**。这是契约,不是遗漏。
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ is faithful — that is a reading job, and a mechanical stand-in would only manu
| **A jq filter containing `\|` gets split by this shell tool's pipeline parsing** | Use pipe-free expressions (e.g. `--jq '.names'` instead of `'.names \| join(", ")'`) |
| **`core.autocrlf=true` with no `.gitattributes` gives a CRLF checkout while the repository stores LF** | Committed content is unaffected, but byte-level checks (file size, SHA-256, a diff against an upstream original) then report differences that do not exist. Hit while byte-checking `LICENSE` against the canonical Apache text, which produced a phantom 202-byte difference. Fixed by the `.gitattributes` at the repository root |
| **A "corrupted" test fixture that silently failed to mutate** | The checker correctly reported VERIFIED, because the file was byte-identical to the control. Assert that every negative fixture actually differs from the control before trusting any result that comes out of it |
| **`euthyna audit`'s two halves do not read the same version** | 🧪 Measured 2026-10-09 on the rustls run: `history` reads the commits named by `--base`/`--head`, while `deps` reads the manifests *currently checked out* in `--repo`. A clone parked on `main` yielded 284 dependency facts for a range whose tag holds 366. Check out one end of the range and confirm with `git rev-parse HEAD` before trusting the dependency half |
| **`euthyna audit`'s two halves do not read the same version** | 🧪 Measured 2026-10-09 on the rustls run: `history` reads the commits named by `--base`/`--head`, while `deps` reads the manifest *currently checked out at the repository root* `--repo` points into. A clone parked on `main` yielded 284 dependency facts for a range whose tag holds 366. The CLI now declares it (`code: manifest-not-head`) by comparing **content** with `--head`, not by comparing commit shas — measured wrong in both directions first: a sha comparison fired on a PR merge commit whose lockfile was byte-identical to `--head`'s (silent noise that forces every dependent gate to `not_evaluated`) and stayed silent on a gitignored lockfile that belongs to no commit. A declaration is not a measurement: still check out one end of the range and confirm with `git rev-parse HEAD` |
| **A control byte in a git argv does not survive the Windows command-line round trip** | Measured while writing the issue #2 regressions: a BEL reached git as `?`, and an ESC-bearing argument got different semantics entirely (exit 0, no error). A hostile-argv test against real git therefore cannot assert the escape form cross-platform — assert the portable property (no raw control byte in the output) against git, and the exact escape form against the pure message builder (`gitFailureMessage`) |
| **euthyna 的 CLI 在沙箱 pwsh 里跑必失败** | 沙箱拒绝 Node 起 git 子进程(piped stdio 的 EPERM,见上)后,`repoToplevel` 静默返回空,CLI 报 exit 2「不在任何 git 仓库」——看起来像目标仓库问题,其实是沙箱问题。一律用侧边栏嵌入式终端跑 `node D:\euthyna\bin\euthyna.js ...` |
| **A user-level installed skill silently lags the published version** | 🧪 Measured 2026-10-09: `~/.agents/skills/euthyna/` was still pre-0.6.0 (1 `--source` mention where the shipped tree has 5, no `coverprofile`, missing the rule-5 wording from PR #21), while npm and ClawHub both read 0.6.0. Inside this repository it is invisible — the project root (rank 200) wins — but **outside it the old discipline is what actually runs**. Check with `git diff --no-index --stat ~/.agents/skills/euthyna .agents/skills/euthyna` (empty output = in sync) rather than assuming a release propagated. Use `git`, not `diff`: in PowerShell `diff` is the `Compare-Object` alias and refuses `-rq`. |
Expand Down
4 changes: 4 additions & 0 deletions docs/fact-contract-zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@
而是「哪个工具、因为什么不可用」)。
3. **空结果要显式区分**:「跑了但没发现」记 `evaluated` + `count: 0`;
「根本没跑」记 `notEvaluated`。这两者在任何报告里都不许长得一样。
4. **`code`(可选)是机读的判别名**,给 CI 和测试断言用:`audit` 在测到的清单与 `--head` 那份
内容不一致(或清单在该提交里根本不存在)时记 `manifest-not-head`,探测本身失败时记
`manifest-unverified`。人读的还是 `reason`——`code` 只在 `--json` 通道上出现,终端渲染打的是
`kind: reason`;它只回答「哪一类缺口」,不替代理由,也不得被拿来当裁定。

---

Expand Down
5 changes: 5 additions & 0 deletions docs/fact-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,11 @@ This is where P2 lands, and it is the key to **guarding against false negatives*
but "which tool, unavailable for what reason").
3. **Empty results must be explicitly distinguished**: "ran but found nothing" is recorded as `evaluated` + `count: 0`;
"never ran at all" is recorded as `notEvaluated`. The two must never look the same in any report.
4. **`code` (optional) is a machine-readable criterion name**, for CI and test assertions: `audit` records
`manifest-not-head` when the manifest it measured differs in content from the one at `--head` (or does not
exist there at all), and `manifest-unverified` when the probe itself failed. Humans still read `reason` —
`code` appears only on the `--json` channel, since the terminal renders `kind: reason`; it answers "which
kind of gap", does not replace the reason, and must never be used as a verdict.

---

Expand Down
78 changes: 77 additions & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { collectCoverageFacts } from './facts/coverage.js';
import { collectDependencyFacts, listDependencies, detectManifest, manifestTypeForFile } from './facts/deps.js';
import { parseGateReport, validateFindings, verifyFinding, renderGateReport } from './gate.js';
import { makeReport, renderReport, safeTextLines, KIND } from './contract.js';
import { repoToplevel, revParse } from './git.js';
import { repoToplevel, revParse, git } from './git.js';
import { resolveLang, T, isLang, DEFAULT_LANG } from './lang.js';

export const EXIT = Object.freeze({
Expand Down Expand Up @@ -409,6 +409,50 @@ async function runDeps(flags, lang) {
* symbol names only the adjudicator knows, so there is nothing honest to
* auto-measure there.
*/
/**
* Decide whether the manifest `audit` measured is the manifest --head holds.
*
* Pure, so that the fail-closed branch — a probe that could not run — is reachable
* from a test. Nothing else here can make git fail on demand.
*/
export function manifestVersionGap({ probeFailed, existsAtHead, differsFromHead, head, worktree, manifest }, t) {
const short = (sha) => (sha ?? '').slice(0, 10);
const where = worktree && worktree !== head ? `(工作区检出在 ${short(worktree)})` : '';
const whereEn = worktree && worktree !== head ? ` (the worktree is at ${short(worktree)})` : '';

if (probeFailed) {
return {
kind: KIND.DEPENDENCY,
code: 'manifest-unverified',
reason: t(
`无法确认依赖清单 ${manifest} 与 --head ${short(head)} 的关系:git 探测失败,这不是匹配的证据`,
`the relationship between the manifest ${manifest} and --head ${short(head)} could not be probed: git failed, which is not evidence of a match`
)
};
}
if (!existsAtHead) {
return {
kind: KIND.DEPENDENCY,
code: 'manifest-not-head',
reason: t(
`测到的依赖清单 ${manifest} 在 --head ${short(head)} 里不存在,它不属于任何被审的提交${where}`,
`the dependency manifest ${manifest} measured here does not exist at --head ${short(head)}; it belongs to no reviewed commit${whereEn}`
)
};
}
if (differsFromHead) {
return {
kind: KIND.DEPENDENCY,
code: 'manifest-not-head',
reason: t(
`测到的依赖清单 ${manifest} 与 --head ${short(head)} 那份内容不一致${where},这些事实不是该提交的声明版本`,
`the dependency manifest ${manifest} measured here differs in content from the one at --head ${short(head)}${whereEn}, so these facts are not that commit's declared versions`
)
};
}
return null;
}

async function runAudit(flags, lang) {
const t = T(lang);
const cwd = path.resolve(flags.repo ? String(flags.repo) : process.cwd());
Expand Down Expand Up @@ -476,6 +520,38 @@ async function runAudit(flags, lang) {
depNotEvaluated.push({ kind: KIND.DEPENDENCY, reason: depResult.error });
}

// history takes the commits named by --base/--head; deps takes the manifest
// checked out in --repo. The honest question is therefore about CONTENT: is the
// file that was measured the file --head holds? Comparing commit shas instead was
// measured to be wrong in both directions — it fired on a PR merge commit whose
// lockfile is byte-identical to --head's (which, per fact-contract rule 1, forces
// every finding citing a dependency fact to not_evaluated), and stayed silent on a
// gitignored lockfile that belongs to no commit at all.
const manifest = typeof depResult.report?.subject?.lockfile === 'string'
? depResult.report.subject.lockfile
: undefined;
if (manifest) {
const probe = { probeFailed: false, existsAtHead: false, differsFromHead: true };
if (path.isAbsolute(manifest)) {
const rel = path.relative(toplevel, manifest).split(path.sep).join('/');
try {
probe.existsAtHead = (await git(['ls-tree', head, '--', rel], { cwd: toplevel })).trim() !== '';
probe.differsFromHead = !probe.existsAtHead ||
(await git(['diff', '--name-only', head, '--', rel], { cwd: toplevel })).trim() !== '';
} catch {
// A probe that could not run is not evidence of a match.
probe.probeFailed = true;
}
} else {
probe.probeFailed = true;
}
const versionGap = manifestVersionGap(
{ ...probe, head, worktree: await revParse(toplevel, 'HEAD'), manifest: path.basename(manifest) },
t
);
if (versionGap) depNotEvaluated.push(versionGap);
}

const facts = [...history.facts, ...depFacts];
const evaluated = [...history.evaluated, ...depEvaluated];
const notEvaluated = [...history.notEvaluated, ...depNotEvaluated];
Expand Down
Loading
Loading