@openmixer/plugin-qualify 0.20.3 → 0.20.4

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 (56) hide show
  1. package/catalogs/ca.json +244 -1
  2. package/catalogs/de.json +49 -0
  3. package/catalogs/es.json +49 -0
  4. package/catalogs/fr.json +49 -0
  5. package/catalogs/it.json +49 -0
  6. package/catalogs/pt.json +49 -0
  7. package/dist/compare.d.ts +12 -11
  8. package/dist/compare.js +71 -38
  9. package/dist/compare.js.map +1 -1
  10. package/dist/hostd-pin-files.d.ts +6 -2
  11. package/dist/hostd-pin-files.js +10 -6
  12. package/dist/hostd-pin-files.js.map +1 -1
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.js +4 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/plugin-db-cli.d.ts +28 -0
  17. package/dist/plugin-db-cli.js +43 -0
  18. package/dist/plugin-db-cli.js.map +1 -0
  19. package/dist/plugin-db-pin.d.ts +8 -0
  20. package/dist/plugin-db-pin.js +29 -0
  21. package/dist/plugin-db-pin.js.map +1 -0
  22. package/dist/qualify-sync.js +3 -2
  23. package/dist/qualify-sync.js.map +1 -1
  24. package/dist/record-figures.d.ts +52 -0
  25. package/dist/record-figures.js +58 -0
  26. package/dist/record-figures.js.map +1 -0
  27. package/dist/refused-stderr-kept.d.ts +12 -0
  28. package/dist/refused-stderr-kept.js +67 -0
  29. package/dist/refused-stderr-kept.js.map +1 -0
  30. package/dist/text-catalog.d.ts +41 -0
  31. package/dist/text-catalog.js +97 -0
  32. package/dist/text-catalog.js.map +1 -0
  33. package/dist/text-codes.d.ts +29 -0
  34. package/dist/text-codes.generated.d.ts +88 -0
  35. package/dist/text-codes.generated.js +96 -0
  36. package/dist/text-codes.generated.js.map +1 -0
  37. package/dist/text-codes.js +19 -0
  38. package/dist/text-codes.js.map +1 -0
  39. package/package.json +3 -3
  40. package/schema/codes.json +372 -1
  41. package/src/compare.ts +85 -45
  42. package/src/hostd-pin-files.ts +13 -8
  43. package/src/index.ts +4 -0
  44. package/src/plugin-db-cli.ts +78 -0
  45. package/src/plugin-db-pin.ts +33 -0
  46. package/src/qualify-sync.ts +3 -2
  47. package/src/record-figures.ts +114 -0
  48. package/src/refused-stderr-kept.ts +84 -0
  49. package/src/text-catalog.ts +116 -0
  50. package/src/text-codes.generated.ts +97 -0
  51. package/src/text-codes.ts +35 -0
  52. package/tools/catalogs-gate.sh +22 -0
  53. package/tools/compare-host.mjs +1 -1
  54. package/tools/compare-run.mjs +10 -6
  55. package/tools/gen-text-codes.d.mts +8 -0
  56. package/tools/gen-text-codes.mjs +77 -0
package/src/compare.ts CHANGED
@@ -15,6 +15,9 @@
15
15
  */
16
16
  import { createHash } from "node:crypto";
17
17
 
18
+ import { TEXT_CODE, type ReasonText } from "./text-codes.js";
19
+ import { reasonText } from "./text-catalog.js";
20
+
18
21
  export const COMPARISON_RECORD_SCHEMA = "plugin-qualify.comparison-record/1";
19
22
  export const COMPARISON_SUMMARY_SCHEMA = "plugin-qualify.comparison-summary/1";
20
23
  export const COMPARISON_RECORD_SUFFIX = ".comparison.json";
@@ -33,7 +36,7 @@ export interface HostTraits {
33
36
  readonly drive: "protocol" | "process";
34
37
  readonly formats: readonly ("lv2" | "clap")[];
35
38
  /** Why the plugin latency it reports is or is not observable. */
36
- readonly latency: { readonly observable: boolean; readonly how: string };
39
+ readonly latency: { readonly observable: boolean; readonly how: ReasonText };
37
40
  readonly rt: string;
38
41
  }
39
42
 
@@ -42,28 +45,28 @@ export const HOST_TRAITS: Readonly<Record<CompareHost, HostTraits>> = {
42
45
  program: "omx-clap-host",
43
46
  drive: "protocol",
44
47
  formats: ["lv2", "clap"],
45
- latency: { observable: true, how: "the host sets a JACK latency callback: the plugin's latency is on its output port's capture range" },
48
+ latency: { observable: true, how: { code: TEXT_CODE.compareLatencyPortObservable } },
46
49
  rt: "its JACK process callback, counted by rt_watch.so",
47
50
  },
48
51
  "plugin-hostd": {
49
52
  program: "plugin-hostd",
50
53
  drive: "protocol",
51
54
  formats: ["lv2", "clap"],
52
- latency: { observable: true, how: "its worker (omx-clap-host) sets a JACK latency callback" },
55
+ latency: { observable: true, how: { code: TEXT_CODE.compareLatencyHostdPortObservable } },
53
56
  rt: "the JACK process callback of every process it starts, counted by rt_watch.so",
54
57
  },
55
58
  "mod-host": {
56
59
  program: "mod-host",
57
60
  drive: "protocol",
58
61
  formats: ["lv2"],
59
- latency: { observable: false, how: "mod-host sets no JACK latency callback, so a plugin's latency is not on its ports" },
62
+ latency: { observable: false, how: { code: TEXT_CODE.compareLatencyNotObservableModHost } },
60
63
  rt: "its JACK process callback, counted by rt_watch.so",
61
64
  },
