euthyna 0.1.1 → 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/SKILL.md +256 -0
- package/.agents/skills/euthyna/references/bug-classes.md +130 -0
- package/.agents/skills/euthyna/references/change-audit.md +219 -0
- package/.agents/skills/euthyna/references/dependency-audit.md +116 -0
- package/.agents/skills/euthyna/references/fact-contract.md +129 -0
- package/.agents/skills/euthyna/references/fact-producers.md +274 -0
- package/.agents/skills/euthyna/references/meta-mechanisms.md +173 -0
- package/.agents/skills/euthyna/references/verification-gates.md +293 -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/LICENSE +202 -202
- package/README.md +55 -8
- package/__init__.py +91 -0
- package/bin/euthyna.js +13 -3
- package/cordis.patch.yml +4 -0
- package/package.json +28 -3
- package/plugin/index.js +89 -0
- 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/package.json
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
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
|
+
"main": "./plugin/index.js",
|
|
6
7
|
"bin": {
|
|
7
8
|
"euthyna": "./bin/euthyna.js"
|
|
8
9
|
},
|
|
9
10
|
"files": [
|
|
10
11
|
"bin/",
|
|
11
12
|
"src/",
|
|
13
|
+
"plugin/",
|
|
14
|
+
"plugin.yaml",
|
|
15
|
+
"__init__.py",
|
|
16
|
+
"cordis.patch.yml",
|
|
17
|
+
".agents/skills/euthyna/",
|
|
18
|
+
".agents/skills/euthyna-en/",
|
|
19
|
+
".claude/commands/",
|
|
20
|
+
".opencode/commands/",
|
|
12
21
|
"assets/euthyna.png",
|
|
13
22
|
"NOTICE.md"
|
|
14
23
|
],
|
|
@@ -25,7 +34,10 @@
|
|
|
25
34
|
"code-review",
|
|
26
35
|
"coverage",
|
|
27
36
|
"ai-agents",
|
|
28
|
-
"ai-coding-agents"
|
|
37
|
+
"ai-coding-agents",
|
|
38
|
+
"dsh-plugin",
|
|
39
|
+
"deepseek-harness",
|
|
40
|
+
"skill"
|
|
29
41
|
],
|
|
30
42
|
"author": "modusensus",
|
|
31
43
|
"license": "Apache-2.0",
|
|
@@ -36,5 +48,18 @@
|
|
|
36
48
|
"bugs": {
|
|
37
49
|
"url": "https://github.com/slow-stack/euthyna/issues"
|
|
38
50
|
},
|
|
39
|
-
"homepage": "https://github.com/slow-stack/euthyna#readme"
|
|
51
|
+
"homepage": "https://github.com/slow-stack/euthyna#readme",
|
|
52
|
+
"dsh": {
|
|
53
|
+
"bundle": {
|
|
54
|
+
"patch": "./cordis.patch.yml"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@deepseek-ai/dsh-skill-filesystem": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0"
|
|
59
|
+
},
|
|
60
|
+
"peerDependenciesMeta": {
|
|
61
|
+
"@deepseek-ai/dsh-skill-filesystem": {
|
|
62
|
+
"optional": true
|
|
63
|
+
}
|
|
64
|
+
}
|
|
40
65
|
}
|
package/plugin/index.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DSH half of euthyna: mounts the packaged skill on `ctx.skills`.
|
|
3
|
+
*
|
|
4
|
+
* The CLI (`bin/euthyna.js`) produces the facts; this plugin is what makes the
|
|
5
|
+
* discipline that consumes them installable with `dsh plugin add euthyna`. It
|
|
6
|
+
* serves the skill directory shipped inside this package, so the bundle carries
|
|
7
|
+
* the skill itself rather than pointing at a checkout.
|
|
8
|
+
*
|
|
9
|
+
* The official filesystem provider is imported instead of reimplemented:
|
|
10
|
+
* frontmatter parsing and root ranking then stay byte-identical with the
|
|
11
|
+
* built-in provider, and the only thing this package decides is which root to
|
|
12
|
+
* serve.
|
|
13
|
+
*
|
|
14
|
+
* @module euthyna/plugin
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync } from 'node:fs';
|
|
17
|
+
import { dirname, join, resolve } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { FileSystemSkillProvider } from '@deepseek-ai/dsh-skill-filesystem';
|
|
20
|
+
|
|
21
|
+
export const name = 'euthyna';
|
|
22
|
+
|
|
23
|
+
/** Without the skill registry there is nothing to register onto. */
|
|
24
|
+
export const inject = ['skills', 'commands'];
|
|
25
|
+
|
|
26
|
+
/** This module's directory: `<package>/plugin`, in the repo and in the tarball alike. */
|
|
27
|
+
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
|
|
29
|
+
/** The packaged skill root, at the same relative path in both layouts. */
|
|
30
|
+
export const SKILLS_ROOT = join(MODULE_DIR, '..', '.agents', 'skills');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Register the packaged skill directory as a provider.
|
|
34
|
+
*
|
|
35
|
+
* `includeDefaultRoots: false` keeps this provider to euthyna's own skill: the
|
|
36
|
+
* built-in provider goes on serving the user's other skills, so installing this
|
|
37
|
+
* bundle adds a skill rather than replacing the catalogue. Registration is a
|
|
38
|
+
* plain call — the provider disposes itself through the control signal it is
|
|
39
|
+
* handed.
|
|
40
|
+
*
|
|
41
|
+
* A missing bundle throws rather than mounting an empty provider, because a
|
|
42
|
+
* plugin that installs cleanly and contributes nothing is worse than one that
|
|
43
|
+
* fails where someone can see it.
|
|
44
|
+
*
|
|
45
|
+
* @param ctx - the cordis context, which must expose `skills`.
|
|
46
|
+
* @param config - optional `{ skillsDir }` override for a relocated skill root.
|
|
47
|
+
*/
|
|
48
|
+
export function apply(ctx, config = {}) {
|
|
49
|
+
const root = resolve(config.skillsDir ?? SKILLS_ROOT);
|
|
50
|
+
const bundle = join(root, 'euthyna', 'SKILL.md');
|
|
51
|
+
if (!existsSync(bundle)) {
|
|
52
|
+
throw new Error(`euthyna: no skill bundle at ${bundle} — set config.skillsDir to the directory holding euthyna/SKILL.md`);
|
|
53
|
+
}
|
|
54
|
+
ctx.skills.registerProvider((control) => new FileSystemSkillProvider(ctx, control, {
|
|
55
|
+
providerName: 'euthyna',
|
|
56
|
+
includeDefaultRoots: false,
|
|
57
|
+
customSkillDirs: [root],
|
|
58
|
+
watch: false,
|
|
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
|
+
});
|
|
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;
|
package/src/contract.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { stripVTControlCharacters } from 'node:util';
|
|
18
|
+
import { T, DEFAULT_LANG } from './lang.js';
|
|
18
19
|
|
|
19
20
|
export const SCHEMA_VERSION = '0.1';
|
|
20
21
|
|
|
@@ -189,10 +190,11 @@ export function notEvaluated(kind, reason) {
|
|
|
189
190
|
* Ordering is deliberate: what was NOT measured comes last but is never
|
|
190
191
|
* omitted, so a reader cannot finish the output without seeing it.
|
|
191
192
|
*/
|
|
192
|
-
export function renderReport(report, { write: rawWrite = console.log } = {}) {
|
|
193
|
+
export function renderReport(report, { write: rawWrite = console.log, lang = DEFAULT_LANG } = {}) {
|
|
193
194
|
// Render boundary: every repo-controlled string below passes through safeText
|
|
194
195
|
// exactly once, here, rather than at each producer. The JSON channel
|
|
195
196
|
// (cli.js) needs no equivalent — JSON.stringify escapes control bytes.
|
|
197
|
+
const t = T(lang);
|
|
196
198
|
const write = (line) => rawWrite(safeText(line));
|
|
197
199
|
const { producer, subject, facts, coverage } = report;
|
|
198
200
|
|
|
@@ -202,9 +204,9 @@ export function renderReport(report, { write: rawWrite = console.log } = {}) {
|
|
|
202
204
|
|
|
203
205
|
write('');
|
|
204
206
|
write(`euthyna ${producer.name} — ${producer.purpose || ''}`.trim());
|
|
205
|
-
write(
|
|
207
|
+
write(`${t('目标: ', 'Target: ')}${subject.repo ?? subject.coverageFile ?? t('(未指定)', '(unspecified)')}`);
|
|
206
208
|
if (subject.base || subject.head) {
|
|
207
|
-
write(
|
|
209
|
+
write(`${t('范围: ', 'Range: ')}${subject.base ?? '?'}..${subject.head ?? 'HEAD'}`);
|
|
208
210
|
}
|
|
209
211
|
write('');
|
|
210
212
|
|
|
@@ -214,26 +216,26 @@ export function renderReport(report, { write: rawWrite = console.log } = {}) {
|
|
|
214
216
|
for (const fact of list) {
|
|
215
217
|
write(` • ${fact.statement}`);
|
|
216
218
|
const where = [fact.evidence.file, fact.evidence.line].filter(Boolean).join(':');
|
|
217
|
-
write(`
|
|
218
|
-
if (fact.command) write(`
|
|
219
|
+
write(` ${t('证据: ', 'Evidence: ')}${where}${fact.evidence.commit ? ` (${fact.evidence.commit.slice(0, 10)})` : ''}`);
|
|
220
|
+
if (fact.command) write(` ${t('复现: ', 'Reproduce: ')}${fact.command}`);
|
|
219
221
|
if (fact.confidence === 'approximate') {
|
|
220
|
-
write(' ⚠ approximate —— 不得用于门禁判定');
|
|
222
|
+
write(t(' ⚠ approximate —— 不得用于门禁判定', ' ⚠ approximate — must not be used for gate adjudication'));
|
|
221
223
|
}
|
|
222
224
|
}
|
|
223
225
|
write('');
|
|
224
226
|
};
|
|
225
227
|
|
|
226
|
-
section('已确证', established);
|
|
227
|
-
section('已证伪', refuted);
|
|
228
|
-
section('无法判定', unknown);
|
|
228
|
+
section(t('已确证', 'Established'), established);
|
|
229
|
+
section(t('已证伪', 'Refuted'), refuted);
|
|
230
|
+
section(t('无法判定', 'Unknown'), unknown);
|
|
229
231
|
|
|
230
232
|
if (facts.length === 0) {
|
|
231
|
-
write(' 本次运行没有产出任何事实。');
|
|
233
|
+
write(t(' 本次运行没有产出任何事实。', ' This run produced no facts.'));
|
|
232
234
|
write('');
|
|
233
235
|
}
|
|
234
236
|
|
|
235
237
|
if (coverage.notEvaluated.length > 0) {
|
|
236
|
-
write('⚠ 未评估的判据(缺数据不等于干净):');
|
|
238
|
+
write(t('⚠ 未评估的判据(缺数据不等于干净):', '⚠ Criteria not evaluated (missing data is not clean):'));
|
|
237
239
|
for (const item of coverage.notEvaluated) {
|
|
238
240
|
write(` • ${item.kind}: ${item.reason}`);
|
|
239
241
|
}
|
|
@@ -241,9 +243,14 @@ export function renderReport(report, { write: rawWrite = console.log } = {}) {
|
|
|
241
243
|
}
|
|
242
244
|
|
|
243
245
|
if (coverage.evaluated.length > 0) {
|
|
244
|
-
write('已评估的判据:');
|
|
246
|
+
write(t('已评估的判据:', 'Evaluated criteria:'));
|
|
245
247
|
for (const item of coverage.evaluated) {
|
|
246
|
-
write(
|
|
248
|
+
write(
|
|
249
|
+
t(
|
|
250
|
+
` • ${item.kind}: ${item.count} 条 (${item.producer})`,
|
|
251
|
+
` • ${item.kind}: ${item.count} (${item.producer})`
|
|
252
|
+
)
|
|
253
|
+
);
|
|
247
254
|
}
|
|
248
255
|
write('');
|
|
249
256
|
}
|