@openmixer/plugin-qualify 0.16.1 → 0.17.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 (61) hide show
  1. package/README.md +153 -6
  2. package/bin/plugin-qualify-records-package.mjs +31 -0
  3. package/bin/plugin-qualify-sync.mjs +6 -1
  4. package/bin/plugin-qualify.mjs +12 -1
  5. package/dist/distributed-records.d.ts +88 -0
  6. package/dist/distributed-records.js +210 -0
  7. package/dist/distributed-records.js.map +1 -0
  8. package/dist/index.d.ts +5 -0
  9. package/dist/index.js +5 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/measurement-grid.d.ts +49 -0
  12. package/dist/measurement-grid.js +76 -0
  13. package/dist/measurement-grid.js.map +1 -0
  14. package/dist/memory-bound.d.ts +23 -0
  15. package/dist/memory-bound.js +46 -0
  16. package/dist/memory-bound.js.map +1 -0
  17. package/dist/probe-judge.js +22 -1
  18. package/dist/probe-judge.js.map +1 -1
  19. package/dist/probe-merge.d.ts +30 -0
  20. package/dist/probe-merge.js +80 -0
  21. package/dist/probe-merge.js.map +1 -0
  22. package/dist/probe-output.d.ts +8 -0
  23. package/dist/probe-output.js +7 -0
  24. package/dist/probe-output.js.map +1 -1
  25. package/dist/probe-runner.d.ts +26 -2
  26. package/dist/probe-runner.js +38 -10
  27. package/dist/probe-runner.js.map +1 -1
  28. package/dist/qualification-record.d.ts +19 -1
  29. package/dist/qualification-record.js +30 -1
  30. package/dist/qualification-record.js.map +1 -1
  31. package/dist/qualify-cli.d.ts +9 -0
  32. package/dist/qualify-cli.js +30 -1
  33. package/dist/qualify-cli.js.map +1 -1
  34. package/dist/qualify-declarations.d.ts +15 -0
  35. package/dist/qualify-declarations.js +15 -0
  36. package/dist/qualify-declarations.js.map +1 -1
  37. package/dist/qualify-options.js +11 -1
  38. package/dist/qualify-options.js.map +1 -1
  39. package/dist/qualify-sync.d.ts +18 -1
  40. package/dist/qualify-sync.js +142 -12
  41. package/dist/qualify-sync.js.map +1 -1
  42. package/dist/records-package.d.ts +63 -0
  43. package/dist/records-package.js +300 -0
  44. package/dist/records-package.js.map +1 -0
  45. package/package.json +3 -2
  46. package/src/distributed-records.ts +251 -0
  47. package/src/index.ts +5 -0
  48. package/src/measurement-grid.ts +97 -0
  49. package/src/memory-bound.ts +55 -0
  50. package/src/probe-judge.ts +25 -1
  51. package/src/probe-merge.ts +89 -0
  52. package/src/probe-output.ts +13 -0
  53. package/src/probe-runner.ts +75 -13
  54. package/src/qualification-record.ts +44 -2
  55. package/src/qualify-cli.ts +32 -1
  56. package/src/qualify-declarations.ts +17 -0
  57. package/src/qualify-options.ts +11 -1
  58. package/src/qualify-sync.ts +147 -11
  59. package/src/records-package.ts +333 -0
  60. package/tools/{ai-trace-commit-check.sh → commit-message-check.sh} +16 -16
  61. package/tools/{ai-trace-patterns.json → commit-message-patterns.json} +1 -1
