@openmixer/plugin-qualify 0.9.0 → 0.11.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 (81) hide show
  1. package/README.md +59 -9
  2. package/bin/plugin-qualify.mjs +62 -29
  3. package/dist/clap-loader.d.ts +1 -1
  4. package/dist/clap-loader.js +3 -3
  5. package/dist/clap-loader.js.map +1 -1
  6. package/dist/clap-qualify.d.ts +21 -45
  7. package/dist/clap-qualify.js +60 -82
  8. package/dist/clap-qualify.js.map +1 -1
  9. package/dist/cost-fields.d.ts +26 -0
  10. package/dist/cost-fields.js +30 -0
  11. package/dist/cost-fields.js.map +1 -1
  12. package/dist/host-profiles.d.ts +14 -0
  13. package/dist/host-profiles.js +27 -1
  14. package/dist/host-profiles.js.map +1 -1
  15. package/dist/hosting-codes.d.ts +6 -2
  16. package/dist/hosting-codes.js +9 -4
  17. package/dist/hosting-codes.js.map +1 -1
  18. package/dist/hosting-suitability.d.ts +43 -4
  19. package/dist/hosting-suitability.js +46 -8
  20. package/dist/hosting-suitability.js.map +1 -1
  21. package/dist/index.d.ts +5 -0
  22. package/dist/index.js +5 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/judge-cli.d.ts +41 -0
  25. package/dist/judge-cli.js +114 -0
  26. package/dist/judge-cli.js.map +1 -0
  27. package/dist/measure-host.js +2 -2
  28. package/dist/measurement/format.d.ts +1 -1
  29. package/dist/measurement/probe-json.js +1 -0
  30. package/dist/measurement/probe-json.js.map +1 -1
  31. package/dist/measurement/vocabulary.d.ts +1 -1
  32. package/dist/measurement/vocabulary.js +7 -1
  33. package/dist/measurement/vocabulary.js.map +1 -1
  34. package/dist/probe-judge.d.ts +60 -0
  35. package/dist/probe-judge.js +213 -0
  36. package/dist/probe-judge.js.map +1 -0
  37. package/dist/probe-output-fixtures.d.ts +25 -0
  38. package/dist/probe-output-fixtures.js +134 -0
  39. package/dist/probe-output-fixtures.js.map +1 -0
  40. package/dist/probe-output.d.ts +150 -0
  41. package/dist/probe-output.js +369 -0
  42. package/dist/probe-output.js.map +1 -0
  43. package/dist/probe-runner.d.ts +79 -0
  44. package/dist/probe-runner.js +191 -0
  45. package/dist/probe-runner.js.map +1 -0
  46. package/dist/qualification-record.d.ts +80 -0
  47. package/dist/qualification-record.js +85 -0
  48. package/dist/qualification-record.js.map +1 -0
  49. package/dist/qualify-cli.js +1 -1
  50. package/dist/qualify-cli.js.map +1 -1
  51. package/dist/qualify-declarations.d.ts +26 -6
  52. package/dist/qualify-declarations.js +25 -7
  53. package/dist/qualify-declarations.js.map +1 -1
  54. package/fixtures/clap-scan/fake.json +30 -6
  55. package/fixtures/probe-output/memory-limit-refused.json +190 -0
  56. package/fixtures/probe-output/plugin-crash.json +236 -0
  57. package/fixtures/probe-output/success-msi.json +363 -0
  58. package/package.json +7 -2
  59. package/src/clap-loader.ts +3 -2
  60. package/src/clap-qualify.ts +70 -124
  61. package/src/cost-fields.ts +39 -0
  62. package/src/host-profiles.ts +29 -1
  63. package/src/hosting-codes.ts +9 -4
  64. package/src/hosting-suitability.ts +82 -11
  65. package/src/index.ts +5 -0
  66. package/src/judge-cli.ts +133 -0
  67. package/src/measure-host.ts +2 -2
  68. package/src/measurement/format.ts +1 -1
  69. package/src/measurement/probe-json.ts +1 -0
  70. package/src/measurement/vocabulary.ts +8 -1
  71. package/src/probe-judge.ts +264 -0
  72. package/src/probe-output-fixtures.ts +146 -0
  73. package/src/probe-output.ts +505 -0
  74. package/src/probe-runner.ts +244 -0
  75. package/src/qualification-record.ts +142 -0
  76. package/src/qualify-cli.ts +1 -1
  77. package/src/qualify-declarations.ts +28 -8
  78. package/tools/ai-trace-commit-check.sh +114 -0
  79. package/tools/ai-trace-patterns.json +33 -0
  80. package/tools/benchmark.py +13 -1
  81. package/tools/private-refs-check.sh +90 -0
