euthyna 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -99,7 +99,7 @@ node <euthyna 仓库>/bin/euthyna.js history --base <改前版本> --no-origins
99
99
  ### 输出长什么样(真实捕获)
100
100
 
101
101
  ```
102
- euthyna euthyna-history — 被删除代码的来源归属与安全分类
102
+ euthyna-history — 被删除代码的来源归属与安全分类
103
103
  目标: D:/axe-core/axe-core
104
104
  范围: 8390a5a9…..397f5fce…
105
105
 
@@ -243,7 +243,7 @@ node <euthyna 仓库>/bin/euthyna.js coverage `
243
243
  ### 输出长什么样(真实捕获,一次查询命中两种结果)
244
244
 
245
245
  ```
246
- euthyna euthyna-coverage — 符号调用计数(只能证伪)
246
+ euthyna-coverage — 符号调用计数(只能证伪)
247
247
 
248
248
  已确证 (1)
249
249
  • 符号 cleanup 在本次测试运行中一次都没有被调用(调用计数为 0)
@@ -89,7 +89,7 @@ When the fork point is uncertain, use the result of `git merge-base <target bran
89
89
  ### What the output looks like (real capture)
90
90
 
91
91
  ```
92
- euthyna euthyna-history — attribution and security classification of deleted code
92
+ euthyna-history — attribution and security classification of deleted code
93
93
  Target: D:/axe-core/axe-core
94
94
  Range: 8390a5a9…..397f5fce…
95
95
 
@@ -217,7 +217,7 @@ node <euthyna repo>/bin/euthyna.js coverage `
217
217
  ### What the output looks like (real capture; one query hitting both result kinds)
218
218
 