@@ -0,0 +1,333 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * `plugin-qualify-records-package`: the Debian package of a device's qualification records, so other devices of the
5
+ * machine class install them as the distributed layer instead of measuring the same plugin set again.
6
+ *
7
+ * In: a qualification directory as plugin-qualify-sync leaves it. Out: `omx-qualification-<class>-<set>_<version>_all.deb`
8
+ * installing every record and the probe output it judged under `/usr/share/omx/qualification/<class>/<set>/`, beside
9
+ * the set's `provenance.json`. A record goes in only with its probe output, byte for byte the copy the record names;
10
+ * an unmeasured record never applies and stays out. The archive is written here, `ar` and two `tar.xz` members, so a
11
+ * machine without dpkg builds it too; the tar members are GNU tar's, owned by root, in name order, at one time.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { spawnSync } from "node:child_process";
15
+ import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ import { DISTRIBUTED_DIR_SYSTEM, DISTRIBUTED_LAYER_SINCE, DISTRIBUTED_PROVENANCE_FILE, DISTRIBUTED_PROVENANCE_SCHEMA, type DistributedProvenance } from "./distributed-records.js";
20
+ import { sha256Hex } from "./probe-output.js";
21
+ import { QUALIFICATION_RECORD_SCHEMA } from "./qualification-record.js";
22
+
23
+ export interface RecordsPackageArgs {
24
+ readonly stateDir: string;
25
+ readonly hostClass: string;
26
+ readonly set: string;
27
+ /** A JSON file, or the JSON itself. */
28
+ readonly provenance: string;
29
+ readonly version?: string;
30
+ readonly out: string;
31
+ readonly maintainer: string;
32
+ }
33
+
34
+ export const RECORDS_PACKAGE_MAINTAINER = "Pau Aliagas <linuxnow@gmail.com>";
35
+
36
+ /** A word a Debian package name can hold: lowercase letters, digits, `+`, `-` and `.`, a letter or digit first. */
37
+ const DEB_WORD = /^[a-z0-9][a-z0-9+.-]*$/;
38
+ /** dpkg counts Installed-Size in kibibytes. */
39
+ const KIB = 2 ** 10;
40
+ const DEB_VERSION = /^[0-9][A-Za-z0-9.+~]*$/;
41
+
42
+ export function recordsPackageHelp(): string {
43
+ return [
44
+ "plugin-qualify-records-package: the Debian package of a device's qualification records, for the distributed layer",
45
+ "",
46
+ " plugin-qualify-records-package <qualification dir> --host-class <class> --set <set> --provenance <json> [options]",
47
+ "",
48
+ " --host-class <class> the machine class the records were measured on (rpi5): the directory they install in",
49
+ " --set <set> the plugin set they cover (zynthian-vangelis)",
50
+ " --provenance <json> a JSON file, or the JSON itself: the image, the package versions, the source revisions",
51
+ " --version <version> the package's version (default: today, 2026.10.29)",
52
+ " --out <dir> where the .deb is written (default: .)",
53
+ " --maintainer <who> the package's Maintainer field",
54
+ " --help this text",
55
+ "",
56
+ `the package installs ${DISTRIBUTED_DIR_SYSTEM}/<class>/<set>/ and depends on plugin-qualify (>= ${DISTRIBUTED_LAYER_SINCE})`,
57
+ "exit 0 the package was written, 2 it could not be",
58
+ "",
59
+ ].join("\n");
60
+ }
61
+
62
+ export function parseRecordsPackageArgs(argv: readonly string[]): { ok: true; args: RecordsPackageArgs } | { ok: true; help: true } | { ok: false; error: string } {
63
+ const values = new Map<string, string>();
64
+ const positional: string[] = [];
65
+ const valued = new Set(["--host-class", "--set", "--provenance", "--version", "--out", "--maintainer"]);
66
+ for (let i = 0; i < argv.length; i++) {
67
+ const a = argv[i]!;
68
+ if (a === "--help") return { ok: true, help: true };
69
+ if (valued.has(a)) {
70
+ const v = argv[++i];
71
+ if (v === undefined) return { ok: false, error: `${a} needs a value` };
72
+ values.set(a, v);
73
+ } else if (a.startsWith("--")) return { ok: false, error: `unknown argument ${a}` };
74
+ else positional.push(a);
75
+ }
76
+ if (positional.length !== 1) return { ok: false, error: "name one qualification directory" };
77
+ for (const flag of ["--host-class", "--set", "--provenance"]) if (!values.has(flag)) return { ok: false, error: `${flag} is needed` };
78
+ const hostClass = values.get("--host-class")!;
79
+ const set = values.get("--set")!;
80
+ for (const [what, v] of [["--host-class", hostClass], ["--set", set]] as const) {
81
+ if (!DEB_WORD.test(v)) return { ok: false, error: `${what} ${v}: a package name holds lowercase letters, digits, + - and . only` };
82
+ }
83
+ const version = values.get("--version");
84
+ if (version !== undefined && !DEB_VERSION.test(version)) return { ok: false, error: `--version ${version} is not a Debian version` };
85
+ return {
86
+ ok: true,
87
+ args: {
88
+ stateDir: positional[0]!,
89
+ hostClass,
90
+ set,
91
+ provenance: values.get("--provenance")!,
92
+ ...(version === undefined ? {} : { version }),
93
+ out: values.get("--out") ?? ".",
94
+ maintainer: values.get("--maintainer") ?? RECORDS_PACKAGE_MAINTAINER,
95
+ },
96
+ };
97
+ }
98
+
99
+ /** The package of a class and a set. */
100
+ export const recordsPackageName = (hostClass: string, set: string): string => `omx-qualification-${hostClass}-${set}`;
101
+
102
+ /** Today as a package version, `2026.10.29`, and as a date, `2026-10-29`, in local time. */
103
+ export function today(now = new Date()): { version: string; date: string } {
104
+ const p = (n: number): string => String(n).padStart(2, "0");
105
+ const [y, m, d] = [String(now.getFullYear()), p(now.getMonth() + 1), p(now.getDate())];
106
+ return { version: `${y}.${m}.${d}`, date: `${y}-${m}-${d}` };
107
+ }
108
+
109
+ /** 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. */
110
+ export function collectRecords(stateDir: string): { records: string[]; files: string[]; left: { name: string; why: string }[] } {
111
+ const names = readdirSync(stateDir).sort();
112
+ const records: string[] = [];
113
+ const files: string[] = [];
114
+ const left: { name: string; why: string }[] = [];
115
+ for (const name of names) {
116
+ if (name.endsWith(".unmeasured.json")) {
117
+ left.push({ name, why: "unmeasured, never applies" });
118
+ continue;
119
+ }
120
+ if (!name.endsWith(".qualification.json")) continue;
121
+ let record: { schema?: unknown; probe?: { file?: unknown; sha256?: unknown } };
122
+ try {
123
+ record = JSON.parse(readFileSync(join(stateDir, name), "utf8"));
124
+ } catch {
125
+ left.push({ name, why: "does not read" });
126
+ continue;
127
+ }
128
+ if (record.schema !== QUALIFICATION_RECORD_SCHEMA || typeof record.probe?.file !== "string" || record.probe.file.includes("/")) {
129
+ left.push({ name, why: `not a ${QUALIFICATION_RECORD_SCHEMA} naming its probe output` });
130
+ continue;
131
+ }
132
+ let probe: Buffer;
133
+ try {
134
+ probe = readFileSync(join(stateDir, record.probe.file));
135
+ } catch {
136
+ left.push({ name, why: `its probe output ${record.probe.file} is not there` });
137
+ continue;
138
+ }
139
+ if (sha256Hex(probe) !== record.probe.sha256) {
140
+ left.push({ name, why: `its probe output ${record.probe.file} is not the copy it names` });
141
+ continue;
142
+ }
143
+ records.push(name);
144
+ files.push(name, record.probe.file);
145
+ }
146
+ return { records, files: [...new Set(files)].sort(), left };
147
+ }
148
+
149
+ /** The provenance argument read: a file's JSON, or the JSON itself. */
150
+ function readProvenanceArg(arg: string): Record<string, unknown> {
151
+ const text = arg.trimStart().startsWith("{") ? arg : readFileSync(arg, "utf8");
152
+ const v = JSON.parse(text) as unknown;
153
+ if (typeof v !== "object" || v === null || Array.isArray(v)) throw new Error("--provenance is not a JSON object");
154
+ return v as Record<string, unknown>;
155
+ }
156
+
157
+ // --- the archive ------------------------------------------------------------------------------------------------------
158
+
159
+ /** One `ar` member header: name, time, owner, group, mode, size, as dpkg reads them. */
160
+ function arHeader(name: string, size: number, mtime: number): Buffer {
161
+ const field = (v: string, n: number): string => v.padEnd(n, " ").slice(0, n);
162
+ return Buffer.from(`${field(name, 16)}${field(String(mtime), 12)}${field("0", 6)}${field("0", 6)}${field("100644", 8)}${field(String(size), 10)}\`\n`, "latin1");
163
+ }
164
+
165
+ function writeAr(path: string, members: readonly { name: string; bytes: Buffer }[], mtime: number): void {
166
+ const fd = openSync(path, "w", 0o644);
167
+ try {
168
+ writeSync(fd, Buffer.from("!<arch>\n"));
169
+ for (const m of members) {
170
+ writeSync(fd, arHeader(m.name, m.bytes.length, mtime));
171
+ writeSync(fd, m.bytes);
172
+ if (m.bytes.length % 2 === 1) writeSync(fd, Buffer.from("\n"));
173
+ }
174
+ } finally {
175
+ closeSync(fd);
176
+ }
177
+ }
178
+
179
+ /** A tree as a `tar.xz`, every entry `./`-rooted, owned by root, in name order, at `mtime`. */
180
+ function tarXz(tree: string, out: string, mtime: number): void {
181
+ const r = spawnSync(
182
+ "tar",
183
+ ["--create", "--xz", "--format=gnu", "--owner=0", "--group=0", "--numeric-owner", "--sort=name", `--mtime=@${String(mtime)}`, "--mode=u=rwX,go=rX", "-f", out, "-C", tree, "."],
184
+ { encoding: "utf8", env: { ...process.env, XZ_OPT: "-6" } },
185
+ );
186
+ if (r.status !== 0) throw new Error(`tar: ${r.error?.message ?? r.stderr.trim()}`);
187
+ }
188
+
189
+ export interface BuiltRecordsPackage {
190
+ readonly path: string;
191
+ readonly name: string;
192
+ readonly version: string;
193
+ readonly records: number;
194
+ /** The files the package installs. */
195
+ readonly files: number;
196
+ readonly left: readonly { name: string; why: string }[];
197
+ }
198
+
199
+ /** The package of `args`, written into `args.out`. Throws, naming why, when it cannot be built. */
200
+ export function buildRecordsPackage(args: RecordsPackageArgs, now = new Date()): BuiltRecordsPackage {
201
+ const name = recordsPackageName(args.hostClass, args.set);
202
+ const day = today(now);
203
+ const version = args.version ?? day.version;
204
+ statSync(args.stateDir);
205
+ const { records, files, left } = collectRecords(args.stateDir);
206
+ if (records.length === 0) throw new Error(`${args.stateDir} holds no record that can go in the package`);
207
+ const provenance: DistributedProvenance = {
208
+ ...readProvenanceArg(args.provenance),
209
+ schema: DISTRIBUTED_PROVENANCE_SCHEMA,
210
+ set: args.set,
211
+ hostClass: args.hostClass,
212
+ package: { name, version },
213
+ date: day.date,
214
+ };
215
+ const work = mkdtempSync(join(tmpdir(), "records-package-"));
216
+ try {
217
+ const data = join(work, "data");
218
+ const rel = join("usr", "share", "omx", "qualification", args.hostClass, args.set);
219
+ const dir = join(data, rel);
220
+ mkdirSync(dir, { recursive: true });
221
+ for (const f of files) copyFileSync(join(args.stateDir, f), join(dir, f));
222
+ writeFileSync(join(dir, DISTRIBUTED_PROVENANCE_FILE), `${JSON.stringify(provenance, null, 2)}\n`);
223
+ const installed = [...files, DISTRIBUTED_PROVENANCE_FILE].sort();
224
+ let kib = 0;
225
+ const sums: string[] = [];
226
+ for (const f of installed) {
227
+ const bytes = readFileSync(join(dir, f));
228
+ kib += Math.ceil(bytes.length / KIB);
229
+ sums.push(`${createHash("md5").update(bytes).digest("hex")} ${join(rel, f)}`);
230
+ }
231
+ const control = join(work, "control");
232
+ mkdirSync(control);
233
+ writeFileSync(
234
+ join(control, "control"),
235
+ [
236
+ `Package: ${name}`,
237
+ `Version: ${version}`,
238
+ "Architecture: all",
239
+ `Maintainer: ${args.maintainer}`,
240
+ `Installed-Size: ${String(kib)}`,
241
+ `Depends: plugin-qualify (>= ${DISTRIBUTED_LAYER_SINCE}~)`,
242
+ "Section: sound",
243
+ "Priority: optional",
244
+ "Multi-Arch: foreign",
245
+ `Description: qualification records of the ${args.set} plugin set for ${args.hostClass}`,
246
+ ` The plugin-qualify records of the ${args.set} plugin set, measured on a`,
247
+ ` ${args.hostClass} device. plugin-qualify-sync takes a plugin from them instead of`,
248
+ " measuring it, and only when every file of the installed plugin hashes as the",
249
+ " measured one did; any other plugin is measured on the device as before.",
250
+ "",
251
+ ].join("\n"),
252
+ );
253
+ writeFileSync(join(control, "md5sums"), `${sums.join("\n")}\n`);
254
+ const mtime = Math.floor(now.getTime() / 1000);
255
+ tarXz(control, join(work, "control.tar.xz"), mtime);
256
+ tarXz(data, join(work, "data.tar.xz"), mtime);
257
+ mkdirSync(args.out, { recursive: true });
258
+ const path = join(args.out, `${name}_${version}_all.deb`);
259
+ writeAr(
260
+ path,
261
+ [
262
+ { name: "debian-binary", bytes: Buffer.from("2.0\n") },
263
+ { name: "control.tar.xz", bytes: readFileSync(join(work, "control.tar.xz")) },
264
+ { name: "data.tar.xz", bytes: readFileSync(join(work, "data.tar.xz")) },
265
+ ],
266
+ mtime,
267
+ );
268
+ return { path, name, version, records: records.length, files: installed.length, left };
269
+ } finally {
270
+ rmSync(work, { recursive: true, force: true });
271
+ }
272
+ }
273
+
274
+ // --- reading one back -------------------------------------------------------------------------------------------------
275
+
276
+ export interface ReadDeb {
277
+ readonly members: readonly string[];
278
+ readonly debianBinary: string;
279
+ readonly control: Readonly<Record<string, string>>;
280
+ /** The control member's files, `./`-rooted. */
281
+ readonly controlFiles: readonly string[];
282
+ /** The data member's regular files, `./`-rooted, sorted. */
283
+ readonly files: readonly string[];
284
+ }
285
+
286
+ /** A `.deb` read with nothing but `ar`'s layout and tar: its members, its control fields, its files; unpacked into `into` when given. */
287
+ export function readDeb(path: string, into?: string): ReadDeb {
288
+ const bytes = readFileSync(path);
289
+ if (bytes.subarray(0, 8).toString("latin1") !== "!<arch>\n") throw new Error(`${path}: not an ar archive`);
290
+ const members = new Map<string, Buffer>();
291
+ for (let at = 8; at < bytes.length; ) {
292
+ const head = bytes.subarray(at, at + 60).toString("latin1");
293
+ const name = head.slice(0, 16).trim().replace(/\/$/, "");
294
+ const size = Number(head.slice(48, 58).trim());
295
+ members.set(name, bytes.subarray(at + 60, at + 60 + size));
296
+ at += 60 + size + (size % 2);
297
+ }
298
+ const work = mkdtempSync(join(tmpdir(), "read-deb-"));
299
+ try {
300
+ const list = (member: string, extract?: string): string[] => {
301
+ const file = join(work, member);
302
+ writeFileSync(file, members.get(member) ?? Buffer.alloc(0));
303
+ const r = spawnSync("tar", ["-tvJf", file], { encoding: "utf8", maxBuffer: 256 << 20 });
304
+ if (r.status !== 0) throw new Error(`${member}: ${r.stderr.trim()}`);
305
+ if (extract !== undefined) {
306
+ mkdirSync(extract, { recursive: true });
307
+ const x = spawnSync("tar", ["-xJf", file, "-C", extract], { encoding: "utf8" });
308
+ if (x.status !== 0) throw new Error(`${member}: ${x.stderr.trim()}`);
309
+ }
310
+ return r.stdout
311
+ .split("\n")
312
+ .filter((l) => l.startsWith("-"))
313
+ .map((l) => l.slice(l.indexOf("./")))
314
+ .sort();
315
+ };
316
+ const controlDir = join(work, "control");
317
+ const controlFiles = list("control.tar.xz", controlDir);
318
+ const control: Record<string, string> = {};
319
+ for (const line of readFileSync(join(controlDir, "control"), "utf8").split("\n")) {
320
+ const m = /^([A-Za-z-]+): (.*)$/.exec(line);
321
+ if (m !== null) control[m[1]!] = m[2]!;
322
+ }
323
+ return {
324
+ members: [...members.keys()],
325
+ debianBinary: (members.get("debian-binary") ?? Buffer.alloc(0)).toString("latin1"),
326
+ control,
327
+ controlFiles,
328
+ files: list("data.tar.xz", into),
329
+ };
330
+ } finally {
331
+ rmSync(work, { recursive: true, force: true });
332
+ }
333
+ }
@@ -1,24 +1,24 @@
1
1
  #!/bin/bash
