euthyna 0.1.0 → 0.2.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.
@@ -0,0 +1,60 @@
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'];
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
+ }
package/src/cli.js CHANGED
@@ -15,8 +15,11 @@
15
15
  */
16
16
  import process from 'node:process';
17
17
  import path from 'node:path';
18
+ import { readFile } from 'node:fs/promises';
18
19
  import { collectHistoryFacts } from './facts/history.js';
19
20
  import { collectCoverageFacts } from './facts/coverage.js';
21
+ import { collectDependencyFacts } from './facts/deps.js';
22
+ import { parseGateReport, validateFindings, verifyFinding, renderGateReport } from './gate.js';
20
23
  import { makeReport, renderReport, safeTextLines, KIND } from './contract.js';
21
24
  import { repoToplevel, revParse } from './git.js';
22
25
 
@@ -37,6 +40,11 @@ const COVERAGE_PRODUCER = {
37
40
  version: '0.1.0',
38
41
  purpose: '符号调用计数(只能证伪)'
39
42
  };
43
+ const DEPS_PRODUCER = {
44
+ name: 'euthyna-deps',
45
+ version: '0.1.0',
46
+ purpose: '依赖锁定版本(读取 lockfile,不含漏洞判定)'
47
+ };
40
48
 
41
49
  const HELP = `
42
50
  euthyna —— 给 AI 编码 agent 用的确定性事实产出器
@@ -44,24 +52,40 @@ euthyna —— 给 AI 编码 agent 用的确定性事实产出器
44
52
  它不是扫描器。它只回答两个模型算不准的问题,并把答案写成带证据的事实。
45
53
 
46
54
  用法:
47
- euthyna history --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--json]
55
+ euthyna history --base <rev> [--head <rev>] [--repo <dir>] [--pickaxe] [--origins] [--json]
48
56
  euthyna coverage --coverage <file> --symbol <name> [--file <path>] [--json]
57
+ euthyna deps [--repo <dir>] [--lockfile <file>] --dep <name> [--dep <name>] [--json]
58
+ euthyna gate <报告文件> [--verify] [--cwd <dir>] [--json]
49
59
 
50
60
  命令:
51
61
  history 本次变更删掉了哪些代码、它们分别由哪个提交引入、该提交是不是安全修复
52
62
  --base 必填,比较的基线版本(如 main、HEAD~5、某个 commit)
53
63
  --head 可选,默认 HEAD
54
64
  --pickaxe 额外检查「曾被移除又加回来」的新增行(有探针上限)
65
+ --origins 用 git log -S 把删除行归属到「最初引入」该内容的提交,
66
+ 而非 blame 的「最后修改者」(有探针上限,比默认慢)
55
67
  coverage 某个符号在测试运行中到底有没有被调用过
56
68
  --coverage 覆盖率数据文件,c8 的 coverage-final.json
57
69
  --symbol 要查询的符号名,可重复
58
70
  --file 可选,限定到某个文件
71
+ deps 某个依赖在 lockfile 里被锁定/声明成什么版本(供应链声明的裁决依据)
72
+ --repo 可选,依赖清单所在目录(默认当前目录,自动检测)
73
+ --lockfile 可选,显式指定清单文件(支持 package-lock.json / Cargo.lock / go.mod)
74
+ --dep 要查询的依赖名,可重复
75
+ 注:只报版本事实,不判「是否含漏洞」——版本到 CVE 的映射归判定层
76
+ gate 检查一份审计报告是否符合 6 门禁契约(不测量,只核对报告的自我声明)
77
+ <报告文件> 报告的 markdown 文件,裁定格式见技能 SKILL.md
78
+ --verify 重跑每条 TRUE POSITIVE 的复现命令(按 argv 执行,不经过 shell)
79
+ 默认只执行 git 命令;⚠ 执行结果以你的权限生效,只对你信任的报告用
80
+ --allow-exec 允许 --verify 执行解释器命令(node/npm/python)——
81
+ 它们能跑报告里的任意代码,加了它就等于你为该报告背书
82
+ --cwd <dir> --verify 的工作目录(默认当前目录)
59
83
 
60
84
  退出码:
61
- 0 已测量,没有安全相关的发现
62
- 10 已测量,且存在被分类为 security 的事实
85
+ 0 已测量,没有安全相关的发现;或 gate 报告全部通过门禁契约
86
+ 10 已测量,且存在被分类为 security 的事实;或 gate 报告有 finding 被降级
63
87
  1 用法错误
64
- 2 完全无法测量(此时**不得**当作干净)
88
+ 2 完全无法测量(此时**不得**当作干净);或 gate 报告无法读取/没有可校验的 finding
65
89
 
66
90
  注意: 缺数据不等于干净。无法测量的判据会列在输出的「未评估的判据」一节。
67
91
  `;
