@openmixer/plugin-qualify 0.7.0 → 0.9.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 (46) hide show
  1. package/README.md +55 -3
  2. package/bin/plugin-qualify.mjs +59 -24
  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 +3 -2
  7. package/dist/clap-qualify.js +15 -15
  8. package/dist/clap-qualify.js.map +1 -1
  9. package/dist/hosting-suitability.js +1 -1
  10. package/dist/hosting-suitability.js.map +1 -1
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/lv2-loader.d.ts +5 -0
  15. package/dist/lv2-loader.js +30 -2
  16. package/dist/lv2-loader.js.map +1 -1
  17. package/dist/measurement/cli.d.ts +13 -0
  18. package/dist/measurement/cli.js +68 -2
  19. package/dist/measurement/cli.js.map +1 -1
  20. package/dist/pins-file.d.ts +76 -0
  21. package/dist/pins-file.js +165 -0
  22. package/dist/pins-file.js.map +1 -0
  23. package/dist/qualify-cli.d.ts +2 -0
  24. package/dist/qualify-cli.js +7 -1
  25. package/dist/qualify-cli.js.map +1 -1
  26. package/dist/qualify-declarations.d.ts +9 -1
  27. package/dist/qualify-declarations.js +9 -1
  28. package/dist/qualify-declarations.js.map +1 -1
  29. package/dist/qualify-options.js +6 -1
  30. package/dist/qualify-options.js.map +1 -1
  31. package/dist/tool-paths.d.ts +35 -1
  32. package/dist/tool-paths.js +50 -6
  33. package/dist/tool-paths.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/clap-loader.ts +99 -7
  36. package/src/clap-qualify.ts +14 -21
  37. package/src/hosting-suitability.ts +1 -1
  38. package/src/index.ts +1 -0
  39. package/src/lv2-loader.ts +28 -2
  40. package/src/measurement/cli.ts +77 -2
  41. package/src/pins-file.ts +200 -0
  42. package/src/qualify-cli.ts +8 -1
  43. package/src/qualify-declarations.ts +10 -1
  44. package/src/qualify-options.ts +7 -1
  45. package/src/tool-paths.ts +68 -5
  46. package/tools/lv2-measure.mjs +29 -9
@@ -3,7 +3,8 @@
3
3
  /**
4
4
  * The CLAP implementation of the one discovery seam (`loader.ts`). A target that is a `.clap` file, or a
5
5
  * directory holding one, is scanned by `omx-clap-scan --json` (the scanner of omx-clap-host),
6
- * located by the ONE declared setting; `urn:clap:<id>` is one plugin without a scan. The
6
+ * located by the ONE declared setting; `urn:clap:<id>` is one plugin without a scan. `--all` walks
7
+ * the standard CLAP search path ({@link clapSearchPath}) through the same loader. The
7
8
  * scanner's JSON is the input: {@link clapScanDescriptors} normalises it into the interchange
8
9
  * rows. A scan that cannot be read is a {@link ClapScanError}, never an empty result.
9
10
  *
@@ -12,11 +13,20 @@
12
13
  * remote-control pages; a row keeps them, and each parameter's module, wherever the scan read them.
13
14
  */
14
15
  import { spawnSync } from "node:child_process";
15
- import { existsSync, readdirSync, statSync } from "node:fs";
16
+ import { type Dirent, existsSync, readdirSync, statSync } from "node:fs";
17
+ import { homedir } from "node:os";
16
18
  import { basename, dirname, join, resolve } from "node:path";
17
19
 
18
20
  import type { PluginLoader, PluginTarget } from "./loader.js";
19
- import { CLAP_SCAN_TIMEOUT_S, CLAP_SCAN_TOOL_DEFAULT, CLAP_SCAN_TOOL_ENV } from "./qualify-declarations.js";
21
+ import { lv2BundlesOnPath } from "./lv2-loader.js";
22
+ import {
23
+ CLAP_PATH_ENV,
24
+ CLAP_SCAN_TIMEOUT_S,
25
+ CLAP_SCAN_TOOL_DEFAULT,
26
+ CLAP_SCAN_TOOL_ENV,
27
+ CLAP_SYSTEM_DIR,
28
+ CLAP_USER_DIR,
29
+ } from "./qualify-declarations.js";
20
30
 
21
31
  /** The scanner's own name in its document — anything else is not its output. */
22
32
  const SCANNER_NAME = "omx-clap-scan";
