@openmixer/plugin-qualify 0.8.0 → 0.10.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 (63) hide show
  1. package/README.md +58 -4
  2. package/bin/plugin-qualify.mjs +60 -25
  3. package/dist/clap-loader.d.ts +25 -1
  4. package/dist/clap-loader.js +88 -7
  5. package/dist/clap-loader.js.map +1 -1
  6. package/dist/clap-qualify.d.ts +29 -6
  7. package/dist/clap-qualify.js +77 -13
  8. package/dist/clap-qualify.js.map +1 -1
  9. package/dist/host-profiles.d.ts +14 -0
  10. package/dist/host-profiles.js +27 -1
  11. package/dist/host-profiles.js.map +1 -1
  12. package/dist/hosting-codes.d.ts +5 -2
  13. package/dist/hosting-codes.js +8 -4
  14. package/dist/hosting-codes.js.map +1 -1
  15. package/dist/hosting-suitability.d.ts +32 -3
  16. package/dist/hosting-suitability.js +35 -6
  17. package/dist/hosting-suitability.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/lv2-loader.d.ts +5 -0
  22. package/dist/lv2-loader.js +30 -2
  23. package/dist/lv2-loader.js.map +1 -1
  24. package/dist/measure-host.js +2 -2
  25. package/dist/measurement/cli.d.ts +13 -0
  26. package/dist/measurement/cli.js +68 -2
  27. package/dist/measurement/cli.js.map +1 -1
  28. package/dist/measurement/format.d.ts +1 -1
  29. package/dist/measurement/vocabulary.d.ts +1 -1
  30. package/dist/measurement/vocabulary.js +1 -1
  31. package/dist/measurement/vocabulary.js.map +1 -1
  32. package/dist/pins-file.d.ts +76 -0
  33. package/dist/pins-file.js +165 -0
  34. package/dist/pins-file.js.map +1 -0
  35. package/dist/qualify-cli.d.ts +2 -0
  36. package/dist/qualify-cli.js +7 -1
  37. package/dist/qualify-cli.js.map +1 -1
  38. package/dist/qualify-declarations.d.ts +23 -4
  39. package/dist/qualify-declarations.js +19 -3
  40. package/dist/qualify-declarations.js.map +1 -1
  41. package/dist/qualify-options.js +6 -1
  42. package/dist/qualify-options.js.map +1 -1
  43. package/package.json +3 -2
  44. package/src/clap-loader.ts +99 -7
  45. package/src/clap-qualify.ts +89 -12
  46. package/src/host-profiles.ts +29 -1
  47. package/src/hosting-codes.ts +8 -4
  48. package/src/hosting-suitability.ts +61 -8
  49. package/src/index.ts +1 -0
  50. package/src/lv2-loader.ts +28 -2
  51. package/src/measure-host.ts +2 -2
  52. package/src/measurement/cli.ts +77 -2
  53. package/src/measurement/format.ts +1 -1
  54. package/src/measurement/vocabulary.ts +1 -1
  55. package/src/pins-file.ts +200 -0
  56. package/src/qualify-cli.ts +8 -1
  57. package/src/qualify-declarations.ts +21 -4
  58. package/src/qualify-options.ts +7 -1
  59. package/tools/ai-trace-commit-check.sh +114 -0
  60. package/tools/ai-trace-patterns.json +33 -0
  61. package/tools/benchmark.py +1 -1
  62. package/tools/lv2-measure.mjs +16 -0
  63. package/tools/private-refs-check.sh +90 -0
