euthyna 0.2.0 → 0.3.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/__init__.py ADDED
@@ -0,0 +1,91 @@
1
+ """The Hermes half of euthyna: in-session slash commands driving the CLI.
2
+
3
+ The CLI (``bin/euthyna.js``, installed by ``npm install -g euthyna``) produces
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
6
+ handler shells out to the real CLI and returns its output verbatim — it does not
7
+ reimplement any measurement, and it injects nothing into the conversation.
8
+
9
+ Like the DSH slash command, this runs without reaching the model: whatever the
10
+ CLI prints comes back as the command result.
11
+
12
+ @module euthyna_hermes
13
+ """
14
+
15
+ import shutil
16
+ import subprocess
17
+
18
+ # Ponytail: one flat dict, not a command framework — there are exactly four
19
+ # subcommands and the CLI already owns usage/exit codes.
20
+ _USAGE = """euthyna slash command — usage:
21
+
22
+ /euthyna history <path> [<path> <rev>...] deleted-code provenance & security classification
23
+ /euthyna coverage <path> symbol invocation counts from a coverage run
24
+ /euthyna deps <path> dependency pinned versions from lockfiles
25
+ /euthyna gate <report> [--verify] validate an audit report against the six gates
26
+
27
+ Args after the subcommand are passed to the CLI unchanged. Requires the CLI:
28
+ npm install -g euthyna (Node >= 20)."""
29
+
30
+
31
+ def _find_cli():
32
+ """Return the absolute path of the euthyna CLI, or None if not installed."""
33
+ for name in ("euthyna", "euthyna.cmd", "euthyna.exe"):
34
+ found = shutil.which(name)
35
+ if found:
36
+ return found
37
+ return None
38
+
39
+
40
+ def _run(raw_args: str):
41
+ """Handle /euthyna: forward <subcommand> <args...> to the CLI, return its output."""
42
+ argv = raw_args.split()
43
+ if not argv or argv[0] in ("help", "-h", "--help"):
44
+ return _USAGE
45
+ sub, rest = argv[0], argv[1:]
46
+ if sub not in ("history", "coverage", "deps", "gate"):
47
+ return f"unknown subcommand: {sub}\n\n{_USAGE}"
48
+
49
+ cli = _find_cli()
50
+ if cli is None:
51
+ return ("euthyna CLI not found. Install it first:\n"
52
+ " npm install -g euthyna\n"
53
+ f"then retry /euthyna {sub} …")
54
+
55
+ # Text mode with explicit utf-8: the CLI reports are written in Chinese and
56
+ # Windows decode would otherwise land on the system codepage.
57
+ proc = subprocess.run(
58
+ [cli, sub, *rest],
59
+ capture_output=True,
60
+ text=True,
61
+ encoding="utf-8",
62
+ errors="replace",
63
+ )
64
+ out = (proc.stdout or "").strip()
65
+ err = (proc.stderr or "").strip()
66
+ # The CLI's exit-code contract (0/10/1/2) is part of the interface; keep it visible.
67
+ header = f"[euthyna {sub} → exit {proc.returncode}]"
68
+ return "\n".join(part for part in (header, out, err) if part)
69
+
70
+
71
+ def register(ctx):
72
+ """Wire /euthyna into the session command registry."""
73
+ ctx.register_command(
74
+ "euthyna",
75
+ handler=_run,
76
+ description="euthyna security audit: run history/coverage/deps/gate fact producers",
77
+ args_hint="<history|coverage|deps|gate> <args…>",
78
+ )
79
+
80
+
81
+ if __name__ == "__main__":
82
+ # Self-check, no Hermes needed: usage path, unknown-subcommand path, and a
83
+ # real CLI run when euthyna is on PATH (skipped when it is not).
84
+ result = _run("")
85
+ assert result == _USAGE, "empty args must print usage"
86
+ assert "unknown subcommand" in _run("bogus"), "unknown subcommand must be rejected"
87
+ cli = _find_cli()
88
+ if cli:
89
+ probe = _run("deps .")
90
+ assert probe.startswith("[euthyna deps"), f"unexpected probe output: {probe[:80]}"
91
+ print("self-check ok", f"(cli={'found' if cli else 'absent'})")
package/bin/euthyna.js CHANGED
@@ -8,6 +8,7 @@
8
8
  import process from 'node:process';
9
9
  import { main } from '../src/cli.js';
