From 52effaee29472f602945498b09f5f58960263ff2 Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 5 Nov 2026 00:32:15 +0800 Subject: [PATCH 1/9] docs(deps): go.sum records version-hash pairs, not "no versions" --- src/facts/deps.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/facts/deps.js b/src/facts/deps.js index 9fe9c78..83f1c57 100644 --- a/src/facts/deps.js +++ b/src/facts/deps.js @@ -16,8 +16,8 @@ * - package-lock.json (npm v1/v2/v3) * - Cargo.lock (Rust) * - go.mod (Go; the DECLARED requirement version, not the resolved one — - * go.sum has no versions, so this producer says "declared", - * never "locked", for Go) + * go.sum records version-hash pairs but not the selected build + * list, so this producer says "declared", never "locked", for Go) * Anything else found at auto-detect (pnpm/yarn/poetry/...) is reported as * notEvaluated with the filenames named, rather than guessed at. */ From 110e5f06828445649287102ccb486108cd505c64 Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 5 Nov 2026 00:42:48 +0800 Subject: [PATCH 2/9] feat(cli): --out writes the run's artifact instead of stdout --- README.md | 5 ++ docs/ci-integration-zh.md | 19 +++--- docs/ci-integration.md | 20 +++---- src/cli.js | 119 ++++++++++++++++++++++++++++++++++---- test/cli.test.js | 100 +++++++++++++++++++++++++++++++- 5 files changed, 231 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 57f027c..3c5039d 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,11 @@ 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. +Every command also takes `--out `: the report goes to a UTF-8 (no BOM) file +instead of stdout — the CI-friendly way to keep the artifact, and the exit code +contract is unchanged. The PR-gate recipe in [docs/ci-integration.md](docs/ci-integration.md) +uses it. + `--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 diff --git a/docs/ci-integration-zh.md b/docs/ci-integration-zh.md index dd6210c..7be80ad 100644 --- a/docs/ci-integration-zh.md +++ b/docs/ci-integration-zh.md @@ -87,14 +87,13 @@ GitHub 把任何非零退出当作步骤失败,所以 `0` 显示绿,`10`、` ## 把报告挂到 PR 上 -运行会把人类可读的报告打进步骤日志。要作为附件挂到 PR 上: +运行会把人类可读的报告打进步骤日志。要作为附件挂到 PR 上,用 `--out` 直接写文件—— +不用重定向、不用 `tee`、也没有 `pipefail` 的坑:artifact 直达文件,步骤状态仍是 +audit 自己的。 ```yaml - name: 变更面审计 - run: | - set -o pipefail - npx --yes euthyna@latest audit --base "origin/${{ github.base_ref }}" --head HEAD \ - | tee audit-report.txt + run: npx --yes euthyna@latest audit --base "origin/${{ github.base_ref }}" --head HEAD --out audit-report.txt continue-on-error: true id: audit - uses: actions/upload-artifact@v4 @@ -104,12 +103,12 @@ GitHub 把任何非零退出当作步骤失败,所以 `0` 显示绿,`10`、` path: audit-report.txt ``` -没有 `pipefail` 时,步骤的状态是 `tee` 的(永远 0),audit 的 `1`/`2`/`10` 会被吞掉、 -门禁假绿。Actions 的默认 shell 是 `bash -e`,**不**隐含 `pipefail`,所以要显式写 -`set -o pipefail`。 +`--out` 以 UTF-8 无 BOM 写入,退出码契约不变:被标记的运行仍然退出 `10`,文件写不进去 +则退出 `1`,不会被读成一次干净的运行。Linux runner 上 shell 重定向(`> audit-report.txt`) +也可用,但 Windows PowerShell 的 `>` 写的是 UTF-16——`--out` 把这一类问题整个移掉了。 -下游消费方(评论机器人、裁定 agent)要读 fact 而不是散文时,加 `--json` 并把 artifact -指向 JSON 文件。 +下游消费方(评论机器人、裁定 agent)要读 fact 而不是散文时,加 `--json --out report.json` +并把 artifact 指向 JSON 文件。 ## 这个门禁回答什么、不回答什么 diff --git a/docs/ci-integration.md b/docs/ci-integration.md index cf3674e..6184798 100644 --- a/docs/ci-integration.md +++ b/docs/ci-integration.md @@ -92,14 +92,13 @@ is the honest gate. Use the two-step form only when `10` must not block merges. ## Reading the report in the PR -The run prints the human-readable report to the step log. To attach it to the PR instead: +The run prints the human-readable report to the step log. To attach it to the PR instead, +write it to a file with `--out` — no redirect, no `tee`, and no `pipefail` subtlety: the +artifact goes straight to the file and the step's status is still the audit's own. ```yaml - name: Change-surface audit - run: | - set -o pipefail - npx --yes euthyna@latest audit --base "origin/${{ github.base_ref }}" --head HEAD \ - | tee audit-report.txt + run: npx --yes euthyna@latest audit --base "origin/${{ github.base_ref }}" --head HEAD --out audit-report.txt continue-on-error: true id: audit - uses: actions/upload-artifact@v4 @@ -109,12 +108,13 @@ The run prints the human-readable report to the step log. To attach it to the PR path: audit-report.txt ``` -Without `pipefail`, the step's status is `tee`'s (always 0), so a `1`/`2`/`10` from the -audit would turn the gate green. Actions' default shell is `bash -e`, which does **not** -imply `pipefail`, hence the explicit `set -o pipefail` line. +`--out` writes UTF-8 without a BOM and keeps the exit code contract: a flagged run still +exits `10`, and a file that could not be written exits `1` rather than reading as a clean +run. A shell redirect (`> audit-report.txt`) also works on a Linux runner, but `>` writes +UTF-16 in Windows PowerShell — `--out` removes that class of problem entirely. -Add `--json` and point the artifact at the JSON file when a downstream consumer (a comment bot, -an adjudicator agent) should read facts rather than prose. +Add `--json --out report.json` and point the artifact at the JSON file when a downstream +consumer (a comment bot, an adjudicator agent) should read facts rather than prose. ## What the gate does and does not answer diff --git a/src/cli.js b/src/cli.js index e3ca033..36b6fa8 100644 --- a/src/cli.js +++ b/src/cli.js @@ -15,7 +15,7 @@ */ import process from 'node:process'; import path from 'node:path'; -import { readFile } from 'node:fs/promises'; +import { readFile, writeFile } from 'node:fs/promises'; import { collectHistoryFacts } from './facts/history.js'; import { collectCoverageFacts } from './facts/coverage.js'; import { collectDependencyFacts, listDependencies, detectManifest, manifestTypeForFile } from './facts/deps.js'; @@ -72,14 +72,19 @@ euthyna —— 给 AI 编码 agent 用的确定性事实产出器 它不是扫描器。它只回答两个模型算不准的问题,并把答案写成带证据的事实。 用法: - 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] + euthyna audit [--base ] [--head ] [--repo ] [--pickaxe] [--no-origins] [--json] [--graph] [--out ] + euthyna history [--base ] [--head ] [--repo ] [--pickaxe] [--no-origins] [--json] [--graph] [--out ] + euthyna coverage --coverage --symbol [--file ] [--source ] [--json] [--out ] + euthyna deps [--repo ] [--lockfile ] (--dep | --all) [--json] [--out ] + euthyna gate <报告文件> [--verify] [--cwd ] [--json] [--out ] 语言: 默认中文。加 --lang en 或设环境变量 EUTHYNA_LANG=en 切换英文输出。 +全局选项: + --out 把本次输出写入文件而非 stdout(内容随 --json / --graph 切换)。 + 恒为 UTF-8 无 BOM;写失败按用法错误退出(1),即使测量本身成功; + 写入后 stderr 提示一行目标路径 + 命令: audit 一次跑完变更面审计:history + deps(枚举 lockfile 全部依赖),合并报告 这是技能文档默认推荐入口;coverage 因需要覆盖率文件和符号名不在其中 @@ -131,15 +136,21 @@ 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 coverage --coverage --symbol [--file ] [--source ] [--json] - euthyna deps [--repo ] [--lockfile ] (--dep | --all) [--json] - euthyna gate [--verify] [--cwd ] [--json] + euthyna audit [--base ] [--head ] [--repo ] [--pickaxe] [--no-origins] [--json] [--graph] [--out ] + euthyna history [--base ] [--head ] [--repo ] [--pickaxe] [--no-origins] [--json] [--graph] [--out ] + euthyna coverage --coverage --symbol [--file ] [--source ] [--json] [--out ] + euthyna deps [--repo ] [--lockfile ] (--dep | --all) [--json] [--out ] + euthyna gate [--verify] [--cwd ] [--json] [--out ] Language: Chinese by default. Pass --lang en or set the environment variable EUTHYNA_LANG=en for English output. +Global options: + --out write this run's output to a file instead of stdout (the content + follows --json / --graph). Always UTF-8 without a BOM; a write + failure exits 1 even when the measurement itself succeeded; + stderr carries one line naming the target path + Commands: audit the change-surface audit in one run: history + deps (every lockfile dependency enumerated), one merged report. The default entry point @@ -245,6 +256,24 @@ function asArray(value) { return Array.isArray(value) ? value : [value]; } +/** + * Write the `--out` artifact. Returns null on success, or a short failure + * reason the caller wraps in a localized usage error. + * + * UTF-8 without a BOM, deliberately: a shell redirect is how this used to be + * done, and this project's own record has PowerShell `>` writing UTF-16 and + * a BOM breaking `gh api --input` JSON — the file written here must be + * byte-clean for whatever reads it next. + */ +async function writeOutFile(outPath, content) { + try { + await writeFile(outPath, content, 'utf8'); + return null; + } catch (error) { + return `${error.code ?? error.message}: ${outPath}`; + } +} + async function runHistory(flags, lang) { const t = T(lang); const cwd = path.resolve(flags.repo ? String(flags.repo) : process.cwd()); @@ -761,6 +790,26 @@ async function runGate(flags, positional, lang) { }; } + // --out for gate's rendered channel: gate renders internally, so the file + // decision happens here rather than in main(). The --json channel returns a + // result.json and main() writes it out. + if (flags.out !== undefined) { + const outPath = path.resolve(String(flags.out)); + const lines = []; + renderGateReport(result, { write: (line) => lines.push(line), lang }); + const failure = await writeOutFile(outPath, `${lines.join('\n')}\n`); + if (failure) { + return { + exit: EXIT.USAGE, + error: t(`无法写入 --out 目标 ${failure}`, `cannot write the --out target ${failure}`) + }; + } + process.stderr.write( + safeTextLines(t(`输出已写入 ${outPath}`, `output written to ${outPath}`) + '\n') + ); + return { exit: downgraded > 0 ? EXIT.FLAGGED : EXIT.CLEAN, report: null }; + } + renderGateReport(result, { lang }); return { exit: downgraded > 0 ? EXIT.FLAGGED : EXIT.CLEAN, report: null }; } @@ -800,6 +849,18 @@ export async function main(argv = process.argv.slice(2)) { return EXIT.USAGE; } + // A bare `--out` records `true`, a repeated one accumulates into an array, + // and an empty string cannot name a file. Writing a file literally named + // "true" or "a,b" would be a silent trap, so all three are caller mistakes. + if (flags.out === true || flags.out === '' || Array.isArray(flags.out)) { + process.stderr.write( + safeTextLines( + t('--out 需要且只需要一个文件路径', '--out requires exactly one file path') + '\n' + ) + ); + return EXIT.USAGE; + } + if (!command || command === 'help' || flags.help) { process.stdout.write(helpText(lang)); return EXIT.CLEAN; @@ -841,6 +902,42 @@ export async function main(argv = process.argv.slice(2)) { return result.exit; } + // --out: the artifact a run would have printed goes to a file instead, and + // stdout stays silent for it. The exit code stays the measurement's — a + // flagged run that was written out must still exit 10 — except when the file + // could not be written: a caller who asked for an artifact and did not get + // one must not read the run as complete, so that is a usage error. + if (flags.out !== undefined) { + const outPath = path.resolve(String(flags.out)); + let content = null; + if (result.json) { + content = `${result.json}\n`; + } else if (flags.json && result.report) { + content = `${JSON.stringify(result.report, null, 2)}\n`; + } else if (flags.graph && result.report) { + content = `${safeTextLines(provenanceGraph(result.report))}\n`; + } else if (result.report) { + // renderReport applies the render boundary (safeText) per line before it + // calls write, so the collected lines are byte-identical to stdout. + const lines = []; + renderReport(result.report, { write: (line) => lines.push(line), lang }); + content = `${lines.join('\n')}\n`; + } + if (content !== null) { + const failure = await writeOutFile(outPath, content); + if (failure) { + process.stderr.write( + safeTextLines(t(`无法写入 --out 目标 ${failure}`, `cannot write the --out target ${failure}`) + '\n') + ); + return EXIT.USAGE; + } + process.stderr.write( + safeTextLines(t(`输出已写入 ${outPath}`, `output written to ${outPath}`) + '\n') + ); + return result.exit; + } + } + if (result.json) { process.stdout.write(`${result.json}\n`); return result.exit; diff --git a/test/cli.test.js b/test/cli.test.js index a572a60..9c0b12a 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -7,7 +7,7 @@ */ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { execFile } from 'node:child_process'; @@ -714,3 +714,101 @@ describe('--graph scope', () => { assert.equal(code, plain.code, '--graph draws the same run the JSON channel reports'); }); }); + +describe('--out', () => { + const GATE_REPORT = [ + 'BUG #1 TRUE POSITIVE — 命令注入:数据集名拼进 exec', + ' 门禁全部通过。证据:src/archive.js:42 (abc1234)', + ' 复现:node bench/exploits.js p3', + ' 可利用性:EASY', + ' 影响:以服务账号执行任意命令', + '' + ].join('\n'); + + async function depsRepo() { + const repo = await mkdtemp(path.join(tmpdir(), 'euthyna-out-')); + await writeFile( + path.join(repo, 'package-lock.json'), + npmLockfile([['left-pad', '1.3.0']]), + 'utf8' + ); + return repo; + } + + test('the JSON artifact is byte-identical to what stdout would have printed', async () => { + const repo = await depsRepo(); + const toStdout = await capture(['deps', '--repo', repo, '--dep', 'left-pad', '--json']); + const file = path.join(repo, 'report.json'); + const toFile = await capture(['deps', '--repo', repo, '--dep', 'left-pad', '--json', '--out', file]); + + assert.equal(toFile.code, toStdout.code); + assert.equal(toFile.out, '', 'stdout stays silent for the artifact'); + assert.equal(await readFile(file, 'utf8'), toStdout.out); + }); + + test('the rendered artifact is byte-identical to what stdout would have printed, without a BOM', async () => { + const repo = await depsRepo(); + const toStdout = await capture(['deps', '--repo', repo, '--dep', 'left-pad']); + const file = path.join(repo, 'report.txt'); + const toFile = await capture(['deps', '--repo', repo, '--dep', 'left-pad', '--out', file]); + + assert.equal(toFile.code, toStdout.code); + const raw = await readFile(file); // buffer, to see the actual bytes + assert.notEqual(raw[0], 0xef, 'no UTF-8 BOM'); + assert.notEqual(raw[0], 0xff, 'no UTF-16LE BOM'); + assert.notEqual(raw[0], 0xfe, 'no UTF-16BE BOM'); + assert.equal(raw.toString('utf8'), toStdout.out); + }); + + test('a flagged run keeps its measurement exit code', async () => { + const repo = await makeRepo(); + const base = await commitFiles(repo, 'fix: prevent authentication bypass (CVE-2024-1111)', { + 'src/a.js': 'if (!ok) throw new Error("denied");\n' + }); + await commitFiles(repo, 'refactor: drop the check', { 'src/a.js': 'doIt();\n' }); + + const file = path.join(repo, 'r.txt'); + const { code } = await capture(['history', '--repo', repo, '--base', base, '--out', file]); + assert.equal(code, EXIT.FLAGGED); + await readFile(file, 'utf8'); // the artifact exists + }); + + test('gate --out writes the rendered check, gate --json --out writes the JSON channel', async () => { + const repo = await depsRepo(); + const report = path.join(repo, 'findings.md'); + await writeFile(report, GATE_REPORT, 'utf8'); + + const file = path.join(repo, 'gate.txt'); + const rendered = await capture(['gate', report, '--out', file]); + assert.equal(rendered.code, EXIT.CLEAN); + assert.equal(rendered.out, ''); + assert.match(await readFile(file, 'utf8'), /BUG #1 TRUE POSITIVE/); + + const jsonFile = path.join(repo, 'gate.json'); + const asJson = await capture(['gate', report, '--json', '--out', jsonFile]); + assert.equal(asJson.code, EXIT.CLEAN); + assert.equal(asJson.out, ''); + assert.equal(JSON.parse(await readFile(jsonFile, 'utf8')).downgraded, 0); + }); + + test('a bare --out, a repeated --out and an empty path are usage errors', async () => { + const repo = await depsRepo(); + for (const argv of [ + ['deps', '--repo', repo, '--dep', 'left-pad', '--out'], + ['deps', '--repo', repo, '--dep', 'left-pad', '--out', 'a.txt', '--out', 'b.txt'], + ['deps', '--repo', repo, '--dep', 'left-pad', '--out', ''] + ]) { + const r = await capture(argv); + assert.equal(r.code, EXIT.USAGE, `expected usage error for ${argv.join(' ')}`); + assert.match(r.err, /--out/); + } + }); + + test('a write failure is a usage error, never a clean run', async () => { + const repo = await depsRepo(); + const missing = path.join(repo, 'no-such-dir', 'r.json'); + const r = await capture(['deps', '--repo', repo, '--dep', 'left-pad', '--json', '--out', missing]); + assert.equal(r.code, EXIT.USAGE); + assert.match(r.err, /no-such-dir/); + }); +}); From 22491ede9c0edb719e498e961016f02314c7db45 Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 5 Nov 2026 00:52:31 +0800 Subject: [PATCH 3/9] fix(history): the porcelain blame parser accepts sha256 object ids --- src/facts/history.js | 5 ++++- test/history.test.js | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/facts/history.js b/src/facts/history.js index af8fefc..c66b7f0 100644 --- a/src/facts/history.js +++ b/src/facts/history.js @@ -81,7 +81,10 @@ export async function blameRanges({ cwd, rev, file, ranges }) { // the wrong one produces a command that looks right and blames other lines. let lineNumber = null; for (const raw of out.split('\n')) { - const header = /^([0-9a-f]{40}) (\d+) (\d+)(?: (\d+))?$/.exec(raw); + // The object id is 40 hex (sha1) or 64 hex (sha256) — a repository picks + // its own format, and the attribution must survive either. Same split as + // mergeBase() in git.js. + const header = /^([0-9a-f]{40}|[0-9a-f]{64}) (\d+) (\d+)(?: (\d+))?$/.exec(raw); if (header) { current = header[1]; lineNumber = Number(header[3]); diff --git a/test/history.test.js b/test/history.test.js index 9c892f0..93e3f35 100644 --- a/test/history.test.js +++ b/test/history.test.js @@ -7,6 +7,9 @@ */ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { makeRepo, commitFiles, git } from './helpers.js'; import { @@ -548,6 +551,26 @@ describe('collectHistoryFacts against a real repository', () => { assert.match(refined[0].command, /-S' if \(!authorized\) return null;'/); assert.doesNotMatch(refined[0].command, /return null;' \.slice/); }); + + test('a sha256 repository still attributes its deleted lines', async () => { + // The porcelain blame parser must accept 64-hex object ids the way + // mergeBase() does: a repository picks its own object format, and a + // hash-length assumption would silently strip every attribution on it. + const dir = await mkdtemp(path.join(tmpdir(), 'euthyna-sha256-')); + await git(dir, ['init', '-q', '--object-format=sha256', '-b', 'main']); + await git(dir, ['config', 'user.email', 'test@example.com']); + await git(dir, ['config', 'user.name', 'Test Author']); + + const base = await commitFiles(dir, 'fix: prevent authentication bypass (CVE-2024-1111)', { + 'src/a.js': 'if (!ok) throw new Error("denied");\n' + }); + await commitFiles(dir, 'refactor: drop the check', { 'src/a.js': 'doIt();\n' }); + + const { facts } = await collectHistoryFacts({ cwd: dir, base, head: 'HEAD', origins: false }); + const security = facts.filter(f => f.detail?.classification === 'security'); + assert.equal(security.length, 1, 'the deleted check is attributed on a sha256 repository'); + assert.equal(security[0].evidence.commit, base); + }); }); describe('reintroduction detection', () => { From 870eaa1cb61202592f0f4651d521a708c80cf52d Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 5 Nov 2026 01:12:27 +0800 Subject: [PATCH 4/9] perf(history): blame deleted ranges in batches, one process per ~400 ranges --- bench/perf.js | 32 ++++++++--------- src/facts/history.js | 81 ++++++++++++++++++++++++++++++++++++-------- test/history.test.js | 69 ++++++++++++++++++++++++++++++++++++- 3 files changed, 150 insertions(+), 32 deletions(-) diff --git a/bench/perf.js b/bench/perf.js index b30234e..dcec591 100644 --- a/bench/perf.js +++ b/bench/perf.js @@ -3,24 +3,23 @@ /** * Scaling benchmark for the `history` producer. * - * The producer blames each deleted range sequentially - * (`src/facts/history.js`: one `git blame` per range), so its cost grows with - * the number of deleted ranges. This script builds synthetic repositories and - * measures the producer at increasing deletion sizes, so a change to batching - * or parallelization has a baseline to be judged against — and a reader can see - * at a glance whether the current shape is linear. + * The producer blames deleted ranges in batches (`src/facts/history.js`: one + * `git blame` process per ~400 ranges, one `-L` flag per range). This script + * builds synthetic repositories and measures the producer at increasing + * deletion sizes, so a change to the batching has a baseline to be judged + * against — and a reader can see at a glance how the shape moves. * * The deleted lines are interleaved (every other line in a region), which - * produces one single-line range per deletion — the worst case for a sequential - * blamer. The file size is held fixed while the deletion count grows, so the - * ms/deleted-line ratio isolates the sequential per-range cost from the - * (separate) cost of blaming a bigger file. Absolute numbers are - * machine-dependent; the ratio is the number that matters. + * produces one single-line range per deletion — the case that used to cost one + * process per range (measured ~48 ms/行 linear on this machine, 2000 ranges + * ≈ 97 s) and now costs one process per 400. The file size is held fixed while + * the deletion count grows. Absolute numbers are machine-dependent; the ratio + * is the number that matters. * * node bench/perf.js [--total 12000] [--min 250] [--max 4000] [--steps 5] * - * 默认档实测 4000 个删除区间约 80s(~21ms/区间,线性);全程 5 档 + 造仓约 - * 2-3 分钟。要快速看曲线就调小 --max/--total。 + * 分批后实测 2000 个删除区间约 3.7s(~1.9ms/行,次线性);全程 4 档 + 造仓约 + * 1-2 分钟。要快速看曲线就调小 --max/--total。 */ const fs = require('node:fs'); @@ -134,10 +133,9 @@ async function main() { } else if (spread < 1.5) { const biggest = rows[rows.length - 1]; console.log( - ' 每删除行的耗时基本恒定 —— 确认是顺序 blame 的线性缩放。' + - `实测 ${biggest.deletedLines} 个删除区间约 ${(biggest.ms / 1000).toFixed(1)}s,` + - '即真实 PR 里删几千个分散行会达到分钟级——这是当前实现的实际天花板。' + - '升级路径: 按文件批量 blame、或对单行区间做并发。' + ' 每删除行的耗时基本恒定 —— 线性缩放。' + + `实测 ${biggest.deletedLines} 个删除区间约 ${(biggest.ms / 1000).toFixed(1)}s。` + + '若回到分钟级,先查 blame 是否退化成了每区间一个进程。' ); } else if (ratios[ratios.length - 1] > ratios[0]) { console.log( diff --git a/src/facts/history.js b/src/facts/history.js index c66b7f0..4df4038 100644 --- a/src/facts/history.js +++ b/src/facts/history.js @@ -53,6 +53,39 @@ export function parseDeletedRanges(diffText) { return ranges; } +/** + * Sort by start and merge overlapping or adjacent ranges. + * + * `parseDeletedRanges` already yields disjoint ranges, so well-formed input + * comes back unchanged; the merge exists because an overlap would double-count + * the shared lines across two `-L` flags, and because merging neighbours keeps + * a batch's argv small. + */ +export function mergeRanges(ranges) { + const sorted = [...ranges].sort((a, b) => a.start - b.start || a.count - b.count); + const merged = []; + for (const range of sorted) { + const end = range.start + range.count - 1; + const last = merged[merged.length - 1]; + const lastEnd = last ? last.start + last.count - 1 : -Infinity; + if (last && range.start <= lastEnd + 1) { + last.count = Math.max(lastEnd, end) - last.start + 1; + } else { + merged.push({ start: range.start, count: range.count }); + } + } + return merged; +} + +/** + * Ranges blamed per `git blame` process. One process carries one `-L` flag per + * range, so the batch size is the process-count divider; 400 keeps the argv + * (≈14 bytes per flag) far under every platform's command-line limit while + * cutting the per-range process cost by that factor (baseline: bench/perf.js, + * measured ~48 ms per deleted range when each range was its own process). + */ +const BLAME_BATCH = 400; + /** * Attribute every line in `ranges` to the commit that last touched it, as of `rev`. * @@ -60,25 +93,22 @@ export function parseDeletedRanges(diffText) { * numbers rather than a bare count, because the fact has to carry a command a * reader can actually paste: `git blame -L 104,104` reproduces the claim, while * `git blame -L ` is a placeholder, not evidence. + * + * The multi-`-L` invocation is the same form the emitted reproduce commands + * already rely on (`git blame --porcelain -L a,b -L c,d -- `), so + * batching adds no new assumption about git — it only stops paying process + * startup per range. A batch git refuses (a range past EOF, a binary file) is + * retried range by range, so one bad range costs itself and not its neighbours. */ export async function blameRanges({ cwd, rev, file, ranges }) { const byCommit = new Map(); - // ponytail: 顺序 blame,每个区间一次 git 进程,耗时与删除区间数线性相关 - // (基准见 bench/perf.js)。若吞吐不满足要求:改成每文件一次 blame 覆盖 - // 全部区间,或对单行区间做有界并发。 - for (const range of ranges) { - const end = range.start + range.count - 1; - const out = await git( - ['blame', '--porcelain', '-L', `${range.start},${end}`, rev, '--', file], - { cwd, allowFailure: true } - ); - if (!out) continue; + // Porcelain emits ` []`. `origLine` + // is the line number in the commit that introduced the line, which is NOT + // where the line sits in the revision being blamed; `finalLine` is. Using + // the wrong one produces a command that looks right and blames other lines. + const parse = (out) => { let current = null; - // Porcelain emits ` []`. `origLine` - // is the line number in the commit that introduced the line, which is NOT - // where the line sits in the revision being blamed; `finalLine` is. Using - // the wrong one produces a command that looks right and blames other lines. let lineNumber = null; for (const raw of out.split('\n')) { // The object id is 40 hex (sha1) or 64 hex (sha256) — a repository picks @@ -97,6 +127,29 @@ export async function blameRanges({ cwd, rev, file, ranges }) { lineNumber++; } } + }; + + const blame = (batch) => + git( + // Ranges are {start, count}: the -L end is start+count-1, computed here. + // A `${r.end}` on a count-shaped range would hand git "undefined" and the + // swallowed failure would read as "no attribution" downstream. + ['blame', '--porcelain', ...batch.flatMap((r) => ['-L', `${r.start},${r.start + r.count - 1}`]), rev, '--', file], + { cwd, allowFailure: true } + ); + + const merged = mergeRanges(ranges); + for (let i = 0; i < merged.length; i += BLAME_BATCH) { + const batch = merged.slice(i, i + BLAME_BATCH); + const out = await blame(batch); + if (out) { + parse(out); + continue; + } + for (const range of batch) { + const single = await blame([range]); + if (single) parse(single); + } } return byCommit; } diff --git a/test/history.test.js b/test/history.test.js index 93e3f35..3d70a5c 100644 --- a/test/history.test.js +++ b/test/history.test.js @@ -18,7 +18,9 @@ import { securityRelevantLines, classifySubject, classifyCommit, - collectHistoryFacts + collectHistoryFacts, + blameRanges, + mergeRanges } from '../src/facts/history.js'; import { STATUS, KIND } from '../src/contract.js'; @@ -193,6 +195,71 @@ describe('classifyCommit', () => { }); }); +describe('mergeRanges', () => { + test('disjoint ranges come back unchanged; overlaps and neighbours collapse', () => { + assert.deepEqual(mergeRanges([{ start: 10, count: 2 }, { start: 1, count: 3 }]), [ + { start: 1, count: 3 }, + { start: 10, count: 2 } + ]); + assert.deepEqual(mergeRanges([{ start: 1, count: 5 }, { start: 5, count: 2 }]), [{ start: 1, count: 6 }]); + assert.deepEqual(mergeRanges([{ start: 1, count: 3 }, { start: 2, count: 8 }]), [{ start: 1, count: 9 }]); + }); +}); + +describe('blameRanges batching', () => { + test('a batched multi-range blame attributes exactly what per-range blames do', async () => { + const repo = await makeRepo(); + const body = [ + 'export function guard() {', + ' if (!authorized) return null;', + ' const secret = loadSecret();', + ' return secret;', + '}', + '' + ].join('\n'); + // The blame rev is the commit that HOLDS the five lines — the first one, + // not whatever HEAD is after the trim. + const base = await commitFiles(repo, 'fix: check authorization (CVE-2024-1234)', { 'src/a.js': body }); + await commitFiles(repo, 'refactor: trim the guard', { + 'src/a.js': ['export function guard() {', ' const secret = loadSecret();', ' return secret;', '}', ''].join('\n') + }); + + // Three separate single-line ranges in one file. + const ranges = [ + { start: 2, count: 1 }, + { start: 4, count: 1 }, + { start: 5, count: 1 } + ]; + + const batched = await blameRanges({ cwd: repo, rev: base, file: 'src/a.js', ranges }); + const perRange = new Map(); + for (const range of ranges) { + const single = await blameRanges({ cwd: repo, rev: base, file: 'src/a.js', ranges: [range] }); + for (const [hash, lines] of single) { + perRange.set(hash, [...(perRange.get(hash) ?? []), ...lines]); + } + } + const normalize = (m) => [...m.entries()].map(([h, l]) => [h.slice(0, 10), l.sort((a, b) => a - b)]).sort((a, b) => a[0].localeCompare(b[0])); + assert.deepEqual(normalize(batched), normalize(perRange)); + // The lines really are attributed, so the equivalence is about something. + assert.equal([...batched.values()].flat().sort().join(','), '2,4,5'); + }); + + test('a range past EOF fails alone; the rest of its batch is still attributed', async () => { + const repo = await makeRepo(); + await commitFiles(repo, 'feat: seed', { 'src/a.js': 'one\ntwo\nthree\n' }); + const base = (await git(repo, ['rev-parse', 'HEAD'])).trim(); + + const batched = await blameRanges({ + cwd: repo, + rev: base, + file: 'src/a.js', + ranges: [{ start: 2, count: 1 }, { start: 9999, count: 1 }] + }); + assert.deepEqual([...batched.entries()].map(([, l]) => l), [[2]]); + }); +}); + describe('collectHistoryFacts against a real repository', () => { test('attributes a deleted validation to the security-fix commit that introduced it', async () => { const repo = await makeRepo(); From e4788e456ec64139b849a0780deb4c3d66464a65 Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 5 Nov 2026 01:16:05 +0800 Subject: [PATCH 5/9] bench(deps): scaling baseline for the whole-manifest (--all) path --- bench/perf-deps.js | 126 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 bench/perf-deps.js diff --git a/bench/perf-deps.js b/bench/perf-deps.js new file mode 100644 index 0000000..c1c728d --- /dev/null +++ b/bench/perf-deps.js @@ -0,0 +1,126 @@ +'use strict'; + +/** + * Scaling benchmark for the `deps` producer's whole-manifest path. + * + * `euthyna audit` queries EVERY dependency the manifest names (the `--all` + * shape). The per-dep resolvers re-scan the manifest text for each dependency — + * `resolveNpm` iterates every `packages` key per dep, `resolveCargo` splits the + * whole file per dep, and each evidence line lookup walks the text prefix — so + * the whole-manifest cost may grow faster than the manifest itself. This script + * measures exactly that shape at increasing sizes, so a change to indexing has + * a baseline to be judged against, and a reader can see at a glance whether the + * per-dependency cost is flat or compounding. + * + * The manifests are synthetic but shape-real: npm v3 with scoped and unscoped + * names, Cargo.lock with [[package]] blocks. Absolute numbers are + * machine-dependent; the per-dependency ratio is the number that matters. + * + * node bench/perf-deps.js [--npm-sizes 500,1000,2000,4000] [--cargo-sizes 500,1000,2000] + * + * 索引化之前实测(本机):npm 每依赖 0.46 → 0.92 → 1.81 → 3.75 ms(清单每翻倍 + * 翻一倍,O(n²) 形状),cargo 0.27 → 0.91 ms/依赖 同形。升级路径:每运行建一次 + * name→条目索引,行号查换行位置表而不是逐字符走前缀。 + */ + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const args = process.argv.slice(2); +const opt = (name, fallback) => { + const i = args.indexOf(name); + return i === -1 ? fallback : args[i + 1]; +}; +const parseSizes = (raw) => + String(raw) + .split(',') + .map(Number); +const NPM_SIZES = parseSizes(opt('--npm-sizes', '500,1000,2000,4000')); +const CARGO_SIZES = parseSizes(opt('--cargo-sizes', '500,1000,2000')); + +function npmLockfile(n) { + const packages = { + '': { name: 'fixture', version: '1.0.0', dependencies: {} } + }; + for (let i = 0; i < n; i++) { + // Scoped names every seventh package: resolveNpm must handle both, and the + // nested-scope key form is what makes its split-by-node_modules non-trivial. + const name = i % 7 === 0 ? `@scope/pkg-${i}` : `pkg-${i}`; + packages[`node_modules/${name}`] = { + version: `${(i % 20) + 1}.${i % 9}.${i % 5}`, + resolved: `https://registry.example/${name}/-/${name}.tgz`, + integrity: `sha512-${'a'.repeat(40)}` + }; + } + return JSON.stringify({ name: 'fixture', lockfileVersion: 3, packages }, null, 2); +} + +function cargoLockfile(n) { + let text = '# This file is automatically @generated by Cargo.\nversion = 3\n\n'; + for (let i = 0; i < n; i++) { + text += `[[package]]\nname = "crate-${i}"\nversion = "1.${i % 10}.${i % 7}"\nsource = "registry+https://github.com/rust-lang/crates.io-index"\nchecksum = "${'b'.repeat(40)}"\n\n`; + } + return text; +} + +async function measure(label, file, type, text, size) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'euthyna-deps-perf-')); + fs.writeFileSync(path.join(dir, file), text); + try { + const { listDependencies, collectDependencyFacts } = await import('../src/facts/deps.js'); + const names = listDependencies(type, text); + const start = process.hrtime.bigint(); + const { measured } = await collectDependencyFacts({ cwd: dir, deps: names }); + const ms = Number(process.hrtime.bigint() - start) / 1e6; + if (!measured) throw new Error(`${label} ${size}: the producer could not measure its own fixture`); + process.stdout.write( + ` ${String(size).padStart(6)} 依赖 ${ms.toFixed(0).padStart(7)} ms (${(ms / size).toFixed(2)} ms/依赖, 清单 ${(text.length / 1024 / 1024).toFixed(1)} MB)\n` + ); + return ms / size; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +async function main() { + if (!NPM_SIZES.every(Number.isFinite) || !CARGO_SIZES.every(Number.isFinite)) { + throw new Error('--npm-sizes/--cargo-sizes 必须是逗号分隔的整数'); + } + + const results = []; + for (const size of NPM_SIZES) { + const ratio = await measure('npm', 'package-lock.json', 'npm', npmLockfile(size), size); + results.push({ kind: 'npm', size, ratio }); + } + for (const size of CARGO_SIZES) { + const ratio = await measure('cargo', 'Cargo.lock', 'cargo', cargoLockfile(size), size); + results.push({ kind: 'cargo', size, ratio }); + } + + for (const kind of ['npm', 'cargo']) { + const rows = results.filter((r) => r.kind === kind); + if (rows.length < 2) continue; + const spread = Math.max(...rows.map((r) => r.ratio)) / Math.min(...rows.map((r) => r.ratio)); + const first = rows[0]; + const last = rows[rows.length - 1]; + console.log(`\n结论 (${kind}):`); + if (last.ratio > first.ratio * 2) { + console.log( + ' 每依赖成本在随清单变大 —— 超线性:每依赖重扫全文的形状在复合。' + + '真实的大仓(数千依赖的 package-lock)会在这里到分钟级;' + + '升级路径:每次运行建一次 name→条目索引,行号用换行位置表而不是逐字符走前缀。' + ); + } else if (last.ratio < first.ratio * 0.5) { + console.log(' 每依赖成本在变小 —— 次线性,固定开销在小规模档位里占比较高。'); + } else { + console.log(' 每依赖成本基本恒定 —— 线性缩放,当前形状没有明显天花板。'); + } + } + console.log(' 绝对数值依赖机器;横向比较 ms/依赖 而非 ms。'); +} + +main().catch((error) => { + console.error(`bench/perf-deps.js: ${error.message}`); + process.exitCode = 1; +}); From effc4308199fc4c6220b57c5a15b733825e3ecaa Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 5 Nov 2026 01:21:10 +0800 Subject: [PATCH 6/9] perf(deps): build the name index and line table once per manifest --- bench/perf-deps.js | 12 +++-- src/facts/deps.js | 130 ++++++++++++++++++++++++++++++++------------- 2 files changed, 99 insertions(+), 43 deletions(-) diff --git a/bench/perf-deps.js b/bench/perf-deps.js index c1c728d..e57f3e2 100644 --- a/bench/perf-deps.js +++ b/bench/perf-deps.js @@ -19,8 +19,10 @@ * node bench/perf-deps.js [--npm-sizes 500,1000,2000,4000] [--cargo-sizes 500,1000,2000] * * 索引化之前实测(本机):npm 每依赖 0.46 → 0.92 → 1.81 → 3.75 ms(清单每翻倍 - * 翻一倍,O(n²) 形状),cargo 0.27 → 0.91 ms/依赖 同形。升级路径:每运行建一次 - * name→条目索引,行号查换行位置表而不是逐字符走前缀。 + * 翻一倍,O(n²) 形状),cargo 0.27 → 0.91 ms/依赖 同形。 + * 索引化之后实测(同机):npm 0.04 → 0.09 ms/依赖,cargo 恒定 ~0.05 —— 4000 依赖 + * 从 15.0s 降到 0.36s。npm 残留的轻微超线性来自每个命中一次的原生 indexOf 证据 + * 锚定(O(文件) 但字节速度),绝对值已不构成实际天花板。 */ const fs = require('node:fs'); @@ -107,9 +109,9 @@ async function main() { console.log(`\n结论 (${kind}):`); if (last.ratio > first.ratio * 2) { console.log( - ' 每依赖成本在随清单变大 —— 超线性:每依赖重扫全文的形状在复合。' + - '真实的大仓(数千依赖的 package-lock)会在这里到分钟级;' + - '升级路径:每次运行建一次 name→条目索引,行号用换行位置表而不是逐字符走前缀。' + ' 每依赖成本在随清单变大 —— 超线性。若绝对值回到秒级,' + + '先查每依赖是否又在重扫全文(name→条目索引是否还在),' + + '以及证据行锚定的 indexOf 是否回到了逐字符的前缀遍历。' ); } else if (last.ratio < first.ratio * 0.5) { console.log(' 每依赖成本在变小 —— 次线性,固定开销在小规模档位里占比较高。'); diff --git a/src/facts/deps.js b/src/facts/deps.js index 83f1c57..6e1a2cc 100644 --- a/src/facts/deps.js +++ b/src/facts/deps.js @@ -44,19 +44,38 @@ const UNSUPPORTED = [ 'bun.lock' ]; -/** 1-indexed line of the first line containing `needle`, or null. */ -function lineOf(text, needle) { - return lineOfAt(text, text.indexOf(needle)); +/** + * A line-number lookup over a fixed text: newline offsets collected once, + * queried by binary search. + * + * It answers exactly what counting the newlines before an index answers — the + * table just does not re-count. That matters on the whole-manifest path: the + * prefix walk was O(prefix) per evidence lookup, and with every dependency in + * the manifest queried (`euthyna audit`), the whole run compounded + * quadratically (bench/perf-deps.js measured the per-dependency cost doubling + * with each doubling of the manifest). Build once per file, query per hit. + */ +function makeLineTable(text) { + const starts = [0]; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10) starts.push(i + 1); + } + return (index) => { + if (index < 0 || index >= text.length) return null; + let lo = 0; + let hi = starts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (starts[mid] <= index) lo = mid; + else hi = mid - 1; + } + return lo + 1; + }; } -/** 1-indexed line of the absolute text index, or null when it is not found. */ -function lineOfAt(text, index) { - if (index < 0) return null; - let line = 1; - for (let i = 0; i < index; i++) { - if (text[i] === '\n') line++; - } - return line; +/** 1-indexed line of the first line containing `needle`, or null when absent. */ +function lineOf(text, needle, lineAt) { + return lineAt(text.indexOf(needle)); } /** @@ -65,33 +84,53 @@ function lineOfAt(text, index) { * v2/v3: `packages` maps "node_modules/" (possibly nested, and possibly * scoped "@scope/name") to metadata; the package name is the path segment after * the last "node_modules/". v1: a recursive `dependencies` tree. + * + * Called once per queried dependency — and `euthyna audit` queries every + * dependency — so the name→entries indexes are built once per file and the + * per-dep work is a Map lookup, not a rescan of every key. */ -function resolveNpm(root, dep, text) { - const found = []; +function makeNpmResolver(root, text, lineAt) { + const found = (dep) => { + const hits = []; + for (const { version, key } of byKey.get(dep) ?? []) { + hits.push({ version, location: key, line: lineOf(text, `"${key}":`, lineAt) }); + } + for (const version of byName.get(dep) ?? []) { + // Anchor at this dep's own block: a same-version string earlier in the + // file (some other package) must not hijack the evidence pointer. + const blockAt = text.indexOf(`"${dep}":`); + const verAt = blockAt < 0 ? -1 : text.indexOf(`"version": "${version}"`, blockAt); + hits.push({ + version, + location: `dependencies.${dep}`, + line: lineAt(verAt) + }); + } + return hits; + }; + const byKey = new Map(); if (root.packages && typeof root.packages === 'object') { for (const [key, meta] of Object.entries(root.packages)) { if (!key.includes('node_modules/')) continue; if (!meta || typeof meta !== 'object' || !meta.version) continue; - if (key.split('node_modules/').pop() !== dep) continue; - found.push({ version: meta.version, location: key, line: lineOf(text, `"${key}":`) }); + const name = key.split('node_modules/').pop(); + if (!name) continue; + const list = byKey.get(name) ?? []; + list.push({ version: meta.version, key }); + byKey.set(name, list); } } + const byName = new Map(); if (root.dependencies && typeof root.dependencies === 'object') { const walk = (tree) => { for (const [name, meta] of Object.entries(tree)) { if (!meta || typeof meta !== 'object') continue; - if (name === dep && meta.version) { - // Anchor at this dep's own block: a same-version string earlier in the - // file (some other package) must not hijack the evidence pointer. - const blockAt = text.indexOf(`"${name}":`); - const verAt = blockAt < 0 ? -1 : text.indexOf(`"version": "${meta.version}"`, blockAt); - found.push({ - version: meta.version, - location: `dependencies.${name}`, - line: lineOfAt(text, verAt) - }); + if (meta.version) { + const list = byName.get(name) ?? []; + list.push(meta.version); + byName.set(name, list); } if (meta.dependencies && typeof meta.dependencies === 'object') walk(meta.dependencies); } @@ -102,25 +141,39 @@ function resolveNpm(root, dep, text) { return found; } -/** Cargo.lock: [[package]] blocks with `name = "..."` and `version = "..."`. */ -function resolveCargo(text, dep) { - const found = []; +/** + * Cargo.lock: [[package]] blocks with `name = "..."` and `version = "..."`. + * Blocks are parsed once per file (per-dep splits of the whole text were the + * measured quadratic term on this format, bench/perf-deps.js). + */ +function makeCargoResolver(text, lineAt) { + const byName = new Map(); for (const block of text.split('[[package]]')) { const name = /^\s*name\s*=\s*"([^"]+)"/m.exec(block); const version = /^\s*version\s*=\s*"([^"]+)"/m.exec(block); - if (!name || !version || name[1] !== dep) continue; + if (!name || !version) continue; + const list = byName.get(name[1]) ?? []; + list.push(version[1]); + byName.set(name[1], list); + } + + return (dep) => { + const versions = byName.get(dep); + if (!versions) return []; // Package names are unique in a Cargo.lock, so anchoring the version line // at this dep's own name block keeps the pointer on this crate even when a // different crate earlier in the file pins the same version string. const nameAt = text.indexOf(`name = "${dep}"`); - const verAt = nameAt < 0 ? -1 : text.indexOf(`version = "${version[1]}"`, nameAt); - found.push({ - version: version[1], - location: `[[package]] ${dep}`, - line: lineOfAt(text, verAt) + if (nameAt < 0) return []; + return versions.map((version) => { + const verAt = text.indexOf(`version = "${version}"`, nameAt); + return { + version, + location: `[[package]] ${dep}`, + line: lineAt(verAt) + }; }); - } - return found; + }; } /** go.mod require lines, single or inside a require (...) block. */ @@ -152,6 +205,7 @@ function resolveGo(text, dep) { */ function makeResolver(type, text, lang = DEFAULT_LANG) { const t = T(lang); + const lineAt = makeLineTable(text); if (type === 'npm') { const root = JSON.parse(text); // throws on malformed JSON // A syntactically valid but structurally empty file (e.g. `{}`) is not a @@ -169,7 +223,7 @@ function makeResolver(type, text, lang = DEFAULT_LANG) { ) ); } - return (dep) => resolveNpm(root, dep, text); + return makeNpmResolver(root, text, lineAt); } if (type === 'cargo') { if (!/\[\[package\]\]|^version\s*=/m.test(text)) { @@ -180,7 +234,7 @@ function makeResolver(type, text, lang = DEFAULT_LANG) { ) ); } - return (dep) => resolveCargo(text, dep); + return makeCargoResolver(text, lineAt); } if (type === 'go') { if (!/^module\s+\S+/m.test(text)) { From ec10bac4df0bc9beb86a39823fbae3a79eef3322 Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 5 Nov 2026 01:24:56 +0800 Subject: [PATCH 7/9] docs(agents): record --out, the blame batching, the deps index, and the suite counts --- AGENTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5d81e9c..c02159a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -760,10 +760,10 @@ is faithful — that is a reading job, and a mechanical stand-in would only manu | Path | What it is | |---|---| -| `bin/` + `src/` | **Fact producers** (zero-dependency Node CLI). `src/contract.js` is the contract in code; `src/facts/history.js`, `src/facts/coverage.js` and `src/facts/deps.js` are the three measurements (`history` has `--origins` to chase the first introducer and classifies by message + deleted-line + diff, **on by default since `euthyna audit`**; `coverage.js` reads **five** formats — c8/V8 JSON, classic istanbul JSON (jest's default provider, nyc: the same `fnMap`/`f` shape, so the symbol locator shares the c8 code path), coverage.py JSON (format 3), Go's text `-coverprofile` **and lcov tracefiles** (the Go profile needs `--source `: the profile carries no function names, so the producer parses top-level `func` ranges from the gofmt'd source; a file absent from the profile is notEvaluated, never "never executed"; the lcov path needs no `--source` because `FN:` carries the names — they are mangled, and the producer matches a `module::function` query against the `` tail segments of Rust v0 mangling (what `-C instrument-coverage` implies), since no demangler exists on this machine — each segment is anchored at a boundary and never inside a longer `` pair, so a query for `get` does not answer for `cfg_3get`; see `docs/fact-contract.md` §6.2 for the measured record, including that inlining was measured **not** to flatten a called function to 0); `deps.js` reads package-lock.json / Cargo.lock / go.mod, **located from the repository root only** — a monorepo sub-package lock is measured only by `deps --lockfile `, and `audit` does not notice its absence, which `docs/fact-contract.md` §6.3 records with the numbers from the probe). `audit` compares the manifest it measured with the one at `--head` and records `manifest-not-head` when the content differs or the file is absent there. `history` and `audit` also accept **no `--base` at all**: the range is then inferred as `merge-base(, 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/` | 326 tests via `node --test` (measured 2026-10-21: +2 over the 324 that `0.9.0` shipped — `test/hermes-plugin.test.js` gained the case that pins the usage text naming every dispatched command, `test/i18n.test.js` the one that pins the producer name printed once in the header; that 324 was +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` | +| `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. Every command takes `--out ` (added 2026-11-05): the run's artifact goes to a UTF-8-no-BOM file instead of stdout, the exit code stays the measurement's, and a write failure is a usage error rather than a clean run — it exists because a PowerShell `>` redirect writes UTF-16 (pitfall table above). `blameRanges` batches its `-L` flags, one process per ~400 ranges (2026-11-05; was one process per range at ~48 ms/行, now ~1.9 ms/行 sublinear — bench/perf.js), and the porcelain header accepts sha256's 64-hex ids the way `mergeBase` does. `deps` builds its name index and line table once per manifest (2026-11-05; was O(deps × manifest) — bench/perf-deps.js measured 4000 deps at 15.0s → 0.36s) | +| `test/` | 336 tests via `node --test` (measured 2026-11-05: +10 over the 326 that `0.9.1` shipped — six `--out` cases in `test/cli.test.js`, a sha256-repo attribution case and the `blameRanges`/`mergeRanges` batching cases in `test/history.test.js`; 0.9.1 was +2 over the 324 that `0.9.0` shipped — `test/hermes-plugin.test.js` gained the case that pins the usage text naming every dispatched command, `test/i18n.test.js` the one that pins the producer name printed once in the header; that 324 was +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` | +| `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, `perf-deps.js` the deps whole-manifest (`--all`) one. 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 | | `docs/fact-contract.md` + `-zh` | The measurement ↔ adjudication interface. The project's core design artefact | | `docs/dsh-stop-gate.md` + `-zh` | Hook-gate facts, with reproduction | From e46b56c7c4333939ca0cd7bcd8dfbe09fd737b54 Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 5 Nov 2026 07:15:40 +0800 Subject: [PATCH 8/9] =?UTF-8?q?docs(ci):=20the=20artifact=20recipe=20is=20?= =?UTF-8?q?blocking=20=E2=80=94=20drop=20continue-on-error=20from=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ci-integration-zh.md | 1 - docs/ci-integration.md | 1 - 2 files changed, 2 deletions(-) diff --git a/docs/ci-integration-zh.md b/docs/ci-integration-zh.md index 7be80ad..e92e1a7 100644 --- a/docs/ci-integration-zh.md +++ b/docs/ci-integration-zh.md @@ -94,7 +94,6 @@ audit 自己的。 ```yaml - name: 变更面审计 run: npx --yes euthyna@latest audit --base "origin/${{ github.base_ref }}" --head HEAD --out audit-report.txt - continue-on-error: true id: audit - uses: actions/upload-artifact@v4 if: always() diff --git a/docs/ci-integration.md b/docs/ci-integration.md index 6184798..3767ec3 100644 --- a/docs/ci-integration.md +++ b/docs/ci-integration.md @@ -99,7 +99,6 @@ artifact goes straight to the file and the step's status is still the audit's ow ```yaml - name: Change-surface audit run: npx --yes euthyna@latest audit --base "origin/${{ github.base_ref }}" --head HEAD --out audit-report.txt - continue-on-error: true id: audit - uses: actions/upload-artifact@v4 if: always() From ebc3ff1bf8843a6ea0bf72a4e59e909c3242f838 Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 5 Nov 2026 07:15:43 +0800 Subject: [PATCH 9/9] docs(perf): attribute the measured tier to the invocation that produced it --- bench/perf.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bench/perf.js b/bench/perf.js index dcec591..3567e64 100644 --- a/bench/perf.js +++ b/bench/perf.js @@ -18,8 +18,9 @@ * * node bench/perf.js [--total 12000] [--min 250] [--max 4000] [--steps 5] * - * 分批后实测 2000 个删除区间约 3.7s(~1.9ms/行,次线性);全程 4 档 + 造仓约 - * 1-2 分钟。要快速看曲线就调小 --max/--total。 + * 分批后实测(--total 8000 --max 2000 --steps 4):2000 个删除区间约 3.7s + * (~1.9ms/行,次线性)。默认全程 5 档 + 造仓约 2-3 分钟(估算,未逐档实测)。 + * 要快速看曲线就调小 --max/--total。 */ const fs = require('node:fs');