@@ -0,0 +1,200 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The pins file `plugin-qualify --pins <file>` writes and plugin-hostd loads (`pin_file` in its settings, or its
5
+ * pin store's import): one line per qualified plugin, the SAME line as hostd's `pin_set` verb, with the
6
+ * qualification verdict as a last word.
7
+ *
8
+ * pin_set <uri> <path>=<sha256>[,<path>=<sha256>...] <scheme>:<sha256> [verdict=<word>]
9
+ *
10
+ * The digests and the layout are the prober's, raw (`IntegrityMeasurement`); nothing here computes a hash. A plugin
11
+ * whose run took no whole pin has no line: a pins file never holds a partial pin. The grammar is plugin-hostd's
12
+ * (`include/plugin-hostd/protocol.h`, PHD_PIN_*); `parsePinLine` reads a line back with the daemon's rules, so a
13
+ * writer that drifts from them is a red test and not a refused add.
14
+ */
15
+ import { resolve } from "node:path";
16
+
17
+ import type { RtSafetyDocument } from "./clap-qualify.js";
18
+ import type { IntegrityMeasurement, MeasuredPlugin } from "./measurement/format.js";
19
+ import type { QualifyVerdict } from "./qualify-cli.js";
20
+ import type { SuitabilityRating } from "./rating.js";
21
+
22
+ /** The verb the line starts with in a pins file, hostd's `PHD_VERB_PIN_SET`. */
23
+ export const PIN_VERB = "pin_set";
24
+
25
+ /** The key of the verdict word, hostd's `PHD_PIN_VERDICT_KEY`. */
26
+ export const PIN_VERDICT_KEY = "verdict=";
27
+
28
+ /** The verdict words hostd knows (`PHD_PIN_VERDICTS`). */
29
+ export const PIN_VERDICTS = ["in-process", "isolated", "refused"] as const;
30
+ export type PinVerdict = (typeof PIN_VERDICTS)[number];
31
+
32
+ /**
33
+ * What a rating says about where the plugin may run, in hostd's words. `suitable` is qualified for a worker that other
34
+ * plugins share. `conditional` loads and is isolated. `unsuitable` is measured to fail and is `refused`. `unknown` is no
35
+ * opinion: no verdict word, and hostd treats the plugin as not qualified.
36
+ */
37
+ export function pinVerdictOf(rating: SuitabilityRating): PinVerdict | undefined {
38
+ switch (rating) {
39
+ case "suitable":
40
+ return "in-process";
41
+ case "conditional":
42
+ return "isolated";
43
+ case "unsuitable":
44
+ return "refused";
45
+ case "unknown":
46
+ return undefined;
47
+ }
48
+ }
49
+
50
+ /** One pin: what hostd's `pin_set` takes, and the verdict of the run that took it. */
51
+ export interface PinEntry {
52
+ /** hostd's uri: `clap:<path of the .clap>#<id>`, or an LV2 plugin's own URI. */
53
+ readonly uri: string;
54
+ readonly integrity: IntegrityMeasurement;
55
+ readonly verdict?: PinVerdict;
56
+ }
57
+
58
+ /** The line of one pin, or `undefined` when the integrity block is not a whole pin. A uri with whitespace in it would be read back as more words: it is refused, by name. */
59
+ export function pinLine(entry: PinEntry): string | undefined {
60
+ const spaced = [entry.uri, ...(entry.integrity.files ?? []).map((f) => f.path)].find((w) => /\s/.test(w));
61
+ if (spaced !== undefined) {
62
+ throw new Error(`${entry.uri}: ${JSON.stringify(spaced)} has whitespace in it and cannot be a word of a pin line`);
63
+ }
64
+ const { files, layout } = entry.integrity;
65
+ if (files === undefined || files.length === 0 || layout === undefined) return undefined;
66
+ const list = files.map((f) => `${f.path}=${f.sha256}`).join(",");
67
+ const verdict = entry.verdict === undefined ? "" : ` ${PIN_VERDICT_KEY}${entry.verdict}`;
68
+ return `${PIN_VERB} ${entry.uri} ${list} ${layout.scheme}:${layout.sha256}${verdict}`;
69
+ }
70
+
71
+ /** hostd's uri of a CLAP plugin the prober loaded: its file as it was handed, made absolute, and its id. */
72
+ export function clapPinUri(binaryPath: string, pluginUri: string): string {
73
+ const id = pluginUri.startsWith("urn:clap:") ? pluginUri.slice("urn:clap:".length) : pluginUri;
74
+ return `clap:${resolve(binaryPath)}#${id}`;
75
+ }
76
+
77
+ /** What went into a pins file and what did not. */
78
+ export interface PinsFile {
79
+ readonly text: string;
80
+ /** The uris written. */
81
+ readonly written: readonly string[];
82
+ /** The uris of plugins that took no whole pin: they have no line. */
83
+ readonly skipped: readonly string[];
84
+ /** The uris refused because a word of their line has whitespace in it, each with the reason: they have no line. */
85
+ readonly refused: readonly { readonly uri: string; readonly problem: string }[];
86
+ }
87
+
88
+ /**
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 entry whose verdict is the worse one (`refused` over `isolated` over `in-process`, no word over all), so a
91
+ * plugin found twice is never pinned more kindly than its worst measurement.
92
+ */
93
+ export function pinsFile(entries: readonly PinEntry[]): PinsFile {
94
+ const byUri = new Map<string, string>();
95
+ const verdictOf = new Map<string, PinVerdict | undefined>();
96
+ const skipped: string[] = [];
97
+ const refused: { uri: string; problem: string }[] = [];
98
+ const rank = (v: PinVerdict | undefined): number => (v === undefined ? 4 : { "in-process": 1, isolated: 2, refused: 3 }[v]);
99
+ for (const entry of entries) {
100
+ let line: string | undefined;
101
+ try {
102
+ line = pinLine(entry);
103
+ } catch (e) {
104
+ refused.push({ uri: entry.uri, problem: (e as Error).message });
105
+ continue;
106
+ }
107
+ if (line === undefined) {
108
+ skipped.push(entry.uri);
109
+ continue;
110
+ }
111
+ if (byUri.has(entry.uri) && rank(verdictOf.get(entry.uri)) >= rank(entry.verdict)) continue;
112
+ byUri.set(entry.uri, line);
113
+ verdictOf.set(entry.uri, entry.verdict);
114
+ }
115
+ const uris = [...byUri.keys()].sort();
116
+ const text = [
117
+ "# written by plugin-qualify --pins; plugin-hostd reads it with pin_file",
118
+ ...uris.map((u) => byUri.get(u)!),
119
+ "",
120
+ ].join("\n");
121
+ return { text, written: uris, skipped: skipped.filter((u) => !byUri.has(u)), refused };
122
+ }
123
+
124
+ /**
125
+ * The entries of a CLAP pass for one host: each document of that host's profile with its pin and the verdict its
126
+ * rating says. The documents of other profiles are ignored; a pins file is for one host.
127
+ */
128
+ export function clapPinEntries(documents: readonly RtSafetyDocument[], host: string): PinEntry[] {
129
+ return documents
130
+ .filter((d) => d.console === host)
131
+ .map((d) => {
132
+ const verdict = pinVerdictOf(d.verdict.rating);
133
+ return {
134
+ uri: clapPinUri(d.plugin.binary.path, d.plugin.uri),
135
+ integrity: d.measured.integrity,
136
+ ...(verdict === undefined ? {} : { verdict }),
137
+ };
138
+ });
139
+ }
140
+
141
+ /**
142
+ * 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
+ * row for the host is pinned without a verdict word.
144
+ */
145
+ export function measuredPinEntries(
146
+ plugins: readonly MeasuredPlugin[],
147
+ verdicts: readonly QualifyVerdict[],
148
+ host: string,
149
+ ): PinEntry[] {
150
+ return plugins.flatMap((p) => {
151
+ if (p.integrity === undefined) return [];
152
+ const row = verdicts.find((v) => v.uri === p.uri && v.host === host);
153
+ const verdict = row === undefined || !row.measured ? undefined : pinVerdictOf(row.rating);
154
+ return [{ uri: p.uri, integrity: p.integrity, ...(verdict === undefined ? {} : { verdict }) }];
155
+ });
156
+ }
157
+
158
+ /** A line read back with hostd's rules. */
159
+ export interface ParsedPin {
160
+ readonly uri: string;
161
+ readonly files: readonly { readonly path: string; readonly sha256: string }[];
162
+ readonly layout: { readonly scheme: string; readonly sha256: string };
163
+ readonly verdict?: PinVerdict;
164
+ }
165
+
166
+ const HEX = /^[0-9a-f]{64}$/;
167
+
168
+ /**
169
+ * hostd's grammar for a pin line, the verb optional; `undefined` for a blank or comment line, and a thrown Error for a
170
+ * line the daemon would refuse (a path holding a separator, a path that climbs out of the bundle, a digest that is not
171
+ * 64 lowercase hex digits, a layout without a scheme, a verdict outside the vocabulary, a path named twice).
172
+ */
173
+ export function parsePinLine(line: string): ParsedPin | undefined {
174
+ const words = line.trim().split(/\s+/).filter((w) => w !== "");
175
+ if (words.length === 0 || words[0]!.startsWith("#")) return undefined;
176
+ if (words[0] === PIN_VERB) words.shift();
177
+ if (words.length !== 3 && words.length !== 4) throw new Error(`a pin line has 3 or 4 words, not ${words.length}`);
178
+ const [uri, list, layout, verdictWord] = words as [string, string, string, string | undefined];
179
+ const files = list.split(",").map((item) => {
180
+ const eq = item.lastIndexOf("=");
181
+ if (eq < 0) throw new Error(`${item}: no =`);
182
+ const path = item.slice(0, eq);
183
+ const sha256 = item.slice(eq + 1);
184
+ if (path === "" || path.startsWith("/") || path.split("/").some((part) => part === "" || part === "..")) {
185
+ throw new Error(`${path}: not a path inside the bundle`);
186
+ }
187
+ if (!HEX.test(sha256)) throw new Error(`${path}: not a sha256`);
188
+ return { path, sha256 };
189
+ });
190
+ if (new Set(files.map((f) => f.path)).size !== files.length) throw new Error("a path is named twice");
191
+ const colon = layout.lastIndexOf(":");
192
+ if (colon <= 0 || !HEX.test(layout.slice(colon + 1))) throw new Error(`${layout}: not a layout pin`);
193
+ let verdict: PinVerdict | undefined;
194
+ if (verdictWord !== undefined) {
195
+ const word = verdictWord.startsWith(PIN_VERDICT_KEY) ? verdictWord.slice(PIN_VERDICT_KEY.length) : "";
196
+ if (!(PIN_VERDICTS as readonly string[]).includes(word)) throw new Error(`${verdictWord}: not a verdict`);
197
+ verdict = word as PinVerdict;
198
+ }
199
+ return { uri, files, layout: { scheme: layout.slice(0, colon), sha256: layout.slice(colon + 1) }, ...(verdict === undefined ? {} : { verdict }) };
200
+ }
@@ -27,6 +27,8 @@ export interface QualifyArgs {
27
27
  readonly rates: readonly number[];
28
28
  readonly quanta: readonly number[];
29
29
  readonly out?: string;
30
+ /** `--pins`: where the pins file is written. */
31
+ readonly pins?: string;
30
32
  readonly format: OutputFormat;
31
33
  readonly isolate: boolean;
32
34
  readonly hostSweep: boolean;
@@ -83,6 +85,8 @@ export function parseQualifyArgs(argv: readonly string[]): ParseResult {
83
85
  return { ok: false, error: `--format must be one of ${OUTPUT_FORMATS.join(", ")}` };
84
86
  }
85
87
  const out = seen.get("--out") as string | undefined;
88
+ const pins = seen.get("--pins") as string | undefined;
89
+ if (pins !== undefined && pins.startsWith("--")) return { ok: false, error: "--pins needs a file name" };
86
90
  const hostProfiles = [...new Set(profileSpecs.length > 0 ? profileSpecs : [defaultOf("--host-profile")])];
87
91
  return {
88
92
  ok: true,
@@ -92,6 +96,7 @@ export function parseQualifyArgs(argv: readonly string[]): ParseResult {
92
96
  rates,
93
97
  quanta,
94
98
  ...(out === undefined ? {} : { out }),
99
+ ...(pins === undefined ? {} : { pins }),
95
100
  format: format as OutputFormat,
96
101
  isolate: seen.has("--isolate"),
97
102
  hostSweep: !seen.has("--no-host-sweep"),
@@ -111,7 +116,9 @@ export function helpText(): string {
111
116
  "plugin-qualify — qualify audio plugins for real-time hosting",
112
117
  "",
113
118
  " plugin-qualify <uri|bundle-path>... exactly the named plugins; a .lv2 bundle = every plugin in it",
114
- " plugin-qualify --all every plugin lilv finds (LV2_PATH honoured)",
119
+ " plugin-qualify --all every CLAP plugin on CLAP_PATH, then ~/.clap, then /usr/lib/clap;",
120
+ " refuses (exit 2) when LV2 bundles are on the LV2 path (LV2_PATH),",
121
+ " which this entry point cannot measure",
115
122
  "",
116
123
  ...rows,
117
124
  "",
@@ -69,22 +69,35 @@ export const SOAK_CALIBRATION_S = 30;
69
69
  export const CLAP_SCAN_TOOL_ENV = "OMX_CLAP_SCAN";
70
70
  export const CLAP_SCAN_TOOL_DEFAULT = "omx-clap-scan";
71
71
 
72
+ /**
73
+ * The standard CLAP search path `--all` walks, in this order: every directory `CLAP_PATH` names
74
+ * (colon-separated), then `~/.clap`, then `/usr/lib/clap`. The system directory is the same on
75
+ * every distribution and architecture; it is never the library directory (`/usr/lib64`).
76
+ */
77
+ export const CLAP_PATH_ENV = "CLAP_PATH";
78
+ export const CLAP_USER_DIR = ".clap";
79
+ export const CLAP_SYSTEM_DIR = "/usr/lib/clap";
80
+
72
81
  /** Seconds one run of the CLAP scanner may take over everything it was pointed at. */
73
82
  export const CLAP_SCAN_TIMEOUT_S = 300;
74
83
 
75
84
  /**
76
- * The CLAP prober (`native/clap-qualify.c`), the LV2 measurer (`native/lv2-measure.c`) and the
77
- * interposer both run under (`native/rt-interpose.c`): each located by one environment variable, else
78
- * the package's own build in `native/build/`, else the first of `NATIVE_TOOL_DIRS` that holds it.
85
+ * The CLAP prober (`native/clap-qualify.c`), the LV2 measurer (`native/lv2-measure.c`), the
86
+ * interposer both run under (`native/rt-interpose.c`) and the LV2 host probe
87
+ * (`native/lv2-host-probe.c`, a worker plugin driven through lilv as a host drives it, for a console
88
+ * that exports an LV2 plugin to test its host-facing half): each located by one environment variable,
89
+ * else the package's own build in `native/build/`, else the first of `NATIVE_TOOL_DIRS` that holds it.
79
90
  */
80
91
  export const CLAP_QUALIFY_TOOL_ENV = "OMX_CLAP_QUALIFY";
81
92
  export const LV2_MEASURE_TOOL_ENV = "OMX_LV2_MEASURE";
82
93
  export const RT_INTERPOSE_ENV = "OMX_RT_INTERPOSE";
94
+ export const LV2_HOST_PROBE_TOOL_ENV = "OMX_LV2_HOST_PROBE";
83
95
 
84
96
  /** The file names of the native tools, in every place they are looked for. */
85
97
  export const CLAP_QUALIFY_TOOL_NAME = "clap-qualify";
86
98
  export const LV2_MEASURE_TOOL_NAME = "lv2_measure";
87
99
  export const RT_INTERPOSE_NAME = "rt_interpose.so";
100
+ export const LV2_HOST_PROBE_TOOL_NAME = "lv2_host_probe";
88
101
 
89
102
  /**
90
103
  * The ONE list of the native tools the packages install, each with the setting that names it.
@@ -94,6 +107,7 @@ export const NATIVE_TOOLS: readonly { readonly name: string; readonly env: strin
94
107
  { name: CLAP_QUALIFY_TOOL_NAME, env: CLAP_QUALIFY_TOOL_ENV },
95
108
  { name: LV2_MEASURE_TOOL_NAME, env: LV2_MEASURE_TOOL_ENV },
96
109
  { name: RT_INTERPOSE_NAME, env: RT_INTERPOSE_ENV },
110
+ { name: LV2_HOST_PROBE_TOOL_NAME, env: LV2_HOST_PROBE_TOOL_ENV },
97
111
  ];
98
112
 
99
113
  /**
@@ -153,7 +167,7 @@ export const DEFAULT_HOSTING_POLICY: HostingPolicy = {
153
167
  soakDeadDropDb: 40,
154
168
  };
155
169
 
156
- /** What a host spends on one plugin instance (`2026-09-25-plugin-qualify.md` §3a) — the
170
+ /** What a host spends on one plugin instance — the
157
171
  * interchange's shape, since a document carries each profile's budget. */
158
172
  import type { HostBudget } from "./measurement/format.js";
159
173
  export type { HostBudget };
@@ -164,10 +178,13 @@ export type { HostBudget };
164
178
  * so it gets the same per-instance share, at 48 kHz / 128 frames. `jalv` gives each plugin its own
165
179
  * process and client, so a quarter core per instance, at 48 kHz / 256 frames. The last two are
166
180
  * declared defaults, not a measurement of anyone's board — a project brings its own profile file.
181
+ * `omx-clap-host` runs its plugins one JACK client each, called in turn on the server's one cycle,
182
+ * so it gets mod-host's share and pair.
167
183
  */
168
184
  export const HOST_BUDGETS = {
169
185
  "openmixer-console": { coreFractionCeiling: 0.05, rate: COST_REFERENCE_RATE, quantum: QUANTUM_FRAMES },
170
186
  jalv: { coreFractionCeiling: 0.25, rate: MEASURE_BASE_RATE, quantum: 256 },
171
187
  "mod-host": { coreFractionCeiling: 0.05, rate: MEASURE_BASE_RATE, quantum: 128 },
188
+ "omx-clap-host": { coreFractionCeiling: 0.05, rate: MEASURE_BASE_RATE, quantum: 128 },
172
189
  } as const satisfies Readonly<Record<string, HostBudget>>;
173
190
 
@@ -32,7 +32,7 @@ export interface QualifyOption {
32
32
  }
33
33
 
34
34
  export const QUALIFY_OPTIONS: readonly QualifyOption[] = [
35
- { flag: "--all", describe: "every plugin lilv finds (LV2_PATH honoured)" },
35
+ { flag: "--all", describe: "every CLAP plugin on the CLAP search path; refuses when LV2 bundles are on the LV2 path" },
36
36
  { flag: "--rates", value: "list", describe: "sample rates to measure at, comma-separated", default: QUALIFY_RATES.join(",") },
37
37
  { flag: "--quanta", value: "list", describe: "quanta (frames) to rate cost at, comma-separated", default: QUALIFY_QUANTA.join(",") },
38
38
  { flag: "--out", value: "dir", describe: "write one interchange JSON document per run into this directory" },
@@ -45,5 +45,11 @@ export const QUALIFY_OPTIONS: readonly QualifyOption[] = [
45
45
  describe: `judge against this host (${HOST_PROFILES.map((p) => p.id).join(", ")}, or a profile JSON file); repeat or comma-separate for one verdict per profile`,
46
46
  default: OPENMIXER_CONSOLE_PROFILE.id,
47
47
  },
48
+ {
49
+ flag: "--pins",
50
+ value: "file",
51
+ describe:
52
+ "write one pin per qualified plugin to this file, with its verdict, for plugin-hostd's pin_file (the verdict is the first host profile's)",
53
+ },
48
54
  { flag: "--help", describe: "this text" },
49
55
  ];
@@ -0,0 +1,114 @@
1
+ #!/bin/bash
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+ # Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
4
+ #
5
+ # ai-trace-commit-check.sh: fail when a commit message carries an AI-attribution line.
6
+ #
7
+ # ai-trace-commit-check.sh <range> every commit in the range (e.g. origin/main..HEAD)
8
+ # ai-trace-commit-check.sh --msg-file FILE one message (the commit-msg hook; # lines are git comments, skipped)
9
+ # ai-trace-commit-check.sh --text-file FILE any text, every line judged (pull request titles and bodies)
10
+ # ai-trace-commit-check.sh --selftest fixtures plus a sabotage run
11
+ #
12
+ # Exit 0 clean, 1 a hit (each commit and offending line is named), 2 a range, file or pattern list that cannot be read
13
+ # (any crash is 2). A caller refuses on 2: a checker that cannot judge is never a pass.
14
+ # The patterns are ai-trace-patterns.json beside this script; AI_TRACE_PATTERNS names another JSON (self-tests).
15
+ set -euo pipefail
16
+ export AI_TRACE_SELF="${BASH_SOURCE[0]}"
17
+ exec python3 -I - "$@" <<'PY'
18
+ import json, os, re, subprocess, sys
19
+
20
+ here = os.path.dirname(os.path.realpath(os.environ["AI_TRACE_SELF"]))
21
+ pfile = os.environ.get("AI_TRACE_PATTERNS") or os.path.join(here, "ai-trace-patterns.json")
22
+
23
+
24
+ def load():
25
+ with open(pfile, encoding="utf-8") as f:
26
+ return [(m["name"], re.compile(m["pattern"], re.I | re.M)) for m in json.load(f)["markers"]]
27
+
28
+
29
+ def hits(text, markers):
30
+ out = []
31
+ for n, line in enumerate(text.splitlines(), 1):
32
+ for name, rx in markers:
33
+ if rx.search(line):
34
+ out.append((n, name, line.strip()))
35
+ return out
36
+
37
+
38
+ def report(label, text, markers):
39
+ h = hits(text, markers)
40
+ for n, name, line in h:
41
+ print(f"{label}: line {n} [{name}]: {line}")
42
+ return len(h)
43
+
44
+
45
+ def git(*a):
46
+ p = subprocess.run(["git", *a], capture_output=True, text=True)
47
+ if p.returncode:
48
+ print(f"ai-trace-commit-check: git {' '.join(a[:3])}: {p.stderr.strip()}", file=sys.stderr)
49
+ sys.exit(2)
50
+ return p.stdout
51
+
52
+
53
+ def selftest():
54
+ # built from fragments: this file must not itself carry an attribution line
55
+ co, by, ai = "Co-Authored-", "By: ", "Cl" + "aude"
56
+ sess = "Cl" + "aude-Session: https://cl" + "aude.ai/code/session_01"
57
+ fx = {
58
+ "co-authored-by": f"Fix x\n\n{co}{by}{ai} Opus <noreply@anthro" + "pic.com>\n",
59
+ "claude-session": f"Fix x\n\n{sess}\n",
60
+ "generated-footer": f"Fix x\n\n\U0001F916 Generated with [{ai} Code](https://{ai.lower()}.com/{ai.lower()}-code)\n",
61
+ "claude.ai/code": f"Fix x\n\nsee https://{ai.lower()}.ai/code/session_9\n",
62
+ }
63
+ clean = f"Fix x\n\nGenerated by the build; mentions a hidden directory and a {co}{by}Pau line.\n"
64
+ def run(markers):
65
+ bad = [k for k, v in fx.items() if not hits(v, markers)]
66
+ if hits(clean, markers):
67
+ bad.append("clean-flagged")
68
+ return bad
69
+ m = load()
70
+ first = run(m)
71
+ print("selftest:", "GREEN" if not first else f"RED {first}")
72
+ sab = run([x for x in m if x[0] not in ("co-authored-by", "ai-co-author", "anthropic-noreply", "claude-session")])
73
+ print("sabotage (the co-author, email and session patterns removed):", "RED as expected" if sab else "still GREEN: blind")
74
+ return 0 if not first and sab else 1
75
+
76
+
77
+ def main(a):
78
+ if a == ["--selftest"]:
79
+ return selftest()
80
+ markers = load()
81
+ if len(a) == 2 and a[0] == "--msg-file":
82
+ try:
83
+ text = open(a[1], encoding="utf-8", errors="replace").read()
84
+ except OSError as e:
85
+ print(f"ai-trace-commit-check: {e}", file=sys.stderr)
86
+ return 2
87
+ # lines git adds as comments are not part of the message
88
+ text = "\n".join(l for l in text.splitlines() if not l.startswith("#"))
89
+ return 1 if report("commit message", text, markers) else 0
90
+ if len(a) == 2 and a[0] == "--text-file":
91
+ try:
92
+ text = open(a[1], encoding="utf-8", errors="replace").read()
93
+ except OSError as e:
94
+ print(f"ai-trace-commit-check: {e}", file=sys.stderr)
95
+ return 2
96
+ return 1 if report("text", text, markers) else 0
97
+ if len(a) == 1 and not a[0].startswith("-"):
98
+ shas = git("rev-list", a[0]).split()
99
+ bad = 0
100
+ for sha in shas:
101
+ bad += report(sha[:12], git("log", "-1", "--format=%B", sha), markers)
102
+ print(f"ai-trace-commit-check: {len(shas)} commits, {bad} offending lines")
103
+ return 1 if bad else 0
104
+ print(__doc__ if False else "usage: ai-trace-commit-check.sh <range> | --msg-file FILE | --text-file FILE | --selftest", file=sys.stderr)
105
+ return 2
106
+
107
+
108
+ try:
109
+ rc = main(sys.argv[1:])
110
+ except Exception as e: # a bad or missing pattern file, a decode error: unreadable, never "a hit"
111
+ print(f"ai-trace-commit-check: {type(e).__name__}: {e}", file=sys.stderr)
112
+ rc = 2
113
+ sys.exit(rc)
114
+ PY
@@ -0,0 +1,33 @@
1
+ {
2
+ "$comment": "SPDX-License-Identifier: GPL-3.0-or-later. The AI-attribution patterns (Python re, flags IGNORECASE|MULTILINE) read by ai-trace-commit-check.sh. Edit here only.",
3
+ "markers": [
4
+ {
5
+ "name": "robot-footer",
6
+ "pattern": "^[^\\S\\r\\n]*[_*>]*[^\\S\\r\\n]*🤖[^\\r\\n]*?(?:Claude|claude\\.(?:ai|com))[^\\r\\n]*"
7
+ },
8
+ {
9
+ "name": "generated-footer",
10
+ "pattern": "(?:🤖[^\\S\\r\\n]*)?Generated[^\\S\\r\\n]+(?:with|by)[^\\S\\r\\n]+\\[?Claude[^\\S\\r\\n]+Code\\]?(?:\\([^)\\s]*\\))?"
11
+ },
12
+ {
13
+ "name": "co-authored-by",
14
+ "pattern": "Co-Authored-By:[^\\S\\r\\n]*Claude\\b[^\\r\\n<`]*(?:<[^>\\r\\n]*>)?"
15
+ },
16
+ {
17
+ "name": "ai-co-author",
18
+ "pattern": "Co-Authored-By:[^\\S\\r\\n]*(?:Anthropic|OpenAI|ChatGPT|Copilot|Gemini|Codex|Cursor|Aider|Devin)\\b[^\\r\\n<`]*(?:<[^>\\r\\n]*>)?"
19
+ },
20
+ {
21
+ "name": "anthropic-noreply",
22
+ "pattern": "noreply@anthropic\\.com"
23
+ },
24
+ {
25
+ "name": "claude-session",
26
+ "pattern": "Claude-Session:[^\\S\\r\\n]*[^\\s`]*"
27
+ },
28
+ {
29
+ "name": "claude.ai/code",
30
+ "pattern": "\\[[^\\]\\r\\n]*\\]\\([^\\S\\r\\n]*<?https?://claude\\.ai/code[^)\\s]*>?[^\\S\\r\\n]*\\)|<?https?://claude\\.ai/code\\b[^\\s)>`]*>?"
31
+ }
32
+ ]
33
+ }
@@ -1095,7 +1095,7 @@ LV2_WORKER_ERR_NO_SPACE = 3
1095
1095
  class WorkerQueue:
1096
1096
  """The host half of `worker:schedule`: jobs are QUEUED, and run between blocks, off `run()`.
1097
1097
 
1098
- Spec 2a (`2026-09-25-plugin-qualify.md`): a schedule that accepts a job and
1098
+ A schedule that accepts a job and
1099
1099
  drops it measures an UNBUILT plugin — the Hex Chain amp read 0.9 us/sample that way against 5.4
1100
1100
  built. After every block the host calls `pump(interface)`: the plugin's `work()` for each
1101
1101
  queued job, then `work_response()` with whatever it answered, then `end_run()` once every answer
@@ -85,6 +85,22 @@ const HELP = `${TOOL_NAME} ${TOOL_VERSION} — measure LV2 plugin latency and CP
85
85
  --dry-run Print the plan and probe nothing.
86
86
  --progress-json Emit machine-readable progress on stderr, one '@progress {...}' per line.
87
87
  --help This text.
88
+
89
+ The hosting pass's host features are omx-clap-core's LV2 feature provider; each of its options is
90
+ a flag, passed to every run of lv2_measure and named in each plugin's record (featureOptions):
91
+
92
+ --features LIST Kinds offered: map,unmap,options,bounded,worker,default-state,log
93
+ (default: all but default-state; empty: none).
94
+ --strict Refuse a plugin requiring a feature not offered, naming it (refusedFeature).
95
+ --no-strict Hand it to its own instantiate without it (default).
96
+ --block-declares LIST Buf-size declarations when bounded is offered: bounded,fixed,pow2
97
+ (default: bounded; empty: none).
98
+ --block-min N bufsz:minBlockLength (default: 1; 0: the run's block).
99
+ --block-max N bufsz:maxBlockLength (default: 0, the run's block).
100
+ --block-nominal N bufsz:nominalBlockLength (default: 0, the maximum).
101
+ --log MODE log:log lines: silent, route (to stderr) or capture (into the record)
102
+ (default: silent).
103
+ --log-capture-bytes N The capture buffer, its NUL included (default: 4096).
88
104
  `;
89
105
 
90
106
  function fail(message, code = 2) {
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+ # Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
4
+ #
5
+ # private-refs-check.sh — this repository is public, so its tracked files state facts and never point
6
+ # into a private repository: no private repository name, no private issue or pull request number, no
7
+ # private branch name or file path, no name of a private specification file. Say what the value is and
8
+ # why instead, and cite only public repositories (omx-dsp, omx-contract, omx-plugins, omx-clap-core, plugin-qualify).
9
+ # Product-name mentions of OpenMixer, and values such as the org.openmixer.* CLAP ids, the
10
+ # urn:openmixer:* LV2 URIs and the @openmixer npm scope, are not pointers and are not matched.
11
+ #
12
+ # tools/private-refs-check.sh the check, over every tracked file (generated ones included)
13
+ # tools/private-refs-check.sh --self-test each sabotage, in every kind of file, must go red
14
+ #
15
+ # ALLOW lists the tracked files exempt from the check, each with the reason; nothing else is.
16
+ set -euo pipefail
17
+ HERE="$(cd "$(dirname "$0")/.." && pwd)"
18
+
19
+ # <file> — <why it may name them>
20
+ ALLOW=(
21
+ "tools/private-refs-check.sh" # it spells the patterns it looks for
22
+ )
23
+ EXTRA_PATHSPEC=() # the self-test sets a bad one, to prove a git grep error is red
24
+
25
+ # Extended regexps, matched case-insensitively. A new kind of pointer is one line here.
26
+ PATTERNS=(
27
+ 'FreeMixer/openmixer([^A-Za-z0-9_-]|$)' # the private repository
28
+ 'openmixer-ops' # the private operations repository
29
+ 'openmixer ?#[0-9]' # one of its issues or pull requests
30
+ '[A-Za-z0-9_.]-dev#[0-9]' # an issue of a private *-dev repository
31
+ 'integration/[A-Za-z0-9]' # a private integration branch
32
+ 'packages/[a-z][a-z0-9-]+' # a path into the private repository's packages
33
+ '\.vue([^A-Za-z0-9_]|$)' # its web UI components
34
+ "openmixer's spec" # its specification files
35
+ 'docs/design/' # its design documents
36
+ 'harness/' # its test harness
37
+ '20[0-9]{2}-[0-9]{2}-[0-9]{2}-[a-z][a-z0-9-]+\.md' # a dated specification file name
38
+ 'spec 20[0-9]{2}-[0-9]{2}-[0-9]{2}' # a citation of one
39
+ )
40
+
41
+ check() { # <root>
42
+ local root="$1" fail=0 args=() p a hits
43
+ for p in "${PATTERNS[@]}"; do args+=(-e "$p"); done
44
+ local excl=()
45
+ for a in "${ALLOW[@]}"; do excl+=(":(exclude)${a%% *}"); done
46
+ [ -n "$(git -C "$root" ls-files)" ] || { echo "private-refs-check: git lists no file" >&2; return 1; }
47
+ # git grep exits 1 for no match and 128 or more for an error; an error must not read as "no hits"
48
+ hits=$(git -C "$root" grep -nIiE "${args[@]}" -- . "${excl[@]}" "${EXTRA_PATHSPEC[@]}") || rc=$?
49
+ case "${rc:-0}" in 0|1) ;; *) echo "private-refs-check: git grep failed (exit $rc)" >&2; return 1 ;; esac
50
+ hits=$(printf '%s' "$hits" | cut -c1-200)
51
+ [ -z "$hits" ] || { printf '%s\n' "$hits" >&2; fail=1; }
52
+ [ "$fail" = 0 ] || { echo "private-refs-check: the lines above point into private repositories; state the fact instead" >&2; return 1; }
53
+ echo "private-refs-check: no tracked file points into a private repository"
54
+ }
55
+
56
+ if [ "${1:-}" = --self-test ]; then
57
+ scratch="$(mktemp -d)"; trap 'rm -rf "$scratch"' EXIT
58
+ fail=0
59
+ # a clean tree with one file of each kind the repository tracks; every sabotage writes one private pointer into one
60
+ mk() { rm -rf "$scratch/t"; mkdir -p "$scratch/t/src" "$scratch/t/tools" "$scratch/t/docs"
61
+ git -C "$scratch" init -q t
62
+ printf 'rdfs:comment "The OpenMixer console x." ;\nlv2:binary <x.so> ; # urn:openmixer:x\n' > "$scratch/t/src/x.ttl"
63
+ printf '// org.openmixer.x, @openmixer/x\n' > "$scratch/t/tools/gen.mjs"
64
+ printf '# Docs\n\nOpenMixer plugins.\n' > "$scratch/t/docs/a.md"
65
+ printf '/* x */\n' > "$scratch/t/src/x.h"; printf '#!/bin/sh\n# x\n' > "$scratch/t/tools/x.sh"; }
66
+ sab() { # <name> <file> <line>
67
+ mk; printf '%s\n' "$3" >> "$scratch/t/$2"; git -C "$scratch/t" add -A
68
+ if check "$scratch/t" >/dev/null 2>&1; then echo "SABOTAGE $1: STAYED GREEN"; fail=1; else echo "SABOTAGE $1: red"; fi; }
69
+ mk; git -C "$scratch/t" add -A
70
+ check "$scratch/t" >/dev/null || { echo "self-test: the clean tree is not green" >&2; exit 1; }
71
+ sab repo-in-header src/x.h '/* as FreeMixer/openmixer draws it */'
72
+ sab branch-in-sh tools/x.sh '# drawn on integration/lean-engine'
73
+ sab issue-in-md docs/a.md 'see openmixer#1580'
74
+ sab path-in-md docs/a.md 'packages/web-ui/Foo.vue'
75
+ sab default-path tools/x.sh 'plugin=${P:-$root/../openmixer/packages/omx-plugins/bin/omx-delay.clap}'
76
+ sab ops-in-mjs tools/gen.mjs '// see openmixer-ops'
77
+ sab dev-in-mjs tools/gen.mjs '// omx-dsp-dev#32'
78
+ sab spec-in-mjs tools/gen.mjs "// openmixer's spec 2026-10-09-plugin-from-contract.md"
79
+ sab ttl src/x.ttl 'rdfs:comment "see FreeMixer/openmixer#12" ;'
80
+ sab header src/x.h '/* packages/core/src/x.ts */'
81
+ sab design-dir docs/a.md 'the notes in docs/design/specs'
82
+ sab harness-dir tools/gen.mjs '// harness/dsp-check.sh'
83
+ mk; git -C "$scratch/t" add -A
84
+ if ( EXTRA_PATHSPEC=(":(bogus)x"); check "$scratch/t" >/dev/null 2>&1 ); then echo "SABOTAGE git-grep-error: STAYED GREEN"; fail=1; else echo "SABOTAGE git-grep-error: red"; fi
85
+ [ "$fail" = 0 ] || exit 1
86
+ check "$HERE" >/dev/null || { echo "self-test: the repository is not green" >&2; exit 1; }
87
+ echo "private-refs-check self-test: every sabotage red"
88
+ exit 0
89
+ fi
90
+ check "$HERE"