@openmixer/plugin-qualify 0.14.1 → 0.16.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 (88) hide show
  1. package/README.md +127 -14
  2. package/bin/plugin-qualify-sync.mjs +57 -0
  3. package/bin/plugin-qualify.mjs +66 -15
  4. package/dist/adjust-cli.d.ts +83 -0
  5. package/dist/adjust-cli.js +214 -0
  6. package/dist/adjust-cli.js.map +1 -0
  7. package/dist/clap-loader.d.ts +3 -3
  8. package/dist/clap-loader.js +11 -11
  9. package/dist/clap-loader.js.map +1 -1
  10. package/dist/history-adjust.d.ts +77 -0
  11. package/dist/history-adjust.js +99 -0
  12. package/dist/history-adjust.js.map +1 -0
  13. package/dist/history-file.d.ts +73 -0
  14. package/dist/history-file.js +133 -0
  15. package/dist/history-file.js.map +1 -0
  16. package/dist/host-admission.d.ts +53 -0
  17. package/dist/host-admission.js +96 -0
  18. package/dist/host-admission.js.map +1 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +6 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/lv2-loader.d.ts +16 -2
  23. package/dist/lv2-loader.js +168 -17
  24. package/dist/lv2-loader.js.map +1 -1
  25. package/dist/measurement/cli.d.ts +6 -4
  26. package/dist/measurement/cli.js +12 -19
  27. package/dist/measurement/cli.js.map +1 -1
  28. package/dist/pins-file.d.ts +8 -2
  29. package/dist/pins-file.js +17 -3
  30. package/dist/pins-file.js.map +1 -1
  31. package/dist/probe-judge.d.ts +11 -1
  32. package/dist/probe-judge.js +20 -1
  33. package/dist/probe-judge.js.map +1 -1
  34. package/dist/probe-output-fixtures.d.ts +10 -0
  35. package/dist/probe-output-fixtures.js +86 -0
  36. package/dist/probe-output-fixtures.js.map +1 -1
  37. package/dist/probe-output.d.ts +2 -0
  38. package/dist/probe-output.js +1 -0
  39. package/dist/probe-output.js.map +1 -1
  40. package/dist/probe-runner.d.ts +9 -3
  41. package/dist/probe-runner.js +36 -12
  42. package/dist/probe-runner.js.map +1 -1
  43. package/dist/qualification-record.d.ts +109 -3
  44. package/dist/qualification-record.js +79 -13
  45. package/dist/qualification-record.js.map +1 -1
  46. package/dist/qualify-cli.d.ts +0 -1
  47. package/dist/qualify-cli.js +2 -4
  48. package/dist/qualify-cli.js.map +1 -1
  49. package/dist/qualify-declarations.d.ts +8 -1
  50. package/dist/qualify-declarations.js +8 -1
  51. package/dist/qualify-declarations.js.map +1 -1
  52. package/dist/qualify-options.js +1 -2
  53. package/dist/qualify-options.js.map +1 -1
  54. package/dist/qualify-sync.d.ts +215 -0
  55. package/dist/qualify-sync.js +794 -0
  56. package/dist/qualify-sync.js.map +1 -0
  57. package/dist/whole-file.d.ts +3 -0
  58. package/dist/whole-file.js +36 -0
  59. package/dist/whole-file.js.map +1 -0
  60. package/fixtures/admission/2026-10-26.json +19 -0
  61. package/fixtures/clap-scan/fake.json +24 -24
  62. package/package.json +5 -4
  63. package/src/adjust-cli.ts +262 -0
  64. package/src/clap-loader.ts +9 -13
  65. package/src/history-adjust.ts +156 -0
  66. package/src/history-file.ts +177 -0
  67. package/src/host-admission.ts +136 -0
  68. package/src/index.ts +6 -0
  69. package/src/lv2-loader.ts +150 -15
  70. package/src/measurement/cli.ts +11 -17
  71. package/src/pins-file.ts +17 -3
  72. package/src/probe-judge.ts +27 -1
  73. package/src/probe-output-fixtures.ts +86 -0
  74. package/src/probe-output.ts +3 -0
  75. package/src/probe-runner.ts +45 -14
  76. package/src/qualification-record.ts +174 -15
  77. package/src/qualify-cli.ts +2 -5
  78. package/src/qualify-declarations.ts +9 -1
  79. package/src/qualify-options.ts +1 -2
  80. package/src/qualify-sync.ts +889 -0
  81. package/src/whole-file.ts +35 -0
  82. package/tools/_declarations.py +3 -1
  83. package/tools/ai-trace-commit-check.sh +26 -14
  84. package/tools/ai-trace-patterns.json +10 -2
  85. package/tools/gen-hosting-codes.mjs +1 -1
  86. package/tools/gen-python-declarations.mjs +2 -0
  87. package/tools/install-roundtrip.sh +173 -0
  88. package/tools/mod-host-admit.mjs +74 -0
