@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.
- package/README.md +127 -14
- package/bin/plugin-qualify-sync.mjs +57 -0
- package/bin/plugin-qualify.mjs +66 -15
- package/dist/adjust-cli.d.ts +83 -0
- package/dist/adjust-cli.js +214 -0
- package/dist/adjust-cli.js.map +1 -0
- package/dist/clap-loader.d.ts +3 -3
- package/dist/clap-loader.js +11 -11
- package/dist/clap-loader.js.map +1 -1
- package/dist/history-adjust.d.ts +77 -0
- package/dist/history-adjust.js +99 -0
- package/dist/history-adjust.js.map +1 -0
- package/dist/history-file.d.ts +73 -0
- package/dist/history-file.js +133 -0
- package/dist/history-file.js.map +1 -0
- package/dist/host-admission.d.ts +53 -0
- package/dist/host-admission.js +96 -0
- package/dist/host-admission.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/lv2-loader.d.ts +16 -2
- package/dist/lv2-loader.js +168 -17
- package/dist/lv2-loader.js.map +1 -1
- package/dist/measurement/cli.d.ts +6 -4
- package/dist/measurement/cli.js +12 -19
- package/dist/measurement/cli.js.map +1 -1
- package/dist/pins-file.d.ts +8 -2
- package/dist/pins-file.js +17 -3
- package/dist/pins-file.js.map +1 -1
- package/dist/probe-judge.d.ts +11 -1
- package/dist/probe-judge.js +20 -1
- package/dist/probe-judge.js.map +1 -1
- package/dist/probe-output-fixtures.d.ts +10 -0
- package/dist/probe-output-fixtures.js +86 -0
- package/dist/probe-output-fixtures.js.map +1 -1
- package/dist/probe-output.d.ts +2 -0
- package/dist/probe-output.js +1 -0
- package/dist/probe-output.js.map +1 -1
- package/dist/probe-runner.d.ts +9 -3
- package/dist/probe-runner.js +36 -12
- package/dist/probe-runner.js.map +1 -1
- package/dist/qualification-record.d.ts +109 -3
- package/dist/qualification-record.js +79 -13
- package/dist/qualification-record.js.map +1 -1
- package/dist/qualify-cli.d.ts +0 -1
- package/dist/qualify-cli.js +2 -4
- package/dist/qualify-cli.js.map +1 -1
- package/dist/qualify-declarations.d.ts +8 -1
- package/dist/qualify-declarations.js +8 -1
- package/dist/qualify-declarations.js.map +1 -1
- package/dist/qualify-options.js +1 -2
- package/dist/qualify-options.js.map +1 -1
- package/dist/qualify-sync.d.ts +215 -0
- package/dist/qualify-sync.js +794 -0
- package/dist/qualify-sync.js.map +1 -0
- package/dist/whole-file.d.ts +3 -0
- package/dist/whole-file.js +36 -0
- package/dist/whole-file.js.map +1 -0
- package/fixtures/admission/2026-10-26.json +19 -0
- package/fixtures/clap-scan/fake.json +24 -24
- package/package.json +5 -4
- package/src/adjust-cli.ts +262 -0
- package/src/clap-loader.ts +9 -13
- package/src/history-adjust.ts +156 -0
- package/src/history-file.ts +177 -0
- package/src/host-admission.ts +136 -0
- package/src/index.ts +6 -0
- package/src/lv2-loader.ts +150 -15
- package/src/measurement/cli.ts +11 -17
- package/src/pins-file.ts +17 -3
- package/src/probe-judge.ts +27 -1
- package/src/probe-output-fixtures.ts +86 -0
- package/src/probe-output.ts +3 -0
- package/src/probe-runner.ts +45 -14
- package/src/qualification-record.ts +174 -15
- package/src/qualify-cli.ts +2 -5
- package/src/qualify-declarations.ts +9 -1
- package/src/qualify-options.ts +1 -2
- package/src/qualify-sync.ts +889 -0
- package/src/whole-file.ts +35 -0
- package/tools/_declarations.py +3 -1
- package/tools/ai-trace-commit-check.sh +26 -14
- package/tools/ai-trace-patterns.json +10 -2
- package/tools/gen-hosting-codes.mjs +1 -1
- package/tools/gen-python-declarations.mjs +2 -0
- package/tools/install-roundtrip.sh +173 -0
- package/tools/mod-host-admit.mjs +74 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* HOST ADMISSION — a `mod-host` or `jalv` verdict is MEASURED by running the plugin under that host,
|
|
5
|
+
* one admission per plugin, not judged from a declaration. mod-host: `add`, `remove` on
|
|
6
|
+
* its socket, a fresh host after any death (`tools/mod-host-admit.mjs` does the run and prints what
|
|
7
|
+
* happened; one line of JSON). jalv: `jalv -n <name> <uri>` headless, hosted when it is still alive
|
|
8
|
+
* at the deadline. The classifiers here are pure: what the host answered goes in, a {@link HostAdmission}
|
|
9
|
+
* with the deciding code comes out; the runs are injected, so a test replays recorded answers.
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
import type { HostProfile } from "./host-profiles.js";
|
|
15
|
+
|
|
16
|
+
/** The profile ids whose verdict is measured by a run. */
|
|
17
|
+
export const ADMITTED_HOSTS = ["mod-host", "jalv"] as const;
|
|
18
|
+
export type AdmittedHost = (typeof ADMITTED_HOSTS)[number];
|
|
19
|
+
|
|
20
|
+
/** The admitting host of a profile, or `undefined` when its verdict is judged from a declaration. */
|
|
21
|
+
export function admittedHostOf(profile: HostProfile): AdmittedHost | undefined {
|
|
22
|
+
return ADMITTED_HOSTS.find((h) => h === profile.id);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What running the plugin under one host came to. */
|
|
26
|
+
export interface HostAdmission {
|
|
27
|
+
readonly host: AdmittedHost;
|
|
28
|
+
/** False when the host could not be run (not installed): there is no evidence either way. */
|
|
29
|
+
readonly measured: boolean;
|
|
30
|
+
readonly admitted: boolean;
|
|
31
|
+
/** Why not: `<host>.resp-<n>`, `<host>.signal-<SIG>`, `mod-host.died-on-add`, `jalv.unknown-port-type`, `<host>.not-installed`, … */
|
|
32
|
+
readonly code?: string;
|
|
33
|
+
readonly params: Readonly<Record<string, string | number>>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const admitted = (host: AdmittedHost): HostAdmission => ({ host, measured: true, admitted: true, params: {} });
|
|
37
|
+
const refused = (host: AdmittedHost, code: string, params: HostAdmission["params"] = {}): HostAdmission => ({
|
|
38
|
+
host,
|
|
39
|
+
measured: true,
|
|
40
|
+
admitted: false,
|
|
41
|
+
code: `${host}.${code}`,
|
|
42
|
+
params,
|
|
43
|
+
});
|
|
44
|
+
const notInstalled = (host: AdmittedHost): HostAdmission => ({ host, measured: false, admitted: false, code: `${host}.not-installed`, params: {} });
|
|
45
|
+
|
|
46
|
+
/** One command mod-host was sent and what came of it: its `resp` number, or how the host died. */
|
|
47
|
+
export interface ModHostStep {
|
|
48
|
+
readonly cmd: "add" | "remove";
|
|
49
|
+
readonly resp?: number;
|
|
50
|
+
/** The signal that killed the host, or `exit` / `closed` when it went without one. */
|
|
51
|
+
readonly died?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What `tools/mod-host-admit.mjs` printed. */
|
|
55
|
+
export interface ModHostRun {
|
|
56
|
+
readonly installed: boolean;
|
|
57
|
+
readonly steps: readonly ModHostStep[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The verdict of a mod-host run: the first step that did not answer `resp 0` decides. */
|
|
61
|
+
export function classifyModHostRun(run: ModHostRun): HostAdmission {
|
|
62
|
+
if (!run.installed) return notInstalled("mod-host");
|
|
63
|
+
for (const s of run.steps) {
|
|
64
|
+
if (s.died !== undefined) {
|
|
65
|
+
if (s.died.startsWith("SIG")) return refused("mod-host", `signal-${s.died}`, { step: s.cmd });
|
|
66
|
+
return s.cmd === "add" ? refused("mod-host", "died-on-add", { how: s.died }) : refused("mod-host", "died", { step: s.cmd, how: s.died });
|
|
67
|
+
}
|
|
68
|
+
if (s.resp === undefined) return refused("mod-host", "no-answer", { step: s.cmd });
|
|
69
|
+
// mod-host answers an error as a negative number (`resp -102`); the code carries its magnitude
|
|
70
|
+
if (s.resp < 0) return refused("mod-host", `resp-${-s.resp}`, { step: s.cmd, resp: s.resp });
|
|
71
|
+
}
|
|
72
|
+
if (run.steps.length === 0) return refused("mod-host", "no-answer");
|
|
73
|
+
return admitted("mod-host");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** What `jalv -n <name> <uri>` did in its window. */
|
|
77
|
+
export interface JalvRun {
|
|
78
|
+
readonly installed: boolean;
|
|
79
|
+
/** True when jalv was still running at the deadline and was stopped by the caller. */
|
|
80
|
+
readonly stillRunning: boolean;
|
|
81
|
+
readonly status: number | null;
|
|
82
|
+
readonly signal: string | null;
|
|
83
|
+
readonly stderr: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const UNKNOWN_PORT_TYPE = /Mandatory port "([^"]*)" has unknown type/;
|
|
87
|
+
|
|
88
|
+
/** The verdict of a jalv run: alive at the deadline is hosted; a signal, an unknown port type, or an exit is not. */
|
|
89
|
+
export function classifyJalvRun(run: JalvRun): HostAdmission {
|
|
90
|
+
if (!run.installed) return notInstalled("jalv");
|
|
91
|
+
if (run.stillRunning) return admitted("jalv");
|
|
92
|
+
if (run.signal !== null) return refused("jalv", `signal-${run.signal}`);
|
|
93
|
+
const port = UNKNOWN_PORT_TYPE.exec(run.stderr)?.[1];
|
|
94
|
+
if (port !== undefined) return refused("jalv", "unknown-port-type", { port });
|
|
95
|
+
if (run.status !== 0 && run.status !== null) return refused("jalv", `exit-${run.status}`);
|
|
96
|
+
return admitted("jalv");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** How long jalv must stay alive to count as hosting the plugin, in milliseconds. */
|
|
100
|
+
export const JALV_HOSTED_MS = 3000;
|
|
101
|
+
/** The wall clock of one whole mod-host admission, in milliseconds. */
|
|
102
|
+
export const MOD_HOST_ADMISSION_MS = 30000;
|
|
103
|
+
|
|
104
|
+
export interface AdmissionRuns {
|
|
105
|
+
readonly modHost: (uri: string) => ModHostRun;
|
|
106
|
+
readonly jalv: (uri: string) => JalvRun;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const MOD_HOST_ADMIT = fileURLToPath(new URL("../tools/mod-host-admit.mjs", import.meta.url));
|
|
110
|
+
|
|
111
|
+
/** The runs against the installed hosts. */
|
|
112
|
+
export const SPAWNED_ADMISSIONS: AdmissionRuns = {
|
|
113
|
+
modHost(uri) {
|
|
114
|
+
const r = spawnSync(process.execPath, [MOD_HOST_ADMIT, uri], { encoding: "utf8", timeout: MOD_HOST_ADMISSION_MS });
|
|
115
|
+
if (r.error !== undefined || r.status !== 0) {
|
|
116
|
+
throw new Error(`mod-host admission of ${uri} gave no answer (${r.error?.message ?? `exit ${String(r.status)}`}): ${(r.stderr ?? "").trim()}`);
|
|
117
|
+
}
|
|
118
|
+
return JSON.parse(r.stdout) as ModHostRun;
|
|
119
|
+
},
|
|
120
|
+
jalv(uri) {
|
|
121
|
+
const r = spawnSync("jalv", ["-n", "plugin-qualify", uri], { encoding: "utf8", timeout: JALV_HOSTED_MS, input: "" });
|
|
122
|
+
const code = (r.error as NodeJS.ErrnoException | undefined)?.code;
|
|
123
|
+
if (code === "ENOENT") return { installed: false, stillRunning: false, status: null, signal: null, stderr: "" };
|
|
124
|
+
return { installed: true, stillRunning: code === "ETIMEDOUT", status: r.status, signal: code === "ETIMEDOUT" ? null : r.signal, stderr: r.stderr ?? "" };
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** One admission of `uri` under `host`. */
|
|
129
|
+
export function admitPlugin(host: AdmittedHost, uri: string, runs: AdmissionRuns = SPAWNED_ADMISSIONS): HostAdmission {
|
|
130
|
+
try {
|
|
131
|
+
return host === "mod-host" ? classifyModHostRun(runs.modHost(uri)) : classifyJalvRun(runs.jalv(uri));
|
|
132
|
+
} catch (e) {
|
|
133
|
+
// the run itself broke: nothing was learned about the plugin, so nothing is rated
|
|
134
|
+
return { host, measured: false, admitted: false, code: `${host}.admission-failed`, params: { problem: e instanceof Error ? e.message : String(e) } };
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -25,3 +25,9 @@ export * from "./probe-judge.js";
|
|
|
25
25
|
export * from "./qualification-record.js";
|
|
26
26
|
export * from "./probe-runner.js";
|
|
27
27
|
export * from "./judge-cli.js";
|
|
28
|
+
export * from "./whole-file.js";
|
|
29
|
+
export * from "./qualify-sync.js";
|
|
30
|
+
export * from "./host-admission.js";
|
|
31
|
+
export * from "./history-file.js";
|
|
32
|
+
export * from "./history-adjust.js";
|
|
33
|
+
export * from "./adjust-cli.js";
|
package/src/lv2-loader.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* plugin, with its `lv2:binary` basename. Anything else shaped like a URI (`scheme:rest`) is one
|
|
7
7
|
* plugin. A path that does not exist is not this loader's — the CLI refuses it by name.
|
|
8
8
|
*
|
|
9
|
-
* The manifest read
|
|
10
|
-
*
|
|
9
|
+
* The manifest is read as plugin-hostd reads it to pin a plugin: statements of IRIs and prefixed names, with blank
|
|
10
|
+
* nodes and collections stepped over, not a whole Turtle parser. A plugin is a subject typed `lv2:Plugin`; its files
|
|
11
|
+
* are the ones the manifest names for it with `lv2:binary` or `rdfs:seeAlso`.
|
|
11
12
|
*/
|
|
12
13
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
13
14
|
import { homedir } from "node:os";
|
|
@@ -17,21 +18,144 @@ import type { PluginLoader, PluginTarget } from "./loader.js";
|
|
|
17
18
|
|
|
18
19
|
const URI_SHAPE = /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/;
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
21
|
+
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
|
22
|
+
const LV2_PLUGIN = "http://lv2plug.in/ns/lv2core#Plugin";
|
|
23
|
+
const LV2_BINARY = "http://lv2plug.in/ns/lv2core#binary";
|
|
24
|
+
const RDFS_SEE_ALSO = "http://www.w3.org/2000/01/rdf-schema#seeAlso";
|
|
25
|
+
|
|
26
|
+
type Token = { readonly kind: "iri" | "name" | "punct" | "literal"; readonly text: string };
|
|
27
|
+
|
|
28
|
+
/** The words of a Turtle text: IRIs, prefixed names, literals (with their tag or type) and punctuation; comments dropped. */
|
|
29
|
+
function turtleTokens(ttl: string): Token[] {
|
|
30
|
+
const out: Token[] = [];
|
|
31
|
+
let i = 0;
|
|
32
|
+
while (i < ttl.length) {
|
|
33
|
+
const c = ttl[i]!;
|
|
34
|
+
if (/\s/.test(c)) i++;
|
|
35
|
+
else if (c === "#") while (i < ttl.length && ttl[i] !== "\n") i++;
|
|
36
|
+
else if (c === "<") {
|
|
37
|
+
const end = ttl.indexOf(">", i);
|
|
38
|
+
if (end < 0) break;
|
|
39
|
+
out.push({ kind: "iri", text: ttl.slice(i + 1, end) });
|
|
40
|
+
i = end + 1;
|
|
41
|
+
} else if (c === '"' || c === "'") {
|
|
42
|
+
const quote = ttl.startsWith(c.repeat(3), i) ? c.repeat(3) : c;
|
|
43
|
+
let j = i + quote.length;
|
|
44
|
+
while (j < ttl.length && !ttl.startsWith(quote, j)) j += ttl[j] === "\\" ? 2 : 1;
|
|
45
|
+
i = j + quote.length;
|
|
46
|
+
// a language tag or a datatype belongs to the literal
|
|
47
|
+
if (ttl[i] === "@") while (i < ttl.length && /[A-Za-z0-9-@]/.test(ttl[i]!)) i++;
|
|
48
|
+
else if (ttl.startsWith("^^", i)) {
|
|
49
|
+
i += 2;
|
|
50
|
+
if (ttl[i] === "<") i = ttl.indexOf(">", i) + 1;
|
|
51
|
+
else while (i < ttl.length && /[^\s;,.\])]/.test(ttl[i]!)) i++;
|
|
52
|
+
}
|
|
53
|
+
out.push({ kind: "literal", text: "" });
|
|
54
|
+
} else if (".;,[]()".includes(c)) {
|
|
55
|
+
out.push({ kind: "punct", text: c });
|
|
56
|
+
i++;
|
|
57
|
+
} else {
|
|
58
|
+
let j = i;
|
|
59
|
+
while (j < ttl.length && /[^\s<>"';,\[\]()]/.test(ttl[j]!) && !(ttl[j] === "." && (j + 1 >= ttl.length || /\s/.test(ttl[j + 1]!)))) j++;
|
|
60
|
+
out.push({ kind: "name", text: ttl.slice(i, j) });
|
|
61
|
+
i = Math.max(j, i + 1);
|
|
62
|
+
}
|
|
31
63
|
}
|
|
32
64
|
return out;
|
|
33
65
|
}
|
|
34
66
|
|
|
67
|
+
/** A plugin a manifest declares, with the files it names for it, each relative to the bundle, in the order named. */
|
|
68
|
+
export interface ManifestPlugin {
|
|
69
|
+
readonly uri: string;
|
|
70
|
+
readonly files: readonly string[];
|
|
71
|
+
/** The basename of its `lv2:binary`, when it names one. */
|
|
72
|
+
readonly binary?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Every plugin a bundle manifest declares and the files it names for it with `lv2:binary` or `rdfs:seeAlso`: the
|
|
77
|
+
* files plugin-hostd's pin holds besides `manifest.ttl` itself. A file named outside the bundle (an absolute IRI) is
|
|
78
|
+
* not one of them.
|
|
79
|
+
*/
|
|
80
|
+
export function manifestPlugins(ttl: string): ManifestPlugin[] {
|
|
81
|
+
// the three vocabularies every manifest uses, so a manifest that leaves a prefix undeclared still reads
|
|
82
|
+
const prefixes = new Map<string, string>([
|
|
83
|
+
["lv2", "http://lv2plug.in/ns/lv2core#"],
|
|
84
|
+
["rdfs", "http://www.w3.org/2000/01/rdf-schema#"],
|
|
85
|
+
["rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"],
|
|
86
|
+
]);
|
|
87
|
+
const subjects = new Map<string, { typed: boolean; files: string[]; binary?: string }>();
|
|
88
|
+
const expand = (t: Token): string | undefined => {
|
|
89
|
+
if (t.kind === "iri") return t.text;
|
|
90
|
+
if (t.kind !== "name") return undefined;
|
|
91
|
+
if (t.text === "a") return RDF_TYPE;
|
|
92
|
+
const colon = t.text.indexOf(":");
|
|
93
|
+
const base = colon < 0 ? undefined : prefixes.get(t.text.slice(0, colon));
|
|
94
|
+
return base === undefined ? undefined : base + t.text.slice(colon + 1);
|
|
95
|
+
};
|
|
96
|
+
const tokens = turtleTokens(ttl);
|
|
97
|
+
let state: "subject" | "predicate" | "object" | "after" = "subject";
|
|
98
|
+
let depth = 0;
|
|
99
|
+
let subject: string | undefined;
|
|
100
|
+
let predicate: string | undefined;
|
|
101
|
+
for (let k = 0; k < tokens.length; k++) {
|
|
102
|
+
const t = tokens[k]!;
|
|
103
|
+
if (t.kind === "name" && /^(@prefix|PREFIX)$/i.test(t.text) && state === "subject" && depth === 0) {
|
|
104
|
+
const name = tokens[k + 1]?.text ?? "";
|
|
105
|
+
const iri = tokens[k + 2];
|
|
106
|
+
if (iri?.kind === "iri") prefixes.set(name.replace(/:$/, ""), iri.text);
|
|
107
|
+
k += t.text.startsWith("@") ? 3 : 2;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (t.kind === "name" && /^(@base|BASE)$/i.test(t.text) && state === "subject" && depth === 0) {
|
|
111
|
+
k += t.text.startsWith("@") ? 2 : 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (t.kind === "punct" && (t.text === "[" || t.text === "(")) {
|
|
115
|
+
if (depth++ === 0 && state === "subject") subject = undefined;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (t.kind === "punct" && (t.text === "]" || t.text === ")")) {
|
|
119
|
+
if (depth > 0 && --depth === 0) state = state === "subject" ? "predicate" : "after";
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (depth > 0) continue;
|
|
123
|
+
if (t.kind === "punct") {
|
|
124
|
+
if (t.text === ".") state = "subject";
|
|
125
|
+
else if (t.text === ";") state = "predicate";
|
|
126
|
+
else if (t.text === ",") state = "object";
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (state === "subject") {
|
|
130
|
+
subject = expand(t);
|
|
131
|
+
state = "predicate";
|
|
132
|
+
} else if (state === "predicate") {
|
|
133
|
+
predicate = expand(t);
|
|
134
|
+
state = "object";
|
|
135
|
+
} else if (state === "object") {
|
|
136
|
+
state = "after";
|
|
137
|
+
if (subject === undefined) continue;
|
|
138
|
+
const entry = subjects.get(subject) ?? { typed: false, files: [] };
|
|
139
|
+
subjects.set(subject, entry);
|
|
140
|
+
const object = expand(t);
|
|
141
|
+
if (predicate === RDF_TYPE && object === LV2_PLUGIN) entry.typed = true;
|
|
142
|
+
if ((predicate === LV2_BINARY || predicate === RDFS_SEE_ALSO) && t.kind === "iri" && !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(t.text) && !t.text.startsWith("/")) {
|
|
143
|
+
const file = t.text.replace(/^(\.\/)+/, "");
|
|
144
|
+
if (!entry.files.includes(file)) entry.files.push(file);
|
|
145
|
+
if (predicate === LV2_BINARY && entry.binary === undefined) entry.binary = basename(file);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return [...subjects.entries()]
|
|
150
|
+
.filter(([, e]) => e.typed)
|
|
151
|
+
.map(([uri, e]) => ({ uri, files: e.files, ...(e.binary === undefined ? {} : { binary: e.binary }) }));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The plugins a bundle manifest declares, each with its `lv2:binary` basename. */
|
|
155
|
+
export function pluginsInManifest(ttl: string): { uri: string; binary?: string }[] {
|
|
156
|
+
return manifestPlugins(ttl).map((p) => (p.binary === undefined ? { uri: p.uri } : { uri: p.uri, binary: p.binary }));
|
|
157
|
+
}
|
|
158
|
+
|
|
35
159
|
export const lv2Loader: PluginLoader = {
|
|
36
160
|
format: "lv2",
|
|
37
161
|
resolve(target: string): readonly PluginTarget[] | undefined {
|
|
@@ -55,7 +179,8 @@ const LV2_DEFAULT_DIRS = [".lv2", "/usr/lib/lv2", "/usr/lib64/lv2", "/usr/local/
|
|
|
55
179
|
|
|
56
180
|
/**
|
|
57
181
|
* Every `.lv2` bundle directly under the LV2 search path (`LV2_PATH`, else lilv's defaults). An
|
|
58
|
-
* absent directory holds none; any other fs error propagates for the caller to name.
|
|
182
|
+
* absent directory holds none; any other fs error propagates for the caller to name. A bundle is
|
|
183
|
+
* a directory holding `manifest.ttl`: a dangling `.lv2` symlink or a plain `.lv2` file is not one.
|
|
59
184
|
*/
|
|
60
185
|
export function lv2BundlesOnPath(env: Readonly<Record<string, string | undefined>>, home: string = homedir()): string[] {
|
|
61
186
|
const named = (env["LV2_PATH"] ?? "").split(":").filter((d) => d !== "");
|
|
@@ -70,7 +195,17 @@ export function lv2BundlesOnPath(env: Readonly<Record<string, string | undefined
|
|
|
70
195
|
if (code === "ENOENT" || code === "ENOTDIR") continue;
|
|
71
196
|
throw e;
|
|
72
197
|
}
|
|
73
|
-
for (const n of names.sort())
|
|
198
|
+
for (const n of names.sort()) {
|
|
199
|
+
if (!n.endsWith(".lv2")) continue;
|
|
200
|
+
const path = join(dir, n);
|
|
201
|
+
let isBundle: boolean;
|
|
202
|
+
try {
|
|
203
|
+
isBundle = statSync(path).isDirectory() && existsSync(join(path, "manifest.ttl"));
|
|
204
|
+
} catch {
|
|
205
|
+
isBundle = false; // a dangling symlink
|
|
206
|
+
}
|
|
207
|
+
if (isBundle) bundles.push(path);
|
|
208
|
+
}
|
|
74
209
|
}
|
|
75
210
|
return bundles;
|
|
76
211
|
}
|
package/src/measurement/cli.ts
CHANGED
|
@@ -43,6 +43,7 @@ import { measuredPluginsFromSweep } from "./sweep-jsonl.js";
|
|
|
43
43
|
import { isRecord, nonEmptyString } from "./guards.js";
|
|
44
44
|
import { HOSTING_PASS_DIMENSIONS, type MeasurementDimension, PROBED_DIMENSIONS } from "./vocabulary.js";
|
|
45
45
|
import { LV2_HOSTING_BLOCK_FRAMES, LV2_HOSTING_LIFECYCLES, LV2_HOSTING_REPEATS } from "../qualify-declarations.js";
|
|
46
|
+
import { extractJsonRecord } from "../measure-host.js";
|
|
46
47
|
|
|
47
48
|
/** Name and version this tool stamps into every document it writes. */
|
|
48
49
|
export const TOOL_NAME = "lv2-measure";
|
|
@@ -269,26 +270,19 @@ export interface HostingRunResult {
|
|
|
269
270
|
}
|
|
270
271
|
|
|
271
272
|
/**
|
|
272
|
-
* One plugin's run of the native measurer as the hosting sweep's JSONL line:
|
|
273
|
-
*
|
|
274
|
-
* beside it, a death written as 128 + its signal and a timeout
|
|
275
|
-
*
|
|
273
|
+
* One plugin's run of the native measurer as the hosting sweep's JSONL line: its stdout's JSON
|
|
274
|
+
* record ({@link extractJsonRecord}, which repairs a plugin's banner printed into the middle of
|
|
275
|
+
* the line), with the exit status beside it, a death written as 128 + its signal and a timeout
|
|
276
|
+
* as 124. A run whose stdout had a `{`-leading line that never repaired is `parseError`; one
|
|
277
|
+
* with no such line at all is `crashed`. Either way the exit is kept: what it means is the
|
|
278
|
+
* reader's (`sweep-jsonl.ts`) to say.
|
|
276
279
|
*/
|
|
277
280
|
export function hostingLine(uri: string, run: HostingRunResult): Record<string, unknown> {
|
|
278
281
|
const exit = run.timedOut === true ? 124 : run.signalNumber !== undefined ? 128 + run.signalNumber : (run.status ?? 1);
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (last !== undefined) {
|
|
284
|
-
try {
|
|
285
|
-
const row: unknown = JSON.parse(last);
|
|
286
|
-
if (isRecord(row)) return { ...row, exit };
|
|
287
|
-
} catch {
|
|
288
|
-
return { uri, exit, parseError: true };
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
return { uri, exit, crashed: true };
|
|
282
|
+
const row = extractJsonRecord(run.stdout);
|
|
283
|
+
if (row !== undefined) return { ...row, exit };
|
|
284
|
+
const hadCandidate = run.stdout.split("\n").some((line) => line.startsWith("{"));
|
|
285
|
+
return hadCandidate ? { uri, exit, parseError: true } : { uri, exit, crashed: true };
|
|
292
286
|
}
|
|
293
287
|
|
|
294
288
|
// ---------------------------------------------------------------------------
|
package/src/pins-file.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { resolve } from "node:path";
|
|
|
16
16
|
|
|
17
17
|
import type { RtSafetyDocument } from "./clap-qualify.js";
|
|
18
18
|
import type { IntegrityMeasurement, MeasuredPlugin } from "./measurement/format.js";
|
|
19
|
+
import { integrityOfProbeOutput, type Judgment } from "./probe-judge.js";
|
|
19
20
|
import type { QualifyVerdict } from "./qualify-cli.js";
|
|
20
21
|
import type { SuitabilityRating } from "./rating.js";
|
|
21
22
|
|
|
@@ -86,11 +87,11 @@ export interface PinsFile {
|
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
/**
|
|
89
|
-
* The file: a comment that says
|
|
90
|
+
* The file: a comment that says which command wrote it, then one line per plugin, sorted by uri. A uri given twice keeps
|
|
90
91
|
* the entry whose verdict is the worse one (`refused` over `isolated` over `in-process`, no word over all), so a
|
|
91
92
|
* plugin found twice is never pinned more kindly than its worst measurement.
|
|
92
93
|
*/
|
|
93
|
-
export function pinsFile(entries: readonly PinEntry[]): PinsFile {
|
|
94
|
+
export function pinsFile(entries: readonly PinEntry[], writer = "plugin-qualify --pins"): PinsFile {
|
|
94
95
|
const byUri = new Map<string, string>();
|
|
95
96
|
const verdictOf = new Map<string, PinVerdict | undefined>();
|
|
96
97
|
const skipped: string[] = [];
|
|
@@ -114,7 +115,7 @@ export function pinsFile(entries: readonly PinEntry[]): PinsFile {
|
|
|
114
115
|
}
|
|
115
116
|
const uris = [...byUri.keys()].sort();
|
|
116
117
|
const text = [
|
|
117
|
-
|
|
118
|
+
`# written by ${writer}; plugin-hostd reads it with pin_file`,
|
|
118
119
|
...uris.map((u) => byUri.get(u)!),
|
|
119
120
|
"",
|
|
120
121
|
].join("\n");
|
|
@@ -138,6 +139,19 @@ export function clapPinEntries(documents: readonly RtSafetyDocument[], host: str
|
|
|
138
139
|
});
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The entry of one judged probe output for one host, CLAP or LV2: the pin the run took and the verdict that host's
|
|
144
|
+
* judgment gives it. A run that did not finish has no verdict word; a host the judgment does not hold, no entry.
|
|
145
|
+
*/
|
|
146
|
+
export function judgmentPinEntries(judgment: Judgment, host: string): PinEntry[] {
|
|
147
|
+
const judged = judgment.hosts.find((h) => h.verdict.host === host);
|
|
148
|
+
if (judged === undefined) return [];
|
|
149
|
+
const o = judgment.output;
|
|
150
|
+
const uri = o.plugin.format === "clap" ? clapPinUri(o.binary.path, `urn:clap:${o.plugin.id}`) : o.plugin.id;
|
|
151
|
+
const verdict = judged.verdict.measured ? pinVerdictOf(judged.verdict.rating) : undefined;
|
|
152
|
+
return [{ uri, integrity: integrityOfProbeOutput(o), ...(verdict === undefined ? {} : { verdict }) }];
|
|
153
|
+
}
|
|
154
|
+
|
|
141
155
|
/**
|
|
142
156
|
* The entries of an LV2 run: each measured plugin with a pin and the verdict row of that host. A plugin with no verdict
|
|
143
157
|
* row for the host is pinned without a verdict word.
|
package/src/probe-judge.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
type HostingReason,
|
|
34
34
|
} from "./hosting-suitability.js";
|
|
35
35
|
import type { HostProfile } from "./host-profiles.js";
|
|
36
|
+
import { admittedHostOf, type HostAdmission } from "./host-admission.js";
|
|
36
37
|
import { PROBE_NOT_ATTEMPTED, PROBE_OK, type ProbeGridPoint, type ProbeOutput } from "./probe-output.js";
|
|
37
38
|
import type { QualifyVerdict } from "./qualify-cli.js";
|
|
38
39
|
import { RATING_SEVERITY, worstRating, type SuitabilityRating } from "./rating.js";
|
|
@@ -42,6 +43,11 @@ import type { IntegrityMeasurement, VoicesHeld } from "./measurement/format.js";
|
|
|
42
43
|
export interface JudgeFacts {
|
|
43
44
|
/** Note input ports the plugin declares. */
|
|
44
45
|
readonly noteInputs?: number;
|
|
46
|
+
/**
|
|
47
|
+
* The plugin run under each measured host, by profile id (`host-admission.ts`). A profile that has one is
|
|
48
|
+
* judged by it as well as by its declaration; one that has none is judged from its declaration alone.
|
|
49
|
+
*/
|
|
50
|
+
readonly admissions?: Readonly<Record<string, HostAdmission>>;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
/** A sum of one count over the points, or `undefined` when a point lacks it: a missing count is not zero. */
|
|
@@ -102,7 +108,8 @@ function noteSweepOfProbeOutput(output: ProbeOutput, rt: ClapRtSafetyReading): C
|
|
|
102
108
|
};
|
|
103
109
|
}
|
|
104
110
|
|
|
105
|
-
|
|
111
|
+
/** The pin a probe output took: its files and layout, or why it has none. */
|
|
112
|
+
export function integrityOfProbeOutput(output: ProbeOutput): IntegrityMeasurement {
|
|
106
113
|
const { files, layout, unpinnable } = output.binary;
|
|
107
114
|
if (layout === undefined || files.length === 0) {
|
|
108
115
|
return { unmeasuredReason: unpinnable === undefined ? "probe-error" : "unpinnable", unmeasuredDetail: unpinnable ?? "the probe output holds no layout pin" };
|
|
@@ -168,6 +175,8 @@ export interface HostJudgment {
|
|
|
168
175
|
readonly verdict: QualifyVerdict;
|
|
169
176
|
/** In-process only for a suitable plugin on a host that runs its plugins in its own process, else isolated. */
|
|
170
177
|
readonly hosting: "in-process" | "isolated";
|
|
178
|
+
/** `run` when the plugin was run under this host, `declaration` when the verdict is only what the profile's declaration makes of the probe output. */
|
|
179
|
+
readonly arm: "run" | "declaration";
|
|
171
180
|
readonly reasons: readonly RecordReason[];
|
|
172
181
|
/**
|
|
173
182
|
* The cost reasons at this host's rate and quantum, kept beside the verdict, not folded into it; for an instrument,
|
|
@@ -228,6 +237,7 @@ export function judgeProbeOutput(output: ProbeOutput, profiles: readonly HostPro
|
|
|
228
237
|
hosts: profiles.map((profile) => ({
|
|
229
238
|
verdict: { uri, host: profile.id, measured: false, rating: "unknown", qualified: false, deciding: failure },
|
|
230
239
|
hosting: "isolated",
|
|
240
|
+
arm: "declaration",
|
|
231
241
|
reasons: [reason],
|
|
232
242
|
cost: costOf(output, profile),
|
|
233
243
|
})),
|
|
@@ -247,6 +257,21 @@ export function judgeProbeOutput(output: ProbeOutput, profiles: readonly HostPro
|
|
|
247
257
|
const notes = noteSweepOfProbeOutput(output, rt);
|
|
248
258
|
reasons = [...rtSafetyReasons(rt), ...noteSweepReasons(notes, facts.noteInputs ?? (notes.applicable ? 1 : 0))].map(asRecordReason);
|
|
249
259
|
}
|
|
260
|
+
const admission = facts.admissions?.[profile.id];
|
|
261
|
+
if (admission !== undefined && admittedHostOf(profile) !== undefined) {
|
|
262
|
+
if (!admission.measured || !admission.admitted) {
|
|
263
|
+
// the host could not be run, or the plugin did not survive it: the run decides, whatever the declaration says
|
|
264
|
+
const code = admission.code ?? `${admission.host}.not-admitted`;
|
|
265
|
+
const reason: RecordReason = { dimension: "hostAdmission", rating: admission.measured ? "unsuitable" : "unknown", code, params: admission.params };
|
|
266
|
+
return {
|
|
267
|
+
verdict: { uri, host: profile.id, measured: admission.measured, rating: reason.rating, qualified: false, deciding: code },
|
|
268
|
+
hosting: "isolated",
|
|
269
|
+
arm: "run",
|
|
270
|
+
reasons: [reason, ...reasons],
|
|
271
|
+
cost: costOf(output, profile),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
250
275
|
const rating = worstRating(reasons.map((r) => r.rating));
|
|
251
276
|
const deciding = decidingOf(reasons, rating);
|
|
252
277
|
return {
|
|
@@ -259,6 +284,7 @@ export function judgeProbeOutput(output: ProbeOutput, profiles: readonly HostPro
|
|
|
259
284
|
...(deciding === undefined ? {} : { deciding: deciding.code }),
|
|
260
285
|
},
|
|
261
286
|
hosting: rating === "suitable" && profile.isolation === "in-process" ? "in-process" : "isolated",
|
|
287
|
+
arm: admission !== undefined && admittedHostOf(profile) !== undefined ? "run" : "declaration",
|
|
262
288
|
reasons,
|
|
263
289
|
cost: costOf(output, profile),
|
|
264
290
|
};
|
|
@@ -144,3 +144,89 @@ export function clapLineFixture(patch: {
|
|
|
144
144
|
};
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
|
|
148
|
+
/** One LV2 measurer line, as `lv2_measure` prints it: the keys the translation reads. */
|
|
149
|
+
export type Lv2MeasureLine = Record<string, any>;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The probe output the runner writes for an LV2 measurer line: the grid point of the run's rate and block with what
|
|
153
|
+
* run() did under the interposer, and, under the same field names as a CLAP point, the `latency` and `cost` the line holds
|
|
154
|
+
* at that rate. A latency or cost the line could not take is no field of the point: it is a step that says so, with the
|
|
155
|
+
* hosting code, and the run still ends `probe.ok`. The runner's translation in the small (plugin-hostd's `put_lv2_measure`), as
|
|
156
|
+
* `probeOutputOfLine` is its CLAP one, so a record is read from a real measurement through the real reader.
|
|
157
|
+
*/
|
|
158
|
+
export function probeOutputOfLv2Line(line: Lv2MeasureLine): Record<string, any> {
|
|
159
|
+
const doc = fixtureJson("success-msi");
|
|
160
|
+
const rate = Number(line["rate"]);
|
|
161
|
+
const quantum = Number(line["block"]);
|
|
162
|
+
const rt = line["rtSafety"];
|
|
163
|
+
const key = String(rate);
|
|
164
|
+
const latency = line["latency"]?.[key];
|
|
165
|
+
const cost = line["cost"]?.perRate?.[key];
|
|
166
|
+
const pin = line["integrity"];
|
|
167
|
+
doc["plugin"] = { format: "lv2", id: line["uri"], ...(pin?.version === undefined ? {} : { version: pin.version }) };
|
|
168
|
+
doc["binary"] = {
|
|
169
|
+
path: pin?.files?.find((f: { path: string }) => !f.path.endsWith(".ttl"))?.path ?? "plugin.so",
|
|
170
|
+
sha256: pin?.files?.find((f: { path: string }) => !f.path.endsWith(".ttl"))?.sha256 ?? "0".repeat(64),
|
|
171
|
+
files: pin?.files ?? [],
|
|
172
|
+
...(pin?.layout === undefined ? {} : { layout: pin.layout }),
|
|
173
|
+
};
|
|
174
|
+
delete doc["descriptor"];
|
|
175
|
+
doc["offer"] = { id: "default", realtime: "none" };
|
|
176
|
+
const steps: Record<string, unknown>[] = [
|
|
177
|
+
{ step: "hash", outcome: "probe.ok", elapsedMs: 1 },
|
|
178
|
+
{ step: "instantiate", outcome: "probe.ok", elapsedMs: 1 },
|
|
179
|
+
{ step: "rt-safety", rate, quantum, outcome: "probe.ok", elapsedMs: 1 },
|
|
180
|
+
];
|
|
181
|
+
const absent = (step: "latency" | "cost", at: any): void => {
|
|
182
|
+
steps.push({
|
|
183
|
+
step,
|
|
184
|
+
rate,
|
|
185
|
+
quantum,
|
|
186
|
+
outcome: "probe.not-attempted",
|
|
187
|
+
elapsedMs: 1,
|
|
188
|
+
hostingCode: step === "latency" ? "hosting.latency.unmeasured" : "hosting.cost.unmeasured-at-rate-and-quantum",
|
|
189
|
+
detail: `${at?.unmeasuredReason ?? "not-attempted"}: ${at?.unmeasuredDetail ?? "the measurer's line holds no figure"}`,
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
const point: Record<string, unknown> = { rate, quantum, outcome: "probe.ok" };
|
|
193
|
+
if (latency?.measuredFrames !== undefined) {
|
|
194
|
+
steps.push({ step: "latency", rate, quantum, outcome: "probe.ok", elapsedMs: 1 });
|
|
195
|
+
point["latency"] = {
|
|
196
|
+
declaredFrames: latency.declaredFrames,
|
|
197
|
+
measuredFrames: latency.measuredFrames,
|
|
198
|
+
method: latency.from === "note" ? "note-onset" : "impulse-onset",
|
|
199
|
+
...(latency.from === "note" ? { heard: true } : {}),
|
|
200
|
+
};
|
|
201
|
+
} else absent("latency", latency);
|
|
202
|
+
if (cost?.nsPerSampleMedian !== undefined) {
|
|
203
|
+
steps.push({ step: "cost", rate, quantum, outcome: "probe.ok", elapsedMs: 1 });
|
|
204
|
+
point["cost"] = {
|
|
205
|
+
unit: "ns-per-sample",
|
|
206
|
+
median: cost.nsPerSampleMedian,
|
|
207
|
+
percentile: { p: line["cost"].percentile, value: cost.nsPerSampleP95 },
|
|
208
|
+
min: cost.nsPerSampleMin,
|
|
209
|
+
max: cost.nsPerSampleMax,
|
|
210
|
+
blocks: cost.blocks,
|
|
211
|
+
warmupBlocks: line["cost"].warmupBlocks,
|
|
212
|
+
...(cost.voices === undefined ? {} : { voices: cost.voices }),
|
|
213
|
+
};
|
|
214
|
+
} else absent("cost", cost);
|
|
215
|
+
if (rt !== undefined) {
|
|
216
|
+
point["rtSafety"] = {
|
|
217
|
+
interposer: rt.interposer,
|
|
218
|
+
swept: rt.swept,
|
|
219
|
+
sweepApplicable: rt.sweepApplicable,
|
|
220
|
+
blocks: rt.blocks,
|
|
221
|
+
allocations: rt.allocationsInRun,
|
|
222
|
+
locks: rt.locksInRun,
|
|
223
|
+
syscalls: rt.syscallsInRun,
|
|
224
|
+
events: [],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
point["noteSweep"] = { applicable: rt?.noteSweep?.applicable === true };
|
|
228
|
+
doc["steps"] = steps;
|
|
229
|
+
doc["grid"] = [point];
|
|
230
|
+
doc["behaviour"] = { lifecycle: line["lifecycle"], threads: line["threads"], features: { required: line["requiredFeatures"] ?? [] } };
|
|
231
|
+
return doc;
|
|
232
|
+
}
|
package/src/probe-output.ts
CHANGED
|
@@ -102,6 +102,8 @@ export interface ProbeStep {
|
|
|
102
102
|
readonly outcome: string;
|
|
103
103
|
readonly rate?: number;
|
|
104
104
|
readonly quantum?: number;
|
|
105
|
+
/** The hosting code the step carries when it says why a figure is absent (`hosting.latency.unmeasured`). */
|
|
106
|
+
readonly hostingCode?: string;
|
|
105
107
|
readonly detail?: string;
|
|
106
108
|
}
|
|
107
109
|
|
|
@@ -310,6 +312,7 @@ function readStep(v: unknown, i: number): ProbeStep {
|
|
|
310
312
|
outcome: outcome(o, where),
|
|
311
313
|
...optional("rate", o["rate"] === undefined ? undefined : posInt(o, "rate", where)),
|
|
312
314
|
...optional("quantum", o["quantum"] === undefined ? undefined : posInt(o, "quantum", where)),
|
|
315
|
+
...optional("hostingCode", nonEmptyString(o["hostingCode"])),
|
|
313
316
|
...optional("detail", nonEmptyString(o["detail"])),
|
|
314
317
|
};
|
|
315
318
|
}
|