@openmixer/plugin-qualify 0.8.0 → 0.10.1

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 (63) hide show
  1. package/README.md +58 -4
  2. package/bin/plugin-qualify.mjs +60 -25
  3. package/dist/clap-loader.d.ts +25 -1
  4. package/dist/clap-loader.js +88 -7
  5. package/dist/clap-loader.js.map +1 -1
  6. package/dist/clap-qualify.d.ts +29 -6
  7. package/dist/clap-qualify.js +77 -13
  8. package/dist/clap-qualify.js.map +1 -1
  9. package/dist/host-profiles.d.ts +14 -0
  10. package/dist/host-profiles.js +27 -1
  11. package/dist/host-profiles.js.map +1 -1
  12. package/dist/hosting-codes.d.ts +5 -2
  13. package/dist/hosting-codes.js +8 -4
  14. package/dist/hosting-codes.js.map +1 -1
  15. package/dist/hosting-suitability.d.ts +32 -3
  16. package/dist/hosting-suitability.js +35 -6
  17. package/dist/hosting-suitability.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/lv2-loader.d.ts +5 -0
  22. package/dist/lv2-loader.js +30 -2
  23. package/dist/lv2-loader.js.map +1 -1
  24. package/dist/measure-host.js +2 -2
  25. package/dist/measurement/cli.d.ts +13 -0
  26. package/dist/measurement/cli.js +68 -2
  27. package/dist/measurement/cli.js.map +1 -1
  28. package/dist/measurement/format.d.ts +1 -1
  29. package/dist/measurement/vocabulary.d.ts +1 -1
  30. package/dist/measurement/vocabulary.js +1 -1
  31. package/dist/measurement/vocabulary.js.map +1 -1
  32. package/dist/pins-file.d.ts +76 -0
  33. package/dist/pins-file.js +165 -0
  34. package/dist/pins-file.js.map +1 -0
  35. package/dist/qualify-cli.d.ts +2 -0
  36. package/dist/qualify-cli.js +7 -1
  37. package/dist/qualify-cli.js.map +1 -1
  38. package/dist/qualify-declarations.d.ts +23 -4
  39. package/dist/qualify-declarations.js +19 -3
  40. package/dist/qualify-declarations.js.map +1 -1
  41. package/dist/qualify-options.js +6 -1
  42. package/dist/qualify-options.js.map +1 -1
  43. package/package.json +3 -2
  44. package/src/clap-loader.ts +99 -7
  45. package/src/clap-qualify.ts +89 -12
  46. package/src/host-profiles.ts +29 -1
  47. package/src/hosting-codes.ts +8 -4
  48. package/src/hosting-suitability.ts +61 -8
  49. package/src/index.ts +1 -0
  50. package/src/lv2-loader.ts +28 -2
  51. package/src/measure-host.ts +2 -2
  52. package/src/measurement/cli.ts +77 -2
  53. package/src/measurement/format.ts +1 -1
  54. package/src/measurement/vocabulary.ts +1 -1
  55. package/src/pins-file.ts +200 -0
  56. package/src/qualify-cli.ts +8 -1
  57. package/src/qualify-declarations.ts +21 -4
  58. package/src/qualify-options.ts +7 -1
  59. package/tools/ai-trace-commit-check.sh +114 -0
  60. package/tools/ai-trace-patterns.json +33 -0
  61. package/tools/benchmark.py +1 -1
  62. package/tools/lv2-measure.mjs +16 -0
  63. package/tools/private-refs-check.sh +90 -0
@@ -24,12 +24,19 @@ import {
24
24
  HOSTING_CODE,
25
25
  clapRtReasons,
26
26
  contributedHostingRating,
27
+ noteSweepReasons,
27
28
  rtSafetyReasons,
28
29
  type HostingReason,
29
30
  type PluginClapMeasurement,
31
+ type PluginNoteSweepMeasurement,
30
32
  type PluginRtSafetyMeasurement,
31
33
  } from "./hosting-suitability.js";
32
- import { OPENMIXER_CONSOLE_PROFILE, type HostProfile } from "./host-profiles.js";
34
+ import {
35
+ OMX_CLAP_HOST_CONFIGURED_EXTENSIONS,
36
+ OMX_CLAP_HOST_EXTENSIONS,
37
+ OPENMIXER_CONSOLE_PROFILE,
38
+ type HostProfile,
39
+ } from "./host-profiles.js";
33
40
  import type { PluginTarget } from "./loader.js";