package/README.md CHANGED
@@ -7,22 +7,20 @@ Licence: GPL-3.0-or-later (`LICENSE`).
7
7
 
8
8
  ## Install
9
9
 
10
- plugin-qualify is published, signed, in the FreeMixer package channel, for Fedora 44 (x86_64, aarch64)
10
+ plugin-qualify is published, signed, in the OpenMixer package channel, for Fedora 44 (x86_64, aarch64)
11
11
  and for Debian bookworm and trixie, including Raspberry Pi OS (amd64, arm64).
12
12
 
13
13
  Fedora:
14
14
 
15
15
  ```
16
- sudo dnf config-manager addrepo --from-repofile=https://freemixer.github.io/rpm/freemixer.repo
16
+ sudo dnf install https://www.openmixer.org/rpm/openmixer-release.noarch.rpm
17
17
  sudo dnf install plugin-qualify
18
18
  ```
19
19
 
20
20
  Debian and Raspberry Pi OS:
21
21
 
22
22
  ```
23
- sudo install -d /etc/apt/keyrings
24
- sudo curl -fsSL -o /etc/apt/keyrings/freemixer.asc https://freemixer.github.io/deb/freemixer.asc
25
- echo "deb [signed-by=/etc/apt/keyrings/freemixer.asc] https://freemixer.github.io/deb/debian/$(. /etc/os-release && echo $VERSION_CODENAME) ./" | sudo tee /etc/apt/sources.list.d/freemixer.list
23
+ curl -fsSLO https://www.openmixer.org/deb/openmixer-release.deb && sudo apt install ./openmixer-release.deb
26
24
  sudo apt update
27
25
  sudo apt install plugin-qualify
28
26
  ```
@@ -59,13 +57,12 @@ them. The commands below are written as `plugin-qualify`; from a checkout run th
59
57
  ```
60
58
  plugin-qualify urn:example:my-plugin # one plugin, by URI
61
59
  plugin-qualify ./my-plugin.lv2 # every plugin in a bundle
62
- plugin-qualify --all # every CLAP on the search path; refuses if LV2 bundles are on LV2_PATH
60
+ plugin-qualify --all --out ./qualification # every LV2 bundle on LV2_PATH and every CLAP on the search path, one record each
63
61
  plugin-qualify --help # every option and its default
64
62
  ```
65
63
 
66
64
  `--format json` prints the verdicts as JSON; `--out <dir>` writes the full measurement document.
67
- `--isolate` measures each plugin in a fresh worker so a crash or hang takes only that plugin;
68
- `--no-host-sweep` measures offline only, without mod-host. The `Containerfile` builds an image
65
+ `--isolate` measures each plugin in a fresh worker so a crash or hang takes only that plugin. The `Containerfile` builds an image
69
66
  with lilv, mod-host and the ASan mod-host, if you do not want to install them yourself.
70
67
 
71
68
  A named LV2 plugin is measured the same way as a CLAP one: through the probe runner, then judged from the probe
@@ -151,12 +148,54 @@ declares (CLAP `voice-info`, basis `declared`), or, when it declares none, the c
151
148
  (basis `probe`; LV2 declares none). Each rate's figure says how many notes it held and why (`voices: { held,
