@openmixer/plugin-qualify 0.20.3 → 0.20.4

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 (56) hide show
  1. package/catalogs/ca.json +244 -1
  2. package/catalogs/de.json +49 -0
  3. package/catalogs/es.json +49 -0
  4. package/catalogs/fr.json +49 -0
  5. package/catalogs/it.json +49 -0
  6. package/catalogs/pt.json +49 -0
  7. package/dist/compare.d.ts +12 -11
  8. package/dist/compare.js +71 -38
  9. package/dist/compare.js.map +1 -1
  10. package/dist/hostd-pin-files.d.ts +6 -2
  11. package/dist/hostd-pin-files.js +10 -6
  12. package/dist/hostd-pin-files.js.map +1 -1
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.js +4 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/plugin-db-cli.d.ts +28 -0
  17. package/dist/plugin-db-cli.js +43 -0
  18. package/dist/plugin-db-cli.js.map +1 -0
  19. package/dist/plugin-db-pin.d.ts +8 -0
  20. package/dist/plugin-db-pin.js +29 -0
  21. package/dist/plugin-db-pin.js.map +1 -0
  22. package/dist/qualify-sync.js +3 -2
  23. package/dist/qualify-sync.js.map +1 -1
  24. package/dist/record-figures.d.ts +52 -0
  25. package/dist/record-figures.js +58 -0
  26. package/dist/record-figures.js.map +1 -0
  27. package/dist/refused-stderr-kept.d.ts +12 -0
  28. package/dist/refused-stderr-kept.js +67 -0
  29. package/dist/refused-stderr-kept.js.map +1 -0
  30. package/dist/text-catalog.d.ts +41 -0
  31. package/dist/text-catalog.js +97 -0
  32. package/dist/text-catalog.js.map +1 -0
  33. package/dist/text-codes.d.ts +29 -0
  34. package/dist/text-codes.generated.d.ts +88 -0
  35. package/dist/text-codes.generated.js +96 -0
  36. package/dist/text-codes.generated.js.map +1 -0
  37. package/dist/text-codes.js +19 -0
  38. package/dist/text-codes.js.map +1 -0
  39. package/package.json +3 -3
  40. package/schema/codes.json +372 -1
  41. package/src/compare.ts +85 -45
  42. package/src/hostd-pin-files.ts +13 -8
  43. package/src/index.ts +4 -0
  44. package/src/plugin-db-cli.ts +78 -0
  45. package/src/plugin-db-pin.ts +33 -0
  46. package/src/qualify-sync.ts +3 -2
  47. package/src/record-figures.ts +114 -0
  48. package/src/refused-stderr-kept.ts +84 -0
  49. package/src/text-catalog.ts +116 -0
  50. package/src/text-codes.generated.ts +97 -0
  51. package/src/text-codes.ts +35 -0
  52. package/tools/catalogs-gate.sh +22 -0
  53. package/tools/compare-host.mjs +1 -1
  54. package/tools/compare-run.mjs +10 -6
  55. package/tools/gen-text-codes.d.mts +8 -0
  56. package/tools/gen-text-codes.mjs +77 -0
