@openmixer/plugin-qualify 0.9.0 → 0.11.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 (81) hide show
  1. package/README.md +59 -9
  2. package/bin/plugin-qualify.mjs +62 -29
  3. package/dist/clap-loader.d.ts +1 -1
  4. package/dist/clap-loader.js +3 -3
  5. package/dist/clap-loader.js.map +1 -1
  6. package/dist/clap-qualify.d.ts +21 -45
  7. package/dist/clap-qualify.js +60 -82
  8. package/dist/clap-qualify.js.map +1 -1
  9. package/dist/cost-fields.d.ts +26 -0
  10. package/dist/cost-fields.js +30 -0
  11. package/dist/cost-fields.js.map +1 -1
  12. package/dist/host-profiles.d.ts +14 -0
  13. package/dist/host-profiles.js +27 -1
  14. package/dist/host-profiles.js.map +1 -1
  15. package/dist/hosting-codes.d.ts +6 -2
  16. package/dist/hosting-codes.js +9 -4
  17. package/dist/hosting-codes.js.map +1 -1
  18. package/dist/hosting-suitability.d.ts +43 -4
  19. package/dist/hosting-suitability.js +46 -8
  20. package/dist/hosting-suitability.js.map +1 -1
  21. package/dist/index.d.ts +5 -0
  22. package/dist/index.js +5 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/judge-cli.d.ts +41 -0
  25. package/dist/judge-cli.js +114 -0
  26. package/dist/judge-cli.js.map +1 -0
  27. package/dist/measure-host.js +2 -2
  28. package/dist/measurement/format.d.ts +1 -1
  29. package/dist/measurement/probe-json.js +1 -0
  30. package/dist/measurement/probe-json.js.map +1 -1
  31. package/dist/measurement/vocabulary.d.ts +1 -1
  32. package/dist/measurement/vocabulary.js +7 -1
  33. package/dist/measurement/vocabulary.js.map +1 -1
  34. package/dist/probe-judge.d.ts +60 -0
  35. package/dist/probe-judge.js +213 -0
  36. package/dist/probe-judge.js.map +1 -0
  37. package/dist/probe-output-fixtures.d.ts +25 -0
  38. package/dist/probe-output-fixtures.js +134 -0
  39. package/dist/probe-output-fixtures.js.map +1 -0
  40. package/dist/probe-output.d.ts +150 -0
  41. package/dist/probe-output.js +369 -0
  42. package/dist/probe-output.js.map +1 -0
  43. package/dist/probe-runner.d.ts +79 -0
  44. package/dist/probe-runner.js +191 -0
  45. package/dist/probe-runner.js.map +1 -0
  46. package/dist/qualification-record.d.ts +80 -0
  47. package/dist/qualification-record.js +85 -0
  48. package/dist/qualification-record.js.map +1 -0
  49. package/dist/qualify-cli.js +1 -1
  50. package/dist/qualify-cli.js.map +1 -1
  51. package/dist/qualify-declarations.d.ts +26 -6
  52. package/dist/qualify-declarations.js +25 -7
  53. package/dist/qualify-declarations.js.map +1 -1
  54. package/fixtures/clap-scan/fake.json +30 -6
  55. package/fixtures/probe-output/memory-limit-refused.json +190 -0
  56. package/fixtures/probe-output/plugin-crash.json +236 -0
  57. package/fixtures/probe-output/success-msi.json +363 -0
  58. package/package.json +7 -2
  59. package/src/clap-loader.ts +3 -2
  60. package/src/clap-qualify.ts +70 -124
  61. package/src/cost-fields.ts +39 -0
  62. package/src/host-profiles.ts +29 -1
  63. package/src/hosting-codes.ts +9 -4
  64. package/src/hosting-suitability.ts +82 -11
  65. package/src/index.ts +5 -0
  66. package/src/judge-cli.ts +133 -0
  67. package/src/measure-host.ts +2 -2
  68. package/src/measurement/format.ts +1 -1
  69. package/src/measurement/probe-json.ts +1 -0
  70. package/src/measurement/vocabulary.ts +8 -1
  71. package/src/probe-judge.ts +264 -0
  72. package/src/probe-output-fixtures.ts +146 -0
  73. package/src/probe-output.ts +505 -0
  74. package/src/probe-runner.ts +244 -0
  75. package/src/qualification-record.ts +142 -0
  76. package/src/qualify-cli.ts +1 -1
  77. package/src/qualify-declarations.ts +28 -8
  78. package/tools/ai-trace-commit-check.sh +114 -0
  79. package/tools/ai-trace-patterns.json +33 -0
  80. package/tools/benchmark.py +13 -1
  81. package/tools/private-refs-check.sh +90 -0