2
2
  # SPDX-License-Identifier: GPL-3.0-or-later
3
- # ai-trace-commit-check.sh: fail when a commit message carries an AI-attribution line.
3
+ # commit-message-check.sh: fail when a commit message carries an attribution line.
4
4
  #
5
- # ai-trace-commit-check.sh <range> every commit in the range (e.g. origin/main..HEAD)
6
- # ai-trace-commit-check.sh --msg-file FILE one message (the commit-msg hook; # lines are git comments, skipped)
7
- # ai-trace-commit-check.sh --text-file FILE any text, every line judged (PR titles and bodies)
8
- # ai-trace-commit-check.sh --selftest fixtures plus a sabotage run
5
+ # commit-message-check.sh <range> every commit in the range (e.g. origin/main..HEAD)
6
+ # commit-message-check.sh --msg-file FILE one message (the commit-msg hook; # lines are git comments, skipped)
7
+ # commit-message-check.sh --text-file FILE any text, every line judged (PR titles and bodies)
8
+ # commit-message-check.sh --selftest fixtures plus a sabotage run
9
9
  #
10
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
11
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
12
+ # The patterns are commit-message-patterns.json beside this script, the same list the ops sweep strips
13
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.
14
+ # (ops/tools/tests/commit-message-one-list.t.sh refuses a second list). COMMIT_MESSAGE_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
- export AI_TRACE_SELF="${BASH_SOURCE[0]}"
16
+ export COMMIT_MESSAGE_CHECK_SELF="${BASH_SOURCE[0]}"
17
17
  exec python3 -I - "$@" <<'PY'