@@ -0,0 +1,84 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * #6's conformance check: a refused row's cause must be readable from the record directory itself,
5
+ * not summed to a byte count in the probe copy's `logs` entry. #311 copies a child's stderr beside
6
+ * its record (`stderrCopyFileName`) so the bytes `logs` counts can still be read back; this walks a
7
+ * records directory and names every refused row where that reading still fails: the count is zero
8
+ * (nothing was ever captured) or the count is positive but no copy answers it (the count was kept,
9
+ * the words were not).
10
+ */
11
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { readProbeOutput, sha256Hex, PROBE_OK } from "./probe-output.js";
14
+ import { stderrCopyFileName, type QualificationRecord } from "./qualification-record.js";
15
+
16
+ export type StderrKeptProblem = "empty-stderr" | "log-line-not-kept";
17
+
18
+ export interface StderrKeptFinding {
19
+ readonly record: string;
20
+ readonly plugin: string;
21
+ readonly problem: StderrKeptProblem;
22
+ readonly detail: string;
23
+ }
24
+
25
+ interface ProbeLogEntry {
26
+ readonly name?: string;
27
+ readonly sha256?: string;
28
+ readonly bytes?: number;
29
+ }
30
+
31
+ const isRefused = (record: QualificationRecord): boolean => record.probe.outcome !== PROBE_OK || record.verdicts.some((v) => !v.qualified);
32
+
33
+ /** Every `*.qualification.json` in `dir`, refused rows only, with its parsed record. */
34
+ function refusedRecords(dir: string): { readonly file: string; readonly record: QualificationRecord }[] {
35
+ return readdirSync(dir)
36
+ .filter((f) => f.endsWith(".qualification.json"))
37
+ .map((file) => ({ file, record: JSON.parse(readFileSync(join(dir, file), "utf8")) as QualificationRecord }))
38
+ .filter((r) => isRefused(r.record));
39
+ }
40
+
41
+ /**
42
+ * Every refused row in `dir` whose child's own words are not readable back from disk: a row whose every run's stderr
43
+ * was never kept, and a row whose `logs` count bytes a copy beside the record does not answer.
44
+ */
45
+ export function checkStderrKept(dir: string): StderrKeptFinding[] {
46
+ const findings: StderrKeptFinding[] = [];
47
+ for (const { file, record } of refusedRecords(dir)) {
48
+ const probePath = join(dir, record.probe.file);
49
+ if (!existsSync(probePath)) {
50
+ findings.push({ record: file, plugin: record.plugin.id, problem: "empty-stderr", detail: `${record.probe.file}: the probe copy named by the record is not beside it` });
51
+ continue;
52
+ }
53
+ const probeText = readFileSync(probePath, "utf8");
54
+ const read = readProbeOutput(probeText);
55
+ if (!read.ok) {
56
+ findings.push({ record: file, plugin: record.plugin.id, problem: "empty-stderr", detail: `${record.probe.file}: ${read.problem}` });
57
+ continue;
58
+ }
59
+ const probeDoc = JSON.parse(probeText) as { readonly logs?: readonly ProbeLogEntry[] };
60
+ const logs: readonly ProbeLogEntry[] = Array.isArray(probeDoc.logs) ? probeDoc.logs : [];
61
+ const written = logs.filter((l) => (l.bytes ?? 0) > 0);
62
+ if (written.length === 0) {
63
+ findings.push({ record: file, plugin: record.plugin.id, problem: "empty-stderr", detail: "refused, and no run's child printed anything on stderr" });
64
+ continue;
65
+ }
66
+ for (const entry of written) {
67
+ const name = entry.name ?? "stderr";
68
+ if (entry.sha256 === undefined) {
69
+ findings.push({ record: file, plugin: record.plugin.id, problem: "log-line-not-kept", detail: `${name}: ${String(entry.bytes)} bytes counted, no SHA-256 to find a copy by` });
70
+ continue;
71
+ }
72
+ const copyPath = join(dir, stderrCopyFileName(read.output, entry.sha256));
73
+ if (!existsSync(copyPath)) {
74
+ findings.push({ record: file, plugin: record.plugin.id, problem: "log-line-not-kept", detail: `${name}: ${String(entry.bytes)} bytes counted, no copy at ${stderrCopyFileName(read.output, entry.sha256)}` });
75
+ continue;
76
+ }
77
+ const copied = readFileSync(copyPath);
78
+ if (copied.length === 0 || sha256Hex(copied) !== entry.sha256) {
79
+ findings.push({ record: file, plugin: record.plugin.id, problem: "log-line-not-kept", detail: `${name}: the copy at ${stderrCopyFileName(read.output, entry.sha256)} is empty or does not match the count` });
80
+ }
81
+ }
82
+ }
83
+ return findings;
84
+ }
@@ -0,0 +1,116 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The sentence a surface shows for one of plugin-qualify's own {@link TextRef}s: the catalog's, for the given
5
+ * language, else the source sentence `schema/codes.json` declares; its `{placeholder}`s filled from `args`
6
+ * An entry is the sentence, or the sentence with how it was made (omx-contract 3.16.0's `catalogEntry`); a model's
7
+ * entry made from another source sentence is stale and skipped. This is the lookup of
8
+ * `@openmixer/omx-contract/catalog` (`sentenceOf`, `fill`), made here: that module reads its schema through ajv, which
9
+ * this package does not depend on, and the npm floor is below the release that knows an entry's provenance.
10
+ *
11
+ * `schema/codes.json` and `catalogs/<lang>.json` ship inside this package (`package.json`'s `files`), read relative
12
+ * to this module so it works from `src/` under vitest and from `dist/` once built alike. `PLUGIN_QUALIFY_CATALOGS_DIR`
13
+ * names another catalogs directory instead, for a test that wants its own fixture catalogs.
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ import { readFileSync } from "node:fs";
17
+ import { dirname, join, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import type { ReasonText, TextRef } from "./text-codes.js";
20
+
21
+ export const OWNER = "plugin-qualify";
22
+ export const CATALOGS_DIR_ENV = "PLUGIN_QUALIFY_CATALOGS_DIR";
23
+
24
+ const PKG = resolve(dirname(fileURLToPath(import.meta.url)), "..");
25
+
26
+ /** Where this package's own `catalogs/<lang>.json` live: `PLUGIN_QUALIFY_CATALOGS_DIR`, else beside this module's package. */
27
+ export function catalogsDir(env: Readonly<Record<string, string | undefined>> = process.env): string {
28
+ return env[CATALOGS_DIR_ENV] ?? join(PKG, "catalogs");
29
+ }
30
+
31
+ /** One code of `schema/codes.json`: the code, the args its sentence names and the sentence. */
32
+ export interface DeclaredCode {
33
+ readonly code: string;
34
+ readonly doc?: string;
35
+ readonly text: { readonly en: string };
36
+ readonly args: readonly string[];
37
+ }
38
+
39
+ /** One catalog entry: the sentence, or the sentence with how it was made (omx-contract's `catalogEntry`). */
40
+ export type CatalogEntry = string | { readonly text: string; readonly by: "human" | "machine"; readonly model?: string; readonly source?: string };
41
+
42
+ /** Where the codes are declared: `schema/codes.json` of this package. */
43
+ export const CODES_FILE = join(PKG, "schema", "codes.json");
44
+
45
+ let declared: ReadonlyMap<string, DeclaredCode> | undefined;
46
+
47
+ /** Every code `schema/codes.json` declares, by its code; none where the file does not read. */
48
+ export function declaredCodes(): ReadonlyMap<string, DeclaredCode> {
49
+ if (declared !== undefined) return declared;
50
+ let codes: Record<string, DeclaredCode> = {};
51
+ try {
52
+ codes = (JSON.parse(readFileSync(CODES_FILE, "utf8")) as { codes?: Record<string, DeclaredCode> }).codes ?? {};
53
+ } catch {
54
+ codes = {};
55
+ }
56
+ declared = new Map(Object.values(codes).map((c) => [c.code, c]));
57
+ return declared;
58
+ }
59
+
60
+ const messagesCache = new Map<string, Record<string, CatalogEntry> | undefined>();
61
+
62
+ /** `catalogs/<lang>.json`'s `messages`, or `undefined` where the file is absent or does not read as one. */
63
+ export function loadMessages(lang: string, dir: string = catalogsDir()): Record<string, CatalogEntry> | undefined {
64
+ const cacheKey = `${dir}\0${lang}`;
65
+ if (messagesCache.has(cacheKey)) return messagesCache.get(cacheKey);
66
+ let messages: Record<string, CatalogEntry> | undefined;
67
+ try {
68
+ const catalog = JSON.parse(readFileSync(join(dir, `${lang}.json`), "utf8")) as { messages?: Record<string, CatalogEntry> };
69
+ messages = catalog.messages;
70
+ } catch {
71
+ messages = undefined;
72
+ }
73
+ messagesCache.set(cacheKey, messages);
74
+ return messages;
75
+ }
76
+
77
+ /** `sentence` with each `{name}` replaced by `args[name]`; a placeholder with no value is left as written. */
78
+ export function fill(sentence: string, args: Readonly<Record<string, string | number>> = {}): string {
79
+ return sentence.replace(/\{([^{}]*)\}/g, (m, n: string) => (Object.hasOwn(args, n) ? String(args[n]) : m));
80
+ }
81
+
82
+ /** The sha256 of a source sentence, lowercase hex of its UTF-8 bytes: what a model's entry records as `source`. */
83
+ export const sourceHash = (sentence: string): string => createHash("sha256").update(sentence, "utf8").digest("hex");
84
+
85
+ /** The sentence of `entry`; `undefined` for no entry, or a model's entry made from another sentence than `source`. */
86
+ export function entrySentence(entry: CatalogEntry | undefined, source: string): string | undefined {
87
+ if (entry === undefined || typeof entry === "string") return entry;
88
+ return entry.source !== undefined && entry.source !== sourceHash(source) ? undefined : entry.text;
89
+ }
90
+
91
+ /** The source sentence `code` was declared with (`schema/codes.json`); throws on a code this package never declared. */
92
+ export function sourceOf(code: string): string {
93
+ const source = declaredCodes().get(code)?.text.en;
94
+ if (source === undefined) throw new Error(`plugin-qualify declares no source sentence for ${code}`);
95
+ return source;
96
+ }
97
+
98
+ /** `ref` rendered for `lang`: the catalog's sentence, else the source, its `{placeholder}`s filled. `lang` undefined or "en" skips the catalog. */
99
+ export function textOf(ref: TextRef, lang?: string): string {
100
+ const source = sourceOf(ref.code);
101
+ const sentence = lang === undefined || lang === "en" ? source : (entrySentence(loadMessages(lang)?.[ref.code], source) ?? source);
102
+ return fill(sentence, ref.args ?? {});
103
+ }
104
+
105
+ /** A {@link ReasonText} rendered for `lang`: another tool's own words pass straight through; plugin-qualify's own go through {@link textOf}. */
106
+ export function reasonText(reason: ReasonText, lang?: string): string {
107
+ return typeof reason === "string" ? reason : textOf(reason, lang);
108
+ }
109
+
110
+ /** The language of `LC_ALL`, else `LANG` (the POSIX precedence), as the two letters before `_` or `.`; `undefined` unset, unparsed, or `C`/`POSIX` (textOf's own fallback to the source sentence is the same as leaving this undefined). */
111
+ export function localeOf(env: Readonly<Record<string, string | undefined>> = process.env): string | undefined {
112
+ const raw = env["LC_ALL"] || env["LANG"];
113
+ if (raw === undefined || raw === "") return undefined;
114
+ const m = /^([a-z]{2})(?:[_.].*)?$/.exec(raw);
115
+ return m?.[1];
116
+ }
@@ -0,0 +1,97 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ //
4
+ // GENERATED by tools/gen-text-codes.mjs from schema/codes.json — do not hand-edit.
5
+ //
6
+ // Regenerate: node tools/gen-text-codes.mjs --write
7
+ // Verify: node tools/gen-text-codes.mjs --check
8
+
9
+ /** plugin-qualify's own codes, by key (schema/codes.json). */
10
+ export const TEXT_CODE = Object.freeze({
11
+ descriptorDuplicateSymbol: "qualify.descriptor.duplicate-symbol",
12
+ compareHostNotInstalled: "qualify.compare.host-not-installed",
13
+ compareHostDidNotStart: "qualify.compare.host-did-not-start",
14
+ compareHostNotAsked: "qualify.compare.host-not-asked",
15
+ compareHostDidNotRun: "qualify.compare.host-did-not-run",
16
+ compareFormatNotTaken: "qualify.compare.format-not-taken",
17
+ compareRtNotReported: "qualify.compare.rt-not-reported",
18
+ compareRtNoCallback: "qualify.compare.rt-no-callback",
19
+ compareRtNoWarmUp: "qualify.compare.rt-no-warm-up",
20
+ compareRtTrylocksOnly: "qualify.compare.rt-trylocks-only",
21
+ compareRtUnsafe: "qualify.compare.rt-unsafe",
22
+ compareLoadHostEnded: "qualify.compare.load-host-ended",
23
+ compareLoadResp: "qualify.compare.load-resp",
24
+ compareLoadTimeout: "qualify.compare.load-timeout",
25
+ compareNotLoaded: "qualify.compare.not-loaded",
26
+ compareNoPort: "qualify.compare.no-port",
27
+ compareNotRunning: "qualify.compare.not-running",
28
+ compareNoAudioOutput: "qualify.compare.no-audio-output",
29
+ compareOutputNotFinite: "qualify.compare.output-not-finite",
30
+ compareNoStimulusSilent: "qualify.compare.no-stimulus-silent",
31
+ compareSilentUnderStimulus: "qualify.compare.silent-under-stimulus",
32
+ compareCyclesNotRun: "qualify.compare.cycles-not-run",
33
+ compareCycleHostEnded: "qualify.compare.cycle-host-ended",
34
+ compareCycleFailed: "qualify.compare.cycle-failed",
35
+ compareCycleFailedAt: "qualify.compare.cycle-failed-at",
36
+ compareSoakHostEnded: "qualify.compare.soak-host-ended",
37
+ compareSoakEnded: "qualify.compare.soak-ended",
38
+ compareLatencyPortObservable: "qualify.compare.latency-port-observable",
39
+ compareLatencyHostdPortObservable: "qualify.compare.latency-hostd-port-observable",
40
+ compareLatencyNotObservableModHost: "qualify.compare.latency-not-observable-mod-host",
41
+ compareLatencyUnreadable: "qualify.compare.latency-unreadable",
42
+ compareSkippedReason: "qualify.compare.skipped-reason",
43
+ compareDoesBetterOn: "qualify.compare.does-better-on",
44
+ compareBeatDescription: "qualify.compare.beat-description",
45
+ compareNotPublishableWithMissing: "qualify.compare.not-publishable-with-missing",
46
+ compareNotPublishableNoFlag: "qualify.compare.not-publishable-no-flag",
47
+ pinHostdNotInstalled: "qualify.pin.hostd-not-installed",
48
+ pinHostdNoPinFiles: "qualify.pin.hostd-no-pin-files",
49
+ pinHostdExited: "qualify.pin.hostd-exited",
50
+ pinHostdNoRootOrFile: "qualify.pin.hostd-no-root-or-file",
51
+ pinFileUnreadable: "qualify.pin.file-unreadable",
52
+ } as const);
53
+
54
+ /** The args each code's source sentence names, by the same key. */
55
+ export const TEXT_CODE_ARGS = Object.freeze({
56
+ descriptorDuplicateSymbol: ["symbol"],
57
+ compareHostNotInstalled: ["program"],
58
+ compareHostDidNotStart: ["program"],
59
+ compareHostNotAsked: [],
60
+ compareHostDidNotRun: ["program"],
61
+ compareFormatNotTaken: ["program", "format"],
62
+ compareRtNotReported: [],
63
+ compareRtNoCallback: [],
64
+ compareRtNoWarmUp: [],
65
+ compareRtTrylocksOnly: ["trylocks", "cycles"],
66
+ compareRtUnsafe: ["cycles", "allocations", "locks", "syscalls"],
67
+ compareLoadHostEnded: ["died"],
68
+ compareLoadResp: ["resp"],
69
+ compareLoadTimeout: [],
70
+ compareNotLoaded: [],
71
+ compareNoPort: [],
72
+ compareNotRunning: [],
73
+ compareNoAudioOutput: [],
74
+ compareOutputNotFinite: ["count"],
75
+ compareNoStimulusSilent: [],
76
+ compareSilentUnderStimulus: ["stimulus"],
77
+ compareCyclesNotRun: [],
78
+ compareCycleHostEnded: ["cycle", "died"],
79
+ compareCycleFailed: ["cycle"],
80
+ compareCycleFailedAt: ["cycle", "step"],
81
+ compareSoakHostEnded: ["died"],
82
+ compareSoakEnded: [],
83
+ compareLatencyPortObservable: [],
84
+ compareLatencyHostdPortObservable: [],
85
+ compareLatencyNotObservableModHost: [],
86
+ compareLatencyUnreadable: [],
87
+ compareSkippedReason: ["reason"],
88
+ compareDoesBetterOn: ["host", "count", "detail"],
89
+ compareBeatDescription: ["id", "on", "over"],
90
+ compareNotPublishableWithMissing: ["missing"],
91
+ compareNotPublishableNoFlag: [],
92
+ pinHostdNotInstalled: [],
93
+ pinHostdNoPinFiles: [],
94
+ pinHostdExited: ["status"],
95
+ pinHostdNoRootOrFile: [],
96
+ pinFileUnreadable: ["error"],
97
+ } as const);
@@ -0,0 +1,35 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * plugin-qualify's own verdict and reason sentences, as codes (omx-contract#2, "codes then catalogs", owner
5
+ * ruling 2026-11-09: a code carries its identifier, its arguments and one source sentence in English with named
6
+ * placeholders; translations live in catalogs the owner ships, keyed by code, checked for placeholders against the
7
+ * code's `args`; a surface looks up the sentence for the user's locale and falls back to this source sentence).
8
+ *
9
+ * plugin-qualify is the owner of these codes (the hosting.* codes of {@link ../hosting-codes.js} are omx-clap-core's
10
+ * instead, and that package owns their catalogs). A code, its args and its source sentence are written once, in
11
+ * `schema/codes.json`; {@link TEXT_CODE} is that file's `key: code` map, generated (`tools/gen-text-codes.mjs`), so a
12
+ * call site names a code by its key and no sentence is written in a module. `catalogs/<lang>.json`, read by
13
+ * {@link ../text-catalog.js}, hold each listed language's sentences; `src/text-catalog-conformance.test.ts` turns red
14
+ * on a code without its source sentence or without an entry in a language, and on a sentence whose placeholders are
15
+ * not its code's `args`.
16
+ */
17
+ import { TEXT_CODE, TEXT_CODE_ARGS } from "./text-codes.generated.js";
18
+
19
+ export { TEXT_CODE, TEXT_CODE_ARGS };
20
+
21
+ /** A key of {@link TEXT_CODE}. */
22
+ export type TextCodeKey = keyof typeof TEXT_CODE;
23
+
24
+ /** A code of {@link TEXT_CODE} with the arguments its source sentence names. A reader renders it; it is never prose itself. */
25
+ export interface TextRef {
26
+ readonly code: string;
27
+ readonly args?: Readonly<Record<string, string | number>>;
28
+ }
29
+
30
+ /**
31
+ * A reason as plugin-qualify writes it: its own sentence as a {@link TextRef}, or another tool's own words passed
32
+ * through unclassified (the shape `UnmeasuredFigure.detail` already uses elsewhere: a prober's raw text is never
33
+ * coded, since nothing here can translate words it did not write).
34
+ */
35
+ export type ReasonText = TextRef | string;
@@ -0,0 +1,22 @@
1
+ #!/bin/sh
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+ # Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
4
+ #
5
+ # The catalogs through omx-contract's release gate (translate --check, 3.16.0): every listed language has its
6
+ # catalog, no key is missing and no entry is stale. The codes are a bare item file (schema/codes.json); the gate reads
7
+ # a vocabulary, so it is handed one holding that item alone.
8
+ #
9
+ # tools/catalogs-gate.sh [translate options] (default --check; e.g. --lang es --model <alias> to make a language)
10
+
11
+ set -eu
12
+ cd "$(dirname "$0")/.."
13
+ owner=$(python3 -c 'import json; print(json.load(open("package.json"))["name"].split("/")[-1])')
14
+ data=$(mktemp -d)
15
+ trap 'rm -r "$data"' EXIT
16
+ python3 - "$data/codes.json" "$owner" <<'EOF'
17
+ import json, sys
18
+ item = sys.argv[2].upper().replace("-", "_") + "_CODES"
19
+ json.dump({item: json.load(open("schema/codes.json"))}, open(sys.argv[1], "w"), ensure_ascii=False)
20
+ EOF
21
+ [ "$#" -gt 0 ] || set -- --check
22
+ exec omx-contract translate "$@" --data "$data" --catalogs catalogs --package "$owner"
@@ -316,7 +316,7 @@ async function protocolHost() {
316
316
  for (let i = 0; i < CYCLES; i++) {
317
317
  const base = ports() ?? [];
318
318
  const a = await ask(`add ${spec} ${INSTANCE}`);
319
- if (i === 0) obs.load = a.resp !== undefined ? { ok: a.resp >= 0, resp: a.resp } : { ok: false, ...(a.died ? { died: a.died } : { detail: "no answer in time" }) };
319
+ if (i === 0) obs.load = a.resp !== undefined ? { ok: a.resp >= 0, resp: a.resp } : { ok: false, ...(a.died ? { died: a.died } : {}) };
320
320
  if (a.resp === undefined || a.resp < 0) {
321
321
  obs.cycles = { done, of: CYCLES, failedAt: "add", ...(a.died ? { died: a.died } : {}) };
322
322
  if (!alive() || closed) crashed(i === 0 ? "load" : `cycle ${String(i + 1)}`);
@@ -32,8 +32,12 @@ import {
32
32
  summaryHelpText,
33
33
  summaryTable,
34
34
  writeFileWhole,
35
+ localeOf,
36
+ reasonText,
37
+ TEXT_CODE,
35
38
  } from "../dist/index.js";
36
39
 
40
+ const LANG = localeOf();
37
41
  const DRIVER = fileURLToPath(new URL("./compare-host.mjs", import.meta.url));
38
42
  const log = (line) => process.stderr.write(`plugin-qualify compare: ${line}\n`);
39
43
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -62,7 +66,7 @@ function presence(hosts) {
62
66
  const program = HOST_TRAITS[host].program;
63
67
  const w = sh("sh", ["-c", `command -v "$1"`, "sh", program]);
64
68
  const path = w.status === 0 ? w.stdout.trim() : "";
65
- if (!path) return { host, installed: false, version: null, reason: `${program} is not installed (not on PATH)` };
69
+ if (!path) return { host, installed: false, version: null, reason: { code: TEXT_CODE.compareHostNotInstalled, args: { program } } };
66
70
  return { host, installed: true, version: versionOf(program, path) };
67
71
  });
68
72
  }
@@ -187,7 +191,7 @@ function writeSummary(dir, records, present, meta, format) {
187
191
  writeFileWhole(join(dir, COMPARISON_SUMMARY_FILE), `${JSON.stringify(summary, null, 2)}\n`);
188
192
  log(`summary ${join(dir, COMPARISON_SUMMARY_FILE)}`);
189
193
  }
190
- process.stdout.write(format === "json" ? `${JSON.stringify(summary, null, 2)}\n` : summaryTable(summary));
194
+ process.stdout.write(format === "json" ? `${JSON.stringify(summary, null, 2)}\n` : summaryTable(summary, LANG));
191
195
  return summary;
192
196
  }
193
197
 
@@ -209,7 +213,7 @@ export async function compareSummaryMain(argv) {
209
213
  const present = presenceFromRecords(records);
210
214
  const missing = missingReferences(present);
211
215
  if (parsed.args.publish && missing.length) {
212
- for (const m of missing) log(`not publishable: ${m.host}: ${m.reason}`);
216
+ for (const m of missing) log(`not publishable: ${m.host}: ${reasonText(m.reason, LANG)}`);
213
217
  return 1;
214
218
  }
215
219
  writeSummary(parsed.args.dir, records, present, { hostClass: parsed.args.hostClass, set: parsed.args.set, publish: parsed.args.publish }, "text");
@@ -229,11 +233,11 @@ export async function compareMain(argv) {
229
233
  return 0;
230
234
  }
231
235
  const present = presence(a.hosts);
232
- for (const p of present) log(p.installed ? `host ${p.host}: ${p.version ?? "version unknown"}` : `host ${p.host}: skipped, ${p.reason}`);
236
+ for (const p of present) log(p.installed ? `host ${p.host}: ${p.version ?? "version unknown"}` : `host ${p.host}: skipped, ${reasonText(p.reason, LANG)}`);
233
237
  const missing = missingReferences(present);
234
238
  if (a.publish && missing.length) {
235
239
  // a publishable comparison is against both references: refused before anything runs, each missing one named
236
- for (const m of missing) log(`--publish refused: ${m.host}: ${m.reason}`);
240
+ for (const m of missing) log(`--publish refused: ${m.host}: ${reasonText(m.reason, LANG)}`);
237
241
  log(`install them (see the README, "Compare with mod-host and jalv"), or run without --publish for a local look`);
238
242
  return 1;
239
243
  }
@@ -337,7 +341,7 @@ export async function compareMain(argv) {
337
341
  rows.push(row);
338
342
  const c = row.checks;
339
343
  log(
340
- `[${String(i + 1)}/${String(targets.length)}] ${t.uri} ${p.host}: ${row.status === "ran" ? `${row.runsClean ? "clean" : "not clean"}; load ${c.loads.result}, output ${c.output.result}, cycles ${c.cycles.result}, soak ${c.soak.result}, rt ${c.rtSafe.result}${row.crash ? `, crashed at ${row.crash.step} (${row.crash.signal})` : ""}` : `${row.status}: ${row.reason}`}`,
344
+ `[${String(i + 1)}/${String(targets.length)}] ${t.uri} ${p.host}: ${row.status === "ran" ? `${row.runsClean ? "clean" : "not clean"}; load ${c.loads.result}, output ${c.output.result}, cycles ${c.cycles.result}, soak ${c.soak.result}, rt ${c.rtSafe.result}${row.crash ? `, crashed at ${row.crash.step} (${row.crash.signal})` : ""}` : `${row.status}: ${reasonText(row.reason, LANG)}`}`,
341
345
  );
342
346
  }
343
347
  const rec = comparisonRecordOf({ format: t.format, id: t.uri, ...(t.binary ? { binary: t.binary } : {}) }, stimulus, conditions, new Date().toISOString(), rows);
@@ -0,0 +1,8 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /** Where the codes are declared, `schema/codes.json`. */
4
+ export const SOURCE: string;
5
+ /** The committed generated module, `src/text-codes.generated.ts`. */
6
+ export const OUT: string;
7
+ /** The generated module's text for the `codes` of a codes file. */
8
+ export function textCodesFile(codes: Readonly<Record<string, { readonly code: string; readonly args?: readonly string[] }>>): string;
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
4
+ /*
5
+ * gen-text-codes.mjs — `src/text-codes.generated.ts`, the codes of schema/codes.json as one `key: code` map
6
+ * and the args each one names, as plain data.
7
+ *
8
+ * schema/codes.json is where a code, its args and its English source sentence are written, once. The call sites
9
+ * (src/compare.ts, src/hostd-pin-files.ts) name a code by its key; they reach it as a generated module rather than a read at
10
+ * import, so every module a public subpath reaches stays free of `node:` imports and bundles for a browser (#134).
11
+ * No sentence is in the generated module: the sentence is read from schema/codes.json where it is rendered
12
+ * (src/text-catalog.ts). The file is committed and checked identical, as src/hosting-codes.generated.ts is.
13
+ *
14
+ * Usage:
15
+ * node tools/gen-text-codes.mjs --check # exit 1 if the committed file is stale
16
+ * node tools/gen-text-codes.mjs --write # (re)write the committed file
17
+ */
18
+ import { readFileSync, writeFileSync } from 'node:fs';
19
+ import { dirname, join, resolve } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const PKG = resolve(dirname(fileURLToPath(import.meta.url)), '..');
23
+ export const SOURCE = join(PKG, 'schema/codes.json');
24
+ export const OUT = join(PKG, 'src/text-codes.generated.ts');
25
+
26
+ /** The generated module's text for the `codes` of a codes file. */
27
+ export function textCodesFile(codes) {
28
+ const entries = Object.entries(codes);
29
+ const code = entries.map(([key, c]) => ` ${key}: ${JSON.stringify(c.code)},\n`);
30
+ const args = entries.map(([key, c]) => ` ${key}: [${(c.args ?? []).map((a) => JSON.stringify(a)).join(', ')}],\n`);
31
+ return (
32
+ `// SPDX-License-Identifier: GPL-3.0-or-later\n` +
33
+ `// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>\n` +
34
+ `//\n` +
35
+ `// GENERATED by tools/gen-text-codes.mjs from schema/codes.json — do not hand-edit.\n` +
36
+ `//\n` +
37
+ `// Regenerate: node tools/gen-text-codes.mjs --write\n` +
38
+ `// Verify: node tools/gen-text-codes.mjs --check\n` +
39
+ `\n` +
40
+ `/** plugin-qualify's own codes, by key (schema/codes.json). */\n` +
41
+ `export const TEXT_CODE = Object.freeze({\n` +
42
+ code.join('') +
43
+ `} as const);\n` +
44
+ `\n` +
45
+ `/** The args each code's source sentence names, by the same key. */\n` +
46
+ `export const TEXT_CODE_ARGS = Object.freeze({\n` +
47
+ args.join('') +
48
+ `} as const);\n`
49
+ );
50
+ }
51
+
52
+ function main() {
53
+ const mode = process.argv[2];
54
+ if (!['--check', '--write'].includes(mode)) {
55
+ console.error('usage: node tools/gen-text-codes.mjs --check|--write');
56
+ process.exit(2);
57
+ }
58
+ const content = textCodesFile(JSON.parse(readFileSync(SOURCE, 'utf8')).codes);
59
+ let onDisk;
60
+ try {
61
+ onDisk = readFileSync(OUT, 'utf8');
62
+ } catch {
63
+ onDisk = undefined;
64
+ }
65
+ if (mode === '--check') {
66
+ if (onDisk !== content) {
67
+ console.error(`gen-text-codes: ${OUT} is stale against ${SOURCE} — run with --write`);
68
+ process.exit(1);
69
+ }
70
+ console.log('gen-text-codes: up to date');
71
+ return;
72
+ }
73
+ if (onDisk !== content) writeFileSync(OUT, content);
74
+ console.log(`gen-text-codes: ${onDisk === content ? 'up to date' : 'wrote'} ${OUT}`);
75
+ }
76
+
77
+ if (process.argv[1] === fileURLToPath(import.meta.url)) main();