@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.
@@ -1,2 +1,18 @@
1
1
  #!/usr/bin/env node
2
- export {};
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;
@@ -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 OUT = resolve(fileURLToPath(new URL("../../../", import.meta.url)), "runs/worker-compare");
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(OUT, {
378
+ mkdirSync(outDir, {
359
379
  recursive: true
360
380
  });
361
- writeFileSync(resolve(OUT, "compare.json"), JSON.stringify({
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(OUT, "compare.json")}\n`);
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 SCORER_MODEL_DIR = fileURLToPath(new URL("../../scorer/models/screenreader-scorer/", import.meta.url));
86
- const DATASET = resolve(fileURLToPath(new URL("../../../", import.meta.url)), "runs/screenreader-dataset");
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 = resolve(fileURLToPath(new URL(".", import.meta.url)), "..", "..", "..");
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 thisCheckoutRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), "..", "..", "..");
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 checkJudge() {
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
- const weights = resolve(SCORER_MODEL_DIR, "model.safetensors");
284
- 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} — they ship in the repo, so this means an incomplete checkout`);
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(DATASET, "manifest.json");
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(DATASET, "capture-progress.json");
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 };
@@ -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
  *
@@ -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 INVENTORY = fileURLToPath(new URL("../../control/ansible/inventory.yml", import.meta.url));
23
- const GROUP_VARS = fileURLToPath(new URL("../../control/ansible/group_vars/a11y_workers.yml", import.meta.url));
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
- const port = portFromGroupVars(readFileSync(GROUP_VARS, "utf8"));
211
- const { stdout, stderr } = fleetEnvOutput(readFileSync(INVENTORY, "utf8"), {
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.0",
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",