34
41
  import type { QualifyVerdict } from "./qualify-cli.js";
35
42
  import { RATING_SEVERITY, worstRating, type SuitabilityRating } from "./rating.js";
@@ -51,6 +58,12 @@ export interface ClapRtSafetyReading extends PluginRtSafetyMeasurement {
51
58
  readonly events?: readonly string[];
52
59
  }
53
60
 
61
+ /** What the note sweep counted; `applicable: false` is a plugin the prober did not play. */
62
+ export interface ClapNoteSweepReading extends PluginNoteSweepMeasurement {
63
+ /** The first events the interposer recorded inside `process()` while the plugin was played. */
64
+ readonly events?: readonly string[];
65
+ }
66
+
54
67
  /** The CLAP facts as the prober printed them: the measurement, and whether any output was not finite. */
55
68
  export interface ClapProbedFacts extends PluginClapMeasurement {
56
69
  readonly nonFinite: boolean;
@@ -65,6 +78,8 @@ export interface ClapQualifyLine {
65
78
  readonly hostExtensions: readonly string[];
66
79
  readonly clap: ClapProbedFacts;
67
80
  readonly rtSafety: ClapRtSafetyReading;
81
+ /** The note sweep: the plugin played through its note input, counted apart. */
82
+ readonly noteSweep: ClapNoteSweepReading;
68
83
  /**
69
84
  * The pin (spec §11): the `.clap`'s digest by its name in its directory and the layout fingerprint
70
85
  * the prober computed through plugin-hostd's `pin.h`, or the reason it took none.
@@ -143,6 +158,31 @@ function readRtSafety(v: unknown): ClapRtSafetyReading {
143
158
  };
144
159
  }
145
160
 
161
+ function readNoteSweep(v: unknown): ClapNoteSweepReading {
162
+ if (!isRecord(v)) return bad("no noteSweep");
163
+ if (!bool(v, "applicable", "noteSweep")) return { applicable: false };
164
+ const dialect = str(v, "dialect", "noteSweep");
165
+ if (dialect !== "clap" && dialect !== "midi") return bad(`noteSweep dialect ${dialect}`);
166
+ const played = {
167
+ applicable: true,
168
+ dialect,
169
+ blocks: count(v, "blocks", "noteSweep"),
170
+ blockFrames: count(v, "blockFrames", "noteSweep"),
171
+ noteEvents: count(v, "noteEvents", "noteSweep"),
172
+ processErrors: count(v, "processErrors", "noteSweep"),
173
+ nonFinite: bool(v, "nonFinite", "noteSweep"),
174
+ } as const;
175
+ if (!bool(v, "interposer", "noteSweep")) return { ...played, interposer: false };
176
+ return {
177
+ ...played,
178
+ interposer: true,
179
+ allocationsInRun: count(v, "allocationsInRun", "noteSweep"),
180
+ locksInRun: count(v, "locksInRun", "noteSweep"),
181
+ syscallsInRun: count(v, "syscallsInRun", "noteSweep"),
182
+ events: strings(v, "events", "noteSweep"),
183
+ };
184
+ }
185
+
146
186
  /** One line of the prober's stdout, or a {@link ClapQualifyError} saying how it differs. */
147
187
  export function parseClapQualifyLine(text: string): ClapQualifyLine {
148
188
  let v: unknown;
@@ -167,6 +207,7 @@ export function parseClapQualifyLine(text: string): ClapQualifyLine {
167
207
  hostExtensions: strings(v, "hostExtensions", "the line"),
168
208
  clap: readClap(v["clap"]),
169
209
  rtSafety: readRtSafety(v["rtSafety"]),
210
+ noteSweep: readNoteSweep(v["noteSweep"]),
170
211
  integrity: readIntegrity(v["integrity"]) ?? bad("no integrity"),
171
212
  };
172
213
  }
@@ -178,8 +219,13 @@ export function parseClapQualifyOutput(stdout: string): ClapQualifyLine[] {
178
219
 
179
220
  // --- the verdict document ------------------------------------------------------------------------
180
221
 
181
- /** The document's schema name; a reader refuses any other. */
182
- export const RT_SAFETY_DOCUMENT_SCHEMA = "plugin-qualify.rt-safety/1";
222
+ /**
223
+ * The document's schema name; a reader refuses any other it does not know. `/2` is `/1` with
224
+ * `measured.noteSweep` added and the note sweep's reasons folded into `verdict` (a plugin with no
225
+ * note input gets none, so its `/2` verdict is its `/1` verdict); every `/1` field is unchanged, so
226
+ * a `/1` reader of `verdict.rating` and `verdict.rtSafe` reads a `/2` document right.
227
+ */
228
+ export const RT_SAFETY_DOCUMENT_SCHEMA = "plugin-qualify.rt-safety/2";
183
229
 
184
230
  /**
185
231
  * The RT-safety verdict of one plugin binary for one console: keyed by the binary's SHA-256 and
@@ -207,6 +253,7 @@ export interface RtSafetyDocument {
207
253
  readonly hostExtensions: readonly string[];
208
254
  readonly clap: ClapProbedFacts;
209
255
  readonly rtSafety: ClapRtSafetyReading;
256
+ readonly noteSweep: ClapNoteSweepReading;
210
257
  readonly integrity: IntegrityMeasurement;
211
258
  };
212
259
  }
@@ -225,7 +272,7 @@ export function rtSafetyDocument(line: ClapQualifyLine, profile: HostProfile = O
225
272
  offered === null
226
273
  ? [{ dimension: "rtSafety", kind: "objective", rating: "unknown", code: HOSTING_CODE.clapFormatNotHosted, params: {} }]
227
274
  : offeredAsProfile
228
- ? [...rtSafetyReasons(line.rtSafety), ...clapRtReasons(line.clap)]
275
+ ? [...rtSafetyReasons(line.rtSafety), ...clapRtReasons(line.clap), ...noteSweepReasons(line.noteSweep, line.clap.noteInputs)]
229
276
  : [
230
277
  {
231
278
  dimension: "rtSafety",
@@ -244,7 +291,13 @@ export function rtSafetyDocument(line: ClapQualifyLine, profile: HostProfile = O
244
291
  plugin: { uri: line.uri, format: "clap", binary: line.binary },
245
292
  console: profile.id,
246
293
  verdict: { rating, rtSafe: rating === "suitable", ...(rating === "suitable" || deciding === undefined ? {} : { deciding }), reasons },
247
- measured: { hostExtensions: line.hostExtensions, clap: line.clap, rtSafety: line.rtSafety, integrity: line.integrity },
294
+ measured: {
295
+ hostExtensions: line.hostExtensions,
296
+ clap: line.clap,
297
+ rtSafety: line.rtSafety,
298
+ noteSweep: line.noteSweep,
299
+ integrity: line.integrity,
300
+ },
248
301
  };
249
302
  }
250
303
 
@@ -303,6 +356,8 @@ export interface ClapQualifyOptions {
303
356
  readonly env?: Readonly<Record<string, string | undefined>>;
304
357
  /** Whether a file exists; the default asks the filesystem. */
305
358
  readonly exists?: (path: string) => boolean;
359
+ /** The host extensions the prober offers beyond the console's, each one of {@link OMX_CLAP_HOST_CONFIGURED_EXTENSIONS}. */
360
+ readonly offer?: readonly string[];
306
361
  }
307
362
 
308
363
  /**
@@ -341,6 +396,7 @@ export function qualifyClap(binaryPath: string, id: string | undefined, options:
341
396
  String(CLAP_SWEEP_FRAMES),
342
397
  "--blocks",
343
398
  String(CLAP_SWEEP_BLOCKS),
399
+ ...(options.offer === undefined || options.offer.length === 0 ? [] : ["--offer", options.offer.join(",")]),
344
400
  binaryPath,
345
401
  ...(id === undefined ? [] : [id]),
346
402
  ];
@@ -364,9 +420,24 @@ export interface ClapQualifyPass {
364
420
  }
365
421
 
366
422
  /**
367
- * Qualify every CLAP target, one prober run per target (a crash takes only that plugin), and judge
368
- * each line under every host profile. A target without a binary path (a bare `urn:clap:` id) has
369
- * nothing to load and is a failure, never a pass.
423
+ * What the prober must offer beyond the console's extensions to measure for `profile`: the extra
424
+ * extensions of its list, when the prober can offer each and the list holds the console's; `undefined`
425
+ * for a profile the prober cannot stand in for (no CLAP path, or an extension it cannot offer), which
426
+ * is judged from the console's run and reads `unknown` there.
427
+ */
428
+ export function proberOffer(profile: HostProfile): readonly string[] | undefined {
429
+ const wanted = profile.clapExtensions;
430
+ if (wanted === null || !OMX_CLAP_HOST_EXTENSIONS.every((e) => wanted.includes(e))) return undefined;
431
+ const extra = wanted.filter((e) => !OMX_CLAP_HOST_EXTENSIONS.includes(e));
432
+ if (!extra.every((e) => OMX_CLAP_HOST_CONFIGURED_EXTENSIONS.includes(e))) return undefined;
433
+ return OMX_CLAP_HOST_CONFIGURED_EXTENSIONS.filter((e) => extra.includes(e));
434
+ }
435
+
436
+ /**
437
+ * Qualify every CLAP target, one prober run per target and set of offered extensions (a crash takes
438
+ * only that plugin), and judge each line under every host profile, from the run that offered that
439
+ * profile's extensions. A target without a binary path (a bare `urn:clap:` id) has nothing to load
440
+ * and is a failure, never a pass.
370
441
  */
371
442
  export function qualifyClapTargets(
372
443
  targets: readonly PluginTarget[],
@@ -378,10 +449,16 @@ export function qualifyClapTargets(
378
449
  const failures: { uri: string; problem: string }[] = [];
379
450
  for (const target of targets) {
380
451
  const id = target.uri.startsWith("urn:clap:") ? target.uri.slice("urn:clap:".length) : undefined;
381
- let lines: ClapQualifyLine[];
452
+ // one run per distinct offer, the console's (none) for a profile the prober cannot stand in for
453
+ const offers = new Map<string, readonly string[]>();
454
+ for (const profile of profiles) {
455
+ const offer = proberOffer(profile) ?? [];
456
+ offers.set(offer.join(","), offer);
457
+ }
458
+ const runs = new Map<string, ClapQualifyLine[]>();
382
459
  try {
383
460
  if (target.binaryPath === undefined) throw new ClapQualifyError(`${target.uri}: no .clap to load; name the file or a directory holding it`);
384
- lines = qualifyClap(target.binaryPath, id, options);
461
+ for (const [key, offer] of offers) runs.set(key, qualifyClap(target.binaryPath, id, { ...options, offer }));
385
462
  } catch (e) {
386
463
  if (!(e instanceof ClapQualifyError)) throw e;
387
464
  failures.push({ uri: target.uri, problem: e.message });
@@ -390,8 +467,8 @@ export function qualifyClapTargets(
390
467
  }
391
468
  continue;
392
469
  }
393
- for (const line of lines) {
394
- for (const profile of profiles) {
470
+ for (const profile of profiles) {
471
+ for (const line of runs.get((proberOffer(profile) ?? []).join(","))!) {
395
472
  const doc = rtSafetyDocument(line, profile);
396
473
  documents.push(doc);
397
474
  verdicts.push(rtSafetyVerdict(doc));
@@ -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',
@@ -72,6 +76,6 @@ export const HOSTING_CODE = {
72
76
  noRealisation: 'hosting.no-realisation',
73
77
  realisedInProcess: 'hosting.realised.in-process',
74
78
  // 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).
79
+ // verdict: the library's oracles are its proof.
76
80
  linked: 'hosting.linked',
77
81
  } as const;
@@ -199,12 +199,12 @@ export interface CrashSignature {
199
199
  /** The top plugin-owned stack frame, or a host frame that exonerates the plugin. */
200
200
  readonly topFrame: string;
201
201
  readonly signal?: "SIGSEGV" | "SIGABRT" | "SIGBUS";
202
- /** The note that recorded this cluster. Provenance, so a row is never folklore. */
202
+ /** What was found about this cluster. Provenance, so a row is never folklore. */
203
203
  readonly note: string;
204
204
  }
205
205
 
206
206
  /**
207
- * Seeded from the crash notes of 2026-09-03/04. A raw crash matching NO row is
207
+ * Seeded from the crashes investigated on 2026-09-03/04. A raw crash matching NO row is
208
208
  * `unknown` — the table blames a plugin only where a named investigation did.
209
209
  */
210
210
  export const CRASH_SIGNATURES: readonly CrashSignature[] = [
@@ -213,26 +213,26 @@ export const CRASH_SIGNATURES: readonly CrashSignature[] = [
213
213
  attribution: "host",
214
214
  topFrame: "pw_memmap_free",
215
215
  signal: "SIGSEGV",
216
- note: "2026-09-04-pipewire-jack-client-close-double-free.md",
216
+ note: "closing a PipeWire JACK client frees a memory map twice; the host crashes, not the plugin",
217
217
  },
218
218
  {
219
219
  id: "pipewire-jack-close-double-free-abort",
220
220
  attribution: "host",
221
221
  topFrame: "tcache_double_free_verify",
222
222
  signal: "SIGABRT",
223
- note: "2026-09-04-pipewire-jack-client-close-double-free.md",
223
+ note: "the same double free in PipeWire's JACK client close, caught by glibc's tcache check as an abort",
224
224
  },
225
225
  {
226
226
  id: "mod-host-effects-remove",
227
227
  attribution: "host",
228
228
  topFrame: "effects_remove",
229
- note: "2026-09-03-mod-host-crash-cluster2-3-investigation.md",
229
+ note: "mod-host crashes in effects_remove while removing an effect; the host is at fault",
230
230
  },
231
231
  {
232
232
  id: "plugin-instantiate-layout",
233
233
  attribution: "plugin",
234
234
  topFrame: "lilv_instance_instantiate",
235
- note: "2026-09-03-mod-host-crash-cluster2-3-investigation.md",
235
+ note: "the plugin crashes while lilv instantiates it, on a port layout it cannot host",
236
236
  },
237
237
  ];
238
238
 
@@ -342,6 +342,28 @@ export interface PluginClapMeasurement {
342
342
  readonly processCalls: number;
343
343
  }
344
344
 
345
+ /**
346
+ * The note sweep of a CLAP plugin with a note input (plugin-qualify's prober): the plugin played at
347
+ * every rate — a chord, bend and a controller, pressure, the chord let go, a burst of note ons and
348
+ * their offs, all notes off — in the dialect its first note input reads, counted under the
349
+ * interposer apart from the parameter sweep. `applicable: false` is a plugin the prober could not
350
+ * play: no note input, or none in a dialect it speaks.
351
+ */
352
+ export interface PluginNoteSweepMeasurement {
353
+ readonly applicable: boolean;
354
+ readonly dialect?: "clap" | "midi";
355
+ readonly blocks?: number;
356
+ readonly blockFrames?: number;
357
+ /** Note, expression and MIDI events the sweep sent. */
358
+ readonly noteEvents?: number;
359
+ readonly processErrors?: number;
360
+ readonly nonFinite?: boolean;
361
+ readonly interposer?: boolean;
362
+ readonly allocationsInRun?: number;
363
+ readonly locksInRun?: number;
364
+ readonly syscallsInRun?: number;
365
+ }
366
+
345
367
  /** What the classifier is told about the console asking. */
346
368
  export interface HostingClassifyOptions {
347
369
  /** The LIVE rate. Cost is read here and nowhere else — never projected. */
@@ -888,6 +910,37 @@ export function clapRtReasons(clap: PluginClapMeasurement | undefined): readonly
888
910
  return out;
889
911
  }
890
912
 
913
+ /**
914
+ * The rtSafety reasons of the note sweep, for a plugin with `noteInputs` note inputs: none for a
915
+ * plugin that takes no notes; `unknown` when it takes notes and was not played under the
916
+ * interposer (an instrument is cheap and clean at rest, so the parameter sweep alone says nothing
917
+ * about a voice); `unsuitable` on any allocation, lock or syscall while it was played, or a process
918
+ * error; `suitable` on a clean count.
919
+ */
920
+ export function noteSweepReasons(sweep: PluginNoteSweepMeasurement | undefined, noteInputs: number): readonly HostingReason[] {
921
+ if (noteInputs === 0) return [];
922
+ if (sweep === undefined || !sweep.applicable || sweep.interposer !== true || !sweep.blocks ||
923
+ sweep.allocationsInRun === undefined || sweep.locksInRun === undefined || sweep.syscallsInRun === undefined) {
924
+ return [reason("rtSafety", "objective", "unknown", HOSTING_CODE.rtSafetyNotesUnmeasured)];
925
+ }
926
+ const out: HostingReason[] = [];
927
+ if (sweep.allocationsInRun + sweep.locksInRun + sweep.syscallsInRun > 0) {
928
+ out.push(
929
+ reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.rtSafetyNotesViolations, {
930
+ allocations: sweep.allocationsInRun,
931
+ syscalls: sweep.syscallsInRun,
932
+ locks: sweep.locksInRun,
933
+ }),
934
+ );
935
+ }
936
+ if ((sweep.processErrors ?? 0) > 0) {
937
+ out.push(reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.clapProcessError, { errors: sweep.processErrors ?? 0 }));
938
+ }
939
+ return out.length > 0
940
+ ? out
941
+ : [reason("rtSafety", "objective", "suitable", HOSTING_CODE.rtSafetyNotesClean, { blocks: sweep.blocks, events: sweep.noteEvents ?? 0 })];
942
+ }
943
+
891
944
  /**
892
945
  * The verdict. Worst-wins across every dimension, exactly as `classifyForRole` folds — with
893
946
  * three gates that can poison the whole result before the fold is even read: a curator
@@ -905,7 +958,7 @@ export function classifyPluginHosting(
905
958
  // interchange's one door (`measurement-catalog.ts`), whose per-dimension precedence is the
906
959
  // only precedence there is. A second "passed measurements win" seam here was how an offline
907
960
  // 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
961
+ // The CLAP arm: the SAME six dimensions, fed from the
909
962
  // qualifier's CLAP facts where the LV2 scan's facts do not exist for the format. Stability,
910
963
  // the interposer's counts and the cost are format-blind and read unchanged.
911
964
  const reasons: HostingReason[] = descriptor.format === "clap"
@@ -988,7 +1041,7 @@ export interface HostingRealisation {
988
1041
  readonly inProcessAvailable: boolean;
989
1042
  /**
990
1043
  * 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.
1044
+ * to run in-process. Absent reads as not linked.
992
1045
  */
993
1046
  readonly linked?: boolean;
994
1047
  }
package/src/index.ts CHANGED
@@ -17,5 +17,6 @@ export * from "./clap-loader.js";
17
17
  export * from "./clap-qualify.js";
18
18
  export * from "./qualify-options.js";
19
19
  export * from "./qualify-cli.js";
20
+ export * from "./pins-file.js";
20
21
  export * from "./measure-host.js";
21
22
  export * from "./cost-fields.js";
package/src/lv2-loader.ts CHANGED
@@ -9,8 +9,9 @@
9
9
  * The manifest read is the subset every LV2 bundle's manifest uses (subject, `a lv2:Plugin`,
10
10
  * `lv2:binary <x.so>`), not a Turtle parser; `--all` goes through lilv itself.
11
11
  */
12
- import { existsSync, readFileSync, statSync } from "node:fs";
13
- import { basename, join } from "node:path";
12
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { basename, join, resolve } from "node:path";
14
15
 
15
16
  import type { PluginLoader, PluginTarget } from "./loader.js";
16
17
 
@@ -48,3 +49,28 @@ export const lv2Loader: PluginLoader = {
48
49
  return URI_SHAPE.test(target) ? [{ format: "lv2", uri: target }] : undefined;
49
50
  },
50
51
  };
52
+
53
+ /** lilv's default LV2 search path, used when `LV2_PATH` is unset. */
54
+ const LV2_DEFAULT_DIRS = [".lv2", "/usr/lib/lv2", "/usr/lib64/lv2", "/usr/local/lib/lv2", "/usr/local/lib64/lv2"];
55
+
56
+ /**
57
+ * Every `.lv2` bundle directly under the LV2 search path (`LV2_PATH`, else lilv's defaults). An
58
+ * absent directory holds none; any other fs error propagates for the caller to name.
59
+ */
60
+ export function lv2BundlesOnPath(env: Readonly<Record<string, string | undefined>>, home: string = homedir()): string[] {
61
+ const named = (env["LV2_PATH"] ?? "").split(":").filter((d) => d !== "");
62
+ const dirs = named.length > 0 ? named : LV2_DEFAULT_DIRS.map((d) => (d.startsWith("/") ? d : join(home, d)));
63
+ const bundles: string[] = [];
64
+ for (const dir of new Set(dirs.map((d) => resolve(d)))) {
65
+ let names: string[];
66
+ try {
67
+ names = readdirSync(dir);
68
+ } catch (e) {
69
+ const code = (e as NodeJS.ErrnoException).code;
70
+ if (code === "ENOENT" || code === "ENOTDIR") continue;
71
+ throw e;
72
+ }
73
+ for (const n of names.sort()) if (n.endsWith(".lv2")) bundles.push(join(dir, n));
74
+ }
75
+ return bundles;
76
+ }
@@ -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";
@@ -79,8 +79,22 @@ export interface MeasureOptions {
79
79
  readonly run: boolean;
80
80
  /** Print the plan and probe nothing. */
81
81
  readonly dryRun: boolean;
82
+ /**
83
+ * The LV2 measurer's feature-option flags (`--features`, `--strict`, ...), in the order given, passed
84
+ * to every run of the hosting pass. Absent or empty: the measurer's defaults.
85
+ */
86
+ readonly featureFlags?: readonly string[];
82
87
  }
83
88
 
89
+ /**
90
+ * The words the LV2 measurer's feature-option flags take, as `native/lv2-measure-options.c` names
91
+ * them: the provider's kinds (`--features`), its buf-size declarations (`--block-declares`) and its log
92
+ * modes (`--log`). A conformance test reads both.
93
+ */
94
+ export const LV2_FEATURE_KINDS = ["map", "unmap", "options", "bounded", "worker", "default-state", "log"] as const;
95
+ export const LV2_BLOCK_DECLARATIONS = ["bounded", "fixed", "pow2"] as const;
96
+ export const LV2_LOG_MODES = ["silent", "route", "capture"] as const;
97
+
84
98
  /** One subprocess the pipeline runs, and what it leaves behind. */
85
99
  export interface PipelineStep {
86
100
  /** Stable id: `scan` / `latency-declared` / `latency-measured` / `cost` / `hosting`. */
@@ -205,6 +219,7 @@ export function buildPipeline(options: MeasureOptions, native?: HostingTools): P
205
219
  String(LV2_HOSTING_BLOCK_FRAMES),
206
220
  String(LV2_HOSTING_LIFECYCLES),
207
221
  String(LV2_HOSTING_REPEATS),
222
+ ...(options.featureFlags ?? []),
208
223
  ],
209
224
  perPlugin: true,
210
225
  env: { LD_PRELOAD: native.interposer, OMX_STIM: "noise" },
@@ -287,6 +302,32 @@ function parseRates(text: string): number[] | undefined {
287
302
  /** What `--dimensions` takes: the prober's, and the hosting pass's. */
288
303
  export const MEASURED_DIMENSIONS: readonly MeasurementDimension[] = [...PROBED_DIMENSIONS, ...HOSTING_PASS_DIMENSIONS];
289
304
 
305
+ /** Whether `text` is a comma-separated list of `words` (empty: none of them). */
306
+ function wordList(text: string, words: readonly string[]): boolean {
307
+ return text === "" || text.split(",").every((w) => words.includes(w));
308
+ }
309
+
310
+ /** A block length or a buffer size: a whole number of at least `min`. */
311
+ function wholeNumber(text: string, min: number): boolean {
312
+ return /^\d+$/.test(text) && Number(text) >= min && Number(text) <= 2 ** 31 - 1;
313
+ }
314
+
315
+ /**
316
+ * What each feature-option flag takes: a check of its value, or `undefined` for a switch. The
317
+ * measurer checks the values again; these refuse a bad one before any plugin is run.
318
+ */
319
+ const FEATURE_FLAGS: Readonly<Record<string, ((value: string) => boolean) | undefined>> = {
320
+ "--features": (v) => wordList(v, LV2_FEATURE_KINDS),
321
+ "--block-declares": (v) => wordList(v, LV2_BLOCK_DECLARATIONS),
322
+ "--block-min": (v) => wholeNumber(v, 0),
323
+ "--block-max": (v) => wholeNumber(v, 0),
324
+ "--block-nominal": (v) => wholeNumber(v, 0),
325
+ "--log": (v) => (LV2_LOG_MODES as readonly string[]).includes(v),
326
+ "--log-capture-bytes": (v) => wholeNumber(v, 2),
327
+ "--strict": undefined,
328
+ "--no-strict": undefined,
329
+ };
330
+
290
331
  function parseDimensions(text: string): MeasurementDimension[] | undefined {
291
332
  const wanted = text.split(",").map((part) => part.trim());
292
333
  const dimensions = MEASURED_DIMENSIONS.filter((d) => wanted.includes(d));
@@ -320,6 +361,7 @@ export function parseMeasureArgs(
320
361
  let python = env.PYTHON ?? "python3";
321
362
  let run = env.LV2_MEASURE_RUN === "1";
322
363
  let dryRun = false;
364
+ const featureFlags: string[] = [];
323
365
 
324
366
  for (let i = 0; i < argv.length; i += 1) {
325
367
  const flag = argv[i];
@@ -403,8 +445,19 @@ export function parseMeasureArgs(
403
445
  case "--dry-run":
404
446
  dryRun = true;
405
447
  break;
406
- default:
407
- return { ok: false, error: `unknown argument: ${String(flag)}` };
448
+ default: {
449
+ if (flag === undefined || !Object.hasOwn(FEATURE_FLAGS, flag)) {
450
+ return { ok: false, error: `unknown argument: ${String(flag)}` };
451
+ }
452
+ const check = FEATURE_FLAGS[flag];
453
+ if (check === undefined) {
454
+ featureFlags.push(flag);
455
+ break;
456
+ }
457
+ const value = next();
458
+ if (value === undefined || !check(value)) return { ok: false, error: `${flag}: ${featureFlagUse(flag)}` };
459
+ featureFlags.push(flag, value);
460
+ }
408
461
  }
409
462
  }
410
463
 
@@ -414,6 +467,12 @@ export function parseMeasureArgs(
414
467
  error: "--rates is required: a measurement with no stated rate is not a measurement",
415
468
  };
416
469
  }
470
+ if (featureFlags.length > 0 && !dimensions.some((d) => (HOSTING_PASS_DIMENSIONS as readonly string[]).includes(d))) {
471
+ return {
472
+ ok: false,
473
+ error: `the feature options are the hosting pass's: ${featureFlags[0]} needs one of ${HOSTING_PASS_DIMENSIONS.join(",")}`,
474
+ };
475
+ }
417
476
  if (plugins !== undefined && dimensions.some((d) => (PROBED_DIMENSIONS as readonly string[]).includes(d))) {
418
477
  return {
419
478
  ok: false,
@@ -436,10 +495,26 @@ export function parseMeasureArgs(
436
495
  ...(plugins === undefined ? {} : { plugins }),
437
496
  ...(costCpu === undefined ? {} : { costCpu }),
438
497
  ...(label === undefined ? {} : { label }),
498
+ ...(featureFlags.length === 0 ? {} : { featureFlags }),
439
499
  },
440
500
  };
441
501
  }
442
502
 
503
+ function featureFlagUse(flag: string): string {
504
+ switch (flag) {
505
+ case "--features":
506
+ return `a comma-separated list of ${LV2_FEATURE_KINDS.join(",")} (empty: none)`;
507
+ case "--block-declares":
508
+ return `a comma-separated list of ${LV2_BLOCK_DECLARATIONS.join(",")} (empty: none)`;
509
+ case "--log":
510
+ return `one of ${LV2_LOG_MODES.join(", ")}`;
511
+ case "--log-capture-bytes":
512
+ return "a size in bytes of at least 2";
513
+ default:
514
+ return "a number of frames, 0 for the run's block";
515
+ }
516
+ }
517
+
443
518
  // ---------------------------------------------------------------------------
444
519
  // Progress
445
520
  // ---------------------------------------------------------------------------
@@ -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
  */
@@ -302,7 +302,7 @@ export function readDimension(value: unknown): MeasurementDimension | undefined
302
302
  export const VERDICT_RATINGS = ["suitable", "conditional", "unknown", "unsuitable"] as const;
303
303
  export type VerdictRating = (typeof VERDICT_RATINGS)[number];
304
304
 
305
- /** How a host runs its plugins (`2026-09-25-plugin-qualify.md` §3a). */
305
+ /** How a host runs its plugins. */
306
306
  export const HOST_ISOLATIONS = ["in-process", "per-process", "shared-process"] as const;
307
307
  export type HostIsolation = (typeof HOST_ISOLATIONS)[number];
308
308