@openmixer/plugin-qualify 0.0.0-stage → 0.4.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 (114) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +203 -2
  3. package/bin/plugin-qualify.mjs +77 -0
  4. package/dist/clap-loader.d.ts +147 -0
  5. package/dist/clap-loader.js +252 -0
  6. package/dist/clap-loader.js.map +1 -0
  7. package/dist/clap-qualify.d.ts +126 -0
  8. package/dist/clap-qualify.js +272 -0
  9. package/dist/clap-qualify.js.map +1 -0
  10. package/dist/host-profiles.d.ts +42 -0
  11. package/dist/host-profiles.js +106 -0
  12. package/dist/host-profiles.js.map +1 -0
  13. package/dist/hosting-codes.d.ts +62 -0
  14. package/dist/hosting-codes.js +72 -0
  15. package/dist/hosting-codes.js.map +1 -0
  16. package/dist/hosting-suitability.d.ts +362 -0
  17. package/dist/hosting-suitability.js +669 -0
  18. package/dist/hosting-suitability.js.map +1 -0
  19. package/dist/index.d.ts +17 -0
  20. package/dist/index.js +20 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/loader.d.ts +26 -0
  23. package/dist/loader.js +9 -0
  24. package/dist/loader.js.map +1 -0
  25. package/dist/lv2-loader.d.ts +7 -0
  26. package/dist/lv2-loader.js +50 -0
  27. package/dist/lv2-loader.js.map +1 -0
  28. package/dist/measurement/cli.d.ts +168 -0
  29. package/dist/measurement/cli.js +353 -0
  30. package/dist/measurement/cli.js.map +1 -0
  31. package/dist/measurement/fixtures.d.ts +23 -0
  32. package/dist/measurement/fixtures.js +129 -0
  33. package/dist/measurement/fixtures.js.map +1 -0
  34. package/dist/measurement/format.d.ts +483 -0
  35. package/dist/measurement/format.js +627 -0
  36. package/dist/measurement/format.js.map +1 -0
  37. package/dist/measurement/guards.d.ts +33 -0
  38. package/dist/measurement/guards.js +61 -0
  39. package/dist/measurement/guards.js.map +1 -0
  40. package/dist/measurement/index.d.ts +36 -0
  41. package/dist/measurement/index.js +39 -0
  42. package/dist/measurement/index.js.map +1 -0
  43. package/dist/measurement/merge.d.ts +143 -0
  44. package/dist/measurement/merge.js +315 -0
  45. package/dist/measurement/merge.js.map +1 -0
  46. package/dist/measurement/plan.d.ts +127 -0
  47. package/dist/measurement/plan.js +141 -0
  48. package/dist/measurement/plan.js.map +1 -0
  49. package/dist/measurement/probe-json.d.ts +61 -0
  50. package/dist/measurement/probe-json.js +253 -0
  51. package/dist/measurement/probe-json.js.map +1 -0
  52. package/dist/measurement/provenance.d.ts +91 -0
  53. package/dist/measurement/provenance.js +173 -0
  54. package/dist/measurement/provenance.js.map +1 -0
  55. package/dist/measurement/sweep-jsonl.d.ts +40 -0
  56. package/dist/measurement/sweep-jsonl.js +154 -0
  57. package/dist/measurement/sweep-jsonl.js.map +1 -0
  58. package/dist/measurement/vocabulary.d.ts +125 -0
  59. package/dist/measurement/vocabulary.js +217 -0
  60. package/dist/measurement/vocabulary.js.map +1 -0
  61. package/dist/qualify-cli.d.ts +83 -0
  62. package/dist/qualify-cli.js +224 -0
  63. package/dist/qualify-cli.js.map +1 -0
  64. package/dist/qualify-declarations.d.ts +98 -0
  65. package/dist/qualify-declarations.js +76 -0
  66. package/dist/qualify-declarations.js.map +1 -0
  67. package/dist/qualify-options.d.ts +22 -0
  68. package/dist/qualify-options.js +36 -0
  69. package/dist/qualify-options.js.map +1 -0
  70. package/dist/rating.d.ts +32 -0
  71. package/dist/rating.js +30 -0
  72. package/dist/rating.js.map +1 -0
  73. package/dist/tool-paths.d.ts +12 -0
  74. package/dist/tool-paths.js +29 -0
  75. package/dist/tool-paths.js.map +1 -0
  76. package/fixtures/clap-scan/controls.json +8 -0
  77. package/fixtures/clap-scan/fake.json +16 -0
  78. package/fixtures/clap-scan/mixed.json +18 -0
  79. package/fixtures/zero-path.lv2/manifest.ttl +3 -0
  80. package/fixtures/zero-path.lv2/zero-path.c +85 -0
  81. package/fixtures/zero-path.lv2/zero-path.ttl +21 -0
  82. package/package.json +94 -4
  83. package/src/clap-loader.ts +393 -0
  84. package/src/clap-qualify.ts +387 -0
  85. package/src/host-profiles.ts +122 -0
  86. package/src/hosting-codes.ts +71 -0
  87. package/src/hosting-suitability.ts +998 -0
  88. package/src/index.ts +19 -0
  89. package/src/loader.ts +30 -0
  90. package/src/lv2-loader.ts +50 -0
  91. package/src/measurement/cli.ts +458 -0
  92. package/src/measurement/fixtures.ts +141 -0
  93. package/src/measurement/format.ts +1064 -0
  94. package/src/measurement/guards.ts +64 -0
  95. package/src/measurement/index.ts +39 -0
  96. package/src/measurement/merge.ts +434 -0
  97. package/src/measurement/plan.ts +219 -0
  98. package/src/measurement/probe-json.ts +271 -0
  99. package/src/measurement/provenance.ts +220 -0
  100. package/src/measurement/sweep-jsonl.ts +173 -0
  101. package/src/measurement/vocabulary.ts +298 -0
  102. package/src/qualify-cli.ts +277 -0
  103. package/src/qualify-declarations.ts +117 -0
  104. package/src/qualify-options.ts +49 -0
  105. package/src/rating.ts +53 -0
  106. package/src/tool-paths.ts +32 -0
  107. package/tools/_declarations.py +21 -0
  108. package/tools/benchmark.py +2462 -0
  109. package/tools/gen-python-declarations.d.mts +4 -0
  110. package/tools/gen-python-declarations.mjs +92 -0
  111. package/tools/hosting-sweep.mjs +51 -0
  112. package/tools/lv2-measure.mjs +204 -0
  113. package/tools/lv2_presets.py +132 -0
  114. package/tools/scan.py +432 -0