18
18
  import json, os, re, subprocess, sys
19
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")
20
+ here = os.path.dirname(os.path.realpath(os.environ["COMMIT_MESSAGE_CHECK_SELF"]))
21
+ pfile = os.environ.get("COMMIT_MESSAGE_PATTERNS") or os.path.join(here, "commit-message-patterns.json")
22
22
 
23
23
 
24
24
  def load():
@@ -45,7 +45,7 @@ def report(label, text, markers):
45
45
  def git(*a):
46
46
  p = subprocess.run(["git", *a], capture_output=True, text=True)
47
47
  if p.returncode:
48
- print(f"ai-trace-commit-check: git {' '.join(a[:3])}: {p.stderr.strip()}", file=sys.stderr)
48
+ print(f"commit-message-check: git {' '.join(a[:3])}: {p.stderr.strip()}", file=sys.stderr)
49
49
  sys.exit(2)
50
50
  return p.stdout
51
51
 
@@ -96,7 +96,7 @@ def main(a):
96
96
  try:
97
97
  text = open(a[1], encoding="utf-8", errors="replace").read()
98
98
  except OSError as e:
99
- print(f"ai-trace-commit-check: {e}", file=sys.stderr)
99
+ print(f"commit-message-check: {e}", file=sys.stderr)
100
100
  return 2
