@openmixer/plugin-qualify 0.14.1 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +117 -14
  2. package/bin/plugin-qualify-sync.mjs +57 -0
  3. package/bin/plugin-qualify.mjs +66 -15
  4. package/dist/adjust-cli.d.ts +83 -0
  5. package/dist/adjust-cli.js +214 -0
  6. package/dist/adjust-cli.js.map +1 -0
  7. package/dist/clap-loader.d.ts +3 -3
  8. package/dist/clap-loader.js +11 -11
  9. package/dist/clap-loader.js.map +1 -1
  10. package/dist/history-adjust.d.ts +77 -0
  11. package/dist/history-adjust.js +99 -0
  12. package/dist/history-adjust.js.map +1 -0
  13. package/dist/history-file.d.ts +73 -0
  14. package/dist/history-file.js +133 -0
  15. package/dist/history-file.js.map +1 -0
  16. package/dist/host-admission.d.ts +53 -0
  17. package/dist/host-admission.js +96 -0
  18. package/dist/host-admission.js.map +1 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +6 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/lv2-loader.d.ts +16 -2
  23. package/dist/lv2-loader.js +168 -17
  24. package/dist/lv2-loader.js.map +1 -1
  25. package/dist/measurement/cli.d.ts +6 -4
  26. package/dist/measurement/cli.js +12 -19
  27. package/dist/measurement/cli.js.map +1 -1
  28. package/dist/pins-file.d.ts +8 -2
  29. package/dist/pins-file.js +17 -3
  30. package/dist/pins-file.js.map +1 -1
  31. package/dist/probe-judge.d.ts +11 -1
  32. package/dist/probe-judge.js +20 -1
  33. package/dist/probe-judge.js.map +1 -1
  34. package/dist/probe-output-fixtures.d.ts +10 -0
  35. package/dist/probe-output-fixtures.js +86 -0
  36. package/dist/probe-output-fixtures.js.map +1 -1
  37. package/dist/probe-output.d.ts +2 -0
  38. package/dist/probe-output.js +1 -0
  39. package/dist/probe-output.js.map +1 -1
  40. package/dist/probe-runner.d.ts +9 -3
  41. package/dist/probe-runner.js +36 -12
  42. package/dist/probe-runner.js.map +1 -1
  43. package/dist/qualification-record.d.ts +109 -3
  44. package/dist/qualification-record.js +79 -13
  45. package/dist/qualification-record.js.map +1 -1
  46. package/dist/qualify-cli.d.ts +0 -1
  47. package/dist/qualify-cli.js +2 -4
  48. package/dist/qualify-cli.js.map +1 -1
  49. package/dist/qualify-declarations.d.ts +1 -1
  50. package/dist/qualify-declarations.js +1 -1
  51. package/dist/qualify-options.js +1 -2
  52. package/dist/qualify-options.js.map +1 -1
  53. package/dist/qualify-sync.d.ts +202 -0
  54. package/dist/qualify-sync.js +731 -0
  55. package/dist/qualify-sync.js.map +1 -0
  56. package/dist/whole-file.d.ts +3 -0
  57. package/dist/whole-file.js +36 -0
  58. package/dist/whole-file.js.map +1 -0
  59. package/fixtures/admission/2026-10-26.json +19 -0
  60. package/fixtures/clap-scan/fake.json +24 -24
  61. package/package.json +5 -4
  62. package/src/adjust-cli.ts +262 -0
  63. package/src/clap-loader.ts +9 -13
  64. package/src/history-adjust.ts +156 -0
  65. package/src/history-file.ts +177 -0
  66. package/src/host-admission.ts +136 -0
  67. package/src/index.ts +6 -0
  68. package/src/lv2-loader.ts +150 -15
  69. package/src/measurement/cli.ts +11 -17
  70. package/src/pins-file.ts +17 -3
  71. package/src/probe-judge.ts +27 -1
  72. package/src/probe-output-fixtures.ts +86 -0
  73. package/src/probe-output.ts +3 -0
  74. package/src/probe-runner.ts +45 -14
  75. package/src/qualification-record.ts +174 -15
  76. package/src/qualify-cli.ts +2 -5
  77. package/src/qualify-declarations.ts +1 -1
  78. package/src/qualify-options.ts +1 -2
  79. package/src/qualify-sync.ts +830 -0
  80. package/src/whole-file.ts +35 -0
  81. package/tools/_declarations.py +1 -1
  82. package/tools/gen-hosting-codes.mjs +1 -1
  83. 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 is the subset every LV2 bundle's manifest uses (subject, `a lv2:Plugin`,
10
- * `lv2:binary <x.so>`), not a Turtle parser; `--all` goes through lilv itself.
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
- /** The plugins a bundle manifest declares: one statement per subject, `;`-separated predicates. */
21
- export function pluginsInManifest(ttl: string): { uri: string; binary?: string }[] {
22
- const body = ttl.replace(/#[^\n>]*$/gm, "");
23
- const out: { uri: string; binary?: string }[] = [];
24
- const statement = /<([^>]+)>\s+((?:[^.<"]|<[^>]*>|"[^"]*")*)\./g;
25
- for (let m = statement.exec(body); m !== null; m = statement.exec(body)) {
26
- const [, subject, predicates] = m;
27
- if (subject === undefined || predicates === undefined) continue;
28
- if (!/(^|;)\s*a\s+lv2:Plugin\b/.test(predicates)) continue;
29
- const bin = /lv2:binary\s+<([^>]+)>/.exec(predicates)?.[1];
30
- out.push(bin === undefined ? { uri: subject } : { uri: subject, binary: basename(bin) });
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()) if (n.endsWith(".lv2")) bundles.push(join(dir, n));
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
  }
@@ -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: the LAST line of its
273
- * stdout that is a JSON object (a plugin may print its own banner first), with the exit status
274
- * beside it, a death written as 128 + its signal and a timeout as 124. A run that printed no line
275
- * is `crashed`, its exit kept: what that exit means is the reader's (`sweep-jsonl.ts`) to say.
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 last = run.stdout
280
- .split("\n")
281
- .filter((line) => line.startsWith("{"))
282
- .pop();
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 where it came from, then one line per plugin, sorted by uri. A uri given twice keeps
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
- "# written by plugin-qualify --pins; plugin-hostd reads it with pin_file",
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.
@@ -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
- function integrityOfProbeOutput(output: ProbeOutput): IntegrityMeasurement {
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
+ }
@@ -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
  }