@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.
Files changed (88) hide show
  1. package/README.md +127 -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 +8 -1
  50. package/dist/qualify-declarations.js +8 -1
  51. package/dist/qualify-declarations.js.map +1 -1
  52. package/dist/qualify-options.js +1 -2
  53. package/dist/qualify-options.js.map +1 -1
  54. package/dist/qualify-sync.d.ts +215 -0
  55. package/dist/qualify-sync.js +794 -0
  56. package/dist/qualify-sync.js.map +1 -0
  57. package/dist/whole-file.d.ts +3 -0
  58. package/dist/whole-file.js +36 -0
  59. package/dist/whole-file.js.map +1 -0
  60. package/fixtures/admission/2026-10-26.json +19 -0
  61. package/fixtures/clap-scan/fake.json +24 -24
  62. package/package.json +5 -4
  63. package/src/adjust-cli.ts +262 -0
  64. package/src/clap-loader.ts +9 -13
  65. package/src/history-adjust.ts +156 -0
  66. package/src/history-file.ts +177 -0
  67. package/src/host-admission.ts +136 -0
  68. package/src/index.ts +6 -0
  69. package/src/lv2-loader.ts +150 -15
  70. package/src/measurement/cli.ts +11 -17
  71. package/src/pins-file.ts +17 -3
  72. package/src/probe-judge.ts +27 -1
  73. package/src/probe-output-fixtures.ts +86 -0
  74. package/src/probe-output.ts +3 -0
  75. package/src/probe-runner.ts +45 -14
  76. package/src/qualification-record.ts +174 -15
  77. package/src/qualify-cli.ts +2 -5
  78. package/src/qualify-declarations.ts +9 -1
  79. package/src/qualify-options.ts +1 -2
  80. package/src/qualify-sync.ts +889 -0
  81. package/src/whole-file.ts +35 -0
  82. package/tools/_declarations.py +3 -1
  83. package/tools/ai-trace-commit-check.sh +26 -14
  84. package/tools/ai-trace-patterns.json +10 -2
  85. package/tools/gen-hosting-codes.mjs +1 -1
  86. package/tools/gen-python-declarations.mjs +2 -0
  87. package/tools/install-roundtrip.sh +173 -0
  88. package/tools/mod-host-admit.mjs +74 -0
@@ -0,0 +1,35 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * A file written whole or not at all: the bytes go to `<path>.new` beside it, are flushed to the disk, and the name is
5
+ * moved over the old file, then the directory is flushed so the rename itself survives a power cut. A reader of
6
+ * `path`, plugin-hostd at start among them, sees the old file or the whole new one, never a half.
7
+ */
8
+ import { closeSync, fsyncSync, mkdirSync, openSync, renameSync, writeSync } from "node:fs";
9
+ import { dirname } from "node:path";
10
+
11
+ /** The bytes one write call takes; a large file goes out in pieces of this size. */
12
+ const PIECE_BYTES = 1 << 16;
13
+
14
+ /** The name the new bytes are written under before the rename. */
15
+ export const pendingPath = (path: string): string => `${path}.new`;
16
+
17
+ export function writeFileWhole(path: string, content: string | Uint8Array): void {
18
+ mkdirSync(dirname(path), { recursive: true });
19
+ const bytes = typeof content === "string" ? Buffer.from(content, "utf8") : content;
20
+ const pending = pendingPath(path);
21
+ const fd = openSync(pending, "w", 0o644);
22
+ try {
23
+ for (let at = 0; at < bytes.length; at += PIECE_BYTES) writeSync(fd, bytes, at, Math.min(PIECE_BYTES, bytes.length - at));
24
+ fsyncSync(fd);
25
+ } finally {
26
+ closeSync(fd);
27
+ }
28
+ renameSync(pending, path);
29
+ const dir = openSync(dirname(path), "r");
30
+ try {
31
+ fsyncSync(dir);
32
+ } finally {
33
+ closeSync(dir);
34
+ }
35
+ }
@@ -25,8 +25,10 @@ DESCRIPTOR_DIR_SYSTEM = "/var/lib/omx/descriptors"
25
25
  DESCRIPTOR_HASH_PREFIX = 12