@@ -70,3 +70,42 @@ export function classifierCostFromDocument(reading: CostAtRate, rate: number): C
70
70
  [COST_FIELDS.classifier.coreFraction]: fraction,
71
71
  };
72
72
  }
73
+
74
+ /**
75
+ * The key of one point of a cost grid: the rate and the quantum it was measured at. One place spells it,
76
+ * so the reader that fills a grid and the gate that looks a point up cannot disagree.
77
+ */
78
+ export function costPointKey(rate: number, quantum: number): string {
79
+ return `${rate}/${quantum}`;
80
+ }
81
+
82
+ /** The percentile the classifier's `nsPerSampleP95` is. A figure taken at another percentile is not that field. */
83
+ export const CLASSIFIER_PERCENTILE = 95;
84
+
85
+ /** One point of a grid as it is measured: the figures {@link gridCostReadings} turns into the classifier's. */
86
+ export interface CostGridPoint {
87
+ readonly rate: number;
88
+ readonly quantum: number;
89
+ readonly outcome: string;
90
+ readonly cost?: { readonly median: number; readonly percentile: { readonly p: number; readonly value: number } };
91
+ }
92
+
93
+ /**
94
+ * The classifier's cost readings of a grid, by {@link costPointKey}. A point that did not end `probe.ok`, has no
95
+ * cost, or whose percentile is not {@link CLASSIFIER_PERCENTILE} has no reading: the gate then reads the pair as
96
+ * unmeasured, which is the truth, where a figure under the wrong name would be judged as if it were the P95.
97
+ */
98
+ export function gridCostReadings(points: readonly CostGridPoint[], okOutcome: string): Record<string, ClassifierCostReading> {
99
+ const out: Record<string, ClassifierCostReading> = {};
100
+ for (const p of points) {
101
+ if (p.outcome !== okOutcome || p.cost === undefined || p.cost.percentile.p !== CLASSIFIER_PERCENTILE) continue;
102
+ const fraction = coreFraction(p.cost.percentile.value, p.rate);
103
+ if (fraction === undefined) continue;
104
+ out[costPointKey(p.rate, p.quantum)] = {
105
+ [COST_FIELDS.classifier.median]: p.cost.median,
106
+ [COST_FIELDS.classifier.percentile]: p.cost.percentile.value,
107
+ [COST_FIELDS.classifier.coreFraction]: fraction,
108
+ };
109
+ }
110
+ return out;
111
+ }
@@ -54,6 +54,17 @@ export const OMX_CLAP_HOST_EXTENSIONS: readonly string[] = [
54
54
  "clap.state",
55
55
  ];
56
56
 
57
+ /**
58
+ * The three host extensions omx-clap-host turns on beyond the library's own (omx-clap-core
59
+ * `src/effects.c` `configure_core`: `preset_load`, `track_info`, `remote_controls`), by the ids
60
+ * its host object answers (`src/clap_host.c`); the prober offers them with `--offer`.
61
+ */
62
+ export const OMX_CLAP_HOST_CONFIGURED_EXTENSIONS: readonly string[] = [
63
+ "clap.preset-load/2",
64
+ "clap.track-info/1",
65
+ "clap.remote-controls/2",
66
+ ];
67
+
57
68
  /** The LV2 extension namespace the jalv and mod-host lists are spelled in. */
58
69
  const LV2_EXT = "http://lv2plug.in/ns/ext/";
59
70
  const BUF_SIZE_ALL = ["powerOf2BlockLength", "fixedBlockLength", "boundedBlockLength"].map((f) => `${LV2_EXT}buf-size#${f}`);
@@ -113,8 +124,25 @@ export const MOD_HOST_PROFILE: HostProfile = {
113
124
  isolation: "shared-process",
114
125
  };
115
126
 
