@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.
- package/catalogs/ca.json +244 -1
- package/catalogs/de.json +49 -0
- package/catalogs/es.json +49 -0
- package/catalogs/fr.json +49 -0
- package/catalogs/it.json +49 -0
- package/catalogs/pt.json +49 -0
- package/dist/compare.d.ts +12 -11
- package/dist/compare.js +71 -38
- package/dist/compare.js.map +1 -1
- package/dist/hostd-pin-files.d.ts +6 -2
- package/dist/hostd-pin-files.js +10 -6
- package/dist/hostd-pin-files.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/plugin-db-cli.d.ts +28 -0
- package/dist/plugin-db-cli.js +43 -0
- package/dist/plugin-db-cli.js.map +1 -0
- package/dist/plugin-db-pin.d.ts +8 -0
- package/dist/plugin-db-pin.js +29 -0
- package/dist/plugin-db-pin.js.map +1 -0
- package/dist/qualify-sync.js +3 -2
- package/dist/qualify-sync.js.map +1 -1
- package/dist/record-figures.d.ts +52 -0
- package/dist/record-figures.js +58 -0
- package/dist/record-figures.js.map +1 -0
- package/dist/refused-stderr-kept.d.ts +12 -0
- package/dist/refused-stderr-kept.js +67 -0
- package/dist/refused-stderr-kept.js.map +1 -0
- package/dist/text-catalog.d.ts +41 -0
- package/dist/text-catalog.js +97 -0
- package/dist/text-catalog.js.map +1 -0
- package/dist/text-codes.d.ts +29 -0
- package/dist/text-codes.generated.d.ts +88 -0
- package/dist/text-codes.generated.js +96 -0
- package/dist/text-codes.generated.js.map +1 -0
- package/dist/text-codes.js +19 -0
- package/dist/text-codes.js.map +1 -0
- package/package.json +3 -3
- package/schema/codes.json +372 -1
- package/src/compare.ts +85 -45
- package/src/hostd-pin-files.ts +13 -8
- package/src/index.ts +4 -0
- package/src/plugin-db-cli.ts +78 -0
- package/src/plugin-db-pin.ts +33 -0
- package/src/qualify-sync.ts +3 -2
- package/src/record-figures.ts +114 -0
- package/src/refused-stderr-kept.ts +84 -0
- package/src/text-catalog.ts +116 -0
- package/src/text-codes.generated.ts +97 -0
- package/src/text-codes.ts +35 -0
- package/tools/catalogs-gate.sh +22 -0
- package/tools/compare-host.mjs +1 -1
- package/tools/compare-run.mjs +10 -6
- package/tools/gen-text-codes.d.mts +8 -0
- 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"
|
package/tools/compare-host.mjs
CHANGED
|
@@ -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 } : {
|
|
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)}`);
|
package/tools/compare-run.mjs
CHANGED
|
@@ -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:
|
|
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();
|