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.
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { git, commitSummaries } from '../git.js';
13
13
  import { makeFact, notEvaluated as notEvaluatedEntry, KIND, STATUS, shellQuote } from '../contract.js';
14
+ import { T, DEFAULT_LANG } from '../lang.js';
14
15
 
15
16
  /**
16
17
  * Strong signal: the commit is about security.
@@ -216,8 +217,10 @@ export async function collectHistoryFacts({
216
217
  origins = false,
217
218
  maxOrigins = 40,
218
219
  securityPattern = SECURITY_PATTERN,
219
- fixPattern = FIX_PATTERN
220
+ fixPattern = FIX_PATTERN,
221
+ lang = DEFAULT_LANG
220
222
  } = {}) {
223
+ const t = T(lang);
221
224
  const facts = [];
222
225
  const evaluated = [];
223
226
  const notEvaluated = [];
@@ -229,7 +232,13 @@ export async function collectHistoryFacts({
229
232
 
230
233
  if (files.length === 0) {
231
234
  notEvaluated.push(
232
- notEvaluatedEntry('history', `范围 ${base}..${head} 内没有文件变更,没有可归属的删除行`)
235
+ notEvaluatedEntry(
236
+ 'history',
237
+ t(
238
+ `范围 ${base}..${head} 内没有文件变更,没有可归属的删除行`,
239
+ `No file changes in ${base}..${head}; no deleted lines to attribute`
240
+ )
241
+ )
233
242
  );
234
243
  return { facts, evaluated, notEvaluated, files, measured: true };
235
244
  }
@@ -271,8 +280,12 @@ export async function collectHistoryFacts({
271
280
  notEvaluated.push(
272
281
  notEvaluatedEntry(
273
282
  'history',
274
- `范围 ${base}..${head} 内有 ${files.length} 个文件变更,但没有任何一行被删除,` +
275
- '因此没有可归属的历史来源'
283
+ t(
284
+ `范围 ${base}..${head} 内有 ${files.length} 个文件变更,但没有任何一行被删除,` +
285
+ '因此没有可归属的历史来源',
286
+ `Range ${base}..${head} changes ${files.length} file(s) but deletes no lines; ` +
287
+ 'there is no attributable history source'
288
+ )
276
289
  )
277
290
  );
278
291
  }
@@ -369,7 +382,10 @@ export async function collectHistoryFacts({
369
382
  notEvaluated.push(
370
383
  notEvaluatedEntry(
371
384
  'history-origins',
372
- `git log -S 来源探针上限 ${maxOrigins} 已用尽,其余删除行保留 blame 归属`
385
+ t(
386
+ `git log -S 来源探针上限 ${maxOrigins} 已用尽,其余删除行保留 blame 归属`,
387
+ `git log -S origin probe cap (${maxOrigins}) exhausted; remaining deleted lines keep their blame attribution`
388
+ )
373
389
  )
374
390
  );
375
391
  }
@@ -377,8 +393,12 @@ export async function collectHistoryFacts({
377
393
  notEvaluated.push(
378
394
  notEvaluatedEntry(
379
395
  'history-origins',
380
- `${shortSkipped} 行内容过短(< ${MIN_PICKAXE_LINE_LENGTH} 字符),` +
381
- '未做来源追溯(太通用时 -S 会指向整个历史的第一个提交而非真实来源),保留 blame 归属'
396
+ t(
397
+ `${shortSkipped} 行内容过短(< ${MIN_PICKAXE_LINE_LENGTH} 字符),` +
398
+ '未做来源追溯(太通用时 -S 会指向整个历史的第一个提交而非真实来源),保留 blame 归属',
399
+ `${shortSkipped} line(s) too short (< ${MIN_PICKAXE_LINE_LENGTH} chars); ` +
400
+ 'no origin tracing done (for generic content, -S points at the first commit of the whole history rather than a real origin); blame attribution kept'
401
+ )
382
402
  )
383
403
  );
384
404
  }
@@ -386,8 +406,12 @@ export async function collectHistoryFacts({
386
406
  notEvaluated.push(
387
407
  notEvaluatedEntry(
388
408
  'history-origins',
389
- '未启用 --origins,删除行的归属基于 git blame 的「最后修改」语义;' +
390
- '被删行本身或提交 diff 含安全关键词时仍会标为 security'
409
+ t(
410
+ '未启用 --origins,删除行的归属基于 git blame 的「最后修改」语义;' +
411
+ '被删行本身或提交 diff 含安全关键词时仍会标为 security',
412
+ '--origins not enabled; deleted lines are attributed by git blame\'s "last modified" semantics; ' +
413
+ 'lines whose content or commit diff contains security vocabulary are still classified security'
414
+ )
391
415
  )
392
416
  );
393
417
  }
@@ -395,8 +419,9 @@ export async function collectHistoryFacts({
395
419
  // Split every blame group into its remainder (blame attribution) and its
396
420
  // refined subsets (one fact per origin commit). Each becomes a fact.
397
421
  const pending = [];
422
+ const unreadableSubject = () => t('(提交信息不可读)', '(unreadable commit message)');
398
423
  for (const [hash, entry] of blameByCommit) {
399
- const summary = summaries.get(hash) ?? { subject: '(提交信息不可读)', author: null, date: null };
424
+ const summary = summaries.get(hash) ?? { subject: unreadableSubject(), author: null, date: null };
400
425
  const groups = originGroups.get(hash);
401
426
  if (!groups) {
402
427
  pending.push({ hash, entry, summary, originMethod: 'blame' });
@@ -436,7 +461,7 @@ export async function collectHistoryFacts({
436
461
  lines: [...group.byFile.values()].reduce((sum, a) => sum + a.length, 0),
437
462
  byFile: group.byFile
438
463
  },
439
- summary: group.summary ?? { subject: '(提交信息不可读)', author: null, date: null },
464
+ summary: group.summary ?? { subject: unreadableSubject(), author: null, date: null },
440
465
  originMethod: 'pickaxe',
441
466
  blameCommit: hash,
442
467
  contents: group.contents
@@ -493,13 +518,25 @@ export async function collectHistoryFacts({
493
518
  // the wrong place and makes an accurate attribution look wrong.
494
519
  const originPhrase =
495
520
  originMethod === 'pickaxe'
496
- ? `本次变更删除了 ${entry.lines} 行代码,其内容最初由提交 ${hash.slice(0, 10)} 引入` +
497
- `(git blame 的最后修改者是 ${blameCommit.slice(0, 10)})`
498
- : `本次变更删除了 ${entry.lines} 行来自提交 ${hash.slice(0, 10)} 的代码`;
521
+ ? t(
522
+ `本次变更删除了 ${entry.lines} 行代码,其内容最初由提交 ${hash.slice(0, 10)} 引入` +
523
+ `(git blame 的最后修改者是 ${blameCommit.slice(0, 10)})`,
524
+ `This change deleted ${entry.lines} lines whose content was first introduced by commit ${hash.slice(0, 10)} ` +
525
+ `(git blame's last modifier is ${blameCommit.slice(0, 10)})`
526
+ )
527
+ : t(
528
+ `本次变更删除了 ${entry.lines} 行来自提交 ${hash.slice(0, 10)} 的代码`,
529
+ `This change deleted ${entry.lines} lines of code from commit ${hash.slice(0, 10)}`
530
+ );
499
531
  const statement =
500
532
  originPhrase +
501
- (fileCount > 1 ? `,分布在 ${fileCount} 个文件` : `(文件:${byFile[0].file})`) +
502
- `。提交信息:${JSON.stringify(summary.subject)},分类:${classification}`;
533
+ (fileCount > 1
534
+ ? t(`,分布在 ${fileCount} 个文件`, `, spread across ${fileCount} files`)
535
+ : t(`(文件:${byFile[0].file})`, ` (file: ${byFile[0].file})`)) +
536
+ t(
537
+ `。提交信息:${JSON.stringify(summary.subject)},分类:${classification}`,
538
+ `. Commit message: ${JSON.stringify(summary.subject)}, classification: ${classification}`
539
+ );
503
540
 
504
541
  const detail = {
505
542
  classification,
@@ -514,8 +551,14 @@ export async function collectHistoryFacts({
514
551
  // covers lines in the other files too.
515
552
  reproductionNote:
516
553
  fileCount > 1
517
- ? `上面的命令只复现「${primary.file}」中的归属;其余 ${fileCount - 1} 个文件的行区间见 byFile`
518
- : '上面的命令复现本事实涉及的全部行'
554
+ ? t(
555
+ `上面的命令只复现「${primary.file}」中的归属;其余 ${fileCount - 1} 个文件的行区间见 byFile`,
556
+ `The command above only reproduces the attribution for "${primary.file}"; the other ${fileCount - 1} file(s) line ranges are in byFile`
557
+ )
558
+ : t(
559
+ '上面的命令复现本事实涉及的全部行',
560
+ 'The command above reproduces every line this fact covers'
561
+ )
519
562
  };
520
563
 
521
564
  if (originMethod === 'pickaxe') {
@@ -552,7 +595,13 @@ export async function collectHistoryFacts({
552
595
 
553
596
  if (ranked.length === 0 && linesByFile.size > 0) {
554
597
  notEvaluated.push(
555
- notEvaluatedEntry('history', '有删除行但 blame 未能归属到任何提交(可能是二进制文件或路径异常)')
598
+ notEvaluatedEntry(
599
+ 'history',
600
+ t(
601
+ '有删除行但 blame 未能归属到任何提交(可能是二进制文件或路径异常)',
602
+ 'Deleted lines exist but blame could not attribute them to any commit (possibly binary files or path anomalies)'
603
+ )
604
+ )
556
605
  );
557
606
  }
558
607
 
@@ -565,7 +614,8 @@ export async function collectHistoryFacts({
565
614
  maxPickaxe,
566
615
  securityPattern,
567
616
  fixPattern,
568
- startCounter: counter
617
+ startCounter: counter,
618
+ lang
569
619
  });
570
620
  facts.push(...reintro.facts);
571
621
  counter = reintro.counter;
@@ -573,7 +623,10 @@ export async function collectHistoryFacts({
573
623
  notEvaluated.push(...reintro.notEvaluated);
574
624
  } else {
575
625
  notEvaluated.push(
576
- notEvaluatedEntry('reintroduction', '未启用 --pickaxe,未检查「被移除又加回」的代码行')
626
+ notEvaluatedEntry(
627
+ 'reintroduction',
628
+ t('未启用 --pickaxe,未检查「被移除又加回」的代码行', '--pickaxe not enabled; reintroduced lines were not checked')
629
+ )
577
630
  );
578
631
  }
579
632
 
@@ -602,8 +655,10 @@ async function collectReintroductionFacts({
602
655
  maxPickaxe,
603
656
  securityPattern,
604
657
  fixPattern,
605
- startCounter
658
+ startCounter,
659
+ lang = DEFAULT_LANG
606
660
  }) {
661
+ const t = T(lang);
607
662
  const facts = [];
608
663
  const notEvaluated = [];
609
664
  let counter = startCounter;
@@ -654,9 +709,15 @@ async function collectReintroductionFacts({
654
709
  id: `reintroduction-${++counter}`,
655
710
  kind: KIND.REINTRODUCTION,
656
711
  statement:
657
- `本次变更加回了一行在 ${base} 中不存在的代码,而该行的出现次数曾被 ${hashes.length} 个提交改变` +
712
+ t(
713
+ `本次变更加回了一行在 ${base} 中不存在的代码,而该行的出现次数曾被 ${hashes.length} 个提交改变`,
714
+ `This change re-adds a line that did not exist at ${base}, and whose occurrence count was changed by ${hashes.length} commit(s)`
715
+ ) +
658
716
  (securityOrigin
659
- ? `,其中提交 ${securityOrigin.hash.slice(0, 10)} 的提交信息含安全关键词`
717
+ ? t(
718
+ `,其中提交 ${securityOrigin.hash.slice(0, 10)} 的提交信息含安全关键词`,
719
+ `, and commit ${securityOrigin.hash.slice(0, 10)} has security vocabulary in its message`
720
+ )
660
721
  : ''),
661
722
  status: STATUS.ESTABLISHED,
662
723
  evidence: { file, snippet: line.slice(0, 160), commit: hashes[0] },
@@ -682,7 +743,10 @@ async function collectReintroductionFacts({
682
743
  notEvaluated.push(
683
744
  notEvaluatedEntry(
684
745
  'reintroduction',
685
- `pickaxe 探针上限 ${maxPickaxe} 已用尽,另有 ${candidates - probed} 行新增代码未检查`
746
+ t(
747
+ `pickaxe 探针上限 ${maxPickaxe} 已用尽,另有 ${candidates - probed} 行新增代码未检查`,
748
+ `pickaxe probe cap (${maxPickaxe}) exhausted; ${candidates - probed} added line(s) were not checked`
749
+ )
686
750
  )
687
751
  );
688
752
  }
package/src/gate.js CHANGED
@@ -25,6 +25,7 @@
25
25
  import { execFile } from 'node:child_process';
26
26
  import { promisify } from 'node:util';
27
27
  import { safeTextLines } from './contract.js';
28
+ import { T, DEFAULT_LANG } from './lang.js';
28
29
 
29
30
  const execFileAsync = promisify(execFile);
30
31
 
@@ -40,6 +41,30 @@ export const GATE_NAMES = Object.freeze({
40
41
  6: '环境'
41
42
  });
42
43
 
44
+ /** The gate statuses as written in a report, in either language. */
45
+ const GATE_STATUSES = Object.freeze({
46
+ PASS: 'PASS',
47
+ FAIL: 'FAIL',
48
+ PASS_ZH: '通过',
49
+ NOT_EVALUATED_ZH: '未评估',
50
+ NOT_EVALUATED_EN: 'NOT EVALUATED'
51
+ });
52
+
53
+ /** True when a parsed status means "the gate passed". */
54
+ export function isPassStatus(status) {
55
+ return status === GATE_STATUSES.PASS || status === GATE_STATUSES.PASS_ZH;
56
+ }
57
+
58
+ /** True when a parsed status means "the gate failed". */
59
+ export function isFailStatus(status) {
60
+ return status === GATE_STATUSES.FAIL;
61
+ }
62
+
63
+ /** True when a parsed status means "the gate could not be evaluated". */
64
+ export function isNotEvaluatedStatus(status) {
65
+ return status === GATE_STATUSES.NOT_EVALUATED_ZH || /^not evaluated$/i.test(status);
66
+ }
67
+
43
68
  /**
44
69
  * A finding header: `BUG #N <VERDICT> — <claim>`. The em dash is the skill's
45
70
  * form, a hyphen is tolerated, and the claim may be empty (that is itself a
@@ -50,16 +75,23 @@ const HEADER = /^BUG\s+#?(\d+)\s+(TRUE POSITIVE|FALSE POSITIVE|INCONCLUSIVE)\s*(
50
75
  /** A line that starts like a finding but does not parse - reported, not dropped. */
