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/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 { repoToplevel, revParse, git } from './git.js';
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 必填,比较的基线版本(如 main、HEAD~5、某个 commit)
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 覆盖率数据文件,c8 的 coverage-final.json
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 required, the baseline revision to compare against
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, c8's coverage-final.json
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(`${t('范围: ', 'Range: ')}${subject.base ?? '?'}..${subject.head ?? 'HEAD'}`);
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