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.
Files changed (33) hide show
  1. package/.agents/skills/euthyna/SKILL.md +256 -0
  2. package/.agents/skills/euthyna/references/bug-classes.md +130 -0
  3. package/.agents/skills/euthyna/references/change-audit.md +219 -0
  4. package/.agents/skills/euthyna/references/dependency-audit.md +116 -0
  5. package/.agents/skills/euthyna/references/fact-contract.md +129 -0
  6. package/.agents/skills/euthyna/references/fact-producers.md +274 -0
  7. package/.agents/skills/euthyna/references/meta-mechanisms.md +173 -0
  8. package/.agents/skills/euthyna/references/verification-gates.md +293 -0
  9. package/.agents/skills/euthyna-en/SKILL.md +285 -0
  10. package/.agents/skills/euthyna-en/references/bug-classes.md +130 -0
  11. package/.agents/skills/euthyna-en/references/change-audit.md +212 -0
  12. package/.agents/skills/euthyna-en/references/dependency-audit.md +116 -0
  13. package/.agents/skills/euthyna-en/references/fact-contract.md +129 -0
  14. package/.agents/skills/euthyna-en/references/fact-producers.md +258 -0
  15. package/.agents/skills/euthyna-en/references/meta-mechanisms.md +174 -0
  16. package/.agents/skills/euthyna-en/references/verification-gates.md +291 -0
  17. package/.claude/commands/euthyna.md +18 -0
  18. package/.opencode/commands/euthyna.md +8 -0
  19. package/LICENSE +202 -202
  20. package/README.md +55 -8
  21. package/__init__.py +91 -0
  22. package/bin/euthyna.js +13 -3
  23. package/cordis.patch.yml +4 -0
  24. package/package.json +28 -3
  25. package/plugin/index.js +89 -0
  26. package/plugin.yaml +8 -0
  27. package/src/cli.js +189 -41
  28. package/src/contract.js +20 -13
  29. package/src/facts/coverage.js +69 -23
  30. package/src/facts/deps.js +81 -24
  31. package/src/facts/history.js +89 -25
  32. package/src/gate.js +171 -43
  33. package/src/lang.js +55 -0
@@ -20,6 +20,7 @@
20
20
  import { readFile } from 'node:fs/promises';
21
21
  import path from 'node:path';
22
22
  import { makeFact, notEvaluated as notEvaluatedEntry, KIND, STATUS, shellQuote } from '../contract.js';
23
+ import { T, DEFAULT_LANG } from '../lang.js';
23
24
 
24
25
  /** c8's default exclusions. A production file matching one of these vanishes from the report. */