@@ -0,0 +1,393 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The CLAP implementation of the one discovery seam (`loader.ts`). A target that is a `.clap` file, or a
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
7
+ * scanner's JSON is the input: {@link clapScanDescriptors} normalises it into the interchange
8
+ * rows. A scan that cannot be read is a {@link ClapScanError}, never an empty result.
9
+ *
10
+ * plugin-qualify's own prober writes the same document (`native/clap-qualify --json`, set as the
11
+ * scanner by the same setting), and with it an enum parameter's labels and the plugin's
12
+ * remote-control pages; a row keeps them, and each parameter's module, wherever the scan read them.
13
+ */
14
+ import { spawnSync } from "node:child_process";
15
+ import { existsSync, readdirSync, statSync } from "node:fs";
16
+ import { basename, dirname, join, resolve } from "node:path";
17
+
18
+ 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";
20
+
21
+ /** The scanner's own name in its document — anything else is not its output. */
22
+ const SCANNER_NAME = "omx-clap-scan";
23
+ /** The same document from plugin-qualify's own prober (`native/clap-qualify --json`). */
24
+ const SCANNER_NAMES: readonly string[] = [SCANNER_NAME, "clap-qualify"];
25
+ const CLAP_URI = /^urn:clap:(\S+)$/;
26
+ const CLAP_SUFFIX = ".clap";
27
+ /** A `.clap` directory listing can be tens of megabytes of JSON. */
28
+ const MAX_SCAN_BYTES = 1 << 30;
29
+
30
+ export class ClapScanError extends Error {
31
+ constructor(message: string) {
32
+ super(message);
33
+ this.name = "ClapScanError";
34
+ }
35
+ }
36
+
37
+ // --- the scanner's document ----------------------------------------------------------------------
38
+
39
+ export interface ClapScanParam {
40
+ readonly id: number;
41
+ readonly name: string;
42
+ readonly module: string;
43
+ /** null where the plugin leaves the bound open (JSON has no infinity). */
44
+ readonly min: number | null;
45
+ readonly max: number | null;
46
+ readonly default: number | null;
47
+ readonly flags: readonly string[];
48
+ /** An enum parameter's labels, as `value_to_text` renders each whole value of its range. */
49
+ readonly labels?: readonly ClapScanLabel[];
50
+ }
51
+
52
+ export interface ClapScanLabel {
53
+ readonly value: number;
54
+ readonly label: string;
55
+ }
56
+
57
+ /** A remote-control page: eight slots, each a parameter id or null when empty. */
58
+ export interface ClapScanRemotePage {
59
+ readonly id: number;
60
+ readonly section: string;
61
+ readonly name: string;
62
+ readonly params: readonly (number | null)[];
63
+ readonly forPreset: boolean;
64
+ }
65
+
66
+ export interface ClapScanPort {
67
+ readonly id: number;
68
+ readonly name: string;
69
+ readonly role: "main" | "aux";
70
+ readonly channels: number;
71
+ }
72
+
73
+ /** A plugin the scanner created and read. */
74
+ export interface ClapScanPlugin {
75
+ readonly id: string;
76
+ readonly name: string;
77
+ readonly vendor: string;
78
+ readonly version: string;
79
+ readonly description: string;
80
+ readonly url: string;
81
+ readonly features: readonly string[];
82
+ readonly params: readonly ClapScanParam[];
83
+ /** Absent where the scanner does not read the pages. */
84
+ readonly remote_controls?: readonly ClapScanRemotePage[];
85
+ readonly audio_ports: { readonly inputs: readonly ClapScanPort[]; readonly outputs: readonly ClapScanPort[] };
86
+ readonly note_ports: { readonly inputs: number; readonly outputs: number };
87
+ /** Frames, present only when the plugin reported more than 0 before it was activated. */
88
+ readonly latency?: number;
89
+ }
90
+
91
+ /** A plugin whose descriptor was read but which could not be created or initialised. */
92
+ export interface ClapScanPluginError {
93
+ readonly id: string;
94
+ readonly name: string;
95
+ readonly error: string;
96
+ }
97
+
98
+ export type ClapScanFile =
99
+ | { readonly path: string; readonly plugins: readonly (ClapScanPlugin | ClapScanPluginError)[] }
100
+ | { readonly path: string; readonly error: string };
101
+
102
+ export interface ClapScanDocument {
103
+ readonly scanner: string;
104
+ readonly version: string;
105
+ readonly files: readonly ClapScanFile[];
106
+ }
107
+
108
+ function isRecord(v: unknown): v is Record<string, unknown> {
109
+ return Object.prototype.toString.call(v) === "[object Object]";
110
+ }
111
+
112
+ function bad(what: string): never {
113
+ throw new ClapScanError(`${SCANNER_NAME} output is not its document: ${what}`);
114
+ }
115
+
116
+ function str(o: Record<string, unknown>, key: string, where: string): string {
117
+ const v = o[key];
118
+ return typeof v === "string" ? v : bad(`${where} has no string ${key}`);
119
+ }
120
+
121
+ function num(o: Record<string, unknown>, key: string, where: string): number {
122
+ const v = o[key];
123
+ return typeof v === "number" ? v : bad(`${where} has no number ${key}`);
124
+ }
125
+
126
+ function list(o: Record<string, unknown>, key: string, where: string): unknown[] {
127
+ const v = o[key];
128
+ return Array.isArray(v) ? v : bad(`${where} has no array ${key}`);
129
+ }
130
+
131
+ function bound(o: Record<string, unknown>, key: string, where: string): number | null {
132
+ const v = o[key];
133
+ return v === null ? null : typeof v === "number" ? v : bad(`${where} has no number or null ${key}`);
134
+ }
135
+
136
+ function parsePort(v: unknown, where: string): ClapScanPort {
137
+ if (!isRecord(v)) return bad(`${where} is not an object`);
138
+ const role = str(v, "role", where);
139
+ if (role !== "main" && role !== "aux") return bad(`${where} has role ${role}`);
140
+ return { id: num(v, "id", where), name: str(v, "name", where), role, channels: num(v, "channels", where) };
141
+ }
142
+
143
+ function parseLabel(v: unknown, where: string): ClapScanLabel {
144
+ if (!isRecord(v)) return bad(`${where} is not an object`);
145
+ return { value: num(v, "value", where), label: str(v, "label", where) };
146
+ }
147
+
148
+ function parsePage(v: unknown, where: string): ClapScanRemotePage {
149
+ if (!isRecord(v)) return bad(`${where} is not an object`);
150
+ const slots = list(v, "params", where).map((p) => (p === null || typeof p === "number" ? p : bad(`${where} has a slot that is not an id or null`)));
151
+ const forPreset = v["forPreset"];
152
+ if (typeof forPreset !== "boolean") return bad(`${where} has no boolean forPreset`);
153
+ return { id: num(v, "id", where), section: str(v, "section", where), name: str(v, "name", where), params: slots, forPreset };
154
+ }
155
+
156
+ function parsePlugin(v: unknown, where: string): ClapScanPlugin | ClapScanPluginError {
157
+ if (!isRecord(v)) return bad(`${where} is not an object`);
158
+ const id = str(v, "id", where);
159
+ const name = str(v, "name", where);
160
+ if (typeof v["error"] === "string") return { id, name, error: v["error"] };
161
+ const ports = v["audio_ports"];
162
+ const notes = v["note_ports"];
163
+ if (!isRecord(ports)) return bad(`${where} has no audio_ports`);
164
+ if (!isRecord(notes)) return bad(`${where} has no note_ports`);
165
+ const params = list(v, "params", where).map((p, i): ClapScanParam => {
166
+ const w = `${where} param ${i}`;
167
+ if (!isRecord(p)) return bad(`${w} is not an object`);
168
+ return {
169
+ id: num(p, "id", w),
170
+ name: str(p, "name", w),
171
+ module: str(p, "module", w),
172
+ min: bound(p, "min", w),
173
+ max: bound(p, "max", w),
174
+ default: bound(p, "default", w),
175
+ flags: list(p, "flags", w).map((f) => (typeof f === "string" ? f : bad(`${w} has a flag that is not a string`))),
176
+ ...(p["labels"] === undefined ? {} : { labels: list(p, "labels", w).map((l, k) => parseLabel(l, `${w} label ${k}`)) }),
177
+ };
178
+ });
179
+ const pages = v["remote_controls"] === undefined ? undefined : list(v, "remote_controls", where).map((r, i) => parsePage(r, `${where} page ${i}`));
180
+ return {
181
+ id,
182
+ name,
183
+ vendor: str(v, "vendor", where),
184
+ version: str(v, "version", where),
185
+ description: str(v, "description", where),
186
+ url: str(v, "url", where),
187
+ features: list(v, "features", where).map((f) => (typeof f === "string" ? f : bad(`${where} has a feature that is not a string`))),
188
+ params,
189
+ audio_ports: {
190
+ inputs: list(ports, "inputs", `${where} audio_ports`).map((p, i) => parsePort(p, `${where} input ${i}`)),
191
+ outputs: list(ports, "outputs", `${where} audio_ports`).map((p, i) => parsePort(p, `${where} output ${i}`)),
192
+ },
193
+ note_ports: { inputs: num(notes, "inputs", `${where} note_ports`), outputs: num(notes, "outputs", `${where} note_ports`) },
194
+ ...(pages === undefined ? {} : { remote_controls: pages }),
195
+ ...(v["latency"] === undefined ? {} : { latency: num(v, "latency", where) }),
196
+ };
197
+ }
198
+
199
+ /** The scanner's stdout as its document, or a {@link ClapScanError} saying how it differs. */
200
+ export function parseClapScan(text: string): ClapScanDocument {
201
+ let doc: unknown;
202
+ try {
203
+ doc = JSON.parse(text);
204
+ } catch {
205
+ throw new ClapScanError(`${SCANNER_NAME} printed something that is not JSON`);
206
+ }
207
+ if (!isRecord(doc)) return bad("not an object");
208
+ const scanner = doc["scanner"];
209
+ if (typeof scanner !== "string" || !SCANNER_NAMES.includes(scanner)) return bad(`scanner is ${JSON.stringify(scanner)}`);
210
+ const files = list(doc, "files", "the document").map((f, i): ClapScanFile => {
211
+ const where = `file ${i}`;
212
+ if (!isRecord(f)) return bad(`${where} is not an object`);
213
+ const path = str(f, "path", where);
214
+ if (typeof f["error"] === "string") return { path, error: f["error"] };
215
+ return { path, plugins: list(f, "plugins", `file ${path}`).map((p, j) => parsePlugin(p, `plugin ${j} of ${path}`)) };
216
+ });
217
+ return { scanner, version: str(doc, "version", "the document"), files };
218
+ }
219
+
220
+ // --- the interchange row -------------------------------------------------------------------------
221
+
222
+ /** One control, shaped as the catalog's `PluginParam` (structurally; this package imports no other). */
223
+ export interface ClapDescriptorParam extends ClapControlBounds {
224
+ readonly isInteger: boolean;
225
+ readonly isToggle: boolean;
226
+ readonly isEnumeration: boolean;
227
+ readonly kind: "control";
228
+ /** The CLAP parameter id in decimal — the key `param_set` takes. */
229
+ readonly symbol: string;
230
+ readonly name: string;
231
+ /** The parameter's module path (`Filter/Envelope`), where the plugin names one. */
232
+ readonly module?: string;
233
+ /** An enum parameter's labels, where the scan read them. */
234
+ readonly scalePoints?: readonly { readonly label: string; readonly value: number }[];
235
+ }
236
+
237
+ /** A remote-control page, its slots as parameter symbols (null where empty). */
238
+ export interface ClapDescriptorRemotePage {
239
+ readonly id: number;
240
+ readonly section: string;
241
+ readonly name: string;
242
+ readonly params: readonly (string | null)[];
243
+ }
244
+
245
+ /** Each bound is absent where the plugin leaves it open. */
246
+ interface ClapControlBounds {
247
+ readonly min?: number;
248
+ readonly max?: number;
249
+ readonly default?: number;
250
+ }
251
+
252
+ /** A scanned CLAP plugin as the catalog's `PluginDescriptor` reads it — a declared fact of the scan. */
253
+ export interface ClapDescriptorRow {
254
+ readonly uri: string;
255
+ readonly name: string;
256
+ readonly lv2Class: null;
257
+ readonly audioInputs: number;
258
+ readonly audioOutputs: number;
259
+ readonly hasMidiIn: boolean;
260
+ readonly params: readonly ClapDescriptorParam[];
261
+ readonly format: "clap";
262
+ readonly binaryPath: string;
263
+ readonly latency?: { readonly reportedFrames: number };
264
+ /** The plugin's remote-control pages (presets' own pages excluded), where the scan read them. */
265
+ readonly remoteControls?: readonly ClapDescriptorRemotePage[];
266
+ }
267
+
268
+ function isPlugin(p: ClapScanPlugin | ClapScanPluginError): p is ClapScanPlugin {
269
+ return !("error" in p);
270
+ }
271
+
272
+ function controlOf(p: ClapScanParam): ClapDescriptorParam {
273
+ const stepped = p.flags.includes("stepped");
274
+ return {
275
+ kind: "control",
276
+ symbol: String(p.id),
277
+ name: p.name,
278
+ ...(p.min === null ? {} : { min: p.min }),
279
+ ...(p.max === null ? {} : { max: p.max }),
280
+ ...(p.default === null ? {} : { default: p.default }),
281
+ isInteger: stepped,
282
+ isToggle: stepped && p.min === 0 && p.max === 1,
283
+ isEnumeration: p.flags.includes("enum"),
284
+ ...(p.module === "" ? {} : { module: p.module }),
285
+ ...(p.labels === undefined || p.labels.length === 0 ? {} : { scalePoints: p.labels.map(({ label, value }) => ({ label, value })) }),
286
+ };
287
+ }
288
+
289
+ function pageOf(page: ClapScanRemotePage): ClapDescriptorRemotePage {
290
+ return { id: page.id, section: page.section, name: page.name, params: page.params.map((id) => (id === null ? null : String(id))) };
291
+ }
292
+
293
+ const channels = (ports: readonly ClapScanPort[]): number => ports.reduce((n, p) => n + p.channels, 0);
294
+
295
+ /**
296
+ * The rows for every plugin the scanner created. A hidden or read-only parameter is not a
297
+ * control anyone may set, so it is not offered as one. Nothing here claims a qualification:
298
+ * the `clap` measurement stays absent, which the verdict reads as `unknown`.
299
+ */
300
+ export function clapScanDescriptors(doc: ClapScanDocument): ClapDescriptorRow[] {
301
+ const rows: ClapDescriptorRow[] = [];
302
+ for (const file of doc.files) {
303
+ if (!("plugins" in file)) continue;
304
+ const binaryPath = resolve(file.path);
305
+ for (const p of file.plugins) {
306
+ if (!isPlugin(p)) continue;
307
+ rows.push({
308
+ uri: `urn:clap:${p.id}`,
309
+ name: p.name,
310
+ lv2Class: null,
311
+ audioInputs: channels(p.audio_ports.inputs),
312
+ audioOutputs: channels(p.audio_ports.outputs),
313
+ hasMidiIn: p.note_ports.inputs > 0,
314
+ params: p.params.filter((q) => !q.flags.includes("hidden") && !q.flags.includes("readonly")).map(controlOf),
315
+ format: "clap",
316
+ binaryPath,
317
+ ...(p.latency === undefined ? {} : { latency: { reportedFrames: p.latency } }),
318
+ ...(p.remote_controls === undefined ? {} : { remoteControls: p.remote_controls.filter((r) => !r.forPreset).map(pageOf) }),
319
+ });
320
+ }
321
+ }
322
+ return rows;
323
+ }
324
+
325
+ // --- the loader ----------------------------------------------------------------------------------
326
+
327
+ /** What a run of the scanner gave back; `error` is a spawn failure (absent binary, timeout). */
328
+ export interface ClapScanRunResult {
329
+ readonly status: number | null;
330
+ readonly stdout: string;
331
+ readonly error?: Error;
332
+ }
333
+
334
+ export type ClapScanRun = (tool: string, args: readonly string[], timeoutMs: number) => ClapScanRunResult;
335
+
336
+ const spawnScan: ClapScanRun = (tool, args, timeoutMs) => {
337
+ const r = spawnSync(tool, [...args], { encoding: "utf8", timeout: timeoutMs, maxBuffer: MAX_SCAN_BYTES });
338
+ return { status: r.status, stdout: r.stdout ?? "", ...(r.error === undefined ? {} : { error: r.error }) };
339
+ };
340
+
341
+ /** The scanner binary: the declared setting when set, else the declared default (on `PATH`). */
342
+ export function clapScanTool(env: Readonly<Record<string, string | undefined>>): string {
343
+ const named = env[CLAP_SCAN_TOOL_ENV];
344
+ return named === undefined || named === "" ? CLAP_SCAN_TOOL_DEFAULT : named;
345
+ }
346
+
347
+ 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;
352
+ }
353
+ return false;
354
+ }
355
+
356
+ export interface ClapLoaderOptions {
357
+ /** Runs the scanner; the default spawns it. Injectable so the seam is testable without the binary. */
358
+ readonly run?: ClapScanRun;
359
+ /** Where the one setting is read from; the default is `process.env`. */
360
+ readonly env?: Readonly<Record<string, string | undefined>>;
361
+ }
362
+
363
+ function scan(target: string, options: ClapLoaderOptions): ClapScanDocument {
364
+ const tool = clapScanTool(options.env ?? process.env);
365
+ const r = (options.run ?? spawnScan)(tool, ["--json", target], CLAP_SCAN_TIMEOUT_S * 1000);
366
+ if (r.error !== undefined) {
367
+ throw new ClapScanError(
368
+ `cannot run the CLAP scanner "${tool}" (${r.error.message}): set ${CLAP_SCAN_TOOL_ENV} to the omx-clap-scan binary of omx-clap-host`,
369
+ );
370
+ }
371
+ if (r.status !== 0) throw new ClapScanError(`${tool} exited ${String(r.status)} scanning ${target}`);
372
+ return parseClapScan(r.stdout);
373
+ }
374
+
375
+ export function clapLoader(options: ClapLoaderOptions = {}): PluginLoader {
376
+ return {
377
+ format: "clap",
378
+ resolve(target: string): readonly PluginTarget[] | undefined {
379
+ const named = CLAP_URI.exec(target);
380
+ if (named !== null) return [{ format: "clap", uri: target }];
381
+ if (!existsSync(target)) return undefined;
382
+ const st = statSync(target);
383
+ if (st.isFile() ? !target.endsWith(CLAP_SUFFIX) : !st.isDirectory() || !holdsClap(target)) return undefined;
384
+ return clapScanDescriptors(scan(target, options)).map((row) => ({
385
+ format: "clap",
386
+ uri: row.uri,
387
+ bundle: dirname(row.binaryPath),
388
+ binary: basename(row.binaryPath),
389
+ binaryPath: row.binaryPath,
390
+ }));
391
+ },
392
+ };
393
+ }