127
+ /**
128
+ * omx-clap-host, alone or as plugin-hostd's CLAP worker: one JACK client per plugin in a host
129
+ * process that several plugins may share (a plugin-hostd pool), instruments fed through each
130
+ * client's `midi_in`, latency published on the JACK ports. Its CLAP host object is the library's
131
+ * plus the three it configures; its LV2 plugins run through the same feature provider as the
132
+ * console's in-process stage.
133
+ */
134
+ export const OMX_CLAP_HOST_PROFILE: HostProfile = {
135
+ id: "omx-clap-host",
136
+ lv2Features: OMX_INPROCESS_FEATURES,
137
+ clapExtensions: [...OMX_CLAP_HOST_EXTENSIONS, ...OMX_CLAP_HOST_CONFIGURED_EXTENSIONS],
138
+ instruments: true,
139
+ cost: HOST_BUDGETS["omx-clap-host"],
140
+ latency: "uncompensated",
141
+ isolation: "shared-process",
142
+ };
143
+
116
144
  /** Every profile the tool ships, by id. */
117
- export const HOST_PROFILES: readonly HostProfile[] = [OPENMIXER_CONSOLE_PROFILE, JALV_PROFILE, MOD_HOST_PROFILE];
145
+ export const HOST_PROFILES: readonly HostProfile[] = [OPENMIXER_CONSOLE_PROFILE, JALV_PROFILE, MOD_HOST_PROFILE, OMX_CLAP_HOST_PROFILE];
118
146
 
119
147
  /** A shipped profile by id, or `undefined`. */