25
26
  const C8_DEFAULT_EXCLUDES = [
@@ -210,7 +211,8 @@ function locateSymbolInEntry(entry, symbol) {
210
211
  * @param {string} options.coverageFile
211
212
  * @param {Array<{file?: string, symbol: string}>} options.targets
212
213
  */
213
- export async function collectCoverageFacts({ coverageFile, targets = [] } = {}) {
214
+ export async function collectCoverageFacts({ coverageFile, targets = [], lang = DEFAULT_LANG } = {}) {
215
+ const t = T(lang);
214
216
  const facts = [];
215
217
  const evaluated = [];
216
218
  const notEvaluated = [];
@@ -223,8 +225,12 @@ export async function collectCoverageFacts({ coverageFile, targets = [] } = {})
223
225
  notEvaluated.push(
224
226
  notEvaluatedEntry(
225
227
  KIND.TEST_COVERAGE,
226
- `无法读取覆盖率数据 ${coverageFile}: ${error.code ?? error.message}。` +
227
- '未运行测试或测试未产出覆盖率时,这属于「未评估」,不是「未被覆盖」'
228
+ t(
229
+ `无法读取覆盖率数据 ${coverageFile}: ${error.code ?? error.message}。` +
230
+ '未运行测试或测试未产出覆盖率时,这属于「未评估」,不是「未被覆盖」',
231
+ `cannot read coverage data ${coverageFile}: ${error.code ?? error.message}. ` +
232
+ 'When no tests ran or none produced coverage, this is "not evaluated", not "not covered"'
233
+ )
228
234
  )
229
235
  );
230
236
  return { facts, evaluated, notEvaluated, measured: false };
@@ -245,8 +251,13 @@ export async function collectCoverageFacts({ coverageFile, targets = [] } = {})
245
251
  notEvaluated.push(
246
252
  notEvaluatedEntry(
247
253
  KIND.TEST_COVERAGE,
248
- '覆盖率数据为空对象。这通常意味着测试运行没有加载到被测代码(常见于缺少 --all 或 --source),' +
249
- '因此任何「未在数据中」的文件都必须按未覆盖处理,而这里连文件清单都没有'
254
+ t(
255
+ '覆盖率数据为空对象。这通常意味着测试运行没有加载到被测代码(常见于缺少 --all 或 --source),' +
256
+ '因此任何「未在数据中」的文件都必须按未覆盖处理,而这里连文件清单都没有',
257
+ 'Coverage data is an empty object. This usually means the test run never loaded the code under test ' +
258
+ '(commonly a missing --all or --source), so any file "not in the data" must be treated as uncovered — ' +
259
+ 'and here there is not even a file list'
260
+ )
250
261
  )
251
262
  );
252
263
  return { facts, evaluated, notEvaluated, measured: false };
@@ -260,15 +271,24 @@ export async function collectCoverageFacts({ coverageFile, targets = [] } = {})
260
271
  notEvaluated.push(
261
272
  notEvaluatedEntry(
262
273
  KIND.TEST_COVERAGE,
263
- '覆盖率数据不是可识别的报告形状(既无 c8/istanbul 的 fnMap/f,也无 coverage.py 的 functions)——' +
264
- '它可能根本不是覆盖率文件,任何「符号未定位/未覆盖」的结论在此都不可信'
274
+ t(
275
+ '覆盖率数据不是可识别的报告形状(既无 c8/istanbul 的 fnMap/f,也无 coverage.py 的 functions)——' +
276
+ '它可能根本不是覆盖率文件,任何「符号未定位/未覆盖」的结论在此都不可信',
277
+ 'Coverage data is not a recognizable report shape (neither c8/istanbul\'s fnMap/f nor coverage.py\'s functions) — ' +
278
+ 'it may not be a coverage file at all, and any "symbol not located / not covered" conclusion is untrustworthy here'
279
+ )
265
280
  )
266
281
  );
267
282
  return { facts, evaluated, notEvaluated, measured: false };
268
283
  }
269
284
 
270
285
  if (targets.length === 0) {
271
- notEvaluated.push(notEvaluatedEntry(KIND.TEST_COVERAGE, '没有指定要查询的符号(--symbol)'));
286
+ notEvaluated.push(
287
+ notEvaluatedEntry(
288
+ KIND.TEST_COVERAGE,
289
+ t('没有指定要查询的符号(--symbol)', 'no symbol requested for query (--symbol)')
290
+ )
291
+ );
272
292
  return { facts, evaluated, notEvaluated, measured: false };
273
293
  }
274
294
 
@@ -277,8 +297,12 @@ export async function collectCoverageFacts({ coverageFile, targets = [] } = {})
277
297
  notEvaluated.push(
278
298
  notEvaluatedEntry(
279
299
  KIND.TEST_COVERAGE,
280
- `${target.file} 命中默认排除规则(c8 排除 test/ 等目录),很可能根本不在覆盖率数据里。` +
281
- '需要显式 --all(c8)或 --source(coverage.py)或调整 exclude 才能测到它'
300
+ t(
301
+ `${target.file} 命中默认排除规则(c8 排除 test/ 等目录),很可能根本不在覆盖率数据里。` +
302
+ '需要显式 --all(c8)或 --source(coverage.py)或调整 exclude 才能测到它',
303
+ `${target.file} matches a default exclusion rule (c8 excludes test/ etc.), so it is likely absent from the coverage data entirely. ` +
304
+ 'Explicit --all (c8) or --source (coverage.py), or adjusting exclude, is required to measure it'
305
+ )
282
306
  )
283
307
  );
284
308
  }
@@ -293,8 +317,12 @@ export async function collectCoverageFacts({ coverageFile, targets = [] } = {})
293
317
  id: `coverage-${++counter}`,
294
318
  kind: KIND.TEST_COVERAGE,
295
319
  statement:
296
- `${target.file} 完全没有出现在覆盖率数据中 —— 测试运行期间该文件未被加载,` +
297
- `因此其中的符号 ${target.symbol} 未曾被执行`,
320
+ t(
321
+ `${target.file} 完全没有出现在覆盖率数据中 —— 测试运行期间该文件未被加载,` +
322
+ `因此其中的符号 ${target.symbol} 未曾被执行`,
323
+ `${target.file} does not appear in the coverage data at all — the file was never loaded during the test run, ` +
324
+ `so the symbol ${target.symbol} inside it was never executed`
325
+ ),
298
326
  status: STATUS.ESTABLISHED,
299
327
  evidence: { file: target.file },
300
328
  method: 'command',
@@ -322,8 +350,12 @@ export async function collectCoverageFacts({ coverageFile, targets = [] } = {})
322
350
  id: `coverage-${++counter}`,
323
351
  kind: KIND.TEST_COVERAGE,
324
352
  statement:
325
- `符号 ${target.symbol} 在本次测试运行中一次都没有被调用(调用计数为 0)—— ` +
326
- `任何依赖它的行为都没有被执行验证`,
353
+ t(
354
+ `符号 ${target.symbol} 在本次测试运行中一次都没有被调用(调用计数为 0)—— ` +
355
+ `任何依赖它的行为都没有被执行验证`,
356
+ `Symbol ${target.symbol} was never invoked in this test run (invocation count 0) — ` +
357
+ `any behavior depending on it was never exercised`
358
+ ),
327
359
  status: STATUS.ESTABLISHED,
328
360
  evidence: { file: entryPath, line },
329
361
  method: 'command',
@@ -341,10 +373,18 @@ export async function collectCoverageFacts({ coverageFile, targets = [] } = {})
341
373
  kind: KIND.TEST_COVERAGE,
342
374
  statement:
343
375
  isPy
344
- ? `符号 ${target.symbol} 至少被调用过一次,但被调用**不能**证明任何特定调用点执行过 —— ` +
345
- `本事实只能证伪,不能证实`
346
- : `符号 ${target.symbol} 被调用了 ${count} 次,但调用计数非零**不能**证明任何特定调用点执行过 —— ` +
347
- `本事实只能证伪,不能证实`,
376
+ ? t(
377
+ `符号 ${target.symbol} 至少被调用过一次,但被调用**不能**证明任何特定调用点执行过 —— ` +
378
+ `本事实只能证伪,不能证实`,
379
+ `Symbol ${target.symbol} was invoked at least once, but being invoked **cannot** prove any specific call site ran — ` +
380
+ `this fact can only falsify, never confirm`
381
+ )
382
+ : t(
383
+ `符号 ${target.symbol} 被调用了 ${count} 次,但调用计数非零**不能**证明任何特定调用点执行过 —— ` +
384
+ `本事实只能证伪,不能证实`,
385
+ `Symbol ${target.symbol} was invoked ${count} time(s), but a non-zero invocation count **cannot** prove any specific call site ran — ` +
386
+ `this fact can only falsify, never confirm`
387
+ ),
348
388
  status: STATUS.UNKNOWN,
349
389
  evidence: { file: entryPath, line },
350
390
  method: 'command',
@@ -366,13 +406,19 @@ export async function collectCoverageFacts({ coverageFile, targets = [] } = {})
366
406
  id: `coverage-${++counter}`,
367
407
  kind: KIND.TEST_COVERAGE,
368
408
  statement:
369
- `未能在覆盖率数据中定位符号 ${target.symbol}` +
370
- (target.file ? `(限定文件 ${target.file})` : '') +
409
+ t(
410
+ `未能在覆盖率数据中定位符号 ${target.symbol}`,
411
+ `Could not locate symbol ${target.symbol} in the coverage data`
412
+ ) +
413
+ (target.file ? t(`(限定文件 ${target.file})`, ` (restricted to file ${target.file})`) : '') +
371
414
  (isPy
372
- ? ' —— 可能是被重命名、被内联,或它只在模块顶层出现'
373
- : ' —— 可能是被重命名、被内联,或它只在模块顶层出现(c8 把这类调用点放在 branchMap 而非 fnMap)'),
415
+ ? t(' —— 可能是被重命名、被内联,或它只在模块顶层出现', ' — it may have been renamed, inlined, or it only appears at module top level')
416
+ : t(
417
+ ' —— 可能是被重命名、被内联,或它只在模块顶层出现(c8 把这类调用点放在 branchMap 而非 fnMap)',
418
+ ' — it may have been renamed, inlined, or it only appears at module top level (c8 places such call sites in branchMap, not fnMap)'
419
+ )),
374
420
  status: STATUS.UNKNOWN,
375
- evidence: { file: target.file ?? '(未限定文件)' },
421
+ evidence: { file: target.file ?? t('(未限定文件)', '(unrestricted file)') },
376
422
  method: 'static',
377
423
  detail: { symbol: target.symbol, reason: 'symbol_not_located_in_fnmap' }
378
424
  })
