@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.
- package/README.md +153 -6
- package/bin/plugin-qualify-records-package.mjs +31 -0
- package/bin/plugin-qualify-sync.mjs +6 -1
- package/bin/plugin-qualify.mjs +12 -1
- package/dist/distributed-records.d.ts +88 -0
- package/dist/distributed-records.js +210 -0
- package/dist/distributed-records.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/measurement-grid.d.ts +49 -0
- package/dist/measurement-grid.js +76 -0
- package/dist/measurement-grid.js.map +1 -0
- package/dist/memory-bound.d.ts +23 -0
- package/dist/memory-bound.js +46 -0
- package/dist/memory-bound.js.map +1 -0
- package/dist/probe-judge.js +22 -1
- package/dist/probe-judge.js.map +1 -1
- package/dist/probe-merge.d.ts +30 -0
- package/dist/probe-merge.js +80 -0
- package/dist/probe-merge.js.map +1 -0
- package/dist/probe-output.d.ts +8 -0
- package/dist/probe-output.js +7 -0
- package/dist/probe-output.js.map +1 -1
- package/dist/probe-runner.d.ts +26 -2
- package/dist/probe-runner.js +38 -10
- package/dist/probe-runner.js.map +1 -1
- package/dist/qualification-record.d.ts +19 -1
- package/dist/qualification-record.js +30 -1
- package/dist/qualification-record.js.map +1 -1
- package/dist/qualify-cli.d.ts +9 -0
- package/dist/qualify-cli.js +30 -1
- package/dist/qualify-cli.js.map +1 -1
- package/dist/qualify-declarations.d.ts +15 -0
- package/dist/qualify-declarations.js +15 -0
- package/dist/qualify-declarations.js.map +1 -1
- package/dist/qualify-options.js +11 -1
- package/dist/qualify-options.js.map +1 -1
- package/dist/qualify-sync.d.ts +18 -1
- package/dist/qualify-sync.js +142 -12
- package/dist/qualify-sync.js.map +1 -1
- package/dist/records-package.d.ts +63 -0
- package/dist/records-package.js +300 -0
- package/dist/records-package.js.map +1 -0
- package/package.json +3 -2
- package/src/distributed-records.ts +251 -0
- package/src/index.ts +5 -0
- package/src/measurement-grid.ts +97 -0
- package/src/memory-bound.ts +55 -0
- package/src/probe-judge.ts +25 -1
- package/src/probe-merge.ts +89 -0
- package/src/probe-output.ts +13 -0
- package/src/probe-runner.ts +75 -13
- package/src/qualification-record.ts +44 -2
- package/src/qualify-cli.ts +32 -1
- package/src/qualify-declarations.ts +17 -0
- package/src/qualify-options.ts +11 -1
- package/src/qualify-sync.ts +147 -11
- package/src/records-package.ts +333 -0
- package/tools/{ai-trace-commit-check.sh → commit-message-check.sh} +16 -16
- 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
|
-
#
|
|
3
|
+
# commit-message-check.sh: fail when a commit message carries an attribution line.
|
|
4
4
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
|
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/
|
|
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
|
|
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["
|
|
21
|
-
pfile = os.environ.get("
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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:
|
|
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"
|
|
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
|
|
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",
|