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/.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/__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
|
-
|
|
22
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
{
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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({
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
367
|
-
// preserves its line structure and is a no-op on its
|
|
368
|
-
process.stderr.write(safeTextLines(
|
|
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;
|