@@ -0,0 +1,244 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * Measuring through plugin-hostd's probe runner (`plugin-hostd-probe run`). The runner starts the
5
+ * prober under limits (a wall clock that kills the whole process tree, CPU time, memory, files),
6
+ * and writes the probe output; plugin-qualify waits for the runner and reads the document. No
7
+ * timeout of ours sits around it: the runner owns the deadline, and a node timer that signalled only
8
+ * the direct child is exactly what the runner replaces.
9
+ *
10
+ * The runner's exit says whether there is a document: 0 and 1 write one (1 is a run whose steps did
11
+ * not all end well, which the document records), 2 is no document (usage, a required limit
12
+ * unavailable, an output that could not be written), 3 is no such plugin.
13
+ */
14
+ import { spawnSync } from "node:child_process";
15
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ import { ClapQualifyError, clapQualifyTools, proberOffer, type RtSafetyDocument } from "./clap-qualify.js";
20
+ import type { HostProfile } from "./host-profiles.js";
21
+ import { OPENMIXER_CONSOLE_PROFILE } from "./host-profiles.js";
22
+ import type { PluginTarget } from "./loader.js";
23
+ import { judgeProbeOutput, judgmentVerdicts, type Judgment } from "./probe-judge.js";
24
+ import { readProbeOutput, type ProbeOutput } from "./probe-output.js";
25
+ import type { QualifyVerdict } from "./qualify-cli.js";
26
+ import {
27
+ CLAP_QUALIFY_TIMEOUT_S,
28
+ CLAP_SWEEP_BLOCKS,
29
+ CLAP_SWEEP_FRAMES,
30
+ LV2_HOSTING_BLOCK_FRAMES,
31
+ LV2_HOSTING_TIMEOUT_S,
32
+ PROBE_RUNNER_DEFAULT,
33
+ PROBE_RUNNER_ENV,
34
+ } from "./qualify-declarations.js";
35
+
36
+ export class ProbeRunnerError extends Error {
37
+ constructor(message: string) {
38
+ super(message);
39
+ this.name = "ProbeRunnerError";
40
+ }
41
+ }
42
+
43
+ /** The runner: the declared setting when set, else the declared default (on `PATH`). */
44
+ export function probeRunnerTool(env: Readonly<Record<string, string | undefined>>): string {
45
+ const named = env[PROBE_RUNNER_ENV];
46
+ return named === undefined || named === "" ? PROBE_RUNNER_DEFAULT : named;
47
+ }
48
+
49
+ /** What one run of the runner gave back; `error` is a spawn failure, `signal` a death. */
50
+ export interface ProbeRunResult {
51
+ readonly status: number | null;
52
+ readonly signal?: string | null;
53
+ readonly stderr?: string;
54
+ readonly error?: Error;
55
+ }
56
+
57
+ /** Runs the runner. It is given no time limit of its own, since the runner keeps the deadline. */
58
+ export type ProbeRun = (tool: string, args: readonly string[], env: Readonly<Record<string, string | undefined>>) => ProbeRunResult;
59
+
60
+ const spawnRunner: ProbeRun = (tool, args, env) => {
61
+ const r = spawnSync(tool, [...args], { encoding: "utf8", env: { ...env }, maxBuffer: 64 << 20 });
62
+ return { status: r.status, signal: r.signal, stderr: r.stderr ?? "", ...(r.error === undefined ? {} : { error: r.error }) };
63
+ };
64
+
65
+ export interface ProbeTargetOptions {
66
+ /** The rates the plugin is measured at. */
67
+ readonly rates: readonly number[];
68
+ /** Host extensions to offer beyond the prober's own (a CLAP run). */
69
+ readonly offer?: readonly string[];
70
+ readonly run?: ProbeRun;
71
+ readonly env?: Readonly<Record<string, string | undefined>>;
72
+ readonly exists?: (path: string) => boolean;
73
+ }
74
+
75
+ /** The runner's arguments for a CLAP file; `child` is the prober the runner starts. */
76
+ export function clapRunnerArgs(
77
+ options: { rates: readonly number[]; offer?: readonly string[]; out: string; log: string; child: string },
78
+ binaryPath: string,
79
+ id: string | undefined,
80
+ ): string[] {
81
+ return [
82
+ "run",
83
+ "--child",
84
+ options.child,
85
+ "--rates",
86
+ options.rates.join(","),
87
+ "--quantum",
88
+ String(CLAP_SWEEP_FRAMES),
89
+ "--blocks",
90
+ String(CLAP_SWEEP_BLOCKS),
91
+ ...(options.offer === undefined || options.offer.length === 0 ? [] : ["--offer", options.offer.join(",")]),
92
+ "--wall-s",
93
+ String(CLAP_QUALIFY_TIMEOUT_S),
94
+ "--out",
95
+ options.out,
96
+ "--log",
97
+ options.log,
98
+ binaryPath,
99
+ ...(id === undefined ? [] : [id]),
100
+ ];
101
+ }
102
+
103
+ /**
104
+ * The runner's arguments for an LV2 plugin, named as `lv2:<uri>` and measured by the runner's own LV2
105
+ * child. The runner of plugin-hostd 0.4.0 starts a CLAP prober only (the LV2 child, plugin-hostd#76, is in no release yet), so
106
+ * against it this run ends with the runner's own refusal, which {@link runOne} reports.
107
+ */
108
+ export function lv2RunnerArgs(options: { rates: readonly number[]; out: string; log: string }, uri: string): string[] {
109
+ return [
110
+ "run",
111
+ "--rates",
112
+ options.rates.join(","),
113
+ "--quantum",
114
+ String(LV2_HOSTING_BLOCK_FRAMES),
115
+ "--wall-s",
116
+ String(LV2_HOSTING_TIMEOUT_S),
117
+ "--out",
118
+ options.out,
119
+ "--log",
120
+ options.log,
121
+ `lv2:${uri}`,
122
+ ];
123
+ }
124
+
125
+ /** One probe output and the bytes it was read from (the bytes are what gets copied beside the record). */
126
+ export interface ProbedTarget {
127
+ readonly bytes: Buffer;
128
+ readonly output: ProbeOutput;
129
+ }
130
+
131
+ function runOne(target: PluginTarget, options: ProbeTargetOptions): ProbedTarget {
132
+ const env = options.env ?? process.env;
133
+ const tool = probeRunnerTool(env);
134
+ const work = mkdtempSync(join(tmpdir(), "plugin-qualify-probe-"));
135
+ const out = join(work, "probe.json");
136
+ const log = join(work, "child.stderr.log");
137
+ try {
138
+ let args: string[];
139
+ let runEnv: Record<string, string | undefined> = { ...env };
140
+ let expectedId: string;
141
+ if (target.format === "clap") {
142
+ if (target.binaryPath === undefined) throw new ProbeRunnerError(`${target.uri}: no .clap to load; name the file or a directory holding it`);
143
+ expectedId = target.uri.startsWith("urn:clap:") ? target.uri.slice("urn:clap:".length) : target.uri;
144
+ const { tool: child, interposer } = clapQualifyTools(env, options.exists ?? existsSync);
145
+ args = clapRunnerArgs({ rates: options.rates, ...(options.offer === undefined ? {} : { offer: options.offer }), out, log, child }, target.binaryPath, expectedId);
146
+ runEnv = { ...env, LD_PRELOAD: interposer };
147
+ } else {
148
+ expectedId = target.uri;
149
+ args = lv2RunnerArgs({ rates: options.rates, out, log }, target.uri);
150
+ }
151
+ const r = (options.run ?? spawnRunner)(tool, args, runEnv);
152
+ if (r.error !== undefined) {
153
+ throw new ProbeRunnerError(`cannot run the probe runner "${tool}" (${r.error.message}): install plugin-hostd or set ${PROBE_RUNNER_ENV} to plugin-hostd-probe`);
154
+ }
155
+ if (r.signal) throw new ProbeRunnerError(`${tool} was killed (${r.signal}) measuring ${target.uri}`);
156
+ const said = (r.stderr ?? "").trim();
157
+ if (r.status === 3) throw new ProbeRunnerError(`${tool}: no such plugin ${target.uri}${said === "" ? "" : `: ${said}`}`);
158
+ if (r.status !== 0 && r.status !== 1) {
159
+ throw new ProbeRunnerError(`${tool} exited ${String(r.status)} measuring ${target.uri}, and wrote no document${said === "" ? "" : `: ${said}`}`);
160
+ }
161
+ let bytes: Buffer;
162
+ try {
163
+ bytes = readFileSync(out);
164
+ } catch {
165
+ throw new ProbeRunnerError(`${tool} exited ${String(r.status)} but wrote no document at the path it was given`);
166
+ }
167
+ const read = readProbeOutput(bytes.toString("utf8"));
168
+ if (!read.ok) throw new ProbeRunnerError(`${tool} wrote a document that is refused (${read.code}): ${read.problem}`);
169
+ if (read.output.plugin.id !== expectedId) {
170
+ throw new ProbeRunnerError(`${tool} measured ${read.output.plugin.id} when asked for ${expectedId}: not judged as the plugin asked for`);
171
+ }
172
+ return { bytes, output: read.output };
173
+ } finally {
174
+ rmSync(work, { recursive: true, force: true });
175
+ }
176
+ }
177
+
178
+ /** The probe output of one target, through the runner. Throws {@link ProbeRunnerError} when there is none to judge. */
179
+ export function probeTarget(target: PluginTarget, options: ProbeTargetOptions): ProbedTarget {
180
+ return runOne(target, options);
181
+ }
182
+
183
+ /** What the pass over a run's targets produced. */
184
+ export interface ProbePass {
185
+ /** Every probe output that was judged, with its bytes, for the qualification directory. */
186
+ readonly runs: readonly { readonly bytes: Buffer; readonly judgment: Judgment }[];
187
+ readonly verdicts: readonly QualifyVerdict[];
188
+ /** The CLAP documents of today's judge, which the pins file is taken from. */
189
+ readonly documents: readonly RtSafetyDocument[];
190
+ /** Each target that could not be measured, with why; its verdict rows read `unknown`, unmeasured. */
191
+ readonly failures: readonly { readonly uri: string; readonly problem: string }[];
192
+ }
193
+
194
+ /**
195
+ * Measure every target through the runner, one run per target and set of offered extensions (a crash
196
+ * takes only that plugin), and judge each probe output under the profiles whose offer it was taken
197
+ * with. A CLAP profile the prober cannot stand in for is judged from the run with no offer and reads
198
+ * `unknown` there, as before. An LV2 plugin has one run for every profile.
199
+ */
200
+ export function qualifyTargets(
201
+ targets: readonly PluginTarget[],
202
+ options: ProbeTargetOptions & { readonly profiles?: readonly HostProfile[] },
203
+ ): ProbePass {
204
+ const profiles = options.profiles ?? [OPENMIXER_CONSOLE_PROFILE];
205
+ const runs: { bytes: Buffer; judgment: Judgment }[] = [];
206
+ const verdicts: QualifyVerdict[] = [];
207
+ const documents: RtSafetyDocument[] = [];
208
+ const failures: { uri: string; problem: string }[] = [];
209
+ for (const target of targets) {
210
+ const byOffer = new Map<string, { offer: readonly string[]; profiles: HostProfile[] }>();
211
+ for (const profile of profiles) {
212
+ const offer = target.format === "clap" ? (proberOffer(profile) ?? []) : [];
213
+ const key = offer.join(",");
214
+ const entry = byOffer.get(key) ?? { offer, profiles: [] };
215
+ entry.profiles.push(profile);
216
+ byOffer.set(key, entry);
217
+ }
218
+ const judged = new Map<string, QualifyVerdict>();
219
+ // a target's runs and documents count only when every one of its runs gave a probe output: one that
220
+ // failed leaves the target not measured, and then nothing of it is recorded or pinned
221
+ const targetRuns: { bytes: Buffer; judgment: Judgment }[] = [];
222
+ try {
223
+ for (const { offer, profiles: forOffer } of byOffer.values()) {
224
+ const probed = probeTarget(target, { ...options, offer });
225
+ const judgment = judgeProbeOutput(probed.output, forOffer);
226
+ targetRuns.push({ bytes: probed.bytes, judgment });
227
+ for (const v of judgmentVerdicts(judgment)) judged.set(v.host, v);
228
+ }
229
+ } catch (e) {
230
+ if (!(e instanceof ProbeRunnerError) && !(e instanceof ClapQualifyError)) throw e;
231
+ failures.push({ uri: target.uri, problem: e.message });
232
+ for (const profile of profiles) {
233
+ verdicts.push({ uri: target.uri, host: profile.id, measured: false, rating: "unknown", qualified: false, deciding: "not-measured" });
234
+ }
235
+ continue;
236
+ }
237
+ for (const r of targetRuns) {
238
+ runs.push(r);
239
+ documents.push(...r.judgment.documents);
240
+ }
241
+ for (const profile of profiles) verdicts.push(judged.get(profile.id)!);
242
+ }
243
+ return { runs, verdicts, documents, failures };
244
+ }
@@ -0,0 +1,142 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The qualification record: what plugin-qualify wrote down about one plugin binary and one offer
5
+ * after judging its probe output, kept in `qualification_dir`. Evidence, not a constraint: no
6
+ * reader refuses a descriptor because of it.
7
+ *
8
+ * Two files, always together. The record is the judgment; beside it is a byte copy of the probe
9
+ * output it judged, because the probe directory is runtime and may be wiped. The record names the
10
+ * copy and the SHA-256 of the copy's bytes, so the facts outlive the run without hostd keeping a
11
+ * second store, and a reader can tell that the figures it holds are the ones the verdict came from.
12
+ *
13
+ * Not yet in the record: the fingerprint of the descriptor's controls, since the descriptor is
14
+ * written by the scan (stage 5) and plugin-qualify does not read one yet.
15
+ */
16
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ import type { Judgment, RecordReason } from "./probe-judge.js";
20
+ import { sha256Hex, type ProbeGridPoint, type ProbeOutput } from "./probe-output.js";
21
+
22
+ export const QUALIFICATION_RECORD_SCHEMA = "plugin-qualify.qualification-record/1";
23
+
24
+ /** One host's verdict in the record. */
25
+ export interface RecordVerdict {
26
+ readonly host: string;
27
+ /** False when the run did not finish: the rating is `unknown` and the reason is the run's outcome. */
28
+ readonly measured: boolean;
29
+ readonly rating: string;
30
+ readonly qualified: boolean;
31
+ readonly hosting: "in-process" | "isolated";
32
+ /** The code that decided it; absent for `suitable`. */
33
+ readonly deciding?: string;
34
+ readonly reasons: readonly RecordReason[];
35
+ }
36
+
37
+ export interface QualificationRecord {
38
+ readonly schema: typeof QUALIFICATION_RECORD_SCHEMA;
39
+ readonly plugin: { readonly format: string; readonly id: string; readonly name?: string };
40
+ /** The plugin's version when it was tested; a newer installed one is shown as "tested on X, installed Y". */
41
+ readonly testedVersion?: string;
42
+ readonly binary: ProbeOutput["binary"];
43
+ readonly verdicts: readonly RecordVerdict[];
44
+ /** The cost reasons at each host's rate and quantum, beside the verdicts. */
45
+ readonly cost: readonly { readonly host: string; readonly rate: number; readonly quantum: number; readonly reasons: readonly RecordReason[] }[];
46
+ /** The figures as measured, per rate and quantum. */
47
+ readonly measurements: { readonly grid: readonly ProbeGridPoint[]; readonly behaviour?: Readonly<Record<string, unknown>> };
48
+ /** Who measured and when. */
49
+ readonly tool: Readonly<Record<string, string>>;
50
+ readonly measuredAt: string;
51
+ /** The probe output this record judged. */
52
+ readonly probe: {
53
+ readonly file: string;
54
+ readonly sha256: string;
55
+ readonly bytes: number;
56
+ readonly protocol: { readonly name: string; readonly version: number };
57
+ readonly reason?: string;
58
+ readonly outcome: string;
59
+ readonly complete: boolean;
60
+ readonly machineClass: Readonly<Record<string, string | boolean>>;
61
+ /** Whether the machine met the protocol's optimised profile: a figure that did not is not a reference figure. */
62
+ readonly meetsProtocol: boolean;
63
+ };
64
+ }
65
+
66
+ const safe = (text: string): string => text.replace(/[^A-Za-z0-9._-]/g, "_");
67
+
68
+ /** `<id>@<version>-<sha256 first 12>.<offer>`: one binary and one offer, whatever the run. */
69
+ export function qualificationBaseName(output: ProbeOutput): string {
70
+ return `${safe(output.plugin.id)}@${safe(output.plugin.version ?? "unversioned")}-${output.binary.sha256.slice(0, 12)}.${safe(output.offer.id)}`;
71
+ }
72
+
73
+ export const recordFileName = (output: ProbeOutput): string => `${qualificationBaseName(output)}.qualification.json`;
74
+ export const probeCopyFileName = (output: ProbeOutput): string => `${qualificationBaseName(output)}.probe.json`;
75
+
76
+ export function buildQualificationRecord(judgment: Judgment, copy: { file: string; sha256: string; bytes: number }): QualificationRecord {
77
+ const o = judgment.output;
78
+ return {
79
+ schema: QUALIFICATION_RECORD_SCHEMA,
80
+ plugin: { format: o.plugin.format, id: o.plugin.id, ...(o.plugin.name === undefined ? {} : { name: o.plugin.name }) },
81
+ ...(o.plugin.version === undefined ? {} : { testedVersion: o.plugin.version }),
82
+ binary: o.binary,
83
+ verdicts: judgment.hosts.map((h) => ({
84
+ host: h.verdict.host,
85
+ measured: h.verdict.measured,
86
+ rating: h.verdict.rating,
87
+ qualified: h.verdict.qualified,
88
+ hosting: h.hosting,
89
+ ...(h.verdict.deciding === undefined ? {} : { deciding: h.verdict.deciding }),
90
+ reasons: h.reasons,
91
+ })),
92
+ cost: judgment.hosts.map((h) => ({ host: h.verdict.host, ...h.cost })),
93
+ measurements: { grid: o.grid, ...(o.behaviour === undefined ? {} : { behaviour: o.behaviour }) },
94
+ tool: o.run.tools,
95
+ measuredAt: o.run.startedAt,
96
+ probe: {
97
+ file: copy.file,
98
+ sha256: copy.sha256,
99
+ bytes: copy.bytes,
100
+ protocol: o.run.protocol,
101
+ ...(o.run.reason === undefined ? {} : { reason: o.run.reason }),
102
+ outcome: o.run.outcome,
103
+ complete: o.run.complete,
104
+ machineClass: o.machine.class,
105
+ meetsProtocol: o.machine.profile.meetsProtocol,
106
+ },
107
+ };
108
+ }
109
+
110
+ /** The record of a judgment, with the copy fields taken from the bytes that were judged. */
111
+ export function qualificationRecordOf(probeBytes: Uint8Array, judgment: Judgment): QualificationRecord {
112
+ return buildQualificationRecord(judgment, { file: probeCopyFileName(judgment.output), sha256: sha256Hex(probeBytes), bytes: probeBytes.length });
113
+ }
114
+
115
+ /** A file whole or not at all: written beside its name and renamed over it. */
116
+ function writeWhole(path: string, bytes: string | Uint8Array): void {
117
+ const pending = `${path}.new`;
118
+ writeFileSync(pending, bytes);
119
+ renameSync(pending, path);
120
+ }
121
+
122
+ export interface WrittenQualification {
123
+ readonly recordPath: string;
124
+ readonly probePath: string;
125
+ readonly record: QualificationRecord;
126
+ }
127
+
128
+ /**
129
+ * Write the byte copy of the probe output, then the record that names it. The copy goes first, and the
130
+ * record names the SHA-256 of the copy as it is on disk, read back, so a record never names bytes that
131
+ * are not there. A rescan of the same binary and offer replaces both.
132
+ */
133
+ export function writeQualification(dir: string, probeBytes: Uint8Array, judgment: Judgment): WrittenQualification {
134
+ mkdirSync(dir, { recursive: true });
135
+ const probePath = join(dir, probeCopyFileName(judgment.output));
136
+ writeWhole(probePath, probeBytes);
137
+ const copy = readFileSync(probePath);
138
+ const record = buildQualificationRecord(judgment, { file: probeCopyFileName(judgment.output), sha256: sha256Hex(copy), bytes: copy.length });
139
+ const recordPath = join(dir, recordFileName(judgment.output));
140
+ writeWhole(recordPath, `${JSON.stringify(record, null, 2)}\n`);
141
+ return { recordPath, probePath, record };
142
+ }
@@ -118,7 +118,7 @@ export function helpText(): string {
118
118
  " plugin-qualify <uri|bundle-path>... exactly the named plugins; a .lv2 bundle = every plugin in it",
119
119
  " plugin-qualify --all every CLAP plugin on CLAP_PATH, then ~/.clap, then /usr/lib/clap;",
120
120
  " refuses (exit 2) when LV2 bundles are on the LV2 path (LV2_PATH),",
121
- " which this entry point cannot measure",
121
+ " whose plugins --all does not enumerate: name them instead",
122
122
  "",
123
123
  ...rows,
124
124
  "",
@@ -1,5 +1,7 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-or-later
2
2
  // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ import { ORACLE_FLOOR_RATES, RT_HARD_TARGET } from "@openmixer/omx-contract";
4
+
3
5
  /**
4
6
  * EVERY NUMBER plugin-qualify uses, declared once. The CLI's defaults and `--help`, the rating, and the Python tools (through the generated
5
7
  * `tools/_declarations.py`) all READ these; a consumer holding its own copy is what the package's
@@ -26,11 +28,11 @@ export const MEASURE_BASE_RATE = 48000;
26
28
  */
27
29
  export const OPERATIONAL_RATE_FLOOR = 48000;
28
30
 
29
- /** The rates a run measures when `--rates` is not given — recorded in `run.rates`. */
30
- export const QUALIFY_RATES: readonly number[] = [44100, MEASURE_BASE_RATE, COST_REFERENCE_RATE, 192000];
31
+ /** The rates a run measures when `--rates` is not given — recorded in `run.rates`: omx-contract's ORACLE_FLOOR_RATES. */
32
+ export const QUALIFY_RATES: readonly number[] = ORACLE_FLOOR_RATES;
31
33
 
32
- /** The quanta (frames) a run rates cost at when `--quanta` is not given. */
33
- export const QUALIFY_QUANTA: readonly number[] = [128, 256, 512, QUANTUM_FRAMES];
34
+ /** The quanta (frames) a run rates cost at when `--quanta` is not given: the contract's hard-target quantum, then 256, 512 and the reference. */
35
+ export const QUALIFY_QUANTA: readonly number[] = [RT_HARD_TARGET.quantum, 256, 512, QUANTUM_FRAMES];
34
36
 
35
37
  /** Seconds one plugin's offline pass may take before its worker is killed and it reads as a timeout. */
36
38
  export const PLUGIN_TIMEOUT_S = 15;
@@ -77,23 +79,37 @@ export const CLAP_SCAN_TOOL_DEFAULT = "omx-clap-scan";
77
79
  export const CLAP_PATH_ENV = "CLAP_PATH";
78
80
  export const CLAP_USER_DIR = ".clap";
79
81
  export const CLAP_SYSTEM_DIR = "/usr/lib/clap";
82
+ /** Names a directory to read in place of `CLAP_SYSTEM_DIR`: a package root, or a test that must not see what the host installed there. */
83
+ export const CLAP_SYSTEM_DIR_ENV = "OMX_CLAP_SYSTEM_DIR";
84
+
85
+ /**
86
+ * The ONE setting that locates plugin-hostd's probe runner: the environment variable that names the
87
+ * `plugin-hostd-probe` binary, and what is run when it is unset (looked up on `PATH`). The runner is
88
+ * plugin-hostd's, not a tool of this package, so it is not one of `NATIVE_TOOLS`.
89
+ */
90
+ export const PROBE_RUNNER_ENV = "OMX_PROBE_RUNNER";
91
+ export const PROBE_RUNNER_DEFAULT = "plugin-hostd-probe";
80
92
 
81
93
  /** Seconds one run of the CLAP scanner may take over everything it was pointed at. */
82
94
  export const CLAP_SCAN_TIMEOUT_S = 300;
83
95
 
84
96
  /**
85
- * The CLAP prober (`native/clap-qualify.c`), the LV2 measurer (`native/lv2-measure.c`) and the
86
- * interposer both run under (`native/rt-interpose.c`): each located by one environment variable, else
87
- * the package's own build in `native/build/`, else the first of `NATIVE_TOOL_DIRS` that holds it.
97
+ * The CLAP prober (`native/clap-qualify.c`), the LV2 measurer (`native/lv2-measure.c`), the
98
+ * interposer both run under (`native/rt-interpose.c`) and the LV2 host probe
99
+ * (`native/lv2-host-probe.c`, a worker plugin driven through lilv as a host drives it, for a console
100
+ * that exports an LV2 plugin to test its host-facing half): each located by one environment variable,
101
+ * else the package's own build in `native/build/`, else the first of `NATIVE_TOOL_DIRS` that holds it.
88
102
  */
89
103
  export const CLAP_QUALIFY_TOOL_ENV = "OMX_CLAP_QUALIFY";
90
104
  export const LV2_MEASURE_TOOL_ENV = "OMX_LV2_MEASURE";
91
105
  export const RT_INTERPOSE_ENV = "OMX_RT_INTERPOSE";
106
+ export const LV2_HOST_PROBE_TOOL_ENV = "OMX_LV2_HOST_PROBE";
92
107
 
93
108
  /** The file names of the native tools, in every place they are looked for. */
94
109
  export const CLAP_QUALIFY_TOOL_NAME = "clap-qualify";
95
110
  export const LV2_MEASURE_TOOL_NAME = "lv2_measure";
96
111
  export const RT_INTERPOSE_NAME = "rt_interpose.so";
112
+ export const LV2_HOST_PROBE_TOOL_NAME = "lv2_host_probe";
97
113
 
98
114
  /**
99
115
  * The ONE list of the native tools the packages install, each with the setting that names it.
@@ -103,6 +119,7 @@ export const NATIVE_TOOLS: readonly { readonly name: string; readonly env: strin
103
119
  { name: CLAP_QUALIFY_TOOL_NAME, env: CLAP_QUALIFY_TOOL_ENV },
104
120
  { name: LV2_MEASURE_TOOL_NAME, env: LV2_MEASURE_TOOL_ENV },
105
121
  { name: RT_INTERPOSE_NAME, env: RT_INTERPOSE_ENV },
122
+ { name: LV2_HOST_PROBE_TOOL_NAME, env: LV2_HOST_PROBE_TOOL_ENV },
106
123
  ];
107
124
 
108
125
  /**
@@ -162,7 +179,7 @@ export const DEFAULT_HOSTING_POLICY: HostingPolicy = {
162
179
  soakDeadDropDb: 40,
163
180
  };
164
181
 
165
- /** What a host spends on one plugin instance (`2026-09-25-plugin-qualify.md` §3a) — the
182
+ /** What a host spends on one plugin instance — the
166
183
  * interchange's shape, since a document carries each profile's budget. */
167
184
  import type { HostBudget } from "./measurement/format.js";
168
185
  export type { HostBudget };
@@ -173,10 +190,13 @@ export type { HostBudget };
173
190
  * so it gets the same per-instance share, at 48 kHz / 128 frames. `jalv` gives each plugin its own
174
191
  * process and client, so a quarter core per instance, at 48 kHz / 256 frames. The last two are
175
192
  * declared defaults, not a measurement of anyone's board — a project brings its own profile file.
193
+ * `omx-clap-host` runs its plugins one JACK client each, called in turn on the server's one cycle,
194
+ * so it gets mod-host's share and pair.
176
195
  */
177
196
  export const HOST_BUDGETS = {
178
197
  "openmixer-console": { coreFractionCeiling: 0.05, rate: COST_REFERENCE_RATE, quantum: QUANTUM_FRAMES },
179
198
  jalv: { coreFractionCeiling: 0.25, rate: MEASURE_BASE_RATE, quantum: 256 },
180
199
  "mod-host": { coreFractionCeiling: 0.05, rate: MEASURE_BASE_RATE, quantum: 128 },
200
+ "omx-clap-host": { coreFractionCeiling: 0.05, rate: MEASURE_BASE_RATE, quantum: 128 },
181
201
  } as const satisfies Readonly<Record<string, HostBudget>>;
182
202
 
@@ -0,0 +1,114 @@
1
+ #!/bin/bash
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+ # Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
4
+ #
5
+ # ai-trace-commit-check.sh: fail when a commit message carries an AI-attribution line.
6
+ #
7
+ # ai-trace-commit-check.sh <range> every commit in the range (e.g. origin/main..HEAD)
8
+ # ai-trace-commit-check.sh --msg-file FILE one message (the commit-msg hook; # lines are git comments, skipped)
9
+ # ai-trace-commit-check.sh --text-file FILE any text, every line judged (pull request titles and bodies)
10
+ # ai-trace-commit-check.sh --selftest fixtures plus a sabotage run
11
+ #
12
+ # Exit 0 clean, 1 a hit (each commit and offending line is named), 2 a range, file or pattern list that cannot be read
13
+ # (any crash is 2). A caller refuses on 2: a checker that cannot judge is never a pass.
14
+ # The patterns are ai-trace-patterns.json beside this script; AI_TRACE_PATTERNS names another JSON (self-tests).
15
+ set -euo pipefail
16
+ export AI_TRACE_SELF="${BASH_SOURCE[0]}"
17
+ exec python3 -I - "$@" <<'PY'
18
+ import json, os, re, subprocess, sys
19
+
20
+ here = os.path.dirname(os.path.realpath(os.environ["AI_TRACE_SELF"]))
21
+ pfile = os.environ.get("AI_TRACE_PATTERNS") or os.path.join(here, "ai-trace-patterns.json")
22
+
23
+
24
+ def load():
25
+ with open(pfile, encoding="utf-8") as f:
26
+ return [(m["name"], re.compile(m["pattern"], re.I | re.M)) for m in json.load(f)["markers"]]
27
+
28
+
29
+ def hits(text, markers):
30
+ out = []
31
+ for n, line in enumerate(text.splitlines(), 1):
32
+ for name, rx in markers:
33
+ if rx.search(line):
34
+ out.append((n, name, line.strip()))
35
+ return out
36
+
37
+
38
+ def report(label, text, markers):
39
+ h = hits(text, markers)
40
+ for n, name, line in h:
41
+ print(f"{label}: line {n} [{name}]: {line}")
42
+ return len(h)
43
+
44
+
45
+ def git(*a):
46
+ p = subprocess.run(["git", *a], capture_output=True, text=True)
47
+ if p.returncode:
48
+ print(f"ai-trace-commit-check: git {' '.join(a[:3])}: {p.stderr.strip()}", file=sys.stderr)
49
+ sys.exit(2)
50
+ return p.stdout
51
+
52
+
53
+ def selftest():
54
+ # built from fragments: this file must not itself carry an attribution line
55
+ co, by, ai = "Co-Authored-", "By: ", "Cl" + "aude"
56
+ sess = "Cl" + "aude-Session: https://cl" + "aude.ai/code/session_01"
57
+ fx = {
58
+ "co-authored-by": f"Fix x\n\n{co}{by}{ai} Opus <noreply@anthro" + "pic.com>\n",
59
+ "claude-session": f"Fix x\n\n{sess}\n",
60
+ "generated-footer": f"Fix x\n\n\U0001F916 Generated with [{ai} Code](https://{ai.lower()}.com/{ai.lower()}-code)\n",
61
+ "claude.ai/code": f"Fix x\n\nsee https://{ai.lower()}.ai/code/session_9\n",
62
+ }
63
+ clean = f"Fix x\n\nGenerated by the build; mentions a hidden directory and a {co}{by}Pau line.\n"
64
+ def run(markers):
65
+ bad = [k for k, v in fx.items() if not hits(v, markers)]
66
+ if hits(clean, markers):
67
+ bad.append("clean-flagged")
68
+ return bad
69
+ m = load()
70
+ first = run(m)
71
+ print("selftest:", "GREEN" if not first else f"RED {first}")
72
+ sab = run([x for x in m if x[0] not in ("co-authored-by", "ai-co-author", "anthropic-noreply", "claude-session")])
73
+ print("sabotage (the co-author, email and session patterns removed):", "RED as expected" if sab else "still GREEN: blind")
74
+ return 0 if not first and sab else 1
75
+
76
+
77
+ def main(a):
78
+ if a == ["--selftest"]:
79
+ return selftest()
80
+ markers = load()
81
+ if len(a) == 2 and a[0] == "--msg-file":
82
+ try:
83
+ text = open(a[1], encoding="utf-8", errors="replace").read()
84
+ except OSError as e:
85
+ print(f"ai-trace-commit-check: {e}", file=sys.stderr)
86
+ return 2
87
+ # lines git adds as comments are not part of the message
88
+ text = "\n".join(l for l in text.splitlines() if not l.startswith("#"))
89
+ return 1 if report("commit message", text, markers) else 0
90
+ if len(a) == 2 and a[0] == "--text-file":
91
+ try:
92
+ text = open(a[1], encoding="utf-8", errors="replace").read()
93
+ except OSError as e:
94
+ print(f"ai-trace-commit-check: {e}", file=sys.stderr)
95
+ return 2
96
+ return 1 if report("text", text, markers) else 0
97
+ if len(a) == 1 and not a[0].startswith("-"):
98
+ shas = git("rev-list", a[0]).split()
99
+ bad = 0
100
+ for sha in shas:
101
+ bad += report(sha[:12], git("log", "-1", "--format=%B", sha), markers)
102
+ print(f"ai-trace-commit-check: {len(shas)} commits, {bad} offending lines")
103
+ return 1 if bad else 0
104
+ print(__doc__ if False else "usage: ai-trace-commit-check.sh <range> | --msg-file FILE | --text-file FILE | --selftest", file=sys.stderr)
105
+ return 2
106
+
107
+
108
+ try:
109
+ rc = main(sys.argv[1:])
110
+ except Exception as e: # a bad or missing pattern file, a decode error: unreadable, never "a hit"
111
+ print(f"ai-trace-commit-check: {type(e).__name__}: {e}", file=sys.stderr)
112
+ rc = 2
113
+ sys.exit(rc)
114
+ PY
@@ -0,0 +1,33 @@
1
+ {
2
+ "$comment": "SPDX-License-Identifier: GPL-3.0-or-later. The AI-attribution patterns (Python re, flags IGNORECASE|MULTILINE) read by ai-trace-commit-check.sh. Edit here only.",
3
+ "markers": [
4
+ {
5
+ "name": "robot-footer",
6
+ "pattern": "^[^\\S\\r\\n]*[_*>]*[^\\S\\r\\n]*🤖[^\\r\\n]*?(?:Claude|claude\\.(?:ai|com))[^\\r\\n]*"
7
+ },
8
+ {
9
+ "name": "generated-footer",
10
+ "pattern": "(?:🤖[^\\S\\r\\n]*)?Generated[^\\S\\r\\n]+(?:with|by)[^\\S\\r\\n]+\\[?Claude[^\\S\\r\\n]+Code\\]?(?:\\([^)\\s]*\\))?"
11
+ },
12
+ {
13
+ "name": "co-authored-by",
14
+ "pattern": "Co-Authored-By:[^\\S\\r\\n]*Claude\\b[^\\r\\n<`]*(?:<[^>\\r\\n]*>)?"
15
+ },
16
+ {
17
+ "name": "ai-co-author",
18
+ "pattern": "Co-Authored-By:[^\\S\\r\\n]*(?:Anthropic|OpenAI|ChatGPT|Copilot|Gemini|Codex|Cursor|Aider|Devin)\\b[^\\r\\n<`]*(?:<[^>\\r\\n]*>)?"
19
+ },
20
+ {
21
+ "name": "anthropic-noreply",
22
+ "pattern": "noreply@anthropic\\.com"
23
+ },
24
+ {
25
+ "name": "claude-session",
26
+ "pattern": "Claude-Session:[^\\S\\r\\n]*[^\\s`]*"
27
+ },
28
+ {
29
+ "name": "claude.ai/code",
30
+ "pattern": "\\[[^\\]\\r\\n]*\\]\\([^\\S\\r\\n]*<?https?://claude\\.ai/code[^)\\s]*>?[^\\S\\r\\n]*\\)|<?https?://claude\\.ai/code\\b[^\\s)>`]*>?"
31
+ }
32
+ ]
33
+ }