10
10
  import { safeTextLines } from '../src/contract.js';
11
+ import { resolveLang } from '../src/lang.js';
11
12
 
12
13
  try {
13
14
  process.exitCode = await main(process.argv.slice(2));
@@ -18,8 +19,17 @@ try {
18
19
  // stderr boundary: an error message inherits repo-controlled text (git embeds
19
20
  // filenames and refs in its stderr), so the stack is made terminal-safe at
20
21
  // this write, exactly once, like the report channel at its render boundary.
21
- process.stderr.write(
22
- safeTextLines(`euthyna: 测量过程抛出异常,结果不可用\n${error?.stack ?? error}\n`)
23
- );
22
+ //
23
+ // The language for this line is read from the environment and a bare --lang
24
+ // scan of argv: main() has already consumed --lang by the time a throw
25
+ // reaches here, so this entry point re-reads it from the raw arguments.
26
+ const argv = process.argv.slice(2);
27
+ const langIndex = argv.indexOf('--lang');
28
+ const lang = resolveLang({ flag: langIndex >= 0 ? argv[langIndex + 1] : undefined });
29
+ const headline =
30
+ lang === 'en'
31
+ ? 'euthyna: measurement threw an exception; the result is unusable'
32
+ : 'euthyna: 测量过程抛出异常,结果不可用';
33
+ process.stderr.write(safeTextLines(`${headline}\n${error?.stack ?? error}\n`));
24
34
  process.exitCode = 2;
25
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euthyna",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Deterministic fact producers for security auditing: which deleted code came from a security fix, and which changed symbols no test ever executed.",
5
5
  "type": "module",
6
6
  "main": "./plugin/index.js",
@@ -11,8 +11,13 @@
11
11
  "bin/",
12
12
  "src/",
13
13
  "plugin/",
14
+ "plugin.yaml",
15
+ "__init__.py",
14
16
  "cordis.patch.yml",
15
17
  ".agents/skills/euthyna/",
18
+ ".agents/skills/euthyna-en/",
19
+ ".claude/commands/",
20
+ ".opencode/commands/",
16
21
  "assets/euthyna.png",
17
22
  "NOTICE.md"
18
23
  ],
package/plugin/index.js CHANGED
@@ -21,7 +21,7 @@ import { FileSystemSkillProvider } from '@deepseek-ai/dsh-skill-filesystem';
21
21
  export const name = 'euthyna';
22
22
 
23
23
  /** Without the skill registry there is nothing to register onto. */
24
- export const inject = ['skills'];
24
+ export const inject = ['skills', 'commands'];
25
25
 
26
26
  /** This module's directory: `<package>/plugin`, in the repo and in the tarball alike. */
27
27
  const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
@@ -57,4 +57,33 @@ export function apply(ctx, config = {}) {
57
57
  customSkillDirs: [root],
58
58
  watch: false,
59
59
  }));
60
+
61
+ // A DSH slash command is a UI shortcut, not a model prompt: the handler
62
+ // runs and returns text without reaching the model. /euthyna therefore
63
+ // returns the usage manual rather than trying to run an audit itself —
64
+ // the audit itself is triggered by naming the skill in conversation.
65
+ ctx.commands.register({
66
+ name: 'euthyna',
67
+ description: '查看 euthyna 代码安全审计的使用方法',
68
+ handler: () => ({
69
+ kind: 'success',
70
+ text: [
71
+ 'euthyna —— 代码安全审计的判定纪律与交付门禁',
72
+ '',
73
+ '怎么触发审计(三选一):',
74
+ '1. 对话里直接说「用 euthyna 审计 <路径>,审完再交付」',
75
+ '2. 新开会话后在技能列表点 euthyna',
76
+ '3. 本命令只返回说明,不执行审计',
77
+ '',
78
+ 'CLI 测量工具(终端里跑):',
79
+ ' euthyna history --base <基线> --repo <仓库> # 删除行归因到提交',
80
+ ' euthyna coverage --coverage <文件> --symbol <符号>',
81
+ ' euthyna deps --repo <仓库> --dep <包名>',
82
+ ' euthyna gate <报告.md> [--verify] # 六门禁机械校验',
83
+ '',
84
+ '退出码:0=干净 / 10=有安全发现 / 1=用法错 / 2=无法测量',
85
+ '报告:<项目>_EUTHYNA_AUDIT_<日期>.md,必须落盘',
86
+ ].join('\n'),
87
+ }),
88
+ });
60
89
  }
