From 454146d808a368db73d1fc43326bdafe119c1028 Mon Sep 17 00:00:00 2001 From: modusensus Date: Sat, 10 Oct 2026 03:31:30 +0800 Subject: [PATCH 1/2] fix(audit): declare when the dependency half is not the --head version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit audit's two halves read from different places: history takes the commits named by --base/--head, deps takes the manifest checked out in --repo. Measured on the rustls run, a clone parked on main produced 284 dependency facts for a range whose tag holds 366, and nothing in the output said so. The fix declares rather than corrects: a dependency entry under criteria-not- evaluated, with a machine-readable code (worktree-not-head, or manifest-uncommitted for an edited-but-uncommitted manifest, which a commit comparison cannot see). Choosing declaration over reading the blob at --head was deliberate — the evidence pointers would stop naming files a reader can re-run. A declared gap cannot fail a run the history half answered, so the exit-code contract is unchanged. Tests build real repositories and assert both the presence and the absence of the declaration; the English string is pinned separately, because an unexercised half of a t() call is still production code. --- .../euthyna-en/references/fact-producers.md | 1 + .../euthyna/references/fact-producers.md | 4 + AGENTS.md | 2 +- docs/fact-contract-zh.md | 3 + docs/fact-contract.md | 4 + src/cli.js | 35 +++++++- test/cli.test.js | 89 ++++++++++++++++++- test/helpers.js | 17 ++++ test/i18n.test.js | 26 ++++++ 9 files changed, 178 insertions(+), 3 deletions(-) diff --git a/.agents/skills/euthyna-en/references/fact-producers.md b/.agents/skills/euthyna-en/references/fact-producers.md index 1097de8..f007abc 100644 --- a/.agents/skills/euthyna-en/references/fact-producers.md +++ b/.agents/skills/euthyna-en/references/fact-producers.md @@ -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 version**: `history` takes the commits named by `--base`/`--head`, while `deps` takes the manifest **currently checked out** in `--repo`. When the worktree is not at `--head`, or the manifest has uncommitted changes, the output names it under *Criteria not evaluated* (`worktree-not-head` / `manifest-uncommitted`). **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. diff --git a/.agents/skills/euthyna/references/fact-producers.md b/.agents/skills/euthyna/references/fact-producers.md index a5c76f9..6e96cbf 100644 --- a/.agents/skills/euthyna/references/fact-producers.md +++ b/.agents/skills/euthyna/references/fact-producers.md @@ -294,4 +294,8 @@ node /bin/euthyna.js gate <报告文件> --verify --cwd <被审 - `coverage` **只能证伪**。见上文。 - `coverage` 依赖调用计数,**不处理动态派发、反射、字符串调用**。 计数为 0 的结论在存在这些机制时要打折扣。 +- `audit` 的两个半边**读的不是同一个版本**:`history` 取 `--base`/`--head` 命名的提交,`deps` 取 + `--repo` 里**当前检出**的清单。工作区不在 `--head`、或清单有未提交改动时,输出会在「未评估的判据」里点名 + (`worktree-not-head` / `manifest-uncommitted`)。**看到它就检出到范围的一端再重跑**——声明只保证你不静默 + 踩坑,不会替你把事实测对。 - 两者都**不产出分数、严重性、修复建议**。这是契约,不是遗漏。 diff --git a/AGENTS.md b/AGENTS.md index f74c450..f468087 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 manifests *currently checked out* in `--repo`. A clone parked on `main` yielded 284 dependency facts for a range whose tag holds 366. The CLI now declares it — a `dependency` entry under *not evaluated* with `code: worktree-not-head` (or `manifest-uncommitted` when the file is edited but not committed, which a commit comparison cannot see) — but a declaration is not a measurement: 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`. | diff --git a/docs/fact-contract-zh.md b/docs/fact-contract-zh.md index 8d990b2..bd145d2 100644 --- a/docs/fact-contract-zh.md +++ b/docs/fact-contract-zh.md @@ -183,6 +183,9 @@ 而是「哪个工具、因为什么不可用」)。 3. **空结果要显式区分**:「跑了但没发现」记 `evaluated` + `count: 0`; 「根本没跑」记 `notEvaluated`。这两者在任何报告里都不许长得一样。 +4. **`code`(可选)是机读的判别名**,给 CI 和测试断言用:`audit` 的两半版本不一致记 + `worktree-not-head`,清单有未提交改动记 `manifest-uncommitted`。人读的还是 `reason`—— + `code` 只回答「哪一类缺口」,不替代理由,也不得被拿来当裁定。 --- diff --git a/docs/fact-contract.md b/docs/fact-contract.md index 693dda1..2c6ab11 100644 --- a/docs/fact-contract.md +++ b/docs/fact-contract.md @@ -182,6 +182,10 @@ 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 + `worktree-not-head` when its two halves describe different versions, and `manifest-uncommitted` when the + manifest has uncommitted changes. Humans still read `reason` — `code` only answers "which kind of gap", it + does not replace the reason and must never be used as a verdict. --- diff --git a/src/cli.js b/src/cli.js index aef92f8..373fc16 100644 --- a/src/cli.js +++ b/src/cli.js @@ -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({ @@ -476,6 +476,39 @@ async function runAudit(flags, lang) { depNotEvaluated.push({ kind: KIND.DEPENDENCY, reason: depResult.error }); } + // The two halves read from different places: history takes the commits named by + // --base/--head, deps takes the manifest checked out in --repo. A clone parked + // somewhere else therefore reports dependency facts about a version the caller + // never named (measured on rustls: 284 facts where the tag holds 366). An + // uncommitted manifest edit is the same trap with the commits matching, so it is + // declared separately rather than folded into the comparison above. + const worktree = await revParse(toplevel, 'HEAD'); + if (worktree && worktree !== head) { + depNotEvaluated.push({ + kind: KIND.DEPENDENCY, + code: 'worktree-not-head', + reason: t( + `依赖事实取自工作区检出的 ${worktree.slice(0, 10)},不是 --head 指定的 ${head.slice(0, 10)};本范围内清单的差异未被测量`, + `dependency facts come from the checked-out ${worktree.slice(0, 10)}, not from the --head ${head.slice(0, 10)}; this range's manifest difference was not measured` + ) + }); + } + + const manifest = depResult.report?.subject?.lockfile; + if (typeof manifest === 'string' && path.isAbsolute(manifest)) { + const dirty = await git(['status', '--porcelain', '--', manifest], { cwd: toplevel, allowFailure: true }); + if (dirty.trim() !== '') { + depNotEvaluated.push({ + kind: KIND.DEPENDENCY, + code: 'manifest-uncommitted', + reason: t( + `依赖清单 ${path.basename(manifest)} 有未提交的改动,测到的是工作区内容,不属于任何提交`, + `the manifest ${path.basename(manifest)} has uncommitted changes; what was measured is working-tree content, which belongs to no commit` + ) + }); + } + } + const facts = [...history.facts, ...depFacts]; const evaluated = [...history.evaluated, ...depEvaluated]; const notEvaluated = [...history.notEvaluated, ...depNotEvaluated]; diff --git a/test/cli.test.js b/test/cli.test.js index 43de937..a9a276c 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -15,7 +15,7 @@ import { promisify } from 'node:util'; import { fileURLToPath } from 'node:url'; import { parseArgs, EXIT } from '../src/cli.js'; -import { makeRepo, commitFiles, git } from './helpers.js'; +import { makeRepo, commitFiles, git, npmLockfile } from './helpers.js'; const execFileAsync = promisify(execFile); @@ -246,6 +246,93 @@ describe('audit command (history + deps in one run)', () => { }); }); +describe('audit declares which version its dependency half measured', () => { + // The two halves of audit read from different places: history from the commits + // named by --base/--head, deps from the manifest checked out in --repo. A clone + // parked elsewhere silently reports facts about a version the caller did not ask + // for — measured on a real repository as 284 dependency facts where the tag holds + // 366. These tests pin the declaration, not a fix to the dependency producer. + async function repoWithAGrowingLock() { + const repo = await makeRepo(); + const base = await commitFiles(repo, 'chore: pin left-pad', { + 'package-lock.json': npmLockfile([['left-pad', '1.3.0']]), + 'src/a.js': 'export const a = 1;\n' + }); + const head = await commitFiles(repo, 'chore: pin alpha', { + 'package-lock.json': npmLockfile([['left-pad', '1.3.0'], ['alpha', '2.0.0']]), + 'src/b.js': 'export const b = 2;\n' + }); + return { repo, base, head }; + } + + async function auditJson(args) { + 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; + }; + let code; + try { + code = await main(args); + } finally { + process.stdout.write = original; + } + return { code, report: JSON.parse(chunks.join('')) }; + } + + const declared = (report) => report.coverage.notEvaluated.filter((e) => e.code); + + test('a worktree behind --head names both commits in a not-evaluated entry', async () => { + const { repo, base, head } = await repoWithAGrowingLock(); + await git(repo, ['checkout', '-q', '--detach', base]); + + const { code, report } = await auditJson(['audit', '--repo', repo, '--base', base, '--head', head, '--json']); + + const entry = declared(report).find((e) => e.kind === 'dependency' && e.code === 'worktree-not-head'); + assert.ok(entry, 'the mismatch between the checkout and --head must be declared'); + assert.ok( + entry.reason.includes(base.slice(0, 10)) && entry.reason.includes(head.slice(0, 10)), + `the reason must name both commits, got: ${entry.reason}` + ); + assert.ok( + report.facts.some((f) => f.kind === 'dependency'), + 'declaring the mismatch must not suppress the dependency facts that were measured' + ); + assert.equal(code, EXIT.CLEAN, 'a declared gap cannot fail a run the history half answered'); + }); + + test('a worktree exactly at --head declares nothing', async () => { + const { repo, base, head } = await repoWithAGrowingLock(); + await git(repo, ['checkout', '-q', '--detach', head]); + + const { report } = await auditJson(['audit', '--repo', repo, '--base', base, '--head', head, '--json']); + + assert.deepEqual(declared(report), [], 'the normal case must not gain a warning line'); + }); + + test('a manifest with uncommitted changes is declared by file name', async () => { + const { repo, base, head } = await repoWithAGrowingLock(); + await git(repo, ['checkout', '-q', '--detach', head]); + await writeFile(path.join(repo, 'package-lock.json'), npmLockfile([['left-pad', '1.3.0'], ['beta', '3.0.0']]), 'utf8'); + + const { report } = await auditJson(['audit', '--repo', repo, '--base', base, '--head', head, '--json']); + + const entry = declared(report).find((e) => e.code === 'manifest-uncommitted'); + assert.ok(entry, 'a dirty manifest is invisible to a commit comparison, so it needs its own declaration'); + assert.ok(entry.reason.includes('package-lock.json'), `the reason must name the manifest, got: ${entry.reason}`); + }); + + test('omitting --head declares nothing, because HEAD is the head by definition', async () => { + const { repo, base } = await repoWithAGrowingLock(); + + const { report } = await auditJson(['audit', '--repo', repo, '--base', base, '--json']); + + assert.deepEqual(declared(report), [], 'comparing the literal "HEAD" against a sha would warn on every run'); + }); +}); + describe('deps --all (enumerate every manifest dependency)', () => { test('reports one fact per lockfile entry without any --dep flag', async () => { const { main } = await import('../src/cli.js'); diff --git a/test/helpers.js b/test/helpers.js index 861e76f..6c635b2 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -45,3 +45,20 @@ export async function commitFiles(cwd, message, files) { await git(cwd, ['commit', '-q', '-m', message]); return (await git(cwd, ['rev-parse', 'HEAD'])).trim(); } + +/** + * A minimal npm v3 lockfile pinning the given [name, version] pairs. + * + * Shared because two files need a manifest the deps producer will actually + * accept: one dependency fact per entry, and a line number to point at. + */ +export function npmLockfile(entries) { + return JSON.stringify({ + name: 'fixture', + lockfileVersion: 3, + packages: { + '': { name: 'fixture' }, + ...Object.fromEntries(entries.map(([name, version]) => [`node_modules/${name}`, { version }])) + } + }); +} diff --git a/test/i18n.test.js b/test/i18n.test.js index 96ef5bb..991a61f 100644 --- a/test/i18n.test.js +++ b/test/i18n.test.js @@ -17,6 +17,7 @@ import { resolveLang, T, isLang, DEFAULT_LANG } from '../src/lang.js'; import { makeReport, renderReport, makeFact, notEvaluated, KIND, STATUS } from '../src/contract.js'; import { parseGateReport, validateFindings, renderGateReport, isNotEvaluatedStatus, isPassStatus } from '../src/gate.js'; import { EXIT } from '../src/cli.js'; +import { makeRepo, commitFiles, git, npmLockfile } from './helpers.js'; describe('src/lang.js', () => { test('defaults to Chinese and switches on --lang en', () => { @@ -235,6 +236,31 @@ describe('CLI language switch', () => { } }); + test('the audit version declaration follows the requested language', async () => { + // The Chinese half of this pair is pinned in cli.test.js; without this case + // the English string in the same t() call is production code nothing runs. + const { main } = await import('../src/cli.js'); + const repo = await makeRepo(); + const base = await commitFiles(repo, 'chore: pin left-pad', { + 'package-lock.json': npmLockfile([['left-pad', '1.3.0']]), + 'src/a.js': 'export const a = 1;\n' + }); + const head = await commitFiles(repo, 'chore: pin alpha', { + 'package-lock.json': npmLockfile([['left-pad', '1.3.0'], ['alpha', '2.0.0']]), + 'src/b.js': 'export const b = 2;\n' + }); + await git(repo, ['checkout', '-q', '--detach', base]); + + const restore = capture(process.stdout); + try { + await main(['audit', '--repo', repo, '--base', base, '--head', head, '--lang', 'en']); + } finally { + const out = restore(); + assert.match(out, /dependency facts come from the checked-out/); + assert.doesNotMatch(out, /依赖事实取自/); + } + }); + test('EUTHYNA_LANG=en switches without the flag', async () => { const { main } = await import('../src/cli.js'); const dir = await mkdtemp(path.join(tmpdir(), 'euthyna-i18n-')); From 2a1e74f9d5bcc3d4d0d66e9e4cf044b4a95737e3 Mon Sep 17 00:00:00 2001 From: modusensus Date: Sat, 10 Oct 2026 04:13:37 +0800 Subject: [PATCH 2/2] fix(audit): compare manifest content against --head, not the checked-out sha MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An independent review of the first version found the criterion wrong in both directions, and both cases reproduce against the real CLI: - a PR merge commit whose package-lock.json is byte-identical to --head's got worktree-not-head on every run. Not cosmetic: with a non-empty notEvaluated no gate may pass (fact-contract rule 1), so every finding citing a dependency fact was forced to not_evaluated, and the remedy the skill teaches — check out an end of the range — is impossible on a merge ref. - a lockfile that is gitignored and was never committed produced dependency facts with no declaration at all, even though its content belongs to no commit. The criterion is now content: git ls-tree decides whether the measured manifest exists at --head, git diff --name-only -- decides whether it matches, and either way the entry is one code (manifest-not-head) rather than two. Using git diff also keeps core.autocrlf checkouts from reporting a difference that exists only in line endings. A probe that fails is declared as manifest-unverified: a git error is not evidence of a match, which is the direction the allowFailure version got backwards. The decision moved into an exported pure function so the fail-closed branch is reachable from a test; nothing else here can make git fail on demand. Suite: 246 tests, 245 pass, 0 fail, 1 skipped. --- .../euthyna-en/references/fact-producers.md | 2 +- .../euthyna/references/fact-producers.md | 9 +- AGENTS.md | 2 +- docs/fact-contract-zh.md | 7 +- docs/fact-contract.md | 7 +- src/cli.js | 103 +++++++++++++----- test/cli.test.js | 103 +++++++++++++++--- test/i18n.test.js | 4 +- 8 files changed, 177 insertions(+), 60 deletions(-) diff --git a/.agents/skills/euthyna-en/references/fact-producers.md b/.agents/skills/euthyna-en/references/fact-producers.md index f007abc..0de4645 100644 --- a/.agents/skills/euthyna-en/references/fact-producers.md +++ b/.agents/skills/euthyna-en/references/fact-producers.md @@ -280,5 +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 version**: `history` takes the commits named by `--base`/`--head`, while `deps` takes the manifest **currently checked out** in `--repo`. When the worktree is not at `--head`, or the manifest has uncommitted changes, the output names it under *Criteria not evaluated* (`worktree-not-head` / `manifest-uncommitted`). **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. +- 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. diff --git a/.agents/skills/euthyna/references/fact-producers.md b/.agents/skills/euthyna/references/fact-producers.md index 6e96cbf..242360d 100644 --- a/.agents/skills/euthyna/references/fact-producers.md +++ b/.agents/skills/euthyna/references/fact-producers.md @@ -294,8 +294,9 @@ node /bin/euthyna.js gate <报告文件> --verify --cwd <被审 - `coverage` **只能证伪**。见上文。 - `coverage` 依赖调用计数,**不处理动态派发、反射、字符串调用**。 计数为 0 的结论在存在这些机制时要打折扣。 -- `audit` 的两个半边**读的不是同一个版本**:`history` 取 `--base`/`--head` 命名的提交,`deps` 取 - `--repo` 里**当前检出**的清单。工作区不在 `--head`、或清单有未提交改动时,输出会在「未评估的判据」里点名 - (`worktree-not-head` / `manifest-uncommitted`)。**看到它就检出到范围的一端再重跑**——声明只保证你不静默 - 踩坑,不会替你把事实测对。 +- `audit` 的两个半边**读的不是同一个东西**:`history` 取 `--base`/`--head` 命名的提交,`deps` 取 + `--repo` 所在**仓库根**当前检出的那份清单(清单只按仓库根查找)。所以判据是**内容**:测到的清单与 `--head` 那份不一致、或在那个提交里根本 + 不存在(被 gitignore 的锁文件就是这种),「未评估的判据」里会多一条写明文件名与 `--head` 的理由; + `code`(`manifest-not-head` / `manifest-unverified`)只在 `--json` 通道上。**看到它就检出到范围的一端再重跑** + ——声明只保证你不静默踩坑,不会替你把事实测对。 - 两者都**不产出分数、严重性、修复建议**。这是契约,不是遗漏。 diff --git a/AGENTS.md b/AGENTS.md index f468087..8ddcd77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. The CLI now declares it — a `dependency` entry under *not evaluated* with `code: worktree-not-head` (or `manifest-uncommitted` when the file is edited but not committed, which a commit comparison cannot see) — but a declaration is not a measurement: check out one end of the range and confirm with `git rev-parse HEAD` | +| **`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`. | diff --git a/docs/fact-contract-zh.md b/docs/fact-contract-zh.md index bd145d2..44f3a37 100644 --- a/docs/fact-contract-zh.md +++ b/docs/fact-contract-zh.md @@ -183,9 +183,10 @@ 而是「哪个工具、因为什么不可用」)。 3. **空结果要显式区分**:「跑了但没发现」记 `evaluated` + `count: 0`; 「根本没跑」记 `notEvaluated`。这两者在任何报告里都不许长得一样。 -4. **`code`(可选)是机读的判别名**,给 CI 和测试断言用:`audit` 的两半版本不一致记 - `worktree-not-head`,清单有未提交改动记 `manifest-uncommitted`。人读的还是 `reason`—— - `code` 只回答「哪一类缺口」,不替代理由,也不得被拿来当裁定。 +4. **`code`(可选)是机读的判别名**,给 CI 和测试断言用:`audit` 在测到的清单与 `--head` 那份 + 内容不一致(或清单在该提交里根本不存在)时记 `manifest-not-head`,探测本身失败时记 + `manifest-unverified`。人读的还是 `reason`——`code` 只在 `--json` 通道上出现,终端渲染打的是 + `kind: reason`;它只回答「哪一类缺口」,不替代理由,也不得被拿来当裁定。 --- diff --git a/docs/fact-contract.md b/docs/fact-contract.md index 2c6ab11..0b67d26 100644 --- a/docs/fact-contract.md +++ b/docs/fact-contract.md @@ -183,9 +183,10 @@ This is where P2 lands, and it is the key to **guarding against false negatives* 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 - `worktree-not-head` when its two halves describe different versions, and `manifest-uncommitted` when the - manifest has uncommitted changes. Humans still read `reason` — `code` only answers "which kind of gap", it - does not replace the reason and must never be used as a verdict. + `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. --- diff --git a/src/cli.js b/src/cli.js index 373fc16..ff00212 100644 --- a/src/cli.js +++ b/src/cli.js @@ -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()); @@ -476,37 +520,36 @@ async function runAudit(flags, lang) { depNotEvaluated.push({ kind: KIND.DEPENDENCY, reason: depResult.error }); } - // The two halves read from different places: history takes the commits named by - // --base/--head, deps takes the manifest checked out in --repo. A clone parked - // somewhere else therefore reports dependency facts about a version the caller - // never named (measured on rustls: 284 facts where the tag holds 366). An - // uncommitted manifest edit is the same trap with the commits matching, so it is - // declared separately rather than folded into the comparison above. - const worktree = await revParse(toplevel, 'HEAD'); - if (worktree && worktree !== head) { - depNotEvaluated.push({ - kind: KIND.DEPENDENCY, - code: 'worktree-not-head', - reason: t( - `依赖事实取自工作区检出的 ${worktree.slice(0, 10)},不是 --head 指定的 ${head.slice(0, 10)};本范围内清单的差异未被测量`, - `dependency facts come from the checked-out ${worktree.slice(0, 10)}, not from the --head ${head.slice(0, 10)}; this range's manifest difference was not measured` - ) - }); - } - - const manifest = depResult.report?.subject?.lockfile; - if (typeof manifest === 'string' && path.isAbsolute(manifest)) { - const dirty = await git(['status', '--porcelain', '--', manifest], { cwd: toplevel, allowFailure: true }); - if (dirty.trim() !== '') { - depNotEvaluated.push({ - kind: KIND.DEPENDENCY, - code: 'manifest-uncommitted', - reason: t( - `依赖清单 ${path.basename(manifest)} 有未提交的改动,测到的是工作区内容,不属于任何提交`, - `the manifest ${path.basename(manifest)} has uncommitted changes; what was measured is working-tree content, which belongs to no commit` - ) - }); + // 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]; diff --git a/test/cli.test.js b/test/cli.test.js index a9a276c..8e8baa9 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -246,12 +246,14 @@ describe('audit command (history + deps in one run)', () => { }); }); -describe('audit declares which version its dependency half measured', () => { +describe('audit declares when the manifest it measured is not the --head manifest', () => { // The two halves of audit read from different places: history from the commits // named by --base/--head, deps from the manifest checked out in --repo. A clone // parked elsewhere silently reports facts about a version the caller did not ask // for — measured on a real repository as 284 dependency facts where the tag holds - // 366. These tests pin the declaration, not a fix to the dependency producer. + // 366. The criterion is manifest CONTENT, not the checked-out sha: comparing shas + // was shown to fire on a PR merge commit whose lockfile is byte-identical to + // --head's, and to stay silent on a gitignored lockfile that belongs to no commit. async function repoWithAGrowingLock() { const repo = await makeRepo(); const base = await commitFiles(repo, 'chore: pin left-pad', { @@ -284,26 +286,65 @@ describe('audit declares which version its dependency half measured', () => { const declared = (report) => report.coverage.notEvaluated.filter((e) => e.code); - test('a worktree behind --head names both commits in a not-evaluated entry', async () => { + test('a manifest that differs from --head is declared and does not lose the facts', async () => { const { repo, base, head } = await repoWithAGrowingLock(); await git(repo, ['checkout', '-q', '--detach', base]); const { code, report } = await auditJson(['audit', '--repo', repo, '--base', base, '--head', head, '--json']); - const entry = declared(report).find((e) => e.kind === 'dependency' && e.code === 'worktree-not-head'); - assert.ok(entry, 'the mismatch between the checkout and --head must be declared'); - assert.ok( - entry.reason.includes(base.slice(0, 10)) && entry.reason.includes(head.slice(0, 10)), - `the reason must name both commits, got: ${entry.reason}` - ); + const entry = declared(report).find((e) => e.code === 'manifest-not-head'); + assert.ok(entry, 'content that is not the --head content must be declared'); + assert.ok(entry.reason.includes(head.slice(0, 10)), `the reason must name --head, got: ${entry.reason}`); assert.ok( report.facts.some((f) => f.kind === 'dependency'), - 'declaring the mismatch must not suppress the dependency facts that were measured' + 'declaring the gap must not suppress the dependency facts that were measured' ); assert.equal(code, EXIT.CLEAN, 'a declared gap cannot fail a run the history half answered'); }); - test('a worktree exactly at --head declares nothing', async () => { + test('a merge commit with lockfile content identical to --head declares nothing', async () => { + // actions/checkout leaves a pull_request run on the merge commit. Its lockfile + // equals the PR head's, so the dependency facts ARE the --head facts, and + // declaring them a gap would force every finding that cites them to + // not_evaluated for no reason (fact-contract hard rule 1). + const { repo, base, head } = await repoWithAGrowingLock(); + await git(repo, ['checkout', '-q', '-b', 'other', base]); + await commitFiles(repo, 'chore: touch an unrelated file on the other branch', { + 'src/c.js': 'export const c = 3;\n' + }); + await git(repo, ['merge', '-q', '--no-ff', '-m', `Merge the lockfile change into other`, head]); + + assert.equal( + (await git(repo, ['diff', '--name-only', head, '--', 'package-lock.json'])).trim(), + '', + 'the fixture is only worth this test if its manifest content equals --head' + ); + assert.notEqual((await git(repo, ['rev-parse', 'HEAD'])).trim(), head, 'and if the commit itself differs'); + + const { report } = await auditJson(['audit', '--repo', repo, '--base', base, '--head', head, '--json']); + assert.deepEqual(declared(report), [], 'identical content must not be declared as a gap'); + }); + + test('a manifest that exists only in the working tree is declared', async () => { + // The case a commit comparison cannot see at all: the lockfile is gitignored, + // so nothing about it belongs to any commit, and the measured facts are the + // working tree's by construction. + const repo = await makeRepo(); + const base = await commitFiles(repo, 'chore: ignore the lockfile', { + '.gitignore': 'package-lock.json\n', + 'src/a.js': 'export const a = 1;\n' + }); + const head = await commitFiles(repo, 'chore: touch another file', { 'src/b.js': 'export const b = 2;\n' }); + await writeFile(path.join(repo, 'package-lock.json'), npmLockfile([['left-pad', '1.3.0']]), 'utf8'); + + const { report } = await auditJson(['audit', '--repo', repo, '--base', base, '--head', head, '--json']); + + const entry = declared(report).find((e) => e.code === 'manifest-not-head'); + assert.ok(entry, 'a manifest absent from --head cannot read as measured-at---head'); + assert.ok(entry.reason.includes('package-lock.json'), `the reason must name the file, got: ${entry.reason}`); + }); + + test('a manifest exactly matching --head declares nothing', async () => { const { repo, base, head } = await repoWithAGrowingLock(); await git(repo, ['checkout', '-q', '--detach', head]); @@ -312,16 +353,14 @@ describe('audit declares which version its dependency half measured', () => { assert.deepEqual(declared(report), [], 'the normal case must not gain a warning line'); }); - test('a manifest with uncommitted changes is declared by file name', async () => { + test('an uncommitted edit away from the --head content is declared', async () => { const { repo, base, head } = await repoWithAGrowingLock(); await git(repo, ['checkout', '-q', '--detach', head]); await writeFile(path.join(repo, 'package-lock.json'), npmLockfile([['left-pad', '1.3.0'], ['beta', '3.0.0']]), 'utf8'); const { report } = await auditJson(['audit', '--repo', repo, '--base', base, '--head', head, '--json']); - const entry = declared(report).find((e) => e.code === 'manifest-uncommitted'); - assert.ok(entry, 'a dirty manifest is invisible to a commit comparison, so it needs its own declaration'); - assert.ok(entry.reason.includes('package-lock.json'), `the reason must name the manifest, got: ${entry.reason}`); + assert.ok(declared(report).some((e) => e.code === 'manifest-not-head'), 'working-tree content belongs to no commit'); }); test('omitting --head declares nothing, because HEAD is the head by definition', async () => { @@ -329,7 +368,39 @@ describe('audit declares which version its dependency half measured', () => { const { report } = await auditJson(['audit', '--repo', repo, '--base', base, '--json']); - assert.deepEqual(declared(report), [], 'comparing the literal "HEAD" against a sha would warn on every run'); + assert.deepEqual(declared(report), [], 'the checkout is at the head that was measured against'); + }); +}); + +describe('the manifest-version decision table', () => { + // The probes that feed the decision run git; the decision itself is pure, and its + // fail-closed branch is otherwise unreachable from a test. Missing data must never + // read as clean, so an unreadable probe is itself a declared gap. + const t = (zh) => zh; + + test('identical content at --head yields no gap', async () => { + const { manifestVersionGap } = await import('../src/cli.js'); + assert.equal(manifestVersionGap({ probeFailed: false, existsAtHead: true, differsFromHead: false, head: 'a'.repeat(40), worktree: 'a'.repeat(40), manifest: 'Cargo.lock' }, t), null); + }); + + test('content differing from --head is a gap naming that commit', async () => { + const { manifestVersionGap } = await import('../src/cli.js'); + const gap = manifestVersionGap({ probeFailed: false, existsAtHead: true, differsFromHead: true, head: 'b'.repeat(40), worktree: 'c'.repeat(40), manifest: 'Cargo.lock' }, t); + assert.equal(gap.code, 'manifest-not-head'); + assert.ok(gap.reason.includes('b'.repeat(10)), gap.reason); + }); + + test('a manifest absent at --head is the same gap', async () => { + const { manifestVersionGap } = await import('../src/cli.js'); + const gap = manifestVersionGap({ probeFailed: false, existsAtHead: false, differsFromHead: false, head: 'd'.repeat(40), worktree: 'd'.repeat(40), manifest: 'go.mod' }, t); + assert.equal(gap.code, 'manifest-not-head'); + assert.ok(gap.reason.includes('go.mod'), gap.reason); + }); + + test('a failed probe is declared, never treated as clean', async () => { + const { manifestVersionGap } = await import('../src/cli.js'); + const gap = manifestVersionGap({ probeFailed: true, existsAtHead: false, differsFromHead: false, head: 'e'.repeat(40), worktree: 'e'.repeat(40), manifest: 'Cargo.lock' }, t); + assert.equal(gap.code, 'manifest-unverified', 'a probe that could not run must not read as no gap'); }); }); diff --git a/test/i18n.test.js b/test/i18n.test.js index 991a61f..6f809f0 100644 --- a/test/i18n.test.js +++ b/test/i18n.test.js @@ -256,8 +256,8 @@ describe('CLI language switch', () => { await main(['audit', '--repo', repo, '--base', base, '--head', head, '--lang', 'en']); } finally { const out = restore(); - assert.match(out, /dependency facts come from the checked-out/); - assert.doesNotMatch(out, /依赖事实取自/); + assert.match(out, /differs in content from the one at --head/); + assert.doesNotMatch(out, /测到的依赖清单/); } });