51
76
  const BUGLIKE = /^BUG\b/i;
52
77
 
78
+ /**
79
+ * Field markers, accepted in either language. The parser is bilingual on
80
+ * purpose and independent of the output language: a report is data, and a
81
+ * Chinese-written report must validate under an English session and vice
82
+ * versa. The English keywords are case-insensitive; the Chinese ones are
83
+ * unaffected by the flag.
84
+ */
53
85
  const FIELDS = {
54
- evidence: /^证据\s*[::]\s*(.+)$/,
55
- reproduce: /^复现\s*[::]\s*(.+)$/,
56
- impact: /^影响\s*[::]\s*(.+)$/,
57
- exploitability: /^可利用性\s*[::]\s*(.+)$/,
58
- gate: /^门禁\s*(\d+)\s*(?:[((]([^))]*)[))])?\s*(PASS|通过|FAIL|未评估)\s*[::]?\s*(.*)$/
86
+ evidence: /^(?:证据|Evidence)\s*[::]\s*(.+)$/i,
87
+ reproduce: /^(?:复现|Reproduce)\s*[::]\s*(.+)$/i,
88
+ impact: /^(?:影响|Impact)\s*[::]\s*(.+)$/i,
89
+ exploitability: /^(?:可利用性|Exploitability)\s*[::]\s*(.+)$/i,
90
+ gate: /^(?:门禁|Gate)\s*(\d+)\s*(?:[((]([^))]*)[))])?\s*(PASS|通过|FAIL|未评估|NOT EVALUATED|Not evaluated)\s*[::]?\s*(.*)$/i
59
91
  };
