@openmixer/plugin-qualify 0.20.0 → 0.20.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 (48) hide show
  1. package/bin/plugin-qualify-records-package.mjs +9 -7
  2. package/bin/plugin-qualify.mjs +34 -0
  3. package/dist/distributed-records.d.ts +16 -3
  4. package/dist/distributed-records.js +38 -4
  5. package/dist/distributed-records.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +2 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/judged-layer.d.ts +91 -0
  10. package/dist/judged-layer.js +144 -0
  11. package/dist/judged-layer.js.map +1 -0
  12. package/dist/judgement-line.d.ts +6 -0
  13. package/dist/judgement-line.js +20 -0
  14. package/dist/judgement-line.js.map +1 -0
  15. package/dist/measurement/cli.d.ts +2 -0
  16. package/dist/measurement/cli.js +25 -2
  17. package/dist/measurement/cli.js.map +1 -1
  18. package/dist/measurement/format.d.ts +2 -0
  19. package/dist/measurement/format.js +5 -0
  20. package/dist/measurement/format.js.map +1 -1
  21. package/dist/qualification-record.d.ts +8 -0
  22. package/dist/qualification-record.js +12 -2
  23. package/dist/qualification-record.js.map +1 -1
  24. package/dist/qualify-isolated.d.ts +5 -3
  25. package/dist/qualify-isolated.js +6 -4
  26. package/dist/qualify-isolated.js.map +1 -1
  27. package/dist/qualify-sync.d.ts +7 -2
  28. package/dist/qualify-sync.js +21 -11
  29. package/dist/qualify-sync.js.map +1 -1
  30. package/dist/records-package.d.ts +31 -4
  31. package/dist/records-package.js +149 -24
  32. package/dist/records-package.js.map +1 -1
  33. package/fixtures/records-rpi5-0.19.1/http___drumkv1.sourceforge.net_lv2@0.2-7d179bdcf9e5.default.probe.json +58 -0
  34. package/fixtures/records-rpi5-0.19.1/http___drumkv1.sourceforge.net_lv2@0.2-7d179bdcf9e5.default.qualification.json +305 -0
  35. package/fixtures/records-rpi5-0.19.1/http___samplv1.sourceforge.net_lv2@0.2-c9cd8fb3102f.default.probe.json +58 -0
  36. package/fixtures/records-rpi5-0.19.1/http___samplv1.sourceforge.net_lv2@0.2-c9cd8fb3102f.default.qualification.json +301 -0
  37. package/package.json +2 -2
  38. package/src/distributed-records.ts +41 -4
  39. package/src/index.ts +2 -0
  40. package/src/judged-layer.ts +197 -0
  41. package/src/judgement-line.ts +22 -0
  42. package/src/measurement/cli.ts +26 -2
  43. package/src/measurement/format.ts +8 -0
  44. package/src/qualification-record.ts +19 -2
  45. package/src/qualify-isolated.ts +6 -4
  46. package/src/qualify-sync.ts +22 -10
  47. package/src/records-package.ts +156 -25
  48. package/tools/lv2-measure.mjs +21 -2
@@ -4,10 +4,13 @@
4
4
  * `plugin-qualify-records-package`: the Debian package of a device's qualification records, so other devices of the
5
5
  * machine class install them as the distributed layer instead of measuring the same plugin set again.
6
6
  *
