euthyna 0.2.0 → 0.3.1
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 +1 -0
- package/.agents/skills/euthyna-en/SKILL.md +285 -0
- package/.agents/skills/euthyna-en/references/bug-classes.md +130 -0
- package/.agents/skills/euthyna-en/references/change-audit.md +212 -0
- package/.agents/skills/euthyna-en/references/dependency-audit.md +116 -0
- package/.agents/skills/euthyna-en/references/fact-contract.md +129 -0
- package/.agents/skills/euthyna-en/references/fact-producers.md +258 -0
- package/.agents/skills/euthyna-en/references/meta-mechanisms.md +174 -0
- package/.agents/skills/euthyna-en/references/verification-gates.md +291 -0
- package/.claude/commands/euthyna.md +18 -0
- package/.opencode/commands/euthyna.md +8 -0
- package/README.md +54 -7
- package/__init__.py +91 -0
- package/bin/euthyna.js +13 -3
- package/package.json +6 -1
- package/plugin/index.js +30 -1
- package/plugin.yaml +8 -0
- package/src/cli.js +189 -41
- package/src/contract.js +20 -13
- package/src/facts/coverage.js +69 -23
- package/src/facts/deps.js +81 -24
- package/src/facts/history.js +89 -25
- package/src/gate.js +171 -43
- package/src/lang.js +55 -0
package/src/facts/history.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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
|
-
|
|
390
|
-
'
|
|
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:
|
|
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:
|
|
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
|
-
?
|
|
497
|
-
|
|
498
|
-
|
|
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
|
|
502
|
-
|
|
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
|
-
?
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
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:
|
|
55
|
-
reproduce:
|
|
56
|
-
impact:
|
|
57
|
-
exploitability:
|
|
58
|
-
gate:
|
|
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
|
|
185
|
-
const notEval = finding.gates.filter((g) => g.status
|
|
186
|
-
const passes = finding.gates.filter((g) => 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(
|
|
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
|
|
193
|
-
violations.push(
|
|
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())
|
|
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(
|
|
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(
|
|
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(
|
|
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)
|
|
211
|
-
|
|
212
|
-
|
|
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)
|
|
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)
|
|
217
|
-
|
|
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(
|
|
330
|
-
|
|
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(
|
|
333
|
-
|
|
334
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
}
|