@openmixer/plugin-qualify 0.16.0 → 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 (68) hide show
  1. package/README.md +154 -7
  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/host-profiles.d.ts +5 -0
  9. package/dist/host-profiles.js +13 -0
  10. package/dist/host-profiles.js.map +1 -1
  11. package/dist/hosting-suitability.js +9 -1
  12. package/dist/hosting-suitability.js.map +1 -1
  13. package/dist/index.d.ts +5 -0
  14. package/dist/index.js +5 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/measurement-grid.d.ts +49 -0
  17. package/dist/measurement-grid.js +76 -0
  18. package/dist/measurement-grid.js.map +1 -0
  19. package/dist/memory-bound.d.ts +23 -0
  20. package/dist/memory-bound.js +46 -0
  21. package/dist/memory-bound.js.map +1 -0
  22. package/dist/probe-judge.js +22 -1
  23. package/dist/probe-judge.js.map +1 -1
  24. package/dist/probe-merge.d.ts +30 -0
  25. package/dist/probe-merge.js +80 -0
  26. package/dist/probe-merge.js.map +1 -0
  27. package/dist/probe-output.d.ts +8 -0
  28. package/dist/probe-output.js +7 -0
  29. package/dist/probe-output.js.map +1 -1
  30. package/dist/probe-runner.d.ts +26 -2
  31. package/dist/probe-runner.js +38 -10
  32. package/dist/probe-runner.js.map +1 -1
  33. package/dist/qualification-record.d.ts +19 -1
  34. package/dist/qualification-record.js +30 -1
  35. package/dist/qualification-record.js.map +1 -1
  36. package/dist/qualify-cli.d.ts +9 -0
  37. package/dist/qualify-cli.js +30 -1
  38. package/dist/qualify-cli.js.map +1 -1
  39. package/dist/qualify-declarations.d.ts +15 -0
  40. package/dist/qualify-declarations.js +15 -0
  41. package/dist/qualify-declarations.js.map +1 -1
  42. package/dist/qualify-options.js +11 -1
  43. package/dist/qualify-options.js.map +1 -1
  44. package/dist/qualify-sync.d.ts +23 -3
  45. package/dist/qualify-sync.js +172 -16
  46. package/dist/qualify-sync.js.map +1 -1
  47. package/dist/records-package.d.ts +63 -0
  48. package/dist/records-package.js +300 -0
  49. package/dist/records-package.js.map +1 -0
  50. package/package.json +3 -2
  51. package/src/distributed-records.ts +251 -0
  52. package/src/host-profiles.ts +13 -0
  53. package/src/hosting-suitability.ts +11 -1
  54. package/src/index.ts +5 -0
  55. package/src/measurement-grid.ts +97 -0
  56. package/src/memory-bound.ts +55 -0
  57. package/src/probe-judge.ts +25 -1
  58. package/src/probe-merge.ts +89 -0
  59. package/src/probe-output.ts +13 -0
  60. package/src/probe-runner.ts +75 -13
  61. package/src/qualification-record.ts +44 -2
  62. package/src/qualify-cli.ts +32 -1
  63. package/src/qualify-declarations.ts +17 -0
  64. package/src/qualify-options.ts +11 -1
  65. package/src/qualify-sync.ts +175 -16
  66. package/src/records-package.ts +333 -0
  67. package/tools/{ai-trace-commit-check.sh → commit-message-check.sh} +30 -28
  68. 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,28 +45,30 @@ 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
 
52
52
 
53
53
  def selftest():