package/src/facts/deps.js CHANGED
@@ -24,6 +24,7 @@
24
24
  import { readFile, stat } from 'node:fs/promises';
25
25
  import path from 'node:path';
26
26
  import { makeFact, notEvaluated as notEvaluatedEntry, KIND, STATUS, shellQuote } from '../contract.js';
27
+ import { T, DEFAULT_LANG } from '../lang.js';
27
28
 
28
29
  /** Formats this producer can parse, and the filenames that select them. */
29
30
  const SUPPORTED = [
@@ -149,7 +150,8 @@ function resolveGo(text, dep) {
149
150
  * that cannot be read. The throw happens once, up front, so a malformed file
150
151
  * becomes one notEvaluated reason instead of one failed fact per dependency.
151
152
  */
152
- function makeResolver(type, text) {
153
+ function makeResolver(type, text, lang = DEFAULT_LANG) {
154
+ const t = T(lang);
153
155
  if (type === 'npm') {
154
156
  const root = JSON.parse(text); // throws on malformed JSON
155
157
  // A syntactically valid but structurally empty file (e.g. `{}`) is not a
@@ -160,23 +162,38 @@ function makeResolver(type, text) {
160
162
  typeof root !== 'object' ||
161
163
  (root.packages === undefined && root.dependencies === undefined && root.lockfileVersion === undefined)
162
164
  ) {
163
- throw new Error('package-lock.json 结构不完整(缺 lockfileVersion/packages/dependencies),不像真实的 npm 锁文件');
165
+ throw new Error(
166
+ t(
167
+ 'package-lock.json 结构不完整(缺 lockfileVersion/packages/dependencies),不像真实的 npm 锁文件',
168
+ 'package-lock.json is structurally incomplete (missing lockfileVersion/packages/dependencies); it does not look like a real npm lockfile'
169
+ )
170
+ );
164
171
  }
165
172
  return (dep) => resolveNpm(root, dep, text);
166
173
  }
167
174
  if (type === 'cargo') {
168
175
  if (!/\[\[package\]\]|^version\s*=/m.test(text)) {
169
- throw new Error('Cargo.lock 结构不完整(无 [[package]] 块或 version 头),不像真实的 Cargo 锁文件');
176
+ throw new Error(
177
+ t(
178
+ 'Cargo.lock 结构不完整(无 [[package]] 块或 version 头),不像真实的 Cargo 锁文件',
179
+ 'Cargo.lock is structurally incomplete (no [[package]] blocks or version header); it does not look like a real Cargo lockfile'
180
+ )
181
+ );
170
182
  }
171
183
  return (dep) => resolveCargo(text, dep);
172
184
  }
173
185
  if (type === 'go') {
174
186
  if (!/^module\s+\S+/m.test(text)) {
175
- throw new Error('go.mod 结构不完整(无 module 行),不像真实的 go.mod');
187
+ throw new Error(
188
+ t(
189
+ 'go.mod 结构不完整(无 module 行),不像真实的 go.mod',
190
+ 'go.mod is structurally incomplete (no module line); it does not look like a real go.mod'
191
+ )
192
+ );
176
193
  }
177
194
  return (dep) => resolveGo(text, dep);
178
195
  }
179
- throw new Error(`不受支持的依赖清单格式: ${type}`);
196
+ throw new Error(t(`不受支持的依赖清单格式: ${type}`, `unsupported dependency manifest format: ${type}`));
180
197
  }
181
198
 
182
199
  function typeForFile(name) {
@@ -222,14 +239,20 @@ async function unsupportedIn(cwd) {
222
239
  * @param {string} [options.lockfile] explicit manifest path (overrides detection)
223
240
  * @param {string[]} options.deps dependency names to query (repeatable)
224
241
  */
225
- export async function collectDependencyFacts({ cwd = process.cwd(), lockfile, deps = [] } = {}) {
242
+ export async function collectDependencyFacts({ cwd = process.cwd(), lockfile, deps = [], lang = DEFAULT_LANG } = {}) {
243
+ const t = T(lang);
226
244
  const facts = [];
227
245
  const evaluated = [];
228
246
  const notEvaluated = [];
229
247
  let counter = 0;
230
248
 
231
249
  if (deps.length === 0) {
232
- notEvaluated.push(notEvaluatedEntry(KIND.DEPENDENCY, '没有指定要查询的依赖(--dep)'));
250
+ notEvaluated.push(
251
+ notEvaluatedEntry(
252
+ KIND.DEPENDENCY,
253
+ t('没有指定要查询的依赖(--dep)', 'no dependency requested for query (--dep)')
254
+ )
255
+ );
233
256
  return { facts, evaluated, notEvaluated, measured: false };
234
257
  }
235
258
 
@@ -241,7 +264,10 @@ export async function collectDependencyFacts({ cwd = process.cwd(), lockfile, de
241
264
  await stat(filePath);
242
265
  } catch {
243
266
  notEvaluated.push(
244
- notEvaluatedEntry(KIND.DEPENDENCY, `无法读取依赖清单 ${filePath}: ENOENT`)
267
+ notEvaluatedEntry(
268
+ KIND.DEPENDENCY,
269
+ t(`无法读取依赖清单 ${filePath}: ENOENT`, `cannot read dependency manifest ${filePath}: ENOENT`)
270
+ )
245
271
  );
246
272
  return { facts, evaluated, notEvaluated, measured: false };
247
273
  }
@@ -249,7 +275,10 @@ export async function collectDependencyFacts({ cwd = process.cwd(), lockfile, de
249
275
  notEvaluated.push(
250
276
  notEvaluatedEntry(
251
277
  KIND.DEPENDENCY,
252
- `${path.basename(filePath)} 不是受支持的依赖清单格式;本产出器支持 package-lock.json / Cargo.lock / go.mod`
278
+ t(
279
+ `${path.basename(filePath)} 不是受支持的依赖清单格式;本产出器支持 package-lock.json / Cargo.lock / go.mod`,
280
+ `${path.basename(filePath)} is not a supported dependency manifest format; this producer supports package-lock.json / Cargo.lock / go.mod`
281
+ )
253
282
  )
254
283
  );
255
284
  return { facts, evaluated, notEvaluated, measured: false };
@@ -260,8 +289,14 @@ export async function collectDependencyFacts({ cwd = process.cwd(), lockfile, de
260
289
  if (found.length === 0) {
261
290
  const unsupported = await unsupportedIn(cwd);
262
291
  const reason = unsupported.length
263
- ? `检测到 ${unsupported.join('、')},但 Tier 0 暂不支持;本产出器支持 package-lock.json / Cargo.lock / go.mod`
264
- : '未找到受支持的依赖清单(package-lock.json / Cargo.lock / go.mod),没有可测量的锁定版本';
292
+ ? t(
293
+ `检测到 ${unsupported.join('、')},但 Tier 0 暂不支持;本产出器支持 package-lock.json / Cargo.lock / go.mod`,
294
+ `detected ${unsupported.join(', ')}, but Tier 0 does not support them yet; this producer supports package-lock.json / Cargo.lock / go.mod`
295
+ )
296
+ : t(
297
+ '未找到受支持的依赖清单(package-lock.json / Cargo.lock / go.mod),没有可测量的锁定版本',
298
+ 'no supported dependency manifest found (package-lock.json / Cargo.lock / go.mod); no pinned versions to measure'
299
+ );
265
300
  notEvaluated.push(notEvaluatedEntry(KIND.DEPENDENCY, reason));
266
301
  return { facts, evaluated, notEvaluated, measured: false };
267
302
  }
@@ -275,7 +310,10 @@ export async function collectDependencyFacts({ cwd = process.cwd(), lockfile, de
275
310
  notEvaluated.push(
276
311
  notEvaluatedEntry(
277
312
  KIND.DEPENDENCY,
278
- `同目录还检测到 ${unsupported.join('、')}(Tier 0 暂不支持),本次只测了 ${target.file}`
313
+ t(
314
+ `同目录还检测到 ${unsupported.join('、')}(Tier 0 暂不支持),本次只测了 ${target.file}`,
315
+ `also detected ${unsupported.join(', ')} in the same directory (Tier 0 does not support them yet); only ${target.file} was measured this run`
316
+ )
279
317
  )
280
318
  );
281
319
  }
@@ -286,26 +324,36 @@ export async function collectDependencyFacts({ cwd = process.cwd(), lockfile, de
286
324
  text = await readFile(target.path, 'utf8');
287
325
  } catch (error) {
288
326
  notEvaluated.push(
289
- notEvaluatedEntry(KIND.DEPENDENCY, `无法读取 ${target.path}: ${error.code ?? error.message}`)
327
+ notEvaluatedEntry(
328
+ KIND.DEPENDENCY,
329
+ t(
330
+ `无法读取 ${target.path}: ${error.code ?? error.message}`,
331
+ `cannot read ${target.path}: ${error.code ?? error.message}`
332
+ )
333
+ )
290
334
  );
291
335
  return { facts, evaluated, notEvaluated, measured: false };
292
336
  }
293
337
 
294
338
  let resolver;
295
339
  try {
296
- resolver = makeResolver(target.type, text);
340
+ resolver = makeResolver(target.type, text, lang);
297
341
  } catch (error) {
298
342
  notEvaluated.push(
299
343
  notEvaluatedEntry(
300
344
  KIND.DEPENDENCY,
301
- `${target.path} 解析失败(${target.type}):${error.message}`
345
+ t(
346
+ `${target.path} 解析失败(${target.type}):${error.message}`,
347
+ `${target.path} failed to parse (${target.type}): ${error.message}`
348
+ )
302
349
  )
303
350
  );
304
351
  return { facts, evaluated, notEvaluated, measured: false };
305
352
  }
306
353
 
307
- const verb = target.type === 'go' ? '声明' : '锁定';
308
- const listLabel = target.type === 'go' ? 'go.mod' : 'lockfile';
354
+ const isGo = target.type === 'go';
355
+ const verb = isGo ? t('声明', 'declared') : t('锁定', 'locked');
356
+ const listLabel = isGo ? 'go.mod' : 'lockfile';
309
357
 
310
358
  for (const dep of deps) {
311
359
  const hits = resolver(dep);
@@ -319,10 +367,16 @@ export async function collectDependencyFacts({ cwd = process.cwd(), lockfile, de
319
367
  id: `dependency-${++counter}`,
320
368
  kind: KIND.DEPENDENCY,
321
369
  statement:
322
- `依赖 ${dep} 在 ${listLabel}(${target.type})中被${verb}为版本 ${versions.join(' / ')}` +
323
- (target.type === 'go'
324
- ? ' —— 这是声明的需求版本,非最终解析版本(go.sum 不含版本,无法在此验证解析结果)'
325
- : ''),
370
+ t(
371
+ `依赖 ${dep} 在 ${listLabel}(${target.type})中被${verb}为版本 ${versions.join(' / ')}` +
372
+ (isGo
373
+ ? ' —— 这是声明的需求版本,非最终解析版本(go.sum 不含版本,无法在此验证解析结果)'
374
+ : ''),
375
+ isGo
376
+ ? `Dependency ${dep} is declared as version ${versions.join(' / ')} in the ${listLabel} (${target.type})` +
377
+ ' — this is the declared requirement version, not the final resolved version (go.sum carries no versions, so the resolution cannot be verified here)'
378
+ : `Dependency ${dep} is locked to version ${versions.join(' / ')} in the ${listLabel} (${target.type})`
379
+ ),
326
380
  status: STATUS.ESTABLISHED,
327
381
  evidence: { file: target.path, ...(first.line ? { line: first.line } : {}) },
328
382
  method: 'command',
@@ -334,7 +388,7 @@ export async function collectDependencyFacts({ cwd = process.cwd(), lockfile, de
334
388
  // go.mod declares a requirement; calling it "resolved" would let a
335
389
  // consumer mistake it for the final build version. The field name
336
390
  // must not oversell what the source can prove.
337
- ...(target.type === 'go' ? { declaredVersions: versions } : { resolvedVersions: versions }),
391
+ ...(isGo ? { declaredVersions: versions } : { resolvedVersions: versions }),
338
392
  locations: hits.map((h) => h.location)
339
393
  }
340
394
  })
@@ -344,7 +398,10 @@ export async function collectDependencyFacts({ cwd = process.cwd(), lockfile, de
344
398
  makeFact({
345
399
  id: `dependency-${++counter}`,
346
400
  kind: KIND.DEPENDENCY,
347
- statement: `依赖 ${dep} 未出现在 ${listLabel}(${target.type})的依赖树中 —— 声称它影响本应用的声明在此被证伪`,
401
+ statement: t(
402
+ `依赖 ${dep} 未出现在 ${listLabel}(${target.type})的依赖树中 —— 声称它影响本应用的声明在此被证伪`,
403
+ `Dependency ${dep} does not appear in the dependency tree of the ${listLabel} (${target.type}) — the claim that it affects this application is falsified here`
404
+ ),
348
405
  status: STATUS.ESTABLISHED,
349
406
  evidence: { file: target.path },
350
407
  method: 'command',
@@ -352,7 +409,7 @@ export async function collectDependencyFacts({ cwd = process.cwd(), lockfile, de
352
409
  detail: {
353
410
  lockfileType: target.type,
354
411
  dependency: dep,
355
- ...(target.type === 'go' ? { declaredVersions: [] } : { resolvedVersions: [] }),
412
+ ...(isGo ? { declaredVersions: [] } : { resolvedVersions: [] }),
356
413
  reason: 'absent_from_lockfile'
357
414
  }
358
415
  })
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { git, commitSummaries } from '../git.js';
13
13
  import { makeFact, notEvaluated as notEvaluatedEntry, KIND, STATUS, shellQuote } from '../contract.js';
14
+ import { T, DEFAULT_LANG } from '../lang.js';
14
15
 
15
16
  /**
16
17
  * Strong signal: the commit is about security.
@@ -216,8 +217,10 @@ export async function collectHistoryFacts({
216
217
  origins = false,
217
218
  maxOrigins = 40,
218
219
  securityPattern = SECURITY_PATTERN,
219
- fixPattern = FIX_PATTERN
220
+ fixPattern = FIX_PATTERN,
221
+ lang = DEFAULT_LANG
220
222
  } = {}) {
223
+ const t = T(lang);
221
224
  const facts = [];
222
225
  const evaluated = [];
223
226
  const notEvaluated = [];
@@ -229,7 +232,13 @@ export async function collectHistoryFacts({
229
232
 
230
233
  if (files.length === 0) {
231
234
  notEvaluated.push(
232
- notEvaluatedEntry('history', `范围 ${base}..${head} 内没有文件变更,没有可归属的删除行`)
235
+ notEvaluatedEntry(
236
+ 'history',
237
+ t(
238
+ `范围 ${base}..${head} 内没有文件变更,没有可归属的删除行`,
239
+ `No file changes in ${base}..${head}; no deleted lines to attribute`
240
+ )
241
+ )
233
242
  );
234
243
  return { facts, evaluated, notEvaluated, files, measured: true };
235
244
  }
@@ -271,8 +280,12 @@ export async function collectHistoryFacts({
271
280
  notEvaluated.push(
272
281
  notEvaluatedEntry(
273
282
  'history',
274
- `范围 ${base}..${head} 内有 ${files.length} 个文件变更,但没有任何一行被删除,` +
275
- '因此没有可归属的历史来源'
283
+ t(
284
+ `范围 ${base}..${head} 内有 ${files.length} 个文件变更,但没有任何一行被删除,` +
285
+ '因此没有可归属的历史来源',
286
+ `Range ${base}..${head} changes ${files.length} file(s) but deletes no lines; ` +
287
+ 'there is no attributable history source'
288
+ )
276
289
  )
277
290
  );
278
291
  }
@@ -369,7 +382,10 @@ export async function collectHistoryFacts({
369
382
  notEvaluated.push(
370
383
  notEvaluatedEntry(
371
384
  'history-origins',
372
- `git log -S 来源探针上限 ${maxOrigins} 已用尽,其余删除行保留 blame 归属`
385
+ t(
386
+ `git log -S 来源探针上限 ${maxOrigins} 已用尽,其余删除行保留 blame 归属`,
387
+ `git log -S origin probe cap (${maxOrigins}) exhausted; remaining deleted lines keep their blame attribution`
388
+ )
373
389
  )
374
390
  );
375
391
  }
@@ -377,8 +393,12 @@ export async function collectHistoryFacts({
377
393
  notEvaluated.push(
378
394
  notEvaluatedEntry(
379
395
  'history-origins',
380
- `${shortSkipped} 行内容过短(< ${MIN_PICKAXE_LINE_LENGTH} 字符),` +
381
- '未做来源追溯(太通用时 -S 会指向整个历史的第一个提交而非真实来源),保留 blame 归属'
396
+ t(
397
+ `${shortSkipped} 行内容过短(< ${MIN_PICKAXE_LINE_LENGTH} 字符),` +
398
+ '未做来源追溯(太通用时 -S 会指向整个历史的第一个提交而非真实来源),保留 blame 归属',
399
+ `${shortSkipped} line(s) too short (< ${MIN_PICKAXE_LINE_LENGTH} chars); ` +
400
+ 'no origin tracing done (for generic content, -S points at the first commit of the whole history rather than a real origin); blame attribution kept'
401
+ )
382
402
  )
383
403
  );
384
404
  }
@@ -386,8 +406,12 @@ export async function collectHistoryFacts({
386
406
  notEvaluated.push(
387
407
  notEvaluatedEntry(
388
408
  'history-origins',
389
- '未启用 --origins,删除行的归属基于 git blame 的「最后修改」语义;' +
390
- '被删行本身或提交 diff 含安全关键词时仍会标为 security'
409
+ t(
410
+ '未启用 --origins,删除行的归属基于 git blame 的「最后修改」语义;' +
411
+ '被删行本身或提交 diff 含安全关键词时仍会标为 security',
412
+ '--origins not enabled; deleted lines are attributed by git blame\'s "last modified" semantics; ' +
413
+ 'lines whose content or commit diff contains security vocabulary are still classified security'
414
+ )
391
415
  )
392
416
  );
393
417
  }
@@ -395,8 +419,9 @@ export async function collectHistoryFacts({
395
419
  // Split every blame group into its remainder (blame attribution) and its
396
420
  // refined subsets (one fact per origin commit). Each becomes a fact.
397
421
  const pending = [];
422
+ const unreadableSubject = () => t('(提交信息不可读)', '(unreadable commit message)');
398
423
  for (const [hash, entry] of blameByCommit) {
399
- const summary = summaries.get(hash) ?? { subject: '(提交信息不可读)', author: null, date: null };
424
+ const summary = summaries.get(hash) ?? { subject: unreadableSubject(), author: null, date: null };
400
425
  const groups = originGroups.get(hash);
401
426
  if (!groups) {
402
427
  pending.push({ hash, entry, summary, originMethod: 'blame' });
@@ -436,7 +461,7 @@ export async function collectHistoryFacts({
436
461
  lines: [...group.byFile.values()].reduce((sum, a) => sum + a.length, 0),
437
462
  byFile: group.byFile
438
463
  },
439
- summary: group.summary ?? { subject: '(提交信息不可读)', author: null, date: null },
464
+ summary: group.summary ?? { subject: unreadableSubject(), author: null, date: null },
440
465
  originMethod: 'pickaxe',
441
466
  blameCommit: hash,
442
467
  contents: group.contents
@@ -493,13 +518,25 @@ export async function collectHistoryFacts({
493
518
  // the wrong place and makes an accurate attribution look wrong.
494
519
  const originPhrase =
495
520
  originMethod === 'pickaxe'
496
- ? `本次变更删除了 ${entry.lines} 行代码,其内容最初由提交 ${hash.slice(0, 10)} 引入` +
497
- `(git blame 的最后修改者是 ${blameCommit.slice(0, 10)})`
498
- : `本次变更删除了 ${entry.lines} 行来自提交 ${hash.slice(0, 10)} 的代码`;
521
+ ? t(
522
+ `本次变更删除了 ${entry.lines} 行代码,其内容最初由提交 ${hash.slice(0, 10)} 引入` +
523
+ `(git blame 的最后修改者是 ${blameCommit.slice(0, 10)})`,
524
+ `This change deleted ${entry.lines} lines whose content was first introduced by commit ${hash.slice(0, 10)} ` +
525
+ `(git blame's last modifier is ${blameCommit.slice(0, 10)})`
526
+ )
527
+ : t(
528
+ `本次变更删除了 ${entry.lines} 行来自提交 ${hash.slice(0, 10)} 的代码`,
529
+ `This change deleted ${entry.lines} lines of code from commit ${hash.slice(0, 10)}`
530
+ );
499
531
  const statement =
500
532
  originPhrase +
501
- (fileCount > 1 ? `,分布在 ${fileCount} 个文件` : `(文件:${byFile[0].file})`) +
502
- `。提交信息:${JSON.stringify(summary.subject)},分类:${classification}`;
533
+ (fileCount > 1
534
+ ? t(`,分布在 ${fileCount} 个文件`, `, spread across ${fileCount} files`)
535
+ : t(`(文件:${byFile[0].file})`, ` (file: ${byFile[0].file})`)) +
536
+ t(
537
+ `。提交信息:${JSON.stringify(summary.subject)},分类:${classification}`,
538
+ `. Commit message: ${JSON.stringify(summary.subject)}, classification: ${classification}`
539
+ );
503
540
 
504
541
  const detail = {
505
542
  classification,
@@ -514,8 +551,14 @@ export async function collectHistoryFacts({
514
551
  // covers lines in the other files too.
515
552
  reproductionNote:
516
553
  fileCount > 1
517
- ? `上面的命令只复现「${primary.file}」中的归属;其余 ${fileCount - 1} 个文件的行区间见 byFile`
518
- : '上面的命令复现本事实涉及的全部行'
554
+ ? t(
555
+ `上面的命令只复现「${primary.file}」中的归属;其余 ${fileCount - 1} 个文件的行区间见 byFile`,
556
+ `The command above only reproduces the attribution for "${primary.file}"; the other ${fileCount - 1} file(s) line ranges are in byFile`
557
+ )
558
+ : t(
559
+ '上面的命令复现本事实涉及的全部行',
560
+ 'The command above reproduces every line this fact covers'
561
+ )
519
562
  };
520
563
 
521
564
  if (originMethod === 'pickaxe') {
@@ -552,7 +595,13 @@ export async function collectHistoryFacts({
552
595
 
553
596
  if (ranked.length === 0 && linesByFile.size > 0) {
554
597
  notEvaluated.push(
555
- notEvaluatedEntry('history', '有删除行但 blame 未能归属到任何提交(可能是二进制文件或路径异常)')
598
+ notEvaluatedEntry(
599
+ 'history',
600
+ t(
601
+ '有删除行但 blame 未能归属到任何提交(可能是二进制文件或路径异常)',
602
+ 'Deleted lines exist but blame could not attribute them to any commit (possibly binary files or path anomalies)'
603
+ )
604
+ )
556
605
  );
557
606
  }
558
607
 
@@ -565,7 +614,8 @@ export async function collectHistoryFacts({
565
614
  maxPickaxe,
566
615
  securityPattern,
567
616
  fixPattern,
568
- startCounter: counter
617
+ startCounter: counter,
618
+ lang
569
619
  });
570
620
  facts.push(...reintro.facts);
571
621
  counter = reintro.counter;
@@ -573,7 +623,10 @@ export async function collectHistoryFacts({
573
623
  notEvaluated.push(...reintro.notEvaluated);
574
624
  } else {
575
625
  notEvaluated.push(
576
- notEvaluatedEntry('reintroduction', '未启用 --pickaxe,未检查「被移除又加回」的代码行')
626
+ notEvaluatedEntry(
627
+ 'reintroduction',
628
+ t('未启用 --pickaxe,未检查「被移除又加回」的代码行', '--pickaxe not enabled; reintroduced lines were not checked')
629
+ )
577
630
  );
578
631
  }
579
632
 
@@ -602,8 +655,10 @@ async function collectReintroductionFacts({
602
655
  maxPickaxe,
603
656
  securityPattern,
604
657
  fixPattern,
605
- startCounter
658
+ startCounter,
659
+ lang = DEFAULT_LANG
606
660
  }) {
661
+ const t = T(lang);
607
662
  const facts = [];
608
663
  const notEvaluated = [];
609
664
  let counter = startCounter;
@@ -654,9 +709,15 @@ async function collectReintroductionFacts({
654
709
  id: `reintroduction-${++counter}`,
655
710
  kind: KIND.REINTRODUCTION,
656
711
  statement:
657
- `本次变更加回了一行在 ${base} 中不存在的代码,而该行的出现次数曾被 ${hashes.length} 个提交改变` +
712
+ t(
713
+ `本次变更加回了一行在 ${base} 中不存在的代码,而该行的出现次数曾被 ${hashes.length} 个提交改变`,
714
+ `This change re-adds a line that did not exist at ${base}, and whose occurrence count was changed by ${hashes.length} commit(s)`
715
+ ) +
658
716
  (securityOrigin
659
- ? `,其中提交 ${securityOrigin.hash.slice(0, 10)} 的提交信息含安全关键词`
717
+ ? t(
718
+ `,其中提交 ${securityOrigin.hash.slice(0, 10)} 的提交信息含安全关键词`,
719
+ `, and commit ${securityOrigin.hash.slice(0, 10)} has security vocabulary in its message`
720
+ )
660
721
  : ''),
661
722
  status: STATUS.ESTABLISHED,
662
723
  evidence: { file, snippet: line.slice(0, 160), commit: hashes[0] },
@@ -682,7 +743,10 @@ async function collectReintroductionFacts({
682
743
  notEvaluated.push(
683
744
  notEvaluatedEntry(
684
745
  'reintroduction',
685
- `pickaxe 探针上限 ${maxPickaxe} 已用尽,另有 ${candidates - probed} 行新增代码未检查`
746
+ t(
747
+ `pickaxe 探针上限 ${maxPickaxe} 已用尽,另有 ${candidates - probed} 行新增代码未检查`,
748
+ `pickaxe probe cap (${maxPickaxe}) exhausted; ${candidates - probed} added line(s) were not checked`
749
+ )
686
750
  )
687
751
  );
688
752
  }