euthyna 0.7.0 → 0.9.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/SKILL.md +18 -1
- package/.agents/skills/euthyna/references/fact-producers.md +57 -1
- package/.agents/skills/euthyna-en/SKILL.md +22 -1
- package/.agents/skills/euthyna-en/references/fact-producers.md +37 -1
- package/.claude/commands/euthyna.md +2 -2
- package/.cursor/commands/euthyna.md +1 -1
- package/.github/prompts/euthyna.prompt.md +1 -1
- package/.opencode/commands/euthyna.md +4 -2
- package/README.md +22 -5
- package/package.json +2 -2
- package/plugin.yaml +1 -1
- package/src/cli.js +125 -37
- package/src/contract.js +6 -1
- package/src/facts/coverage.js +504 -1
- package/src/git.js +16 -0
- package/src/graph.js +73 -0
package/src/cli.js
CHANGED
|
@@ -21,7 +21,8 @@ import { collectCoverageFacts } from './facts/coverage.js';
|
|
|
21
21
|
import { collectDependencyFacts, listDependencies, detectManifest, manifestTypeForFile } from './facts/deps.js';
|
|
22
22
|
import { parseGateReport, validateFindings, verifyFinding, renderGateReport } from './gate.js';
|
|
23
23
|
import { makeReport, renderReport, safeTextLines, KIND } from './contract.js';
|
|
24
|
-
import {
|
|
24
|
+
import { provenanceGraph } from './graph.js';
|
|
25
|
+
import { repoToplevel, revParse, mergeBase, git } from './git.js';
|
|
25
26
|
import { resolveLang, T, isLang, DEFAULT_LANG } from './lang.js';
|
|
26
27
|
|
|
27
28
|
export const EXIT = Object.freeze({
|
|
@@ -71,8 +72,8 @@ euthyna —— 给 AI 编码 agent 用的确定性事实产出器
|
|
|
71
72
|
它不是扫描器。它只回答两个模型算不准的问题,并把答案写成带证据的事实。
|
|
72
73
|
|
|
73
74
|
用法:
|
|
74
|
-
euthyna audit --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json]
|
|
75
|
-
euthyna history --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json]
|
|
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]
|
|
76
77
|
euthyna coverage --coverage <file> --symbol <name> [--file <path>] [--source <dir>] [--json]
|
|
77
78
|
euthyna deps [--repo <dir>] [--lockfile <file>] (--dep <name> | --all) [--json]
|
|
78
79
|
euthyna gate <报告文件> [--verify] [--cwd <dir>] [--json]
|
|
@@ -83,17 +84,24 @@ euthyna —— 给 AI 编码 agent 用的确定性事实产出器
|
|
|
83
84
|
audit 一次跑完变更面审计:history + deps(枚举 lockfile 全部依赖),合并报告
|
|
84
85
|
这是技能文档默认推荐入口;coverage 因需要覆盖率文件和符号名不在其中
|
|
85
86
|
history 本次变更删掉了哪些代码、它们分别由哪个提交引入、该提交是不是安全修复
|
|
86
|
-
--base
|
|
87
|
+
--base 可选:省略时按 merge-base(HEAD, origin/main) 推断分叉点。
|
|
88
|
+
推断会写在「范围」那一行,并在「未评估的判据」里加一条 range-inferred:
|
|
89
|
+
推断出的范围只到分叉点,不是被审历史的全貌。两个引用都解析不出来时报错,
|
|
90
|
+
不退回去猜 HEAD~1
|
|
87
91
|
--head 可选,默认 HEAD
|
|
88
92
|
--pickaxe 额外检查「曾被移除又加回来」的新增行(有探针上限)
|
|
89
93
|
--no-origins 关闭来源追溯;默认用 git log -S 把删除行归属到「最初引入」
|
|
90
94
|
该内容的提交(有探针上限,比纯 blame 慢)
|
|
95
|
+
--graph 输出 Mermaid 来源谱系图:范围 → 最后修改这些行的提交 → 最初引入
|
|
96
|
+
其内容的提交(第二条边来自 -S 追溯,--no-origins 时没有)。
|
|
97
|
+
图只画测到的状态与行数,不画严重性;与 --json 互斥,只适用于 history 与 audit
|
|
91
98
|
coverage 某个符号在测试运行中到底有没有被调用过
|
|
92
|
-
--coverage
|
|
99
|
+
--coverage 覆盖率数据文件,按形状自动识别 c8 / istanbul、
|
|
100
|
+
coverage.py JSON、Go -coverprofile 文本、lcov tracefile
|
|
93
101
|
--symbol 要查询的符号名,可重复
|
|
94
102
|
--file 可选,限定到某个文件
|
|
95
103
|
--source 可选,Go profile(go test -coverprofile)必需:模块根目录,
|
|
96
|
-
用于把行区间映射回函数(profile
|
|
104
|
+
用于把行区间映射回函数(profile 不含函数名);其余格式不需要
|
|
97
105
|
deps 某个依赖在 lockfile 里被锁定/声明成什么版本(供应链声明的裁决依据)
|
|
98
106
|
--repo 可选,依赖清单所在目录(默认当前目录,自动检测)
|
|
99
107
|
--lockfile 可选,显式指定清单文件(支持 package-lock.json / Cargo.lock / go.mod)
|
|
@@ -123,8 +131,8 @@ It is not a scanner. It answers only the two questions a model cannot compute
|
|
|
123
131
|
reliably, and writes the answers as facts with evidence.
|
|
124
132
|
|
|
125
133
|
Usage:
|
|
126
|
-
euthyna audit --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json]
|
|
127
|
-
euthyna history --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--no-origins] [--json]
|
|
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]
|
|
128
136
|
euthyna coverage --coverage <file> --symbol <name> [--file <path>] [--source <dir>] [--json]
|
|
129
137
|
euthyna deps [--repo <dir>] [--lockfile <file>] (--dep <name> | --all) [--json]
|
|
130
138
|
euthyna gate <report file> [--verify] [--cwd <dir>] [--json]
|
|
@@ -139,19 +147,30 @@ Commands:
|
|
|
139
147
|
and symbol names only the adjudicator knows)
|
|
140
148
|
history which lines did this change delete, which commit introduced them,
|
|
141
149
|
and was that commit a security fix?
|
|
142
|
-
--base
|
|
150
|
+
--base optional: omitted means the fork point is inferred as
|
|
151
|
+
merge-base(HEAD, origin/main). The inference is stated on the range
|
|
152
|
+
line and declared as range-inferred among the criteria not evaluated —
|
|
153
|
+
an inferred range reaches the fork point, not the whole history. When
|
|
154
|
+
neither ref resolves the run stops rather than guessing HEAD~1
|
|
143
155
|
--head optional, defaults to HEAD
|
|
144
156
|
--pickaxe also check added lines that were once removed and now return (probe-capped)
|
|
145
157
|
--no-origins disable origin tracing; by default git log -S attributes deleted
|
|
146
158
|
lines to the commit that FIRST introduced their content
|
|
147
159
|
(probe-capped, slower than plain blame)
|
|
160
|
+
--graph print a Mermaid provenance graph: the range → the commit that last
|
|
161
|
+
modified the deleted lines → the commit that first introduced their
|
|
162
|
+
content (the second edge comes from -S tracing; --no-origins has
|
|
163
|
+
none). It draws measured states and line counts, never severity;
|
|
164
|
+
mutually exclusive with --json, history and audit only
|
|
148
165
|
coverage was this symbol ever actually invoked by a test run?
|
|
149
|
-
--coverage the coverage data file
|
|
166
|
+
--coverage the coverage data file; identified by shape as c8 / istanbul,
|
|
167
|
+
coverage.py JSON, a Go -coverprofile, or an lcov tracefile
|
|
150
168
|
--symbol symbol name(s) to query, repeatable
|
|
151
169
|
--file optional, restrict to one file
|
|
152
170
|
--source optional, required for Go profiles (go test -coverprofile):
|
|
153
171
|
the module root, used to map line ranges back to
|
|
154
|
-
functions (the profile carries no function names)
|
|
172
|
+
functions (the profile carries no function names); other
|
|
173
|
+
formats do not need it
|
|
155
174
|
deps what version does the lockfile pin or declare for a dependency? (the basis for supply-chain claims)
|
|
156
175
|
--repo optional, directory containing the manifest (default cwd, auto-detected)
|
|
157
176
|
--lockfile optional, explicit manifest path (package-lock.json / Cargo.lock / go.mod)
|
|
@@ -251,18 +270,6 @@ async function runHistory(flags, lang) {
|
|
|
251
270
|
};
|
|
252
271
|
}
|
|
253
272
|
|
|
254
|
-
if (!flags.base) {
|
|
255
|
-
return { exit: EXIT.USAGE, error: t('history 需要 --base <rev>', 'history requires --base <rev>') };
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const base = await revParse(toplevel, String(flags.base));
|
|
259
|
-
if (!base) {
|
|
260
|
-
return {
|
|
261
|
-
exit: EXIT.USAGE,
|
|
262
|
-
error: t(`无法解析 --base "${flags.base}" 为一个提交`, `cannot resolve --base "${flags.base}" to a commit`)
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
|
|
266
273
|
const head = await revParse(toplevel, String(flags.head ?? 'HEAD'));
|
|
267
274
|
if (!head) {
|
|
268
275
|
return {
|
|
@@ -271,6 +278,12 @@ async function runHistory(flags, lang) {
|
|
|
271
278
|
};
|
|
272
279
|
}
|
|
273
280
|
|
|
281
|
+
const range = await resolveRangeBase({ flags, cwd: toplevel, head, t });
|
|
282
|
+
if (range.error) {
|
|
283
|
+
return { exit: EXIT.USAGE, error: range.error };
|
|
284
|
+
}
|
|
285
|
+
const base = range.base;
|
|
286
|
+
|
|
274
287
|
// Origin tracing is the default (see runAudit); --no-origins opts out.
|
|
275
288
|
const origins = flags['no-origins'] !== true;
|
|
276
289
|
|
|
@@ -283,9 +296,13 @@ async function runHistory(flags, lang) {
|
|
|
283
296
|
lang
|
|
284
297
|
});
|
|
285
298
|
|
|
299
|
+
if (range.ref) {
|
|
300
|
+
notEvaluated.push(rangeInferenceDeclaration({ ref: range.ref, head }, t));
|
|
301
|
+
}
|
|
302
|
+
|
|
286
303
|
const report = makeReport({
|
|
287
304
|
producer: producerFor(HISTORY_PRODUCER, lang),
|
|
288
|
-
subject: { repo: toplevel, base, head },
|
|
305
|
+
subject: { repo: toplevel, base, head, ...(range.ref ? { rangeSource: range.ref } : {}) },
|
|
289
306
|
facts,
|
|
290
307
|
evaluated,
|
|
291
308
|
notEvaluated
|
|
@@ -409,6 +426,56 @@ async function runDeps(flags, lang) {
|
|
|
409
426
|
* symbol names only the adjudicator knows, so there is nothing honest to
|
|
410
427
|
* auto-measure there.
|
|
411
428
|
*/
|
|
429
|
+
/**
|
|
430
|
+
* Declare the range the run inferred, in the shape every other declaration uses:
|
|
431
|
+
* a `notEvaluated` entry with a `code`, so the JSON channel carries it and a
|
|
432
|
+
* reader of the terminal output meets it in the caveat list.
|
|
433
|
+
*
|
|
434
|
+
* Pure, so the wording is testable in both languages without building a repository.
|
|
435
|
+
*/
|
|
436
|
+
export function rangeInferenceDeclaration({ ref, head }, t) {
|
|
437
|
+
const short = (sha) => (sha ?? '').slice(0, 10);
|
|
438
|
+
return {
|
|
439
|
+
kind: KIND.HISTORY,
|
|
440
|
+
code: 'range-inferred',
|
|
441
|
+
reason: t(
|
|
442
|
+
`未给 --base,比较范围按 merge-base(${short(head)}, ${ref}) 推断——这不是被审历史的全貌,换范围请显式传 --base <rev>`,
|
|
443
|
+
`no --base given: the range was inferred as merge-base(${short(head)}, ${ref}). That is not the whole history under review — pass --base <rev> to audit another range`
|
|
444
|
+
)
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Resolve `--base`, or infer the fork point when the caller gave none.
|
|
450
|
+
*
|
|
451
|
+
* The inference is where an argument-free run could quietly narrow what is
|
|
452
|
+
* audited — a smaller range deletes edges and reads as cleaner — so the rule used
|
|
453
|
+
* comes back with the value and the caller must declare it. It forks from the
|
|
454
|
+
* resolved `--head`, not from whatever happens to be checked out: asking about
|
|
455
|
+
* another commit and getting this commit's ancestor would report a range nobody
|
|
456
|
+
* requested. There is no fallback to HEAD~1: with no remote-tracking branch to
|
|
457
|
+
* fork from, this says so and the run does not happen.
|
|
458
|
+
*/
|
|
459
|
+
export async function resolveRangeBase({ flags, cwd, head, t }) {
|
|
460
|
+
if (flags.base !== undefined) {
|
|
461
|
+
const base = await revParse(cwd, String(flags.base));
|
|
462
|
+
if (!base) {
|
|
463
|
+
return { error: t(`无法解析 --base "${flags.base}" 为一个提交`, `cannot resolve --base "${flags.base}" to a commit`) };
|
|
464
|
+
}
|
|
465
|
+
return { base };
|
|
466
|
+
}
|
|
467
|
+
for (const ref of ['origin/main', 'origin/HEAD']) {
|
|
468
|
+
const base = await mergeBase(cwd, head, ref);
|
|
469
|
+
if (base) return { base, ref };
|
|
470
|
+
}
|
|
471
|
+
return {
|
|
472
|
+
error: t(
|
|
473
|
+
'未给 --base,且 origin/main 与 origin/HEAD 都无法解析出分叉点——请显式传 --base <rev>',
|
|
474
|
+
'no --base given, and neither origin/main nor origin/HEAD resolves to a fork point — pass --base <rev>'
|
|
475
|
+
)
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
412
479
|
/**
|
|
413
480
|
* Decide whether the manifest `audit` measured is the manifest --head holds.
|
|
414
481
|
*
|
|
@@ -457,10 +524,6 @@ async function runAudit(flags, lang) {
|
|
|
457
524
|
const t = T(lang);
|
|
458
525
|
const cwd = path.resolve(flags.repo ? String(flags.repo) : process.cwd());
|
|
459
526
|
|
|
460
|
-
if (!flags.base) {
|
|
461
|
-
return { exit: EXIT.USAGE, error: t('audit 需要 --base <rev>', 'audit requires --base <rev>') };
|
|
462
|
-
}
|
|
463
|
-
|
|
464
527
|
// --- history half (mirrors runHistory's repo/base/head resolution) ---
|
|
465
528
|
const toplevel = await repoToplevel(cwd);
|
|
466
529
|
if (!toplevel) {
|
|
@@ -483,14 +546,6 @@ async function runAudit(flags, lang) {
|
|
|
483
546
|
};
|
|
484
547
|
}
|
|
485
548
|
|
|
486
|
-
const base = await revParse(toplevel, String(flags.base));
|
|
487
|
-
if (!base) {
|
|
488
|
-
return {
|
|
489
|
-
exit: EXIT.USAGE,
|
|
490
|
-
error: t(`无法解析 --base "${flags.base}" 为一个提交`, `cannot resolve --base "${flags.base}" to a commit`)
|
|
491
|
-
};
|
|
492
|
-
}
|
|
493
|
-
|
|
494
549
|
const head = await revParse(toplevel, String(flags.head ?? 'HEAD'));
|
|
495
550
|
if (!head) {
|
|
496
551
|
return {
|
|
@@ -499,6 +554,12 @@ async function runAudit(flags, lang) {
|
|
|
499
554
|
};
|
|
500
555
|
}
|
|
501
556
|
|
|
557
|
+
const range = await resolveRangeBase({ flags, cwd: toplevel, head, t });
|
|
558
|
+
if (range.error) {
|
|
559
|
+
return { exit: EXIT.USAGE, error: range.error };
|
|
560
|
+
}
|
|
561
|
+
const base = range.base;
|
|
562
|
+
|
|
502
563
|
const origins = flags['no-origins'] !== true;
|
|
503
564
|
const history = await collectHistoryFacts({
|
|
504
565
|
cwd: toplevel,
|
|
@@ -555,10 +616,13 @@ async function runAudit(flags, lang) {
|
|
|
555
616
|
const facts = [...history.facts, ...depFacts];
|
|
556
617
|
const evaluated = [...history.evaluated, ...depEvaluated];
|
|
557
618
|
const notEvaluated = [...history.notEvaluated, ...depNotEvaluated];
|
|
619
|
+
if (range.ref) {
|
|
620
|
+
notEvaluated.push(rangeInferenceDeclaration({ ref: range.ref, head }, t));
|
|
621
|
+
}
|
|
558
622
|
|
|
559
623
|
const report = makeReport({
|
|
560
624
|
producer: producerFor(AUDIT_PRODUCER, lang),
|
|
561
|
-
subject: { repo: toplevel, base, head },
|
|
625
|
+
subject: { repo: toplevel, base, head, ...(range.ref ? { rangeSource: range.ref } : {}) },
|
|
562
626
|
facts,
|
|
563
627
|
evaluated,
|
|
564
628
|
notEvaluated
|
|
@@ -726,11 +790,31 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
726
790
|
const lang = resolveLang({ flag: flagLang });
|
|
727
791
|
const t = T(lang);
|
|
728
792
|
|
|
793
|
+
// `--graph` is a render mode, not an extra channel: a graph with a JSON
|
|
794
|
+
// document glued to it on one stdout makes a CI step that parses either one
|
|
795
|
+
// fail without saying which.
|
|
796
|
+
if (flags.graph && flags.json) {
|
|
797
|
+
process.stderr.write(
|
|
798
|
+
safeTextLines(t('--graph 与 --json 互斥,一次只选一种输出', '--graph and --json are mutually exclusive; choose one output') + '\n')
|
|
799
|
+
);
|
|
800
|
+
return EXIT.USAGE;
|
|
801
|
+
}
|
|
802
|
+
|
|
729
803
|
if (!command || command === 'help' || flags.help) {
|
|
730
804
|
process.stdout.write(helpText(lang));
|
|
731
805
|
return EXIT.CLEAN;
|
|
732
806
|
}
|
|
733
807
|
|
|
808
|
+
// Only `history` and `audit` produce deleted-line attribution. Drawing the
|
|
809
|
+
// provenance graph for another command prints a range node with no edges, which
|
|
810
|
+
// reads as "no provenance found" — the one thing this must not imply.
|
|
811
|
+
if (flags.graph && command !== 'history' && command !== 'audit') {
|
|
812
|
+
process.stderr.write(
|
|
813
|
+
safeTextLines(t('--graph 只适用于 history 与 audit', '--graph applies to history and audit only') + '\n')
|
|
814
|
+
);
|
|
815
|
+
return EXIT.USAGE;
|
|
816
|
+
}
|
|
817
|
+
|
|
734
818
|
let result;
|
|
735
819
|
if (command === 'history') {
|
|
736
820
|
result = await runHistory(flags, lang);
|
|
@@ -764,6 +848,10 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
764
848
|
|
|
765
849
|
if (flags.json) {
|
|
766
850
|
process.stdout.write(`${JSON.stringify(result.report, null, 2)}\n`);
|
|
851
|
+
} else if (flags.graph && result.report) {
|
|
852
|
+
// The graph is terminal output like every other render: safeTextLines keeps
|
|
853
|
+
// its line structure and neutralises control bytes a repo path could carry.
|
|
854
|
+
process.stdout.write(safeTextLines(`${provenanceGraph(result.report)}\n`));
|
|
767
855
|
} else if (result.report) {
|
|
768
856
|
renderReport(result.report, { lang });
|
|
769
857
|
}
|
package/src/contract.js
CHANGED
|
@@ -206,7 +206,12 @@ export function renderReport(report, { write: rawWrite = console.log, lang = DEF
|
|
|
206
206
|
write(`euthyna ${producer.name} — ${producer.purpose || ''}`.trim());
|
|
207
207
|
write(`${t('目标: ', 'Target: ')}${subject.repo ?? subject.coverageFile ?? t('(未指定)', '(unspecified)')}`);
|
|
208
208
|
if (subject.base || subject.head) {
|
|
209
|
-
write(
|
|
209
|
+
write(
|
|
210
|
+
`${t('范围: ', 'Range: ')}${subject.base ?? '?'}..${subject.head ?? 'HEAD'}` +
|
|
211
|
+
// An inferred range is a decision the tool made on the reader's behalf, so
|
|
212
|
+
// it is said on the line that states the range — not only in the caveat list.
|
|
213
|
+
(subject.rangeSource ? t(`(推断:与 ${subject.rangeSource} 的分叉点)`, ` (inferred: the fork point from ${subject.rangeSource})`) : '')
|
|
214
|
+
);
|
|
210
215
|
}
|
|
211
216
|
write('');
|
|
212
217
|
|