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.
- package/.agents/skills/euthyna/references/fact-producers.md +2 -2
- package/.agents/skills/euthyna-en/references/fact-producers.md +2 -2
- package/README.md +5 -0
- package/__init__.py +18 -7
- package/package.json +2 -2
- package/plugin.yaml +2 -2
- package/src/cli.js +108 -11
- package/src/contract.js +4 -1
- package/src/facts/deps.js +94 -40
- package/src/facts/history.js +71 -15
|
@@ -99,7 +99,7 @@ node <euthyna 仓库>/bin/euthyna.js history --base <改前版本> --no-origins
|
|
|
99
99
|
### 输出长什么样(真实捕获)
|
|
100
100
|
|
|
101
101
|
```
|
|
102
|
-
euthyna
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
19
|
-
#
|
|
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
|
-
|
|
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
|
|
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:
|
|
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.
|
|
4
|
-
"description": "
|
|
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.
|
|
3
|
-
description: euthyna audit discipline — slash
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
53
|
-
function
|
|
54
|
-
|
|
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
|
|
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
|
-
|
|
77
|
-
|
|
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 (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
/**
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
version
|
|
119
|
-
|
|
120
|
-
|
|
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 (
|
|
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 (
|
|
237
|
+
return makeCargoResolver(text, lineAt);
|
|
184
238
|
}
|
|
185
239
|
if (type === 'go') {
|
|
186
240
|
if (!/^module\s+\S+/m.test(text)) {
|
package/src/facts/history.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|