@@ -344,11 +354,33 @@ export function clapScanTool(env: Readonly<Record<string, string | undefined>>):
344
354
  return named === undefined || named === "" ? CLAP_SCAN_TOOL_DEFAULT : named;
345
355
  }
346
356
 
357
+ /** An fs error met while walking a search directory, as the scan refusal that names the path. */
358
+ function unreadable(path: string, e: unknown): ClapScanError {
359
+ if (e instanceof ClapScanError) return e;
360
+ const code = (e as NodeJS.ErrnoException).code;
361
+ return new ClapScanError(`cannot read ${path}: ${typeof code === "string" ? code : String(e)}`);
362
+ }
363
+
364
+ /** A directory entry that is a directory, or a symlink to one; a dangling link is not, any other fs error (a loop) is thrown. */
365
+ function isDirectory(e: Dirent, p: string): boolean {
366
+ if (e.isDirectory()) return true;
367
+ if (!e.isSymbolicLink()) return false;
368
+ try {
369
+ return statSync(p).isDirectory();
370
+ } catch (err) {
371
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
372
+ throw err;
373
+ }
374
+ }
375
+
347
376
  function holdsClap(dir: string): boolean {
348
- for (const e of readdirSync(dir, { withFileTypes: true })) {
349
- const p = join(dir, e.name);
350
- const isDir = e.isDirectory() || (e.isSymbolicLink() && existsSync(p) && statSync(p).isDirectory());
351
- if (isDir ? holdsClap(p) : e.name.endsWith(CLAP_SUFFIX)) return true;
377
+ try {
378
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
379
+ const p = join(dir, e.name);
380
+ if (isDirectory(e, p) ? holdsClap(p) : e.name.endsWith(CLAP_SUFFIX)) return true;
381
+ }
382
+ } catch (e) {
383
+ throw unreadable(dir, e);
352
384
  }
353
385
  return false;
354
386
  }
@@ -391,3 +423,63 @@ export function clapLoader(options: ClapLoaderOptions = {}): PluginLoader {
391
423
  },
392
424
  };
393
425
  }
426
+
427
+ // --- the standard search path (`--all`) ----------------------------------------------------------
428
+
429
+ /**
430
+ * The directories `--all` walks, in order and once each: every non-empty entry of `CLAP_PATH`, then
431
+ * `~/.clap`, then `/usr/lib/clap`.
432
+ */
433
+ export function clapSearchPath(env: Readonly<Record<string, string | undefined>>, home: string): string[] {
434
+ const named = (env[CLAP_PATH_ENV] ?? "").split(":").filter((d) => d !== "");
435
+ return [...new Set([...named, join(home, CLAP_USER_DIR), CLAP_SYSTEM_DIR].map((d) => resolve(d)))];
436
+ }
437
+
438
+ export interface ClapDiscoverOptions extends ClapLoaderOptions {
439
+ /** The home directory `~/.clap` is read under; the default is the user's. */
440
+ readonly home?: string;
441
+ }
442
+
443
+ /**
444
+ * Every CLAP plugin on the standard search path, each directory resolved through {@link clapLoader}
445
+ * exactly as a named directory is. A directory that is absent or holds no `.clap` is skipped; a
446
+ * plugin id found twice keeps its first place on the path. A directory that cannot be read (a
447
+ * permission error, a symlink loop) is a {@link ClapScanError} naming it, never a partial list.
448
+ */
449
+ export function discoverClapTargets(options: ClapDiscoverOptions = {}): PluginTarget[] {
450
+ const loader = clapLoader(options);
451
+ const found = new Map<string, PluginTarget>();
452
+ for (const dir of clapSearchPath(options.env ?? process.env, options.home ?? homedir())) {
453
+ try {
454
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) continue;
455
+ for (const t of loader.resolve(dir) ?? []) if (!found.has(t.uri)) found.set(t.uri, t);
456
+ } catch (e) {
457
+ throw unreadable(dir, e);
458
+ }
459
+ }
460
+ return [...found.values()];
461
+ }
462
+
463
+ export interface DiscoverAllOptions extends ClapDiscoverOptions {}
464
+
465
+ /**
466
+ * The targets of `--all`. The LV2 plugins lilv finds cannot be measured from this entry point, so
467
+ * when the LV2 search path holds any bundle the run refuses (exit 2) and names how many, rather
468
+ * than qualifying only the CLAP side and reporting everything qualified.
469
+ */
470
+ export function discoverAllTargets(options: DiscoverAllOptions = {}): PluginTarget[] {
471
+ const env = options.env ?? process.env;
472
+ let bundles: string[];
473
+ try {
474
+ bundles = lv2BundlesOnPath(env, options.home ?? homedir());
475
+ } catch (e) {
476
+ throw unreadable("the LV2 search path", e);
477
+ }
478
+ if (bundles.length > 0) {
479
+ throw new ClapScanError(
480
+ `--all cannot measure LV2 from this entry point and ${String(bundles.length)} LV2 bundle(s) are on the LV2 path ` +
481
+ `(first: ${bundles[0]}); name the plugins or bundles to qualify instead, or unset LV2_PATH to an empty directory`,
482
+ );
483
+ }
484
+ return discoverClapTargets(options);
485
+ }
@@ -33,7 +33,7 @@ import { OPENMIXER_CONSOLE_PROFILE, type HostProfile } from "./host-profiles.js"
33
33
  import type { PluginTarget } from "./loader.js";