26
26
  DESCRIPTOR_SCHEMA_ENV = "OMX_CONTRACT_SCHEMA"
27
27
  DESCRIPTOR_SCHEMA_INSTALLED = "/usr/share/omx-contract/omx-contract.schema.json"
28
- DESCRIPTOR_SCHEMA_FLOOR = ">= 3.0.0"
28
+ DESCRIPTOR_SCHEMA_FLOOR = ">= 3.4.0"
29
+ DESCRIPTOR_ID_HASH_PREFIX = 8
29
30
  DESCRIPTOR_CODE_SCHEMA = "contract.file.invalid"
30
31
  DESCRIPTOR_CODE_SCHEMA_MISSING = "qualify.descriptor.schema-missing"
32
+ DESCRIPTOR_CODE_SCHEMA_FLOOR = "qualify.descriptor.schema-floor"
31
33
  DESCRIPTOR_CODE_BINARY_UNHASHED = "qualify.descriptor.binary-unhashed"
32
34
  DESCRIPTOR_CODE_DIR_UNWRITABLE = "qualify.descriptor.dir-unwritable"
@@ -1,17 +1,17 @@
1
1
  #!/bin/bash
2
2
  # SPDX-License-Identifier: GPL-3.0-or-later
3
- # Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
4
- #
5
3
  # ai-trace-commit-check.sh: fail when a commit message carries an AI-attribution line.
6
4
  #
7
5
  # ai-trace-commit-check.sh <range> every commit in the range (e.g. origin/main..HEAD)
8
6
  # 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)
7
+ # ai-trace-commit-check.sh --text-file FILE any text, every line judged (PR titles and bodies)
10
8
  # ai-trace-commit-check.sh --selftest fixtures plus a sabotage run
11
9
  #
12
10
  # 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).
11
+ # (any crash is 2). Callers refuse on 2: a checker that cannot judge is never a pass.
12
+ # The patterns are ai-trace-patterns.json beside this script, the same list ai-trace-sweep.sh strips
13
+ # issue and PR text with, and the one list every door reads: merge-pr.sh, integrate-check.sh, pr-from-devel.sh
14
+ # (ops/tools/tests/ai-trace-one-list.t.sh refuses a second list). AI_TRACE_PATTERNS names another JSON (self-tests). Repos that cannot see the ops tree carry a copy of both files.
15
15
  set -euo pipefail
16
16
  export AI_TRACE_SELF="${BASH_SOURCE[0]}"
17
17
  exec python3 -I - "$@" <<'PY'
@@ -51,16 +51,22 @@ def git(*a):
51
51
 
52
52
 
53
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
54
  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",
55
+ "co-authored-by": "Fix x\n\nCo-Authored-By: Claude Opus <noreply@anthropic.com>\n",
56
+ "claude-session": "Fix x\n\nClaude-Session: https://claude.ai/code/session_01\n",
57
+ "generated-footer": "Fix x\n\n\U0001F916 Generated with [Claude Code](https://claude.com/claude-code)\n",
58
+ "claude.ai/code": "Fix x\n\nsee https://claude.ai/code/session_9\n",
59
+ # the shapes the omx-dsp#155 review saw pass
60
+ "assisted-by": "Fix x\n\nAssisted-by: Claude\n",
61
+ "made-with": "Fix x\n\nMade with Claude Code\n",
62
+ "generated-with-claude": "Fix x\n\nGenerated with Claude\n",
63
+ "generated-by-trailer": "Fix x\n\nGenerated-by: Claude\n",
64
+ "co-author-model": "Fix x\n\nCo-authored-by: Opus 5.5 <x@example.invalid>\n",
65
+ "co-author-github-copilot": "Fix x\n\nCo-authored-by: GitHub Copilot <copilot@github.com>\n",
62
66
  }