101
101
  # lines git adds as comments are not part of the message
102
102
  text = "\n".join(l for l in text.splitlines() if not l.startswith("#"))
@@ -105,7 +105,7 @@ def main(a):
105
105
  try:
106
106
  text = open(a[1], encoding="utf-8", errors="replace").read()
107
107
  except OSError as e:
108
- print(f"ai-trace-commit-check: {e}", file=sys.stderr)
108
+ print(f"commit-message-check: {e}", file=sys.stderr)
109
109
  return 2
110
110
  return 1 if report("text", text, markers) else 0
111
111
  if len(a) == 1 and not a[0].startswith("-"):
@@ -113,16 +113,16 @@ def main(a):
113
113
  bad = 0
114
114
  for sha in shas:
115
115
  bad += report(sha[:12], git("log", "-1", "--format=%B", sha), markers)
116
- print(f"ai-trace-commit-check: {len(shas)} commits, {bad} offending lines")
116
+ print(f"commit-message-check: {len(shas)} commits, {bad} offending lines")
117
117
  return 1 if bad else 0
118
- print(__doc__ if False else "usage: ai-trace-commit-check.sh <range> | --msg-file FILE | --text-file FILE | --selftest", file=sys.stderr)
118
+ print(__doc__ if False else "usage: commit-message-check.sh <range> | --msg-file FILE | --text-file FILE | --selftest", file=sys.stderr)
119
119
  return 2
120
120
 
121
121
 
122
122
  try:
123
123
  rc = main(sys.argv[1:])
124
124
  except Exception as e: # a bad or missing pattern file, a decode error: unreadable, never "a hit"
125
- print(f"ai-trace-commit-check: {type(e).__name__}: {e}", file=sys.stderr)
125
+ print(f"commit-message-check: {type(e).__name__}: {e}", file=sys.stderr)
126
126
  rc = 2
127
127
  sys.exit(rc)
128
128
  PY
@@ -1,5 +1,5 @@
1
1
  {
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.",
2
+ "$comment": "Single source of the attribution patterns (Python re, flags IGNORECASE|MULTILINE). Read by the ops sweep and commit-message-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",