@openmixer/plugin-qualify 0.0.0-stage → 0.3.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 (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 +354 -0
  17. package/dist/hosting-suitability.js +654 -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 +91 -3
  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 +982 -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
package/tools/scan.py ADDED
@@ -0,0 +1,432 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+ # Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
4
+ """
5
+ Introspect installed LV2 plugins with lilv (the same library Zynthian uses) and
6
+ emit a JSON catalog of plugin descriptors for openmixer.
7
+
8
+ Each descriptor carries everything the graphical configurator needs to
9
+ auto-generate the right control per parameter: type, range, default, integer/
10
+ toggle/enumeration/logarithmic/trigger flags, enumeration scale points, the unit
11
+ symbol (LV2 units extension), and audio in/out port counts. Both kinds of
12
+ parameter are captured: classic LV2 **control ports** (set via mod-host
13
+ `param_set <id> <symbol> <value>`) and LV2 **Patch properties** (`lv2:Parameter`
14
+ flagged `patch:writable`, set via `patch_set <id> <uri> <value>`).
15
+
16
+ Usage:
17
+ scan.py # scan all installed plugins -> stdout JSON
18
+ scan.py --out catalog.json # write to a file
19
+ scan.py URI [URI ...] # scan only these URIs (faster, for tests)
20
+ scan.py --annotate catalog.json # refresh only the DECLARED fields, in place
21
+ """
22
+ import argparse
23
+ import json
24
+ import subprocess
25
+ import sys
26
+
27
+ from lv2_presets import read_presets
28
+
29
+ LV2 = "http://lv2plug.in/ns/lv2core#"
30
+ MINIMUM = LV2 + "minimum"
31
+ MAXIMUM = LV2 + "maximum"
32
+ DEFAULT = LV2 + "default"
33
+ PP = "http://lv2plug.in/ns/ext/port-props#"
34
+ UNITS = "http://lv2plug.in/ns/extensions/units#"
35
+ PATCH = "http://lv2plug.in/ns/ext/patch#"
36
+ ATOM = "http://lv2plug.in/ns/ext/atom#"
37
+ RDFS = "http://www.w3.org/2000/01/rdf-schema#"
38
+ MIDI = "http://lv2plug.in/ns/ext/midi#"
39
+
40
+ # An output control port designated lv2:latency reports the plugin's own latency
41
+ # (in frames) — the truth a host delay-compensates by, and what openmixer reads to
42
+ # annotate the catalog. Captured here as a symbol so the benchmark + the engine can
43
+ # read the live value. `lv2:reportsLatency` is the (legacy) port property form.
44
+ LATENCY = LV2 + "latency"
45
+ REPORTS_LATENCY = LV2 + "reportsLatency"
46
+
47
+ # A plugin's own version. Recorded because a measured latency without it is a trap:
48
+ # the next release moves the FFT window and a committed figure becomes wrong without
49
+ # becoming absent. Absent here means the plugin declares no version — NOT "0.0",
50
+ # which is a real LV2 answer meaning "unstable/development".
51
+ MINOR_VERSION = LV2 + "minorVersion"
52
+ MICRO_VERSION = LV2 + "microVersion"
53
+
54
+ # Common LV2 unit URIs -> display symbol. Plugins may also carry an explicit
55
+ # units:symbol (handled below); this is the fallback for the standard units.
56
+ KNOWN_UNITS = {
57
+ UNITS + "db": "dB", UNITS + "hz": "Hz", UNITS + "khz": "kHz",
58
+ UNITS + "s": "s", UNITS + "ms": "ms", UNITS + "pc": "%",
59
+ UNITS + "semitone12TET": "semi", UNITS + "cent": "ct",
60
+ UNITS + "degree": "deg", UNITS + "coef": "x", UNITS + "oct": "oct",
61
+ UNITS + "bpm": "BPM", UNITS + "midiNote": "note", UNITS + "frame": "fr",
62
+ }
63
+
64
+
65
+ def fnum(node):
66
+ """float(node) tolerant of None and integer/string-typed nodes.
67
+
68
+ lilv's Node.__float__ raises unless the node is a float literal, but LV2
69
+ min/max/default are often integer literals — so fall back to parsing the
70
+ string form, and return None for anything non-numeric.
71
+ """
72
+ if node is None:
73
+ return None
74
+ try:
75
+ return float(node)
76
+ except (ValueError, TypeError):
77
+ try:
78
+ return float(str(node))
79
+ except (ValueError, TypeError):
80
+ return None
81
+
82
+
83
+ def first(nodes):
84
+ """First element of a lilv Nodes collection, or None."""
85
+ if nodes is None:
86
+ return None
87
+ for n in nodes:
88
+ return n
89
+ return None
90
+
91
+
92
+ def bundle_path(plugin):
93
+ """Filesystem path of a lilv plugin's bundle (the `.lv2` dir), or None.
94
+
95
+ lilv returns a `file://` URI for the bundle; strip the scheme so `rpm -qf` can own it.
96
+ """
97
+ try:
98
+ uri = plugin.get_bundle_uri()
99
+ except Exception: # noqa: BLE001 - a plugin with no resolvable bundle just has no owner
100
+ return None
101
+ if uri is None:
102
+ return None
103
+ p = str(uri)
104
+ if p.startswith("file://"):
105
+ p = p[len("file://"):]
106
+ return p.rstrip("/") or None
107
+
108
+
109
+ def owning_rpm(path, run=subprocess.run):
110
+ """The RPM package name owning `path`, or None (unowned / rpm unavailable / error).
111
+
112
+ `run` is injectable (defaults to subprocess.run) so the mapping unit-tests without rpm.
113
+ Every plugin on the rig is expected to map to exactly one RPM (no hand-installed plugins);
114
+ a None here is surfaced by the assembler (Task 5) as an unpackaged plugin, never silently
115
+ dropped.
116
+ """
117
+ if not path:
118
+ return None
119
+ try:
120
+ r = run(["rpm", "-qf", "--queryformat", "%{NAME}", path],
121
+ capture_output=True, text=True, check=False, timeout=15)
122
+ except Exception: # noqa: BLE001 - rpm not installed / timeout: no owner, not a scan failure
123
+ return None
124
+ if r.returncode != 0:
125
+ return None
126
+ name = (r.stdout or "").strip()
127
+ return name or None
128
+
129
+
130
+ class Scanner:
131
+ def __init__(self):
132
+ # lilv is imported here, not at module load: the owner mapping and the pure helpers
133
+ # (test_scan.py) run on a host without the bindings, the way benchmark.py's worker does.
134
+ import lilv
135
+
136
+ self.w = lilv.World()
137
+ self.w.load_all()
138
+ self.u = {k: self.w.new_uri(k) for k in (
139
+ LV2 + "AudioPort", LV2 + "ControlPort", LV2 + "CVPort",
140
+ LV2 + "InputPort", LV2 + "OutputPort", ATOM + "AtomPort",
141
+ LV2 + "integer", LV2 + "toggled", LV2 + "enumeration",
142
+ PP + "logarithmic", PP + "trigger", PP + "notOnGUI",
143
+ UNITS + "unit", UNITS + "symbol", PATCH + "writable",
144
+ RDFS + "label", RDFS + "range", MIDI + "MidiEvent",
145
+ LV2 + "designation", LATENCY, REPORTS_LATENCY,
146
+ MINIMUM, MAXIMUM, DEFAULT,
147
+ )}
148
+
149
+ def is_a(self, port, uri):
150
+ return port.is_a(self.u[uri])
151
+
152
+ def has(self, port, uri):
153
+ return bool(port.has_property(self.u[uri]))
154
+
155
+ def unit_symbol(self, port):
156
+ un = first(port.get_value(self.u[UNITS + "unit"]))
157
+ if un is None:
158
+ return None
159
+ uri = str(un)
160
+ if uri in KNOWN_UNITS:
161
+ return KNOWN_UNITS[uri]
162
+ sym = first(self.w.find_nodes(un, self.u[UNITS + "symbol"], None))
163
+ return str(sym) if sym is not None else None
164
+
165
+ def scale_points(self, port):
166
+ out = []
167
+ sps = port.get_scale_points()
168
+ if sps:
169
+ for sp in sps:
170
+ v = fnum(sp.get_value())
171
+ if v is not None:
172
+ out.append({"label": str(sp.get_label()), "value": v})
173
+ out.sort(key=lambda s: s["value"])
174
+ return out
175
+
176
+ def control_param(self, port):
177
+ d, mn, mx = port.get_range()
178
+ nm = port.get_name()
179
+ sps = self.scale_points(port)
180
+ mnf, mxf, df = fnum(mn), fnum(mx), fnum(d)
181
+ return {
182
+ "kind": "control",
183
+ "symbol": str(port.get_symbol()),
184
+ "name": str(nm) if nm is not None else str(port.get_symbol()),
185
+ "min": mnf if mnf is not None else 0.0,
186
+ "max": mxf if mxf is not None else 1.0,
187
+ "default": df if df is not None else (mnf if mnf is not None else 0.0),
188
+ "isInteger": self.has(port, LV2 + "integer"),
189
+ "isToggle": self.has(port, LV2 + "toggled"),
190
+ # Enumeration is ONLY the explicit lv2:enumeration designation. Scale
191
+ # points are labels/snap-hints layered on top (carried below) — a
192
+ # continuous port that happens to label one value (e.g. darc Ratio's
193
+ # lone "Lim" mark) is NOT a dropdown.
194
+ "isEnumeration": self.has(port, LV2 + "enumeration"),
195
+ "isLogarithmic": self.has(port, PP + "logarithmic"),
196
+ "isTrigger": self.has(port, PP + "trigger"),
197
+ "unit": self.unit_symbol(port),
198
+ **({"scalePoints": sps} if sps else {}),
199
+ }
200
+
201
+ def is_latency_port(self, port):
202
+ """True for an output control port that reports the plugin's latency.
203
+
204
+ Two LV2 spellings: `lv2:designation lv2:latency` (current) and the legacy
205
+ `lv2:reportsLatency` port property. Either marks the port whose value is the
206
+ plugin's latency in frames.
207
+ """
208
+ try:
209
+ for d in (port.get_value(self.u[LV2 + "designation"]) or []):
210
+ if str(d) == LATENCY:
211
+ return True
212
+ except Exception: # noqa: BLE001
213
+ pass
214
+ return self.has(port, REPORTS_LATENCY)
215
+
216
+ def patch_params(self, plugin):
217
+ """LV2 Parameters (patch:writable) — the atom-port / patch:Set kind.
218
+
219
+ Numeric patch params (Int/Long/Float/Double) often declare lv2:minimum /
220
+ maximum / default just like control ports; capture them so the configurator
221
+ can render a bounded widget (stepper/knob) instead of a bare readout.
222
+ """
223
+ out = []
224
+ try:
225
+ writable = plugin.get_value(self.u[PATCH + "writable"])
226
+ for puri in (writable if writable is not None else []):
227
+ label = first(self.w.find_nodes(
228
+ puri, self.u[RDFS + "label"], None))
229
+ rng = first(self.w.find_nodes(
230
+ puri, self.u[RDFS + "range"], None))
231
+ mn = fnum(first(self.w.find_nodes(puri, self.u[MINIMUM], None)))
232
+ mx = fnum(first(self.w.find_nodes(puri, self.u[MAXIMUM], None)))
233
+ df = fnum(first(self.w.find_nodes(puri, self.u[DEFAULT], None)))
234
+ out.append({
235
+ "kind": "patch",
236
+ "symbol": str(puri).rsplit("#", 1)[-1].rsplit("/", 1)[-1],
237
+ "uri": str(puri),
238
+ "name": str(label) if label is not None else str(puri),
239
+ "datatype": str(rng).rsplit("#", 1)[-1] if rng is not None else "String",
240
+ **({"min": mn} if mn is not None else {}),
241
+ **({"max": mx} if mx is not None else {}),
242
+ **({"default": df} if df is not None else {}),
243
+ })
244
+ except Exception as e: # noqa: BLE001 - best-effort, never fail the scan
245
+ sys.stderr.write(f" patch params skipped: {e}\n")
246
+ return out
247
+
248
+ def describe(self, plugin):
249
+ params, audio_in, audio_out, midi_in = [], 0, 0, False
250
+ latency_symbol = None
251
+ for i in range(plugin.get_num_ports()):
252
+ port = plugin.get_port_by_index(i)
253
+ if self.is_a(port, LV2 + "AudioPort"):
254
+ if self.is_a(port, LV2 + "InputPort"):
255
+ audio_in += 1
256
+ else:
257
+ audio_out += 1
258
+ elif self.is_a(port, LV2 + "ControlPort") and self.is_a(port, LV2 + "InputPort"):
259
+ if self.has(port, PP + "notOnGUI"):
260
+ continue
261
+ params.append(self.control_param(port))
262
+ elif self.is_a(port, LV2 + "ControlPort") and self.is_a(port, LV2 + "OutputPort"):
263
+ # output control ports aren't user params, but the lv2:latency one
264
+ # tells us (and the benchmark) where to read the plugin's latency.
265
+ if latency_symbol is None and self.is_latency_port(port):
266
+ latency_symbol = str(port.get_symbol())
267
+ elif self.is_a(port, ATOM + "AtomPort") and self.is_a(port, LV2 + "InputPort"):
268
+ try:
269
+ if port.supports_event(self.u[MIDI + "MidiEvent"]):
270
+ midi_in = True
271
+ except Exception: # noqa: BLE001
272
+ pass
273
+ params.extend(self.patch_params(plugin))
274
+ cls = plugin.get_class().get_label()
275
+ out = {
276
+ "uri": str(plugin.get_uri()),
277
+ "name": str(plugin.get_name()),
278
+ "lv2Class": str(cls) if cls is not None else None,
279
+ "audioInputs": audio_in,
280
+ "audioOutputs": audio_out,
281
+ "hasMidiIn": midi_in,
282
+ "params": params,
283
+ }
284
+ # The bundle's own one-line description of itself. A DECLARED fact, like the
285
+ # class beside it, and the only other thing a plugin says about its role —
286
+ # which is what the kind ladder falls through to when a plugin declares the
287
+ # bare lv2:Plugin and nothing more (416 of the reference install's 958 carry
288
+ # one). Absent stays absent: an empty string is not a description.
289
+ comment = declared_comment(plugin, self.w)
290
+ if comment:
291
+ out["lv2Comment"] = comment
292
+ # The presets the plugin SHIPS:
293
+ # a DECLARED fact like the comment above, read off the bundle and judged against the params
294
+ # just read. Always present on a scanned plugin — `[]` is "ships none", never "not read".
295
+ out["presets"] = read_presets(self.w, plugin, params)
296
+ # Record the latency port symbol if the plugin reports one. The actual frame
297
+ # value is filled later by the on-rig benchmark (benchmark.py); a plugin with
298
+ # no latency port carries no `latency` key → host treats it as unknown, not 0.
299
+ if latency_symbol is not None:
300
+ out["latency"] = {"portSymbol": latency_symbol}
301
+ version = self.plugin_version(plugin)
302
+ if version is not None:
303
+ out["minorVersion"], out["microVersion"] = version
304
+ bp = bundle_path(plugin)
305
+ out["bundlePath"] = bp
306
+ out["owningRpm"] = owning_rpm(bp)
307
+ return out
308
+
309
+ def plugin_version(self, plugin):
310
+ """(minor, micro) from lv2:minorVersion / lv2:microVersion, or None.
311
+
312
+ Both or neither: a half-declared version is not a version, and pairing a real
313
+ minor with an invented micro would make two builds compare equal that are not.
314
+ """
315
+ minor = fnum(first(plugin.get_value(self.w.new_uri(MINOR_VERSION))))
316
+ micro = fnum(first(plugin.get_value(self.w.new_uri(MICRO_VERSION))))
317
+ if minor is None or micro is None:
318
+ return None
319
+ return int(minor), int(micro)
320
+
321
+ def scan(self, uris=None):
322
+ plugins = self.w.get_all_plugins()
323
+ out = []
324
+ if uris:
325
+ for uri in uris:
326
+ p = plugins.get_by_uri(self.w.new_uri(uri))
327
+ if p is not None:
328
+ out.append(self.describe(p))
329
+ else:
330
+ sys.stderr.write(f"not found: {uri}\n")
331
+ else:
332
+ for p in plugins:
333
+ try:
334
+ out.append(self.describe(p))
335
+ except Exception as e: # noqa: BLE001
336
+ sys.stderr.write(f"skip {p.get_uri()}: {e}\n")
337
+ out.sort(key=lambda d: d["uri"])
338
+ return out
339
+
340
+
341
+ def declared_comment(plugin, world):
342
+ """The bundle's `rdfs:comment` for this plugin, or None.
343
+
344
+ One value only: a bundle that declares several is declaring alternatives, and
345
+ concatenating them would invent a sentence nobody wrote.
346
+ """
347
+ nodes = plugin.get_value(world.new_uri(RDFS + "comment"))
348
+ values = list(nodes) if nodes is not None else []
349
+ if not values:
350
+ return None
351
+ text = " ".join(str(values[0]).split())
352
+ return text or None
353
+
354
+
355
+ DECLARED_FIELDS = ("name", "lv2Class", "lv2Comment", "presets")
356
+
357
+
358
+ def annotate(catalog, scanner):
359
+ """Refresh the DECLARED fields of every URI already in `catalog`, in place.
360
+
361
+ The division of labour this file has always kept: scan.py owns what a plugin
362
+ SAYS about itself, benchmark.py owns what the rig MEASURED of it. So this pass
363
+ rewrites `name`, `lv2Class`, `lv2Comment` and `presets` from the installed bundle and
364
+ touches nothing else — no latency, no cost, no owning package, and no entry is
365
+ added or removed. A URI this host does not have installed is left exactly as it
366
+ is and counted, because "not installed here" and "declares nothing" are
367
+ different facts and only one of them is about the plugin.
368
+
369
+ Returns (changed, absent) counts.
370
+ """
371
+ plugins = scanner.w.get_all_plugins()
372
+ changed = 0
373
+ absent = 0
374
+ for entry in catalog:
375
+ plugin = plugins.get_by_uri(scanner.w.new_uri(entry["uri"]))
376
+ if plugin is None:
377
+ absent += 1
378
+ continue
379
+ fresh = scanner.describe(plugin)
380
+ for field in DECLARED_FIELDS:
381
+ before = entry.get(field)
382
+ after = fresh.get(field)
383
+ if after is None:
384
+ entry.pop(field, None)
385
+ else:
386
+ entry[field] = after
387
+ if before != entry.get(field):
388
+ changed += 1
389
+ return changed, absent
390
+
391
+
392
+ def main():
393
+ ap = argparse.ArgumentParser(description="Scan installed LV2 plugins -> JSON.")
394
+ ap.add_argument("uris", nargs="*", help="specific plugin URIs (default: all)")
395
+ ap.add_argument("--out", help="write JSON here (default: stdout)")
396
+ ap.add_argument("--annotate", metavar="FILE",
397
+ help="refresh only the DECLARED fields of an existing catalog, in place")
398
+ args = ap.parse_args()
399
+ if args.annotate:
400
+ with open(args.annotate, encoding="utf-8") as f:
401
+ catalog = json.load(f)
402
+ if not catalog:
403
+ sys.exit(f"{args.annotate} read as empty; refusing to annotate nothing")
404
+ changed, absent = annotate(catalog, Scanner())
405
+ with open(args.annotate, "w", encoding="utf-8") as f:
406
+ f.write(json.dumps(catalog, indent=2, ensure_ascii=False) + "\n")
407
+ sys.stderr.write(
408
+ f"annotated {len(catalog)} plugins in {args.annotate}: "
409
+ f"{changed} declared fields changed, {absent} not installed here\n")
410
+ return
411
+ catalog = Scanner().scan(args.uris or None)
412
+ text = json.dumps(catalog, indent=2, ensure_ascii=False)
413
+ if args.out:
414
+ with open(args.out, "w", encoding="utf-8") as f:
415
+ f.write(text + "\n")
416
+ sys.stderr.write(f"wrote {len(catalog)} plugins -> {args.out}\n")
417
+ # Sibling URI manifest (committed, small) — the guardrail test cross-checks
418
+ # every curated URI against it, so a typo'd URI fails CI even though the
419
+ # full catalog.json is gitignored/host-specific. Only on a full scan.
420
+ if not args.uris:
421
+ import os
422
+ uri_path = os.path.join(os.path.dirname(args.out) or ".", "installed-uris.json")
423
+ uris = sorted(d["uri"] for d in catalog)
424
+ with open(uri_path, "w", encoding="utf-8") as f:
425
+ f.write(json.dumps(uris, indent=1, ensure_ascii=False) + "\n")
426
+ sys.stderr.write(f"wrote {len(uris)} URIs -> {uri_path}\n")
427
+ else:
428
+ print(text)
429
+
430
+
431
+ if __name__ == "__main__":
432
+ main()