7
- * In: a qualification directory as plugin-qualify-sync leaves it. Out: `plugin-db-qualification-<class>-<set>_<version>_all.deb`,
8
- * a package of plugin-db's qualification layer,
9
- * installing every record and the probe output it judged under `/usr/share/omx/qualification/<class>/<set>/`, beside
10
- * the set's `provenance.json`. A record goes in only with its probe output, byte for byte the copy the record names;
7
+ * A record set is per machine class, rate and quantum (#317). In: the qualification directories of a run, one per
8
+ * cell it was measured at, as plugin-qualify-sync leaves them, or the run's root holding `b<rate>-<quantum>/qualification`.
9
+ * Out: one `plugin-db-qualification-<class>-<set>-<rate>-<quantum>_<version>_all.deb` per cell, a package of plugin-db's
10
+ * qualification layer, installing every record of that cell and the probe output it judged under
11
+ * `/usr/share/omx/qualification/<class>/<set>-<rate>-<quantum>/`, beside the set's `provenance.json`, which names the
12
+ * cell. A directory's cell is the one its base is named for (`b48000-512`), else `--cell`, else the one cell every
13
+ * record's grid holds; a record whose grid does not hold its set's cell stays out, named. A record goes in only with its probe output, byte for byte the copy the record names;
11
14
  * an unmeasured record never applies and stays out. The archive is written here, `ar` and two `tar.xz` members, so a
12
15
  * machine without dpkg builds it too; the tar members are GNU tar's, owned by root, in name order, at one time.
13
16
  */
@@ -15,14 +18,18 @@ import { createHash } from "node:crypto";
15
18
  import { spawnSync } from "node:child_process";
16
19
  import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
17
20
  import { tmpdir } from "node:os";
18
- import { join } from "node:path";
21
+ import { basename, dirname, join, resolve } from "node:path";
19
22
 
20
- import { DISTRIBUTED_DIR_SYSTEM, DISTRIBUTED_PROVENANCE_FILE, DISTRIBUTED_PROVENANCE_SCHEMA, type DistributedProvenance } from "./distributed-records.js";
23
+ import { cellKey, type GridCell } from "./measurement-grid.js";
24
+ import { DISTRIBUTED_DIR_SYSTEM, recordCells, DISTRIBUTED_PROVENANCE_FILE, DISTRIBUTED_PROVENANCE_SCHEMA, type DistributedProvenance } from "./distributed-records.js";
21
25
  import { sha256Hex } from "./probe-output.js";
22
26
  import { QUALIFICATION_RECORD_SCHEMA } from "./qualification-record.js";
23
27
 
24
28
  export interface RecordsPackageArgs {
25
- readonly stateDir: string;
29
+ /** Qualification directories, or run roots holding `b<rate>-<quantum>/qualification`: one set per cell. */
30
+ readonly sources: readonly string[];
31
+ /** The cell of a lone directory whose name says none. */
32
+ readonly cell?: GridCell;
26
33
  readonly hostClass: string;
27
34
  readonly set: string;
28
35
  /** A JSON file, or the JSON itself. */
@@ -44,18 +51,22 @@ export function recordsPackageHelp(): string {
44
51
  return [
45
52
  "plugin-qualify-records-package: the Debian package of a device's qualification records, for the distributed layer",
46
53
  "",
47
- " plugin-qualify-records-package <qualification dir> --host-class <class> --set <set> --provenance <json> [options]",
54
+ " plugin-qualify-records-package <qualification dir|run root>... --host-class <class> --set <set> --provenance <json> [options]",
55
+ "",
56
+ " one package per cell (rate and quantum): each qualification directory is a cell, named by its base",
57
+ " (<run>/b48000-512/qualification); a run root stands for every b<rate>-<quantum>/qualification under it",
48
58
  "",
49
59
  " --host-class <class> the machine class the records were measured on (rpi5): the directory they install in",
50
- " --set <set> the plugin set they cover (zynthian-vangelis)",
60
+ " --set <set> the plugin set they cover (zynthian-vangelis); each package's set is <set>-<rate>-<quantum>",
61
+ " --cell <rate>/<quantum> the cell of a lone directory not named b<rate>-<quantum> whose records hold several",
51
62
  " --provenance <json> a JSON file, or the JSON itself: the image, the package versions, the source revisions",
52
63
  " --version <version> the package's version (default: today, 2026.10.29)",
53
64
  " --out <dir> where the .deb is written (default: .)",
54
65
  " --maintainer <who> the package's Maintainer field",
55
66
  " --help this text",
56
67
  "",
57
- `the package installs ${DISTRIBUTED_DIR_SYSTEM}/<class>/<set>/ and depends on this plugin-qualify's minor, up to the next one`,
58
- "exit 0 the package was written, 2 it could not be",
68
+ `each package installs ${DISTRIBUTED_DIR_SYSTEM}/<class>/<set>-<rate>-<quantum>/ and depends on this plugin-qualify's minor, up to the next one`,
69
+ "exit 0 every package was written, 2 one could not be",
59
70
  "",
60
71
  ].join("\n");
61
72
  }
@@ -63,7 +74,7 @@ export function recordsPackageHelp(): string {
63
74
  export function parseRecordsPackageArgs(argv: readonly string[]): { ok: true; args: RecordsPackageArgs } | { ok: true; help: true } | { ok: false; error: string } {
64
75
  const values = new Map<string, string>();
65
76
  const positional: string[] = [];
66
- const valued = new Set(["--host-class", "--set", "--provenance", "--version", "--out", "--maintainer"]);
77
+ const valued = new Set(["--host-class", "--set", "--cell", "--provenance", "--version", "--out", "--maintainer"]);
67
78
  for (let i = 0; i < argv.length; i++) {
68
79
  const a = argv[i]!;
69
80
  if (a === "--help") return { ok: true, help: true };
@@ -74,19 +85,24 @@ export function parseRecordsPackageArgs(argv: readonly string[]): { ok: true; ar
74
85
  } else if (a.startsWith("--")) return { ok: false, error: `unknown argument ${a}` };
75
86
  else positional.push(a);
76
87
  }
77
- if (positional.length !== 1) return { ok: false, error: "name one qualification directory" };
88
+ if (positional.length === 0) return { ok: false, error: "name a qualification directory or a run root" };
78
89
  for (const flag of ["--host-class", "--set", "--provenance"]) if (!values.has(flag)) return { ok: false, error: `${flag} is needed` };
79
90
  const hostClass = values.get("--host-class")!;
80
91
  const set = values.get("--set")!;
81
92
  for (const [what, v] of [["--host-class", hostClass], ["--set", set]] as const) {
82
93
  if (!DEB_WORD.test(v)) return { ok: false, error: `${what} ${v}: a package name holds lowercase letters, digits, + - and . only` };
83
94
  }
95
+ const cellText = values.get("--cell");
96
+ const cell = cellText === undefined ? undefined : parseCell(cellText);
97
+ if (cellText !== undefined && cell === undefined) return { ok: false, error: `--cell ${cellText} is not <rate>/<quantum>` };
98
+ if (cell !== undefined && positional.length !== 1) return { ok: false, error: "--cell names the cell of one directory; name one" };
84
99
  const version = values.get("--version");
85
100
  if (version !== undefined && !DEB_VERSION.test(version)) return { ok: false, error: `--version ${version} is not a Debian version` };
86
101
  return {
87
102
  ok: true,
88
103
  args: {
89
- stateDir: positional[0]!,
104
+ sources: positional,
105
+ ...(cell === undefined ? {} : { cell }),
90
106
  hostClass,
91
107
  set,
92
108
  provenance: values.get("--provenance")!,
@@ -97,6 +113,68 @@ export function parseRecordsPackageArgs(argv: readonly string[]): { ok: true; ar
97
113
  };
98
114
  }
99
115
 
116
+ function parseCell(text: string): GridCell | undefined {
117
+ const m = /^(\d+)\/(\d+)$/.exec(text.trim());
118
+ if (m === null) return undefined;
119
+ const [rate, quantum] = [Number(m[1]), Number(m[2])];
120
+ return rate > 0 && quantum > 0 && Number.isSafeInteger(rate) && Number.isSafeInteger(quantum) ? { rate, quantum } : undefined;
121
+ }
122
+
123
+ /** A run's base directory names its cell: `b48000-512`. */
124
+ const BASE_NAME = /^b(\d+)-(\d+)$/;
125
+
126
+ /** The set of a cell: `<set>-<rate>-<quantum>`. */
127
+ export const recordsSetName = (set: string, cell: GridCell): string => `${set}-${String(cell.rate)}-${String(cell.quantum)}`;
128
+
129
+ /** One qualification directory of a run and the cell its name says, when it says one. */
130
+ export interface RecordsSource {
131
+ readonly dir: string;
132
+ readonly named?: GridCell;
133
+ }
134
+
135
+ /**
136
+ * The qualification directories the arguments stand for, in name order: a directory holding records is one; a run
137
+ * root stands for every `b<rate>-<quantum>/qualification` (or `b<rate>-<quantum>` holding records) under it.
138
+ */
139
+ export function recordsSources(args: readonly string[]): RecordsSource[] {
140
+ const holdsRecords = (d: string): boolean => {
141
+ try {
142
+ return readdirSync(d).some((n) => n.endsWith(".qualification.json") || n.endsWith(".unmeasured.json"));
143
+ } catch {
144
+ return false;
145
+ }
146
+ };
147
+ const namedBy = (d: string): GridCell | undefined => {
148
+ for (const part of [basename(d), basename(dirname(d))]) {
149
+ const m = BASE_NAME.exec(part);
150
+ if (m !== null) return { rate: Number(m[1]), quantum: Number(m[2]) };
151
+ }
152
+ return undefined;
153
+ };
154
+ const out: RecordsSource[] = [];
155
+ for (const a of args) {
156
+ const dir = resolve(a);
157
+ statSync(dir);
158
+ if (holdsRecords(dir)) {
159
+ const named = namedBy(dir);
160
+ out.push({ dir, ...(named === undefined ? {} : { named }) });
161
+ continue;
162
+ }
163
+ const bases = readdirSync(dir).filter((n) => BASE_NAME.test(n)).sort();
164
+ let found = 0;
165
+ for (const b of bases) {
166
+ for (const d of [join(dir, b, "qualification"), join(dir, b)]) {
167
+ if (!holdsRecords(d)) continue;
168
+ out.push({ dir: d, named: namedBy(d)! });
169
+ found++;
170
+ break;
171
+ }
172
+ }
173
+ if (found === 0) throw new Error(`${dir} holds no record and no b<rate>-<quantum> base with records`);
174
+ }
175
+ return out;
176
+ }
177
+
100
178
  /** The package of a class and a set. */
101
179
  export const recordsPackageName = (hostClass: string, set: string): string => `plugin-db-qualification-${hostClass}-${set}`;
102
180
 
@@ -122,7 +200,7 @@ export function today(now = new Date()): { version: string; date: string } {
122
200
  }
123
201
 
124
202
  /** The files of a qualification directory that go in the package: each record with its probe output, and what stays out, by name and why. */
125
- export function collectRecords(stateDir: string): { records: string[]; files: string[]; left: { name: string; why: string }[] } {
203
+ export function collectRecords(stateDir: string, cell?: GridCell): { records: string[]; files: string[]; left: { name: string; why: string }[] } {
126
204
  const names = readdirSync(stateDir).sort();
127
205
  const records: string[] = [];
128
206
  const files: string[] = [];
@@ -133,7 +211,7 @@ export function collectRecords(stateDir: string): { records: string[]; files: st
133
211
  continue;
134
212
  }
135
213
  if (!name.endsWith(".qualification.json")) continue;
136
- let record: { schema?: unknown; probe?: { file?: unknown; sha256?: unknown } };
214
+ let record: { schema?: unknown; probe?: { file?: unknown; sha256?: unknown }; measurements?: { grid?: readonly GridCell[] } };
137
215
  try {
138
216
  record = JSON.parse(readFileSync(join(stateDir, name), "utf8"));
139
217
  } catch {
@@ -144,6 +222,11 @@ export function collectRecords(stateDir: string): { records: string[]; files: st
144
222
  left.push({ name, why: `not a ${QUALIFICATION_RECORD_SCHEMA} naming its probe output` });
145
223
  continue;
146
224
  }
225
+ const cells = gridCellsOf(record);
226
+ if (cell !== undefined && !cells.includes(cellKey(cell))) {
227
+ left.push({ name, why: `measured at ${cells.length === 0 ? "no cell" : cells.join(", ")}, not at its set's ${cellKey(cell)}` });
228
+ continue;
229
+ }
147
230
  let probe: Buffer;
148
231
  try {
149
232
  probe = readFileSync(join(stateDir, record.probe.file));
@@ -161,6 +244,29 @@ export function collectRecords(stateDir: string): { records: string[]; files: st
161
244
  return { records, files: [...new Set(files)].sort(), left };
162
245
  }
163
246
 
247
+ /** The cells of a record's grid, `rate/quantum`, by rate then quantum: `recordCells`, keyed. */
248
+ const gridCellsOf = (record: { measurements?: { grid?: readonly GridCell[] } }): string[] =>
249
+ recordCells({ measurements: { grid: Array.isArray(record.measurements?.grid) ? record.measurements.grid : [] } } as unknown as Parameters<typeof recordCells>[0]).map(cellKey);
250
+
251
+ /**
252
+ * The cell of a source: its base's name, else the one given, else the one cell every record of it holds alone.
253
+ * Throws, naming the cells, when none decides it.
254
+ */
255
+ export function cellOfSource(source: RecordsSource, given?: GridCell): GridCell {
256
+ if (source.named !== undefined) return source.named;
257
+ if (given !== undefined) return given;
258
+ const seen = new Set<string>();
259
+ for (const name of readdirSync(source.dir).filter((n) => n.endsWith(".qualification.json"))) {
260
+ try {
261
+ for (const c of gridCellsOf(JSON.parse(readFileSync(join(source.dir, name), "utf8")))) seen.add(c);
262
+ } catch {
263
+ // a record that does not read is named by the collection, and decides no cell
264
+ }
265
+ }
266
+ if (seen.size === 1) return parseCell([...seen][0]!)!;
267
+ throw new Error(`${source.dir}: its records are measured at ${seen.size === 0 ? "no cell" : [...seen].join(", ")}; a set is one cell: name the base b<rate>-<quantum> or give --cell`);
268
+ }
269
+
164
270
  /** The provenance argument read: a file's JSON, or the JSON itself. */
165
271
  function readProvenanceArg(arg: string): Record<string, unknown> {
166
272
  const text = arg.trimStart().startsWith("{") ? arg : readFileSync(arg, "utf8");
@@ -203,6 +309,9 @@ function tarXz(tree: string, out: string, mtime: number): void {
203
309
 
204
310
  export interface BuiltRecordsPackage {
205
311
  readonly path: string;
312
+ /** The set, `<set>-<rate>-<quantum>`, and its cell. */
313
+ readonly set: string;
314
+ readonly cell: GridCell;
206
315
  readonly name: string;
207
316
  readonly version: string;
208
317
  readonly records: number;
@@ -211,28 +320,49 @@ export interface BuiltRecordsPackage {
211
320
  readonly left: readonly { name: string; why: string }[];
212
321
  }
213
322
 
214
- /** The package of `args`, written into `args.out`. Throws, naming why, when it cannot be built. */
215
- export function buildRecordsPackage(args: RecordsPackageArgs, now = new Date()): BuiltRecordsPackage {
216
- const name = recordsPackageName(args.hostClass, args.set);
323
+ /**
324
+ * One package per cell of `args.sources`, written into `args.out`, in the order of the cells. Every source's cell is
325
+ * decided and no two may share one before any package is written; throws, naming why, when one cannot be built.
326
+ */
327
+ export function buildRecordsPackages(args: RecordsPackageArgs, now = new Date()): BuiltRecordsPackage[] {
328
+ const sources = recordsSources(args.sources);
329
+ if (args.cell !== undefined && sources.length !== 1) throw new Error("--cell names the cell of one directory, and these are several");
330
+ const cells = sources.map((s) => ({ source: s, cell: cellOfSource(s, args.cell) }));
331
+ const seen = new Map<string, string>();
332
+ for (const { source, cell } of cells) {
333
+ const other = seen.get(cellKey(cell));
334
+ if (other !== undefined) throw new Error(`${source.dir} and ${other} are both measured at ${cellKey(cell)}: one set per cell`);
335
+ seen.set(cellKey(cell), source.dir);
336
+ }
337
+ return cells
338
+ .sort((a, b) => a.cell.rate - b.cell.rate || a.cell.quantum - b.cell.quantum)
339
+ .map(({ source, cell }) => buildRecordsPackage({ ...args, stateDir: source.dir, cell }, now));
340
+ }
341
+
342
+ /** The package of one cell's directory, written into `args.out`. Throws, naming why, when it cannot be built. */
343
+ function buildRecordsPackage(args: RecordsPackageArgs & { readonly stateDir: string; readonly cell: GridCell }, now: Date): BuiltRecordsPackage {
344
+ const set = recordsSetName(args.set, args.cell);
345
+ const name = recordsPackageName(args.hostClass, set);
217
346
  const range = recordsDependsRange(ownVersion());
218
347
  if (range === undefined) throw new Error("this plugin-qualify names no version, so the package cannot say which one reads it");
219
348
  const day = today(now);
220
349
  const version = args.version ?? day.version;
221
350
  statSync(args.stateDir);
222
- const { records, files, left } = collectRecords(args.stateDir);
351
+ const { records, files, left } = collectRecords(args.stateDir, args.cell);
223
352
  if (records.length === 0) throw new Error(`${args.stateDir} holds no record that can go in the package`);
224
353
  const provenance: DistributedProvenance = {
225
354
  ...readProvenanceArg(args.provenance),
226
355
  schema: DISTRIBUTED_PROVENANCE_SCHEMA,
227
- set: args.set,
356
+ set,
228
357
  hostClass: args.hostClass,
358
+ cell: { rate: args.cell.rate, quantum: args.cell.quantum },
229
359
  package: { name, version },
230
360
  date: day.date,
231
361
  };
232
362
  const work = mkdtempSync(join(tmpdir(), "records-package-"));
233
363
  try {
234
364
  const data = join(work, "data");
235
- const rel = join("usr", "share", "omx", "qualification", args.hostClass, args.set);
365
+ const rel = join("usr", "share", "omx", "qualification", args.hostClass, set);
236
366
  const dir = join(data, rel);
237
367
  mkdirSync(dir, { recursive: true });
238
368
  for (const f of files) copyFileSync(join(args.stateDir, f), join(dir, f));
@@ -259,9 +389,10 @@ export function buildRecordsPackage(args: RecordsPackageArgs, now = new Date()):
259
389
  "Section: sound",
260
390
  "Priority: optional",
261
391
  "Multi-Arch: foreign",
262
- `Description: plugin-db qualification layer: ${args.set} set for ${args.hostClass}`,
392
+ `Description: plugin-db qualification layer: ${args.set} set for ${args.hostClass} at ${String(args.cell.rate)} Hz, ${String(args.cell.quantum)} frames`,
263
393
  ` The qualification layer of the plugin database: the plugin-qualify records`,
264
- ` of the ${args.set} plugin set, measured on a ${args.hostClass} device. plugin-qualify-sync takes a plugin from them instead of`,
394
+ ` of the ${args.set} plugin set, measured on a ${args.hostClass} device at ${String(args.cell.rate)} Hz`,
395
+ ` and a quantum of ${String(args.cell.quantum)} frames. plugin-qualify-sync takes a plugin from them instead of`,
265
396
  " measuring it, and only when every file of the installed plugin hashes as the",
266
397
  " measured one did; any other plugin is measured on the device as before.",
267
398
  "",
@@ -282,7 +413,7 @@ export function buildRecordsPackage(args: RecordsPackageArgs, now = new Date()):
282
413
  ],
283
414
  mtime,
284
415
  );
285
- return { path, name, version, records: records.length, files: installed.length, left };
416
+ return { path, set, cell: args.cell, name, version, records: records.length, files: installed.length, left };
286
417
  } finally {
287
418
  rmSync(work, { recursive: true, force: true });
288
419
  }
@@ -50,6 +50,7 @@ import {
50
50
  buildPipeline,
51
51
  collectHostFacts,
52
52
  HOSTING_PASS_DIMENSIONS,
53
+ PROBED_DIMENSIONS,
53
54
  hostingLine,
54
55
  INITIAL_PROGRESS,
55
56
  parseMeasureArgs,
@@ -131,6 +132,20 @@ function hostingTools() {
131
132
  return { tool, interposer };
132
133
  }
133
134
 
135
+ /**
136
+ * The native measurer for the pin step of a run with no hosting pass: absent, the run still measures and says on stderr
137
+ * that its plugins carry no integrity block (it does not need the interposer, so a half-populated pair is not refused here).
138
+ */
139
+ function pinTools() {
140
+ const { tools } = locateNativeTools([LV2_MEASURE_TOOL_NAME, RT_INTERPOSE_NAME], process.env, existsSync);
141
+ const [tool, interposer] = tools;
142
+ if (tool?.path === undefined) {
143
+ process.stderr.write(`${TOOL_NAME}: no ${LV2_MEASURE_TOOL_NAME} at ${tool?.looked.join(", ") ?? "its places"}: the plugins are written with no integrity block (build it with make -C native or set ${tool?.setting})\n`);
144
+ return undefined;
145
+ }
146
+ return { tool: tool.path, interposer: interposer?.path ?? "" };
147
+ }
148
+
134
149
  /** The hosting pass: the measurer once per plugin, each in its own process, its lines written as JSONL. */
135
150
  function runHosting(step, uris, onProgress) {
136
151
  const lines = [];
@@ -201,7 +216,8 @@ async function main() {
201
216
  const options = parsed.options;
202
217
 
203
218
  const hosting = options.dimensions.some((d) => HOSTING_PASS_DIMENSIONS.includes(d));
204
- const pipeline = buildPipeline(options, hosting ? hostingTools() : undefined);
219
+ const probedOnly = !hosting && options.dimensions.some((d) => PROBED_DIMENSIONS.includes(d));
220
+ const pipeline = buildPipeline(options, hosting ? hostingTools() : probedOnly ? pinTools() : undefined);
205
221
 
206
222
  if (options.dryRun) {
207
223
  process.stderr.write(
@@ -242,11 +258,13 @@ async function main() {
242
258
  const measuredAt = new Date().toISOString();
243
259
  emit({ step: "start", steps: pipeline.steps.map((s) => s.id) });
244
260
  let hostingLines;
261
+ let pinLines;
245
262
  for (const step of pipeline.steps) {
246
263
  process.stderr.write(`== ${step.id}\n`);
247
264
  if (step.perPlugin) {
248
265
  const uris = options.plugins ?? urisFromScan(readJson(pipeline.hostingScan));
249
- hostingLines = runHosting(step, uris, onProgress);
266
+ if (step.id === "pin") pinLines = runHosting(step, uris, onProgress);
267
+ else hostingLines = runHosting(step, uris, onProgress);
250
268
  } else {
251
269
  await runStep(step, onProgress);
252
270
  }
@@ -257,6 +275,7 @@ async function main() {
257
275
  options,
258
276
  ...(pipeline.finalCatalog === undefined ? {} : { catalog: readJson(pipeline.finalCatalog) }),
259
277
  ...(hostingLines === undefined ? {} : { hosting: hostingLines }),
278
+ ...(pinLines === undefined ? {} : { pins: pinLines }),
260
279
  costProvenance:
261
280
  pipeline.costProvenance !== undefined && existsSync(pipeline.costProvenance)
262
281
  ? readJson(pipeline.costProvenance)