@a11ign/screenreader-fleet 0.4.0 → 0.4.2
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/dist/compare-workers.d.mts +17 -1
- package/dist/compare-workers.mjs +30 -9
- package/dist/doctor.d.mts +101 -1
- package/dist/doctor.mjs +72 -19
- package/dist/fleet-env.d.mts +21 -0
- package/dist/fleet-env.mjs +43 -8
- package/package.json +1 -1
|
@@ -1,2 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Where the report is written: under the caller's `--runs-dir=`, else the monorepo's own `runs/`, else a refusal naming
|
|
4
|
+
* the directory and the flag (a11ign/a11ign#3767).
|
|
5
|
+
*
|
|
6
|
+
* Resolved from THIS module, never the cwd -- same reason as `doctor.mjs`'s `runsDirFor`, which this duplicates because
|
|
7
|
+
* this package cannot import `@a11ign/lab`'s canonical `runs/` resolution without a dependency cycle. From an INSTALLED
|
|
8
|
+
* package `../../../` is `node_modules`, so the default would write the report under the dependency. A supplied
|
|
9
|
+
* directory need not exist: the report's own `mkdirSync` makes it, which is what asking for one means.
|
|
10
|
+
* `baseDir` is INJECTED so a test can stand in for either layout.
|
|
11
|
+
*
|
|
12
|
+
* @param {{ argv?: readonly string[], baseDir?: string }} [options]
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function outDirFor({ argv, baseDir }?: {
|
|
16
|
+
argv?: readonly string[];
|
|
17
|
+
baseDir?: string;
|
|
18
|
+
}): string;
|
package/dist/compare-workers.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
4
|
import { execFileSync } from "node:child_process";
|
|
5
5
|
import { resolve } from "node:path";
|
|
6
6
|
import { describeProbe, probeHealth, WORKER_PROBE_TIMEOUT_MS } from "./probe-outcome.mjs";
|
|
7
7
|
import { CAPTURE_CLIENT_TIMEOUT_MS, requestJson } from "./worker-http.mjs";
|
|
8
|
-
import { refuseUnknownFlags } from "./cli-flags.mjs";
|
|
8
|
+
import { refuseUnknownFlags, flagValue } from "./cli-flags.mjs";
|
|
9
9
|
async function refuseIfBusy(workers, { what, timeoutMs = WORKER_PROBE_TIMEOUT_MS, request }) {
|
|
10
10
|
const states = await Promise.all(workers.map(async (worker)=>{
|
|
11
11
|
const probe = await probeHealth(worker, {
|
|
@@ -221,12 +221,22 @@ function diffHost(before, after) {
|
|
|
221
221
|
}
|
|
222
222
|
refuseUnknownFlags([
|
|
223
223
|
"--rounds=",
|
|
224
|
-
"--runs="
|
|
224
|
+
"--runs=",
|
|
225
|
+
"--runs-dir="
|
|
225
226
|
], {
|
|
226
227
|
entry: import.meta.url,
|
|
227
228
|
command: "npm run worker:compare"
|
|
228
229
|
});
|
|
229
|
-
const
|
|
230
|
+
const MODULE_DIR = fileURLToPath(new URL(".", import.meta.url));
|
|
231
|
+
const isMonorepoRoot = (root)=>existsSync(resolve(root, "packages")) && existsSync(resolve(root, "package.json"));
|
|
232
|
+
function outDirFor({ argv = process.argv.slice(2), baseDir = MODULE_DIR } = {}) {
|
|
233
|
+
const supplied = flagValue(argv, "runs-dir");
|
|
234
|
+
if ("" === supplied) throw new Error("worker:compare: --runs-dir= is empty. Pass --runs-dir=<the runs directory>.");
|
|
235
|
+
if (void 0 !== supplied) return resolve(supplied, "worker-compare");
|
|
236
|
+
const root = resolve(baseDir, "..", "..", "..");
|
|
237
|
+
if (isMonorepoRoot(root)) return resolve(root, "runs", "worker-compare");
|
|
238
|
+
throw new Error(`worker:compare: no runs directory: ${root} is not a checkout (this is an installed package), so there is no runs/ to write worker-compare/ under. Pass --runs-dir=<the runs directory>.`);
|
|
239
|
+
}
|
|
230
240
|
const MS_PER_S = 1000;
|
|
231
241
|
function refuseIfRunsReadonly() {
|
|
232
242
|
if ("1" !== process.env.A11Y_RUNS_READONLY) return;
|
|
@@ -234,6 +244,14 @@ function refuseIfRunsReadonly() {
|
|
|
234
244
|
console.error(" Unset A11Y_RUNS_READONLY to run this for real.");
|
|
235
245
|
process.exit(3);
|
|
236
246
|
}
|
|
247
|
+
function refuseUnlessOutDir() {
|
|
248
|
+
try {
|
|
249
|
+
return outDirFor();
|
|
250
|
+
} catch (error) {
|
|
251
|
+
process.stderr.write(`${error.message}\n`);
|
|
252
|
+
return process.exit(2);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
237
255
|
const compare_workers_args = process.argv.slice(2);
|
|
238
256
|
const runs = Number(compare_workers_args.find((a)=>a.startsWith("--rounds="))?.slice(9) ?? compare_workers_args.find((a)=>a.startsWith("--runs="))?.slice(7) ?? 6);
|
|
239
257
|
const [page, ...compare_workers_workers] = compare_workers_args.filter((a)=>!a.startsWith("--"));
|
|
@@ -297,6 +315,7 @@ async function main() {
|
|
|
297
315
|
process.stderr.write("usage: npm run worker:compare -- <page-url> <worker> <worker> [--rounds=6]\n");
|
|
298
316
|
process.exit(2);
|
|
299
317
|
}
|
|
318
|
+
const outDir = refuseUnlessOutDir();
|
|
300
319
|
refuseIfRunsReadonly();
|
|
301
320
|
await refuseIfBusy(compare_workers_workers, {
|
|
302
321
|
what: `worker:compare against ${page}`
|
|
@@ -334,10 +353,11 @@ async function main() {
|
|
|
334
353
|
results,
|
|
335
354
|
vitalsBefore,
|
|
336
355
|
vitalsAfter,
|
|
337
|
-
hostBefore
|
|
356
|
+
hostBefore,
|
|
357
|
+
outDir
|
|
338
358
|
});
|
|
339
359
|
}
|
|
340
|
-
function report({ results, vitalsBefore, vitalsAfter, hostBefore }) {
|
|
360
|
+
function report({ results, vitalsBefore, vitalsAfter, hostBefore, outDir }) {
|
|
341
361
|
const allPhases = [
|
|
342
362
|
...new Set(Object.values(results).flatMap((r)=>r.phases.flatMap(Object.keys)))
|
|
343
363
|
];
|
|
@@ -355,10 +375,10 @@ function report({ results, vitalsBefore, vitalsAfter, hostBefore }) {
|
|
|
355
375
|
const { foundations, hostAfter } = reportFoundations({
|
|
356
376
|
hostBefore
|
|
357
377
|
});
|
|
358
|
-
mkdirSync(
|
|
378
|
+
mkdirSync(outDir, {
|
|
359
379
|
recursive: true
|
|
360
380
|
});
|
|
361
|
-
writeFileSync(resolve(
|
|
381
|
+
writeFileSync(resolve(outDir, "compare.json"), JSON.stringify({
|
|
362
382
|
page: page,
|
|
363
383
|
rounds: runs,
|
|
364
384
|
results,
|
|
@@ -368,7 +388,7 @@ function report({ results, vitalsBefore, vitalsAfter, hostBefore }) {
|
|
|
368
388
|
hostBefore,
|
|
369
389
|
hostAfter
|
|
370
390
|
}, null, 2) + "\n", "utf8");
|
|
371
|
-
process.stdout.write(`\nReport: ${resolve(
|
|
391
|
+
process.stdout.write(`\nReport: ${resolve(outDir, "compare.json")}\n`);
|
|
372
392
|
}
|
|
373
393
|
function reportWallTime({ results, vitalsBefore, vitalsAfter }) {
|
|
374
394
|
const wallSeconds = Object.fromEntries(compare_workers_workers.map((w)=>[
|
|
@@ -458,3 +478,4 @@ function reportFoundations({ hostBefore }) {
|
|
|
458
478
|
};
|
|
459
479
|
}
|
|
460
480
|
if (import.meta.url === pathToFileURL(process.argv[1] ? realpathSync(process.argv[1]) : "").href) await main();
|
|
481
|
+
export { outDirFor };
|
package/dist/doctor.d.mts
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The `runs/` directory doctor reads the dataset from: the caller's `--runs-dir=`, else the monorepo's own `runs/`,
|
|
4
|
+
* else a refusal naming the directory and the flag (a11ign/a11ign#3767).
|
|
5
|
+
*
|
|
6
|
+
* `@a11ign/lab` owns the canonical `runs/` resolution (`packages/lab/src/dataset-paths.mjs`), but `lab` depends on
|
|
7
|
+
* `worker-fleet`, so this package cannot import it without a cycle: the same computation, duplicated (and again in
|
|
8
|
+
* `compare-workers.mjs`) rather than left cwd-anchored. From an INSTALLED package `../../../` is `node_modules`, so the
|
|
9
|
+
* default is taken only when that directory is a checkout; a missing `runs/` inside one is normal (a fresh clone).
|
|
10
|
+
* `baseDir` is INJECTED so a test can stand in for either layout.
|
|
11
|
+
*
|
|
12
|
+
* @param {{ argv?: readonly string[], baseDir?: string }} [options]
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function runsDirFor({ argv, baseDir }?: {
|
|
16
|
+
argv?: readonly string[];
|
|
17
|
+
baseDir?: string;
|
|
18
|
+
}): string;
|
|
2
19
|
/**
|
|
3
20
|
* The remedy for a failed local-pool query, as a runnable command plus the advice that is not one.
|
|
4
21
|
*
|
|
@@ -14,6 +31,45 @@ export function workerControlFix(observed: string): {
|
|
|
14
31
|
fix: string | null;
|
|
15
32
|
note: string | null;
|
|
16
33
|
};
|
|
34
|
+
/**
|
|
35
|
+
* IS THE FLEET KEY SITTING NEXT TO 100 MB OF PACKAGES NOBODY AUDITED? — ADR 0012, checked rather than
|
|
36
|
+
* asserted.
|
|
37
|
+
*
|
|
38
|
+
* Found on 2026-08-29 to be violated on BOTH machines the ADR is about: the control plane carried 56 MB
|
|
39
|
+
* and 121 packages beside the key, and this laptop carries 103 MB beside the same key plus the lab key.
|
|
40
|
+
* The document was accurate about the intent and described a system that did not exist — which is worse
|
|
41
|
+
* than no document, because it is read as a guarantee.
|
|
42
|
+
*
|
|
43
|
+
* Reported by `doctor` because that is the command whose whole promise is that every check names its own
|
|
44
|
+
* fix, and because a check nobody runs is one this repo has learned not to write.
|
|
45
|
+
*/
|
|
46
|
+
/**
|
|
47
|
+
* IS THIS CHECKOUT MARKED AS THE PRIMARY, and does that match what it looks like?
|
|
48
|
+
*
|
|
49
|
+
* The primary-checkout guards (`pre-commit`, `post-checkout`) are OPT-IN as of #198: they fire only where
|
|
50
|
+
* `git config --local a11y.primaryCheckout` is `true`. That is the correct default — inferring it from
|
|
51
|
+
* `.git` being a directory made the hook fire on the lab, which is an ordinary clone, and broke every
|
|
52
|
+
* `lab:job -e ref=<branch>`.
|
|
53
|
+
*
|
|
54
|
+
* But an opt-in guard nobody can find the switch for is an OFF guard, and "unmarked" must not read the
|
|
55
|
+
* same as "safe". So this reports the state on every run rather than only when something is wrong — the
|
|
56
|
+
* `isolation` check above takes the same shape for the same reason: a debt that is reported every run is
|
|
57
|
+
* a known one, and a debt reported never is a forgotten one.
|
|
58
|
+
*
|
|
59
|
+
* ADVISORY, never a hard failure. `doctor` exits 0 when a RUN can proceed, and an unmarked checkout can
|
|
60
|
+
* run perfectly well — it is the fleet-driving machine's protection that is missing, not its capability.
|
|
61
|
+
* A doctor that refused READY over this would be ignored, which is how a guard gets switched off.
|
|
62
|
+
*
|
|
63
|
+
* It does not GUESS which machine deserves the mark. `doctor` runs on laptops, worktrees, the lab and CI,
|
|
64
|
+
* and telling four of those five to mark themselves would be the #198 defect wearing an advisory's
|
|
65
|
+
* clothes. It states what is true and names the command; the operator decides.
|
|
66
|
+
*/
|
|
67
|
+
export function checkPrimaryCheckoutMark({ baseDir }?: {
|
|
68
|
+
baseDir?: string | undefined;
|
|
69
|
+
}): void;
|
|
70
|
+
export function checkControlPlaneIsolation({ baseDir }?: {
|
|
71
|
+
baseDir?: string | undefined;
|
|
72
|
+
}): void;
|
|
17
73
|
/**
|
|
18
74
|
* Pure: does `resolvedRealPath` (already realpath'd) live under `thisCheckoutRoot` (also realpath'd)?
|
|
19
75
|
* Both must be realpath'd BEFORE calling this, never inside it -- comparing a symlinked path against a
|
|
@@ -59,6 +115,40 @@ export function checkoutRootFor(resolvedRealPath: string): string | null;
|
|
|
59
115
|
export function tscProjectUpToDate(tsconfigPath: string, { run }?: {
|
|
60
116
|
run?: (cmd: string, args: string[]) => string;
|
|
61
117
|
}): boolean | null;
|
|
118
|
+
/**
|
|
119
|
+
* WHOSE dist a cross-package import actually resolves to, and is IT stale (#256) -- both computed from
|
|
120
|
+
* the exact SPECIFIER a real import site in this repo uses, never the bare package name. CLAUDE.md's own
|
|
121
|
+
* recorded lesson: "resolving @a11ign/judge does not prove @a11ign/judge/rules came from your
|
|
122
|
+
* tree" -- a package can export subpaths from elsewhere, so resolving the root proves nothing about a
|
|
123
|
+
* subpath. `@a11ign/judge/rules` is a real specifier this repo imports
|
|
124
|
+
* (`packages/lab/scripts/score-rules.ts` and others), not a synthetic probe.
|
|
125
|
+
*
|
|
126
|
+
* ADVISORY, never a hard failure -- same reasoning as `isolation` above: a worktree resolving to the
|
|
127
|
+
* primary's dist can still run every command correctly today, and a doctor that refused READY over an
|
|
128
|
+
* environmental fact would be ignored, which is how a guard gets switched off. It is reported every run
|
|
129
|
+
* so a stale answer is a known condition, not a silent one.
|
|
130
|
+
*/
|
|
131
|
+
export function checkCrossPackageDist({ baseDir }?: {
|
|
132
|
+
baseDir?: string | undefined;
|
|
133
|
+
}): number | void;
|
|
134
|
+
/**
|
|
135
|
+
* Where the trained scorer's weights are, asked of `@a11ign/scorer` itself (a11ign/a11ign#3784).
|
|
136
|
+
*
|
|
137
|
+
* This was `../../scorer/models/...` from this module: the monorepo layout, which from an installed package points into
|
|
138
|
+
* `node_modules/@a11ign/` and answers "missing" under pnpm, where the scorer sits beside `@a11ign/judge` in the store and
|
|
139
|
+
* not beside this package. The scorer is a PEER of the judge, so it is resolved FROM the judge's real path, the one place
|
|
140
|
+
* both layouts agree it is reachable, and its own `scorerPaths()` says where its weights are ("the weights are the API").
|
|
141
|
+
* `from` is INJECTED so a test can stand in for either layout.
|
|
142
|
+
*
|
|
143
|
+
* @param {{ from?: string }} [options] a path or file URL to resolve `@a11ign/judge` from
|
|
144
|
+
* @returns {Promise<string>}
|
|
145
|
+
*/
|
|
146
|
+
export function scorerWeightsFor({ from }?: {
|
|
147
|
+
from?: string;
|
|
148
|
+
}): Promise<string>;
|
|
149
|
+
export function checkJudge({ from }?: {
|
|
150
|
+
from?: string | undefined;
|
|
151
|
+
}): Promise<void>;
|
|
62
152
|
/**
|
|
63
153
|
* The agreement sentence, with WHICH FIELDS AGREED DERIVED rather than retyped — #1997.
|
|
64
154
|
*
|
|
@@ -198,7 +288,7 @@ export function errorDocument(error: unknown): {
|
|
|
198
288
|
* The whole run, as a function of its steps and its streams, returning an exit code.
|
|
199
289
|
*
|
|
200
290
|
* @param {{ steps?: (() => unknown)[], json?: boolean, out?: (line: string) => void,
|
|
201
|
-
* err?: (line: string) => void }} [deps]
|
|
291
|
+
* err?: (line: string) => void, runsDir?: () => string }} [deps]
|
|
202
292
|
* @returns {Promise<number>}
|
|
203
293
|
*/
|
|
204
294
|
export function doctorRun(deps?: {
|
|
@@ -206,7 +296,17 @@ export function doctorRun(deps?: {
|
|
|
206
296
|
json?: boolean;
|
|
207
297
|
out?: (line: string) => void;
|
|
208
298
|
err?: (line: string) => void;
|
|
299
|
+
runsDir?: () => string;
|
|
209
300
|
}): Promise<number>;
|
|
301
|
+
export function recordedChecks(): {
|
|
302
|
+
name: string;
|
|
303
|
+
id: string;
|
|
304
|
+
ok: boolean;
|
|
305
|
+
detail: string;
|
|
306
|
+
fix: string | null;
|
|
307
|
+
note?: string | null;
|
|
308
|
+
advisory?: boolean;
|
|
309
|
+
}[];
|
|
210
310
|
export function addCheck(name: string, ok: boolean, detail: string, remedy?: string | null | {
|
|
211
311
|
fix: string | null;
|
|
212
312
|
note?: string | null;
|
package/dist/doctor.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { availableHostMemoryMb, workersHostCanRun } from "./host-capacity.mjs";
|
|
|
10
10
|
import { fleetConsistency, describeMismatches } from "./fleet-consistency.mjs";
|
|
11
11
|
import { fleetScriptPaths } from "./src_fleet-scripts_mjs.mjs";
|
|
12
12
|
import { namedInventoryWorkers, configuredWorkers } from "./fleet-env.mjs";
|
|
13
|
-
import { refuseUnknownFlags } from "./cli-flags.mjs";
|
|
13
|
+
import { refuseUnknownFlags, flagValue } from "./cli-flags.mjs";
|
|
14
14
|
import { requestJson } from "./worker-http.mjs";
|
|
15
15
|
import { sandboxGitEnv } from "./src_git-safe-env_mjs.mjs";
|
|
16
16
|
import { assessWorker } from "./worker-health.mjs";
|
|
@@ -72,20 +72,44 @@ function shimInvocation(shim, script, args) {
|
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
74
|
refuseUnknownFlags([
|
|
75
|
-
"--json"
|
|
75
|
+
"--json",
|
|
76
|
+
"--runs-dir="
|
|
76
77
|
], {
|
|
77
78
|
entry: import.meta.url,
|
|
78
79
|
command: "pnpm run doctor"
|
|
79
80
|
});
|
|
80
81
|
const doctor_run = promisify(execFile);
|
|
81
82
|
const JSON_OUT = process.argv.includes("--json");
|
|
83
|
+
const REFUSED = 2;
|
|
82
84
|
const WORKERS_ENV = configuredWorkers();
|
|
83
85
|
const PAGES_PORT = Number(process.env.DATASET_PAGES_PORT || 5050);
|
|
84
86
|
const CTL = fleetScriptPaths().workerCtl;
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
+
const MODULE_DIR = fileURLToPath(new URL(".", import.meta.url));
|
|
88
|
+
const isMonorepoRoot = (root)=>external_node_fs_existsSync(resolve(root, "packages")) && external_node_fs_existsSync(resolve(root, "package.json"));
|
|
89
|
+
function checkoutLayout(baseDir = MODULE_DIR) {
|
|
90
|
+
const root = resolve(baseDir, "..", "..", "..");
|
|
91
|
+
return {
|
|
92
|
+
root,
|
|
93
|
+
checkout: isMonorepoRoot(root)
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const notACheckout = (names, root)=>{
|
|
97
|
+
for (const name of names)advise(name, `n/a: installed package -- ${root} is not a checkout, so there is nothing here for this check to read`);
|
|
98
|
+
};
|
|
99
|
+
function runsDirFor({ argv = process.argv.slice(2), baseDir = MODULE_DIR } = {}) {
|
|
100
|
+
const supplied = flagValue(argv, "runs-dir");
|
|
101
|
+
if (void 0 !== supplied) {
|
|
102
|
+
if (external_node_fs_existsSync(supplied)) return resolve(supplied);
|
|
103
|
+
throw new Error(`doctor: --runs-dir=${supplied} does not exist. Pass --runs-dir=<the runs directory>.`);
|
|
104
|
+
}
|
|
105
|
+
const root = resolve(baseDir, "..", "..", "..");
|
|
106
|
+
if (isMonorepoRoot(root)) return resolve(root, "runs");
|
|
107
|
+
throw new Error(`doctor: no runs directory: ${root} is not a checkout (this is an installed package), so there is no runs/ to default to. Pass --runs-dir=<the runs directory>.`);
|
|
108
|
+
}
|
|
109
|
+
const datasetDir = ()=>resolve(runsDirFor(), "screenreader-dataset");
|
|
87
110
|
const PROBE_TIMEOUT_MS = 8000;
|
|
88
111
|
const checks = [];
|
|
112
|
+
const recordedChecks = ()=>checks;
|
|
89
113
|
const GATES = Object.freeze({
|
|
90
114
|
worker: true,
|
|
91
115
|
fleet: true,
|
|
@@ -168,8 +192,11 @@ async function httpJson(url) {
|
|
|
168
192
|
if (void 0 === response.json) throw new Error(`invalid JSON from ${url}`);
|
|
169
193
|
return response.json;
|
|
170
194
|
}
|
|
171
|
-
function checkPrimaryCheckoutMark() {
|
|
172
|
-
const root
|
|
195
|
+
function checkPrimaryCheckoutMark({ baseDir = MODULE_DIR } = {}) {
|
|
196
|
+
const { root, checkout } = checkoutLayout(baseDir);
|
|
197
|
+
if (!checkout) return notACheckout([
|
|
198
|
+
"primary checkout"
|
|
199
|
+
], root);
|
|
173
200
|
let linked;
|
|
174
201
|
try {
|
|
175
202
|
linked = !statSync(resolve(root, ".git")).isDirectory();
|
|
@@ -195,17 +222,19 @@ function checkPrimaryCheckoutMark() {
|
|
|
195
222
|
if (marked) return add("primary checkout", true, "MARKED — pre-commit refuses commits here and post-checkout keeps it detached at origin/main");
|
|
196
223
|
advise("primary checkout", "not marked, so the primary-checkout guards are INERT here. Correct for the lab, a worker or a colleague's clone; wrong for the machine that drives the fleet.", "pnpm run primary:mark -- --set (only on the fleet-driving checkout — see docs/primary-checkout.md)");
|
|
197
224
|
}
|
|
198
|
-
function checkControlPlaneIsolation() {
|
|
225
|
+
function checkControlPlaneIsolation({ baseDir = MODULE_DIR } = {}) {
|
|
226
|
+
const { root, checkout } = checkoutLayout(baseDir);
|
|
227
|
+
if (!checkout) return notACheckout([
|
|
228
|
+
"isolation"
|
|
229
|
+
], root);
|
|
199
230
|
const raw = process.env.A11Y_SSH_KEY || "~/.ssh/a11y-witness_ed25519";
|
|
200
231
|
const keyPath = raw.startsWith("~/") ? resolve(homedir(), raw.slice(2)) : raw;
|
|
201
232
|
const hasFleetKey = external_node_fs_existsSync(keyPath);
|
|
202
|
-
const root = resolve(fileURLToPath(new URL(".", import.meta.url)), "..", "..", "..");
|
|
203
233
|
const hasNodeModules = external_node_fs_existsSync(resolve(root, "node_modules"));
|
|
204
|
-
const isWorkspace = external_node_fs_existsSync(resolve(root, "packages")) && external_node_fs_existsSync(resolve(root, "package.json"));
|
|
205
234
|
const verdict = controlPlaneIsolation({
|
|
206
235
|
hasNodeModules,
|
|
207
236
|
hasFleetKey,
|
|
208
|
-
isWorkspace
|
|
237
|
+
isWorkspace: checkout
|
|
209
238
|
});
|
|
210
239
|
if (!verdict.violated) return add("isolation", true, verdict.why);
|
|
211
240
|
advise("isolation", verdict.why, "docs/control-plane-plan.md L3 — drive the control plane rather than holding its keys");
|
|
@@ -256,9 +285,13 @@ function behindOriginMainNote(otherRoot) {
|
|
|
256
285
|
return "";
|
|
257
286
|
}
|
|
258
287
|
}
|
|
259
|
-
function checkCrossPackageDist() {
|
|
288
|
+
function checkCrossPackageDist({ baseDir = MODULE_DIR } = {}) {
|
|
260
289
|
const specifier = "@a11ign/judge/rules";
|
|
261
|
-
const
|
|
290
|
+
const { root: thisCheckoutRoot, checkout } = checkoutLayout(baseDir);
|
|
291
|
+
if (!checkout) return notACheckout([
|
|
292
|
+
"dist-resolution",
|
|
293
|
+
"dist-freshness"
|
|
294
|
+
], thisCheckoutRoot);
|
|
262
295
|
let resolvedRealPath;
|
|
263
296
|
try {
|
|
264
297
|
resolvedRealPath = external_node_fs_realpathSync(createRequire(import.meta.url).resolve(specifier));
|
|
@@ -277,11 +310,24 @@ function checkCrossPackageDist() {
|
|
|
277
310
|
if (upToDate) return add("dist-freshness", true, `packages/judge under ${distRoot} is up to date (tsc --build --dry)`);
|
|
278
311
|
advise("dist-freshness", `packages/judge under ${distRoot} is NOT up to date (tsc --build --dry) -- a build compiled before the source it now reflects`, "pnpm run build # in that checkout");
|
|
279
312
|
}
|
|
280
|
-
async function
|
|
313
|
+
async function scorerWeightsFor({ from = import.meta.url } = {}) {
|
|
314
|
+
const judgeEntry = external_node_fs_realpathSync(createRequire(from).resolve("@a11ign/judge"));
|
|
315
|
+
const scorerEntry = external_node_fs_realpathSync(createRequire(judgeEntry).resolve("@a11ign/scorer"));
|
|
316
|
+
const { scorerPaths } = await import(pathToFileURL(scorerEntry).href);
|
|
317
|
+
return scorerPaths().weights;
|
|
318
|
+
}
|
|
319
|
+
async function checkJudge({ from = import.meta.url } = {}) {
|
|
281
320
|
const backend = (process.env.JUDGE_BACKEND || "local").toLowerCase();
|
|
282
321
|
if ("local" === backend) {
|
|
283
|
-
|
|
284
|
-
|
|
322
|
+
let weights;
|
|
323
|
+
try {
|
|
324
|
+
weights = await scorerWeightsFor({
|
|
325
|
+
from
|
|
326
|
+
});
|
|
327
|
+
} catch (error) {
|
|
328
|
+
return add("judge", false, `backend=local, but @a11ign/scorer could not be resolved from @a11ign/judge: ${error.message}`, "install @a11ign/scorer (a peer dependency of @a11ign/judge) beside @a11ign/judge");
|
|
329
|
+
}
|
|
330
|
+
return add("judge", external_node_fs_existsSync(weights), external_node_fs_existsSync(weights) ? "backend=local, trained scorer present" : "backend=local, but the trained scorer is missing", `expected weights at ${weights} (where @a11ign/scorer says they are) — reinstall @a11ign/scorer`);
|
|
285
331
|
}
|
|
286
332
|
if ("anthropic" === backend || "openai" === backend) {
|
|
287
333
|
const key = "anthropic" === backend ? "ANTHROPIC_API_KEY" : "JUDGE_BASE_URL";
|
|
@@ -419,7 +465,7 @@ function checkHostCapacity(pool) {
|
|
|
419
465
|
add("host memory", true, limit >= poolSize ? detail : `${detail}; the rest stay stopped so the run does not swap (override: A11Y_MAX_WORKERS)`);
|
|
420
466
|
}
|
|
421
467
|
async function checkDatasetPages() {
|
|
422
|
-
const manifestPath = resolve(
|
|
468
|
+
const manifestPath = resolve(datasetDir(), "manifest.json");
|
|
423
469
|
if (!external_node_fs_existsSync(manifestPath)) return add("dataset", false, "no manifest — the dataset has not been generated", "pnpm run training:generate");
|
|
424
470
|
const sample = JSON.parse(readFileSync(manifestPath, "utf8")).cases?.[0]?.id;
|
|
425
471
|
const probe = sample ? `${sample}/good.html` : "";
|
|
@@ -434,7 +480,7 @@ async function checkDatasetPages() {
|
|
|
434
480
|
}
|
|
435
481
|
}
|
|
436
482
|
function checkRunState() {
|
|
437
|
-
const progress = resolve(
|
|
483
|
+
const progress = resolve(datasetDir(), "capture-progress.json");
|
|
438
484
|
if (!external_node_fs_existsSync(progress)) return add("run", true, "no capture run recorded");
|
|
439
485
|
const p = JSON.parse(readFileSync(progress, "utf8"));
|
|
440
486
|
if (!p.startedAt) return add("run", true, "no capture run recorded");
|
|
@@ -475,7 +521,14 @@ function errorDocument(error) {
|
|
|
475
521
|
};
|
|
476
522
|
}
|
|
477
523
|
async function doctorRun(deps = {}) {
|
|
478
|
-
const { steps = DEFAULT_STEPS, json = JSON_OUT, out = console.log, err = console.error } = deps;
|
|
524
|
+
const { steps = DEFAULT_STEPS, json = JSON_OUT, out = console.log, err = console.error, runsDir = runsDirFor } = deps;
|
|
525
|
+
try {
|
|
526
|
+
runsDir();
|
|
527
|
+
} catch (error) {
|
|
528
|
+
if (json) out(JSON.stringify(errorDocument(error), null, 2));
|
|
529
|
+
else err(error.message);
|
|
530
|
+
return REFUSED;
|
|
531
|
+
}
|
|
479
532
|
try {
|
|
480
533
|
for (const step of steps)await step();
|
|
481
534
|
} catch (error) {
|
|
@@ -511,4 +564,4 @@ async function main() {
|
|
|
511
564
|
process.exit(await doctorRun());
|
|
512
565
|
}
|
|
513
566
|
if (import.meta.url === pathToFileURL(process.argv[1] ? external_node_fs_realpathSync(process.argv[1]) : "").href) await main();
|
|
514
|
-
export { addCheck, allChecks, checkoutRootFor, doctorRun, errorDocument, fleetAgreementLine, gatingChecks, isRunnableCommand, nextCommand, readyFrom, resolvesToThisCheckout, tscProjectUpToDate, workerControlFix };
|
|
567
|
+
export { addCheck, allChecks, checkControlPlaneIsolation, checkCrossPackageDist, checkJudge, checkPrimaryCheckoutMark, checkoutRootFor, doctorRun, errorDocument, fleetAgreementLine, gatingChecks, isRunnableCommand, nextCommand, readyFrom, recordedChecks, resolvesToThisCheckout, runsDirFor, scorerWeightsFor, tscProjectUpToDate, workerControlFix };
|
package/dist/fleet-env.d.mts
CHANGED
|
@@ -20,6 +20,27 @@ export function configuredWorkers(): Array<{
|
|
|
20
20
|
name: string;
|
|
21
21
|
url: string;
|
|
22
22
|
}>;
|
|
23
|
+
/**
|
|
24
|
+
* The two files the COMMAND reads, from the caller or else from the monorepo layout, and a refusal naming the path
|
|
25
|
+
* and the flag when the one it would read is not there (a11ign/a11ign#3767).
|
|
26
|
+
*
|
|
27
|
+
* `INVENTORY` above points into `packages/control`, which an installed `@a11ign/screenreader-fleet` does not have, so
|
|
28
|
+
* `fleet-env --list` there died on an ENOENT for a path nobody could have guessed. The library functions keep their
|
|
29
|
+
* default because their answer for "no inventory here" is `[]`, which is supported; a command that prints
|
|
30
|
+
* `export A11Y_WORKERS=''` for a missing file would be the same silent wrong answer, so it refuses.
|
|
31
|
+
*
|
|
32
|
+
* `baseDir` is INJECTED so a test can stand in for either layout without moving a file.
|
|
33
|
+
*
|
|
34
|
+
* @param {{ argv?: readonly string[], baseDir?: string }} [options]
|
|
35
|
+
* @returns {{ inventoryPath: string, groupVarsPath: string }}
|
|
36
|
+
*/
|
|
37
|
+
export function inventoryPathsFor({ argv, baseDir }?: {
|
|
38
|
+
argv?: readonly string[];
|
|
39
|
+
baseDir?: string;
|
|
40
|
+
}): {
|
|
41
|
+
inventoryPath: string;
|
|
42
|
+
groupVarsPath: string;
|
|
43
|
+
};
|
|
23
44
|
/**
|
|
24
45
|
* Which group each line of the inventory sits in, index-aligned with `text.split(/\r?\n/)`.
|
|
25
46
|
*
|
package/dist/fleet-env.mjs
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
2
3
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
3
4
|
import { assertWorkerUrl } from "./worker-http.mjs";
|
|
4
|
-
import { refuseUnknownFlags } from "./cli-flags.mjs";
|
|
5
|
+
import { refuseUnknownFlags, flagValue } from "./cli-flags.mjs";
|
|
5
6
|
refuseUnknownFlags([
|
|
6
|
-
"--list"
|
|
7
|
+
"--list",
|
|
8
|
+
"--inventory=",
|
|
9
|
+
"--group-vars="
|
|
7
10
|
], {
|
|
8
11
|
entry: import.meta.url,
|
|
9
12
|
command: "npm run fleet:env"
|
|
@@ -19,8 +22,32 @@ function configuredWorkers() {
|
|
|
19
22
|
url
|
|
20
23
|
}));
|
|
21
24
|
}
|
|
22
|
-
const
|
|
23
|
-
const
|
|
25
|
+
const MODULE_DIR = fileURLToPath(new URL(".", import.meta.url));
|
|
26
|
+
const monorepoAnsibleFile = (relative, baseDir = MODULE_DIR)=>resolve(baseDir, "..", "..", "control", "ansible", relative);
|
|
27
|
+
const INVENTORY = monorepoAnsibleFile("inventory.yml");
|
|
28
|
+
const GROUP_VARS = monorepoAnsibleFile("group_vars/a11y_workers.yml");
|
|
29
|
+
function inventoryPathsFor({ argv = process.argv.slice(2), baseDir = MODULE_DIR } = {}) {
|
|
30
|
+
const inventoryPath = existingPath({
|
|
31
|
+
flag: "inventory",
|
|
32
|
+
supplied: flagValue(argv, "inventory"),
|
|
33
|
+
fallback: monorepoAnsibleFile("inventory.yml", baseDir)
|
|
34
|
+
});
|
|
35
|
+
const groupVarsPath = existingPath({
|
|
36
|
+
flag: "group-vars",
|
|
37
|
+
supplied: flagValue(argv, "group-vars"),
|
|
38
|
+
fallback: monorepoAnsibleFile("group_vars/a11y_workers.yml", baseDir)
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
inventoryPath,
|
|
42
|
+
groupVarsPath
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function existingPath({ flag, supplied, fallback }) {
|
|
46
|
+
const path = supplied ?? fallback;
|
|
47
|
+
if (existsSync(path)) return path;
|
|
48
|
+
const origin = void 0 === supplied ? "the monorepo layout this command defaults to has no such file" : "no such file";
|
|
49
|
+
throw new Error(`fleet:env: ${path} does not exist (${origin}). Pass --${flag}=<file>.`);
|
|
50
|
+
}
|
|
24
51
|
const HOST_LINE = /^\s*ansible_host\s*:\s*(\S+)\s*$/;
|
|
25
52
|
const SUSPECT = /ansible_host\s*:/;
|
|
26
53
|
const KEY_LINE = /^(\s*)([A-Za-z_][\w.-]*)\s*:/;
|
|
@@ -207,8 +234,16 @@ function fleetEnvOutput(text, { port = DEFAULT_WORKER_PORT, mode = "env" } = {})
|
|
|
207
234
|
};
|
|
208
235
|
}
|
|
209
236
|
function main() {
|
|
210
|
-
|
|
211
|
-
|
|
237
|
+
let paths;
|
|
238
|
+
try {
|
|
239
|
+
paths = inventoryPathsFor();
|
|
240
|
+
} catch (error) {
|
|
241
|
+
process.stderr.write(`${error.message}\n`);
|
|
242
|
+
process.exitCode = 2;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const port = portFromGroupVars(readFileSync(paths.groupVarsPath, "utf8"));
|
|
246
|
+
const { stdout, stderr } = fleetEnvOutput(readFileSync(paths.inventoryPath, "utf8"), {
|
|
212
247
|
port,
|
|
213
248
|
mode: process.argv.includes("--list") ? "list" : "env"
|
|
214
249
|
});
|
|
@@ -216,4 +251,4 @@ function main() {
|
|
|
216
251
|
process.stdout.write(stdout);
|
|
217
252
|
}
|
|
218
253
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main();
|
|
219
|
-
export { DEFAULT_WORKER_PORT, WORKER_GROUP, configuredWorkers, fleetEnvOutput, groupPerLine, hostsOutOfCaptureSet, inventoryWorkerUrls, namedInventoryWorkers, portFromGroupVars, resolveWorkerPool, workerNamesFromInventory, workersFromInventory };
|
|
254
|
+
export { DEFAULT_WORKER_PORT, WORKER_GROUP, configuredWorkers, fleetEnvOutput, groupPerLine, hostsOutOfCaptureSet, inventoryPathsFor, inventoryWorkerUrls, namedInventoryWorkers, portFromGroupVars, resolveWorkerPool, workerNamesFromInventory, workersFromInventory };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a11ign/screenreader-fleet",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Host-side lifecycle, health and capacity for a fleet of Windows NVDA capture workers: lease one, judge whether it is degrading, and know how many the host can afford.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|