54
+ # every marker is joined from fragments, so this file carries none of the lines it must refuse
55
+ CL, cl, AN, OP, CP, GEN = "Cl" + "aude", "cl" + "aude", "anthr" + "opic", "Op" + "us", "Copi" + "lot", "Gener" + "ated"
54
56
  fx = {
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",
57
+ "co-authored-by": f"Fix x\n\nCo-Authored-By: {CL} {OP} <noreply@{AN}.com>\n",
58
+ "session-trailer": f"Fix x\n\n{CL}-Session: https://{cl}.ai/code/session_01\n",
59
+ "generated-footer": f"Fix x\n\n\U0001F916 {GEN} with [{CL} Code](https://{cl}.com/{cl}-code)\n",
60
+ "session-url": f"Fix x\n\nsee https://{cl}.ai/code/session_9\n",
59
61
  # 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
+ "assisted-by": f"Fix x\n\nAssisted-by: {CL}\n",
63
+ "made-with": f"Fix x\n\nMade with {CL} Code\n",
64
+ "generated-with-claude": f"Fix x\n\n{GEN} with {CL}\n",
65
+ "generated-by-trailer": f"Fix x\n\nGenerated-by: {CL}\n",
66
+ "co-author-model": f"Fix x\n\nCo-authored-by: {OP} 5.5 <x@example.invalid>\n",
67
+ "co-author-github-copilot": f"Fix x\n\nCo-authored-by: GitHub {CP} <{CP.lower()}@github.com>\n",
66
68
  }
67
69
  clean = ("Fix x\n\nGenerated by the build; mentions .claude/ and a Co-Authored-By: Pau line.\n"
68
70
  "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")
71
+ f"A review quoted 'Assisted-by: {CL}' and 'Co-authored-by: {OP} 5.5 <x>' as shapes that passed.\n")
70
72
  def run(markers):
71
73
  bad = [k for k, v in fx.items() if not hits(v, markers)]
72
74
  if hits(clean, markers):
@@ -75,7 +77,7 @@ def selftest():
75
77
  m = load()
76
78
  first = run(m)
77
79
  print("selftest:", "GREEN" if not first else f"RED {first}")
78
- sab = run([x for x in m if x[0] not in ("co-authored-by", "ai-co-author", "anthropic-noreply", "claude-session")])
80
+ sab = run([x for x in m if x[0] not in ("co-authored-by", "ai-co-author", "anthropic-noreply", "session-trailer")])
79
81
  print("sabotage (the co-author, email and session patterns removed):", "RED as expected" if sab else "still GREEN: blind")
80
82
  # each #152 shape is seen by a pattern of its own family, not by luck
81
83
  new = ["assisted-by", "made-with", "generated-with-claude", "generated-by-trailer", "co-author-model", "co-author-github-copilot"]
@@ -94,7 +96,7 @@ def main(a):
94
96
  try:
95
97
  text = open(a[1], encoding="utf-8", errors="replace").read()
96
98
  except OSError as e:
97
- print(f"ai-trace-commit-check: {e}", file=sys.stderr)
99
+ print(f"commit-message-check: {e}", file=sys.stderr)
98
100
  return 2
99
101
  # lines git adds as comments are not part of the message
100
102
  text = "\n".join(l for l in text.splitlines() if not l.startswith("#"))
@@ -103,7 +105,7 @@ def main(a):
103
105
  try:
104
106
  text = open(a[1], encoding="utf-8", errors="replace").read()
105
107
  except OSError as e:
106
- print(f"ai-trace-commit-check: {e}", file=sys.stderr)
108
+ print(f"commit-message-check: {e}", file=sys.stderr)
107
109
  return 2
108
110
  return 1 if report("text", text, markers) else 0
109
111
  if len(a) == 1 and not a[0].startswith("-"):
@@ -111,16 +113,16 @@ def main(a):
111
113
  bad = 0
112
114
  for sha in shas:
113
115
  bad += report(sha[:12], git("log", "-1", "--format=%B", sha), markers)
114
- print(f"ai-trace-commit-check: {len(shas)} commits, {bad} offending lines")
116
+ print(f"commit-message-check: {len(shas)} commits, {bad} offending lines")
115
117
  return 1 if bad else 0
116
- 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)
117
119
  return 2
118
120
 
119
121
 
120
122
  try:
121
123
  rc = main(sys.argv[1:])
122
124
  except Exception as e: # a bad or missing pattern file, a decode error: unreadable, never "a hit"
123
- 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)
124
126
  rc = 2
125
127
  sys.exit(rc)
126
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",