@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.
- package/README.md +59 -9
- package/bin/plugin-qualify.mjs +62 -29
- package/dist/clap-loader.d.ts +1 -1
- package/dist/clap-loader.js +3 -3
- package/dist/clap-loader.js.map +1 -1
- package/dist/clap-qualify.d.ts +21 -45
- package/dist/clap-qualify.js +60 -82
- package/dist/clap-qualify.js.map +1 -1
- package/dist/cost-fields.d.ts +26 -0
- package/dist/cost-fields.js +30 -0
- package/dist/cost-fields.js.map +1 -1
- package/dist/host-profiles.d.ts +14 -0
- package/dist/host-profiles.js +27 -1
- package/dist/host-profiles.js.map +1 -1
- package/dist/hosting-codes.d.ts +6 -2
- package/dist/hosting-codes.js +9 -4
- package/dist/hosting-codes.js.map +1 -1
- package/dist/hosting-suitability.d.ts +43 -4
- package/dist/hosting-suitability.js +46 -8
- package/dist/hosting-suitability.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/judge-cli.d.ts +41 -0
- package/dist/judge-cli.js +114 -0
- package/dist/judge-cli.js.map +1 -0
- package/dist/measure-host.js +2 -2
- package/dist/measurement/format.d.ts +1 -1
- package/dist/measurement/probe-json.js +1 -0
- package/dist/measurement/probe-json.js.map +1 -1
- package/dist/measurement/vocabulary.d.ts +1 -1
- package/dist/measurement/vocabulary.js +7 -1
- package/dist/measurement/vocabulary.js.map +1 -1
- package/dist/probe-judge.d.ts +60 -0
- package/dist/probe-judge.js +213 -0
- package/dist/probe-judge.js.map +1 -0
- package/dist/probe-output-fixtures.d.ts +25 -0
- package/dist/probe-output-fixtures.js +134 -0
- package/dist/probe-output-fixtures.js.map +1 -0
- package/dist/probe-output.d.ts +150 -0
- package/dist/probe-output.js +369 -0
- package/dist/probe-output.js.map +1 -0
- package/dist/probe-runner.d.ts +79 -0
- package/dist/probe-runner.js +191 -0
- package/dist/probe-runner.js.map +1 -0
- package/dist/qualification-record.d.ts +80 -0
- package/dist/qualification-record.js +85 -0
- package/dist/qualification-record.js.map +1 -0
- package/dist/qualify-cli.js +1 -1
- package/dist/qualify-cli.js.map +1 -1
- package/dist/qualify-declarations.d.ts +26 -6
- package/dist/qualify-declarations.js +25 -7
- package/dist/qualify-declarations.js.map +1 -1
- package/fixtures/clap-scan/fake.json +30 -6
- package/fixtures/probe-output/memory-limit-refused.json +190 -0
- package/fixtures/probe-output/plugin-crash.json +236 -0
- package/fixtures/probe-output/success-msi.json +363 -0
- package/package.json +7 -2
- package/src/clap-loader.ts +3 -2
- package/src/clap-qualify.ts +70 -124
- package/src/cost-fields.ts +39 -0
- package/src/host-profiles.ts +29 -1
- package/src/hosting-codes.ts +9 -4
- package/src/hosting-suitability.ts +82 -11
- package/src/index.ts +5 -0
- package/src/judge-cli.ts +133 -0
- package/src/measure-host.ts +2 -2
- package/src/measurement/format.ts +1 -1
- package/src/measurement/probe-json.ts +1 -0
- package/src/measurement/vocabulary.ts +8 -1
- package/src/probe-judge.ts +264 -0
- package/src/probe-output-fixtures.ts +146 -0
- package/src/probe-output.ts +505 -0
- package/src/probe-runner.ts +244 -0
- package/src/qualification-record.ts +142 -0
- package/src/qualify-cli.ts +1 -1
- package/src/qualify-declarations.ts +28 -8
- package/tools/ai-trace-commit-check.sh +114 -0
- package/tools/ai-trace-patterns.json +33 -0
- package/tools/benchmark.py +13 -1
- package/tools/private-refs-check.sh +90 -0
package/src/cost-fields.ts
CHANGED
|
@@ -70,3 +70,42 @@ export function classifierCostFromDocument(reading: CostAtRate, rate: number): C
|
|
|
70
70
|
[COST_FIELDS.classifier.coreFraction]: fraction,
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The key of one point of a cost grid: the rate and the quantum it was measured at. One place spells it,
|
|
76
|
+
* so the reader that fills a grid and the gate that looks a point up cannot disagree.
|
|
77
|
+
*/
|
|
78
|
+
export function costPointKey(rate: number, quantum: number): string {
|
|
79
|
+
return `${rate}/${quantum}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The percentile the classifier's `nsPerSampleP95` is. A figure taken at another percentile is not that field. */
|
|
83
|
+
export const CLASSIFIER_PERCENTILE = 95;
|
|
84
|
+
|
|
85
|
+
/** One point of a grid as it is measured: the figures {@link gridCostReadings} turns into the classifier's. */
|
|
86
|
+
export interface CostGridPoint {
|
|
87
|
+
readonly rate: number;
|
|
88
|
+
readonly quantum: number;
|
|
89
|
+
readonly outcome: string;
|
|
90
|
+
readonly cost?: { readonly median: number; readonly percentile: { readonly p: number; readonly value: number } };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The classifier's cost readings of a grid, by {@link costPointKey}. A point that did not end `probe.ok`, has no
|
|
95
|
+
* cost, or whose percentile is not {@link CLASSIFIER_PERCENTILE} has no reading: the gate then reads the pair as
|
|
96
|
+
* unmeasured, which is the truth, where a figure under the wrong name would be judged as if it were the P95.
|
|
97
|
+
*/
|
|
98
|
+
export function gridCostReadings(points: readonly CostGridPoint[], okOutcome: string): Record<string, ClassifierCostReading> {
|
|
99
|
+
const out: Record<string, ClassifierCostReading> = {};
|
|
100
|
+
for (const p of points) {
|
|
101
|
+
if (p.outcome !== okOutcome || p.cost === undefined || p.cost.percentile.p !== CLASSIFIER_PERCENTILE) continue;
|
|
102
|
+
const fraction = coreFraction(p.cost.percentile.value, p.rate);
|
|
103
|
+
if (fraction === undefined) continue;
|
|
104
|
+
out[costPointKey(p.rate, p.quantum)] = {
|
|
105
|
+
[COST_FIELDS.classifier.median]: p.cost.median,
|
|
106
|
+
[COST_FIELDS.classifier.percentile]: p.cost.percentile.value,
|
|
107
|
+
[COST_FIELDS.classifier.coreFraction]: fraction,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
package/src/host-profiles.ts
CHANGED
|
@@ -54,6 +54,17 @@ export const OMX_CLAP_HOST_EXTENSIONS: readonly string[] = [
|
|
|
54
54
|
"clap.state",
|
|
55
55
|
];
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The three host extensions omx-clap-host turns on beyond the library's own (omx-clap-core
|
|
59
|
+
* `src/effects.c` `configure_core`: `preset_load`, `track_info`, `remote_controls`), by the ids
|
|
60
|
+
* its host object answers (`src/clap_host.c`); the prober offers them with `--offer`.
|
|
61
|
+
*/
|
|
62
|
+
export const OMX_CLAP_HOST_CONFIGURED_EXTENSIONS: readonly string[] = [
|
|
63
|
+
"clap.preset-load/2",
|
|
64
|
+
"clap.track-info/1",
|
|
65
|
+
"clap.remote-controls/2",
|
|
66
|
+
];
|
|
67
|
+
|
|
57
68
|
/** The LV2 extension namespace the jalv and mod-host lists are spelled in. */
|
|
58
69
|
const LV2_EXT = "http://lv2plug.in/ns/ext/";
|
|
59
70
|
const BUF_SIZE_ALL = ["powerOf2BlockLength", "fixedBlockLength", "boundedBlockLength"].map((f) => `${LV2_EXT}buf-size#${f}`);
|
|
@@ -113,8 +124,25 @@ export const MOD_HOST_PROFILE: HostProfile = {
|
|
|
113
124
|
isolation: "shared-process",
|
|
114
125
|
};
|
|
115
126
|
|
|
127
|
+
/**
|
|
128
|
+
* omx-clap-host, alone or as plugin-hostd's CLAP worker: one JACK client per plugin in a host
|
|
129
|
+
* process that several plugins may share (a plugin-hostd pool), instruments fed through each
|
|
130
|
+
* client's `midi_in`, latency published on the JACK ports. Its CLAP host object is the library's
|
|
131
|
+
* plus the three it configures; its LV2 plugins run through the same feature provider as the
|
|
132
|
+
* console's in-process stage.
|
|
133
|
+
*/
|
|
134
|
+
export const OMX_CLAP_HOST_PROFILE: HostProfile = {
|
|
135
|
+
id: "omx-clap-host",
|
|
136
|
+
lv2Features: OMX_INPROCESS_FEATURES,
|
|
137
|
+
clapExtensions: [...OMX_CLAP_HOST_EXTENSIONS, ...OMX_CLAP_HOST_CONFIGURED_EXTENSIONS],
|
|
138
|
+
instruments: true,
|
|
139
|
+
cost: HOST_BUDGETS["omx-clap-host"],
|
|
140
|
+
latency: "uncompensated",
|
|
141
|
+
isolation: "shared-process",
|
|
142
|
+
};
|
|
143
|
+
|
|
116
144
|
/** Every profile the tool ships, by id. */
|
|
117
|
-
export const HOST_PROFILES: readonly HostProfile[] = [OPENMIXER_CONSOLE_PROFILE, JALV_PROFILE, MOD_HOST_PROFILE];
|
|
145
|
+
export const HOST_PROFILES: readonly HostProfile[] = [OPENMIXER_CONSOLE_PROFILE, JALV_PROFILE, MOD_HOST_PROFILE, OMX_CLAP_HOST_PROFILE];
|
|
118
146
|
|
|
119
147
|
/** A shipped profile by id, or `undefined`. */
|
|
120
148
|
export function shippedHostProfile(id: string): HostProfile | undefined {
|
package/src/hosting-codes.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
2
|
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
3
|
/**
|
|
4
|
-
* The hosting verdict's message codes
|
|
4
|
+
* The hosting verdict's message codes. Codes, never prose: the
|
|
5
5
|
* surface renders the operator's locale.
|
|
6
6
|
*
|
|
7
|
-
* A UNION list file
|
|
7
|
+
* A UNION list file: `.gitattributes` marks it
|
|
8
8
|
* `merge=union`, so two lanes that each add a code merge with both kept. Only the table lives
|
|
9
9
|
* here, one `key: 'code',` per line, with keys and codes unique; the registry-hotspots gate
|
|
10
10
|
* refuses anything else.
|
|
@@ -24,6 +24,10 @@ export const HOSTING_CODE = {
|
|
|
24
24
|
rtSafetyViolations: 'hosting.rt-safety.violations-in-run',
|
|
25
25
|
rtSafetyUnmeasured: 'hosting.rt-safety.unmeasured',
|
|
26
26
|
rtSafetyNotReproducible: 'hosting.rt-safety.not-reproducible',
|
|
27
|
+
// The note sweep: an instrument played under the interposer, a count of its own.
|
|
28
|
+
rtSafetyNotesClean: 'hosting.rt-safety.clean-under-notes',
|
|
29
|
+
rtSafetyNotesViolations: 'hosting.rt-safety.violations-under-notes',
|
|
30
|
+
rtSafetyNotesUnmeasured: 'hosting.rt-safety.notes-unmeasured',
|
|
27
31
|
featuresProvided: 'hosting.features.all-provided',
|
|
28
32
|
featuresMissing: 'hosting.features.required-not-provided',
|
|
29
33
|
featuresDisqualifying: 'hosting.features.host-not-provided',
|
|
@@ -44,7 +48,7 @@ export const HOSTING_CODE = {
|
|
|
44
48
|
topologyNoAudioOutput: 'hosting.topology.no-audio-output',
|
|
45
49
|
topologyExtraInputs: 'hosting.topology.extra-inputs-fed-silence',
|
|
46
50
|
topologyInstrument: 'hosting.topology.instrument',
|
|
47
|
-
// The CLAP arm
|
|
51
|
+
// The CLAP arm: the SAME dimensions fed from CLAP facts,
|
|
48
52
|
// minted here where the LV2 table has no code for the fact.
|
|
49
53
|
topologyWiderThanStrip: 'hosting.topology.wider-than-strip',
|
|
50
54
|
clapLatencyMismatch: 'hosting.clap.latency-mismatch',
|
|
@@ -57,6 +61,7 @@ export const HOSTING_CODE = {
|
|
|
57
61
|
clapFormatNotHosted: 'hosting.clap.format-not-hosted',
|
|
58
62
|
clapExtensionsNotCovered: 'hosting.clap.extensions-not-covered',
|
|
59
63
|
outputNonFinite: 'hosting.output.non-finite',
|
|
64
|
+
workerNonconforming: 'hosting.worker.nonconforming',
|
|
60
65
|
// The pin (spec §11; one-contract §10.3): a host's refusal at add, by the daemon's code.
|
|
61
66
|
integrityUnpinned: 'hosting.integrity.unpinned',
|
|
62
67
|
integrityBinaryMismatch: 'hosting.integrity.binary-mismatch',
|
|
@@ -72,6 +77,6 @@ export const HOSTING_CODE = {
|
|
|
72
77
|
noRealisation: 'hosting.no-realisation',
|
|
73
78
|
realisedInProcess: 'hosting.realised.in-process',
|
|
74
79
|
// A plugin LINKED into the engine (the static-link registry) runs in-process without a
|
|
75
|
-
// verdict: the library's oracles are its proof
|
|
80
|
+
// verdict: the library's oracles are its proof.
|
|
76
81
|
linked: 'hosting.linked',
|
|
77
82
|
} as const;
|
|
@@ -52,11 +52,19 @@ export interface QualifiedPlugin {
|
|
|
52
52
|
readonly scalingClass?: string;
|
|
53
53
|
readonly paramSweep?: unknown;
|
|
54
54
|
};
|
|
55
|
-
/** Measured cost per rate (Hz as a string key). */
|
|
55
|
+
/** Measured cost: per rate (Hz as a string key), or per rate AND quantum. */
|
|
56
56
|
readonly cpuCost?: {
|
|
57
|
+
/** One figure per rate, at the block the prober ran: the old format. Read only when `perRateQuantum` is absent. */
|
|
57
58
|
readonly perRate?: Readonly<
|
|
58
59
|
Record<string, { readonly coreFractionP95: number; readonly nsPerSampleMedian: number; readonly nsPerSampleP95: number }>
|
|
59
60
|
>;
|
|
61
|
+
/**
|
|
62
|
+
* One figure per rate and quantum, keyed by {@link costPointKey}: a probe output's grid. When this is
|
|
63
|
+
* present it is the only cost there is, so a pair it lacks is unmeasured, never read from `perRate`.
|
|
64
|
+
*/
|
|
65
|
+
readonly perRateQuantum?: Readonly<
|
|
66
|
+
Record<string, { readonly coreFractionP95: number; readonly nsPerSampleMedian: number; readonly nsPerSampleP95: number }>
|
|
67
|
+
>;
|
|
60
68
|
};
|
|
61
69
|
readonly stability?: PluginStabilityMeasurement;
|
|
62
70
|
readonly rtSafety?: PluginRtSafetyMeasurement;
|
|
@@ -199,12 +207,12 @@ export interface CrashSignature {
|
|
|
199
207
|
/** The top plugin-owned stack frame, or a host frame that exonerates the plugin. */
|
|
200
208
|
readonly topFrame: string;
|
|
201
209
|
readonly signal?: "SIGSEGV" | "SIGABRT" | "SIGBUS";
|
|
202
|
-
/**
|
|
210
|
+
/** What was found about this cluster. Provenance, so a row is never folklore. */
|
|
203
211
|
readonly note: string;
|
|
204
212
|
}
|
|
205
213
|
|
|
206
214
|
/**
|
|
207
|
-
* Seeded from the
|
|
215
|
+
* Seeded from the crashes investigated on 2026-09-03/04. A raw crash matching NO row is
|
|
208
216
|
* `unknown` — the table blames a plugin only where a named investigation did.
|
|
209
217
|
*/
|
|
210
218
|
export const CRASH_SIGNATURES: readonly CrashSignature[] = [
|
|
@@ -213,26 +221,26 @@ export const CRASH_SIGNATURES: readonly CrashSignature[] = [
|
|
|
213
221
|
attribution: "host",
|
|
214
222
|
topFrame: "pw_memmap_free",
|
|
215
223
|
signal: "SIGSEGV",
|
|
216
|
-
note: "
|
|
224
|
+
note: "closing a PipeWire JACK client frees a memory map twice; the host crashes, not the plugin",
|
|
217
225
|
},
|
|
218
226
|
{
|
|
219
227
|
id: "pipewire-jack-close-double-free-abort",
|
|
220
228
|
attribution: "host",
|
|
221
229
|
topFrame: "tcache_double_free_verify",
|
|
222
230
|
signal: "SIGABRT",
|
|
223
|
-
note: "
|
|
231
|
+
note: "the same double free in PipeWire's JACK client close, caught by glibc's tcache check as an abort",
|
|
224
232
|
},
|
|
225
233
|
{
|
|
226
234
|
id: "mod-host-effects-remove",
|
|
227
235
|
attribution: "host",
|
|
228
236
|
topFrame: "effects_remove",
|
|
229
|
-
note: "
|
|
237
|
+
note: "mod-host crashes in effects_remove while removing an effect; the host is at fault",
|
|
230
238
|
},
|
|
231
239
|
{
|
|
232
240
|
id: "plugin-instantiate-layout",
|
|
233
241
|
attribution: "plugin",
|
|
234
242
|
topFrame: "lilv_instance_instantiate",
|
|
235
|
-
note: "
|
|
243
|
+
note: "the plugin crashes while lilv instantiates it, on a port layout it cannot host",
|
|
236
244
|
},
|
|
237
245
|
];
|
|
238
246
|
|
|
@@ -306,7 +314,7 @@ export type PluginLv2Features = Omit<FeaturesMeasurement, "unmeasuredReason" | "
|
|
|
306
314
|
|
|
307
315
|
export { DEFAULT_HOSTING_POLICY, type HostingPolicy } from "./qualify-declarations.js";
|
|
308
316
|
import { DEFAULT_HOSTING_POLICY, type HostingPolicy } from "./qualify-declarations.js";
|
|
309
|
-
import { costFieldsMissing } from "./cost-fields.js";
|
|
317
|
+
import { costFieldsMissing, costPointKey } from "./cost-fields.js";
|
|
310
318
|
|
|
311
319
|
/** One declared rate's latency reading from the CLAP qualifier (CLAP spec §3, latency). */
|
|
312
320
|
export interface ClapLatencyAtRate {
|
|
@@ -342,6 +350,28 @@ export interface PluginClapMeasurement {
|
|
|
342
350
|
readonly processCalls: number;
|
|
343
351
|
}
|
|
344
352
|
|
|
353
|
+
/**
|
|
354
|
+
* The note sweep of a CLAP plugin with a note input (plugin-qualify's prober): the plugin played at
|
|
355
|
+
* every rate — a chord, bend and a controller, pressure, the chord let go, a burst of note ons and
|
|
356
|
+
* their offs, all notes off — in the dialect its first note input reads, counted under the
|
|
357
|
+
* interposer apart from the parameter sweep. `applicable: false` is a plugin the prober could not
|
|
358
|
+
* play: no note input, or none in a dialect it speaks.
|
|
359
|
+
*/
|
|
360
|
+
export interface PluginNoteSweepMeasurement {
|
|
361
|
+
readonly applicable: boolean;
|
|
362
|
+
readonly dialect?: "clap" | "midi";
|
|
363
|
+
readonly blocks?: number;
|
|
364
|
+
readonly blockFrames?: number;
|
|
365
|
+
/** Note, expression and MIDI events the sweep sent. */
|
|
366
|
+
readonly noteEvents?: number;
|
|
367
|
+
readonly processErrors?: number;
|
|
368
|
+
readonly nonFinite?: boolean;
|
|
369
|
+
readonly interposer?: boolean;
|
|
370
|
+
readonly allocationsInRun?: number;
|
|
371
|
+
readonly locksInRun?: number;
|
|
372
|
+
readonly syscallsInRun?: number;
|
|
373
|
+
}
|
|
374
|
+
|
|
345
375
|
/** What the classifier is told about the console asking. */
|
|
346
376
|
export interface HostingClassifyOptions {
|
|
347
377
|
/** The LIVE rate. Cost is read here and nowhere else — never projected. */
|
|
@@ -678,7 +708,13 @@ export function hostingCostReasons(
|
|
|
678
708
|
options: HostingClassifyOptions,
|
|
679
709
|
policy: HostingPolicy,
|
|
680
710
|
): readonly HostingReason[] {
|
|
681
|
-
const
|
|
711
|
+
const grid = descriptor.cpuCost?.perRateQuantum;
|
|
712
|
+
const perRate =
|
|
713
|
+
grid === undefined
|
|
714
|
+
? descriptor.cpuCost?.perRate?.[String(options.rate)]
|
|
715
|
+
: options.quantum === undefined
|
|
716
|
+
? undefined
|
|
717
|
+
: grid[costPointKey(options.rate, options.quantum)];
|
|
682
718
|
if (perRate === undefined) {
|
|
683
719
|
return [
|
|
684
720
|
reason("cost", "objective", "unknown", HOSTING_CODE.costUnmeasured, {
|
|
@@ -739,6 +775,10 @@ export function hostingLatencyReasons(
|
|
|
739
775
|
// one the benchmark declared unmeasurable, has not been judged and does not pass by
|
|
740
776
|
// default. The oracle's first fixture omitted latency and still expected `suitable`,
|
|
741
777
|
// which is how this branch was missing — a self-consistent suite proving nothing.
|
|
778
|
+
// A plugin whose worker interface was refused is not unmeasured by accident: it is not hostable.
|
|
779
|
+
if (typeof latency?.unmeasurable === "string" && latency.unmeasurable.includes(HOSTING_CODE.workerNonconforming)) {
|
|
780
|
+
return [reason("latency", "objective", "unsuitable", HOSTING_CODE.workerNonconforming)];
|
|
781
|
+
}
|
|
742
782
|
if (latency === undefined || latency.unmeasurable !== undefined) {
|
|
743
783
|
return [reason("latency", "objective", "unknown", HOSTING_CODE.latencyUnmeasured)];
|
|
744
784
|
}
|
|
@@ -888,6 +928,37 @@ export function clapRtReasons(clap: PluginClapMeasurement | undefined): readonly
|
|
|
888
928
|
return out;
|
|
889
929
|
}
|
|
890
930
|
|
|
931
|
+
/**
|
|
932
|
+
* The rtSafety reasons of the note sweep, for a plugin with `noteInputs` note inputs: none for a
|
|
933
|
+
* plugin that takes no notes; `unknown` when it takes notes and was not played under the
|
|
934
|
+
* interposer (an instrument is cheap and clean at rest, so the parameter sweep alone says nothing
|
|
935
|
+
* about a voice); `unsuitable` on any allocation, lock or syscall while it was played, or a process
|
|
936
|
+
* error; `suitable` on a clean count.
|
|
937
|
+
*/
|
|
938
|
+
export function noteSweepReasons(sweep: PluginNoteSweepMeasurement | undefined, noteInputs: number): readonly HostingReason[] {
|
|
939
|
+
if (noteInputs === 0) return [];
|
|
940
|
+
if (sweep === undefined || !sweep.applicable || sweep.interposer !== true || !sweep.blocks ||
|
|
941
|
+
sweep.allocationsInRun === undefined || sweep.locksInRun === undefined || sweep.syscallsInRun === undefined) {
|
|
942
|
+
return [reason("rtSafety", "objective", "unknown", HOSTING_CODE.rtSafetyNotesUnmeasured)];
|
|
943
|
+
}
|
|
944
|
+
const out: HostingReason[] = [];
|
|
945
|
+
if (sweep.allocationsInRun + sweep.locksInRun + sweep.syscallsInRun > 0) {
|
|
946
|
+
out.push(
|
|
947
|
+
reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.rtSafetyNotesViolations, {
|
|
948
|
+
allocations: sweep.allocationsInRun,
|
|
949
|
+
syscalls: sweep.syscallsInRun,
|
|
950
|
+
locks: sweep.locksInRun,
|
|
951
|
+
}),
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
if ((sweep.processErrors ?? 0) > 0) {
|
|
955
|
+
out.push(reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.clapProcessError, { errors: sweep.processErrors ?? 0 }));
|
|
956
|
+
}
|
|
957
|
+
return out.length > 0
|
|
958
|
+
? out
|
|
959
|
+
: [reason("rtSafety", "objective", "suitable", HOSTING_CODE.rtSafetyNotesClean, { blocks: sweep.blocks, events: sweep.noteEvents ?? 0 })];
|
|
960
|
+
}
|
|
961
|
+
|
|
891
962
|
/**
|
|
892
963
|
* The verdict. Worst-wins across every dimension, exactly as `classifyForRole` folds — with
|
|
893
964
|
* three gates that can poison the whole result before the fold is even read: a curator
|
|
@@ -905,7 +976,7 @@ export function classifyPluginHosting(
|
|
|
905
976
|
// interchange's one door (`measurement-catalog.ts`), whose per-dimension precedence is the
|
|
906
977
|
// only precedence there is. A second "passed measurements win" seam here was how an offline
|
|
907
978
|
// report could agree with itself while every served descriptor read unknown.
|
|
908
|
-
// The CLAP arm
|
|
979
|
+
// The CLAP arm: the SAME six dimensions, fed from the
|
|
909
980
|
// qualifier's CLAP facts where the LV2 scan's facts do not exist for the format. Stability,
|
|
910
981
|
// the interposer's counts and the cost are format-blind and read unchanged.
|
|
911
982
|
const reasons: HostingReason[] = descriptor.format === "clap"
|
|
@@ -988,7 +1059,7 @@ export interface HostingRealisation {
|
|
|
988
1059
|
readonly inProcessAvailable: boolean;
|
|
989
1060
|
/**
|
|
990
1061
|
* Whether the plugin is LINKED into the engine (its static-link registry): it needs no verdict
|
|
991
|
-
* to run in-process
|
|
1062
|
+
* to run in-process. Absent reads as not linked.
|
|
992
1063
|
*/
|
|
993
1064
|
readonly linked?: boolean;
|
|
994
1065
|
}
|
package/src/index.ts
CHANGED
|
@@ -20,3 +20,8 @@ export * from "./qualify-cli.js";
|
|
|
20
20
|
export * from "./pins-file.js";
|
|
21
21
|
export * from "./measure-host.js";
|
|
22
22
|
export * from "./cost-fields.js";
|
|
23
|
+
export * from "./probe-output.js";
|
|
24
|
+
export * from "./probe-judge.js";
|
|
25
|
+
export * from "./qualification-record.js";
|
|
26
|
+
export * from "./probe-runner.js";
|
|
27
|
+
export * from "./judge-cli.js";
|
package/src/judge-cli.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* `plugin-qualify judge <probe-output>...`: judge probe outputs already written (by
|
|
5
|
+
* `plugin-hostd-probe run`, by plugin-hostd's `judge_cmd`, or by hand) and write the qualification
|
|
6
|
+
* record beside a byte copy of each. `bin/plugin-qualify.mjs` only reads argv and prints.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import type { HostProfile } from "./host-profiles.js";
|
|
13
|
+
import { judgeProbeOutput, judgmentVerdicts } from "./probe-judge.js";
|
|
14
|
+
import { readProbeOutput } from "./probe-output.js";
|
|
15
|
+
import { writeQualification } from "./qualification-record.js";
|
|
16
|
+
import type { QualifyVerdict } from "./qualify-cli.js";
|
|
17
|
+
import { EXIT_CODE, OUTPUT_FORMATS, type OutputFormat } from "./qualify-options.js";
|
|
18
|
+
import { OPENMIXER_CONSOLE_PROFILE } from "./host-profiles.js";
|
|
19
|
+
|
|
20
|
+
export interface JudgeArgs {
|
|
21
|
+
readonly files: readonly string[];
|
|
22
|
+
readonly hostProfiles: readonly string[];
|
|
23
|
+
readonly qualificationDir?: string;
|
|
24
|
+
readonly format: OutputFormat;
|
|
25
|
+
readonly help: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ParseJudgeResult = { ok: true; args: JudgeArgs } | { ok: false; error: string };
|
|
29
|
+
|
|
30
|
+
const VALUE_FLAGS = ["--host-profile", "--qualification-dir", "--format"];
|
|
31
|
+
|
|
32
|
+
export function parseJudgeArgs(argv: readonly string[]): ParseJudgeResult {
|
|
33
|
+
const files: string[] = [];
|
|
34
|
+
const specs: string[] = [];
|
|
35
|
+
let dir: string | undefined;
|
|
36
|
+
let format: string = "text";
|
|
37
|
+
let help = false;
|
|
38
|
+
for (let i = 0; i < argv.length; i++) {
|
|
39
|
+
const a = argv[i]!;
|
|
40
|
+
if (!a.startsWith("--")) {
|
|
41
|
+
files.push(a);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (a === "--help") {
|
|
45
|
+
help = true;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!VALUE_FLAGS.includes(a)) return { ok: false, error: `unknown option ${a}` };
|
|
49
|
+
const v = argv[++i];
|
|
50
|
+
if (v === undefined || v.startsWith("--")) return { ok: false, error: `${a} needs a value` };
|
|
51
|
+
if (a === "--host-profile") specs.push(...v.split(",").map((t) => t.trim()).filter((t) => t !== ""));
|
|
52
|
+
else if (a === "--qualification-dir") dir = v;
|
|
53
|
+
else format = v;
|
|
54
|
+
}
|
|
55
|
+
if (!(OUTPUT_FORMATS as readonly string[]).includes(format)) return { ok: false, error: `--format must be one of ${OUTPUT_FORMATS.join(", ")}` };
|
|
56
|
+
if (!help && files.length === 0) return { ok: false, error: "name at least one probe output" };
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
args: {
|
|
60
|
+
files,
|
|
61
|
+
hostProfiles: [...new Set(specs.length > 0 ? specs : [OPENMIXER_CONSOLE_PROFILE.id])],
|
|
62
|
+
...(dir === undefined ? {} : { qualificationDir: dir }),
|
|
63
|
+
format: format as OutputFormat,
|
|
64
|
+
help,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function judgeHelpText(): string {
|
|
70
|
+
return [
|
|
71
|
+
"plugin-qualify judge — judge probe outputs and write the qualification records",
|
|
72
|
+
"",
|
|
73
|
+
" plugin-qualify judge [options] <probe-output.json>...",
|
|
74
|
+
"",
|
|
75
|
+
" --host-profile <file|id> judge against this host; repeat or comma-separate for one verdict per profile",
|
|
76
|
+
` (default: ${OPENMIXER_CONSOLE_PROFILE.id})`,
|
|
77
|
+
" --qualification-dir <dir> where the records and the copies of the probe outputs go",
|
|
78
|
+
" (default: $XDG_STATE_HOME/omx/qualification, else ~/.local/state/omx/qualification)",
|
|
79
|
+
" --format <json|text> json = the records; text = plugin, verdict, deciding reason (default: text)",
|
|
80
|
+
"",
|
|
81
|
+
`exit ${EXIT_CODE.allQualified} all qualified / ${EXIT_CODE.someNotQualified} some not / ${EXIT_CODE.couldNotMeasure} something could not be read or judged`,
|
|
82
|
+
"",
|
|
83
|
+
].join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The qualification directory when none is named: the state directory of owner spec section 6.9, per user. */
|
|
87
|
+
export function defaultQualificationDir(env: Readonly<Record<string, string | undefined>>, home: string = homedir()): string {
|
|
88
|
+
const state = env["XDG_STATE_HOME"];
|
|
89
|
+
return join(state !== undefined && state !== "" ? state : join(home, ".local", "state"), "omx", "qualification");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface JudgeFilesResult {
|
|
93
|
+
readonly verdicts: readonly QualifyVerdict[];
|
|
94
|
+
readonly records: readonly { readonly recordPath: string; readonly probePath: string }[];
|
|
95
|
+
/** Each file that was refused or could not be read, with the code or the reason. */
|
|
96
|
+
readonly failures: readonly { readonly file: string; readonly problem: string }[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Judge each file under every profile and write its record; a file that does not read is a failure, and the others go on. */
|
|
100
|
+
export function judgeFiles(
|
|
101
|
+
files: readonly string[],
|
|
102
|
+
options: { profiles: readonly HostProfile[]; dir: string; read?: (path: string) => Buffer },
|
|
103
|
+
): JudgeFilesResult {
|
|
104
|
+
const read = options.read ?? ((p: string) => readFileSync(p));
|
|
105
|
+
const verdicts: QualifyVerdict[] = [];
|
|
106
|
+
const records: { recordPath: string; probePath: string }[] = [];
|
|
107
|
+
const failures: { file: string; problem: string }[] = [];
|
|
108
|
+
for (const file of files) {
|
|
109
|
+
let bytes: Buffer;
|
|
110
|
+
try {
|
|
111
|
+
bytes = read(file);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
failures.push({ file, problem: `cannot read: ${e instanceof Error ? e.message : String(e)}` });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const r = readProbeOutput(bytes.toString("utf8"));
|
|
117
|
+
if (!r.ok) {
|
|
118
|
+
failures.push({ file, problem: `${r.code}: ${r.problem}` });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const judgment = judgeProbeOutput(r.output, options.profiles);
|
|
122
|
+
const w = writeQualification(options.dir, bytes, judgment);
|
|
123
|
+
records.push({ recordPath: w.recordPath, probePath: w.probePath });
|
|
124
|
+
verdicts.push(...judgmentVerdicts(judgment));
|
|
125
|
+
}
|
|
126
|
+
return { verdicts, records, failures };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 2 when a file could not be judged or a plugin was not measured, else 0 when every verdict qualifies, else 1. */
|
|
130
|
+
export function judgeExitCode(result: JudgeFilesResult): number {
|
|
131
|
+
if (result.failures.length > 0 || result.verdicts.length === 0 || result.verdicts.some((v) => !v.measured)) return EXIT_CODE.couldNotMeasure;
|
|
132
|
+
return result.verdicts.every((v) => v.qualified) ? EXIT_CODE.allQualified : EXIT_CODE.someNotQualified;
|
|
133
|
+
}
|
package/src/measure-host.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
2
|
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
3
|
/**
|
|
4
|
-
* Reading what the measuring host (`lv2_measure`) prints and supervising how long it may take
|
|
5
|
-
*
|
|
4
|
+
* Reading what the measuring host (`lv2_measure`) prints and supervising how long it may take.
|
|
5
|
+
* Pure functions: the runner spawns, these decide.
|
|
6
6
|
*/
|
|
7
7
|
import { isRecord } from "./measurement/guards.js";
|
|
8
8
|
import { SOAK_SWEEP_DB, SOAK_SWEEP_RATIO, SOAK_TIMEOUT_FLOOR_S, SOAK_TIMEOUT_MARGIN } from "./qualify-declarations.js";
|
|
@@ -464,7 +464,7 @@ export interface HostBudget {
|
|
|
464
464
|
}
|
|
465
465
|
|
|
466
466
|
/**
|
|
467
|
-
* A HOST PROFILE
|
|
467
|
+
* A HOST PROFILE: the host a verdict was judged
|
|
468
468
|
* against — only what differs between hosts. A document carries every profile its verdicts name,
|
|
469
469
|
* in full, so a verdict against a project's own profile is reproducible from the document alone.
|
|
470
470
|
*/
|
|
@@ -55,6 +55,7 @@ export const PROBE_REASON_RULES: readonly ReasonRule[] = [
|
|
|
55
55
|
{ match: /^no-audio-in$/, code: "no-audio-in" },
|
|
56
56
|
{ match: /^no-audio-out$/, code: "no-audio-out" },
|
|
57
57
|
{ match: /^silent-output$/, code: "silent-output" },
|
|
58
|
+
{ match: /hosting\.worker\.nonconforming/, code: "worker-nonconforming" },
|
|
58
59
|
{ match: /worker crashed|worker desync|worker unavailable/, code: "probe-crashed" },
|
|
59
60
|
{ match: /timed out|timeout/, code: "probe-timeout" },
|
|
60
61
|
{ match: /non-finite/, code: "non-finite-output" },
|
|
@@ -112,6 +112,13 @@ export const UNMEASURED_TERMS: readonly UnmeasuredTerm[] = [
|
|
|
112
112
|
"The URI was not present in this machine's LV2 world. A fact about this installation, " +
|
|
113
113
|
"not about the plugin — another host may well have it.",
|
|
114
114
|
},
|
|
115
|
+
{
|
|
116
|
+
code: "worker-nonconforming",
|
|
117
|
+
kind: "probe-failed",
|
|
118
|
+
description:
|
|
119
|
+
"The plugin's LV2_Worker_Interface leaves work or work_response NULL, which the LV2 worker " +
|
|
120
|
+
"specification does not allow: it was refused before any call, not measured.",
|
|
121
|
+
},
|
|
115
122
|
{
|
|
116
123
|
code: "probe-error",
|
|
117
124
|
kind: "probe-failed",
|
|
@@ -302,7 +309,7 @@ export function readDimension(value: unknown): MeasurementDimension | undefined
|
|
|
302
309
|
export const VERDICT_RATINGS = ["suitable", "conditional", "unknown", "unsuitable"] as const;
|
|
303
310
|
export type VerdictRating = (typeof VERDICT_RATINGS)[number];
|
|
304
311
|
|
|
305
|
-
/** How a host runs its plugins
|
|
312
|
+
/** How a host runs its plugins. */
|
|
306
313
|
export const HOST_ISOLATIONS = ["in-process", "per-process", "shared-process"] as const;
|
|
307
314
|
export type HostIsolation = (typeof HOST_ISOLATIONS)[number];
|
|
308
315
|
|