63
- clean = f"Fix x\n\nGenerated by the build; mentions a hidden directory and a {co}{by}Pau line.\n"
67
+ clean = ("Fix x\n\nGenerated by the build; mentions .claude/ and a Co-Authored-By: Pau line.\n"
68
+ "Assisted-by: Pau Aliagas\nMade with care; generated with tools/rules-index-gen.sh\n"
69
+ "A review quoted 'Assisted-by: Claude' and 'Co-authored-by: Opus 5.5 <x>' as shapes that passed.\n")
64
70
  def run(markers):
65
71
  bad = [k for k, v in fx.items() if not hits(v, markers)]
66
72
  if hits(clean, markers):
@@ -71,7 +77,13 @@ def selftest():
71
77
  print("selftest:", "GREEN" if not first else f"RED {first}")
72
78
  sab = run([x for x in m if x[0] not in ("co-authored-by", "ai-co-author", "anthropic-noreply", "claude-session")])
73
79
  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
80
+ # each #152 shape is seen by a pattern of its own family, not by luck
81
+ new = ["assisted-by", "made-with", "generated-with-claude", "generated-by-trailer", "co-author-model", "co-author-github-copilot"]
82
+ sab2 = run([x for x in m if x[0] not in ("ai-assisted-by", "generated-footer", "ai-co-author-model")])
83
+ blind = [k for k in new if k not in sab2]
84
+ print("sabotage (the assisted-by, generated and model co-author patterns removed):",
85
+ "RED as expected" if not blind else f"still GREEN for {blind}: blind")
86
+ return 0 if not first and sab and not blind else 1
75
87
 
76
88
 
77
89
  def main(a):