60
92
 
61
- /** `门禁全部通过。` may share its line with the evidence that follows it. */
62
- const ALL_PASS = /^门禁全部通过[。.]?/;
93
+ /** `门禁全部通过。` / `All gates passed.` may share its line with the evidence that follows it. */
94
+ const ALL_PASS = /^(?:门禁全部通过|All gates passed)[。.]?\s*/i;
63
95
 
64
96
  /**
65
97
  * Extract {file, line, commit} from an evidence string. Both `src/a.js:123`
@@ -178,43 +210,113 @@ export function parseGateReport(text) {
178
210
  * `violations` names every way it fails, `downgraded` is true when any fail.
179
211
  * Unparseable BUG-like lines are returned separately as downgrade candidates.
180
212
  */
181
- export function validateFindings(findings) {
213
+ export function validateFindings(findings, { lang = DEFAULT_LANG } = {}) {
214
+ const t = T(lang);
182
215
  return findings.map((finding) => {
183
216
  const violations = [];
184
- const fails = finding.gates.filter((g) => g.status === 'FAIL');
185
- const notEval = finding.gates.filter((g) => g.status === '未评估');
186
- const passes = finding.gates.filter((g) => g.status === 'PASS' || g.status === '通过');
217
+ const fails = finding.gates.filter((g) => isFailStatus(g.status));
218
+ const notEval = finding.gates.filter((g) => isNotEvaluatedStatus(g.status));
219
+ const passes = finding.gates.filter((g) => isPassStatus(g.status));
187
220
 
188
221
  for (const gate of finding.gates) {
189
222
  if (!(gate.number in GATE_NAMES)) {
190
- violations.push(`门禁编号 ${gate.number} 不在 1..6 内`);
223
+ violations.push(
224
+ t(`门禁编号 ${gate.number} 不在 1..6 内`, `gate number ${gate.number} is not in 1..6`)
225
+ );
191
226
  }
192
- if ((gate.status === 'FAIL' || gate.status === '未评估') && !gate.reason.trim()) {
193
- violations.push(`门禁 ${gate.number} ${gate.status} 缺少理由`);
227
+ if ((isFailStatus(gate.status) || isNotEvaluatedStatus(gate.status)) && !gate.reason.trim()) {
228
+ violations.push(
229
+ t(`门禁 ${gate.number} ${gate.status} 缺少理由`, `gate ${gate.number} ${gate.status} lacks a reason`)
230
+ );
194
231
  }
195
232
  }
196
233
 
197
- if (!finding.claim.trim()) violations.push('缺少结论描述(BUG 标题后的说明)');
234
+ if (!finding.claim.trim()) {
235
+ violations.push(
236
+ t('缺少结论描述(BUG 标题后的说明)', 'missing claim description (the note after the BUG header)')
237
+ );
238
+ }
198
239
 
199
240
  if (finding.verdict === 'TRUE POSITIVE') {
200
241
  if (!finding.evidence) {
201
- violations.push('缺少证据(TRUE POSITIVE 必须带 证据:path:L123)');
242
+ violations.push(
243
+ t(
244
+ '缺少证据(TRUE POSITIVE 必须带 证据:path:L123)',
245
+ 'missing evidence (a TRUE POSITIVE must carry Evidence: path:L123)'
246
+ )
247
+ );
202
248
  } else if (finding.evidence.line === null) {
203
- violations.push('证据缺少行号(需要 path:L123 形式,禁止只写文件名)');
249
+ violations.push(
250
+ t(
251
+ '证据缺少行号(需要 path:L123 形式,禁止只写文件名)',
252
+ 'evidence lacks a line number (path:L123 form required; a bare file name is not allowed)'
253
+ )
254
+ );
204
255
  }
205
256
  if (!finding.reproduce) {
206
- violations.push('缺少复现命令(TRUE POSITIVE 必须带 复现:<命令> 或 PoC)');
257
+ violations.push(
258
+ t(
259
+ '缺少复现命令(TRUE POSITIVE 必须带 复现:<命令> 或 PoC)',
260
+ 'missing reproduce command (a TRUE POSITIVE must carry Reproduce: <command> or a PoC)'
261
+ )
262
+ );
263
+ }
264
+ if (!finding.impact) {
265
+ violations.push(
266
+ t('缺少影响说明(门禁 3 的证据)', 'missing impact statement (the evidence for gate 3)')
267
+ );
207
268
  }
208
- if (!finding.impact) violations.push('缺少影响说明(门禁 3 的证据)');
209
269
  const allPassed = finding.allPass || passes.length === 6;
210
- if (!allPassed) violations.push('门禁未全部通过(需要 门禁全部通过。 或六条 PASS)');
211
- if (fails.length) violations.push(`TRUE POSITIVE 含 FAIL 门禁(${fails.map((g) => g.number).join(', ')})——应裁定为 FALSE POSITIVE`);
212
- if (notEval.length) violations.push(`TRUE POSITIVE 含未评估门禁(${notEval.map((g) => g.number).join(', ')})——应裁定为 INCONCLUSIVE`);
270
+ if (!allPassed) {
271
+ violations.push(
272
+ t(
273
+ '门禁未全部通过(需要 门禁全部通过。 或六条 PASS)',
274
+ 'not all gates pass (requires "All gates passed." or six PASS lines)'
275
+ )
276
+ );
277
+ }
278
+ if (fails.length) {
279
+ violations.push(
280
+ t(
281
+ `TRUE POSITIVE 含 FAIL 门禁(${fails.map((g) => g.number).join(', ')})——应裁定为 FALSE POSITIVE`,
282
+ `TRUE POSITIVE contains FAIL gates (${fails.map((g) => g.number).join(', ')}) — should be ruled FALSE POSITIVE`
283
+ )
284
+ );
285
+ }
286
+ if (notEval.length) {
287
+ violations.push(
288
+ t(
289
+ `TRUE POSITIVE 含未评估门禁(${notEval.map((g) => g.number).join(', ')})——应裁定为 INCONCLUSIVE`,
290
+ `TRUE POSITIVE contains not-evaluated gates (${notEval.map((g) => g.number).join(', ')}) — should be ruled INCONCLUSIVE`
291
+ )
292
+ );
293
+ }
213
294
  } else if (finding.verdict === 'FALSE POSITIVE') {
214
- if (fails.length === 0) violations.push('FALSE POSITIVE 必须至少有一条 门禁 N FAIL:<具体证据>');
295
+ if (fails.length === 0) {
296
+ violations.push(
297
+ t(
298
+ 'FALSE POSITIVE 必须至少有一条 门禁 N FAIL:<具体证据>',
299
+ 'a FALSE POSITIVE must have at least one Gate N FAIL: <specific evidence>'
300
+ )
301
+ );
302
+ }
215
303
  } else {
216
- if (notEval.length === 0) violations.push('INCONCLUSIVE 必须至少有一条 门禁 N 未评估:<为什么>');
217
- if (fails.length) violations.push('INCONCLUSIVE 含 FAIL 门禁——应裁定为 FALSE POSITIVE');
304
+ if (notEval.length === 0) {
305
+ violations.push(
306
+ t(
307
+ 'INCONCLUSIVE 必须至少有一条 门禁 N 未评估:<为什么>',
308
+ 'an INCONCLUSIVE must have at least one Gate N NOT EVALUATED: <why>'
309
+ )
310
+ );
311
+ }
312
+ if (fails.length) {
313
+ violations.push(
314
+ t(
315
+ 'INCONCLUSIVE 含 FAIL 门禁——应裁定为 FALSE POSITIVE',
316
+ 'INCONCLUSIVE contains FAIL gates — should be ruled FALSE POSITIVE'
317
+ )
318
+ );
319
+ }
218
320
  }
219
321
 
220
322
  return { finding, violations, downgraded: violations.length > 0 };
@@ -321,27 +423,48 @@ export async function verifyFinding(
321
423
  * Render the gate result for a terminal reader. Report-derived text passes the
322
424
  * render boundary here exactly once, like the fact report channel.
323
425
  */
324
- export function renderGateReport(result, { write: rawWrite = console.log } = {}) {
426
+ export function renderGateReport(result, { write: rawWrite = console.log, lang = DEFAULT_LANG } = {}) {
427
+ const t = T(lang);
325
428
  const write = (line) => rawWrite(safeTextLines(line));
326
429
  const { file, findings, unparseable, downgraded, verify } = result;
327
430
 
328
431
  write('');
329
- write('euthyna gate — 6 门禁契约校验(不测量,只核对报告的自我声明)');
330
- write(`报告: ${file}`);
432
+ write(
433
+ t(
434
+ 'euthyna gate — 6 门禁契约校验(不测量,只核对报告的自我声明)',
435
+ 'euthyna gate — six-gate contract check (no measurement; only the report\'s own claims are checked)'
436
+ )
437
+ );
438
+ write(`${t('报告: ', 'Report: ')}${file}`);
331
439
  if (verify) {
332
- write('⚠ --verify 以当前用户权限执行报告中的复现命令(按 argv 执行,不经过 shell)。');
333
- write(' 默认只执行 git 命令;解释器命令(node/npm/python)需要显式 --allow-exec。');
334
- write(' 它不是一个安全沙箱:只对你自己信任的报告使用。');
440
+ write(
441
+ t(
442
+ '⚠ --verify 以当前用户权限执行报告中的复现命令(按 argv 执行,不经过 shell)。',
443
+ '⚠ --verify runs the report\'s reproduce commands with your privileges (as argv, no shell).'
444
+ )
445
+ );
446
+ write(
447
+ t(
448
+ ' 默认只执行 git 命令;解释器命令(node/npm/python)需要显式 --allow-exec。',
449
+ ' Only git commands run by default; interpreter commands (node/npm/python) need explicit --allow-exec.'
450
+ )
451
+ );
452
+ write(
453
+ t(
454
+ ' 它不是一个安全沙箱:只对你自己信任的报告使用。',
455
+ ' It is not a sandbox: use it only on reports you trust.'
456
+ )
457
+ );
335
458
  }
336
459
  write('');
337
460
 
338
461
  for (const entry of findings) {
339
462
  const f = entry.finding;
340
- const head = ` BUG #${f.number} ${f.verdict} — ${f.claim || '(无结论描述)'}`;
463
+ const head = ` BUG #${f.number} ${f.verdict} — ${f.claim || t('(无结论描述)', '(no claim)')}`;
341
464
  if (entry.downgraded) {
342
- write(`${head} — ✗ 降级为「观察」`);
465
+ write(`${head} — ✗ ${t('降级为「观察」', 'downgraded to "observation"')}`);
343
466
  } else {
344
- write(`${head} — ✓ 通过`);
467
+ write(`${head} — ✓ ${t('通过', 'passed')}`);
345
468
  }
346
469
  if (entry.verification && f.verdict === 'TRUE POSITIVE') {
347
470
  const v = entry.verification;
@@ -352,30 +475,35 @@ export function renderGateReport(result, { write: rawWrite = console.log } = {})
352
475
  : v.status === 'failed'
353
476
  ? `(exit ${v.exitCode ?? '?'}${v.detail ? `: ${v.detail}` : ''})`
354
477
  : v.status === 'refused'
355
- ? `(拒绝运行 ${v.tool} —— 不在白名单)`
478
+ ? t(`(拒绝运行 ${v.tool} —— 不在白名单)`, `(refused to run ${v.tool} — not on the allowlist)`)
356
479
  : v.status === 'needs-consent'
357
- ? `(未执行 ${v.tool} 解释器命令 —— 需要 --allow-exec)`
480
+ ? t(`(未执行 ${v.tool} 解释器命令 —— 需要 --allow-exec)`, `(interpreter command ${v.tool} not run — needs --allow-exec)`)
358
481
  : v.status === 'unparseable'
359
- ? '(命令无法拆分为 argv)'
360
- : '(无复现命令)';
361
- write(` 复现核验 ${mark} ${how}`);
482
+ ? t('(命令无法拆分为 argv)', '(command could not be split into argv)')
483
+ : t('(无复现命令)', '(no reproduce command)');
484
+ write(`${t(' 复现核验 ', ' Reproduce check ')}${mark} ${how}`);
362
485
  }
363
486
  for (const violation of entry.violations) {
364
- write(` 缺: ${violation}`);
487
+ write(`${t(' 缺: ', ' missing: ')}${violation}`);
365
488
  }
366
489
  }
367
490
 
368
491
  if (unparseable.length) {
369
492
  write('');
370
- write('⚠ 无法解析的 BUG 行(计入降级):');
493
+ write(t('⚠ 无法解析的 BUG 行(计入降级):', '⚠ Unparseable BUG lines (counted as downgrades):'));
371
494
  for (const line of unparseable) write(` • ${line}`);
372
495
  }
373
496
 
374
497
  write('');
375
498
  if (downgraded === 0) {
376
- write('全部 finding 通过门禁契约。');
499
+ write(t('全部 finding 通过门禁契约。', 'All findings pass the gate contract.'));
377
500
  } else {
378
- write(`⚠ ${downgraded} 个 finding 被降级为「观察」——缺证据、缺复现或门禁不一致,不得当作已确证的结论。`);
501
+ write(
502
+ t(
503
+ `⚠ ${downgraded} 个 finding 被降级为「观察」——缺证据、缺复现或门禁不一致,不得当作已确证的结论。`,
504
+ `⚠ ${downgraded} finding${downgraded === 1 ? '' : 's'} downgraded to "observation" — missing evidence, missing reproduction, or inconsistent gates; must not be read as established findings.`
505
+ )
506
+ );
379
507
  }
380
508
  write('');
381
509
  }
package/src/lang.js ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Language selection for the CLI (i18n).
3
+ *
4
+ * Zero-dependency on purpose, like everything else in this project: no i18n
5
+ * framework is pulled in. The CLI currently ships two languages, chosen by an
6
+ * explicit switch rather than by locale sniffing, because a locale-derived
7
+ * answer can surprise ("I asked for English and got Chinese") and is hard to
8
+ * reproduce in CI.
9
+ *
10
+ * Precedence: an explicit `--lang` flag beats the EUTHYNA_LANG environment
11
+ * variable, which beats the default (Chinese). The default stays Chinese so a
12
+ * user who never asks for English sees exactly the output they saw before.
13
+ */
14
+
15
+ export const LANGS = Object.freeze(['zh', 'en']);
16
+ export const DEFAULT_LANG = 'zh';
17
+
18
+ /** True for a value the CLI accepts as a language. */
19
+ export function isLang(value) {
20
+ return value === 'zh' || value === 'en';
21
+ }
22
+
23
+ /**
24
+ * Resolve the effective language.
25
+ *
26
+ * Precedence: an explicit flag wins, then the environment variable, then the
27
+ * default. cli.js validates the flag value before calling this, so a non-zh/en
28
+ * flag never reaches here.
29
+ *
30
+ * @param {object} [options]
31
+ * @param {string|boolean} [options.flag] the value of --lang (true when the
32
+ * flag was passed without a value)
33
+ * @param {object} [options.env] defaults to process.env
34
+ * @returns {'zh'|'en'}
35
+ */
36
+ export function resolveLang({ flag, env = process.env } = {}) {
37
+ if (flag === 'en' || flag === 'zh') return flag;
38
+ if (env.EUTHYNA_LANG === 'en') return 'en';
39
+ return DEFAULT_LANG;
40
+ }
41
+
42
+ /**
43
+ * The translation helper. Call it once per scope with the active language:
44
+ *
45
+ * const t = T(lang);
46
+ * write(t('已确证', 'Established'));
47
+ *
48
+ * The Chinese text is always the first argument and stays the canonical form;
49
+ * the English is the second. Each call site keeps both strings next to the
50
+ * interpolation that builds them, so a translation cannot drift from the
51
+ * message it translates.
52
+ */
53
+ export function T(lang) {
54
+ return (zh, en) => (lang === 'en' ? en : zh);
55
+ }