219
219
  ```
220
- euthyna euthyna-coverage — symbol invocation counts (can only falsify)
220
+ euthyna-coverage — symbol invocation counts (can only falsify)
221
221
 
222
222
  Established (1)
223
223
  • Symbol cleanup was never invoked in this test run (invocation count 0) — any behavior depending on it was never exercised
package/README.md CHANGED
@@ -213,6 +213,11 @@ removed and are now being added back. Origin tracing is on by default (deleted l
213
213
  are attributed to the commit that first introduced their content); `--no-origins`
214
214
  falls back to blame's last-modifier attribution, which is faster.
215
215
 
216
+ Every command also takes `--out <file>`: the report goes to a UTF-8 (no BOM) file
217
+ instead of stdout — the CI-friendly way to keep the artifact, and the exit code
218
+ contract is unchanged. The PR-gate recipe in [docs/ci-integration.md](docs/ci-integration.md)
219
+ uses it.
220
+
216
221
  `--graph` on `audit` or `history` draws what `history` already computed — the range, the commit
217
222
  that last modified each deleted line, and the commit that first introduced its content — as
218
223
  Mermaid source, renderable straight into a GitHub comment. It measures nothing new and never
package/__init__.py CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The CLI (``bin/euthyna.js``, installed by ``npm install -g euthyna``) produces
4
4
  the deterministic facts; this plugin registers ``/euthyna`` so a user can invoke
5
- the four CLI subcommands from inside a Hermes session without leaving chat. The
5
+ the CLI's subcommands from inside a Hermes session without leaving chat. The
6
6
  handler shells out to the real CLI and returns its output verbatim — it does not
7
7
  reimplement any measurement, and it injects nothing into the conversation.
8
8
 
@@ -15,19 +15,27 @@ CLI prints comes back as the command result.
15
15
  import shutil
16
16
  import subprocess
17
17
 
18
- # Ponytail: one flat dict, not a command framework — there are exactly four
19
- # subcommands and the CLI already owns usage/exit codes.
18
+ # The subcommands /euthyna reaches. This is the single source the guard in
19
+ # test/hermes-plugin.test.js compares against the CLI's own dispatch, so a new
20
+ # command there is red here until it is wired — which is how `audit` (the
21
+ # entry point since 0.4.0) went seven releases unnoticed behind a hand-written
22
+ # list of four.
23
+ _FORWARDS = ("audit", "coverage", "deps", "gate", "help", "history")
24
+
20
25
  _USAGE = """euthyna slash command — usage:
21
26
 
27
+ /euthyna audit <path> [<path>...] history + dependency surface in one run
22
28
  /euthyna history <path> [<path> <rev>...] deleted-code provenance & security classification
23
29
  /euthyna coverage <path> symbol invocation counts from a coverage run
24
30
  /euthyna deps <path> dependency pinned versions from lockfiles
25
31
  /euthyna gate <report> [--verify] validate an audit report against the six gates
32
+ /euthyna help the CLI's own usage, with every flag
26
33
 
27
34
  Args after the subcommand are passed to the CLI unchanged. Requires the CLI:
28
35
  npm install -g euthyna (Node >= 20)."""
29
36
 
30
37
 
38
+
31
39
  def _find_cli():
32
40
  """Return the absolute path of the euthyna CLI, or None if not installed."""
33
41
  for name in ("euthyna", "euthyna.cmd", "euthyna.exe"):
@@ -40,10 +48,13 @@ def _find_cli():
40
48
  def _run(raw_args: str):
41
49
  """Handle /euthyna: forward <subcommand> <args...> to the CLI, return its output."""
42
50
  argv = raw_args.split()
43
- if not argv or argv[0] in ("help", "-h", "--help"):
51
+ # `-h`/`--help` print this command's own quick usage; bare `help` is forwarded
52
+ # so the flag list has exactly one owner — the CLI's help, which the skill and
53
+ # the docs both quote.
54
+ if not argv or argv[0] in ("-h", "--help"):
44
55
  return _USAGE
45
56
  sub, rest = argv[0], argv[1:]
46
- if sub not in ("history", "coverage", "deps", "gate"):
57
+ if sub not in _FORWARDS:
47
58
  return f"unknown subcommand: {sub}\n\n{_USAGE}"
48
59
 
49
60
  cli = _find_cli()
@@ -73,8 +84,8 @@ def register(ctx):
73
84
  ctx.register_command(
74
85
  "euthyna",
75
86
  handler=_run,
76
- description="euthyna security audit: run history/coverage/deps/gate fact producers",
77
- args_hint="<history|coverage|deps|gate> <args…>",
87
+ description="euthyna security audit: drive the fact-producing CLI (audit is the single entry point)",
88
+ args_hint="<audit|history|coverage|deps|gate|help> <args…>",
78
89
  )
79
90
 
80
91
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "euthyna",
3
- "version": "0.9.0",
4
- "description": "Deterministic fact producers for security auditing: which deleted code came from a security fix, and which changed symbols no test ever executed.",
3
+ "version": "0.10.0",
4
+ "description": "Audit framework for AI coding agents: produces the deterministic facts a model cannot compute — deleted-code provenance, symbol invocations, dependency pins — and adjudicates every claim against six gates, downgrading what cannot produce evidence to an observation. Not a scanner.",
5
5
  "type": "module",
6
6
  "main": "./plugin/index.js",
7
7
  "bin": {
package/plugin.yaml CHANGED
@@ -1,6 +1,6 @@
1
1
  name: euthyna
2
- version: 0.9.0
3
- description: euthyna audit discipline — slash commands that drive the euthyna fact-producing CLI (history / coverage / deps / gate)
2
+ version: 0.10.0
3
+ description: euthyna audit discipline — the /euthyna slash command driving the euthyna CLI, whose single entry point is `euthyna audit`
4
4
  license: Apache-2.0
5
5
  homepage: https://github.com/slow-stack/euthyna
6
6
  tags:
package/src/cli.js CHANGED
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import process from 'node:process';
17
17
  import path from 'node:path';
18
- import { readFile } from 'node:fs/promises';
18
+ import { readFile, writeFile } from 'node:fs/promises';
19
19
  import { collectHistoryFacts } from './facts/history.js';
20
20
  import { collectCoverageFacts } from './facts/coverage.js';
21
21
  import { collectDependencyFacts, listDependencies, detectManifest, manifestTypeForFile } from './facts/deps.js';
@@ -72,14 +72,19 @@ euthyna —— 给 AI 编码 agent 用的确定性事实产出器
72
72
  它不是扫描器。它只回答两个模型算不准的问题,并把答案写成带证据的事实。
73
73
 
74
74
  用法:
75
- euthyna audit [--base <rev>] [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json] [--graph]
76
- euthyna history [--base <rev>] [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json] [--graph]
77
- euthyna coverage --coverage <file> --symbol <name> [--file <path>] [--source <dir>] [--json]
78
- euthyna deps [--repo <dir>] [--lockfile <file>] (--dep <name> | --all) [--json]
79
- euthyna gate <报告文件> [--verify] [--cwd <dir>] [--json]
75
+ euthyna audit [--base <rev>] [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json] [--graph] [--out <file>]
76
+ euthyna history [--base <rev>] [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json] [--graph] [--out <file>]
77
+ euthyna coverage --coverage <file> --symbol <name> [--file <path>] [--source <dir>] [--json] [--out <file>]
78
+ euthyna deps [--repo <dir>] [--lockfile <file>] (--dep <name> | --all) [--json] [--out <file>]
79
+ euthyna gate <报告文件> [--verify] [--cwd <dir>] [--json] [--out <file>]
80
80
 
81
81
  语言: 默认中文。加 --lang en 或设环境变量 EUTHYNA_LANG=en 切换英文输出。
82
82
 
83
+ 全局选项:
84
+ --out <file> 把本次输出写入文件而非 stdout(内容随 --json / --graph 切换)。
85
+ 恒为 UTF-8 无 BOM;写失败按用法错误退出(1),即使测量本身成功;
86
+ 写入后 stderr 提示一行目标路径
87
+
83
88
  命令:
84
89
  audit 一次跑完变更面审计:history + deps(枚举 lockfile 全部依赖),合并报告
85
90
  这是技能文档默认推荐入口;coverage 因需要覆盖率文件和符号名不在其中
@@ -131,15 +136,21 @@ It is not a scanner. It answers only the two questions a model cannot compute
131
136
  reliably, and writes the answers as facts with evidence.
132
137
 
133
138
  Usage:
134
- euthyna audit [--base <rev>] [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json] [--graph]
135
- euthyna history [--base <rev>] [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json] [--graph]
136
- euthyna coverage --coverage <file> --symbol <name> [--file <path>] [--source <dir>] [--json]
137
- euthyna deps [--repo <dir>] [--lockfile <file>] (--dep <name> | --all) [--json]
138
- euthyna gate <report file> [--verify] [--cwd <dir>] [--json]
139
+ euthyna audit [--base <rev>] [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json] [--graph] [--out <file>]
140
+ euthyna history [--base <rev>] [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json] [--graph] [--out <file>]
141
+ euthyna coverage --coverage <file> --symbol <name> [--file <path>] [--source <dir>] [--json] [--out <file>]
142
+ euthyna deps [--repo <dir>] [--lockfile <file>] (--dep <name> | --all) [--json] [--out <file>]
143
+ euthyna gate <report file> [--verify] [--cwd <dir>] [--json] [--out <file>]
139
144
 
140
145
  Language: Chinese by default. Pass --lang en or set the environment variable
141
146
  EUTHYNA_LANG=en for English output.
142
147
 
148
+ Global options:
149
+ --out <file> write this run's output to a file instead of stdout (the content
150
+ follows --json / --graph). Always UTF-8 without a BOM; a write
151
+ failure exits 1 even when the measurement itself succeeded;
152
+ stderr carries one line naming the target path
153
+
143
154
  Commands:
144
155
  audit the change-surface audit in one run: history + deps (every lockfile
145
156
  dependency enumerated), one merged report. The default entry point
@@ -245,6 +256,24 @@ function asArray(value) {
245
256
  return Array.isArray(value) ? value : [value];
246
257
  }
247
258
 
259
+ /**
260
+ * Write the `--out` artifact. Returns null on success, or a short failure
261
+ * reason the caller wraps in a localized usage error.
262
+ *
263
+ * UTF-8 without a BOM, deliberately: a shell redirect is how this used to be
264
+ * done, and this project's own record has PowerShell `>` writing UTF-16 and
265
+ * a BOM breaking `gh api --input` JSON — the file written here must be
266
+ * byte-clean for whatever reads it next.
267
+ */
268
+ async function writeOutFile(outPath, content) {
269
+ try {
270
+ await writeFile(outPath, content, 'utf8');
271
+ return null;
272
+ } catch (error) {
273
+ return `${error.code ?? error.message}: ${outPath}`;
274
+ }
275
+ }
276
+
248
277
  async function runHistory(flags, lang) {
249
278
  const t = T(lang);
250
279
  const cwd = path.resolve(flags.repo ? String(flags.repo) : process.cwd());
@@ -761,6 +790,26 @@ async function runGate(flags, positional, lang) {
761
790
  };
762
791
  }
763
792
 
793
+ // --out for gate's rendered channel: gate renders internally, so the file
794
+ // decision happens here rather than in main(). The --json channel returns a
795
+ // result.json and main() writes it out.
796
+ if (flags.out !== undefined) {
797
+ const outPath = path.resolve(String(flags.out));
798
+ const lines = [];
799
+ renderGateReport(result, { write: (line) => lines.push(line), lang });
800
+ const failure = await writeOutFile(outPath, `${lines.join('\n')}\n`);
801
+ if (failure) {
802
+ return {
803
+ exit: EXIT.USAGE,
804
+ error: t(`无法写入 --out 目标 ${failure}`, `cannot write the --out target ${failure}`)
805
+ };
806
+ }
807
+ process.stderr.write(
808
+ safeTextLines(t(`输出已写入 ${outPath}`, `output written to ${outPath}`) + '\n')
809
+ );
810
+ return { exit: downgraded > 0 ? EXIT.FLAGGED : EXIT.CLEAN, report: null };
811
+ }
812
+
764
813
  renderGateReport(result, { lang });
765
814
  return { exit: downgraded > 0 ? EXIT.FLAGGED : EXIT.CLEAN, report: null };
766
815
  }
@@ -800,6 +849,18 @@ export async function main(argv = process.argv.slice(2)) {
800
849
  return EXIT.USAGE;
801
850
  }
802
851
 
852
+ // A bare `--out` records `true`, a repeated one accumulates into an array,
853
+ // and an empty string cannot name a file. Writing a file literally named
854
+ // "true" or "a,b" would be a silent trap, so all three are caller mistakes.
855
+ if (flags.out === true || flags.out === '' || Array.isArray(flags.out)) {
856
+ process.stderr.write(
857
+ safeTextLines(
858
+ t('--out 需要且只需要一个文件路径', '--out requires exactly one file path') + '\n'
859
+ )
860
+ );
861
+ return EXIT.USAGE;
862
+ }
863
+
803
864
  if (!command || command === 'help' || flags.help) {
804
865
  process.stdout.write(helpText(lang));
805
866
  return EXIT.CLEAN;
@@ -841,6 +902,42 @@ export async function main(argv = process.argv.slice(2)) {
841
902
  return result.exit;
842
903
  }
843
904
 
905
+ // --out: the artifact a run would have printed goes to a file instead, and
906
+ // stdout stays silent for it. The exit code stays the measurement's — a
907
+ // flagged run that was written out must still exit 10 — except when the file
908
+ // could not be written: a caller who asked for an artifact and did not get
909
+ // one must not read the run as complete, so that is a usage error.
910
+ if (flags.out !== undefined) {
911
+ const outPath = path.resolve(String(flags.out));
912
+ let content = null;
913
+ if (result.json) {
914
+ content = `${result.json}\n`;
915
+ } else if (flags.json && result.report) {
916
+ content = `${JSON.stringify(result.report, null, 2)}\n`;
917
+ } else if (flags.graph && result.report) {
918
+ content = `${safeTextLines(provenanceGraph(result.report))}\n`;
919
+ } else if (result.report) {
920
+ // renderReport applies the render boundary (safeText) per line before it
921
+ // calls write, so the collected lines are byte-identical to stdout.
922
+ const lines = [];
923
+ renderReport(result.report, { write: (line) => lines.push(line), lang });
924
+ content = `${lines.join('\n')}\n`;
925
+ }
926
+ if (content !== null) {
927
+ const failure = await writeOutFile(outPath, content);
928
+ if (failure) {
929
+ process.stderr.write(
930
+ safeTextLines(t(`无法写入 --out 目标 ${failure}`, `cannot write the --out target ${failure}`) + '\n')
931
+ );
932
+ return EXIT.USAGE;
933
+ }
934
+ process.stderr.write(
935
+ safeTextLines(t(`输出已写入 ${outPath}`, `output written to ${outPath}`) + '\n')
936
+ );
937
+ return result.exit;
938
+ }
939
+ }
940
+
844
941
  if (result.json) {
845
942
  process.stdout.write(`${result.json}\n`);
846
943
  return result.exit;
package/src/contract.js CHANGED
@@ -203,7 +203,10 @@ export function renderReport(report, { write: rawWrite = console.log, lang = DEF
203
203
  const unknown = facts.filter(f => f.status === STATUS.UNKNOWN);
204
204
 
205
205
  write('');
206
- write(`euthyna ${producer.name} — ${producer.purpose || ''}`.trim());
206
+ // The producer name is `euthyna-history`, `euthyna-audit`, …: it already names
207
+ // the tool, so the header prints it as the contract emits it — the same string
208
+ // the `--json` `producer` field carries.
209
+ write(`${producer.name} — ${producer.purpose || ''}`.trim());
207
210
  write(`${t('目标: ', 'Target: ')}${subject.repo ?? subject.coverageFile ?? t('(未指定)', '(unspecified)')}`);
208
211
  if (subject.base || subject.head) {
209
212
  write(
package/src/facts/deps.js CHANGED
@@ -16,8 +16,8 @@
16
16
  * - package-lock.json (npm v1/v2/v3)
17
17
  * - Cargo.lock (Rust)
18
18
  * - go.mod (Go; the DECLARED requirement version, not the resolved one —
19
- * go.sum has no versions, so this producer says "declared",
20
- * never "locked", for Go)
19
+ * go.sum records version-hash pairs but not the selected build
20
+ * list, so this producer says "declared", never "locked", for Go)
21
21
  * Anything else found at auto-detect (pnpm/yarn/poetry/...) is reported as
22
22
  * notEvaluated with the filenames named, rather than guessed at.
23
23
  */
@@ -44,19 +44,38 @@ const UNSUPPORTED = [
44
44
  'bun.lock'
45
45
  ];
46
46
 
47
- /** 1-indexed line of the first line containing `needle`, or null. */
48
- function lineOf(text, needle) {
49
- return lineOfAt(text, text.indexOf(needle));
47
+ /**
48
+ * A line-number lookup over a fixed text: newline offsets collected once,
49
+ * queried by binary search.
50
+ *
51
+ * It answers exactly what counting the newlines before an index answers — the
52
+ * table just does not re-count. That matters on the whole-manifest path: the
53
+ * prefix walk was O(prefix) per evidence lookup, and with every dependency in
54
+ * the manifest queried (`euthyna audit`), the whole run compounded
55
+ * quadratically (bench/perf-deps.js measured the per-dependency cost doubling
56
+ * with each doubling of the manifest). Build once per file, query per hit.
57
+ */
58
+ function makeLineTable(text) {
59
+ const starts = [0];
60
+ for (let i = 0; i < text.length; i++) {
61
+ if (text.charCodeAt(i) === 10) starts.push(i + 1);
62
+ }
63
+ return (index) => {
64
+ if (index < 0 || index >= text.length) return null;
65
+ let lo = 0;
66
+ let hi = starts.length - 1;
67
+ while (lo < hi) {
68
+ const mid = (lo + hi + 1) >> 1;
69
+ if (starts[mid] <= index) lo = mid;
70
+ else hi = mid - 1;
71
+ }
72
+ return lo + 1;
73
+ };
50
74
  }
51
75
 
52
- /** 1-indexed line of the absolute text index, or null when it is not found. */
53
- function lineOfAt(text, index) {
54
- if (index < 0) return null;
55
- let line = 1;
56
- for (let i = 0; i < index; i++) {
57
- if (text[i] === '\n') line++;
58
- }
59
- return line;
76
+ /** 1-indexed line of the first line containing `needle`, or null when absent. */
77
+ function lineOf(text, needle, lineAt) {
78
+ return lineAt(text.indexOf(needle));
60
79
  }
61
80
 
62
81
  /**
@@ -65,33 +84,53 @@ function lineOfAt(text, index) {
65
84
  * v2/v3: `packages` maps "node_modules/<name>" (possibly nested, and possibly
66
85
  * scoped "@scope/name") to metadata; the package name is the path segment after
67
86
  * the last "node_modules/". v1: a recursive `dependencies` tree.
87
+ *
88
+ * Called once per queried dependency — and `euthyna audit` queries every
89
+ * dependency — so the name→entries indexes are built once per file and the
90
+ * per-dep work is a Map lookup, not a rescan of every key.
68
91
  */
69
- function resolveNpm(root, dep, text) {
70
- const found = [];
92
+ function makeNpmResolver(root, text, lineAt) {
93
+ const found = (dep) => {
94
+ const hits = [];
95
+ for (const { version, key } of byKey.get(dep) ?? []) {
96
+ hits.push({ version, location: key, line: lineOf(text, `"${key}":`, lineAt) });
97
+ }
98
+ for (const version of byName.get(dep) ?? []) {
99
+ // Anchor at this dep's own block: a same-version string earlier in the
100
+ // file (some other package) must not hijack the evidence pointer.
101
+ const blockAt = text.indexOf(`"${dep}":`);
102
+ const verAt = blockAt < 0 ? -1 : text.indexOf(`"version": "${version}"`, blockAt);
103
+ hits.push({
104
+ version,
105
+ location: `dependencies.${dep}`,
106
+ line: lineAt(verAt)
107
+ });
108
+ }
109
+ return hits;
110
+ };
71
111
 
112
+ const byKey = new Map();
72
113
  if (root.packages && typeof root.packages === 'object') {
73
114
  for (const [key, meta] of Object.entries(root.packages)) {
74
115
  if (!key.includes('node_modules/')) continue;
75
116
  if (!meta || typeof meta !== 'object' || !meta.version) continue;
76
- if (key.split('node_modules/').pop() !== dep) continue;
77
- found.push({ version: meta.version, location: key, line: lineOf(text, `"${key}":`) });
117
+ const name = key.split('node_modules/').pop();
118
+ if (!name) continue;
119
+ const list = byKey.get(name) ?? [];
120
+ list.push({ version: meta.version, key });
121
+ byKey.set(name, list);
78
122
  }
79
123
  }
80
124
 
125
+ const byName = new Map();
81
126
  if (root.dependencies && typeof root.dependencies === 'object') {
82
127
  const walk = (tree) => {
83
128
  for (const [name, meta] of Object.entries(tree)) {
84
129
  if (!meta || typeof meta !== 'object') continue;
85
- if (name === dep && meta.version) {
86
- // Anchor at this dep's own block: a same-version string earlier in the
87
- // file (some other package) must not hijack the evidence pointer.
88
- const blockAt = text.indexOf(`"${name}":`);
89
- const verAt = blockAt < 0 ? -1 : text.indexOf(`"version": "${meta.version}"`, blockAt);
90
- found.push({
91
- version: meta.version,
92
- location: `dependencies.${name}`,
93
- line: lineOfAt(text, verAt)
94
- });
130
+ if (meta.version) {
131
+ const list = byName.get(name) ?? [];
132
+ list.push(meta.version);
133
+ byName.set(name, list);
95
134
  }
96
135
  if (meta.dependencies && typeof meta.dependencies === 'object') walk(meta.dependencies);
97
136
  }
@@ -102,25 +141,39 @@ function resolveNpm(root, dep, text) {
102
141
  return found;
103
142
  }
104
143
 
105
- /** Cargo.lock: [[package]] blocks with `name = "..."` and `version = "..."`. */
106
- function resolveCargo(text, dep) {
107
- const found = [];
144
+ /**
145
+ * Cargo.lock: [[package]] blocks with `name = "..."` and `version = "..."`.
146
+ * Blocks are parsed once per file (per-dep splits of the whole text were the
147
+ * measured quadratic term on this format, bench/perf-deps.js).
148
+ */
149
+ function makeCargoResolver(text, lineAt) {
150
+ const byName = new Map();
108
151
  for (const block of text.split('[[package]]')) {
109
152
  const name = /^\s*name\s*=\s*"([^"]+)"/m.exec(block);
110
153
  const version = /^\s*version\s*=\s*"([^"]+)"/m.exec(block);
111
- if (!name || !version || name[1] !== dep) continue;
154
+ if (!name || !version) continue;
155
+ const list = byName.get(name[1]) ?? [];
156
+ list.push(version[1]);
157
+ byName.set(name[1], list);
158
+ }
159
+
160
+ return (dep) => {
161
+ const versions = byName.get(dep);
162
+ if (!versions) return [];
112
163
  // Package names are unique in a Cargo.lock, so anchoring the version line
113
164
  // at this dep's own name block keeps the pointer on this crate even when a
114
165
  // different crate earlier in the file pins the same version string.
115
166
  const nameAt = text.indexOf(`name = "${dep}"`);
116
- const verAt = nameAt < 0 ? -1 : text.indexOf(`version = "${version[1]}"`, nameAt);
117
- found.push({
118
- version: version[1],
119
- location: `[[package]] ${dep}`,
120
- line: lineOfAt(text, verAt)
167
+ if (nameAt < 0) return [];
168
+ return versions.map((version) => {
169
+ const verAt = text.indexOf(`version = "${version}"`, nameAt);
170
+ return {
171
+ version,
172
+ location: `[[package]] ${dep}`,
173
+ line: lineAt(verAt)
174
+ };
121
175
  });
122
- }
123
- return found;
176
+ };
124
177
  }
125
178
 
126
179
  /** go.mod require lines, single or inside a require (...) block. */
@@ -152,6 +205,7 @@ function resolveGo(text, dep) {
152
205
  */
153
206
  function makeResolver(type, text, lang = DEFAULT_LANG) {
154
207
  const t = T(lang);
208
+ const lineAt = makeLineTable(text);
155
209
  if (type === 'npm') {
156
210
  const root = JSON.parse(text); // throws on malformed JSON
157
211
  // A syntactically valid but structurally empty file (e.g. `{}`) is not a
@@ -169,7 +223,7 @@ function makeResolver(type, text, lang = DEFAULT_LANG) {
169
223
  )
170
224
  );
171
225
  }
172
- return (dep) => resolveNpm(root, dep, text);
226
+ return makeNpmResolver(root, text, lineAt);
173
227
  }
174
228
  if (type === 'cargo') {
175
229
  if (!/\[\[package\]\]|^version\s*=/m.test(text)) {
@@ -180,7 +234,7 @@ function makeResolver(type, text, lang = DEFAULT_LANG) {
180
234
  )
181
235
  );
182
236
  }
183
- return (dep) => resolveCargo(text, dep);
237
+ return makeCargoResolver(text, lineAt);
184
238
  }
185
239
  if (type === 'go') {
186
240
  if (!/^module\s+\S+/m.test(text)) {
@@ -53,6 +53,39 @@ export function parseDeletedRanges(diffText) {
53
53
  return ranges;
54
54
  }
55
55
 
56
+ /**
57
+ * Sort by start and merge overlapping or adjacent ranges.
58
+ *
59
+ * `parseDeletedRanges` already yields disjoint ranges, so well-formed input
60
+ * comes back unchanged; the merge exists because an overlap would double-count
61
+ * the shared lines across two `-L` flags, and because merging neighbours keeps
62
+ * a batch's argv small.
63
+ */
64
+ export function mergeRanges(ranges) {
65
+ const sorted = [...ranges].sort((a, b) => a.start - b.start || a.count - b.count);
66
+ const merged = [];
67
+ for (const range of sorted) {
68
+ const end = range.start + range.count - 1;
69
+ const last = merged[merged.length - 1];
70
+ const lastEnd = last ? last.start + last.count - 1 : -Infinity;
71
+ if (last && range.start <= lastEnd + 1) {
72
+ last.count = Math.max(lastEnd, end) - last.start + 1;
73
+ } else {
74
+ merged.push({ start: range.start, count: range.count });
75
+ }
76
+ }
77
+ return merged;
78
+ }
79
+
80
+ /**
81
+ * Ranges blamed per `git blame` process. One process carries one `-L` flag per
82
+ * range, so the batch size is the process-count divider; 400 keeps the argv
83
+ * (≈14 bytes per flag) far under every platform's command-line limit while
84
+ * cutting the per-range process cost by that factor (baseline: bench/perf.js,
85
+ * measured ~48 ms per deleted range when each range was its own process).
86
+ */
87
+ const BLAME_BATCH = 400;
88
+
56
89
  /**
57
90
  * Attribute every line in `ranges` to the commit that last touched it, as of `rev`.
58
91
  *
@@ -60,28 +93,28 @@ export function parseDeletedRanges(diffText) {
60
93
  * numbers rather than a bare count, because the fact has to carry a command a
61
94
  * reader can actually paste: `git blame -L 104,104` reproduces the claim, while
62
95
  * `git blame -L <deleted-range>` is a placeholder, not evidence.
96
+ *
97
+ * The multi-`-L` invocation is the same form the emitted reproduce commands
98
+ * already rely on (`git blame --porcelain -L a,b -L c,d <base> -- <file>`), so
99
+ * batching adds no new assumption about git — it only stops paying process
100
+ * startup per range. A batch git refuses (a range past EOF, a binary file) is
101
+ * retried range by range, so one bad range costs itself and not its neighbours.
63
102
  */
64
103
  export async function blameRanges({ cwd, rev, file, ranges }) {
65
104
  const byCommit = new Map();
66
- // ponytail: 顺序 blame,每个区间一次 git 进程,耗时与删除区间数线性相关
67
- // (基准见 bench/perf.js)。若吞吐不满足要求:改成每文件一次 blame 覆盖
68
- // 全部区间,或对单行区间做有界并发。
69
- for (const range of ranges) {
70
- const end = range.start + range.count - 1;
71
- const out = await git(
72
- ['blame', '--porcelain', '-L', `${range.start},${end}`, rev, '--', file],
73
- { cwd, allowFailure: true }
74
- );
75
- if (!out) continue;
76
105
 
106
+ // Porcelain emits `<sha> <origLine> <finalLine> [<groupSize>]`. `origLine`
107
+ // is the line number in the commit that introduced the line, which is NOT
108
+ // where the line sits in the revision being blamed; `finalLine` is. Using
109
+ // the wrong one produces a command that looks right and blames other lines.
110
+ const parse = (out) => {
77
111
  let current = null;
78
- // Porcelain emits `<sha> <origLine> <finalLine> [<groupSize>]`. `origLine`
79
- // is the line number in the commit that introduced the line, which is NOT
80
- // where the line sits in the revision being blamed; `finalLine` is. Using
81
- // the wrong one produces a command that looks right and blames other lines.
82
112
  let lineNumber = null;
83
113
  for (const raw of out.split('\n')) {
84
- const header = /^([0-9a-f]{40}) (\d+) (\d+)(?: (\d+))?$/.exec(raw);
114
+ // The object id is 40 hex (sha1) or 64 hex (sha256) — a repository picks
115
+ // its own format, and the attribution must survive either. Same split as
116
+ // mergeBase() in git.js.
117
+ const header = /^([0-9a-f]{40}|[0-9a-f]{64}) (\d+) (\d+)(?: (\d+))?$/.exec(raw);
85
118
  if (header) {
86
119
  current = header[1];
87
120
  lineNumber = Number(header[3]);
@@ -94,6 +127,29 @@ export async function blameRanges({ cwd, rev, file, ranges }) {
94
127
  lineNumber++;
95
128
  }
96
129
  }
130
+ };
131
+
132
+ const blame = (batch) =>
133
+ git(
134
+ // Ranges are {start, count}: the -L end is start+count-1, computed here.
135
+ // A `${r.end}` on a count-shaped range would hand git "undefined" and the
136
+ // swallowed failure would read as "no attribution" downstream.
137
+ ['blame', '--porcelain', ...batch.flatMap((r) => ['-L', `${r.start},${r.start + r.count - 1}`]), rev, '--', file],
138
+ { cwd, allowFailure: true }
139
+ );
140
+
141
+ const merged = mergeRanges(ranges);
142
+ for (let i = 0; i < merged.length; i += BLAME_BATCH) {
143
+ const batch = merged.slice(i, i + BLAME_BATCH);
144
+ const out = await blame(batch);
145
+ if (out) {
146
+ parse(out);
147
+ continue;
148
+ }
149
+ for (const range of batch) {
150
+ const single = await blame([range]);
151
+ if (single) parse(single);
152
+ }
97
153
  }
98
154
  return byCommit;
99
155
  }