62
65
  jalv: {
63
66
  program: "jalv",
64
67
  drive: "process",
65
68
  formats: ["lv2"],
66
- latency: { observable: true, how: "jalv sets a JACK latency callback: the plugin's latency is on its output port's capture range" },
69
+ latency: { observable: true, how: { code: TEXT_CODE.compareLatencyPortObservable } },
67
70
  rt: "its JACK process callback, counted by rt_watch.so",
68
71
  },
69
72
  };
@@ -234,15 +237,15 @@ export interface HostPresence {
234
237
  readonly installed: boolean;
235
238
  readonly version: string | null;
236
239
  /** Why it is skipped, when it is. */
237
- readonly reason?: string;
240
+ readonly reason?: ReasonText;
238
241
  }
239
242
 
240
243
  /** The reference hosts a publishable summary lacks, by name, with the reason each is missing. */
241
- export function missingReferences(present: readonly HostPresence[]): readonly { host: CompareHost; reason: string }[] {
244
+ export function missingReferences(present: readonly HostPresence[]): readonly { host: CompareHost; reason: ReasonText }[] {
242
245
  return REFERENCE_HOSTS.flatMap((h) => {
243
246
  const p = present.find((x) => x.host === h);
244
- if (p === undefined) return [{ host: h, reason: "not asked for with --hosts" }];
245
- if (!p.installed) return [{ host: h, reason: p.reason ?? `${HOST_TRAITS[h].program} is not installed` }];
247
+ if (p === undefined) return [{ host: h, reason: { code: TEXT_CODE.compareHostNotAsked } }];
248
+ if (!p.installed) return [{ host: h, reason: p.reason ?? { code: TEXT_CODE.compareHostNotInstalled, args: { program: HOST_TRAITS[h].program } } }];
246
249
  return [];
247
250
  });
248
251
  }
@@ -256,7 +259,7 @@ export function presenceFromRecords(records: readonly ComparisonRecord[]): HostP
256
259
  if (ran !== undefined) return [{ host: h, installed: true, version: ran.version }];
257
260
  if (rows.every((x) => x.status === "not-applicable")) return [{ host: h, installed: true, version: rows[0]?.version ?? null }];
258
261
  const skipped = rows.find((x) => x.status === "skipped");
259
- return [{ host: h, installed: false, version: null, reason: skipped?.reason ?? `${HOST_TRAITS[h].program} did not run` }];
262
+ return [{ host: h, installed: false, version: null, reason: skipped?.reason ?? { code: TEXT_CODE.compareHostDidNotRun, args: { program: HOST_TRAITS[h].program } } }];
260
263
  });
261
264
  }
262
265
 
@@ -308,7 +311,7 @@ export type FigureResult = "measured" | "not-observable" | "not-run";
308
311
 
309
312
  export interface Check {
310
313
  readonly result: CheckResult;
311
- readonly detail?: string;
314
+ readonly detail?: ReasonText;
312
315
  }