package/plugin.yaml ADDED
@@ -0,0 +1,8 @@
1
+ name: euthyna
2
+ version: 0.3.0
3
+ description: euthyna audit discipline — slash commands that drive the euthyna fact-producing CLI (history / coverage / deps / gate)
4
+ license: Apache-2.0
5
+ homepage: https://github.com/slow-stack/euthyna
6
+ tags:
7
+ - security
8
+ - audit
package/src/cli.js CHANGED
@@ -22,6 +22,7 @@ import { collectDependencyFacts } 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
24
  import { repoToplevel, revParse } from './git.js';
25
+ import { resolveLang, T, isLang, DEFAULT_LANG } from './lang.js';
25
26
 
26
27
  export const EXIT = Object.freeze({
27
28
  CLEAN: 0,
@@ -33,20 +34,32 @@ export const EXIT = Object.freeze({
33
34
  const HISTORY_PRODUCER = {
34
35
  name: 'euthyna-history',
35
36
  version: '0.1.0',
36
- purpose: '被删除代码的来源归属与安全分类'
37
+ purpose: '被删除代码的来源归属与安全分类',
38
+ purposeEn: 'attribution and security classification of deleted code'
37
39
  };
38
40
  const COVERAGE_PRODUCER = {
39
41
  name: 'euthyna-coverage',
40
42
  version: '0.1.0',
41
- purpose: '符号调用计数(只能证伪)'
43
+ purpose: '符号调用计数(只能证伪)',
44
+ purposeEn: 'symbol invocation counts (can only falsify)'
42
45
  };
43
46
  const DEPS_PRODUCER = {
44
47
  name: 'euthyna-deps',
45
48
  version: '0.1.0',
46
- purpose: '依赖锁定版本(读取 lockfile,不含漏洞判定)'
49
+ purpose: '依赖锁定版本(读取 lockfile,不含漏洞判定)',
50
+ purposeEn: 'dependency pinned versions (reads lockfiles, no vulnerability verdict)'
47
51
  };
48
52
 
49
- const HELP = `
53
+ /** The producer object that goes into a report, with its purpose in the active language. */
54
+ function producerFor(producer, lang) {
55
+ return {
56
+ name: producer.name,
57
+ version: producer.version,
58
+ purpose: lang === 'en' ? producer.purposeEn : producer.purpose
59
+ };
60
+ }
61
+
62
+ const HELP_ZH = `
50
63
  euthyna —— 给 AI 编码 agent 用的确定性事实产出器
51
64
 
52
65
  它不是扫描器。它只回答两个模型算不准的问题,并把答案写成带证据的事实。
@@ -57,6 +70,8 @@ euthyna —— 给 AI 编码 agent 用的确定性事实产出器
57
70
  euthyna deps [--repo <dir>] [--lockfile <file>] --dep <name> [--dep <name>] [--json]
58
71
  euthyna gate <报告文件> [--verify] [--cwd <dir>] [--json]
59
72
 
73
+ 语言: 默认中文。加 --lang en 或设环境变量 EUTHYNA_LANG=en 切换英文输出。
74
+
60
75
  命令:
61
76
  history 本次变更删掉了哪些代码、它们分别由哪个提交引入、该提交是不是安全修复
62
77
  --base 必填,比较的基线版本(如 main、HEAD~5、某个 commit)
@@ -90,6 +105,64 @@ euthyna —— 给 AI 编码 agent 用的确定性事实产出器
90
105
  注意: 缺数据不等于干净。无法测量的判据会列在输出的「未评估的判据」一节。
91
106
  `;
92
107
 
108
+ const HELP_EN = `
109
+ euthyna — deterministic fact producer for AI coding agents
110
+
111
+ It is not a scanner. It answers only the two questions a model cannot compute
112
+ reliably, and writes the answers as facts with evidence.
113
+
114
+ Usage:
115
+ euthyna history --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--origins] [--json]
116
+ euthyna coverage --coverage <file> --symbol <name> [--file <path>] [--json]
117
+ euthyna deps [--repo <dir>] [--lockfile <file>] --dep <name> [--dep <name>] [--json]
118
+ euthyna gate <report file> [--verify] [--cwd <dir>] [--json]
119
+
120
+ Language: Chinese by default. Pass --lang en or set the environment variable
121
+ EUTHYNA_LANG=en for English output.
122
+
123
+ Commands:
124
+ history Which lines did this change delete, which commit introduced them,
125
+ and was that commit a security fix?
126
+ --base required, the baseline revision to compare against (main, HEAD~5, a commit)
127
+ --head optional, defaults to HEAD
128
+ --pickaxe additionally check added lines that were once removed and now return (probe-capped)
129
+ --origins attribute deleted lines to the commit that FIRST introduced
130
+ their content with git log -S, instead of blame's last modifier
131
+ (probe-capped, slower than the default)
132
+ coverage Was this symbol ever actually invoked by a test run?
133
+ --coverage the coverage data file, c8's coverage-final.json
134
+ --symbol symbol name(s) to query, repeatable
135
+ --file optional, restrict to one file
136
+ deps What version does the lockfile pin or declare for a dependency? (the basis for supply-chain claims)
137
+ --repo optional, directory containing the manifest (default cwd, auto-detected)
138
+ --lockfile optional, explicit manifest path (package-lock.json / Cargo.lock / go.mod)
139
+ --dep dependency name(s) to query, repeatable
140
+ Note: reports version facts only, never "is vulnerable" — version-to-CVE mapping
141
+ belongs to the adjudication layer
142
+ gate Check an audit report against the six-gate contract (no measurement; only the report's self-declared claims)
143
+ <report file> the report's markdown file; the adjudication format is defined in the skill SKILL.md
144
+ --verify re-run each TRUE POSITIVE's reproduce command (as argv, no shell);
145
+ only git commands run by default; ⚠ runs with your privileges,
146
+ use it only on reports you trust
147
+ --allow-exec allow --verify to run interpreter commands (node/npm/python) —
148
+ they can execute arbitrary code from the report; passing it
149
+ is you endorsing that report
150
+ --cwd <dir> working directory for --verify (default cwd)
151
+
152
+ Exit codes:
153
+ 0 measured, nothing security-classified found; or a gate report fully passes the contract
154
+ 10 measured, at least one security-classified fact; or a gate report has findings downgraded
155
+ 1 usage error
156
+ 2 could not measure at all (must NOT be read as clean); or a gate report is unreadable or has no checkable finding
157
+
158
+ Note: missing data is not clean. Criteria that could not be measured are listed in the
159
+ "not evaluated" section of the output.
160
+ `;
161
+
162
+ function helpText(lang) {
163
+ return lang === 'en' ? HELP_EN : HELP_ZH;
164
+ }
165
+
93
166
  /**
94
167
  * Minimal argv parser: `--key value`, `--flag`, and repeated `--key` flags.
95
168
  *
@@ -134,7 +207,8 @@ function asArray(value) {
134
207
  return Array.isArray(value) ? value : [value];
135
208
  }
136
209
 
137
- async function runHistory(flags) {
210
+ async function runHistory(flags, lang) {
211
+ const t = T(lang);
138
212
  const cwd = path.resolve(flags.repo ? String(flags.repo) : process.cwd());
139
213
 
140
214
  const toplevel = await repoToplevel(cwd);
@@ -142,28 +216,40 @@ async function runHistory(flags) {
142
216
  return {
143
217
  exit: EXIT.UNMEASURED,
144
218
  report: makeReport({
145
- producer: HISTORY_PRODUCER,
219
+ producer: producerFor(HISTORY_PRODUCER, lang),
146
220
  subject: { repo: cwd },
147
221
  facts: [],
148
222
  notEvaluated: [
149
- { kind: KIND.HISTORY, reason: `${cwd} 不在任何 git 仓库内,无法测量历史` }
223
+ {
224
+ kind: KIND.HISTORY,
225
+ reason: t(
226
+ `${cwd} 不在任何 git 仓库内,无法测量历史`,
227
+ `${cwd} is not inside any git repository; history cannot be measured`
228
+ )
229
+ }
150
230
  ]
151
231
  })
152
232
  };
153
233
  }
154
234
 
155
235
  if (!flags.base) {
156
- return { exit: EXIT.USAGE, error: 'history 需要 --base <rev>' };
236
+ return { exit: EXIT.USAGE, error: t('history 需要 --base <rev>', 'history requires --base <rev>') };
157
237
  }
158
238
 
159
239
  const base = await revParse(toplevel, String(flags.base));
160
240
  if (!base) {
161
- return { exit: EXIT.USAGE, error: `无法解析 --base "${flags.base}" 为一个提交` };
241
+ return {
242
+ exit: EXIT.USAGE,
243
+ error: t(`无法解析 --base "${flags.base}" 为一个提交`, `cannot resolve --base "${flags.base}" to a commit`)
244
+ };
162
245
  }
163
246
 
164
247
  const head = await revParse(toplevel, String(flags.head ?? 'HEAD'));
165
248
  if (!head) {
166
- return { exit: EXIT.USAGE, error: `无法解析 --head "${flags.head ?? 'HEAD'}" 为一个提交` };
249
+ return {
250
+ exit: EXIT.USAGE,
251
+ error: t(`无法解析 --head "${flags.head ?? 'HEAD'}" 为一个提交`, `cannot resolve --head "${flags.head ?? 'HEAD'}" to a commit`)
252
+ };
167
253
  }
168
254
 
169
255
  const { facts, evaluated, notEvaluated, measured } = await collectHistoryFacts({
@@ -171,11 +257,12 @@ async function runHistory(flags) {
171
257
  base,
172
258
  head,
173
259
  pickaxe: flags.pickaxe === true,
174
- origins: flags.origins === true
260
+ origins: flags.origins === true,
261
+ lang
175
262
  });
176
263
 
177
264
  const report = makeReport({
178
- producer: HISTORY_PRODUCER,
265
+ producer: producerFor(HISTORY_PRODUCER, lang),
179
266
  subject: { repo: toplevel, base, head },
180
267
  facts,
181
268
  evaluated,
@@ -194,7 +281,8 @@ async function runHistory(flags) {
194
281
  };
195
282
  }
196
283
 
197
- async function runCoverage(flags) {
284
+ async function runCoverage(flags, lang) {
285
+ const t = T(lang);
198
286
  const coverageFile = path.resolve(
199
287
  String(flags.coverage ?? 'coverage/coverage-final.json')
200
288
  );
@@ -203,17 +291,21 @@ async function runCoverage(flags) {
203
291
 
204
292
  // Asking for nothing is a caller mistake, not a measurement failure.
205
293
  if (symbols.length === 0) {
206
- return { exit: EXIT.USAGE, error: 'coverage 需要至少一个 --symbol <name>' };
294
+ return {
295
+ exit: EXIT.USAGE,
296
+ error: t('coverage 需要至少一个 --symbol <name>', 'coverage requires at least one --symbol <name>')
297
+ };
207
298
  }
208
299
 
209
300
  const targets = symbols.map(symbol => ({ symbol, ...(file ? { file } : {}) }));
210
301
  const { facts, evaluated, notEvaluated, measured } = await collectCoverageFacts({
211
302
  coverageFile,
212
- targets
303
+ targets,
304
+ lang
213
305
  });
214
306
 
215
307
  const report = makeReport({
216
- producer: COVERAGE_PRODUCER,
308
+ producer: producerFor(COVERAGE_PRODUCER, lang),
217
309
  subject: { coverageFile, symbols, file },
218
310
  facts,
219
311
  evaluated,
@@ -223,25 +315,27 @@ async function runCoverage(flags) {
223
315
  return { exit: measured ? EXIT.CLEAN : EXIT.UNMEASURED, report };
224
316
  }
225
317
 
226
- async function runDeps(flags) {
318
+ async function runDeps(flags, lang) {
319
+ const t = T(lang);
227
320
  const cwd = path.resolve(flags.repo ? String(flags.repo) : process.cwd());
228
321
  const deps = asArray(flags.dep).map(String);
229
322
  const lockfile = flags.lockfile ? String(flags.lockfile) : undefined;
230
323
 
231
324
  // Asking for nothing is a caller mistake, not a measurement failure.
232
325
  if (deps.length === 0) {
233
- return { exit: EXIT.USAGE, error: 'deps 需要至少一个 --dep <name>' };
326
+ return { exit: EXIT.USAGE, error: t('deps 需要至少一个 --dep <name>', 'deps requires at least one --dep <name>') };
234
327
  }
235
328
 
236
329
  const { facts, evaluated, notEvaluated, measured } = await collectDependencyFacts({
237
330
  cwd,
238
331
  lockfile,
239
- deps
332
+ deps,
333
+ lang
240
334
  });
241
335
 
242
336
  const report = makeReport({
243
- producer: DEPS_PRODUCER,
244
- subject: { repo: cwd, lockfile: lockfile ?? '(自动检测)', deps },
337
+ producer: producerFor(DEPS_PRODUCER, lang),
338
+ subject: { repo: cwd, lockfile: lockfile ?? t('(自动检测)', '(auto-detect)'), deps },
245
339
  facts,
246
340
  evaluated,
247
341
  notEvaluated
@@ -249,10 +343,17 @@ async function runDeps(flags) {
249
343
 
250
344
  return { exit: measured ? EXIT.CLEAN : EXIT.UNMEASURED, report };
251
345
  }
252
- async function runGate(flags, positional) {
346
+ async function runGate(flags, positional, lang) {
347
+ const t = T(lang);
253
348
  const file = positional[1];
254
349
  if (!file) {
255
- return { exit: EXIT.USAGE, error: 'gate 需要 <报告文件>(markdown,裁定格式见技能 SKILL.md)' };
350
+ return {
351
+ exit: EXIT.USAGE,
352
+ error: t(
353
+ 'gate 需要 <报告文件>(markdown,裁定格式见技能 SKILL.md)',
354
+ 'gate requires <report file> (markdown; the adjudication format is defined in the skill SKILL.md)'
355
+ )
356
+ };
256
357
  }
257
358
 
258
359
  let text;
@@ -261,17 +362,27 @@ async function runGate(flags, positional) {
261
362
  } catch (error) {
262
363
  // An unreadable report is not a clean pass: there is nothing to check, and
263
364
  // "nothing was checked" must not read as "everything passed".
264
- return { exit: EXIT.UNMEASURED, error: `无法读取报告 ${file}: ${error.message}` };
365
+ return { exit: EXIT.UNMEASURED, error: t(`无法读取报告 ${file}: ${error.message}`, `cannot read report ${file}: ${error.message}`) };
265
366
  }
266
367
 
267
368
  const { findings, unparseable } = parseGateReport(text);
268
369
  if (findings.length === 0 && unparseable.length === 0) {
269
- return { exit: EXIT.UNMEASURED, error: `报告 ${file} 中没有可校验的 finding(需要 BUG #N <VERDICT> — 说明 形式)` };
370
+ return {
371
+ exit: EXIT.UNMEASURED,
372
+ error: t(
373
+ `报告 ${file} 中没有可校验的 finding(需要 BUG #N <VERDICT> — 说明 形式)`,
374
+ `report ${file} contains no checkable finding (needs "BUG #N <VERDICT> — claim" lines)`
375
+ )
376
+ };
270
377
  }
271
378
 
272
- const validated = validateFindings(findings);
379
+ const validated = validateFindings(findings, { lang });
273
380
  for (const line of unparseable) {
274
- validated.push({ unparseableLine: line, violations: ['无法解析的 BUG 行'], downgraded: true });
381
+ validated.push({
382
+ unparseableLine: line,
383
+ violations: [t('无法解析的 BUG 行', 'unparseable BUG line')],
384
+ downgraded: true
385
+ });
275
386
  }
276
387
 
277
388
  if (flags.verify) {
@@ -286,16 +397,33 @@ async function runGate(flags, positional) {
286
397
  // interpreter awaiting consent, an unparseable command and a failed run
287
398
  // are different failures, but none of them is a verified reproduction.
288
399
  if (v.status === 'failed') {
289
- entry.violations.push(`复现命令未通过(exit ${v.exitCode ?? '?'}${v.detail ? `: ${v.detail}` : ''})`);
400
+ entry.violations.push(
401
+ t(
402
+ `复现命令未通过(exit ${v.exitCode ?? '?'}${v.detail ? `: ${v.detail}` : ''})`,
403
+ `reproduce command failed (exit ${v.exitCode ?? '?'}${v.detail ? `: ${v.detail}` : ''})`
404
+ )
405
+ );
290
406
  entry.downgraded = true;
291
407
  } else if (v.status === 'refused') {
292
- entry.violations.push(`复现命令被拒绝(${v.tool} 不在白名单)——复现未验证`);
408
+ entry.violations.push(
409
+ t(
410
+ `复现命令被拒绝(${v.tool} 不在白名单)——复现未验证`,
411
+ `reproduce command refused (${v.tool} is not on the allowlist) — reproduction not verified`
412
+ )
413
+ );
293
414
  entry.downgraded = true;
294
415
  } else if (v.status === 'needs-consent') {
295
- entry.violations.push(`复现命令是解释器命令(${v.tool}),未执行——复现未验证;信任该报告时加 --allow-exec`);
416
+ entry.violations.push(
417
+ t(
418
+ `复现命令是解释器命令(${v.tool}),未执行——复现未验证;信任该报告时加 --allow-exec`,
419
+ `reproduce command is an interpreter command (${v.tool}), not run — reproduction not verified; add --allow-exec if you trust the report`
420
+ )
421
+ );
296
422
  entry.downgraded = true;
297
423
  } else if (v.status === 'unparseable') {
298
- entry.violations.push('复现命令无法拆分为 argv——复现未验证');
424
+ entry.violations.push(
425
+ t('复现命令无法拆分为 argv——复现未验证', 'reproduce command could not be split into argv — reproduction not verified')
426
+ );
299
427
  entry.downgraded = true;
300
428
  }
301
429
  // no-command is already a structural violation (a TRUE POSITIVE without a
@@ -336,7 +464,7 @@ async function runGate(flags, positional) {
336
464
  };
337
465
  }
338
466
 
339
- renderGateReport(result);
467
+ renderGateReport(result, { lang });
340
468
  return { exit: downgraded > 0 ? EXIT.FLAGGED : EXIT.CLEAN, report: null };
341
469
  }
342
470
 
@@ -345,27 +473,47 @@ export async function main(argv = process.argv.slice(2)) {
345
473
  const { positional, flags } = parseArgs(argv);
346
474
  const command = positional[0];
347
475
 
476
+ // A --lang that is present but not a known language is a usage error: a
477
+ // typo must not silently fall back to the default and produce a different
478
+ // language than the caller asked for.
479
+ const flagLang = flags.lang === undefined ? undefined : String(flags.lang);
480
+ if (flags.lang !== undefined && !isLang(flagLang)) {
481
+ const lang = resolveLang({ env: process.env });
482
+ process.stderr.write(
483
+ safeTextLines(
484
+ T(lang)(
485
+ `无法识别的语言 "${flagLang}"(可用:zh / en)`,
486
+ `unrecognized language "${flagLang}" (available: zh / en)`
487
+ ) + '\n'
488
+ )
489
+ );
490
+ return EXIT.USAGE;
491
+ }
492
+
493
+ const lang = resolveLang({ flag: flagLang });
494
+ const t = T(lang);
495
+
348
496
  if (!command || command === 'help' || flags.help) {
349
- process.stdout.write(HELP);
497
+ process.stdout.write(helpText(lang));
350
498
  return EXIT.CLEAN;
351
499
  }
352
500
 
353
501
  let result;
354
502
  if (command === 'history') {
355
- result = await runHistory(flags);
503
+ result = await runHistory(flags, lang);
356
504
  } else if (command === 'coverage') {
357
- result = await runCoverage(flags);
505
+ result = await runCoverage(flags, lang);
358
506
  } else if (command === 'deps') {
359
- result = await runDeps(flags);
507
+ result = await runDeps(flags, lang);
360
508
  } else if (command === 'gate') {
361
- result = await runGate(flags, positional);
509
+ result = await runGate(flags, positional, lang);
362
510
  } else {
363
511
  // stderr boundary, mirroring the render boundary in contract.js: text that
364
512
  // reaches the error channel may carry user or repo-controlled bytes (an
365
513
  // unknown command echoes argv; a usage error embeds flag values), so it is
366
- // made terminal-safe once, here. HELP is multi-line and static; safeTextLines
367
- // preserves its line structure and is a no-op on its plain text.
368
- process.stderr.write(safeTextLines(`未知命令: ${command}\n${HELP}`));
514
+ // made terminal-safe once, here. helpText is multi-line and static;
515
+ // safeTextLines preserves its line structure and is a no-op on its text.
516
+ process.stderr.write(safeTextLines(`${t('未知命令: ', 'Unknown command: ')}${command}\n${helpText(lang)}`));
369
517
  return EXIT.USAGE;
370
518
  }
371
519
 
@@ -382,7 +530,7 @@ export async function main(argv = process.argv.slice(2)) {
382
530
  if (flags.json) {
383
531
  process.stdout.write(`${JSON.stringify(result.report, null, 2)}\n`);
384
532
  } else if (result.report) {
385
- renderReport(result.report);
533
+ renderReport(result.report, { lang });
386
534
  }
387
535
 
388
536
  return result.exit;