120
148
  export function shippedHostProfile(id: string): HostProfile | undefined {
@@ -1,10 +1,10 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-or-later
2
2
  // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
3
  /**
4
- * The hosting verdict's message codes (2026-09-04-lv2-hosting-path.md). Codes, never prose: the
4
+ * The hosting verdict's message codes. Codes, never prose: the
5
5
  * surface renders the operator's locale.
6
6
  *
7
- * A UNION list file (2026-09-15-no-stored-derivations.md §9): `.gitattributes` marks it
7
+ * A UNION list file: `.gitattributes` marks it
8
8
  * `merge=union`, so two lanes that each add a code merge with both kept. Only the table lives
9
9
  * here, one `key: 'code',` per line, with keys and codes unique; the registry-hotspots gate
10
10
  * refuses anything else.
@@ -24,6 +24,10 @@ export const HOSTING_CODE = {
24
24
  rtSafetyViolations: 'hosting.rt-safety.violations-in-run',
25
25
  rtSafetyUnmeasured: 'hosting.rt-safety.unmeasured',
26
26
  rtSafetyNotReproducible: 'hosting.rt-safety.not-reproducible',
27
+ // The note sweep: an instrument played under the interposer, a count of its own.
28
+ rtSafetyNotesClean: 'hosting.rt-safety.clean-under-notes',
29
+ rtSafetyNotesViolations: 'hosting.rt-safety.violations-under-notes',
30
+ rtSafetyNotesUnmeasured: 'hosting.rt-safety.notes-unmeasured',
27
31
  featuresProvided: 'hosting.features.all-provided',
28
32
  featuresMissing: 'hosting.features.required-not-provided',
29
33
  featuresDisqualifying: 'hosting.features.host-not-provided',
@@ -44,7 +48,7 @@ export const HOSTING_CODE = {
44
48
  topologyNoAudioOutput: 'hosting.topology.no-audio-output',
45
49
  topologyExtraInputs: 'hosting.topology.extra-inputs-fed-silence',
46
50
  topologyInstrument: 'hosting.topology.instrument',
47
- // The CLAP arm (2026-09-26-clap-hosting-path.md §3): the SAME dimensions fed from CLAP facts,
51
+ // The CLAP arm: the SAME dimensions fed from CLAP facts,
48
52
  // minted here where the LV2 table has no code for the fact.
49
53
  topologyWiderThanStrip: 'hosting.topology.wider-than-strip',
50
54
  clapLatencyMismatch: 'hosting.clap.latency-mismatch',
@@ -57,6 +61,7 @@ export const HOSTING_CODE = {
57
61
  clapFormatNotHosted: 'hosting.clap.format-not-hosted',
58
62
  clapExtensionsNotCovered: 'hosting.clap.extensions-not-covered',
59
63
  outputNonFinite: 'hosting.output.non-finite',
64
+ workerNonconforming: 'hosting.worker.nonconforming',
60
65
  // The pin (spec §11; one-contract §10.3): a host's refusal at add, by the daemon's code.
61
66
  integrityUnpinned: 'hosting.integrity.unpinned',
62
67
  integrityBinaryMismatch: 'hosting.integrity.binary-mismatch',
@@ -72,6 +77,6 @@ export const HOSTING_CODE = {
72
77
  noRealisation: 'hosting.no-realisation',
73
78
  realisedInProcess: 'hosting.realised.in-process',
74
79
  // A plugin LINKED into the engine (the static-link registry) runs in-process without a
75
- // verdict: the library's oracles are its proof (`2026-09-26-clap-hosting-path.md` §7).
80
+ // verdict: the library's oracles are its proof.
76
81
  linked: 'hosting.linked',
77
82
  } as const;
@@ -52,11 +52,19 @@ export interface QualifiedPlugin {
52
52
  readonly scalingClass?: string;
53
53
  readonly paramSweep?: unknown;
54
54
  };
55
- /** Measured cost per rate (Hz as a string key). */
55
+ /** Measured cost: per rate (Hz as a string key), or per rate AND quantum. */
56
56
  readonly cpuCost?: {
57
+ /** One figure per rate, at the block the prober ran: the old format. Read only when `perRateQuantum` is absent. */
57
58
  readonly perRate?: Readonly<
58
59
  Record<string, { readonly coreFractionP95: number; readonly nsPerSampleMedian: number; readonly nsPerSampleP95: number }>
59
60
  >;
61
+ /**
62
+ * One figure per rate and quantum, keyed by {@link costPointKey}: a probe output's grid. When this is
63
+ * present it is the only cost there is, so a pair it lacks is unmeasured, never read from `perRate`.
64
+ */
65
+ readonly perRateQuantum?: Readonly<
66
+ Record<string, { readonly coreFractionP95: number; readonly nsPerSampleMedian: number; readonly nsPerSampleP95: number }>
67
+ >;
60
68
  };
61
69
  readonly stability?: PluginStabilityMeasurement;
62
70
  readonly rtSafety?: PluginRtSafetyMeasurement;
@@ -199,12 +207,12 @@ export interface CrashSignature {
199
207
  /** The top plugin-owned stack frame, or a host frame that exonerates the plugin. */
200
208
  readonly topFrame: string;
201
209
  readonly signal?: "SIGSEGV" | "SIGABRT" | "SIGBUS";
202
- /** The note that recorded this cluster. Provenance, so a row is never folklore. */
210
+ /** What was found about this cluster. Provenance, so a row is never folklore. */
203
211
  readonly note: string;
204
212
  }
205
213
 
206
214
  /**
207
- * Seeded from the crash notes of 2026-09-03/04. A raw crash matching NO row is
215
+ * Seeded from the crashes investigated on 2026-09-03/04. A raw crash matching NO row is
208
216
  * `unknown` — the table blames a plugin only where a named investigation did.
209
217
  */
210
218
  export const CRASH_SIGNATURES: readonly CrashSignature[] = [
@@ -213,26 +221,26 @@ export const CRASH_SIGNATURES: readonly CrashSignature[] = [
213
221
  attribution: "host",
214
222
  topFrame: "pw_memmap_free",
215
223
  signal: "SIGSEGV",
216
- note: "2026-09-04-pipewire-jack-client-close-double-free.md",
224
+ note: "closing a PipeWire JACK client frees a memory map twice; the host crashes, not the plugin",
217
225
  },
218
226
  {
219
227
  id: "pipewire-jack-close-double-free-abort",
220
228
  attribution: "host",
221
229
  topFrame: "tcache_double_free_verify",
222
230
  signal: "SIGABRT",
223
- note: "2026-09-04-pipewire-jack-client-close-double-free.md",
231
+ note: "the same double free in PipeWire's JACK client close, caught by glibc's tcache check as an abort",
224
232
  },
225
233
  {
226
234
  id: "mod-host-effects-remove",
227
235
  attribution: "host",
228
236
  topFrame: "effects_remove",
229
- note: "2026-09-03-mod-host-crash-cluster2-3-investigation.md",
237
+ note: "mod-host crashes in effects_remove while removing an effect; the host is at fault",
230
238
  },
231
239
  {
232
240
  id: "plugin-instantiate-layout",
233
241
  attribution: "plugin",
234
242
  topFrame: "lilv_instance_instantiate",
235
- note: "2026-09-03-mod-host-crash-cluster2-3-investigation.md",
243
+ note: "the plugin crashes while lilv instantiates it, on a port layout it cannot host",
236
244
  },
237
245
  ];
238
246
 
@@ -306,7 +314,7 @@ export type PluginLv2Features = Omit<FeaturesMeasurement, "unmeasuredReason" | "
306
314
 
307
315
  export { DEFAULT_HOSTING_POLICY, type HostingPolicy } from "./qualify-declarations.js";
308
316
  import { DEFAULT_HOSTING_POLICY, type HostingPolicy } from "./qualify-declarations.js";
309
- import { costFieldsMissing } from "./cost-fields.js";
317
+ import { costFieldsMissing, costPointKey } from "./cost-fields.js";
310
318
 
311
319
  /** One declared rate's latency reading from the CLAP qualifier (CLAP spec §3, latency). */
312
320
  export interface ClapLatencyAtRate {
@@ -342,6 +350,28 @@ export interface PluginClapMeasurement {
342
350
  readonly processCalls: number;
343
351
  }
344
352
 
353
+ /**
354
+ * The note sweep of a CLAP plugin with a note input (plugin-qualify's prober): the plugin played at
355
+ * every rate — a chord, bend and a controller, pressure, the chord let go, a burst of note ons and
356
+ * their offs, all notes off — in the dialect its first note input reads, counted under the
357
+ * interposer apart from the parameter sweep. `applicable: false` is a plugin the prober could not
358
+ * play: no note input, or none in a dialect it speaks.
359
+ */
360
+ export interface PluginNoteSweepMeasurement {
361
+ readonly applicable: boolean;
362
+ readonly dialect?: "clap" | "midi";
363
+ readonly blocks?: number;
364
+ readonly blockFrames?: number;
365
+ /** Note, expression and MIDI events the sweep sent. */
366
+ readonly noteEvents?: number;
367
+ readonly processErrors?: number;
368
+ readonly nonFinite?: boolean;
369
+ readonly interposer?: boolean;
370
+ readonly allocationsInRun?: number;
371
+ readonly locksInRun?: number;
372
+ readonly syscallsInRun?: number;
373
+ }
374
+
345
375
  /** What the classifier is told about the console asking. */
346
376
  export interface HostingClassifyOptions {
347
377
  /** The LIVE rate. Cost is read here and nowhere else — never projected. */
@@ -678,7 +708,13 @@ export function hostingCostReasons(
678
708
  options: HostingClassifyOptions,
679
709
  policy: HostingPolicy,
680
710
  ): readonly HostingReason[] {
681
- const perRate = descriptor.cpuCost?.perRate?.[String(options.rate)];
711
+ const grid = descriptor.cpuCost?.perRateQuantum;
712
+ const perRate =
713
+ grid === undefined
714
+ ? descriptor.cpuCost?.perRate?.[String(options.rate)]
715
+ : options.quantum === undefined
716
+ ? undefined
717
+ : grid[costPointKey(options.rate, options.quantum)];
682
718
  if (perRate === undefined) {
683
719
  return [
684
720
  reason("cost", "objective", "unknown", HOSTING_CODE.costUnmeasured, {
@@ -739,6 +775,10 @@ export function hostingLatencyReasons(
739
775
  // one the benchmark declared unmeasurable, has not been judged and does not pass by
740
776
  // default. The oracle's first fixture omitted latency and still expected `suitable`,
741
777
  // which is how this branch was missing — a self-consistent suite proving nothing.
778
+ // A plugin whose worker interface was refused is not unmeasured by accident: it is not hostable.
779
+ if (typeof latency?.unmeasurable === "string" && latency.unmeasurable.includes(HOSTING_CODE.workerNonconforming)) {
780
+ return [reason("latency", "objective", "unsuitable", HOSTING_CODE.workerNonconforming)];
781
+ }
742
782
  if (latency === undefined || latency.unmeasurable !== undefined) {
743
783
  return [reason("latency", "objective", "unknown", HOSTING_CODE.latencyUnmeasured)];
744
784
  }
@@ -888,6 +928,37 @@ export function clapRtReasons(clap: PluginClapMeasurement | undefined): readonly
888
928
  return out;
889
929
  }
890
930
 
931
+ /**
932
+ * The rtSafety reasons of the note sweep, for a plugin with `noteInputs` note inputs: none for a
933
+ * plugin that takes no notes; `unknown` when it takes notes and was not played under the
934
+ * interposer (an instrument is cheap and clean at rest, so the parameter sweep alone says nothing
935
+ * about a voice); `unsuitable` on any allocation, lock or syscall while it was played, or a process
936
+ * error; `suitable` on a clean count.
937
+ */
938
+ export function noteSweepReasons(sweep: PluginNoteSweepMeasurement | undefined, noteInputs: number): readonly HostingReason[] {
939
+ if (noteInputs === 0) return [];
940
+ if (sweep === undefined || !sweep.applicable || sweep.interposer !== true || !sweep.blocks ||
941
+ sweep.allocationsInRun === undefined || sweep.locksInRun === undefined || sweep.syscallsInRun === undefined) {
942
+ return [reason("rtSafety", "objective", "unknown", HOSTING_CODE.rtSafetyNotesUnmeasured)];
943
+ }
944
+ const out: HostingReason[] = [];
945
+ if (sweep.allocationsInRun + sweep.locksInRun + sweep.syscallsInRun > 0) {
946
+ out.push(
947
+ reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.rtSafetyNotesViolations, {
948
+ allocations: sweep.allocationsInRun,
949
+ syscalls: sweep.syscallsInRun,
950
+ locks: sweep.locksInRun,
951
+ }),
952
+ );
953
+ }
954
+ if ((sweep.processErrors ?? 0) > 0) {
955
+ out.push(reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.clapProcessError, { errors: sweep.processErrors ?? 0 }));
956
+ }
957
+ return out.length > 0
958
+ ? out
959
+ : [reason("rtSafety", "objective", "suitable", HOSTING_CODE.rtSafetyNotesClean, { blocks: sweep.blocks, events: sweep.noteEvents ?? 0 })];
960
+ }
961
+
891
962
  /**
892
963
  * The verdict. Worst-wins across every dimension, exactly as `classifyForRole` folds — with
893
964
  * three gates that can poison the whole result before the fold is even read: a curator
@@ -905,7 +976,7 @@ export function classifyPluginHosting(
905
976
  // interchange's one door (`measurement-catalog.ts`), whose per-dimension precedence is the
906
977
  // only precedence there is. A second "passed measurements win" seam here was how an offline
907
978
  // report could agree with itself while every served descriptor read unknown.
908
- // The CLAP arm (2026-09-26-clap-hosting-path.md §3): the SAME six dimensions, fed from the
979
+ // The CLAP arm: the SAME six dimensions, fed from the
909
980
  // qualifier's CLAP facts where the LV2 scan's facts do not exist for the format. Stability,
910
981
  // the interposer's counts and the cost are format-blind and read unchanged.
911
982
  const reasons: HostingReason[] = descriptor.format === "clap"
@@ -988,7 +1059,7 @@ export interface HostingRealisation {
988
1059
  readonly inProcessAvailable: boolean;
989
1060
  /**
990
1061
  * Whether the plugin is LINKED into the engine (its static-link registry): it needs no verdict
991
- * to run in-process (`2026-09-26-clap-hosting-path.md` §7). Absent reads as not linked.
1062
+ * to run in-process. Absent reads as not linked.
992
1063
  */
993
1064
  readonly linked?: boolean;
994
1065
  }
package/src/index.ts CHANGED
@@ -20,3 +20,8 @@ export * from "./qualify-cli.js";
20
20
  export * from "./pins-file.js";
21
21
  export * from "./measure-host.js";
22
22
  export * from "./cost-fields.js";
23
+ export * from "./probe-output.js";
24
+ export * from "./probe-judge.js";
25
+ export * from "./qualification-record.js";
26
+ export * from "./probe-runner.js";
27
+ export * from "./judge-cli.js";
@@ -0,0 +1,133 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * `plugin-qualify judge <probe-output>...`: judge probe outputs already written (by
5
+ * `plugin-hostd-probe run`, by plugin-hostd's `judge_cmd`, or by hand) and write the qualification
6
+ * record beside a byte copy of each. `bin/plugin-qualify.mjs` only reads argv and prints.
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import type { HostProfile } from "./host-profiles.js";
13
+ import { judgeProbeOutput, judgmentVerdicts } from "./probe-judge.js";
14
+ import { readProbeOutput } from "./probe-output.js";
15
+ import { writeQualification } from "./qualification-record.js";
16
+ import type { QualifyVerdict } from "./qualify-cli.js";
17
+ import { EXIT_CODE, OUTPUT_FORMATS, type OutputFormat } from "./qualify-options.js";
18
+ import { OPENMIXER_CONSOLE_PROFILE } from "./host-profiles.js";
19
+
20
+ export interface JudgeArgs {
21
+ readonly files: readonly string[];
22
+ readonly hostProfiles: readonly string[];
23
+ readonly qualificationDir?: string;
24
+ readonly format: OutputFormat;
25
+ readonly help: boolean;
26
+ }
27
+
28
+ export type ParseJudgeResult = { ok: true; args: JudgeArgs } | { ok: false; error: string };
29
+
30
+ const VALUE_FLAGS = ["--host-profile", "--qualification-dir", "--format"];
31
+
32
+ export function parseJudgeArgs(argv: readonly string[]): ParseJudgeResult {
33
+ const files: string[] = [];
34
+ const specs: string[] = [];
35
+ let dir: string | undefined;
36
+ let format: string = "text";
37
+ let help = false;
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const a = argv[i]!;
40
+ if (!a.startsWith("--")) {
41
+ files.push(a);
42
+ continue;
43
+ }
44
+ if (a === "--help") {
45
+ help = true;
46
+ continue;
47
+ }
48
+ if (!VALUE_FLAGS.includes(a)) return { ok: false, error: `unknown option ${a}` };
49
+ const v = argv[++i];
50
+ if (v === undefined || v.startsWith("--")) return { ok: false, error: `${a} needs a value` };
51
+ if (a === "--host-profile") specs.push(...v.split(",").map((t) => t.trim()).filter((t) => t !== ""));
52
+ else if (a === "--qualification-dir") dir = v;
53
+ else format = v;
54
+ }
55
+ if (!(OUTPUT_FORMATS as readonly string[]).includes(format)) return { ok: false, error: `--format must be one of ${OUTPUT_FORMATS.join(", ")}` };
56
+ if (!help && files.length === 0) return { ok: false, error: "name at least one probe output" };
57
+ return {
58
+ ok: true,
59
+ args: {
60
+ files,
61
+ hostProfiles: [...new Set(specs.length > 0 ? specs : [OPENMIXER_CONSOLE_PROFILE.id])],
62
+ ...(dir === undefined ? {} : { qualificationDir: dir }),
63
+ format: format as OutputFormat,
64
+ help,
65
+ },
66
+ };
67
+ }
68
+
69
+ export function judgeHelpText(): string {
70
+ return [
71
+ "plugin-qualify judge — judge probe outputs and write the qualification records",
72
+ "",
73
+ " plugin-qualify judge [options] <probe-output.json>...",
74
+ "",
75
+ " --host-profile <file|id> judge against this host; repeat or comma-separate for one verdict per profile",
76
+ ` (default: ${OPENMIXER_CONSOLE_PROFILE.id})`,
77
+ " --qualification-dir <dir> where the records and the copies of the probe outputs go",
78
+ " (default: $XDG_STATE_HOME/omx/qualification, else ~/.local/state/omx/qualification)",
79
+ " --format <json|text> json = the records; text = plugin, verdict, deciding reason (default: text)",
80
+ "",
81
+ `exit ${EXIT_CODE.allQualified} all qualified / ${EXIT_CODE.someNotQualified} some not / ${EXIT_CODE.couldNotMeasure} something could not be read or judged`,
82
+ "",
83
+ ].join("\n");
84
+ }
85
+
86
+ /** The qualification directory when none is named: the state directory of owner spec section 6.9, per user. */
87
+ export function defaultQualificationDir(env: Readonly<Record<string, string | undefined>>, home: string = homedir()): string {
88
+ const state = env["XDG_STATE_HOME"];
89
+ return join(state !== undefined && state !== "" ? state : join(home, ".local", "state"), "omx", "qualification");
90
+ }
91
+
92
+ export interface JudgeFilesResult {
93
+ readonly verdicts: readonly QualifyVerdict[];
94
+ readonly records: readonly { readonly recordPath: string; readonly probePath: string }[];
95
+ /** Each file that was refused or could not be read, with the code or the reason. */
96
+ readonly failures: readonly { readonly file: string; readonly problem: string }[];
97
+ }
98
+
99
+ /** Judge each file under every profile and write its record; a file that does not read is a failure, and the others go on. */
100
+ export function judgeFiles(
101
+ files: readonly string[],
102
+ options: { profiles: readonly HostProfile[]; dir: string; read?: (path: string) => Buffer },
103
+ ): JudgeFilesResult {
104
+ const read = options.read ?? ((p: string) => readFileSync(p));
105
+ const verdicts: QualifyVerdict[] = [];
106
+ const records: { recordPath: string; probePath: string }[] = [];
107
+ const failures: { file: string; problem: string }[] = [];
108
+ for (const file of files) {
109
+ let bytes: Buffer;
110
+ try {
111
+ bytes = read(file);
112
+ } catch (e) {
113
+ failures.push({ file, problem: `cannot read: ${e instanceof Error ? e.message : String(e)}` });
114
+ continue;
115
+ }
116
+ const r = readProbeOutput(bytes.toString("utf8"));
117
+ if (!r.ok) {
118
+ failures.push({ file, problem: `${r.code}: ${r.problem}` });
119
+ continue;
120
+ }
121
+ const judgment = judgeProbeOutput(r.output, options.profiles);
122
+ const w = writeQualification(options.dir, bytes, judgment);
123
+ records.push({ recordPath: w.recordPath, probePath: w.probePath });
124
+ verdicts.push(...judgmentVerdicts(judgment));
125
+ }
126
+ return { verdicts, records, failures };
127
+ }
128
+
129
+ /** 2 when a file could not be judged or a plugin was not measured, else 0 when every verdict qualifies, else 1. */
130
+ export function judgeExitCode(result: JudgeFilesResult): number {
131
+ if (result.failures.length > 0 || result.verdicts.length === 0 || result.verdicts.some((v) => !v.measured)) return EXIT_CODE.couldNotMeasure;
132
+ return result.verdicts.every((v) => v.qualified) ? EXIT_CODE.allQualified : EXIT_CODE.someNotQualified;
133
+ }
@@ -1,8 +1,8 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-or-later
2
2
  // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
3
  /**
4
- * Reading what the measuring host (`lv2_measure`) prints and supervising how long it may take
5
- * (`2026-09-25-plugin-qualify.md` §2a). Pure functions: the runner spawns, these decide.
4
+ * Reading what the measuring host (`lv2_measure`) prints and supervising how long it may take.
5
+ * Pure functions: the runner spawns, these decide.
6
6
  */
7
7
  import { isRecord } from "./measurement/guards.js";
8
8
  import { SOAK_SWEEP_DB, SOAK_SWEEP_RATIO, SOAK_TIMEOUT_FLOOR_S, SOAK_TIMEOUT_MARGIN } from "./qualify-declarations.js";
@@ -464,7 +464,7 @@ export interface HostBudget {
464
464
  }
465
465
 
466
466
  /**
467
- * A HOST PROFILE (`2026-09-25-plugin-qualify.md` §3a; §3b here): the host a verdict was judged
467
+ * A HOST PROFILE: the host a verdict was judged
468
468
  * against — only what differs between hosts. A document carries every profile its verdicts name,
469
469
  * in full, so a verdict against a project's own profile is reproducible from the document alone.
470
470
  */
@@ -55,6 +55,7 @@ export const PROBE_REASON_RULES: readonly ReasonRule[] = [
55
55
  { match: /^no-audio-in$/, code: "no-audio-in" },
56
56
  { match: /^no-audio-out$/, code: "no-audio-out" },
57
57
  { match: /^silent-output$/, code: "silent-output" },
58
+ { match: /hosting\.worker\.nonconforming/, code: "worker-nonconforming" },
58
59
  { match: /worker crashed|worker desync|worker unavailable/, code: "probe-crashed" },
59
60
  { match: /timed out|timeout/, code: "probe-timeout" },
60
61
  { match: /non-finite/, code: "non-finite-output" },
@@ -112,6 +112,13 @@ export const UNMEASURED_TERMS: readonly UnmeasuredTerm[] = [
112
112
  "The URI was not present in this machine's LV2 world. A fact about this installation, " +
113
113
  "not about the plugin — another host may well have it.",
114
114
  },
115
+ {
116
+ code: "worker-nonconforming",
117
+ kind: "probe-failed",
118
+ description:
119
+ "The plugin's LV2_Worker_Interface leaves work or work_response NULL, which the LV2 worker " +
120
+ "specification does not allow: it was refused before any call, not measured.",
121
+ },
115
122
  {
116
123
  code: "probe-error",
117
124
  kind: "probe-failed",
@@ -302,7 +309,7 @@ export function readDimension(value: unknown): MeasurementDimension | undefined
302
309
  export const VERDICT_RATINGS = ["suitable", "conditional", "unknown", "unsuitable"] as const;
303
310
  export type VerdictRating = (typeof VERDICT_RATINGS)[number];
304
311
 
305
- /** How a host runs its plugins (`2026-09-25-plugin-qualify.md` §3a). */
312
+ /** How a host runs its plugins. */
306
313
  export const HOST_ISOLATIONS = ["in-process", "per-process", "shared-process"] as const;
307
314
  export type HostIsolation = (typeof HOST_ISOLATIONS)[number];
308
315