152
149
  basis, capacity }`). An instrument with no count to hold is not timed (`voices-unknown`).
153
150
 
151
+ An LV2 plugin's record holds the same two figures, under the same field names, in `measurements.grid[]`:
152
+ `latency { declaredFrames, measuredFrames }` and `cost`. `lv2_measure` takes them at the run's rate and block, the
153
+ way the CLAP prober does: the declared figure is what the plugin reports on its latency port
154
+ (`lv2:designation lv2:latency`, or the older `lv2:reportsLatency`; 0 when it has none), the measured one is where an
155
+ impulse at frame 0 lands (the first frame at or above 1e-3 of the response's own peak), or, for an instrument, where a
156
+ struck A4 sounds. A plugin that has no latency port is measured, never read as 0. An instrument's cost holds
157
+ `OMX_PROBE_NOTES` keys down (the LV2 counterpart of `--probe-notes`). A figure the run could not take (a response that
158
+ stays silent, an instrument with no count to hold) is left out of the grid point and named in
159
+ `measurements.unmeasured` with its hosting code (`hosting.latency.unmeasured`,
160
+ `hosting.cost.unmeasured-at-rate-and-quantum`).
161
+
154
162
 
155
163
  The prober also scans: `OMX_CLAP_SCAN=native/build/clap-qualify` makes it the scanner, and its scan
156
164
  keeps what a console needs to draw a third-party plugin's controls: an enum parameter's labels (as
157
165
  the plugin's `value_to_text` renders them), each parameter's module, and the plugin's
158
166
  remote-control pages.
159
167
 
168
+ ## Adjusting a verdict by what the plugin did on this machine
169
+
170
+ ```
171
+ plugin-qualify adjust --history ~/.local/state/plugin-hostd/history --qualification-dir ./qualification --host-profile omx-clap-host
172
+ plugin-qualify adjust --history history --qualification-dir ./qualification --host-class aarch64/4/2400 --pins pins.txt
173
+ ```
174
+
175
+ plugin-hostd keeps a running history of every plugin binary on every host class it ran on (windows run alone and in a
176
+ group, CPU, xrun windows, crashes, restarts, quarantines). `adjust` reads that file directly, so it runs without a daemon,
177
+ and writes a second verdict beside the measured one in each qualification record: a `history` block per host class of the
178
+ file, holding the line it looked for (the plugin's plugin-hostd uri and the digest of the first file of its pin), whether
179
+ the history has it, the windows it counts, and for each `--host-profile` the measured pin verdict, the adjusted one, the
180
+ move (`demote`, `promote` or `none`) and a reason code. The measured `verdicts` and the measurement are never changed: the
181
+ same plugin on another host class has another history and another adjusted verdict.
182
+
183
+ | move | when | reason |
184
+ |---|---|---|
185
+ | `in-process` to `isolated` | a crash was counted within 30 days, on this host class | `history.crashed` |
186
+ | `in-process` to `isolated` | from 30 grouped windows, the share of xrun windows grouped exceeds the share alone by 5 points | `history.xruns-grouped` |
187
+ | `isolated` to `refused` | three or more solo crashes, the latest within 30 days | `history.crashed-alone` |
188
+ | `isolated` to `in-process` | every reason against the plugin is a `hosting.cost.*` one, 360 windows run alone (six hours), no crash or restart ever counted, no xruns raised when grouped, `cpu_p95_solo` under the host profile's ceiling | `history.steady` |
189
+ | none | no line for this binary on this class | `history.no-record` |
190
+ | none | a line, and no rule moves the verdict | `history.no-change` |
191
+ | none | the line would promote, but the verdict is a fact about the plugin: a real-time-safety, feature, stability, latency or topology reason, an `unknown`, or a `refused` | `history.not-liftable`, with `blockedBy` |
192
+
193
+ History never lifts a real-time-safety, feature or unknown verdict, and never promotes a refused one; a demotion is one
194
+ step. The thresholds are one table, `HISTORY_POLICY` in `src/history-adjust.ts`. `--pins <file>` writes the pins with the
195
+ adjusted verdict of the first `--host-profile` instead of the measured one; it needs exactly one host class, so name one
196
+ with `--host-class` when the history holds several. `--now <seconds>` fixes the time the history is read at. A line of the
197
+ history outside its grammar is not read and is named on stderr; a history of a version this tool does not know is refused.
198
+
160
199
  ## LV2 plugins: the hosting measurement
161
200
 
162
201
  `tools/lv2-measure.mjs` measures the hosting dimensions (`stability`, `rtSafety`, `features`) through
@@ -270,12 +309,27 @@ plugin-qualify --host-profile jalv,mod-host,openmixer-console <uri> # one verdi
270
309
  plugin-qualify --host-profile ./my-host.json <uri> # your own host
271
310
  ```
272
311
 