@@ -146,7 +170,8 @@ async function runHistory(flags) {
146
170
  cwd: toplevel,
147
171
  base,
148
172
  head,
149
- pickaxe: flags.pickaxe === true
173
+ pickaxe: flags.pickaxe === true,
174
+ origins: flags.origins === true
150
175
  });
151
176
 
152
177
  const report = makeReport({
@@ -198,6 +223,123 @@ async function runCoverage(flags) {
198
223
  return { exit: measured ? EXIT.CLEAN : EXIT.UNMEASURED, report };
199
224
  }
200
225
 
226
+ async function runDeps(flags) {
227
+ const cwd = path.resolve(flags.repo ? String(flags.repo) : process.cwd());
228
+ const deps = asArray(flags.dep).map(String);
229
+ const lockfile = flags.lockfile ? String(flags.lockfile) : undefined;
230
+
231
+ // Asking for nothing is a caller mistake, not a measurement failure.
232
+ if (deps.length === 0) {
233
+ return { exit: EXIT.USAGE, error: 'deps 需要至少一个 --dep <name>' };
234
+ }
235
+
236
+ const { facts, evaluated, notEvaluated, measured } = await collectDependencyFacts({
237
+ cwd,
238
+ lockfile,
239
+ deps
240
+ });
241
+
242
+ const report = makeReport({
243
+ producer: DEPS_PRODUCER,
244
+ subject: { repo: cwd, lockfile: lockfile ?? '(自动检测)', deps },
245
+ facts,
246
+ evaluated,
247
+ notEvaluated
248
+ });
249
+
250
+ return { exit: measured ? EXIT.CLEAN : EXIT.UNMEASURED, report };
251
+ }
252
+ async function runGate(flags, positional) {
253
+ const file = positional[1];
254
+ if (!file) {
255
+ return { exit: EXIT.USAGE, error: 'gate 需要 <报告文件>(markdown,裁定格式见技能 SKILL.md)' };
256
+ }
257
+
258
+ let text;
259
+ try {
260
+ text = await readFile(path.resolve(file), 'utf8');
261
+ } catch (error) {
262
+ // An unreadable report is not a clean pass: there is nothing to check, and
263
+ // "nothing was checked" must not read as "everything passed".
264
+ return { exit: EXIT.UNMEASURED, error: `无法读取报告 ${file}: ${error.message}` };
265
+ }
266
+
267
+ const { findings, unparseable } = parseGateReport(text);
268
+ if (findings.length === 0 && unparseable.length === 0) {
269
+ return { exit: EXIT.UNMEASURED, error: `报告 ${file} 中没有可校验的 finding(需要 BUG #N <VERDICT> — 说明 形式)` };
270
+ }
271
+
272
+ const validated = validateFindings(findings);
273
+ for (const line of unparseable) {
274
+ validated.push({ unparseableLine: line, violations: ['无法解析的 BUG 行'], downgraded: true });
275
+ }
276
+
277
+ if (flags.verify) {
278
+ const cwd = path.resolve(flags.cwd ? String(flags.cwd) : process.cwd());
279
+ const allowInterpreters = flags['allow-exec'] === true;
280
+ for (const entry of validated) {
281
+ if (entry.unparseableLine) continue;
282
+ entry.verification = await verifyFinding(entry.finding, { cwd, allowInterpreters });
283
+ const v = entry.verification;
284
+ // Every status other than "verified" means the reproduction claim was
285
+ // not actually checked, which is a downgrade: a refused tool, an
286
+ // interpreter awaiting consent, an unparseable command and a failed run
287
+ // are different failures, but none of them is a verified reproduction.
288
+ if (v.status === 'failed') {
289
+ entry.violations.push(`复现命令未通过(exit ${v.exitCode ?? '?'}${v.detail ? `: ${v.detail}` : ''})`);
290
+ entry.downgraded = true;
291
+ } else if (v.status === 'refused') {
292
+ entry.violations.push(`复现命令被拒绝(${v.tool} 不在白名单)——复现未验证`);
293
+ entry.downgraded = true;
294
+ } else if (v.status === 'needs-consent') {
295
+ entry.violations.push(`复现命令是解释器命令(${v.tool}),未执行——复现未验证;信任该报告时加 --allow-exec`);
296
+ entry.downgraded = true;
297
+ } else if (v.status === 'unparseable') {
298
+ entry.violations.push('复现命令无法拆分为 argv——复现未验证');
299
+ entry.downgraded = true;
300
+ }
301
+ // no-command is already a structural violation (a TRUE POSITIVE without a
302
+ // reproduce line), so --verify does not double-report it.
303
+ }
304
+ }
305
+
306
+ const downgraded = validated.filter(e => e.downgraded).length;
307
+ const result = { file, findings: validated, unparseable, downgraded, verify: flags.verify === true };
308
+
309
+ if (flags.json) {
310
+ return {
311
+ exit: downgraded > 0 ? EXIT.FLAGGED : EXIT.CLEAN,
312
+ json: JSON.stringify(
313
+ {
314
+ command: 'gate',
315
+ file,
316
+ findings: validated.map(e => ({
317
+ ...(e.unparseableLine
318
+ ? { unparseable: e.unparseableLine }
319
+ : {
320
+ number: e.finding.number,
321
+ verdict: e.finding.verdict,
322
+ claim: e.finding.claim,
323
+ evidence: e.finding.evidence,
324
+ reproduce: e.finding.reproduce,
325
+ impact: e.finding.impact,
326
+ gates: e.finding.gates
327
+ }),
328
+ violations: e.violations,
329
+ downgraded: e.downgraded
330
+ })),
331
+ downgraded
332
+ },
333
+ null,
334
+ 2
335
+ )
336
+ };
337
+ }
338
+
339
+ renderGateReport(result);
340
+ return { exit: downgraded > 0 ? EXIT.FLAGGED : EXIT.CLEAN, report: null };
341
+ }
342
+
201
343
  /** Entry point. Returns the process exit code. */
202
344
  export async function main(argv = process.argv.slice(2)) {
203
345
  const { positional, flags } = parseArgs(argv);
@@ -213,6 +355,10 @@ export async function main(argv = process.argv.slice(2)) {
213
355
  result = await runHistory(flags);
214
356
  } else if (command === 'coverage') {
215
357
  result = await runCoverage(flags);
358
+ } else if (command === 'deps') {
359
+ result = await runDeps(flags);
360
+ } else if (command === 'gate') {
361
+ result = await runGate(flags, positional);
216
362
  } else {
217
363
  // stderr boundary, mirroring the render boundary in contract.js: text that
218
364
  // reaches the error channel may carry user or repo-controlled bytes (an
@@ -228,9 +374,14 @@ export async function main(argv = process.argv.slice(2)) {
228
374
  return result.exit;
229
375
  }
230
376
 
377
+ if (result.json) {
378
+ process.stdout.write(`${result.json}\n`);
379
+ return result.exit;
380
+ }
381
+
231
382
  if (flags.json) {
232
383
  process.stdout.write(`${JSON.stringify(result.report, null, 2)}\n`);
233
- } else {
384
+ } else if (result.report) {
234
385
  renderReport(result.report);
235
386
  }
236
387
 
package/src/contract.js CHANGED
@@ -66,7 +66,8 @@ export function safeTextLines(value) {
66
66
  export const KIND = Object.freeze({
67
67
  HISTORY: 'history',
68
68
  REINTRODUCTION: 'reintroduction',
69
- TEST_COVERAGE: 'test_coverage'
69
+ TEST_COVERAGE: 'test_coverage',
70
+ DEPENDENCY: 'dependency'
70
71
  });
71
72
 
72
73
  /** Fact status. `unknown` is a first-class value, not an error. */
@@ -62,14 +62,18 @@ function reproduceCommand({ coverageFile, symbol, file }) {
62
62
  /**
63
63
  * Read a coverage report.
64
64
  *
65
- * Two formats are accepted:
65
+ * Three formats are accepted, all identified by shape rather than by filename:
66
66
  *
67
67
  * 1. c8 / v8-to-istanbul `coverage-final.json` — per-file keys are
68
68
  * path/all/statementMap/s/branchMap/b/fnMap/f. There is no istanbul `hash`,
69
69
  * and `branchMap` is a relabelled V8 block range rather than an if/else
70
70
  * model, so a consumer written against classic istanbul field lists reads
71
71
  * the wrong thing.
72
- * 2. coverage.py JSON (`coverage json`, format 3) — top level is
72
+ * 2. classic istanbul (jest's default provider, nyc) `coverage-final.json`
73
+ * the same per-file statementMap/s/branchMap/b/fnMap/f plus a `hash` field.
74
+ * The symbol locator only reads fnMap/f, which classic istanbul and c8 emit
75
+ * in the same shape, so both resolve through the same code path.
76
+ * 3. coverage.py JSON (`coverage json`, format 3) — top level is
73
77
  * `{meta, files}`, and per-file `functions` maps a function name
74
78
  * (`name`, or `Class.method` for methods) to
75
79
  * `{executed_lines, missing_lines, start_line, ...}`. There is **no
@@ -77,6 +81,10 @@ function reproduceCommand({ coverageFile, symbol, file }) {
77
81
  * evidence that the function was never entered. coverage.py reports
78
82
  * functions that were never called as long as their module was loaded,
79
83
  * which is exactly what makes "never invoked" an established fact.
84
+ *
85
+ * Anything that matches none of these shapes is **not a coverage report this
86
+ * producer can read**, and is reported as notEvaluated — never fed through the
87
+ * locator, where it would answer "symbol not located" with a straight face.
80
88
  */
81
89
  export async function loadCoverage(coverageFile) {
82
90
  const raw = await readFile(coverageFile, 'utf8');
@@ -98,6 +106,35 @@ function isCoveragePyReport(coverage) {
98
106
  );
99
107
  }
100
108
 
109
+ /**
110
+ * Identify the coverage format by shape, or null when the file is not a
111
+ * coverage report at all. c8 and classic istanbul share the fnMap/f shape the
112
+ * locator reads; they differ only in `hash` (istanbul has it, c8 does not) and
113
+ * `all` (c8 has it), neither of which the locator reads — but naming the format
114
+ * honestly is still part of the fact, so the distinction is kept.
115
+ */
116
+ export function detectCoverageFormat(coverage) {
117
+ if (isCoveragePyReport(coverage)) return 'coverage.py';
118
+
119
+ const entries = Object.entries(coverage).filter(([, v]) => v && typeof v === 'object');
120
+ if (entries.length === 0) return null;
121
+
122
+ // A real c8/istanbul entry carries both `fnMap` (function metadata) and `f`
123
+ // (per-index invocation counts). Requiring both stops a partial shape — a
124
+ // file with `fnMap` but no `f`, say — from being classified as coverage and
125
+ // then emitting "never invoked" for a count a missing `f` defaults to zero.
126
+ // coverage.py's per-file `functions` is only valid inside a `meta.files`
127
+ // report, which isCoveragePyReport already handled above; a bare `functions`
128
+ // object is not a JS report and must not be accepted here.
129
+ const jsShaped = entries.some(
130
+ ([, e]) =>
131
+ e.fnMap && typeof e.fnMap === 'object' && e.f && typeof e.f === 'object'
132
+ );
133
+ if (!jsShaped) return null;
134
+
135
+ return entries.some(([, e]) => typeof e === 'object' && 'hash' in e) ? 'istanbul' : 'c8';
136
+ }
137
+
101
138
  /**
102
139
  * Locate a symbol inside one file entry and normalise the hit.
103
140
  *
@@ -215,6 +252,21 @@ export async function collectCoverageFacts({ coverageFile, targets = [] } = {})
215
252
  return { facts, evaluated, notEvaluated, measured: false };
216
253
  }
217
254
 
255
+ // A non-empty object that matches no known shape is not a coverage report at
256
+ // all. Feeding it through the locator would answer "symbol not located" with
257
+ // a straight face — the confident wrong answer this producer exists to refuse.
258
+ const format = detectCoverageFormat(coverage);
259
+ if (format === null) {
260
+ notEvaluated.push(
261
+ notEvaluatedEntry(
262
+ KIND.TEST_COVERAGE,
263
+ '覆盖率数据不是可识别的报告形状(既无 c8/istanbul 的 fnMap/f,也无 coverage.py 的 functions)——' +
264
+ '它可能根本不是覆盖率文件,任何「符号未定位/未覆盖」的结论在此都不可信'
265
+ )
266
+ );
267
+ return { facts, evaluated, notEvaluated, measured: false };
268
+ }
269
+
218
270
  if (targets.length === 0) {
219
271
  notEvaluated.push(notEvaluatedEntry(KIND.TEST_COVERAGE, '没有指定要查询的符号(--symbol)'));
220
272
  return { facts, evaluated, notEvaluated, measured: false };
@@ -331,6 +383,7 @@ export async function collectCoverageFacts({ coverageFile, targets = [] } = {})
331
383
  evaluated.push({
332
384
  kind: KIND.TEST_COVERAGE,
333
385
  producer: 'euthyna-coverage',
386
+ format,
334
387
  count: facts.length
335
388
  });
336
389