@@ -1,5 +1,5 @@
1
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.",
2
+ "$comment": "Single source of the AI-trace patterns (Python re, flags IGNORECASE|MULTILINE). Read by ai-trace-sweep.sh and ai-trace-commit-check.sh. Edit here only. A trailer-shaped pattern that eats the rest of its line is anchored at the line start, so prose quoting a trailer keeps its text.",
3
3
  "markers": [
4
4
  {
5
5
  "name": "robot-footer",
@@ -7,7 +7,7 @@
7
7
  },
8
8
  {
9
9
  "name": "generated-footer",
10
- "pattern": "(?:🤖[^\\S\\r\\n]*)?Generated[^\\S\\r\\n]+(?:with|by)[^\\S\\r\\n]+\\[?Claude[^\\S\\r\\n]+Code\\]?(?:\\([^)\\s]*\\))?"
10
+ "pattern": "(?:🤖[^\\S\\r\\n]*)?(?:Generated|Made)[^\\S\\r\\n]+(?:with|by)[^\\S\\r\\n]+\\[?Claude(?:[^\\S\\r\\n]+Code)?\\]?(?:\\([^)\\s]*\\))?"
11
11
  },
12
12
  {
13
13
  "name": "co-authored-by",
@@ -17,6 +17,14 @@
17
17
  "name": "ai-co-author",
18
18
  "pattern": "Co-Authored-By:[^\\S\\r\\n]*(?:Anthropic|OpenAI|ChatGPT|Copilot|Gemini|Codex|Cursor|Aider|Devin)\\b[^\\r\\n<`]*(?:<[^>\\r\\n]*>)?"
19
19
  },
20
+ {
21
+ "name": "ai-co-author-model",
22
+ "pattern": "^[^\\S\\r\\n]*Co-Authored-By:[^\\S\\r\\n]*(?:GitHub[^\\S\\r\\n]+Copilot|Opus|Sonnet|Haiku|Fable|GPT)\\b[^\\r\\n<`]*(?:<[^>\\r\\n]*>)?"
23
+ },
24
+ {
25
+ "name": "ai-assisted-by",
26
+ "pattern": "^[^\\S\\r\\n]*(?:Assisted|Generated)-By:[^\\S\\r\\n]*(?:Claude|Anthropic|OpenAI|ChatGPT|(?:GitHub[^\\S\\r\\n]+)?Copilot|Gemini|Codex|Cursor|Aider|Devin|Opus|Sonnet|Haiku|Fable|GPT)\\b[^\\r\\n<`]*(?:<[^>\\r\\n]*>)?"
27
+ },
20
28
  {
21
29
  "name": "anthropic-noreply",
22
30
  "pattern": "noreply@anthropic\\.com"
@@ -51,7 +51,7 @@ export async function hostingCodesFileFrom(dir) {
51
51
  function absent() {
52
52
  return (
53
53
  `omx-clap-core's TypeScript modules were not found in any of: ${clapCoreTsDirCandidates().join(', ')}. ` +
54
- 'Install omx-clap-core-devel (pkg-config omx-clap-core), set OMX_CLAP_CORE_TSDIR to its tsdir, or run make -C native deps.'
54
+ 'Install omx-clap-core (its modules in /usr/share/omx-clap-core/ts), set OMX_CLAP_CORE_TSDIR to its tsdir, or run make -C native deps.'
55
55
  );
56
56
  }
57
57
 
@@ -42,8 +42,10 @@ const PY_NAMES = [
42
42
  'DESCRIPTOR_SCHEMA_ENV',
43
43
  'DESCRIPTOR_SCHEMA_INSTALLED',
44
44
  'DESCRIPTOR_SCHEMA_FLOOR',
45
+ 'DESCRIPTOR_ID_HASH_PREFIX',
45
46
  'DESCRIPTOR_CODE_SCHEMA',
46
47
  'DESCRIPTOR_CODE_SCHEMA_MISSING',
48
+ 'DESCRIPTOR_CODE_SCHEMA_FLOOR',
47
49
  'DESCRIPTOR_CODE_BINARY_UNHASHED',
48
50
  'DESCRIPTOR_CODE_DIR_UNWRITABLE',
49
51
  ];
@@ -0,0 +1,173 @@
1
+ #!/bin/bash
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+ # Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
4
+ #
5
+ # The after-install scan, end to end on a clean image (#168): build this checkout's package, install it, install a
6
+ # plugin package, and see the plugin's qualification record, descriptor and pin appear with no manual step; upgrade
7
+ # the plugin package with a changed bundle and see them change; remove it and see them go.
8
+ #
9
+ # podman run --rm -v "$PWD":/src:ro,Z docker.io/library/debian:trixie bash /src/tools/install-roundtrip.sh /src
10
+ # podman run --rm -v "$PWD":/src:ro,Z docker.io/library/fedora:44 bash /src/tools/install-roundtrip.sh /src
11
+ #
12
+ # Runs as root inside a throwaway container; rootless podman has no systemd, which is the "direct run" branch of
13
+ # packaging/trigger-scan.sh (the image-build and chroot case). The scan is never started by hand: every record, descriptor and
14
+ # pin below is what the package manager's trigger left. The units under a running systemd (the service start branch)
15
+ # are not exercised here. Network is needed: the OpenMixer channel (OPENMIXER_URL) and npm.
16
+ #
17
+ # Exit 0 when every check holds; each failed check is printed, the run goes on, and the exit is 1.
18
+ set -uo pipefail
19
+
20
+ src=$(readlink -f "${1:?usage: install-roundtrip.sh <checkout>}")
21
+ url=${OPENMIXER_URL:-https://www.openmixer.org}
22
+ state=/var/lib/omx
23
+ uri_a=urn:plugin-qualify:fixture:lv2-rt-safe
24
+ uri_b=urn:plugin-qualify:fixture:lv2-rt-safe-lib64
25
+ fails=0
26
+ work=$(mktemp -d /tmp/roundtrip.XXXXXX)
27
+
28
+ say() { printf '\n== %s\n' "$*"; }
29
+ check() { # check <what> <command...>
30
+ local what=$1; shift
31
+ if "$@" >/dev/null 2>&1; then echo "ok $what"; else echo "FAIL $what"; fails=$((fails + 1)); fi
32
+ }
33
+ absent() { ! "$@"; }
34
+ has_file_with() { grep -rqF -- "$2" "$1" 2>/dev/null; } # has_file_with <dir> <text>
35
+ pins() { cat "$state/qualified.pins" 2>/dev/null; }
36
+ pin_of() { pins | grep -F -- "$1" | head -1; }
37
+ show() {
38
+ echo "-- records:"; ls "$state/qualification" 2>/dev/null | sed 's/^/ /'
39
+ echo "-- descriptors:"; (cd "$state/descriptors" 2>/dev/null && find . -type f | sort | sed 's/^/ /')
40
+ echo "-- pins:"; pins | sed 's/^/ /'
41
+ }
42
+
43
+ if command -v dnf >/dev/null; then fmt=rpm; else fmt=deb; fi
44
+
45
+ # --- the clean image: the channel, the build tools ------------------------------------------------------------------
46
+ say "prepare ($fmt)"
47
+ if [ $fmt = rpm ]; then
48
+ grep -qx 'retries=10' /etc/dnf/dnf.conf || echo 'retries=10' >> /etc/dnf/dnf.conf
49
+ dnf install -y --setopt=install_weak_deps=False "$url/rpm/openmixer-release.noarch.rpm" >/dev/null &&
50
+ dnf install -y --setopt=install_weak_deps=False rpm-build dnf-plugins-core gcc make lv2-devel clap-devel tar gzip >/dev/null || exit 2
51
+ else
52
+ export DEBIAN_FRONTEND=noninteractive
53
+ apt-get update -qq && apt-get install -y --no-install-recommends ca-certificates curl >/dev/null || exit 2
54
+ curl -fsSL --retry 5 -o "$work/release.deb" "$url/deb/openmixer-release.deb" && chmod 755 "$work" &&
55
+ apt-get install -y --no-install-recommends "$work/release.deb" >/dev/null && apt-get update -qq || exit 2
56
+ apt-get install -y --no-install-recommends devscripts equivs fakeroot build-essential lv2-dev clap-headers >/dev/null || exit 2
57
+ fi
58
+
59
+ # --- this checkout's package ------------------------------------------------------------------------------------------
60
+ say "build plugin-qualify"
61
+ tree=$work/tree
62
+ mkdir -p "$tree" && (cd "$src" && tar --exclude=./node_modules --exclude=./.git --exclude=./native/build --exclude=./native/deps --exclude=./dist -cf - .) | tar -xf - -C "$tree"
63
+ if [ $fmt = rpm ]; then
64
+ ver=$(rpmspec -q --qf '%{version}\n' "$tree/packaging/plugin-qualify.spec" | head -1)
65
+ dnf builddep -y "$tree/packaging/plugin-qualify.spec" >/dev/null || exit 2
66
+ mkdir -p "$work/rpmbuild/SOURCES" && cp -r "$tree" "$work/plugin-qualify-$ver" &&
67
+ tar -czf "$work/rpmbuild/SOURCES/plugin-qualify-$ver.tar.gz" -C "$work" "plugin-qualify-$ver"
68
+ rpmbuild -bb --define "_topdir $work/rpmbuild" "$tree/packaging/plugin-qualify.spec" >"$work/build.log" 2>&1 || { tail -30 "$work/build.log"; exit 2; }
69
+ pkg=$(ls "$work"/rpmbuild/RPMS/*/plugin-qualify-[0-9]*.rpm)
70
+ else
71
+ (cd "$tree" && mk-build-deps -i -r -t 'apt-get -y --no-install-recommends' debian/control >/dev/null 2>&1 &&
72
+ dpkg-buildpackage -us -uc -b >"$work/build.log" 2>&1) || { tail -30 "$work/build.log"; exit 2; }
73
+ pkg=$(ls "$work"/plugin-qualify_*.deb)
74
+ fi
75
+ echo "built $pkg"
76
+
77
+ say "install plugin-qualify"
78
+ # the package manager's defaults, recommends included: the descriptor writer needs python3-lilv, which the deb recommends
79
+ if [ $fmt = rpm ]; then dnf install -y "$pkg" >/dev/null || exit 2; else apt-get install -y "$pkg" >/dev/null || exit 2; fi
80
+ echo "-- after the install, with no plugin: state file $( [ -e $state/qualify-sync.json ] && echo present || echo absent )"
81
+ check "the trigger script ships" test -x "$( (dpkg -L plugin-qualify 2>/dev/null || rpm -ql plugin-qualify) | grep '/trigger-scan$')"
82
+ check "installing with no plugin leaves no record" absent test -e "$state/qualification/$uri_a"
83
+
84
+ # --- a plugin package: the repo's own LV2 fixture, packed as a distribution package would ------------------------------
85
+ say "build the plugin package"
86
+ fx=$work/fx
87
+ make -C "$src/native" BUILD="$fx" "$fx/fixtures/lv2/rt-safe.lv2/gain.so" >/dev/null 2>&1 || { echo "the fixture bundle does not build"; exit 2; }
88
+ bundle() { # bundle <root> <libdir> <version-marker>: the bundles of one plugin package version
89
+ local root=$1 lib=$2 mark=$3 b=$root$2/rt-safe.lv2
90
+ mkdir -p "$b" && cp "$fx"/fixtures/lv2/rt-safe.lv2/* "$b/"
91
+ [ -z "$mark" ] || echo "# $mark" >> "$b/gain.ttl" # a changed bundle: the plugin's file set hashes differently
92
+ if [ $fmt = rpm ]; then # the second plugin of the Fedora package lives in /usr/lib64/lv2, under a uri of its own
93
+ local b2=$root/usr/lib64/lv2/rt-safe-lib64.lv2
94
+ mkdir -p "$b2" && cp "$fx"/fixtures/lv2/rt-safe.lv2/* "$b2/" && sed -i "s/lv2-rt-safe/lv2-rt-safe-lib64/g" "$b2"/*.ttl
95
+ [ -z "$mark" ] || echo "# $mark" >> "$b2/gain.ttl"
96
+ fi
97
+ }
98
+ mkplugin() { # mkplugin <version> <marker>: ./fakeplug-<version>.<fmt>
99
+ local v=$1 m=$2 root=$work/pkg-$1
100
+ rm -rf "$root"; bundle "$root" /usr/lib/lv2 "$m"
101
+ if [ $fmt = deb ]; then
102
+ mkdir -p "$root/DEBIAN"
103
+ printf 'Package: fakeplug\nVersion: %s\nArchitecture: %s\nMaintainer: nobody <nobody@example.org>\nDescription: a fake LV2 plugin package\n' "$v" "$(dpkg --print-architecture)" > "$root/DEBIAN/control"
104
+ dpkg-deb --root-owner-group --build "$root" "$work/fakeplug-$v.deb" >/dev/null
105
+ else
106
+ cat > "$work/fake-$v.spec" <<SPEC
107
+ Name: fakeplug
108
+ Version: $v
109
+ Release: 1
110
+ Summary: a fake LV2 plugin package
111
+ License: GPL-3.0-or-later
112
+ AutoReqProv: no
113
+ %global debug_package %{nil}
114
+ %description
115
+ a fake LV2 plugin package
116
+ %install
117
+ cp -a $root/. %{buildroot}/
118
+ %files
119
+ /usr/lib/lv2/rt-safe.lv2
120
+ /usr/lib64/lv2/rt-safe-lib64.lv2
121
+ SPEC
122
+ rpmbuild -bb --define "_topdir $work/rpmbuild" "$work/fake-$v.spec" >"$work/fake.log" 2>&1 && cp "$work"/rpmbuild/RPMS/*/fakeplug-$v-1.*.rpm "$work/fakeplug-$v.rpm"
123
+ fi
124
+ [ -f "$work/fakeplug-$v.$fmt" ] || { echo "no fake package $v"; tail -20 "$work/fake.log" 2>/dev/null; exit 2; }
125
+ }
126
+ mkplugin 1.0 ""
127
+ mkplugin 2.0 "version 2"
128
+ # what the package manager's own trigger printed is the scan's log: the lines that say what it did are shown
129
+ scan_log() { sed 's/^>>> //' "$work/out" | grep -E '^(qualified |urn:|clap:|LV2 descriptors|plugin-qualify|.*: gone|.*: removed)' | sed 's/^/ scan: /'; }
130
+ install_pkg() { if [ $fmt = rpm ]; then dnf install -y "$1" >"$work/out" 2>"$work/err"; else apt-get install -y "$1" >"$work/out" 2>"$work/err"; fi; local rc=$?; scan_log; return $rc; }
131
+ remove_pkg() { if [ $fmt = rpm ]; then dnf remove -y "$1" >"$work/out" 2>"$work/err"; else apt-get remove -y "$1" >"$work/out" 2>"$work/err"; fi; local rc=$?; scan_log; return $rc; }
132
+
133
+ # --- install --------------------------------------------------------------------------------------------------------------
134
+ say "install the plugin package: nothing is run by hand"
135
+ install_pkg "$work/fakeplug-1.0.$fmt" || { cat "$work/err"; exit 2; }
136
+ show
137
+ check "a qualification record names the plugin" has_file_with "$state/qualification" "$uri_a"
138
+ check "a descriptor names the plugin" has_file_with "$state/descriptors" "$uri_a"
139
+ check "the pin file pins the plugin" grep -qF "$uri_a" "$state/qualified.pins"
140
+ if [ $fmt = rpm ]; then
141
+ check "the /usr/lib64/lv2 plugin has a record" has_file_with "$state/qualification" "$uri_b"
142
+ check "the /usr/lib64/lv2 plugin has a descriptor" has_file_with "$state/descriptors" "$uri_b"
143
+ check "the /usr/lib64/lv2 plugin is pinned" grep -qF "$uri_b" "$state/qualified.pins"
144
+ fi
145
+ pin1=$(pin_of "$uri_a")
146
+ rec1=$(cat "$state/qualification"/* 2>/dev/null | sha256sum)
147
+
148
+ # --- upgrade --------------------------------------------------------------------------------------------------------------
149
+ say "upgrade the plugin package with a changed bundle"
150
+ install_pkg "$work/fakeplug-2.0.$fmt" || { cat "$work/err"; exit 2; }
151
+ show
152
+ pin2=$(pin_of "$uri_a")
153
+ check "the pin changed with the bundle" test -n "$pin2" -a "$pin1" != "$pin2"
154
+ check "the record changed with the bundle" test "$rec1" != "$(cat "$state/qualification"/* 2>/dev/null | sha256sum)"
155
+ check "the descriptor is still there" has_file_with "$state/descriptors" "$uri_a"
156
+
157
+ # --- removal --------------------------------------------------------------------------------------------------------------
158
+ say "remove the plugin package"
159
+ remove_pkg fakeplug || { cat "$work/err"; exit 2; }
160
+ show
161
+ check "the pin is gone" absent grep -qF "$uri_a" "$state/qualified.pins"
162
+ check "the record is gone" absent has_file_with "$state/qualification" "$uri_a"
163
+ check "the descriptor is gone" absent has_file_with "$state/descriptors" "$uri_a"
164
+ if [ $fmt = rpm ]; then
165
+ check "the /usr/lib64/lv2 plugin's pin is gone" absent grep -qF "$uri_b" "$state/qualified.pins"
166
+ check "the /usr/lib64/lv2 plugin's record is gone" absent has_file_with "$state/qualification" "$uri_b"
167
+ check "the /usr/lib64/lv2 plugin's descriptor is gone" absent has_file_with "$state/descriptors" "$uri_b"
168
+ fi
169
+
170
+ echo
171
+ [ "$fails" -eq 0 ] && echo "install-roundtrip: every check holds ($fmt)" || echo "install-roundtrip: $fails check(s) failed ($fmt)"
172
+ rm -rf "$work"
173
+ [ "$fails" -eq 0 ]
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
4
+ // One mod-host admission: a fresh `mod-host -n` on its own port, then `add` and `remove` of one plugin on its
5
+ // socket. Prints one JSON line, {installed, steps:[{cmd, resp | died}]}, for src/host-admission.ts to classify. The host
6
+ // is always killed at the end, so the next plugin gets a fresh one.
7
+ import { spawn } from "node:child_process";
8
+ import { createConnection } from "node:net";
9
+
10
+ const uri = process.argv[2];
11
+ const port = 20000 + (process.pid % 20000);
12
+ const STEP_MS = 10000;
13
+ const INSTANCE = 0;
14
+ const COMMANDS = [
15
+ ["add", `add ${uri} ${INSTANCE}`],
16
+ ["remove", `remove ${INSTANCE}`],
17
+ ];
18
+
19
+ const host = spawn("mod-host", ["-n", "-p", String(port)], { stdio: "ignore" });
20
+ let hostEnd; // how the host went, once it has
21
+ host.on("exit", (code, signal) => (hostEnd = signal ?? "exit"));
22
+ const spawnFailed = new Promise((resolve) => host.on("error", (e) => resolve(e.code)));
23
+
24
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
25
+ async function connect() {
26
+ for (let i = 0; i < 50; i++) {
27
+ if (hostEnd !== undefined) return undefined;
28
+ const s = await new Promise((resolve) => {
29
+ const c = createConnection({ port, host: "127.0.0.1" }, () => resolve(c));
30
+ c.on("error", () => resolve(undefined));
31
+ });
32
+ if (s !== undefined) return s;
33
+ await sleep(100);
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ async function main() {
39
+ if ((await Promise.race([spawnFailed, sleep(200).then(() => undefined)])) === "ENOENT") return { installed: false, steps: [] };
40
+ const sock = await connect();
41
+ if (sock === undefined) return { installed: true, steps: [{ cmd: "add", died: hostEnd ?? "closed" }] };
42
+ let buffer = "";
43
+ let closed = false;
44
+ sock.on("data", (d) => (buffer += d.toString()));
45
+ sock.on("close", () => (closed = true));
46
+ sock.on("error", () => (closed = true));
47
+ const steps = [];
48
+ for (const [cmd, line] of COMMANDS) {
49
+ buffer = "";
50
+ sock.write(`${line}\0`);
51
+ const until = Date.now() + STEP_MS;
52
+ let resp;
53
+ while (Date.now() < until && resp === undefined && !closed && hostEnd === undefined) {
54
+ const m = /resp (-?\d+)/.exec(buffer);
55
+ if (m !== null) resp = Number(m[1]);
56
+ else await sleep(20);
57
+ }
58
+ if (resp === undefined) {
59
+ await sleep(100); // a host that dies on the command closes the socket a moment before it is reaped
60
+ steps.push({ cmd, died: hostEnd ?? (closed ? "closed" : "timeout") });
61
+ if (hostEnd === undefined && !closed) steps[steps.length - 1] = { cmd };
62
+ break;
63
+ }
64
+ steps.push({ cmd, resp });
65
+ if (resp < 0) break;
66
+ }
67
+ sock.destroy();
68
+ return { installed: true, steps };
69
+ }
70
+
71
+ const result = await main();
72
+ host.kill("SIGKILL");
73
+ process.stdout.write(`${JSON.stringify(result)}\n`);
74
+ process.exit(0);