@openmixer/plugin-qualify 0.13.1 → 0.14.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.
package/README.md CHANGED
@@ -193,6 +193,40 @@ names the options it ran under, `featureOptions`:
193
193
  The defaults are the measurement as it has always been, so a verdict reached under them does not
194
194
  move. `lv2_measure --help` lists the same flags.
195
195
 
196
+ ## Scanned descriptors
197
+
198
+ Both scanners also write what each plugin states about itself as a scanned descriptor: one JSON file per
199
+ plugin in omx-contract's descriptor words (identity, controls, ports, what it needs from a host, whether it
200
+ reports latency), held to omx-contract's descriptor schema before it is written. A descriptor holds no
201
+ measurement and no verdict; those are the qualification record's.
202
+
203
+ The files go into the descriptor directory, the key `descriptor_dir`:
204
+
205
+ | | where |
206
+ | --- | --- |
207
+ | named | `--descriptor-dir <dir>` |
208
+ | a user (the default) | `$XDG_STATE_HOME/omx/descriptors`, else `~/.local/state/omx/descriptors` |
209
+ | a system service | `/var/lib/omx/descriptors` (`--system`) |
210
+
211
+ Each file is named `<id>@<version>-<sha256 first 12>.json`: the CLAP id or the LV2 URI, the version the
212
+ plugin states (`unversioned` when it states none) and the SHA-256 of its binary (the `.clap`, or the LV2
213
+ bundle's `lv2:binary`), with every character other than letters, digits, `.`, `_` and `-` written `_`. The hash
214
+ keys which file describes which binary: a changed binary is a new file, and the old one stays as history.
215
+
216
+ ```
217
+ tools/scan.py --out catalog.json # LV2: the catalog, and the descriptors beside it
218
+ tools/scan.py --descriptor-dir ./descriptors urn:my:plugin # one plugin, into a directory of your own
219
+ native/build/clap-qualify --json ./my-fx.clap | tools/descriptors.py clap --descriptor-dir ./descriptors
220
+ ```
221
+
222
+ `scan.py --no-descriptors` writes the catalog only. A descriptor the schema refuses is not written and is
223
+ reported as `contract.file.invalid`, omx-contract's code; a plugin whose binary cannot be read is
224
+ `qualify.descriptor.binary-unhashed`; a directory that cannot be written is `qualify.descriptor.dir-unwritable`;
225
+ no schema found is `qualify.descriptor.schema-missing`. Any of them makes the run exit `2`, and the other
226
+ plugins are still written. The schema is the omx-contract package plugin-qualify depends on, else
227
+ `/usr/share/omx-contract/omx-contract.schema.json`; `OMX_CONTRACT_SCHEMA` names another one, for a test. The
228
+ writer needs Python's `jsonschema` (`python3-jsonschema`).
229
+
196
230
  ## What the verdicts mean
197
231
 
198
232
  - **suitable**: every dimension was measured and passed. Safe to run inside the engine's own
@@ -117,6 +117,32 @@ export declare const LV2_HOSTING_BLOCK_FRAMES = 512;
117
117
  export declare const LV2_HOSTING_LIFECYCLES = 30;
118
118
  export declare const LV2_HOSTING_REPEATS = 3;
119
119
  export declare const LV2_HOSTING_TIMEOUT_S = 240;
120
+ /**
121
+ * The scanned descriptors (owner spec 6.6 and 6.9): both scanners write one file per plugin into the
122
+ * directory the key `descriptor_dir` names, `<id>@<version>-<binary sha256 first DESCRIPTOR_HASH_PREFIX>.json`.
123
+ * With no directory named it is `$XDG_STATE_HOME/<DESCRIPTOR_DIR_STATE_SUBDIR>`, else
124
+ * `~/.local/state/<DESCRIPTOR_DIR_STATE_SUBDIR>`; a system service's is `DESCRIPTOR_DIR_SYSTEM`.
125
+ */
126
+ export declare const DESCRIPTOR_DIR_KEY = "descriptor_dir";
127
+ export declare const DESCRIPTOR_DIR_STATE_SUBDIR = "omx/descriptors";
128
+ export declare const DESCRIPTOR_DIR_SYSTEM = "/var/lib/omx/descriptors";
129
+ export declare const DESCRIPTOR_HASH_PREFIX = 12;
130
+ /**
131
+ * The one descriptor schema both scanners hold a file to: omx-contract's, named by `DESCRIPTOR_SCHEMA_ENV`
132
+ * when set (a test, or a checkout pointing at another contract), else the omx-contract package this one
133
+ * depends on, else `DESCRIPTOR_SCHEMA_INSTALLED`. A written descriptor requires `DESCRIPTOR_SCHEMA_FLOOR`.
134
+ */
135
+ export declare const DESCRIPTOR_SCHEMA_ENV = "OMX_CONTRACT_SCHEMA";
136
+ export declare const DESCRIPTOR_SCHEMA_INSTALLED = "/usr/share/omx-contract/omx-contract.schema.json";
137
+ export declare const DESCRIPTOR_SCHEMA_FLOOR = ">= 3.0.0";
138
+ /**
139
+ * Why a scanned descriptor is not written. A file the schema refuses is omx-contract's own
140
+ * `contract.file.invalid`; the others are the scanners' own.
141
+ */
142
+ export declare const DESCRIPTOR_CODE_SCHEMA = "contract.file.invalid";
143
+ export declare const DESCRIPTOR_CODE_SCHEMA_MISSING = "qualify.descriptor.schema-missing";
144
+ export declare const DESCRIPTOR_CODE_BINARY_UNHASHED = "qualify.descriptor.binary-unhashed";
145
+ export declare const DESCRIPTOR_CODE_DIR_UNWRITABLE = "qualify.descriptor.dir-unwritable";
120
146
  /** Every threshold, so none is baked into the code. */
121
147
  export interface HostingPolicy {
122
148
  /** Minimum instantiate/destroy lifecycles. The operator's §14.4 ruling: 1000. */
@@ -122,6 +122,32 @@ export const LV2_HOSTING_BLOCK_FRAMES = 512;
122
122
  export const LV2_HOSTING_LIFECYCLES = 30;
123
123
  export const LV2_HOSTING_REPEATS = 3;
124
124
  export const LV2_HOSTING_TIMEOUT_S = 240;
125
+ /**
126
+ * The scanned descriptors (owner spec 6.6 and 6.9): both scanners write one file per plugin into the
127
+ * directory the key `descriptor_dir` names, `<id>@<version>-<binary sha256 first DESCRIPTOR_HASH_PREFIX>.json`.
128
+ * With no directory named it is `$XDG_STATE_HOME/<DESCRIPTOR_DIR_STATE_SUBDIR>`, else
129
+ * `~/.local/state/<DESCRIPTOR_DIR_STATE_SUBDIR>`; a system service's is `DESCRIPTOR_DIR_SYSTEM`.
130
+ */
131
+ export const DESCRIPTOR_DIR_KEY = "descriptor_dir";
132
+ export const DESCRIPTOR_DIR_STATE_SUBDIR = "omx/descriptors";
133
+ export const DESCRIPTOR_DIR_SYSTEM = "/var/lib/omx/descriptors";
134
+ export const DESCRIPTOR_HASH_PREFIX = 12;
135
+ /**
136
+ * The one descriptor schema both scanners hold a file to: omx-contract's, named by `DESCRIPTOR_SCHEMA_ENV`
137
+ * when set (a test, or a checkout pointing at another contract), else the omx-contract package this one
138
+ * depends on, else `DESCRIPTOR_SCHEMA_INSTALLED`. A written descriptor requires `DESCRIPTOR_SCHEMA_FLOOR`.
139
+ */
140
+ export const DESCRIPTOR_SCHEMA_ENV = "OMX_CONTRACT_SCHEMA";
141
+ export const DESCRIPTOR_SCHEMA_INSTALLED = "/usr/share/omx-contract/omx-contract.schema.json";
142
+ export const DESCRIPTOR_SCHEMA_FLOOR = ">= 3.0.0";
143
+ /**
144
+ * Why a scanned descriptor is not written. A file the schema refuses is omx-contract's own
145
+ * `contract.file.invalid`; the others are the scanners' own.
146
+ */
147
+ export const DESCRIPTOR_CODE_SCHEMA = "contract.file.invalid";
148
+ export const DESCRIPTOR_CODE_SCHEMA_MISSING = "qualify.descriptor.schema-missing";
149
+ export const DESCRIPTOR_CODE_BINARY_UNHASHED = "qualify.descriptor.binary-unhashed";
150
+ export const DESCRIPTOR_CODE_DIR_UNWRITABLE = "qualify.descriptor.dir-unwritable";
125
151
  /**
126
152
  * Defaults. The cycle floor is the operator's ruling of 2026-09-05 (§14.4, option c) and the
127
153
  * soak is §3's default show length. The per-instance cost ceiling is the HOST's budget and
@@ -1 +1 @@
1
- {"version":3,"file":"qualify-declarations.js","sourceRoot":"","sources":["../src/qualify-declarations.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"qualify-declarations.js","sourceRoot":"","sources":["../src/qualify-declarations.ts"],"names":[],"mappings":"AAAA,4CAA4C;AAC5C,sDAAsD;AACtD,OAAO,EAAE,kBAAkB,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAE7E;;;;GAIG;AAEH;;GAEG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,KAAK,CAAC;AAEzC;;GAEG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,IAAI,CAAC;AAEnC,kFAAkF;AAClF,MAAM,CAAC,MAAM,iBAAiB,GAAG,KAAK,CAAC;AAEvC;;;;GAIG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,KAAK,CAAC;AAE5C,yHAAyH;AACzH,MAAM,CAAC,MAAM,aAAa,GAAsB,kBAAkB,CAAC;AAEnE,iJAAiJ;AACjJ,MAAM,CAAC,MAAM,cAAc,GAAsB,CAAC,cAAc,CAAC,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,cAAc,CAAC,CAAC;AAEpG,wGAAwG;AACxG,MAAM,CAAC,MAAM,gBAAgB,GAAG,EAAE,CAAC;AAEnC,0FAA0F;AAC1F,MAAM,CAAC,MAAM,cAAc,GAAG,GAAG,CAAC;AAElC,8FAA8F;AAC9F,MAAM,CAAC,MAAM,cAAc,GAAG,EAAE,CAAC;AAEjC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,EAAE,CAAC;AAChC,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC;AAElC;;;;GAIG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC;AACrC,MAAM,CAAC,MAAM,oBAAoB,GAAG,EAAE,CAAC;AAEvC,sGAAsG;AACtG,MAAM,CAAC,MAAM,kBAAkB,GAAG,EAAE,CAAC;AAErC;;;GAGG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,eAAe,CAAC;AAClD,MAAM,CAAC,MAAM,sBAAsB,GAAG,eAAe,CAAC;AAEtD;;;;GAIG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,WAAW,CAAC;AACzC,MAAM,CAAC,MAAM,aAAa,GAAG,OAAO,CAAC;AACrC,MAAM,CAAC,MAAM,eAAe,GAAG,eAAe,CAAC;AAC/C,0IAA0I;AAC1I,MAAM,CAAC,MAAM,mBAAmB,GAAG,qBAAqB,CAAC;AAEzD;;;;GAIG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,kBAAkB,CAAC;AACnD,MAAM,CAAC,MAAM,oBAAoB,GAAG,oBAAoB,CAAC;AAEzD,sFAAsF;AACtF,MAAM,CAAC,MAAM,mBAAmB,GAAG,GAAG,CAAC;AAEvC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,kBAAkB,CAAC;AACxD,MAAM,CAAC,MAAM,oBAAoB,GAAG,iBAAiB,CAAC;AACtD,MAAM,CAAC,MAAM,gBAAgB,GAAG,kBAAkB,CAAC;AACnD,MAAM,CAAC,MAAM,uBAAuB,GAAG,oBAAoB,CAAC;AAE5D,8EAA8E;AAC9E,MAAM,CAAC,MAAM,sBAAsB,GAAG,cAAc,CAAC;AACrD,MAAM,CAAC,MAAM,qBAAqB,GAAG,aAAa,CAAC;AACnD,MAAM,CAAC,MAAM,iBAAiB,GAAG,iBAAiB,CAAC;AACnD,MAAM,CAAC,MAAM,wBAAwB,GAAG,gBAAgB,CAAC;AAEzD;;;GAGG;AACH,MAAM,CAAC,MAAM,YAAY,GAA+D;IACtF,EAAE,IAAI,EAAE,sBAAsB,EAAE,GAAG,EAAE,qBAAqB,EAAE;IAC5D,EAAE,IAAI,EAAE,qBAAqB,EAAE,GAAG,EAAE,oBAAoB,EAAE;IAC1D,EAAE,IAAI,EAAE,iBAAiB,EAAE,GAAG,EAAE,gBAAgB,EAAE;IAClD,EAAE,IAAI,EAAE,wBAAwB,EAAE,GAAG,EAAE,uBAAuB,EAAE;CACjE,CAAC;AAEF;;;;GAIG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAsB,CAAC,6BAA6B,EAAE,yBAAyB,CAAC,CAAC;AAE9G,2GAA2G;AAC3G,MAAM,CAAC,MAAM,iBAAiB,GAAG,GAAG,CAAC;AACrC,MAAM,CAAC,MAAM,iBAAiB,GAAG,GAAG,CAAC;AAErC,yFAAyF;AACzF,MAAM,CAAC,MAAM,sBAAsB,GAAG,GAAG,CAAC;AAE1C;;;;;GAKG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,GAAG,CAAC;AAC5C,MAAM,CAAC,MAAM,sBAAsB,GAAG,EAAE,CAAC;AACzC,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC;AACrC,MAAM,CAAC,MAAM,qBAAqB,GAAG,GAAG,CAAC;AAEzC;;;;;GAKG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,gBAAgB,CAAC;AACnD,MAAM,CAAC,MAAM,2BAA2B,GAAG,iBAAiB,CAAC;AAC7D,MAAM,CAAC,MAAM,qBAAqB,GAAG,0BAA0B,CAAC;AAChE,MAAM,CAAC,MAAM,sBAAsB,GAAG,EAAE,CAAC;AAEzC;;;;GAIG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,qBAAqB,CAAC;AAC3D,MAAM,CAAC,MAAM,2BAA2B,GAAG,kDAAkD,CAAC;AAC9F,MAAM,CAAC,MAAM,uBAAuB,GAAG,UAAU,CAAC;AAElD;;;GAGG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,uBAAuB,CAAC;AAC9D,MAAM,CAAC,MAAM,8BAA8B,GAAG,mCAAmC,CAAC;AAClF,MAAM,CAAC,MAAM,+BAA+B,GAAG,oCAAoC,CAAC;AACpF,MAAM,CAAC,MAAM,8BAA8B,GAAG,mCAAmC,CAAC;AAsBlF;;;;GAIG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAkB;IACnD,UAAU,EAAE,IAAI;IAChB,WAAW,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE;IACxB,oBAAoB,EAAE,CAAC;IACvB,cAAc,EAAE,EAAE;CACnB,CAAC;AAOF;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG;IAC1B,mBAAmB,EAAE,EAAE,mBAAmB,EAAE,IAAI,EAAE,IAAI,EAAE,mBAAmB,EAAE,OAAO,EAAE,cAAc,EAAE;IACtG,IAAI,EAAE,EAAE,mBAAmB,EAAE,IAAI,EAAE,IAAI,EAAE,iBAAiB,EAAE,OAAO,EAAE,GAAG,EAAE;IAC1E,UAAU,EAAE,EAAE,mBAAmB,EAAE,IAAI,EAAE,IAAI,EAAE,iBAAiB,EAAE,OAAO,EAAE,GAAG,EAAE;IAChF,eAAe,EAAE,EAAE,mBAAmB,EAAE,IAAI,EAAE,IAAI,EAAE,iBAAiB,EAAE,OAAO,EAAE,GAAG,EAAE;CAC9B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmixer/plugin-qualify",
3
- "version": "0.13.1",
3
+ "version": "0.14.1",
4
4
  "description": "Qualify audio plugins for real-time hosting: scan, offline latency and cost measurement, a mod-host hosting sweep, and a fail-closed rating.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",
@@ -147,6 +147,35 @@ export const LV2_HOSTING_LIFECYCLES = 30;
147
147
  export const LV2_HOSTING_REPEATS = 3;
148
148
  export const LV2_HOSTING_TIMEOUT_S = 240;
149
149
 
150
+ /**
151
+ * The scanned descriptors (owner spec 6.6 and 6.9): both scanners write one file per plugin into the
152
+ * directory the key `descriptor_dir` names, `<id>@<version>-<binary sha256 first DESCRIPTOR_HASH_PREFIX>.json`.
153
+ * With no directory named it is `$XDG_STATE_HOME/<DESCRIPTOR_DIR_STATE_SUBDIR>`, else
154
+ * `~/.local/state/<DESCRIPTOR_DIR_STATE_SUBDIR>`; a system service's is `DESCRIPTOR_DIR_SYSTEM`.
155
+ */
156
+ export const DESCRIPTOR_DIR_KEY = "descriptor_dir";
157
+ export const DESCRIPTOR_DIR_STATE_SUBDIR = "omx/descriptors";
158
+ export const DESCRIPTOR_DIR_SYSTEM = "/var/lib/omx/descriptors";
159
+ export const DESCRIPTOR_HASH_PREFIX = 12;
160
+
161
+ /**
162
+ * The one descriptor schema both scanners hold a file to: omx-contract's, named by `DESCRIPTOR_SCHEMA_ENV`
163
+ * when set (a test, or a checkout pointing at another contract), else the omx-contract package this one
164
+ * depends on, else `DESCRIPTOR_SCHEMA_INSTALLED`. A written descriptor requires `DESCRIPTOR_SCHEMA_FLOOR`.
165
+ */
166
+ export const DESCRIPTOR_SCHEMA_ENV = "OMX_CONTRACT_SCHEMA";
167
+ export const DESCRIPTOR_SCHEMA_INSTALLED = "/usr/share/omx-contract/omx-contract.schema.json";
168
+ export const DESCRIPTOR_SCHEMA_FLOOR = ">= 3.0.0";
169
+
170
+ /**
171
+ * Why a scanned descriptor is not written. A file the schema refuses is omx-contract's own
172
+ * `contract.file.invalid`; the others are the scanners' own.
173
+ */
174
+ export const DESCRIPTOR_CODE_SCHEMA = "contract.file.invalid";
175
+ export const DESCRIPTOR_CODE_SCHEMA_MISSING = "qualify.descriptor.schema-missing";
176
+ export const DESCRIPTOR_CODE_BINARY_UNHASHED = "qualify.descriptor.binary-unhashed";
177
+ export const DESCRIPTOR_CODE_DIR_UNWRITABLE = "qualify.descriptor.dir-unwritable";
178
+
150
179
 
151
180
 
152
181
 
@@ -19,3 +19,14 @@ QUALIFY_QUANTA = (128, 256, 512, 1024)
19
19
  PLUGIN_TIMEOUT_S = 15
20
20
  BOOT_TIMEOUT_S = 120
21
21
  COST_TIMEOUT_S = 60
22
+ DESCRIPTOR_DIR_KEY = "descriptor_dir"
23
+ DESCRIPTOR_DIR_STATE_SUBDIR = "omx/descriptors"
24
+ DESCRIPTOR_DIR_SYSTEM = "/var/lib/omx/descriptors"
25
+ DESCRIPTOR_HASH_PREFIX = 12
26
+ DESCRIPTOR_SCHEMA_ENV = "OMX_CONTRACT_SCHEMA"
27
+ DESCRIPTOR_SCHEMA_INSTALLED = "/usr/share/omx-contract/omx-contract.schema.json"
28
+ DESCRIPTOR_SCHEMA_FLOOR = ">= 3.0.0"
29
+ DESCRIPTOR_CODE_SCHEMA = "contract.file.invalid"
30
+ DESCRIPTOR_CODE_SCHEMA_MISSING = "qualify.descriptor.schema-missing"
31
+ DESCRIPTOR_CODE_BINARY_UNHASHED = "qualify.descriptor.binary-unhashed"
32
+ DESCRIPTOR_CODE_DIR_UNWRITABLE = "qualify.descriptor.dir-unwritable"
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+ # Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
4
+ #
5
+ # audit-overlap.sh — every number in docs/audit-qualify-vs-compat.md, from source, by one command each.
6
+ #
7
+ # tools/audit-overlap.sh <omx-clap-core dir> <plugin-hostd dir>
8
+ # tools/audit-overlap.sh --self-test
9
+ #
10
+ # It prints one line per unit (a function, or a set of functions, in one repository):
11
+ # unit <id> <repo> <file>:<first>-<last> lines=<n>
12
+ # one line per pair that does the same job in two places:
13
+ # pair <a> <b> shared=<s> a=<na> b=<nb> overlap=<s/min(na,nb) %> diff=<changed lines or "-"> strings=<both/a/b>
14
+ # and one line per counted marker:
15
+ # count <id> <repo> <n>
16
+ # A unit is found by its name (a C or TypeScript function runs to the first column-0 `}`, a Python def or class
17
+ # to the next column-0 statement), never by a line number, so the numbers follow the code. The audited commits are
18
+ # printed first (`tree <repo> <commit>`); the document states which ones it quotes.
19
+ #
20
+ # Similarity: both bodies are normalised (comments, blank lines and lone braces dropped, whitespace squeezed), then
21
+ # `shared` is the number of normalised lines the two have in common (comm -12 over the sorted lines, duplicates
22
+ # kept) and `diff` the number of lines `diff` adds or removes between them. Code in two languages shares no line;
23
+ # for it the pair prints overlap=0 and diff=-. `strings` is shared/a/b of the distinct string literals (three or
24
+ # more characters) each body spells: messages, field names and codes, which survive a change of brace style or of
25
+ # language, so a port of the same code shows there when its lines do not.
26
+ set -euo pipefail
27
+ export LC_ALL=C
28
+ Q="$(cd "$(dirname "$0")/.." && pwd)"
29
+
30
+ die() { echo "audit-overlap: $*" >&2; exit 2; }
31
+
32
+ # span <lang> <file> <name> — print "<first> <last>" of the named unit, nothing when there is none
33
+ span() { # <lang> <file> <name>
34
+ awk -v lang="$1" -v name="$3" '
35
+ function starts(l) {
36
+ if (lang == "py") return l ~ ("^(def|class) " name "[(:]")
37
+ if (lang == "ts") return l ~ ("^(export )?(async )?function " name "[(<]")
38
+ return l ~ ("^[A-Za-z].*[ *]" name "\\(") && l !~ /;[ \t]*$/
39
+ }
40
+ !on && starts($0) { on = 1; first = NR; last = NR; next }
41
+ on && lang != "py" && /^}/ { last = NR; print first, last; exit }
42
+ on && lang == "py" && /^[^ \t#\n]/ { print first, last; exit }
43
+ on && lang == "py" && /[^ \t]/ { last = NR }
44
+ END { if (on && lang == "py") print first, last }
45
+ ' "$2"
46
+ }
47
+
48
+ WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
49
+ declare -A ROOT LANG_OF
50
+
51
+ # unit <id> <repo> <lang> <file> <name>... — one unit, the names' bodies concatenated
52
+ unit() {
53
+ local id="$1" repo="$2" lang="$3" file="$4"; shift 4
54
+ local root="${ROOT[$repo]}" n total=0 where="" s
55
+ : > "$WORK/$id"
56
+ for n in "$@"; do
57
+ s="$(span "$lang" "$root/$file" "$n")"
58
+ [ -n "$s" ] || die "$repo $file: no $lang unit named $n"
59
+ local a b; read -r a b <<<"$s"
60
+ sed -n "${a},${b}p" "$root/$file" >> "$WORK/$id"
61
+ total=$((total + b - a + 1)); where="${where:+$where,}$a-$b"
62
+ done
63
+ LANG_OF[$id]="$lang"
64
+ echo "unit $id $repo $file:$where lines=$total"
65
+ }
66
+
67
+ # whole <id> <repo> <lang> <file> — a whole file as one unit
68
+ whole() {
69
+ local id="$1" repo="$2" lang="$3" file="$4" n
70
+ cp "${ROOT[$repo]}/$file" "$WORK/$id"; n=$(wc -l < "$WORK/$id")
71
+ LANG_OF[$id]="$lang"
72
+ echo "unit $id $repo $file:1-$n lines=$n"
73
+ }
74
+
75
+ norm() { # <lang> < body
76
+ case "$1" in
77
+ py) sed -E 's/#.*$//' ;;
78
+ *) sed -E 's#//.*$##; s#/\*.*\*/##g; /^[[:space:]]*\*( |\/|$)/d; /^[[:space:]]*\/\*/d' ;;
79
+ esac | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//' | grep -vE '^[{}();, ]*$' || true
80
+ }
81
+
82
+ lits() { grep -oE "\"[^\"\\\\]{3,}\"|'[^'\\\\]{3,}'" | sed -E "s/^.(.*).$/\\1/"; } # quotes dropped: '' and "" alike
83
+
84
+ pair() { # <a> <b>
85
+ local a="$1" b="$2" na nb s d m l
86
+ norm "${LANG_OF[$a]}" < "$WORK/$a" | sort > "$WORK/$a.n"
87
+ norm "${LANG_OF[$b]}" < "$WORK/$b" | sort > "$WORK/$b.n"
88
+ na=$(wc -l < "$WORK/$a.n"); nb=$(wc -l < "$WORK/$b.n")
89
+ s=$(comm -12 "$WORK/$a.n" "$WORK/$b.n" | wc -l)
90
+ m=$(( na < nb ? na : nb ))
91
+ if [ "${LANG_OF[$a]}" = "${LANG_OF[$b]}" ]; then
92
+ norm "${LANG_OF[$a]}" < "$WORK/$a" > "$WORK/$a.o"; norm "${LANG_OF[$b]}" < "$WORK/$b" > "$WORK/$b.o"
93
+ d=$(diff "$WORK/$a.o" "$WORK/$b.o" | grep -c '^[<>]' || true)
94
+ else d="-"; fi
95
+ # the string literals (messages, field names, codes) both spell: what survives a change of style or language
96
+ norm "${LANG_OF[$a]}" < "$WORK/$a" | lits | sort -u > "$WORK/$a.l" || true
97
+ norm "${LANG_OF[$b]}" < "$WORK/$b" | lits | sort -u > "$WORK/$b.l" || true
98
+ l="$(comm -12 "$WORK/$a.l" "$WORK/$b.l" | wc -l)/$(wc -l < "$WORK/$a.l")/$(wc -l < "$WORK/$b.l")"
99
+ echo "pair $a $b shared=$s a=$na b=$nb overlap=$(( m ? 100 * s / m : 0 ))% diff=$d strings=$l"
100
+ }
101
+
102
+ count() { # <id> <repo> <git grep args> -- <pathspec>... (this audit's own two files are never counted)
103
+ local id="$1" repo="$2"; shift 2
104
+ local self=(":(exclude)tools/audit-overlap.sh" ":(exclude)docs/audit-qualify-vs-compat.md")
105
+ echo "count $id $repo $(git -C "${ROOT[$repo]}" grep -IE "$@" "${self[@]}" | wc -l)"
106
+ }
107
+
108
+ audit() {
109
+ local r
110
+ for r in qualify clap-core hostd; do echo "tree $r $(git -C "${ROOT[$r]}" rev-parse --short HEAD)"; done
111
+
112
+ # 1. plugin enumeration
113
+ unit q.clap-walk qualify c native/clap-qualify.c is_clap walk scan
114
+ unit c.clap-walk clap-core c src/scan.c has_suffix walk main
115
+ unit q.clap-fork qualify c native/clap-qualify.c scan_file
116
+ unit c.clap-fork clap-core c src/scan.c scan_file
117
+ unit q.clap-describe qualify c native/clap-qualify.c scan_file_entry scan_plugin scan_params scan_audio_ports
118
+ unit c.clap-describe clap-core c src/scan.c put_file put_plugin put_descriptor put_params put_audio_ports
119
+ unit q.clap-path qualify ts src/clap-loader.ts clapSearchPath
120
+ unit c.clap-path clap-core c src/scan.c default_paths
121
+ unit q.lv2-manifest qualify ts src/lv2-loader.ts pluginsInManifest lv2BundlesOnPath
122
+ unit c.lv2-manifest clap-core c src/lv2_locate.c lv2_locate_manifest_names walk_dir lv2_locate
123
+ unit q.lv2-lilv qualify py tools/scan.py Scanner
124
+ unit c.lv2-lilv clap-core py tests/lv2_parity.py lilv_facts
125
+ pair q.clap-walk c.clap-walk
126
+ pair q.clap-fork c.clap-fork
127
+ pair q.clap-describe c.clap-describe
128
+ pair q.clap-path c.clap-path
129
+ pair q.lv2-manifest c.lv2-manifest
130
+ pair q.lv2-lilv c.lv2-lilv
131
+ count q.calls-omx-clap-scan qualify -e 'omx-clap-scan' -- src
132
+ # clap-qualify's whole scan mode (--json), which writes the document omx-clap-scan writes, plus labels and pages
133
+ unit q.scan-mode qualify c native/clap-qualify.c put_labels scan_params scan_remote_controls scan_audio_ports \
134
+ scan_plugin put_file_error scan_file_entry scan_file list_add is_clap walk scan
135
+ count c.scan-labels-pages clap-core -i -e 'remote_controls|enum_labels|"labels"' -- src/scan.c
136
+
137
+ # 2. deterministic input
138
+ count q.lcg-nr qualify -e '1664525u' -- native
139
+ count q.lcg-glibc qualify -e '1103515245u' -- native
140
+ count c.lcg-nr clap-core -e '1664525u' -- tests src
141
+ unit q.noise-lv2 qualify c native/lv2-measure.c fill_input
142
+ unit c.signal clap-core c tests/jack_parity.c noise signal_at
143
+ pair q.noise-lv2 c.signal
144
+ unit q.notes qualify c native/clap-qualify.c note_event note_midi note_key note_block note_dialect
145
+ unit q.notes-py qualify py tools/benchmark.py held_keys midi_sequence
146
+ unit c.notes clap-core c tests/jack_parity.c midi_events
147
+ pair q.notes c.notes
148
+ pair q.notes-py q.notes
149
+
150
+ # 3. isolated PipeWire, the feeder
151
+ count q.pipewire qualify -i -e 'pipewire|unshare' -- src native tools bin
152
+ count c.unshare clap-core -e 'unshare' -- tests
153
+ unit c.pw-parity clap-core py tests/lv2_parity.py start_pipewire
154
+ unit c.pw-coverage clap-core py tests/clap_coverage.py start_pipewire
155
+ unit c.ns-parity clap-core py tests/lv2_parity.py inside_run cmd_run
156
+ unit c.ns-coverage clap-core py tests/clap_coverage.py inside_host cmd_host
157
+ unit c.clock clap-core c tests/jack_parity.c clock_process clock_state clock_wait clock_start clock_cycle clock_run
158
+ pair c.pw-parity c.pw-coverage
159
+ pair c.ns-parity c.ns-coverage
160
+
161
+ # 4. host launch, teardown, crash and hang
162
+ unit q.probe-runner qualify ts src/probe-runner.ts runOne probeTarget
163
+ unit h.run-child hostd c src/probe.c apply_limits kill_tree run_child child_end
164
+ unit q.bench-worker qualify py tools/benchmark.py WorkerQueue _Worker
165
+ unit c.host-parity clap-core py tests/lv2_parity.py Host run_plugin
166
+ unit c.host-coverage clap-core py tests/clap_coverage.py Host
167
+ pair c.host-parity c.host-coverage
168
+ pair q.bench-worker c.host-parity
169
+ count h.runs-clap-qualify hostd -e 'clap-qualify|clap_qualify' -- src
170
+ count h.runs-lv2-measure hostd -e 'lv2_measure|lv2-measure' -- src
171
+
172
+ # 5. records, classification, vocabulary, reports
173
+ unit q.records qualify ts src/measurement/sweep-jsonl.ts measuredPluginsFromSweep parseJsonl
174
+ unit c.tsv-parity clap-core py tests/lv2_parity.py read_tsv write_tsv
175
+ unit c.tsv-coverage clap-core py tests/clap_coverage.py read_tsv write_tsv append_tsv
176
+ pair c.tsv-parity c.tsv-coverage
177
+ unit q.classify qualify ts src/hosting-suitability.ts classifyPluginHosting
178
+ unit c.classify clap-core py tests/lv2_parity.py _classify
179
+ pair q.classify c.classify
180
+ unit c.report clap-core py tests/lv2_parity.py report_text
181
+ count q.contract-imports qualify -e "from \"@openmixer/omx-contract" -- src
182
+ count c.contract-macros clap-core -e 'OMX_HOSTING_[A-Z_]+' -- src lv2
183
+ count q.handtyped-hosting qualify -e '"hosting\.[a-z-]+\.[a-z-]+"' -- 'src/*.ts' ':!src/*.test.ts'
184
+
185
+ # 6. cost and latency
186
+ unit q.pct-c qualify c native/clap-qualify.c percentile_sorted
187
+ unit q.pct-py qualify py tools/benchmark.py percentile
188
+ unit q.cost-c qualify c native/clap-qualify.c cost_rate
189
+ unit c.stage-bench clap-core c tests/stage_bench.c main
190
+ pair q.pct-c q.pct-py
191
+ pair q.cost-c c.stage-bench
192
+
193
+ # the probing programs plugin-hostd's probe runs as its children today, and what the move into its child deletes
194
+ whole q.prober-clap qualify c native/clap-qualify.c
195
+ whole q.prober-lv2 qualify c native/lv2-measure.c
196
+ whole q.benchmark qualify py tools/benchmark.py
197
+
198
+ # 7. mod-host in plugin-qualify: code that runs it, and words that name it
199
+ count q.modhost-spawn qualify -e "spawn[A-Za-z]*\\(['\"]mod-host|execvp?\\(\"mod-host" -- src native tools bin
200
+ count q.modhost-words qualify -i -e 'mod-host' -- .
201
+ count c.modhost-words clap-core -i -e 'mod-host' -- tests
202
+ }
203
+
204
+ self_test() {
205
+ T="$(mktemp -d)"; local t="$T"; trap 'rm -rf "$T" "$WORK"' EXIT
206
+ for r in a b c; do mkdir -p "$t/$r"; git -C "$t/$r" init -q; done
207
+ cat > "$t/a/x.c" <<'EOF'
208
+ static int one(int v)
209
+ {
210
+ /* a comment */
211
+ return v + !"same"; /* "comment" */
212
+ }
213
+ static int two(int v) {
214
+ return v * 2;
215
+ }
216
+ EOF
217
+ cat > "$t/b/y.py" <<'EOF'
218
+ def one(v):
219
+ # a comment
220
+ return v + len("same") # "comment"
221
+
222
+ X = 1
223
+ EOF
224
+ cp "$t/a/x.c" "$t/c/z.c"; sed -i 's/v \* 2/v * 3/' "$t/c/z.c"
225
+ for r in a b c; do git -C "$t/$r" add -A; git -C "$t/$r" -c user.name=t -c user.email=t@t commit -qm t; done
226
+ ROOT[qualify]="$t/a"; ROOT[clap-core]="$t/b"; ROOT[hostd]="$t/c"
227
+ local out fail=0
228
+ expect() { grep -qxF -- "$1" <<<"$out" || { echo "self-test: missing: $1" >&2; fail=1; }; }
229
+ out="$(unit u1 qualify c x.c one; unit u2 qualify c x.c two; unit p1 clap-core py y.py one
230
+ unit h1 hostd c z.c one two; unit h2 qualify c x.c one two
231
+ pair h2 h1; pair u1 p1; count n qualify -e 'return' -- .)"
232
+ expect "unit u1 qualify x.c:1-5 lines=5"
233
+ expect "unit u2 qualify x.c:6-8 lines=3"
234
+ expect "unit p1 clap-core y.py:1-3 lines=3"
235
+ expect "pair h2 h1 shared=3 a=4 b=4 overlap=75% diff=2 strings=1/1/1"
236
+ expect "pair u1 p1 shared=0 a=2 b=2 overlap=0% diff=- strings=1/1/1"
237
+ expect "count n qualify 2"
238
+ # a name that is not there is refused, never counted as zero
239
+ if (unit u3 qualify c x.c three) 2>/dev/null; then echo "self-test: a missing unit was not refused" >&2; fail=1; fi
240
+ [ "$fail" = 0 ] && echo "audit-overlap self-test: ok"
241
+ return "$fail"
242
+ }
243
+
244
+ case "${1:-}" in
245
+ --self-test) self_test ;;
246
+ -h|--help|"") sed -n '5,9p' "$0"; [ -n "${1:-}" ] ;;
247
+ *) [ $# = 2 ] || die "usage: $0 <omx-clap-core dir> <plugin-hostd dir>"
248
+ ROOT[qualify]="$Q"; ROOT[clap-core]="$(cd "$1" && pwd)"; ROOT[hostd]="$(cd "$2" && pwd)"
249
+ audit ;;
250
+ esac
@@ -0,0 +1,382 @@
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
+ The scanned descriptors both scanners write (owner spec 6.6 and 6.9): one file per plugin, in
6
+ omx-contract's descriptor words, named by the plugin's id, its version and the SHA-256 of its
7
+ binary, in `descriptor_dir`.
8
+
9
+ This module is the one writer: it resolves the directory, names the file, holds the document to
10
+ the one descriptor schema (omx-contract's) and writes it whole. `scan.py` maps an LV2 plugin into a
11
+ descriptor through lilv and writes it here; for CLAP, `clap-qualify --json` reads the plugin and
12
+ `descriptors.py clap` maps its scan document. A descriptor the schema refuses is never written:
13
+ it is refused with omx-contract's code, `contract.file.invalid`, and the scan goes on.
14
+
15
+ A scan writes only what the plugin states. What it does not state is `unknown` (a unit, a scale,
16
+ a meaning), or absent; a fact of the format that has no descriptor word is carried verbatim in
17
+ `extensions.<format>` under the field the format names it by.
18
+
19
+ Usage:
20
+ clap-qualify --json <path.clap>... | descriptors.py clap [--descriptor-dir DIR | --system] [-]
21
+ descriptors.py clap [--descriptor-dir DIR | --system] scan.json
22
+ """
23
+ import argparse
24
+ import hashlib
25
+ import json
26
+ import os
27
+ import re
28
+ import sys
29
+
30
+ from _declarations import (
31
+ DESCRIPTOR_CODE_BINARY_UNHASHED,
32
+ DESCRIPTOR_CODE_DIR_UNWRITABLE,
33
+ DESCRIPTOR_CODE_SCHEMA,
34
+ DESCRIPTOR_CODE_SCHEMA_MISSING,
35
+ DESCRIPTOR_DIR_STATE_SUBDIR,
36
+ DESCRIPTOR_DIR_SYSTEM,
37
+ DESCRIPTOR_HASH_PREFIX,
38
+ DESCRIPTOR_SCHEMA_ENV,
39
+ DESCRIPTOR_SCHEMA_FLOOR,
40
+ DESCRIPTOR_SCHEMA_INSTALLED,
41
+ )
42
+
43
+ SCHEMA_URL = "https://github.com/FreeMixer/omx-contract/raw/v3/schema/omx-contract.schema.json"
44
+ TOOLS = os.path.dirname(os.path.abspath(__file__))
45
+ PKG = os.path.dirname(TOOLS)
46
+
47
+ # the plugin features the descriptor has words for: CLAP's own names, LV2 classes mapped onto them
48
+ FEATURE_WORDS = ("audio-effect", "instrument", "note-effect", "analyzer")
49
+
50
+
51
+ class DescriptorRefused(Exception):
52
+ """A descriptor that is not written, with the code it is refused by."""
53
+
54
+ def __init__(self, code, problem):
55
+ super().__init__(f"{code}: {problem}")
56
+ self.code = code
57
+ self.problem = problem
58
+
59
+
60
+ # ---- the directory and the name -----------------------------------------------------------------
61
+
62
+ def descriptor_dir(named=None, system=False, env=None, home=None):
63
+ """`descriptor_dir`: the directory named, else a system service's, else the user's state directory."""
64
+ if named:
65
+ return named
66
+ if system:
67
+ return DESCRIPTOR_DIR_SYSTEM
68
+ env = os.environ if env is None else env
69
+ state = env.get("XDG_STATE_HOME") or os.path.join(home or os.path.expanduser("~"), ".local", "state")
70
+ return os.path.join(state, *DESCRIPTOR_DIR_STATE_SUBDIR.split("/"))
71
+
72
+
73
+ def _safe(text):
74
+ return re.sub(r"[^A-Za-z0-9._-]", "_", text)
75
+
76
+
77
+ def descriptor_file_name(plugin_id, version, sha256):
78
+ """`<id>@<version>-<sha256 first 12>.json`, the base-name rule of the qualification record."""
79
+ if not isinstance(sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", sha256):
80
+ raise DescriptorRefused(DESCRIPTOR_CODE_BINARY_UNHASHED, f"{plugin_id}: no SHA-256 of its binary")
81
+ return f"{_safe(plugin_id)}@{_safe(version or 'unversioned')}-{sha256[:DESCRIPTOR_HASH_PREFIX]}.json"
82
+
83
+
84
+ def file_sha256(path):
85
+ """The SHA-256 of a file's bytes, as plugin-hostd's pin.h computes it."""
86
+ h = hashlib.sha256()
87
+ try:
88
+ with open(path, "rb") as f:
89
+ for chunk in iter(lambda: f.read(1 << 20), b""):
90
+ h.update(chunk)
91
+ except OSError as e:
92
+ raise DescriptorRefused(DESCRIPTOR_CODE_BINARY_UNHASHED, f"{path}: {e.strerror}") from e
93
+ return h.hexdigest()
94
+
95
+
96
+ # ---- the schema ---------------------------------------------------------------------------------
97
+
98
+ def schema_candidates(env=None):
99
+ """Where the one schema is looked for, in order: the setting, this package's omx-contract, the installed one."""
100
+ env = os.environ if env is None else env
101
+ named = env.get(DESCRIPTOR_SCHEMA_ENV)
102
+ if named:
103
+ return [named]
104
+ rel = os.path.join("schema", "omx-contract.schema.json")
105
+ return [
106
+ os.path.join(PKG, "node_modules", "@openmixer", "omx-contract", rel), # a checkout
107
+ os.path.join(os.path.dirname(PKG), "omx-contract", rel), # installed beside this package
108
+ DESCRIPTOR_SCHEMA_INSTALLED,
109
+ ]
110
+
111
+
112
+ class SchemaValidator:
113
+ """omx-contract's schema, compiled once; `check` refuses a document it does not hold."""
114
+
115
+ def __init__(self, path=None, env=None):
116
+ found = [path] if path else [p for p in schema_candidates(env) if os.path.isfile(p)]
117
+ if not found or not os.path.isfile(found[0]):
118
+ raise DescriptorRefused(DESCRIPTOR_CODE_SCHEMA_MISSING,
119
+ f"no omx-contract schema at {', '.join(schema_candidates(env))}")
120
+ self.path = found[0]
121
+ try:
122
+ import jsonschema
123
+ except ImportError as e:
124
+ raise DescriptorRefused(DESCRIPTOR_CODE_SCHEMA_MISSING, "python3-jsonschema is not installed") from e
125
+ with open(self.path, encoding="utf-8") as f:
126
+ schema = json.load(f)
127
+ cls = jsonschema.validators.validator_for(schema)
128
+ self._validator = cls(schema)
129
+ self.version = schema.get("x-omx-schema-version")
130
+
131
+ def check(self, doc):
132
+ if not isinstance(doc, dict) or doc.get("origin") != "scanned":
133
+ raise DescriptorRefused(DESCRIPTOR_CODE_SCHEMA, "a scanned descriptor carries origin scanned")
134
+ errors = sorted(self._validator.iter_errors(doc), key=lambda e: list(e.absolute_path))
135
+ if errors:
136
+ e = errors[0]
137
+ where = "/".join(str(p) for p in e.absolute_path) or "(the file)"
138
+ raise DescriptorRefused(DESCRIPTOR_CODE_SCHEMA, f"{where}: {e.message}")
139
+
140
+
141
+ def write_descriptor(directory, doc, sha256, validator):
142
+ """Hold `doc` to the schema and write it whole into `directory`; the path written, or DescriptorRefused."""
143
+ validator.check(doc)
144
+ name = descriptor_file_name(doc["plugin"]["id"], doc["plugin"].get("version"), sha256)
145
+ try:
146
+ os.makedirs(directory, mode=0o700, exist_ok=True)
147
+ path = os.path.join(directory, name)
148
+ pending = path + ".new"
149
+ with open(pending, "w", encoding="utf-8") as f:
150
+ f.write(json.dumps(doc, indent=2, ensure_ascii=False) + "\n")
151
+ os.replace(pending, path)
152
+ except OSError as e:
153
+ raise DescriptorRefused(DESCRIPTOR_CODE_DIR_UNWRITABLE, f"{directory}: {e.strerror}") from e
154
+ return path
155
+
156
+
157
+ def write_all(directory, described, validator, err=sys.stderr):
158
+ """Write each `(doc, sha256)`; a refusal is reported by its code and the others go on. (written, refused)."""
159
+ written, refused = [], []
160
+ for doc, sha256 in described:
161
+ try:
162
+ written.append(write_descriptor(directory, doc, sha256, validator))
163
+ except DescriptorRefused as e:
164
+ pid = (doc.get("plugin") or {}).get("id", "?") if isinstance(doc, dict) else "?"
165
+ refused.append((pid, e.code, e.problem))
166
+ err.write(f"descriptor refused: {pid}: {e.code}: {e.problem}\n")
167
+ return written, refused
168
+
169
+
170
+ # ---- shared words -------------------------------------------------------------------------------
171
+
172
+ def head(fmt, plugin):
173
+ """The fields every scanned descriptor opens with."""
174
+ return {
175
+ "$schema": SCHEMA_URL,
176
+ "requires": {"omx-contract-schema": DESCRIPTOR_SCHEMA_FLOOR},
177
+ "origin": "scanned",
178
+ "plugin": {"format": fmt, **plugin},
179
+ }
180
+
181
+
182
+ def link_segment(text):
183
+ """A path segment or a selector key a link can hold; a character it cannot is written `_`."""
184
+ return re.sub(r"[^A-Za-z0-9_+.-]", "_", text) or "_"
185
+
186
+
187
+ def link_key(text):
188
+ """A link's selector key, `[<key>]`; a character it cannot hold is written `_`."""
189
+ return re.sub(r"[^A-Za-z0-9_.-]", "_", text) or "_"
190
+
191
+
192
+ def link_symbol(text):
193
+ """A link's symbol: an identifier, `_` for a character it cannot hold."""
194
+ s = re.sub(r"[^A-Za-z0-9_]", "_", text)
195
+ return s if re.match(r"[A-Za-z_]", s) else "_" + s
196
+
197
+
198
+ def one_line(text):
199
+ text = " ".join((text or "").split())
200
+ return text or None
201
+
202
+
203
+ def choice_ids(labels):
204
+ """A choice's ids, from its labels: their letters and digits, `v<n>` where that is empty or taken."""
205
+ ids, seen = [], set()
206
+ for n, label in enumerate(labels):
207
+ word = re.sub(r"[^A-Za-z0-9]", "", label)
208
+ word = word[:1].lower() + word[1:]
209
+ if not word or word in seen:
210
+ word = f"v{n}"
211
+ while word in seen:
212
+ word += "x"
213
+ seen.add(word)
214
+ ids.append(word)
215
+ return ids
216
+
217
+
218
+ def _number(v):
219
+ return isinstance(v, (int, float)) and not isinstance(v, bool) and v == v and abs(v) != float("inf")
220
+
221
+
222
+ # ---- CLAP: clap-qualify's scan document ---------------------------------------------------------
223
+
224
+ CLAP_DIALECTS = {"clap": "clap", "midi": "midi", "midi_mpe": "midiMpe", "midi2": "midi2"}
225
+ CLAP_LAYOUTS = {"mono": "mono", "stereo": "stereo", "surround": "surround"}
226
+
227
+
228
+ def clap_control(p, entry):
229
+ flags = set(p.get("flags", []))
230
+ c = {"id": p["id"], "name": p["name"] or str(p["id"])}
231
+ labels = p.get("labels") or []
232
+ if "enum" in flags and labels:
233
+ ids = choice_ids([lb["label"] for lb in labels])
234
+ c["kind"] = "choice"
235
+ c["ids"] = ids
236
+ if all(lb["label"] for lb in labels):
237
+ c["labels"] = [lb["label"] for lb in labels]
238
+ c["values"] = [lb["value"] for lb in labels]
239
+ for i, lb in enumerate(labels):
240
+ if lb["value"] == p.get("default"):
241
+ c["default"] = ids[i]
242
+ else:
243
+ c["kind"] = "travel"
244
+ travel = {
245
+ "min": p["min"],
246
+ "max": p["max"],
247
+ "unit": "unknown",
248
+ "scale": "stepped" if "stepped" in flags else "unknown",
249
+ }
250
+ if "stepped" in flags:
251
+ travel["step"] = 1
252
+ if _number(p.get("default")):
253
+ travel["default"] = p["default"]
254
+ c["travel"] = travel
255
+ if p.get("module"):
256
+ group = [g for g in p["module"].split("/") if g]
257
+ if group:
258
+ c["group"] = group
259
+ c["meaning"] = "bypass" if "bypass" in flags else "unknown"
260
+ for flag in ("hidden", "readonly", "automatable"):
261
+ if flag in flags:
262
+ c[flag] = True
263
+ c["source"] = {"from": f"{entry}[{p['id']}]"}
264
+ return c
265
+
266
+
267
+ def open_bound(p):
268
+ """A parameter the plugin leaves unbounded: no travel holds it, so it is carried verbatim instead."""
269
+ labels = p.get("labels") or []
270
+ return not ("enum" in p.get("flags", []) and labels) and not (_number(p.get("min")) and _number(p.get("max")))
271
+
272
+
273
+ def clap_ports(p):
274
+ ports = []
275
+ for direction, key in (("in", "inputs"), ("out", "outputs")):
276
+ for port in p["audio_ports"][key]:
277
+ q = {"id": port["id"], "name": port["name"] or f"audio {direction} {port['id']}",
278
+ "kind": "audio", "direction": direction, "role": port["role"]}
279
+ if port.get("channels", 0) >= 1:
280
+ q["channels"] = port["channels"]
281
+ layout = CLAP_LAYOUTS.get(port.get("port_type") or "")
282
+ if layout:
283
+ q["layout"] = layout
284
+ ports.append(q)
285
+ notes = p.get("note_port_info")
286
+ for direction, key in (("in", "inputs"), ("out", "outputs")):
287
+ for port in (notes or {}).get(key, []):
288
+ q = {"id": port["id"], "name": port["name"] or f"notes {direction} {port['id']}",
289
+ "kind": "note", "direction": direction}
290
+ dialects = [CLAP_DIALECTS[d] for d in port.get("dialects", []) if d in CLAP_DIALECTS]
291
+ if dialects:
292
+ q["dialects"] = dialects
293
+ ports.append(q)
294
+ return ports
295
+
296
+
297
+ def clap_descriptor(p, path):
298
+ """One plugin of clap-qualify's scan document as a scanned descriptor."""
299
+ entry = f"{link_segment(os.path.basename(path))}#clap_entry[{link_key(p['id'])}]"
300
+ plugin = {"id": p["id"], "name": p["name"] or p["id"]}
301
+ if p.get("vendor"):
302
+ plugin["vendor"] = p["vendor"]
303
+ if p.get("version"):
304
+ plugin["version"] = p["version"]
305
+ doc = one_line(p.get("description"))
306
+ if doc:
307
+ plugin["doc"] = doc
308
+ stated = [f for f in p.get("features", []) if f in FEATURE_WORDS]
309
+ plugin["features"] = list(dict.fromkeys(stated)) or ["audio-effect"]
310
+ d = head("clap", plugin)
311
+ params = p.get("params", [])
312
+ d["controls"] = [clap_control(q, entry) for q in params if not open_bound(q)]
313
+ d["ports"] = clap_ports(p)
314
+ if "latency_extension" in p:
315
+ d["latency"] = {"reports": bool(p["latency_extension"])}
316
+ bag = {}
317
+ if p.get("features"):
318
+ bag["clap_plugin_descriptor.features"] = list(p["features"])
319
+ if p.get("url"):
320
+ bag["clap_plugin_descriptor.url"] = p["url"]
321
+ if "latency" in p:
322
+ bag["clap_plugin_latency.get"] = p["latency"]
323
+ unbounded = [q for q in params if open_bound(q)]
324
+ if unbounded:
325
+ bag["clap_plugin_params.get_info"] = unbounded
326
+ if p.get("remote_controls"):
327
+ bag["clap_plugin_remote_controls.get"] = p["remote_controls"]
328
+ if bag:
329
+ d["extensions"] = {"clap": bag}
330
+ d["source"] = {"from": entry}
331
+ return d
332
+
333
+
334
+ def clap_described(scan, err=sys.stderr):
335
+ """Every plugin of a clap-qualify scan document as `(doc, sha256)`; a file that held none is reported."""
336
+ if not isinstance(scan, dict) or not isinstance(scan.get("files"), list):
337
+ raise DescriptorRefused(DESCRIPTOR_CODE_SCHEMA, "not a CLAP scan document")
338
+ out = []
339
+ for f in scan["files"]:
340
+ if "plugins" not in f:
341
+ err.write(f"not described: {f.get('path')}: {f.get('error')}\n")
342
+ continue
343
+ for p in f["plugins"]:
344
+ if "error" in p:
345
+ err.write(f"not described: {p.get('id')}: {p['error']}\n")
346
+ continue
347
+ out.append((clap_descriptor(p, f["path"]), f.get("sha256")))
348
+ return out
349
+
350
+
351
+ def main(argv=None):
352
+ ap = argparse.ArgumentParser(description="Write the scanned descriptors of a CLAP scan document.")
353
+ ap.add_argument("format", choices=["clap"], help="the scan document's format")
354
+ ap.add_argument("scan", nargs="?", default="-", help="clap-qualify --json output (default: stdin)")
355
+ where = ap.add_mutually_exclusive_group()
356
+ where.add_argument("--descriptor-dir", help="where the descriptors go (descriptor_dir)")
357
+ where.add_argument("--system", action="store_true", help=f"a system service's directory, {DESCRIPTOR_DIR_SYSTEM}")
358
+ # intermixed: `clap --descriptor-dir DIR -` puts the scan after an option, and parse_args on Python 3.11
359
+ # and 3.12.3 (Debian bookworm, Ubuntu noble) fills the optional positional with its default first and
360
+ # then refuses the `-` as unrecognized; later interpreters accept it. Intermixed parsing reads it on all.
361
+ args = ap.parse_intermixed_args(argv)
362
+ try:
363
+ text = sys.stdin.read() if args.scan == "-" else open(args.scan, encoding="utf-8").read()
364
+ scan = json.loads(text)
365
+ except (OSError, ValueError) as e:
366
+ sys.stderr.write(f"cannot read the scan document: {e}\n")
367
+ return 2
368
+ try:
369
+ validator = SchemaValidator()
370
+ described = clap_described(scan)
371
+ except DescriptorRefused as e:
372
+ sys.stderr.write(f"{e.code}: {e.problem}\n")
373
+ return 2
374
+ directory = descriptor_dir(args.descriptor_dir, args.system)
375
+ written, refused = write_all(directory, described, validator)
376
+ for path in written:
377
+ print(path)
378
+ return 2 if refused else 0
379
+
380
+
381
+ if __name__ == "__main__":
382
+ sys.exit(main())
@@ -35,9 +35,21 @@ const PY_NAMES = [
35
35
  'PLUGIN_TIMEOUT_S',
36
36
  'BOOT_TIMEOUT_S',
37
37
  'COST_TIMEOUT_S',
38
+ 'DESCRIPTOR_DIR_KEY',
39
+ 'DESCRIPTOR_DIR_STATE_SUBDIR',
40
+ 'DESCRIPTOR_DIR_SYSTEM',
41
+ 'DESCRIPTOR_HASH_PREFIX',
42
+ 'DESCRIPTOR_SCHEMA_ENV',
43
+ 'DESCRIPTOR_SCHEMA_INSTALLED',
44
+ 'DESCRIPTOR_SCHEMA_FLOOR',
45
+ 'DESCRIPTOR_CODE_SCHEMA',
46
+ 'DESCRIPTOR_CODE_SCHEMA_MISSING',
47
+ 'DESCRIPTOR_CODE_BINARY_UNHASHED',
48
+ 'DESCRIPTOR_CODE_DIR_UNWRITABLE',
38
49
  ];
39
50
 
40
- const py = (v) => (Array.isArray(v) ? `(${v.join(', ')})` : String(v));
51
+ /** A Python literal: a tuple, a string (JSON's quoting is Python's for these ASCII words) or a number. */
52
+ const py = (v) => (Array.isArray(v) ? `(${v.join(', ')})` : typeof v === 'string' ? JSON.stringify(v) : String(v));
41
53
 
42
54
  /** `decl` defaults to the built declarations; a test passes moved ones to prove the Python side follows. */
43
55
  export function declarationsFile(decl = D) {
package/tools/scan.py CHANGED
@@ -13,17 +13,25 @@ parameter are captured: classic LV2 **control ports** (set via mod-host
13
13
  `param_set <id> <symbol> <value>`) and LV2 **Patch properties** (`lv2:Parameter`
14
14
  flagged `patch:writable`, set via `patch_set <id> <uri> <value>`).
15
15
 
16
+ Beside the catalog, every plugin scanned is written as a scanned descriptor (omx-contract's
17
+ descriptor words, `descriptors.py`) into `descriptor_dir`: `--descriptor-dir`, else `--system`'s
18
+ directory, else `$XDG_STATE_HOME/omx/descriptors`, else `~/.local/state/omx/descriptors`.
19
+
16
20
  Usage:
17
21
  scan.py # scan all installed plugins -> stdout JSON
18
22
  scan.py --out catalog.json # write to a file
19
23
  scan.py URI [URI ...] # scan only these URIs (faster, for tests)
20
24
  scan.py --annotate catalog.json # refresh only the DECLARED fields, in place
25
+ scan.py --descriptor-dir DIR # the scanned descriptors go here
26
+ scan.py --no-descriptors # the catalog only
21
27
  """
22
28
  import argparse
23
29
  import json
30
+ import os
24
31
  import subprocess
25
32
  import sys
26
33
 
34
+ import descriptors as desc
27
35
  from lv2_presets import read_presets
28
36
 
29
37
  LV2 = "http://lv2plug.in/ns/lv2core#"
@@ -36,6 +44,9 @@ PATCH = "http://lv2plug.in/ns/ext/patch#"
36
44
  ATOM = "http://lv2plug.in/ns/ext/atom#"
37
45
  RDFS = "http://www.w3.org/2000/01/rdf-schema#"
38
46
  MIDI = "http://lv2plug.in/ns/ext/midi#"
47
+ PG = "http://lv2plug.in/ns/ext/port-groups#"
48
+ PARAM = "http://lv2plug.in/ns/ext/parameters#"
49
+ TIME = "http://lv2plug.in/ns/ext/time#"
39
50
 
40
51
  # An output control port designated lv2:latency reports the plugin's own latency
41
52
  # (in frames) — the truth a host delay-compensates by, and what openmixer reads to
@@ -61,6 +72,30 @@ KNOWN_UNITS = {
61
72
  UNITS + "bpm": "BPM", UNITS + "midiNote": "note", UNITS + "frame": "fr",
62
73
  }
63
74
 
75
+ # LV2 unit URI -> the descriptor's unit word; any other unit, or none, is `unknown`
76
+ DESCRIPTOR_UNITS = {
77
+ UNITS + "db": "dB", UNITS + "hz": "Hz", UNITS + "s": "s", UNITS + "ms": "ms",
78
+ UNITS + "min": "min", UNITS + "pc": "%", UNITS + "semitone12TET": "st", UNITS + "cent": "cents",
79
+ UNITS + "degree": "deg", UNITS + "coef": "x", UNITS + "oct": "oct", UNITS + "bpm": "bpm",
80
+ UNITS + "midiNote": "midiNote", UNITS + "frame": "frames",
81
+ }
82
+
83
+ # lv2:designation -> the descriptor's meaning, where the meaning carries the LV2 term's name
84
+ DESIGNATED_MEANINGS = {
85
+ **{PARAM + m: m for m in ("gain", "threshold", "ratio", "attack", "release", "hold", "decay", "frequency",
86
+ "cutoffFrequency", "resonance", "bypass", "wetDryRatio", "delay")},
87
+ LV2 + "enabled": "enabled",
88
+ }
89
+
90
+ # LV2 classes -> the descriptor's plugin features; a plugin of none of them is an audio effect
91
+ CLASS_FEATURES = {
92
+ LV2 + "InstrumentPlugin": "instrument",
93
+ LV2 + "AnalyserPlugin": "analyzer",
94
+ LV2 + "MIDIPlugin": "note-effect",
95
+ }
96
+
97
+ NUMERIC_PATCH_RANGES = ("Float", "Double", "Int", "Long")
98
+
64
99
 
65
100
  def fnum(node):
66
101
  """float(node) tolerant of None and integer/string-typed nodes.
@@ -80,11 +115,23 @@ def fnum(node):
80
115
  return None
81
116
 
82
117
 
118
+ def each(nodes):
119
+ """Every element of a lilv collection (None is empty), walked by the binding's own begin, is_end and next,
120
+ which every version has: the Python iteration of some does not hold for every collection."""
121
+ if nodes is None:
122
+ return []
123
+ if isinstance(nodes, (list, tuple)):
124
+ return list(nodes)
125
+ out = []
126
+ it = nodes.begin()
127
+ while not it.is_end():
128
+ out.append(it.next())
129
+ return out
130
+
131
+
83
132
  def first(nodes):
84
133
  """First element of a lilv Nodes collection, or None."""
85
- if nodes is None:
86
- return None
87
- for n in nodes:
134
+ for n in each(nodes):
88
135
  return n
89
136
  return None
90
137
 
@@ -144,6 +191,8 @@ class Scanner:
144
191
  RDFS + "label", RDFS + "range", MIDI + "MidiEvent",
145
192
  LV2 + "designation", LATENCY, REPORTS_LATENCY,
146
193
  MINIMUM, MAXIMUM, DEFAULT,
194
+ LV2 + "isSideChain", LV2 + "name", PG + "group", TIME + "Position",
195
+ "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
147
196
  )}
148
197
 
149
198
  def is_a(self, port, uri):
@@ -306,6 +355,195 @@ class Scanner:
306
355
  out["owningRpm"] = owning_rpm(bp)
307
356
  return out
308
357
 
358
+ # ---- the scanned descriptor ----
359
+
360
+ def designation_meaning(self, port):
361
+ """The meaning an lv2:designation names; `unknown` with none; absent (None) for one with no word."""
362
+ des = [str(d) for d in each(port.get_value(self.u[LV2 + "designation"]))]
363
+ if not des:
364
+ return "unknown"
365
+ for d in des:
366
+ if d in DESIGNATED_MEANINGS:
367
+ return DESIGNATED_MEANINGS[d]
368
+ return None
369
+
370
+ def port_group(self, port):
371
+ """The pg:group a port belongs to, by its name, as a one-step group path."""
372
+ g = first(port.get_value(self.u[PG + "group"]))
373
+ if g is None:
374
+ return None
375
+ label = first(self.w.find_nodes(g, self.u[LV2 + "name"], None)) or first(self.w.find_nodes(g, self.u[RDFS + "label"], None))
376
+ return [str(label)] if label is not None and str(label) else None
377
+
378
+ def descriptor_unit(self, port):
379
+ un = first(port.get_value(self.u[UNITS + "unit"]))
380
+ return DESCRIPTOR_UNITS.get(str(un), "unknown") if un is not None else "unknown"
381
+
382
+ def descriptor_control(self, port, link, readonly=False):
383
+ """An input (or, readonly, an output) control port as a descriptor control."""
384
+ d, mn, mx = port.get_range()
385
+ mnf, mxf, df = fnum(mn), fnum(mx), fnum(d)
386
+ sym = str(port.get_symbol())
387
+ nm = port.get_name()
388
+ c = {"id": sym, "name": str(nm) if nm is not None else sym}
389
+ sps = self.scale_points(port)
390
+ if self.has(port, LV2 + "toggled"):
391
+ c["kind"] = "toggle"
392
+ if df is not None:
393
+ c["default"] = df > 0
394
+ elif self.has(port, PP + "trigger"):
395
+ c["kind"] = "trigger"
396
+ elif self.has(port, LV2 + "enumeration") and sps:
397
+ ids = desc.choice_ids([sp["label"] for sp in sps])
398
+ c["kind"] = "choice"
399
+ c["ids"] = ids
400
+ if all(sp["label"] for sp in sps):
401
+ c["labels"] = [sp["label"] for sp in sps]
402
+ c["values"] = [sp["value"] for sp in sps]
403
+ for i, sp in enumerate(sps):
404
+ if df is not None and sp["value"] == df:
405
+ c["default"] = ids[i]
406
+ else:
407
+ # an LV2 port's range defaults to 0..1 where it declares none (the catalog row's own rule)
408
+ integer = self.has(port, LV2 + "integer")
409
+ travel = {
410
+ "min": mnf if mnf is not None else 0.0,
411
+ "max": mxf if mxf is not None else 1.0,
412
+ "unit": self.descriptor_unit(port),
413
+ "scale": "log" if self.has(port, PP + "logarithmic") else "stepped" if integer else "linear",
414
+ }
415
+ if integer:
416
+ travel["step"] = 1
417
+ if df is not None:
418
+ travel["default"] = df
419
+ c["kind"] = "travel"
420
+ c["travel"] = travel
421
+ group = self.port_group(port)
422
+ if group:
423
+ c["group"] = group
424
+ meaning = self.designation_meaning(port)
425
+ if meaning is not None:
426
+ c["meaning"] = meaning
427
+ if self.has(port, PP + "notOnGUI"):
428
+ c["hidden"] = True
429
+ if readonly:
430
+ c["readonly"] = True
431
+ c["source"] = {"from": f"{link}#{desc.link_symbol(sym)}"}
432
+ return c
433
+
434
+ def descriptor(self, plugin):
435
+ """The plugin as a scanned descriptor, and the SHA-256 of its binary: `(doc, sha256)`."""
436
+ uri = str(plugin.get_uri())
437
+ bundle = bundle_path(plugin) or ""
438
+ lib = plugin.get_library_uri()
439
+ lib_path = lib.get_path() if lib is not None else None
440
+ try:
441
+ sha256 = desc.file_sha256(lib_path) if lib_path else None
442
+ except desc.DescriptorRefused:
443
+ sha256 = None # refused by its code when it is written: no name without the binary's hash
444
+ data = sorted(os.path.basename(str(u)) for u in each(plugin.get_data_uris()))
445
+ ttl = next((f for f in data if f != "manifest.ttl"), "manifest.ttl")
446
+ bundle_seg = desc.link_segment(os.path.basename(bundle))
447
+ link = f"{bundle_seg}/{desc.link_segment(ttl)}"
448
+
449
+ types = [str(t) for t in each(plugin.get_value(self.u["http://www.w3.org/1999/02/22-rdf-syntax-ns#type"]))]
450
+ features = [CLASS_FEATURES[t] for t in types if t in CLASS_FEATURES]
451
+ p = {"id": uri, "uri": uri, "name": str(plugin.get_name())}
452
+ author = plugin.get_author_name()
453
+ if author is not None and str(author):
454
+ p["vendor"] = str(author)
455
+ version = self.plugin_version(plugin)
456
+ if version is not None:
457
+ p["version"] = f"{version[0]}.{version[1]}"
458
+ comment = declared_comment(plugin, self.w)
459
+ if comment:
460
+ p["doc"] = comment
461
+ p["features"] = list(dict.fromkeys(features)) or ["audio-effect"]
462
+ d = desc.head("lv2", p)
463
+
464
+ controls, ports = [], []
465
+ latency_symbol, timed = None, False
466
+ for i in range(plugin.get_num_ports()):
467
+ port = plugin.get_port_by_index(i)
468
+ sym = str(port.get_symbol())
469
+ nm = port.get_name()
470
+ name = str(nm) if nm is not None else sym
471
+ direction = "in" if self.is_a(port, LV2 + "InputPort") else "out"
472
+ if self.is_a(port, LV2 + "AudioPort"):
473
+ ports.append({"id": sym, "name": name, "kind": "audio", "direction": direction,
474
+ "role": "sidechain" if self.has(port, LV2 + "isSideChain") else "main", "channels": 1})
475
+ elif self.is_a(port, LV2 + "CVPort"):
476
+ ports.append({"id": sym, "name": name, "kind": "cv", "direction": direction})
477
+ elif self.is_a(port, LV2 + "ControlPort"):
478
+ if direction == "out" and latency_symbol is None and self.is_latency_port(port):
479
+ latency_symbol = sym
480
+ continue
481
+ controls.append(self.descriptor_control(port, link, readonly=direction == "out"))
482
+ elif self.is_a(port, ATOM + "AtomPort"):
483
+ try:
484
+ midi = bool(port.supports_event(self.u[MIDI + "MidiEvent"]))
485
+ timed = timed or (direction == "in" and bool(port.supports_event(self.u[TIME + "Position"])))
486
+ except Exception: # noqa: BLE001
487
+ midi = False
488
+ if midi:
489
+ ports.append({"id": sym, "name": name, "kind": "midi", "direction": direction})
490
+
491
+ writable = []
492
+ for prm in self.patch_params(plugin):
493
+ writable.append({"uri": prm["uri"], "label": prm["name"], "range": prm["datatype"]})
494
+ if prm["datatype"] in NUMERIC_PATCH_RANGES and "min" in prm and "max" in prm:
495
+ travel = {"min": prm["min"], "max": prm["max"], "unit": "unknown", "scale": "linear"}
496
+ if "default" in prm:
497
+ travel["default"] = prm["default"]
498
+ controls.append({"id": desc.link_symbol(prm["symbol"]), "name": prm["name"], "kind": "travel",
499
+ "travel": travel, "meaning": "unknown",
500
+ "source": {"from": f"{link}#{desc.link_symbol(prm['symbol'])}"}})
501
+ d["controls"] = controls
502
+ d["ports"] = ports
503
+
504
+ needs = {}
505
+ if timed:
506
+ needs["tempo"] = needs["transport"] = True
507
+ required = [str(f) for f in each(plugin.get_required_features())]
508
+ optional = [str(f) for f in each(plugin.get_optional_features())]
509
+ ext = {}
510
+ if required:
511
+ ext["required"] = sorted(set(required))
512
+ if optional:
513
+ ext["optional"] = sorted(set(optional))
514
+ if ext:
515
+ needs["extensions"] = ext
516
+ if needs:
517
+ d["needs"] = needs
518
+ d["latency"] = {"reports": latency_symbol is not None}
519
+
520
+ bag = {"rdf:type": str(plugin.get_class().get_uri())}
521
+ if latency_symbol is not None:
522
+ bag["lv2:latency"] = latency_symbol
523
+ if writable:
524
+ bag["patch:writable"] = writable
525
+ d["extensions"] = {"lv2": bag}
526
+ lib_name = os.path.basename(lib_path) if lib_path else "manifest.ttl"
527
+ d["source"] = {"from": f"{bundle_seg}/{desc.link_segment(lib_name)}#lv2_descriptor"}
528
+ return d, sha256
529
+
530
+ def descriptors(self, uris=None):
531
+ """`(doc, sha256)` for every plugin `scan` would describe."""
532
+ plugins = self.w.get_all_plugins()
533
+ chosen = [plugins.get_by_uri(self.w.new_uri(u)) for u in uris] if uris else list(plugins)
534
+ out = []
535
+ for p in chosen:
536
+ if p is None:
537
+ continue
538
+ try:
539
+ out.append(self.descriptor(p))
540
+ except desc.DescriptorRefused as e:
541
+ sys.stderr.write(f"descriptor refused: {p.get_uri()}: {e.code}: {e.problem}\n")
542
+ except Exception as e: # noqa: BLE001 - one plugin's bad data never stops the scan
543
+ line = e.__traceback__.tb_next.tb_lineno if e.__traceback__ and e.__traceback__.tb_next else "?"
544
+ sys.stderr.write(f"not described: {p.get_uri()}: {type(e).__name__}: {e} (scan.py:{line})\n")
545
+ return out
546
+
309
547
  def plugin_version(self, plugin):
310
548
  """(minor, micro) from lv2:minorVersion / lv2:microVersion, or None.
311
549
 
@@ -395,6 +633,11 @@ def main():
395
633
  ap.add_argument("--out", help="write JSON here (default: stdout)")
396
634
  ap.add_argument("--annotate", metavar="FILE",
397
635
  help="refresh only the DECLARED fields of an existing catalog, in place")
636
+ where = ap.add_mutually_exclusive_group()
637
+ where.add_argument("--descriptor-dir", help="where the scanned descriptors go (descriptor_dir)")
638
+ where.add_argument("--system", action="store_true",
639
+ help=f"a system service's descriptor_dir, {desc.DESCRIPTOR_DIR_SYSTEM}")
640
+ where.add_argument("--no-descriptors", action="store_true", help="write the catalog only")
398
641
  args = ap.parse_args()
399
642
  if args.annotate:
400
643
  with open(args.annotate, encoding="utf-8") as f:
@@ -408,7 +651,18 @@ def main():
408
651
  f"annotated {len(catalog)} plugins in {args.annotate}: "
409
652
  f"{changed} declared fields changed, {absent} not installed here\n")
410
653
  return
411
- catalog = Scanner().scan(args.uris or None)
654
+ scanner = Scanner()
655
+ catalog = scanner.scan(args.uris or None)
656
+ refused = 0
657
+ if not args.no_descriptors:
658
+ try:
659
+ validator = desc.SchemaValidator()
660
+ except desc.DescriptorRefused as e:
661
+ sys.exit(f"{e.code}: {e.problem}")
662
+ directory = desc.descriptor_dir(args.descriptor_dir, args.system)
663
+ written, bad = desc.write_all(directory, scanner.descriptors(args.uris or None), validator)
664
+ refused = len(bad)
665
+ sys.stderr.write(f"wrote {len(written)} descriptors -> {directory}\n")
412
666
  text = json.dumps(catalog, indent=2, ensure_ascii=False)
413
667
  if args.out:
414
668
  with open(args.out, "w", encoding="utf-8") as f:
@@ -426,6 +680,8 @@ def main():
426
680
  sys.stderr.write(f"wrote {len(uris)} URIs -> {uri_path}\n")
427
681
  else:
428
682
  print(text)
683
+ if refused:
684
+ sys.exit(2)
429
685
 
430
686
 
431
687
  if __name__ == "__main__":