273
- | profile | isolation | instruments | LV2 features read from | cost budget (P95 of a core, rate / quantum) |
274
- |---|---|---|---|---|
275
- | `openmixer-console` (default) | in-process (the console's RT thread) | no | the console's in-process table | 0.05 at 96000 / 1024 |
276
- | `jalv` | per-process | yes | jalv 1.6.8 `src/jalv.c` (zynthian's fork passes the same list) | 0.25 at 48000 / 256 |
277
- | `mod-host` | shared-process | yes | mod-host f14a230 `src/effects.c` | 0.05 at 48000 / 128 |
278
- | `omx-clap-host` | shared-process | yes | the console's in-process table (the same LV2 feature provider) | 0.05 at 48000 / 128 |
312
+ | profile | isolation | verdict | instruments | LV2 features read from | cost budget (P95 of a core, rate / quantum) |
313
+ |---|---|---|---|---|---|
314
+ | `openmixer-console` (default) | in-process (the console's RT thread) | judged from a declaration | no | the console's in-process table | 0.05 at 96000 / 1024 |
315
+ | `jalv` | per-process | **measured by a run** (`jalv -n <name> <uri>`, headless) | yes | jalv 1.6.8 `src/jalv.c` (zynthian's fork passes the same list) | 0.25 at 48000 / 256 |
316
+ | `mod-host` | shared-process | **measured by a run** (`add`, `activate`, `remove` on mod-host's socket) | yes | mod-host f14a230 `src/effects.c` | 0.05 at 48000 / 128 |
317
+ | `omx-clap-host` | shared-process | judged from a declaration | yes | the console's in-process table (the same LV2 feature provider) | 0.05 at 48000 / 128 |
318
+
319
+ **Measured or declared.** A *declared* verdict is what the profile's feature list and cost budget make of
320
+ the one probe output. A *measured* verdict (`jalv`, `mod-host`, LV2 plugins) also runs the plugin under that
321
+ host, one admission per plugin, and its record entry carries `arm: "run"` and `measured: true`. A refusal
322
+ (`resp -102`), a crash (the signal), a host that dies on `add`, or a port type the host does not know makes the
323
+ plugin `unsuitable` for that host, with the code as the deciding reason (`mod-host.resp-102`,
324
+ `mod-host.signal-SIGABRT`, `mod-host.died-on-add`, `jalv.unknown-port-type`, `jalv.signal-SIGABRT`). A host
325
+ that is not installed leaves the verdict unmeasured (`unknown`, deciding `<host>.not-installed`). After any
326
+ death on mod-host the next admission starts a fresh host. `plugin-qualify judge` reads a probe output
327
+ that holds no run, so its `jalv` and `mod-host` entries stay `arm: "declaration"`.
328
+
329
+ **The console's two tiers.** Every record carries `tiers`: the *in-process* verdict (`openmixer-console`) and the
330
+ *isolated* verdict (`mod-host` for LV2, `omx-clap-host` for CLAP), `offeredOn` (the tiers whose verdict qualified)
331
+ and, when neither did, `dimmed` with the reason. A plugin that kills the isolated host and qualifies in-process is
332
+ offered in-process only. The tiers are judged for every plugin whether or not `--host-profile` names them.
279
333
 
280
334
  `omx-clap-host` offers a CLAP plugin the console's six host extensions and three more,
281
335
  `clap.preset-load/2`, `clap.track-info/1` and `clap.remote-controls/2`. A CLAP plugin judged
@@ -350,6 +404,65 @@ a last word: the SHA-256 of every file the host loads, the parameter-layout fing
350
404
  file in plugin-hostd's settings as `pin_file` and an `add` of that plugin needs no `pin_set`; a changed file is refused
351
405
  with -509. The verdict is the first `--host-profile`'s. A run that took no pin leaves an existing file alone.
352
406
 
407
+ ### Qualifying every plugin on a machine
408
+
409
+ `plugin-qualify-sync` qualifies every plugin installed on the machine, and again each one whose files
410
+ change. With its pin file in plugin-hostd's settings, the daemon runs in its default mode (`require_pins 1`)
411
+ and chains any plugin that qualified, the image's own and any installed later:
412
+
413
+ ```
414
+ pin_file /var/lib/omx/qualified.pins # in plugin-hostd.conf
415
+ ```
416
+
417
+ The package ships it as a service that runs two minutes after boot and whenever a plugin directory
418
+ changes, once the directory has been quiet for a minute. A run finds every `.clap` under the CLAP
419
+ directories and every plugin of every bundle under the LV2 ones, and hashes each one's files as
420
+ plugin-hostd does for a pin. A plugin whose files hash as recorded is skipped. A new or changed one gets the
421
+ quick qualification through the probe runner: its pin, the RT-safety sweep, cost and latency, its
422
+ qualification record and its descriptor. The soak is not part of it, and every record it writes says so in
423
+ `notMeasured` (`hosting.stability.unsoaked`).
424
+
425
+ Each plugin has `budget_s` seconds. One that crashes or hangs is recorded as `crashed` or `hung`, one that
426
+ fails the sweep is pinned `verdict=refused`, and the run goes on to the next. The pin file and the state are
427
+ written whole after every plugin, so a run cut short keeps what it finished. The last line counts the run:
428
+
429
+ ```
430
+ qualified 2, skipped 0, refused 0, crashed 1, hung 1, unmeasured 0, 4.6 s
431
+ ```
432
+
433
+ A plugin that is no longer found, or whose bytes changed, leaves nothing behind: its pin, its qualification
434
+ record (with the probe output copied beside it) and its descriptor are removed. Records the sync did not write
435
+ stay, and nothing is removed in a run that could not read a bundle, so a half-written install is no uninstall.
436
+ A running plugin-hostd is told with `pin_clear`.
437
+
438
+ When plugin-hostd answers on `hostd_port`, the changed pins are also sent to it with `pin_set` and apply at
439
+ once. plugin-hostd serves one controller at a time, so while one is connected the run says so and the pins
440
+ apply at the daemon's next start.
441
+
442
+ The settings are `/etc/omx/qualify.conf`, one `key value` per line, and each is also a flag
443
+ (`--budget-s 30`):
444
+
445
+ | key | default |
446
+ | --- | --- |
447
+ | `lv2_path` | `$LV2_PATH`, else plugin-hostd's: `/usr/lib64/lv2:/usr/lib/lv2:/usr/local/lib/lv2` |
448
+ | `lv2_extra` | `/zynthian/zynthian-plugins/lv2:/zynthian/zynthian-my-data/plugins/lv2` |
449
+ | `clap_path`, `clap_extra` | `$CLAP_PATH`, then `~/.clap`, then `/usr/lib/clap`; none |
450
+ | `pin_file` | `/var/lib/omx/qualified.pins` |
451
+ | `qualification_dir`, `descriptor_dir` | `/var/lib/omx/qualification`, `/var/lib/omx/descriptors` |
452
+ | `state_file` | `/var/lib/omx/qualify-sync.json` |
453
+ | `budget_s` | `60` |
454
+ | `host_profile` | `omx-clap-host` |
455
+ | `hostd_port`, `hostd_feedback_port` | `5555`, the next port |
456
+
457
+ Those are the service's (`--system`); run by a user, the files go under `$XDG_STATE_HOME/omx`. An LV2
458
+ directory added here belongs in plugin-hostd's `lv2_path` too, or the daemon cannot find the bundle it
459
+ pinned. `--force` qualifies everything again.
460
+
461
+ The path unit names the standard folders (`/usr/lib/lv2`, `/usr/lib64/lv2`, `/usr/local/lib/lv2`,
462
+ `/usr/lib/clap`, `/usr/lib64/clap`). The folders the settings file adds (`lv2_path`, `lv2_extra`, `clap_path`,
463
+ `clap_extra`) are written into a drop-in of the unit by a systemd generator at boot and at every
464
+ `systemctl daemon-reload`, so the file is the one place a machine's extra folders are written.
465
+
353
466
  ### The container image
354
467
 
355
468
  ```
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
4
+ /**
5
+ * `plugin-qualify-sync`, the entry point. Every decision is in `src/qualify-sync.ts`; this file reads argv and the
6
+ * settings file, runs one sync, prints its summary line and exits. The exit is 0 whenever the run went through,
7
+ * whatever the plugins' verdicts: a refused plugin is a result, not a failure of the service.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+
11
+ import { SYNC_CONF, parseSyncArgs, parseSyncConf, resolveSyncSettings, runSync, summaryLine, syncHelpText } from "../dist/index.js";
12
+
13
+ const log = (line) => process.stderr.write(`plugin-qualify-sync: ${line}\n`);
14
+
15
+ const parsed = parseSyncArgs(process.argv.slice(2));
16
+ if (!parsed.ok) {
17
+ log(parsed.error);
18
+ process.stderr.write(`\n${syncHelpText()}`);
19
+ process.exit(2);
20
+ }
21
+ if (parsed.args.help) {
22
+ process.stdout.write(syncHelpText());
23
+ process.exit(0);
24
+ }
25
+ const confPath = parsed.args.config ?? SYNC_CONF;
26
+ let confText = "";
27
+ try {
28
+ confText = readFileSync(confPath, "utf8");
29
+ } catch (e) {
30
+ // the defaults hold without a settings file; a named one that cannot be read is an error
31
+ if (parsed.args.config !== undefined || e.code !== "ENOENT") {
32
+ log(`cannot read ${confPath}: ${e.message}`);
33
+ process.exit(2);
34
+ }
35
+ }
36
+ const conf = parseSyncConf(confText, confPath);
37
+ if (!conf.ok) {
38
+ log(conf.error);
39
+ process.exit(2);
40
+ }
41
+ const resolved = resolveSyncSettings(conf.values, parsed.args, process.env);
42
+ if (!resolved.ok) {
43
+ log(resolved.error);
44
+ process.exit(2);
45
+ }
46
+ const s = resolved.settings;
47
+ log(`CLAP ${s.clapDirs.join(":")}; LV2 ${s.lv2Dirs.join(":")}; host ${s.hostProfile.id}; ${String(s.budgetS)} s a plugin`);
48
+ try {
49
+ const summary = await runSync(s, { force: parsed.args.force, log });
50
+ if (summary !== undefined) {
51
+ process.stdout.write(`${summaryLine(summary)}\n`);
52
+ log(`pins in ${s.pinFile}${summary.sent ? `, ${String(summary.sent)} sent to plugin-hostd` : ""}`);
53
+ }
54
+ } catch (e) {
55
+ log(`the run stopped: ${e.message}`);
56
+ process.exit(2);
57
+ }
@@ -8,13 +8,19 @@
8
8
  * Every named plugin, CLAP or LV2, is measured by plugin-hostd's probe runner (`plugin-hostd-probe`,
9
9
  * named by `OMX_PROBE_RUNNER` when it is not on `PATH`), which keeps the deadline and the limits, and is
10
10
  * judged from the probe output it writes: the verdict and the qualification record, per host profile.
11
- * `--all` finds the CLAP plugins on the standard CLAP search path (`CLAP_PATH`, then `~/.clap`, then
11
+ * `--all` finds the LV2 plugins of the bundles on the LV2 path, then the CLAP plugins on the standard CLAP search path (`CLAP_PATH`, then `~/.clap`, then
12
12
  * `/usr/lib/clap`). `plugin-qualify judge` judges probe outputs already written.
13
13
  */
14
- import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeSync } from "node:fs";
15
- import { dirname } from "node:path";
14
+ import { readFileSync } from "node:fs";
16
15
 
17
16
  import {
17
+ adjustHelpText,
18
+ adjustRecords,
19
+ adjustTable,
20
+ adjustedPinEntries,
21
+ parseAdjustArgs,
22
+ readRecords,
23
+ rewriteRecord,
18
24
  ClapScanError,
19
25
  EXIT_CODE,
20
26
  clapLoader,
@@ -22,6 +28,7 @@ import {
22
28
  defaultQualificationDir,
23
29
  judgeExitCode,
24
30
  judgeFiles,
31
+ mergeRunsByRecord,
25
32
  judgeHelpText,
26
33
  parseJudgeArgs,
27
34
  discoverAllTargets,
@@ -36,6 +43,7 @@ import {
36
43
  resolveTargets,
37
44
  writeQualification,
38
45
  textTable,
46
+ writeFileWhole,
39
47
  } from "../dist/index.js";
40
48
 
41
49
  /**
@@ -49,16 +57,7 @@ function writePins(path, file) {
49
57
  process.stderr.write(`plugin-qualify: no pin was taken, ${path} is left as it was\n`);
50
58
  return;
51
59
  }
52
- mkdirSync(dirname(path), { recursive: true });
53
- // the bytes on disk before the name moves, so a crash leaves the old file or the whole new one
54
- const fd = openSync(`${path}.new`, "w");
55
- try {
56
- writeSync(fd, file.text);
57
- fsyncSync(fd);
58
- } finally {
59
- closeSync(fd);
60
- }
61
- renameSync(`${path}.new`, path);
60
+ writeFileWhole(path, file.text);
62
61
  process.stderr.write(`plugin-qualify: ${file.written.length} pin${file.written.length === 1 ? "" : "s"} written to ${path}\n`);
63
62
  }
64
63
 
@@ -89,6 +88,57 @@ if (process.argv[2] === "judge") {
89
88
  process.exit(judgeExitCode(result));
90
89
  }
91
90
 
91
+ if (process.argv[2] === "adjust") {
92
+ const a = parseAdjustArgs(process.argv.slice(3));
93
+ if (!a.ok) {
94
+ process.stderr.write(`plugin-qualify adjust: ${a.error}\n\n${adjustHelpText()}`);
95
+ process.exit(EXIT_CODE.couldNotMeasure);
96
+ }
97
+ if (a.args.help) {
98
+ process.stdout.write(adjustHelpText());
99
+ process.exit(EXIT_CODE.allQualified);
100
+ }
101
+ const hosts = resolveHostProfiles(a.args.hostProfiles);
102
+ if (!hosts.ok) {
103
+ for (const r of hosts.refused) process.stderr.write(`plugin-qualify adjust: --host-profile ${r.spec}: ${r.problem}\n`);
104
+ process.exit(EXIT_CODE.couldNotMeasure);
105
+ }
106
+ let text;
107
+ try {
108
+ text = readFileSync(a.args.history, "utf8");
109
+ } catch (e) {
110
+ process.stderr.write(`plugin-qualify adjust: --history ${a.args.history}: cannot read: ${e.message}\n`);
111
+ process.exit(EXIT_CODE.couldNotMeasure);
112
+ }
113
+ const dir = a.args.qualificationDir ?? defaultQualificationDir(process.env);
114
+ const read = readRecords(dir);
115
+ for (const f of read.failures) process.stderr.write(`plugin-qualify adjust: ${f.file}: ${f.problem}\n`);
116
+ if (read.records.length === 0) {
117
+ process.stderr.write(`plugin-qualify adjust: no qualification record in ${dir}\n`);
118
+ process.exit(EXIT_CODE.couldNotMeasure);
119
+ }
120
+ const now = a.args.now ?? Math.floor(Date.now() / 1000);
121
+ const done = adjustRecords(read.records, text, { profiles: hosts.profiles, file: a.args.history, now, ...(a.args.hostClass === undefined ? {} : { hostClass: a.args.hostClass }) });
122
+ if (!done.ok) {
123
+ process.stderr.write(`plugin-qualify adjust: ${done.problem}\n`);
124
+ process.exit(EXIT_CODE.couldNotMeasure);
125
+ }
126
+ if (a.args.pins !== undefined && done.hostClasses.length !== 1) {
127
+ process.stderr.write(
128
+ `plugin-qualify adjust: --pins writes one adjusted verdict per plugin, and ${a.args.history} holds ${String(done.hostClasses.length)} host classes (${done.hostClasses.join(", ")}); name one with --host-class\n`,
129
+ );
130
+ process.exit(EXIT_CODE.couldNotMeasure);
131
+ }
132
+ for (const i of done.ignored) process.stderr.write(`plugin-qualify adjust: ${a.args.history}:${String(i.line)}: not read: ${i.problem}\n`);
133
+ for (const r of done.records) {
134
+ rewriteRecord(r.path, r.record);
135
+ process.stderr.write(`plugin-qualify adjust: record ${r.path}\n`);
136
+ }
137
+ if (a.args.pins !== undefined) writePins(a.args.pins, pinsFile(adjustedPinEntries(done.records, done.hostClasses[0], hosts.profiles[0].id)));
138
+ process.stdout.write(a.args.format === "json" ? `${JSON.stringify(done.records.map((r) => r.record), null, 2)}\n` : adjustTable(done.records));
139
+ process.exit(EXIT_CODE.allQualified);
140
+ }
141
+
92
142
  const parsed = parseQualifyArgs(process.argv.slice(2));
93
143
  if (!parsed.ok) {
94
144
  process.stderr.write(`plugin-qualify: ${parsed.error}\n\n${helpText()}`);
@@ -126,12 +176,13 @@ if (plugins.length === 0) {
126
176
  process.exit(EXIT_CODE.couldNotMeasure);
127
177
  }
128
178
  if (parsed.args.all) {
129
- process.stderr.write("plugin-qualify: --all qualified the CLAP plugins on the search path (no LV2 bundle is on the LV2 path)\n");
179
+ const lv2 = plugins.filter((p) => p.format === "lv2").length;
180
+ process.stderr.write(`plugin-qualify: --all qualifies ${String(lv2)} LV2 and ${String(plugins.length - lv2)} CLAP plugins on the search paths\n`);
130
181
  }
131
182
  const pass = qualifyTargets(plugins, { rates: parsed.args.rates, profiles: profiles.profiles });
132
183
  for (const f of pass.failures) process.stderr.write(`plugin-qualify: ${f.uri}: ${f.problem}\n`);
133
184
  if (parsed.args.out !== undefined) {
134
- for (const run of pass.runs) {
185
+ for (const run of mergeRunsByRecord(pass.runs)) {
135
186
  const w = writeQualification(parsed.args.out, run.bytes, run.judgment);
136
187
  process.stderr.write(`plugin-qualify: record ${w.recordPath}\n`);
137
188
  }
@@ -0,0 +1,83 @@
1
+ import { type HistoryRead, type HistoryRecord } from "./history-file.js";
2
+ import type { HostProfile } from "./host-profiles.js";
3
+ import { type PinEntry, type PinVerdict } from "./pins-file.js";
4
+ import { type HistoryBlock, type QualificationRecord } from "./qualification-record.js";
5
+ import { type OutputFormat } from "./qualify-options.js";
6
+ export interface AdjustArgs {
7
+ readonly history: string;
8
+ readonly hostProfiles: readonly string[];
9
+ readonly qualificationDir?: string;
10
+ readonly hostClass?: string;
11
+ readonly pins?: string;
12
+ /** UTC seconds the history is read at; the clock when absent. */
13
+ readonly now?: number;
14
+ readonly format: OutputFormat;
15
+ readonly help: boolean;
16
+ }
17
+ export type ParseAdjustResult = {
18
+ ok: true;
19
+ args: AdjustArgs;
20
+ } | {
21
+ ok: false;
22
+ error: string;
23
+ };
24
+ export declare function parseAdjustArgs(argv: readonly string[]): ParseAdjustResult;
25
+ export declare function adjustHelpText(): string;
26
+ /** A record read from the qualification directory, with the file it came from. */
27
+ export interface RecordFile {
28
+ readonly path: string;
29
+ readonly record: QualificationRecord;
30
+ }
31
+ /** Every `*.qualification.json` of the directory with this reader's schema; a file that does not read is a failure, and the others go on. */
32
+ export declare function readRecords(dir: string, read?: (path: string) => string): {
33
+ readonly records: readonly RecordFile[];
34
+ readonly failures: readonly {
35
+ readonly file: string;
36
+ readonly problem: string;
37
+ }[];
38
+ };
39
+ /** plugin-hostd's key of a record's plugin: its uri and the digest of the first file of its pin (files sorted by path, as the pin lists them). `undefined` when the record holds no whole pin. */
40
+ export declare function historyKeyOf(record: QualificationRecord): {
41
+ readonly uri: string;
42
+ readonly digest: string;
43
+ } | undefined;
44
+ /** The `history` block of one record for one host class. */
45
+ export declare function historyBlock(record: QualificationRecord, history: readonly HistoryRecord[], hostClass: string, profiles: readonly HostProfile[], options: {
46
+ file: string;
47
+ now: number;
48
+ }): HistoryBlock;
49
+ export interface AdjustedRecord {
50
+ readonly path: string;
51
+ readonly record: QualificationRecord;
52
+ }
53
+ export type AdjustResult = {
54
+ readonly ok: true;
55
+ readonly records: readonly AdjustedRecord[];
56
+ /** The host classes read. */
57
+ readonly hostClasses: readonly string[];
58
+ readonly ignored: Extract<HistoryRead, {
59
+ ok: true;
60
+ }>["ignored"];
61
+ } | {
62
+ readonly ok: false;
63
+ readonly problem: string;
64
+ };
65
+ /**
66
+ * Each record with its `history` block replaced by the blocks of the host classes read (an earlier block of a class not read
67
+ * is kept, so two histories of two classes can be applied one after the other). The measured fields are copied as they are.
68
+ */
69
+ export declare function adjustRecords(records: readonly RecordFile[], historyText: string, options: {
70
+ profiles: readonly HostProfile[];
71
+ file: string;
72
+ now: number;
73
+ hostClass?: string;
74
+ }): AdjustResult;
75
+ /** The adjusted verdict of a record's host for a host class: the pin verdict, `null` when it has none, `undefined` when the record holds no block for the pair. */
76
+ export declare function adjustedVerdictOf(record: QualificationRecord, hostClass: string, host: string): PinVerdict | null | undefined;
77
+ /**
78
+ * The pin entries of adjusted records for one host and one host class: the integrity is the record's, the verdict is the
79
+ * ADJUSTED one. A record with no verdict row for the host is pinned without a verdict word, as a run without one is.
80
+ */
81
+ export declare function adjustedPinEntries(records: readonly AdjustedRecord[], hostClass: string, host: string): PinEntry[];
82
+ /** One row per plugin, host and host class: the measured verdict, the adjusted one, the reason. */
83
+ export declare function adjustTable(records: readonly AdjustedRecord[]): string;