313
316
  export interface OutputCheck extends Check {
314
317
  readonly peak?: number;
@@ -335,12 +338,12 @@ export interface CpuFigure {
335
338
  readonly result: FigureResult;
336
339
  /** utime + stime of the host's processes over the soak, in percent of one core. */
337
340
  readonly percent?: number;
338
- readonly detail?: string;
341
+ readonly detail?: ReasonText;
339
342
  }
340
343
  export interface LatencyFigure {
341
344
  readonly result: FigureResult;
342
345
  readonly frames?: number;
343
- readonly detail?: string;
346
+ readonly detail?: ReasonText;
344
347
  }
345
348
 
346
349
  export interface HostChecks {
@@ -358,7 +361,7 @@ export interface HostRow {
358
361
  readonly host: CompareHost;
359
362
  readonly version: string | null;
360
363
  readonly status: "ran" | "skipped" | "not-applicable";
361
- readonly reason?: string;
364
+ readonly reason?: ReasonText;
362
365
  readonly checks?: HostChecks;
363
366
  /** The step the host died at and how, when it did. */
364
367
  readonly crash?: { readonly step: string; readonly signal: string };
@@ -410,11 +413,11 @@ const audible = (p: number | null | undefined): boolean => typeof p === "number"
410
413
 
411
414
  /** The real-time verdict from the reports of the host's processes. */
412
415
  export function rtCheckOf(reports: readonly RtReport[] | undefined): RtCheck {
413
- if (reports === undefined || reports.length === 0) return { result: "not-observable", detail: "no process of the host reported (rt_watch.so was not loaded, or the host was gone)" };
416
+ if (reports === undefined || reports.length === 0) return { result: "not-observable", detail: { code: TEXT_CODE.compareRtNotReported } };
414
417
  const watched = reports.filter((r) => r.callbacks > 0);
415
418
  const armed = watched.reduce((s, r) => s + r.armedCycles, 0);
416
- if (watched.length === 0) return { result: "not-observable", detail: "no process of the host set a JACK process callback that rt_watch.so could wrap" };
417
- if (armed === 0) return { result: "not-observable", detail: "the process callback never ran past its warm-up" };
419
+ if (watched.length === 0) return { result: "not-observable", detail: { code: TEXT_CODE.compareRtNoCallback } };
420
+ if (armed === 0) return { result: "not-observable", detail: { code: TEXT_CODE.compareRtNoWarmUp } };
418
421
  const allocations = watched.reduce((s, r) => s + r.allocations, 0);
419
422
  const locks = watched.reduce((s, r) => s + r.locks, 0);
420
423
  const syscalls = watched.reduce((s, r) => s + r.syscalls, 0);
@@ -429,65 +432,92 @@ export function rtCheckOf(reports: readonly RtReport[] | undefined): RtCheck {
429
432
  syscalls,
430
433
  cycles: armed,
431
434
  ...(kinds.length ? { kinds } : {}),
432
- ...(clean ? (trylocks > 0 ? { detail: `${String(trylocks)} try-locks on the audio thread over ${String(armed)} cycles; a try-lock never waits` } : {}) : { detail: `on the audio thread over ${String(armed)} cycles: ${String(allocations)} allocations, ${String(locks)} locks, ${String(syscalls)} system calls` }),
435
+ ...(clean
436
+ ? trylocks > 0
437
+ ? { detail: { code: TEXT_CODE.compareRtTrylocksOnly, args: { trylocks, cycles: armed } } }
438
+ : {}
439
+ : { detail: { code: TEXT_CODE.compareRtUnsafe, args: { cycles: armed, allocations, locks, syscalls } } }),
433
440
  };
434
441
  }
435
442
 
436
- const NOT_RUN = (why: string): Check => ({ result: "not-run", detail: why });
443
+ const NOT_RUNNING: ReasonText = { code: TEXT_CODE.compareNotRunning };
444
+ const NOT_RUN = (why: ReasonText): Check => ({ result: "not-run", detail: why });
437
445
 
438
446
  /** One host's observation of one plugin as its row. */
439
447
  export function hostRowOf(host: CompareHost, version: string | null, obs: HostObservation, conditions: { cycles: number; soakS: number }): HostRow {
440
448
  const base = { host, version };
441
- if (!obs.installed) return { ...base, status: "skipped", reason: obs.problem ?? `${HOST_TRAITS[host].program} is not installed` };
442
- if (obs.started === false) return { ...base, status: "skipped", reason: obs.problem ?? `${HOST_TRAITS[host].program} did not start` };
449
+ if (!obs.installed) return { ...base, status: "skipped", reason: obs.problem ?? { code: TEXT_CODE.compareHostNotInstalled, args: { program: HOST_TRAITS[host].program } } };
450
+ if (obs.started === false) return { ...base, status: "skipped", reason: obs.problem ?? { code: TEXT_CODE.compareHostDidNotStart, args: { program: HOST_TRAITS[host].program } } };
443
451
  const load = obs.load;
444
452
  const loaded = load?.ok === true;
445
453
  const loads: Check = loaded
446
454
  ? { result: "pass" }
447
- : { result: "fail", detail: load?.died ? `the host ended (${load.died})` : load?.resp !== undefined ? `resp ${String(load.resp)}` : load?.detail ?? "no answer in time" };
455
+ : {
456
+ result: "fail",
457
+ detail: load?.died
458
+ ? { code: TEXT_CODE.compareLoadHostEnded, args: { died: load.died } }
459
+ : load?.resp !== undefined
460
+ ? { code: TEXT_CODE.compareLoadResp, args: { resp: load.resp } }
461
+ : (load?.detail ?? { code: TEXT_CODE.compareLoadTimeout }),
462
+ };
448
463
  const ports = obs.ports;
449
464
  const anyPort = ports !== undefined && ports.audioIn + ports.audioOut + ports.midiIn > 0;
450
- const instantiates: Check = !loaded ? NOT_RUN("it did not load") : anyPort ? { result: "pass" } : { result: "fail", detail: "no port of the plugin appeared on the graph" };
465
+ const instantiates: Check = !loaded
466
+ ? NOT_RUN({ code: TEXT_CODE.compareNotLoaded })
467
+ : anyPort
468
+ ? { result: "pass" }
469
+ : { result: "fail", detail: { code: TEXT_CODE.compareNoPort } };
451
470
  const running = loaded && anyPort;
452
471
 
453
472
  let output: OutputCheck;
454
- if (!running) output = { ...NOT_RUN("it is not running") };
455
- else if (ports.audioOut === 0) output = { result: "not-observable", detail: "the plugin has no audio output" };
473
+ if (!running) output = { ...NOT_RUN(NOT_RUNNING) };
474
+ else if (ports.audioOut === 0) output = { result: "not-observable", detail: { code: TEXT_CODE.compareNoAudioOutput } };
456
475
  else if (obs.peakProblem !== undefined) output = { result: "not-observable", detail: obs.peakProblem };
457
- else if ((obs.nonFinite ?? 0) > 0) output = { result: "fail", detail: `the output is not finite (${String(obs.nonFinite)} NaN or infinite samples)`, peak: obs.peak ?? 0, stimulus: obs.stimulus ?? "none" };
476
+ else if ((obs.nonFinite ?? 0) > 0)
477
+ output = { result: "fail", detail: { code: TEXT_CODE.compareOutputNotFinite, args: { count: obs.nonFinite ?? 0 } }, peak: obs.peak ?? 0, stimulus: obs.stimulus ?? "none" };
458
478
  else if (audible(obs.peak)) output = { result: "pass", peak: obs.peak as number, stimulus: obs.stimulus ?? "none" };
459
- else if (obs.stimulus === "none" || obs.stimulus === undefined) output = { result: "not-observable", detail: "no stimulus this plugin takes (no audio or MIDI input), and its output was silent", peak: obs.peak ?? 0, stimulus: "none" };
460
- else output = { result: "fail", detail: `silent under ${obs.stimulus}`, peak: obs.peak ?? 0, stimulus: obs.stimulus };
479
+ else if (obs.stimulus === "none" || obs.stimulus === undefined)
480
+ output = { result: "not-observable", detail: { code: TEXT_CODE.compareNoStimulusSilent }, peak: obs.peak ?? 0, stimulus: "none" };
481
+ else output = { result: "fail", detail: { code: TEXT_CODE.compareSilentUnderStimulus, args: { stimulus: obs.stimulus } }, peak: obs.peak ?? 0, stimulus: obs.stimulus };
461
482
 
462
483
  const c = obs.cycles;
463
484
  const cycles: CyclesCheck =
464
485
  c === undefined
465
- ? { result: "not-run", done: 0, of: conditions.cycles, detail: "the cycles did not run" }
486
+ ? { result: "not-run", done: 0, of: conditions.cycles, detail: { code: TEXT_CODE.compareCyclesNotRun } }
466
487
  : c.done === c.of
467
488
  ? { result: "pass", done: c.done, of: c.of }
468
- : { result: "fail", done: c.done, of: c.of, detail: c.died ? `the host ended at cycle ${String(c.done + 1)} (${c.died})` : `cycle ${String(c.done + 1)} failed${c.failedAt ? ` at ${c.failedAt}` : ""}` };
489
+ : {
490
+ result: "fail",
491
+ done: c.done,
492
+ of: c.of,
493
+ detail: c.died
494
+ ? { code: TEXT_CODE.compareCycleHostEnded, args: { cycle: c.done + 1, died: c.died } }
495
+ : c.failedAt
496
+ ? { code: TEXT_CODE.compareCycleFailedAt, args: { cycle: c.done + 1, step: c.failedAt } }
497
+ : { code: TEXT_CODE.compareCycleFailed, args: { cycle: c.done + 1 } },
498
+ };
469
499
 
470
500
  const s = obs.soak;
471
501
  const soak: SoakCheck = !running || s === undefined
472
- ? { result: "not-run", seconds: 0, detail: "it is not running" }
502
+ ? { result: "not-run", seconds: 0, detail: NOT_RUNNING }
473
503
  : s.alive
474
504
  ? { result: "pass", seconds: s.seconds, ...(typeof s.peakAtEnd === "number" ? { peakAtEnd: s.peakAtEnd } : {}) }
475
- : { result: "fail", seconds: s.seconds, detail: `the host ended during the soak${s.died ? ` (${s.died})` : ""}` };
505
+ : { result: "fail", seconds: s.seconds, detail: s.died ? { code: TEXT_CODE.compareSoakHostEnded, args: { died: s.died } } : { code: TEXT_CODE.compareSoakEnded } };
476
506
 
477
- const rtSafe: RtCheck = !running ? { result: "not-run", detail: "it is not running" } : rtCheckOf(obs.rt);
507
+ const rtSafe: RtCheck = !running ? { result: "not-run", detail: NOT_RUNNING } : rtCheckOf(obs.rt);
478
508
  const cpu: CpuFigure = !running || obs.cpu === undefined
479
- ? { result: "not-run", detail: "it is not running" }
509
+ ? { result: "not-run", detail: NOT_RUNNING }
480
510
  : "percent" in obs.cpu
481
511
  ? { result: "measured", percent: Math.round(obs.cpu.percent * 100) / 100 }
482
512
  : { result: "not-observable", detail: obs.cpu.problem };
483
513
  const traits = HOST_TRAITS[host];
484
514
  const latency: LatencyFigure = !running
485
- ? { result: "not-run", detail: "it is not running" }
515
+ ? { result: "not-run", detail: NOT_RUNNING }
486
516
  : !traits.latency.observable
487
517
  ? { result: "not-observable", detail: traits.latency.how }
488
518
  : typeof obs.latencyFrames === "number"
489
519
  ? { result: "measured", frames: obs.latencyFrames }
490
- : { result: "not-observable", detail: "the output port's latency could not be read" };
520
+ : { result: "not-observable", detail: { code: TEXT_CODE.compareLatencyUnreadable } };
491
521
 
492
522
  const crashed = obs.died !== undefined;
493
523
  const runsClean = !crashed && loaded && running && cycles.result === "pass" && soak.result === "pass" && output.result !== "fail";
@@ -542,7 +572,7 @@ export function comparisonRecordOf(
542
572
 
543
573
  /** The row of a host that does not take this plugin's format. */
544
574
  export function notApplicableRow(host: CompareHost, version: string | null, format: string): HostRow {
545
- return { host, version, status: "not-applicable", reason: `${HOST_TRAITS[host].program} takes no ${format.toUpperCase()} plugin` };
575
+ return { host, version, status: "not-applicable", reason: { code: TEXT_CODE.compareFormatNotTaken, args: { program: HOST_TRAITS[host].program, format: format.toUpperCase() } } };
546
576
  }
547
577
 
548
578
  /** Problems with a record's shape, empty when it is one: the records repository's check holds the same rules. */
@@ -567,7 +597,7 @@ export interface HostTotals {
567
597
  readonly host: CompareHost;
568
598
  readonly version: string | null;
569
599
  readonly status: "ran" | "skipped";
570
- readonly reason?: string;
600
+ readonly reason?: ReasonText;
571
601
  /** Plugins of a format the host takes. */
572
602
  readonly plugins: number;
573
603
  readonly loads: number;
@@ -597,7 +627,7 @@ export interface ComparisonSummary {
597
627
  readonly set: string | null;
598
628
  readonly measured: string | null;
599
629
  readonly publishable: boolean;
600
- readonly missing: readonly { host: CompareHost; reason: string }[];
630
+ readonly missing: readonly { host: CompareHost; reason: ReasonText }[];
601
631
  readonly conditions: ComparisonConditions | null;
602
632
  readonly records: number;
603
633
  /** Plugins every host that ran takes and ran: the like-for-like set the headline counts. */
@@ -636,7 +666,7 @@ export function summarise(
636
666
  host: h,
637
667
  version,
638
668
  status: skipped ? "skipped" : "ran",
639
- ...(skipped ? { reason: p?.reason ?? `${HOST_TRAITS[h].program} is not installed` } : {}),
669
+ ...(skipped ? { reason: p?.reason ?? { code: TEXT_CODE.compareHostNotInstalled, args: { program: HOST_TRAITS[h].program } } } : {}),
640
670
  plugins: rows.length,
641
671
  loads: count((r) => r.checks?.loads.result === "pass"),
642
672
  instantiates: count((r) => r.checks?.instantiates.result === "pass"),
@@ -681,19 +711,29 @@ export function summarise(
681
711
  };
682
712
  }
683
713
 
684
- /** The text table of a summary, for the terminal. */
685
- export function summaryTable(s: ComparisonSummary): string {
714
+ /** The text table of a summary, for the terminal, in `lang` (the catalog's sentence, else the English source). */
715
+ export function summaryTable(s: ComparisonSummary, lang?: string): string {
686
716
  const lines = [`host version plugins loads clean rt-safe/observed crashes cpu%`];
687
717
  for (const t of s.hosts) {
688
718
  if (t.status === "skipped") {
689
- lines.push(`${t.host.padEnd(15)} skipped: ${t.reason ?? ""}`);
719
+ lines.push(`${t.host.padEnd(15)} ${reasonText({ code: TEXT_CODE.compareSkippedReason, args: { reason: t.reason === undefined ? "" : reasonText(t.reason, lang) } }, lang)}`);
690
720
  continue;
691
721
  }
692
722
  lines.push(
693
723
  `${t.host.padEnd(15)} ${(t.version ?? "?").padEnd(27)} ${String(t.plugins).padStart(7)} ${String(t.loads).padStart(5)} ${String(t.runsClean).padStart(5)} ${`${String(t.rtSafe)}/${String(t.rtObserved)}`.padStart(16)} ${String(t.crashes).padStart(7)} ${t.cpuMedianPercent === null ? "-" : String(t.cpuMedianPercent)}`,
694
724
  );
695
725
  }
696
- for (const [h, list] of Object.entries(s.beats)) if (list.length) lines.push(`${h} does better on ${String(list.length)}: ${list.map((b) => `${b.id} (${b.on} over ${b.over.join(",")})`).join("; ")}`);
697
- if (!s.publishable) lines.push(`not publishable${s.missing.length ? `: ${s.missing.map((m) => `${m.host}: ${m.reason}`).join("; ")}` : " (no --publish)"}`);
726
+ for (const [h, list] of Object.entries(s.beats)) {
727
+ if (!list.length) continue;
728
+ const detail = list.map((b) => reasonText({ code: TEXT_CODE.compareBeatDescription, args: { id: b.id, on: b.on, over: b.over.join(",") } }, lang)).join("; ");
729
+ lines.push(reasonText({ code: TEXT_CODE.compareDoesBetterOn, args: { host: h, count: list.length, detail } }, lang));
730
+ }
731
+ if (!s.publishable) {
732
+ lines.push(
733
+ s.missing.length
734
+ ? reasonText({ code: TEXT_CODE.compareNotPublishableWithMissing, args: { missing: s.missing.map((m) => `${m.host}: ${reasonText(m.reason, lang)}`).join("; ") } }, lang)
735
+ : reasonText({ code: TEXT_CODE.compareNotPublishableNoFlag }, lang),
736
+ );
737
+ }
698
738
  return `${lines.join("\n")}\n`;
699
739
  }
@@ -12,6 +12,8 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
12
  import { tmpdir } from "node:os";
13
13
  import { join } from "node:path";
14
14
 
15
+ import { TEXT_CODE, type ReasonText } from "./text-codes.js";
16
+
15
17
  /** The daemon binary, else `plugin-hostd` on the PATH. */
16
18
  export const HOSTD_ENV = "PLUGIN_HOSTD";
17
19
 
@@ -41,9 +43,12 @@ export const SPAWNED_PIN_FILES: PinFilesRun = (uri, lv2Dirs, env) => {
41
43
 
42
44
  export type PinFiles =
43
45
  | { readonly ok: true; readonly root: string; readonly files: readonly { readonly path: string; readonly sha256: string }[] }
44
- | { readonly ok: false; readonly detail: string };
46
+ | { readonly ok: false; readonly detail: ReasonText };
45
47
 
46
- /** The files plugin-hostd collects for `uri`, each hashed; why not, when it cannot say. */
48
+ /**
49
+ * The files plugin-hostd collects for `uri`, each hashed; why not, when it cannot say: one of this package's codes
50
+ * (`reasonText` renders it), or the daemon's own last line as it wrote it.
51
+ */
47
52
  export function hostdPinFiles(
48
53
  uri: string,
49
54
  lv2Dirs: readonly string[],
@@ -52,11 +57,11 @@ export function hostdPinFiles(
52
57
  read: (path: string) => Buffer = (p) => readFileSync(p),
53
58
  ): PinFiles {
54
59
  const r = run(uri, lv2Dirs, env);
55
- if (r.missing) return { ok: false, detail: "plugin-hostd is not installed: the files of a pin are plugin-hostd's to name" };
60
+ if (r.missing) return { ok: false, detail: { code: TEXT_CODE.pinHostdNotInstalled } };
56
61
  if (r.status !== 0) {
57
- const why = r.stdout.startsWith("Usage:")
58
- ? "this plugin-hostd has no --pin-files (plugin-hostd 0.15.0 or later names the files of a pin)"
59
- : r.stderr.trim().split("\n").pop() || `plugin-hostd --pin-files exited ${String(r.status)}`;
62
+ const why: ReasonText = r.stdout.startsWith("Usage:")
63
+ ? { code: TEXT_CODE.pinHostdNoPinFiles }
64
+ : r.stderr.trim().split("\n").pop() || { code: TEXT_CODE.pinHostdExited, args: { status: String(r.status) } };
60
65
  return { ok: false, detail: why };
61
66
  }
62
67
  let root: string | undefined;
@@ -65,13 +70,13 @@ export function hostdPinFiles(
65
70
  if (line.startsWith("root ")) root = line.slice(5);
66
71
  else if (line.startsWith("file ")) names.push(line.slice(5));
67
72
  }
68
- if (root === undefined || names.length === 0) return { ok: false, detail: "plugin-hostd --pin-files printed no root or no file" };
73
+ if (root === undefined || names.length === 0) return { ok: false, detail: { code: TEXT_CODE.pinHostdNoRootOrFile } };
69
74
  try {
70
75
  // a name is the word of a pin, percent-encoded when the file's name needs it (Surge XT's libSurge%20XT.so): the pin
71
76
  // keeps it as written, the file is the name it decodes to, as the daemon opens it
72
77
  const files = names.map((path) => ({ path, sha256: createHash("sha256").update(read(join(root!, decodeURIComponent(path)))).digest("hex") }));
73
78
  return { ok: true, root, files: files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) };
74
79
  } catch (e) {
75
- return { ok: false, detail: `a file plugin-hostd names cannot be read: ${e instanceof Error ? e.message : String(e)}` };
80
+ return { ok: false, detail: { code: TEXT_CODE.pinFileUnreadable, args: { error: e instanceof Error ? e.message : String(e) } } };
76
81
  }
77
82
  }
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ export * from "./cost-fields.js";
24
24
  export * from "./probe-output.js";
25
25
  export * from "./probe-judge.js";
26
26
  export * from "./qualification-record.js";
27
+ export * from "./record-figures.js";
27
28
  export * from "./judgement-line.js";
28
29
  export * from "./judged-layer.js";
29
30
  export * from "./probe-runner.js";
@@ -42,3 +43,6 @@ export * from "./history-file.js";
42
43
  export * from "./history-adjust.js";
43
44
  export * from "./adjust-cli.js";
44
45
  export * from "./compare.js";
46
+ export * from "./text-codes.js";
47
+ export * from "./text-catalog.js";
48
+ export * from "./refused-stderr-kept.js";
@@ -0,0 +1,78 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * plugin-db is the one writer's call into plugin-db's own store, as plugin-db's records documentation describes it:
5
+ * `plugin-db pin <address> --files <sha256> [--layout <scheme>:<sha256>]` writes the pinned layer, `plugin-db put
6
+ * <address> judged --writer plugin-qualify` writes the judged layer plugin-qualify is the one writer of, its body on
7
+ * stdin. `--system` names the service's store (plugin-db's own default otherwise, for an interactive caller). Nothing
8
+ * here computes a digest or a body; `plugin-db-pin.ts` and `judged-layer.ts` do.
9
+ */
10
+ import { spawnSync } from "node:child_process";
11
+
12
+ import type { JudgedBody } from "./judged-layer.js";
13
+ import type { LayerPin } from "./judged-layer.js";
14
+
15
+ /** The binary, else `plugin-db` on the PATH. */
16
+ export const PLUGIN_DB_ENV = "PLUGIN_DB";
17
+
18
+ export interface PluginDbRunResult {
19
+ readonly status: number | null;
20
+ readonly stdout: string;
21
+ readonly stderr: string;
22
+ /** plugin-db is not installed. */
23
+ readonly missing?: boolean;
24
+ }
25
+
26
+ /** One run of `plugin-db <args...>`, the body on stdin when one is given. */
27
+ export type PluginDbRun = (args: readonly string[], input: string | undefined, env: Readonly<Record<string, string | undefined>>) => PluginDbRunResult;
28
+
29
+ export const SPAWNED_PLUGIN_DB: PluginDbRun = (args, input, env) => {
30
+ const r = spawnSync(env[PLUGIN_DB_ENV] ?? "plugin-db", args, { encoding: "utf8", timeout: 30000, env: { ...env }, ...(input === undefined ? {} : { input }) });
31
+ const missing = (r.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
32
+ return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "", ...(missing ? { missing } : {}) };
33
+ };
34
+
35
+ export interface PluginDbCallOptions {
36
+ /** The service's store (`--system`), plugin-db's own default otherwise. */
37
+ readonly system: boolean;
38
+ }
39
+
40
+ export type PluginDbCallResult = { readonly ok: true } | { readonly ok: false; readonly detail: string };
41
+
42
+ function systemFlag(args: readonly string[], opts: PluginDbCallOptions): string[] {
43
+ return opts.system ? [...args, "--system"] : [...args];
44
+ }
45
+
46
+ function refusal(r: PluginDbRunResult, verb: string): PluginDbCallResult {
47
+ if (r.missing) return { ok: false, detail: `plugin-db is not installed: the store is plugin-db's to ${verb}` };
48
+ const detail = r.stderr.trim().split("\n").pop();
49
+ return { ok: false, detail: detail || `plugin-db ${verb} exited ${String(r.status)}` };
50
+ }
51
+
52
+ /** `plugin-db pin <address> --files <sha256> [--layout <scheme>:<sha256>]`: the files of a plugin's bytes, pinned. */
53
+ export function pluginDbPin(
54
+ address: string,
55
+ pin: LayerPin,
56
+ opts: PluginDbCallOptions,
57
+ env: Readonly<Record<string, string | undefined>> = process.env,
58
+ run: PluginDbRun = SPAWNED_PLUGIN_DB,
59
+ ): PluginDbCallResult {
60
+ const args = systemFlag(["pin", address, "--files", pin.files, ...(pin.layout === null ? [] : ["--layout", pin.layout])], opts);
61
+ const r = run(args, undefined, env);
62
+ if (r.status !== 0 || r.missing) return refusal(r, "pin");
63
+ return { ok: true };
64
+ }
65
+
66
+ /** `plugin-db put <address> judged --writer plugin-qualify`, the judged layer's body on stdin. */
67
+ export function pluginDbPutJudged(
68
+ address: string,
69
+ body: JudgedBody,
70
+ opts: PluginDbCallOptions,
71
+ env: Readonly<Record<string, string | undefined>> = process.env,
72
+ run: PluginDbRun = SPAWNED_PLUGIN_DB,
73
+ ): PluginDbCallResult {
74
+ const args = systemFlag(["put", address, "judged", "--writer", "plugin-qualify"], opts);
75
+ const r = run(args, JSON.stringify(body), env);
76
+ if (r.status !== 0 || r.missing) return refusal(r, "put");
77
+ return { ok: true };
78
+ }
@@ -0,0 +1,33 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The pin plugin-db holds for an address: `files`, the combined digest of every file a plugin's bytes are
5
+ * (`omx-files/1`, plugin-db's `plugin_db_tables.h`: the SHA-256 of the line `omx-files/1`, then one line
6
+ * `<path>\t<sha256>` per file in byte order of the paths, each ending in one `\n`), and `layout`, the parameter-layout
7
+ * fingerprint plugin-hostd's prober took (`omx-layout/1:<sha256>`), or none. Computed here from the raw per-file
8
+ * digests the prober wrote (`IntegrityMeasurement`): nothing here opens a file or hashes bytes plugin-hostd did not
9
+ * already hash.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+
13
+ import type { IntegrityMeasurement, PinnedFile } from "./measurement/format.js";
14
+ import type { LayerPin } from "./judged-layer.js";
15
+
16
+ /** The scheme name the combined files digest states, plugin-db's `PDB_STORE_FILES_PIN_SCHEME`. */
17
+ export const OMX_FILES_SCHEME = "omx-files/1";
18
+
19
+ /** The combined `omx-files/1` digest of a pin's files, plugin-db's one way to fold several file hashes into one. */
20
+ export function omxFilesDigest(files: readonly PinnedFile[]): string {
21
+ const sorted = [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
22
+ const text = [`${OMX_FILES_SCHEME}\n`, ...sorted.map((f) => `${f.path}\t${f.sha256}\n`)].join("");
23
+ return createHash("sha256").update(text).digest("hex");
24
+ }
25
+
26
+ /** The pin plugin-db holds for an integrity measurement, or `undefined` when it took no whole pin (no files). */
27
+ export function layerPinOf(integrity: Pick<IntegrityMeasurement, "files" | "layout">): LayerPin | undefined {
28
+ if (integrity.files === undefined || integrity.files.length === 0) return undefined;
29
+ return {
30
+ files: omxFilesDigest(integrity.files),
31
+ layout: integrity.layout === undefined ? null : `${integrity.layout.scheme}:${integrity.layout.sha256}`,
32
+ };
33
+ }
@@ -35,6 +35,7 @@ import { iriPath, lv2BundlesOnPath, manifestPlugins } from "./lv2-loader.js";
35
35
  import { judgmentPinEntries, pinClearLine, pinLineUri, pinsFile, type PinEntry } from "./pins-file.js";
36
36
  import { POOL_RESULT, completePool, judgePoolRun, pinAfterPool, pinFromRecordWithPool, poolEnv, spawnedPoolRunner, type PoolRunner } from "./qualify-pool.js";
37
37
  import { hostdPinFiles, type PinFilesRun } from "./hostd-pin-files.js";
38
+ import { localeOf, reasonText } from "./text-catalog.js";
38
39
  import type { Judgment } from "./probe-judge.js";
39
40
  import { qualifyTargets, type ProbeRun } from "./probe-runner.js";
40
41
  import { GRID_FULL, hostCells, parseGridSpec, type GridCell, type GridSpec } from "./measurement-grid.js";
@@ -670,7 +671,7 @@ function withHostdFiles(ctx: SyncContext, pin: PinEntry | undefined): PinEntry |
670
671
  if (pin === undefined || pin.integrity.files === undefined) return pin;
671
672
  const r = hostdPinFiles(pin.uri, ctx.settings.lv2Dirs, ctx.env, ...(ctx.pinFilesRun === undefined ? [] : [ctx.pinFilesRun]));
672
673
  if (!r.ok) {
673
- ctx.log(`${pin.uri}: not pinned: ${r.detail}`);
674
+ ctx.log(`${pin.uri}: not pinned: ${reasonText(r.detail, localeOf(ctx.env))}`);
674
675
  return undefined;
675
676
  }
676
677
  return { ...pin, integrity: { ...pin.integrity, files: r.files } };
@@ -693,7 +694,7 @@ export function refreshPins(ctx: SyncContext, state: SyncState): { state: SyncSt
693
694
  if (pin === undefined || pin.integrity.files === undefined) return plugin;
694
695
  const r = hostdPinFiles(pin.uri, ctx.settings.lv2Dirs, ctx.env, ...(ctx.pinFilesRun === undefined ? [] : [ctx.pinFilesRun]));
695
696
  if (!r.ok) {
696
- ctx.log(`${pin.uri}: pin files not refreshed: ${r.detail}`);
697
+ ctx.log(`${pin.uri}: pin files not refreshed: ${reasonText(r.detail, localeOf(ctx.env))}`);
697
698
  return plugin;
698
699
  }
699
700
  const old = new Map(pin.integrity.files.map((f) => [f.path, f.sha256]));
@@ -0,0 +1,114 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The figures a qualification record holds at one rate and quantum: what plugin-qualify's own
5
+ * menus and the zynthian-ui fork both need to show, read from `plugin-qualify.qualification-record/1`
6
+ * (`qualification-record.ts`) rather than re-derived.
7
+ *
8
+ * `tools/record_figures.py` is the same reader for a Python console (zynthian-ui): the two are
9
+ * held equal over the same fixtures (`record-figures-python-conformance.test.ts`), figure for
10
+ * figure, word for word and code for code.
11
+ *
12
+ * This reader takes no side on the plugin: no tier, no state, no verdict. A figure plugin-qualify
13
+ * did not take is absent here, never a zero and never guessed — that is plugin-db's reader
14
+ * (plugin-db#4) and the tiers already in the record (`RecordTiers`).
15
+ */
16
+ import type { QualificationRecord } from "./qualification-record.js";
17
+
18
+ /** The latency frames this cell holds: what the plugin declared, and what the run measured, when either was taken. */
19
+ export interface RecordLatencyFigures {
20
+ readonly declaredFrames?: number;
21
+ readonly measuredFrames?: number;
22
+ }
23
+
24
+ /** The interposer's RT-safety counts for this cell, as the probe wrote them — never re-summed. */
25
+ export interface RecordRtSafetyFigures {
26
+ /** The interposer was loaded; a count below is zero from a real run, not from no interposer. */
27
+ readonly interposer?: boolean;
28
+ readonly swept?: boolean;
29
+ readonly sweepApplicable?: boolean;
30
+ readonly allocations?: number;
31
+ readonly locks?: number;
32
+ readonly syscalls?: number;
33
+ readonly threadViolations?: number;
34
+ readonly processErrors?: number;
35
+ /** How many events the interposer logged (`malloc ? ?`, ...); the words themselves are for a log, not this reader. */
36
+ readonly events?: number;
37
+ }
38
+
39
+ /** The figures of one qualification record at one rate and quantum. */
40
+ export interface RecordFigures {
41
+ readonly latency?: RecordLatencyFigures;
42
+ /** The median cost of this cell, in nanoseconds per sample (`ProbeCost.median`'s own unit). */
43
+ readonly costNsPerSampleMedian?: number;
44
+ readonly rtSafety?: RecordRtSafetyFigures;
45
+ /** Whether the continuous soak ran at this rate; `false` when the record never names a soak reading here. */
46
+ readonly soakRan: boolean;
47
+ /** This cell's own outcome code, carried verbatim, exactly when it is a crash. */
48
+ readonly crashCode?: string;
49
+ /** This cell's own outcome code, carried verbatim, exactly when it is a supervisor timeout. */
50
+ readonly timeoutCode?: string;
51
+ }
52
+
53
+ const PROBE_CRASHED = "probe.crashed";
54
+ const PROBE_TIMEOUT = "probe.timeout";
55
+
56
+ function rtSafetyFiguresOf(rtSafety: QualificationRecord["measurements"]["grid"][number]["rtSafety"]): RecordRtSafetyFigures | undefined {
57
+ if (rtSafety === undefined) return undefined;
58
+ return {
59
+ ...(rtSafety.interposer === undefined ? {} : { interposer: rtSafety.interposer }),
60
+ ...(rtSafety.swept === undefined ? {} : { swept: rtSafety.swept }),
61
+ ...(rtSafety.sweepApplicable === undefined ? {} : { sweepApplicable: rtSafety.sweepApplicable }),
62
+ ...(rtSafety.allocations === undefined ? {} : { allocations: rtSafety.allocations }),
63
+ ...(rtSafety.locks === undefined ? {} : { locks: rtSafety.locks }),
64
+ ...(rtSafety.syscalls === undefined ? {} : { syscalls: rtSafety.syscalls }),
65
+ ...(rtSafety.threadViolations === undefined ? {} : { threadViolations: rtSafety.threadViolations }),
66
+ ...(rtSafety.processErrors === undefined ? {} : { processErrors: rtSafety.processErrors }),
67
+ ...(rtSafety.events === undefined ? {} : { events: rtSafety.events.length }),
68
+ };
69
+ }
70
+
71
+ function latencyFiguresOf(latency: QualificationRecord["measurements"]["grid"][number]["latency"]): RecordLatencyFigures | undefined {
72
+ const declaredFrames = (latency as { declaredFrames?: number } | undefined)?.declaredFrames;
73
+ const measuredFrames = (latency as { measuredFrames?: number } | undefined)?.measuredFrames;
74
+ if (declaredFrames === undefined && measuredFrames === undefined) return undefined;
75
+ return {
76
+ ...(declaredFrames === undefined ? {} : { declaredFrames }),
77
+ ...(measuredFrames === undefined ? {} : { measuredFrames }),
78
+ };
79
+ }
80
+
81
+ /** Whether the record names a continuous soak reading at this rate (`measurements.behaviour.soak.perRate`). */
82
+ function soakRanAt(record: QualificationRecord, rate: number): boolean {
83
+ const soak = record.measurements.behaviour?.soak as { readonly perRate?: Readonly<Record<string, unknown>> } | undefined;
84
+ return soak?.perRate?.[String(rate)] !== undefined;
85
+ }
86
+
87
+ /**
88
+ * The figures `plugin-qualify.qualification-record/1` holds at one rate and quantum: the cell the
89
+ * record's grid names at that pair, else every per-cell figure is absent and `soakRan` alone still
90
+ * answers for the rate.
91
+ */
92
+ export function recordFigures(record: QualificationRecord, rate: number, quantum: number): RecordFigures {
93
+ const point = record.measurements.grid.find((p) => p.rate === rate && p.quantum === quantum);
94
+ return {
95
+ ...(point === undefined ? {} : { ...wrapLatency(point.latency), ...wrapCost(point.cost), ...wrapRtSafety(point.rtSafety) }),
96
+ soakRan: soakRanAt(record, rate),
97
+ ...(point?.outcome === PROBE_CRASHED ? { crashCode: point.outcome } : {}),
98
+ ...(point?.outcome === PROBE_TIMEOUT ? { timeoutCode: point.outcome } : {}),
99
+ };
100
+ }
101
+
102
+ function wrapLatency(latency: QualificationRecord["measurements"]["grid"][number]["latency"]): Pick<RecordFigures, "latency"> | Record<string, never> {
103
+ const l = latencyFiguresOf(latency);
104
+ return l === undefined ? {} : { latency: l };
105
+ }
106
+
107
+ function wrapCost(cost: QualificationRecord["measurements"]["grid"][number]["cost"]): Pick<RecordFigures, "costNsPerSampleMedian"> | Record<string, never> {
108
+ return cost === undefined ? {} : { costNsPerSampleMedian: cost.median };
109
+ }
110
+
111
+ function wrapRtSafety(rtSafety: QualificationRecord["measurements"]["grid"][number]["rtSafety"]): Pick<RecordFigures, "rtSafety"> | Record<string, never> {
112
+ const rt = rtSafetyFiguresOf(rtSafety);
113
+ return rt === undefined ? {} : { rtSafety: rt };
114
+ }