34
34
  import type { QualifyVerdict } from "./qualify-cli.js";
35
35
  import { RATING_SEVERITY, worstRating, type SuitabilityRating } from "./rating.js";
36
- import { locateNativeTool } from "./tool-paths.js";
36
+ import { halfPopulatedRefusal, locateNativeTools } from "./tool-paths.js";
37
37
  import { readIntegrity, type IntegrityMeasurement } from "./measurement/format.js";
38
38
 
39
39
  export class ClapQualifyError extends Error {
@@ -305,32 +305,25 @@ export interface ClapQualifyOptions {
305
305
  readonly exists?: (path: string) => boolean;
306
306
  }
307
307
 
308
- /** One native tool, located (`locateNativeTool`). Absent, it throws, naming where it looked. */
309
- function locate(
310
- env: Readonly<Record<string, string | undefined>>,
311
- what: string,
312
- file: string,
313
- exists: (path: string) => boolean,
314
- ): string {
315
- const { path, looked, setting } = locateNativeTool(file, env, exists);
316
- if (path === undefined) {
317
- throw new ClapQualifyError(`no CLAP ${what} at ${looked.join(", ")}: install the plugin-qualify package, build it with make -C native, or set ${setting}`);
318
- }
319
- return path;
320
- }
321
-
322
308
  /**
323
- * The prober and the interposer this run uses: each its setting, else the first of the installed
324
- * directories (`NATIVE_TOOL_DIRS`) that holds it, else the package's own build.
309
+ * The prober and the interposer this run uses: each its setting, else both from one place, the
310
+ * package's own build or else the first installed directory (`NATIVE_TOOL_DIRS`) holding them
311
+ * (`locateNativeTools`). Absent, or half there, it throws, naming where it looked.
325
312
  */
326
313
  export function clapQualifyTools(
327
314
  env: Readonly<Record<string, string | undefined>>,
328
315
  exists: (path: string) => boolean = existsSync,
329
316
  ): { tool: string; interposer: string } {
330
- return {
331
- tool: locate(env, "prober", CLAP_QUALIFY_TOOL_NAME, exists),
332
- interposer: locate(env, "interposer", RT_INTERPOSE_NAME, exists),
333
- };
317
+ const { tools, halfPopulated } = locateNativeTools([CLAP_QUALIFY_TOOL_NAME, RT_INTERPOSE_NAME], env, exists);
318
+ if (halfPopulated !== undefined) throw new ClapQualifyError(`no CLAP prober and interposer pair: ${halfPopulatedRefusal(halfPopulated)}`);
319
+ const [tool, interposer] = tools.map(({ path, looked, setting }, i) => {
320
+ if (path === undefined) {
321
+ const what = i === 0 ? "prober" : "interposer";
322
+ throw new ClapQualifyError(`no CLAP ${what} at ${looked.join(", ")}: install the plugin-qualify package, build it with make -C native, or set ${setting}`);
323
+ }
324
+ return path;
325
+ });
326
+ return { tool: tool!, interposer: interposer! };
334
327
  }
335
328
 
336
329
  /**
@@ -799,7 +799,7 @@ export function clapTopologyReasons(clap: PluginClapMeasurement | undefined, ins
799
799
  return [reason("topology", "objective", "unsuitable", HOSTING_CODE.topologyWiderThanStrip, { ins, outs })];
800
800
  }
801
801
  if (clap.sidechainInputs > 0) {
802
- return [reason("topology", "objective", "conditional", HOSTING_CODE.topologyExtraInputs, { ins, outs, sidechains: clap.sidechainInputs })];
802
+ return [reason("topology", "objective", "unsuitable", HOSTING_CODE.topologyExtraInputs, { ins, outs, sidechains: clap.sidechainInputs })];
803
803
  }
804
804
  return [reason("topology", "objective", "suitable", HOSTING_CODE.topologyMatched, { ins, outs })];
805
805
  }
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
+ }
@@ -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
  // ---------------------------------------------------------------------------
@@ -0,0 +1,200 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The pins file `plugin-qualify --pins <file>` writes and plugin-hostd loads (`pin_file` in its settings, or its
5
+ * pin store's import): one line per qualified plugin, the SAME line as hostd's `pin_set` verb, with the
6
+ * qualification verdict as a last word.
7
+ *
8
+ * pin_set <uri> <path>=<sha256>[,<path>=<sha256>...] <scheme>:<sha256> [verdict=<word>]
9
+ *
10
+ * The digests and the layout are the prober's, raw (`IntegrityMeasurement`); nothing here computes a hash. A plugin
11
+ * whose run took no whole pin has no line: a pins file never holds a partial pin. The grammar is plugin-hostd's
12
+ * (`include/plugin-hostd/protocol.h`, PHD_PIN_*); `parsePinLine` reads a line back with the daemon's rules, so a
13
+ * writer that drifts from them is a red test and not a refused add.
14
+ */
15
+ import { resolve } from "node:path";
16
+
17
+ import type { RtSafetyDocument } from "./clap-qualify.js";
18
+ import type { IntegrityMeasurement, MeasuredPlugin } from "./measurement/format.js";
19
+ import type { QualifyVerdict } from "./qualify-cli.js";
20
+ import type { SuitabilityRating } from "./rating.js";
21
+
22
+ /** The verb the line starts with in a pins file, hostd's `PHD_VERB_PIN_SET`. */
23
+ export const PIN_VERB = "pin_set";
24
+
25
+ /** The key of the verdict word, hostd's `PHD_PIN_VERDICT_KEY`. */
26
+ export const PIN_VERDICT_KEY = "verdict=";
27
+
28
+ /** The verdict words hostd knows (`PHD_PIN_VERDICTS`). */
29
+ export const PIN_VERDICTS = ["in-process", "isolated", "refused"] as const;
30
+ export type PinVerdict = (typeof PIN_VERDICTS)[number];
31
+
32
+ /**
33
+ * What a rating says about where the plugin may run, in hostd's words. `suitable` is qualified for a worker that other
34
+ * plugins share. `conditional` loads and is isolated. `unsuitable` is measured to fail and is `refused`. `unknown` is no
35
+ * opinion: no verdict word, and hostd treats the plugin as not qualified.
36
+ */
37
+ export function pinVerdictOf(rating: SuitabilityRating): PinVerdict | undefined {
38
+ switch (rating) {
39
+ case "suitable":
40
+ return "in-process";
41
+ case "conditional":
42
+ return "isolated";
43
+ case "unsuitable":
44
+ return "refused";
45
+ case "unknown":
46
+ return undefined;
47
+ }
48
+ }
49
+
50
+ /** One pin: what hostd's `pin_set` takes, and the verdict of the run that took it. */
51
+ export interface PinEntry {
52
+ /** hostd's uri: `clap:<path of the .clap>#<id>`, or an LV2 plugin's own URI. */
53
+ readonly uri: string;
54
+ readonly integrity: IntegrityMeasurement;
55
+ readonly verdict?: PinVerdict;
56
+ }
57
+
58
+ /** The line of one pin, or `undefined` when the integrity block is not a whole pin. A uri with whitespace in it would be read back as more words: it is refused, by name. */
59
+ export function pinLine(entry: PinEntry): string | undefined {
60
+ const spaced = [entry.uri, ...(entry.integrity.files ?? []).map((f) => f.path)].find((w) => /\s/.test(w));
61
+ if (spaced !== undefined) {
62
+ throw new Error(`${entry.uri}: ${JSON.stringify(spaced)} has whitespace in it and cannot be a word of a pin line`);
63
+ }
64
+ const { files, layout } = entry.integrity;
65
+ if (files === undefined || files.length === 0 || layout === undefined) return undefined;
66
+ const list = files.map((f) => `${f.path}=${f.sha256}`).join(",");
67
+ const verdict = entry.verdict === undefined ? "" : ` ${PIN_VERDICT_KEY}${entry.verdict}`;
68
+ return `${PIN_VERB} ${entry.uri} ${list} ${layout.scheme}:${layout.sha256}${verdict}`;
69
+ }
70
+
71
+ /** hostd's uri of a CLAP plugin the prober loaded: its file as it was handed, made absolute, and its id. */
72
+ export function clapPinUri(binaryPath: string, pluginUri: string): string {
73
+ const id = pluginUri.startsWith("urn:clap:") ? pluginUri.slice("urn:clap:".length) : pluginUri;
74
+ return `clap:${resolve(binaryPath)}#${id}`;
75
+ }
76
+
77
+ /** What went into a pins file and what did not. */
78
+ export interface PinsFile {
79
+ readonly text: string;
80
+ /** The uris written. */
81
+ readonly written: readonly string[];
82
+ /** The uris of plugins that took no whole pin: they have no line. */
83
+ readonly skipped: readonly string[];
84
+ /** The uris refused because a word of their line has whitespace in it, each with the reason: they have no line. */
85
+ readonly refused: readonly { readonly uri: string; readonly problem: string }[];
86
+ }
87
+
88
+ /**
89
+ * The file: a comment that says where it came from, then one line per plugin, sorted by uri. A uri given twice keeps
90
+ * the entry whose verdict is the worse one (`refused` over `isolated` over `in-process`, no word over all), so a
91
+ * plugin found twice is never pinned more kindly than its worst measurement.
92
+ */
93
+ export function pinsFile(entries: readonly PinEntry[]): PinsFile {
94
+ const byUri = new Map<string, string>();
95
+ const verdictOf = new Map<string, PinVerdict | undefined>();
96
+ const skipped: string[] = [];
97
+ const refused: { uri: string; problem: string }[] = [];
98
+ const rank = (v: PinVerdict | undefined): number => (v === undefined ? 4 : { "in-process": 1, isolated: 2, refused: 3 }[v]);
99
+ for (const entry of entries) {
100
+ let line: string | undefined;
101
+ try {
102
+ line = pinLine(entry);
103
+ } catch (e) {
104
+ refused.push({ uri: entry.uri, problem: (e as Error).message });
105
+ continue;
106
+ }
107
+ if (line === undefined) {
108
+ skipped.push(entry.uri);
109
+ continue;
110
+ }
111
+ if (byUri.has(entry.uri) && rank(verdictOf.get(entry.uri)) >= rank(entry.verdict)) continue;
112
+ byUri.set(entry.uri, line);
113
+ verdictOf.set(entry.uri, entry.verdict);
114
+ }
115
+ const uris = [...byUri.keys()].sort();
116
+ const text = [
117
+ "# written by plugin-qualify --pins; plugin-hostd reads it with pin_file",
118
+ ...uris.map((u) => byUri.get(u)!),
119
+ "",
120
+ ].join("\n");
121
+ return { text, written: uris, skipped: skipped.filter((u) => !byUri.has(u)), refused };
122
+ }
123
+
124
+ /**
125
+ * The entries of a CLAP pass for one host: each document of that host's profile with its pin and the verdict its
126
+ * rating says. The documents of other profiles are ignored; a pins file is for one host.
127
+ */
128
+ export function clapPinEntries(documents: readonly RtSafetyDocument[], host: string): PinEntry[] {
129
+ return documents
130
+ .filter((d) => d.console === host)
131
+ .map((d) => {
132
+ const verdict = pinVerdictOf(d.verdict.rating);
133
+ return {
134
+ uri: clapPinUri(d.plugin.binary.path, d.plugin.uri),
135
+ integrity: d.measured.integrity,
136
+ ...(verdict === undefined ? {} : { verdict }),
137
+ };
138
+ });
139
+ }
140
+
141
+ /**
142
+ * The entries of an LV2 run: each measured plugin with a pin and the verdict row of that host. A plugin with no verdict
143
+ * row for the host is pinned without a verdict word.
144
+ */
145
+ export function measuredPinEntries(
146
+ plugins: readonly MeasuredPlugin[],
147
+ verdicts: readonly QualifyVerdict[],
148
+ host: string,
149
+ ): PinEntry[] {
150
+ return plugins.flatMap((p) => {
151
+ if (p.integrity === undefined) return [];
152
+ const row = verdicts.find((v) => v.uri === p.uri && v.host === host);
153
+ const verdict = row === undefined || !row.measured ? undefined : pinVerdictOf(row.rating);
154
+ return [{ uri: p.uri, integrity: p.integrity, ...(verdict === undefined ? {} : { verdict }) }];
155
+ });
156
+ }
157
+
158
+ /** A line read back with hostd's rules. */
159
+ export interface ParsedPin {
160
+ readonly uri: string;
161
+ readonly files: readonly { readonly path: string; readonly sha256: string }[];
162
+ readonly layout: { readonly scheme: string; readonly sha256: string };
163
+ readonly verdict?: PinVerdict;
164
+ }
165
+
166
+ const HEX = /^[0-9a-f]{64}$/;
167
+
168
+ /**
169
+ * hostd's grammar for a pin line, the verb optional; `undefined` for a blank or comment line, and a thrown Error for a
170
+ * line the daemon would refuse (a path holding a separator, a path that climbs out of the bundle, a digest that is not
171
+ * 64 lowercase hex digits, a layout without a scheme, a verdict outside the vocabulary, a path named twice).
172
+ */
173
+ export function parsePinLine(line: string): ParsedPin | undefined {
174
+ const words = line.trim().split(/\s+/).filter((w) => w !== "");
175
+ if (words.length === 0 || words[0]!.startsWith("#")) return undefined;
176
+ if (words[0] === PIN_VERB) words.shift();
177
+ if (words.length !== 3 && words.length !== 4) throw new Error(`a pin line has 3 or 4 words, not ${words.length}`);
178
+ const [uri, list, layout, verdictWord] = words as [string, string, string, string | undefined];
179
+ const files = list.split(",").map((item) => {
180
+ const eq = item.lastIndexOf("=");
181
+ if (eq < 0) throw new Error(`${item}: no =`);
182
+ const path = item.slice(0, eq);
183
+ const sha256 = item.slice(eq + 1);
184
+ if (path === "" || path.startsWith("/") || path.split("/").some((part) => part === "" || part === "..")) {
185
+ throw new Error(`${path}: not a path inside the bundle`);
186
+ }
187
+ if (!HEX.test(sha256)) throw new Error(`${path}: not a sha256`);
188
+ return { path, sha256 };
189
+ });
190
+ if (new Set(files.map((f) => f.path)).size !== files.length) throw new Error("a path is named twice");
191
+ const colon = layout.lastIndexOf(":");
192
+ if (colon <= 0 || !HEX.test(layout.slice(colon + 1))) throw new Error(`${layout}: not a layout pin`);
193
+ let verdict: PinVerdict | undefined;
194
+ if (verdictWord !== undefined) {
195
+ const word = verdictWord.startsWith(PIN_VERDICT_KEY) ? verdictWord.slice(PIN_VERDICT_KEY.length) : "";
196
+ if (!(PIN_VERDICTS as readonly string[]).includes(word)) throw new Error(`${verdictWord}: not a verdict`);
197
+ verdict = word as PinVerdict;
198
+ }
199
+ return { uri, files, layout: { scheme: layout.slice(0, colon), sha256: layout.slice(colon + 1) }, ...(verdict === undefined ? {} : { verdict }) };
200
+ }
@@ -27,6 +27,8 @@ export interface QualifyArgs {
27
27
  readonly rates: readonly number[];
28
28
  readonly quanta: readonly number[];
29
29
  readonly out?: string;
30
+ /** `--pins`: where the pins file is written. */
31
+ readonly pins?: string;
30
32
  readonly format: OutputFormat;
31
33
  readonly isolate: boolean;
32
34
  readonly hostSweep: boolean;
@@ -83,6 +85,8 @@ export function parseQualifyArgs(argv: readonly string[]): ParseResult {
83
85
  return { ok: false, error: `--format must be one of ${OUTPUT_FORMATS.join(", ")}` };
84
86
  }
85
87
  const out = seen.get("--out") as string | undefined;
88
+ const pins = seen.get("--pins") as string | undefined;
89
+ if (pins !== undefined && pins.startsWith("--")) return { ok: false, error: "--pins needs a file name" };
86
90
  const hostProfiles = [...new Set(profileSpecs.length > 0 ? profileSpecs : [defaultOf("--host-profile")])];
87
91
  return {
88
92
  ok: true,
@@ -92,6 +96,7 @@ export function parseQualifyArgs(argv: readonly string[]): ParseResult {
92
96
  rates,
93
97
  quanta,
94
98
  ...(out === undefined ? {} : { out }),
99
+ ...(pins === undefined ? {} : { pins }),
95
100
  format: format as OutputFormat,
96
101
  isolate: seen.has("--isolate"),
97
102
  hostSweep: !seen.has("--no-host-sweep"),
@@ -111,7 +116,9 @@ export function helpText(): string {
111
116
  "plugin-qualify — qualify audio plugins for real-time hosting",
112
117
  "",
113
118
  " plugin-qualify <uri|bundle-path>... exactly the named plugins; a .lv2 bundle = every plugin in it",
114
- " plugin-qualify --all every plugin lilv finds (LV2_PATH honoured)",
119
+ " plugin-qualify --all every CLAP plugin on CLAP_PATH, then ~/.clap, then /usr/lib/clap;",
120
+ " refuses (exit 2) when LV2 bundles are on the LV2 path (LV2_PATH),",
121
+ " which this entry point cannot measure",
115
122
  "",
116
123
  ...rows,
117
124
  "",
@@ -69,13 +69,22 @@ export const SOAK_CALIBRATION_S = 30;
69
69
  export const CLAP_SCAN_TOOL_ENV = "OMX_CLAP_SCAN";
70
70
  export const CLAP_SCAN_TOOL_DEFAULT = "omx-clap-scan";
71
71
 
72
+ /**
73
+ * The standard CLAP search path `--all` walks, in this order: every directory `CLAP_PATH` names
74
+ * (colon-separated), then `~/.clap`, then `/usr/lib/clap`. The system directory is the same on
75
+ * every distribution and architecture; it is never the library directory (`/usr/lib64`).
76
+ */
77
+ export const CLAP_PATH_ENV = "CLAP_PATH";
78
+ export const CLAP_USER_DIR = ".clap";
79
+ export const CLAP_SYSTEM_DIR = "/usr/lib/clap";
80
+
72
81
  /** Seconds one run of the CLAP scanner may take over everything it was pointed at. */
73
82
  export const CLAP_SCAN_TIMEOUT_S = 300;
74
83
 
75
84
  /**
76
85
  * The CLAP prober (`native/clap-qualify.c`), the LV2 measurer (`native/lv2-measure.c`) and the
77
86
  * interposer both run under (`native/rt-interpose.c`): each located by one environment variable, else
78
- * the first of `NATIVE_TOOL_DIRS` that holds it, else the package's own build in `native/build/`.
87
+ * the package's own build in `native/build/`, else the first of `NATIVE_TOOL_DIRS` that holds it.
79
88
  */
80
89
  export const CLAP_QUALIFY_TOOL_ENV = "OMX_CLAP_QUALIFY";
81
90
  export const LV2_MEASURE_TOOL_ENV = "OMX_LV2_MEASURE";
@@ -32,7 +32,7 @@ export interface QualifyOption {
32
32
  }
33
33
 
34
34
  export const QUALIFY_OPTIONS: readonly QualifyOption[] = [
35
- { flag: "--all", describe: "every plugin lilv finds (LV2_PATH honoured)" },
35
+ { flag: "--all", describe: "every CLAP plugin on the CLAP search path; refuses when LV2 bundles are on the LV2 path" },
36
36
  { flag: "--rates", value: "list", describe: "sample rates to measure at, comma-separated", default: QUALIFY_RATES.join(",") },
37
37
  { flag: "--quanta", value: "list", describe: "quanta (frames) to rate cost at, comma-separated", default: QUALIFY_QUANTA.join(",") },
38
38
  { flag: "--out", value: "dir", describe: "write one interchange JSON document per run into this directory" },
@@ -45,5 +45,11 @@ export const QUALIFY_OPTIONS: readonly QualifyOption[] = [
45
45
  describe: `judge against this host (${HOST_PROFILES.map((p) => p.id).join(", ")}, or a profile JSON file); repeat or comma-separate for one verdict per profile`,
46
46
  default: OPENMIXER_CONSOLE_PROFILE.id,
47
47
  },
48
+ {
49
+ flag: "--pins",
50
+ value: "file",
51
+ describe:
52
+ "write one pin per qualified plugin to this file, with its verdict, for plugin-hostd's pin_file (the verdict is the first host profile's)",
53
+ },
48
54
  { flag: "--help", describe: "this text" },
49
55
  ];