@williamthorsen/nmr 0.48.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.meta/changelog.json +218 -0
  2. package/.readyup/kits/default.js +1527 -1460
  3. package/.readyup/manifest.json +96 -11
  4. package/CHANGELOG.md +65 -0
  5. package/README.md +19 -8
  6. package/agents/guidance/rulebooks/nmr.md +2 -2
  7. package/dist/esm/check-cache-build-output.d.ts +24 -0
  8. package/dist/esm/check-cache-build-output.js +49 -0
  9. package/dist/esm/check-cache-delegation.d.ts +27 -0
  10. package/dist/esm/check-cache-delegation.js +92 -0
  11. package/dist/esm/check-cache-entries.d.ts +109 -0
  12. package/dist/esm/check-cache-entries.js +87 -0
  13. package/dist/esm/check-cache-gate.d.ts +64 -0
  14. package/dist/esm/check-cache-gate.js +226 -0
  15. package/dist/esm/check-cache-key.d.ts +47 -0
  16. package/dist/esm/check-cache-key.js +77 -0
  17. package/dist/esm/check-cache-record.d.ts +69 -0
  18. package/dist/esm/check-cache-record.js +167 -0
  19. package/dist/esm/check-cache.d.ts +8 -176
  20. package/dist/esm/check-cache.js +9 -214
  21. package/dist/esm/child-env.d.ts +57 -0
  22. package/dist/esm/child-env.js +59 -0
  23. package/dist/esm/child-process.d.ts +55 -0
  24. package/dist/esm/child-process.js +112 -0
  25. package/dist/esm/commands/build.d.ts +0 -16
  26. package/dist/esm/commands/build.js +9 -214
  27. package/dist/esm/commands/clean.js +3 -2
  28. package/dist/esm/commands/compiler.d.ts +18 -0
  29. package/dist/esm/commands/compiler.js +49 -0
  30. package/dist/esm/commands/fmt.d.ts +0 -39
  31. package/dist/esm/commands/fmt.js +4 -81
  32. package/dist/esm/commands/format-targets.d.ts +39 -0
  33. package/dist/esm/commands/format-targets.js +83 -0
  34. package/dist/esm/commands/rewrite-specifiers.d.ts +17 -0
  35. package/dist/esm/commands/rewrite-specifiers.js +122 -0
  36. package/dist/esm/commands/tsconfig-chain.d.ts +8 -0
  37. package/dist/esm/commands/tsconfig-chain.js +63 -0
  38. package/dist/esm/config-check-cache.d.ts +17 -0
  39. package/dist/esm/config-check-cache.js +63 -0
  40. package/dist/esm/config-validation.d.ts +20 -0
  41. package/dist/esm/config-validation.js +236 -0
  42. package/dist/esm/config.js +4 -286
  43. package/dist/esm/crossing-diagnostics.d.ts +39 -0
  44. package/dist/esm/crossing-diagnostics.js +132 -0
  45. package/dist/esm/default-scripts.js +1 -1
  46. package/dist/esm/delegate-host.d.ts +43 -0
  47. package/dist/esm/delegate-host.js +191 -0
  48. package/dist/esm/delegation-target.d.ts +52 -0
  49. package/dist/esm/delegation-target.js +71 -0
  50. package/dist/esm/fan-out.d.ts +57 -0
  51. package/dist/esm/fan-out.js +146 -0
  52. package/dist/esm/glyphs.d.ts +1 -1
  53. package/dist/esm/help.js +10 -3
  54. package/dist/esm/helpers/deriveExcerpt.d.ts +9 -0
  55. package/dist/esm/helpers/deriveExcerpt.js +1 -1
  56. package/dist/esm/helpers/renderQuotedList.d.ts +2 -0
  57. package/dist/esm/helpers/renderQuotedList.js +9 -0
  58. package/dist/esm/hooks.d.ts +17 -0
  59. package/dist/esm/hooks.js +23 -0
  60. package/dist/esm/invocation-args.d.ts +40 -0
  61. package/dist/esm/invocation-args.js +178 -0
  62. package/dist/esm/invocation.d.ts +67 -0
  63. package/dist/esm/invocation.js +3 -0
  64. package/dist/esm/machine-lock.d.ts +2 -0
  65. package/dist/esm/machine-lock.js +1 -1
  66. package/dist/esm/output-channel.d.ts +23 -0
  67. package/dist/esm/output-channel.js +14 -0
  68. package/dist/esm/pass-manifest.d.ts +53 -0
  69. package/dist/esm/pass-manifest.js +89 -0
  70. package/dist/esm/presentation.d.ts +47 -0
  71. package/dist/esm/presentation.js +32 -0
  72. package/dist/esm/recording.d.ts +1 -1
  73. package/dist/esm/recording.js +2 -2
  74. package/dist/esm/remote-host-session.d.ts +62 -0
  75. package/dist/esm/remote-host-session.js +277 -0
  76. package/dist/esm/remote-probe-host.d.ts +22 -0
  77. package/dist/esm/remote-probe-host.js +50 -0
  78. package/dist/esm/remote-probe.d.ts +63 -0
  79. package/dist/esm/remote-probe.js +88 -0
  80. package/dist/esm/remote-run.d.ts +45 -65
  81. package/dist/esm/remote-run.js +128 -162
  82. package/dist/esm/remote-session.d.ts +40 -0
  83. package/dist/esm/remote-session.js +151 -0
  84. package/dist/esm/remote-snapshot.d.ts +21 -0
  85. package/dist/esm/remote-snapshot.js +80 -0
  86. package/dist/esm/replay-assembly.d.ts +2 -2
  87. package/dist/esm/replay-assembly.js +1 -1
  88. package/dist/esm/run-chain.d.ts +21 -0
  89. package/dist/esm/run-chain.js +108 -0
  90. package/dist/esm/runCli.d.ts +1 -9
  91. package/dist/esm/runCli.js +39 -1002
  92. package/dist/esm/runner.d.ts +1 -54
  93. package/dist/esm/runner.js +2 -125
  94. package/dist/esm/shell-syntax.d.ts +30 -0
  95. package/dist/esm/shell-syntax.js +120 -0
  96. package/dist/esm/steps.d.ts +0 -2
  97. package/dist/esm/steps.js +3 -119
  98. package/dist/esm/types.d.ts +5 -0
  99. package/dist/esm/user-config.d.ts +34 -0
  100. package/dist/esm/user-config.js +93 -0
  101. package/dist/esm/verbosity.d.ts +6 -0
  102. package/dist/esm/verbosity.js +7 -7
  103. package/dist/esm/verdict.d.ts +1 -1
  104. package/dist/esm/verdict.js +1 -1
  105. package/docs/check-cache.md +28 -12
  106. package/docs/delegate.md +103 -17
  107. package/docs/reporting.md +6 -1
  108. package/docs/scripts.md +2 -1
  109. package/package.json +4 -4
  110. /package/dist/esm/{prettier-default-config.d.ts → prettier-default.config.d.ts} +0 -0
  111. /package/dist/esm/{prettier-default-config.js → prettier-default.config.js} +0 -0
@@ -1,6 +1,6 @@
1
1
  /** @noformat -- @generated. Do not edit. Compiled by rdy. */
2
2
  /* eslint-disable */
3
- export const __readyupVersion = "0.41.0";
3
+ export const __readyupVersion = "0.42.0";
4
4
  import { createRequire as __rdyCreateRequire } from 'node:module';
5
5
  const require = __rdyCreateRequire(import.meta.url);
6
6
 
@@ -7270,7 +7270,7 @@ var require_public_api = __commonJS({
7270
7270
  }
7271
7271
  return doc;
7272
7272
  }
7273
- function parse3(src, reviver, options) {
7273
+ function parse4(src, reviver, options) {
7274
7274
  let _reviver = void 0;
7275
7275
  if (typeof reviver === "function") {
7276
7276
  _reviver = reviver;
@@ -7311,7 +7311,7 @@ var require_public_api = __commonJS({
7311
7311
  return value.toString(options);
7312
7312
  return new Document.Document(value, _replacer, options).toString(options);
7313
7313
  }
7314
- exports.parse = parse3;
7314
+ exports.parse = parse4;
7315
7315
  exports.parseAllDocuments = parseAllDocuments;
7316
7316
  exports.parseDocument = parseDocument;
7317
7317
  exports.stringify = stringify;
@@ -7371,9 +7371,16 @@ var require_dist = __commonJS({
7371
7371
  });
7372
7372
 
7373
7373
  // .readyup/kits/default.ts
7374
- import { execFile } from "node:child_process";
7375
- import { existsSync as existsSync3, globSync as globSync2, readdirSync as readdirSync2 } from "node:fs";
7376
- import { basename, dirname, join, posix, sep } from "node:path";
7374
+ import { defineRdyKit } from "readyup";
7375
+ import { hasDevDependency as hasDevDependency2 } from "readyup/check-utils";
7376
+
7377
+ // .readyup/lib/bin-targets.ts
7378
+ import { basename as basename2, posix } from "node:path";
7379
+ import { isRecord, listTrackedFiles } from "readyup/check-utils";
7380
+
7381
+ // .readyup/lib/repo-files.ts
7382
+ import { globSync } from "node:fs";
7383
+ import { basename, join, sep } from "node:path";
7377
7384
 
7378
7385
  // ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.8.3/node_modules/@williamthorsen/toolbelt.errors/dist/esm/4-release/isError.js
7379
7386
  function isError(error) {
@@ -7392,302 +7399,191 @@ function describeError(error) {
7392
7399
  }
7393
7400
  }
7394
7401
 
7395
- // .readyup/kits/default.ts
7396
- var import_yaml2 = __toESM(require_dist(), 1);
7397
- import { defineRdyKit } from "readyup";
7398
- import {
7399
- discoverWorkspaces,
7400
- fileContains,
7401
- fileExists,
7402
- hasDevDependency,
7403
- hasMinDevDependencyVersion,
7404
- hasPackageJsonField,
7405
- isRecord,
7406
- listTrackedFiles,
7407
- readFile,
7408
- readPackageJson
7409
- } from "readyup/check-utils";
7410
-
7411
- // src/config.ts
7412
- import { existsSync as existsSync2 } from "node:fs";
7413
- import { pathToFileURL } from "node:url";
7414
-
7415
- // src/helpers/config-path.ts
7416
- import path from "node:path";
7417
- var CONFIG_DIR = ".config";
7418
- var CONFIG_FILENAME = "nmr.config.ts";
7419
- var CONFIG_RELATIVE_PATH = path.join(CONFIG_DIR, CONFIG_FILENAME);
7420
- function resolveConfigPath(baseDir) {
7421
- return path.join(baseDir, CONFIG_DIR, CONFIG_FILENAME);
7402
+ // .readyup/lib/repo-files.ts
7403
+ import { discoverWorkspaces, readFile } from "readyup/check-utils";
7404
+ var SCAN_EXCLUDE_DIRS = /* @__PURE__ */ new Set([".git", "coverage", "dist", "node_modules"]);
7405
+ var CONFIG_EXTENSIONS = "{ts,mts,cts,js,mjs,cjs}";
7406
+ function checkNoMatchingFiles(patterns, cwd) {
7407
+ const foundFiles = findFiles(patterns, cwd);
7408
+ if (foundFiles.length === 0) return true;
7409
+ return { ok: false, detail: formatPaths(foundFiles) };
7422
7410
  }
7423
-
7424
- // src/helpers/escapeControlCharacters.ts
7425
- var NAMED_ESCAPES = /* @__PURE__ */ new Map([
7426
- ["\n", String.raw`\n`],
7427
- ["\r", String.raw`\r`],
7428
- [" ", String.raw`\t`]
7429
- ]);
7430
- function escapeControlCharacters(text) {
7431
- let escapedText = text;
7432
- for (const [char, escape] of NAMED_ESCAPES) {
7433
- escapedText = escapedText.split(char).join(escape);
7411
+ function discoverWorkspacesOrFailure(filter) {
7412
+ try {
7413
+ return { ok: true, workspaces: discoverWorkspaces(filter === void 0 ? void 0 : { filter }) };
7414
+ } catch (error) {
7415
+ return { ok: false, detail: `cannot enumerate workspaces: ${describeError(error)}` };
7434
7416
  }
7435
- return escapedText;
7417
+ }
7418
+ function findFiles(patterns, cwd) {
7419
+ return globSync(patterns, { cwd, exclude: (path7) => SCAN_EXCLUDE_DIRS.has(basename(path7)) }).map((path7) => path7.split(sep).join("/")).toSorted();
7420
+ }
7421
+ function formatPaths(paths) {
7422
+ return `${paths.length} found:
7423
+ ${paths.map((path7) => ` ${path7}`).join("\n")}`;
7424
+ }
7425
+ function importsSharedExport(content, exportName, moduleSpecifier) {
7426
+ if (content === void 0) return false;
7427
+ const pattern = new RegExp(String.raw`import\s*\{[^}]*\b${exportName}\b[^}]*\}\s*from\s*['"]${moduleSpecifier}['"]`);
7428
+ return pattern.test(content);
7429
+ }
7430
+ function readFileIn(cwd, relativePath) {
7431
+ return readFile(join(cwd, relativePath));
7436
7432
  }
7437
7433
 
7438
- // src/helpers/findClosestName.ts
7439
- function findClosestName(name, candidates) {
7440
- const ceiling = Math.max(1, Math.floor(name.length / 4));
7441
- let closestName;
7442
- let shortestDistance = Infinity;
7443
- for (const candidate of candidates) {
7444
- const distance = measureDistance(name, candidate);
7445
- if (distance >= shortestDistance) {
7446
- continue;
7447
- }
7448
- shortestDistance = distance;
7449
- closestName = candidate;
7434
+ // .readyup/lib/bin-targets.ts
7435
+ var BUILD_OUTPUT_DIR = "dist";
7436
+ var WRAPPER_TARGET_PATTERN = /['"](\.\.?\/[^'"]+)['"]/;
7437
+ var binTargetChecks = [
7438
+ {
7439
+ name: "every bin target is a committed wrapper",
7440
+ severity: "error",
7441
+ check: () => everyBinTargetIsACommittedWrapper(),
7442
+ fix: `Point each listed entry at a committed wrapper under bin/ that loads the build output at runtime. pnpm links a workspace package's bins during the install's link phase, which runs before anything is built, so a target that is not committed does not exist when pnpm tries to link it. pnpm never retries, which leaves the link missing for the life of the node_modules tree`
7443
+ },
7444
+ {
7445
+ name: "every bin wrapper's build-output target is covered by files",
7446
+ severity: "warn",
7447
+ check: () => everyBinWrapperTargetIsCoveredByFiles(),
7448
+ fix: "Add the build output directory to `files` in each listed package. npm and pnpm publish the bin target itself whatever `files` says, so the wrapper is published pointing at build output missing from the tarball"
7450
7449
  }
7451
- return shortestDistance <= ceiling ? closestName : void 0;
7450
+ ];
7451
+ async function everyBinTargetIsACommittedWrapper() {
7452
+ const workspaceDiscovery = discoverWorkspacesOrFailure((workspace) => !workspace.isRoot);
7453
+ if (!workspaceDiscovery.ok) return workspaceDiscovery;
7454
+ const trackedFiles = await listTrackedFiles();
7455
+ const trackedPaths = trackedFiles === void 0 ? void 0 : new Set(trackedFiles);
7456
+ const offenders = workspaceDiscovery.workspaces.flatMap(
7457
+ (workspace) => readBinEntries(workspace).flatMap((entry) => {
7458
+ const defect = describeBinTargetDefect(workspace, entry, trackedPaths);
7459
+ return defect === void 0 ? [] : `${describeBinEntry(workspace, entry)} (${defect})`;
7460
+ })
7461
+ );
7462
+ if (offenders.length === 0) return true;
7463
+ return { ok: false, detail: formatPaths(offenders) };
7452
7464
  }
7453
- function measureDistance(first, second) {
7454
- if (first.length === 0 || second.length === 0) {
7455
- return Math.max(first.length, second.length);
7456
- }
7457
- let previousRow = Array.from({ length: second.length + 1 }, (_unused, column) => column);
7458
- let currentRow = Array.from({ length: second.length + 1 }, () => 0);
7459
- for (let row = 1; row <= first.length; row += 1) {
7460
- currentRow[0] = row;
7461
- for (let column = 1; column <= second.length; column += 1) {
7462
- const substitution = readCell(previousRow, column - 1) + (first[row - 1] === second[column - 1] ? 0 : 1);
7463
- currentRow[column] = Math.min(
7464
- readCell(currentRow, column - 1) + 1,
7465
- readCell(previousRow, column) + 1,
7466
- substitution
7467
- );
7468
- }
7469
- const completedRow = currentRow;
7470
- currentRow = previousRow;
7471
- previousRow = completedRow;
7472
- }
7473
- return readCell(previousRow, second.length);
7465
+ function everyBinWrapperTargetIsCoveredByFiles() {
7466
+ const workspaceDiscovery = discoverWorkspacesOrFailure((workspace) => !workspace.isRoot);
7467
+ if (!workspaceDiscovery.ok) return workspaceDiscovery;
7468
+ const cwd = process.cwd();
7469
+ const offenders = workspaceDiscovery.workspaces.flatMap((workspace) => {
7470
+ const files = workspace.packageJson["files"];
7471
+ if (!Array.isArray(files)) return [];
7472
+ const publishedSegments = new Set(
7473
+ files.flatMap((entry) => typeof entry === "string" ? readFirstSegment(entry) : [])
7474
+ );
7475
+ return readBinEntries(workspace).flatMap((entry) => {
7476
+ const target = readWrapperTarget(cwd, workspace, entry);
7477
+ if (target === void 0 || publishedSegments.has(readFirstSegment(target))) return [];
7478
+ return `${describeBinEntry(workspace, entry)} -> ${target}`;
7479
+ });
7480
+ });
7481
+ if (offenders.length === 0) return true;
7482
+ return { ok: false, detail: formatPaths(offenders) };
7474
7483
  }
7475
- function readCell(row, index) {
7476
- const value = row[index];
7477
- if (value === void 0) {
7478
- throw new RangeError(`No cell at index ${index} in a row of ${row.length}`);
7479
- }
7480
- return value;
7484
+ function describeBinEntry(workspace, entry) {
7485
+ return `${workspace.name ?? workspace.dir}:${entry.command} -> ${entry.target}`;
7481
7486
  }
7482
-
7483
- // src/helpers/hook-name.ts
7484
- function isHookName(key) {
7485
- return key.length > 4 && key.endsWith(":pre") || key.length > 5 && key.endsWith(":post");
7487
+ function describeBinTargetDefect(workspace, entry, trackedPaths) {
7488
+ if (readFirstSegment(entry.target) === BUILD_OUTPUT_DIR) return `names a path under ${BUILD_OUTPUT_DIR}/`;
7489
+ if (trackedPaths === void 0) return void 0;
7490
+ return trackedPaths.has(`${workspace.dir}/${entry.target}`) ? void 0 : "untracked";
7486
7491
  }
7487
-
7488
- // src/helpers/type-guards.ts
7489
- function isObject(value) {
7490
- return typeof value === "object" && value !== null && !Array.isArray(value);
7492
+ function normalizeBinTarget(target) {
7493
+ return target.replace(/^\.\//, "");
7491
7494
  }
7492
- function isStringRecord(value) {
7493
- return isObject(value) && Object.values(value).every((entry) => typeof entry === "string");
7495
+ function readBinEntries(workspace) {
7496
+ const bin = workspace.packageJson["bin"];
7497
+ if (typeof bin === "string") {
7498
+ const command = workspace.name?.split("/").at(-1) ?? basename2(workspace.dir);
7499
+ return [{ command, target: normalizeBinTarget(bin) }];
7500
+ }
7501
+ if (!isRecord(bin)) return [];
7502
+ return Object.entries(bin).flatMap(
7503
+ ([command, target]) => typeof target === "string" ? { command, target: normalizeBinTarget(target) } : []
7504
+ );
7505
+ }
7506
+ function readFirstSegment(entry) {
7507
+ return normalizeBinTarget(entry).split("/", 1).at(0) ?? "";
7508
+ }
7509
+ function readWrapperTarget(cwd, workspace, entry) {
7510
+ if (readFirstSegment(entry.target) === BUILD_OUTPUT_DIR) return void 0;
7511
+ const content = readFileIn(cwd, `${workspace.dir}/${entry.target}`);
7512
+ if (content === void 0) return void 0;
7513
+ const specifier = WRAPPER_TARGET_PATTERN.exec(content)?.[1];
7514
+ if (specifier === void 0) return void 0;
7515
+ return posix.normalize(posix.join(posix.dirname(entry.target), specifier));
7494
7516
  }
7495
7517
 
7496
- // src/steps.ts
7518
+ // .readyup/lib/root-scripts.ts
7519
+ import { existsSync as existsSync2, readdirSync } from "node:fs";
7520
+ import { join as join2 } from "node:path";
7521
+ import { fileExists, isRecord as isRecord2, readFile as readFile2, readPackageJson } from "readyup/check-utils";
7522
+
7523
+ // src/shell-syntax.ts
7497
7524
  var ENV_ASSIGNMENT = /^\w+=/;
7498
- var LAUNCHERS = /* @__PURE__ */ new Map([
7499
- ["bun", /* @__PURE__ */ new Set(["x"])],
7500
- ["bunx", /* @__PURE__ */ new Set()],
7501
- ["npm", /* @__PURE__ */ new Set(["exec"])],
7502
- ["npx", /* @__PURE__ */ new Set()],
7503
- ["pnpm", /* @__PURE__ */ new Set(["dlx", "exec"])],
7504
- ["yarn", /* @__PURE__ */ new Set(["dlx", "exec"])]
7505
- ]);
7506
7525
  var QUOTES = /* @__PURE__ */ new Set(['"', "'"]);
7507
7526
  var SEGMENT_SEPARATORS = /* @__PURE__ */ new Set([";", "|", "&", "\n", "\r"]);
7508
7527
  var SHELL_SAFE_TOKEN = /^[\w@%+=:,./-]+$/;
7509
7528
  var TOKEN_SEPARATORS = /* @__PURE__ */ new Set([" ", " ", "\n", "\r"]);
7510
- function findUnexpressibleToken(element) {
7511
- return tokenize(element).find((token) => !SHELL_SAFE_TOKEN.test(token));
7529
+ function dropLeadingAssignments(tokens) {
7530
+ const start = tokens.findIndex((token) => !ENV_ASSIGNMENT.test(token));
7531
+ return start === -1 ? [] : tokens.slice(start);
7532
+ }
7533
+ function isShellSafeToken(token) {
7534
+ return SHELL_SAFE_TOKEN.test(token);
7512
7535
  }
7513
7536
  function quoteToken(token) {
7514
- if (SHELL_SAFE_TOKEN.test(token)) {
7537
+ if (isShellSafeToken(token)) {
7515
7538
  return token;
7516
7539
  }
7517
7540
  return "'" + token.replaceAll("'", String.raw`'\''`) + "'";
7518
7541
  }
7519
- function readSelfReference(options) {
7520
- const { commandName, scope, script } = options;
7521
- const segments = splitSegments(script).filter((segment) => segment.trim() !== "");
7522
- const doesReenter = segments.some((segment) => {
7523
- const invocation = readNmrTail(segment);
7524
- const target = invocation === void 0 ? void 0 : readNmrTarget(invocation.tail);
7525
- if (invocation === void 0 || target === void 0) {
7526
- return false;
7542
+ function splitSegments(command) {
7543
+ const segments = [];
7544
+ let currentSegment = "";
7545
+ let quote;
7546
+ let index = 0;
7547
+ while (index < command.length) {
7548
+ const char = command[index] ?? "";
7549
+ if (quote !== void 0) {
7550
+ if (char === "\\" && quote === '"') {
7551
+ currentSegment += char + (command[index + 1] ?? "");
7552
+ index += 2;
7553
+ continue;
7554
+ }
7555
+ currentSegment += char;
7556
+ if (char === quote) quote = void 0;
7557
+ index += 1;
7558
+ continue;
7527
7559
  }
7528
- const launcherScope = readLauncherScope(invocation.launcher, invocation.launcherArgs);
7529
- const effectiveTarget = {
7530
- command: target.command,
7531
- isDelegate: target.isDelegate || launcherScope.isDelegate,
7532
- isRecursive: target.isRecursive || launcherScope.isRecursive,
7533
- isWorkspaceRoot: target.isWorkspaceRoot || launcherScope.isWorkspaceRoot
7534
- };
7535
- return isReentry(effectiveTarget, commandName, scope);
7536
- });
7537
- if (!doesReenter) {
7538
- return void 0;
7560
+ if (char === "\\") {
7561
+ currentSegment += char + (command[index + 1] ?? "");
7562
+ index += 2;
7563
+ continue;
7564
+ }
7565
+ if (QUOTES.has(char)) {
7566
+ quote = char;
7567
+ currentSegment += char;
7568
+ index += 1;
7569
+ continue;
7570
+ }
7571
+ if (SEGMENT_SEPARATORS.has(char)) {
7572
+ if (isRedirectionOperator(char, currentSegment, command[index + 1])) {
7573
+ currentSegment += char;
7574
+ index += 1;
7575
+ continue;
7576
+ }
7577
+ index += command[index + 1] === char ? 2 : 1;
7578
+ segments.push(currentSegment);
7579
+ currentSegment = "";
7580
+ continue;
7581
+ }
7582
+ currentSegment += char;
7583
+ index += 1;
7539
7584
  }
7540
- return segments.length === 1 ? "sole" : "chained";
7541
- }
7542
- function readStepListSelfReference(options) {
7543
- const { commandName, elements, scope } = options;
7544
- const doesReenter = elements.some((element) => {
7545
- const target = readNmrTarget(tokenize(element));
7546
- return target !== void 0 && isReentry(target, commandName, scope);
7547
- });
7548
- if (!doesReenter) {
7549
- return void 0;
7550
- }
7551
- return elements.length === 1 ? "sole" : "chained";
7552
- }
7553
- function dropLeadingAssignments(tokens) {
7554
- const start = tokens.findIndex((token) => !ENV_ASSIGNMENT.test(token));
7555
- return start === -1 ? [] : tokens.slice(start);
7556
- }
7557
- function isRedirectionOperator(char, precedingText, nextChar) {
7558
- if (char !== "&" && char !== "|") {
7559
- return false;
7560
- }
7561
- return char === "&" && nextChar === ">" || /[<>]\s*$/.test(precedingText);
7562
- }
7563
- function isReentry(target, commandName, scope) {
7564
- if (target.command !== commandName) {
7565
- return false;
7566
- }
7567
- switch (scope) {
7568
- case "root":
7569
- return !target.isDelegate;
7570
- case "package":
7571
- return target.isRecursive || !target.isDelegate && !target.isWorkspaceRoot;
7572
- case "workspaceRegistry":
7573
- return target.isDelegate || !target.isWorkspaceRoot;
7574
- }
7575
- }
7576
- function readLauncherScope(launcher, launcherArgs) {
7577
- const scope = { isDelegate: false, isRecursive: false, isWorkspaceRoot: false };
7578
- if (launcher !== "pnpm") {
7579
- return scope;
7580
- }
7581
- for (const token of launcherArgs) {
7582
- if (token === "-r" || token === "--recursive") {
7583
- scope.isDelegate = true;
7584
- scope.isRecursive = true;
7585
- } else if (token === "-F" || token === "--filter" || token.startsWith("--filter=")) {
7586
- scope.isDelegate = true;
7587
- } else if (token === "-w" || token === "--workspace-root") {
7588
- scope.isWorkspaceRoot = true;
7589
- }
7590
- }
7591
- return scope;
7592
- }
7593
- function readNmrTail(segment) {
7594
- const [head, ...rest] = dropLeadingAssignments(tokenizeSegment(segment));
7595
- if (head === void 0) {
7596
- return void 0;
7597
- }
7598
- if (head === "nmr") {
7599
- return { launcher: void 0, launcherArgs: [], tail: rest };
7600
- }
7601
- const subcommands = LAUNCHERS.get(head);
7602
- if (subcommands === void 0) {
7603
- return void 0;
7604
- }
7605
- const nmrIndex = subcommands.size === 0 ? rest.findIndex((token) => !token.startsWith("-")) : rest.findIndex((token) => subcommands.has(token)) + 1;
7606
- if (nmrIndex <= 0 && subcommands.size > 0) {
7607
- return void 0;
7608
- }
7609
- return rest[nmrIndex] === "nmr" ? { launcher: head, launcherArgs: rest.slice(0, nmrIndex), tail: rest.slice(nmrIndex + 1) } : void 0;
7610
- }
7611
- function readNmrTarget(tokens) {
7612
- let isDelegate = false;
7613
- let isRecursive = false;
7614
- let isWorkspaceRoot = false;
7615
- let index = 0;
7616
- while (index < tokens.length) {
7617
- const token = tokens[index] ?? "";
7618
- switch (token) {
7619
- // The pattern is the flag's value, and reading it as a command name would name whichever package it
7620
- // selects rather than the command that every selected scope runs.
7621
- case "-F":
7622
- case "--filter":
7623
- isDelegate = true;
7624
- index += 2;
7625
- break;
7626
- case "-R":
7627
- case "--recursive":
7628
- isDelegate = true;
7629
- isRecursive = true;
7630
- index += 1;
7631
- break;
7632
- case "-w":
7633
- case "--workspace-root":
7634
- isWorkspaceRoot = true;
7635
- index += 1;
7636
- break;
7637
- default:
7638
- if (!token.startsWith("-")) {
7639
- return { command: token, isDelegate, isRecursive, isWorkspaceRoot };
7640
- }
7641
- index += 1;
7642
- }
7643
- }
7644
- return void 0;
7645
- }
7646
- function splitSegments(command) {
7647
- const segments = [];
7648
- let currentSegment = "";
7649
- let quote;
7650
- let index = 0;
7651
- while (index < command.length) {
7652
- const char = command[index] ?? "";
7653
- if (quote !== void 0) {
7654
- if (char === "\\" && quote === '"') {
7655
- currentSegment += char + (command[index + 1] ?? "");
7656
- index += 2;
7657
- continue;
7658
- }
7659
- currentSegment += char;
7660
- if (char === quote) quote = void 0;
7661
- index += 1;
7662
- continue;
7663
- }
7664
- if (char === "\\") {
7665
- currentSegment += char + (command[index + 1] ?? "");
7666
- index += 2;
7667
- continue;
7668
- }
7669
- if (QUOTES.has(char)) {
7670
- quote = char;
7671
- currentSegment += char;
7672
- index += 1;
7673
- continue;
7674
- }
7675
- if (SEGMENT_SEPARATORS.has(char)) {
7676
- if (isRedirectionOperator(char, currentSegment, command[index + 1])) {
7677
- currentSegment += char;
7678
- index += 1;
7679
- continue;
7680
- }
7681
- index += command[index + 1] === char ? 2 : 1;
7682
- segments.push(currentSegment);
7683
- currentSegment = "";
7684
- continue;
7685
- }
7686
- currentSegment += char;
7687
- index += 1;
7688
- }
7689
- segments.push(currentSegment);
7690
- return segments;
7585
+ segments.push(currentSegment);
7586
+ return segments;
7691
7587
  }
7692
7588
  function tokenize(element) {
7693
7589
  return element.split(/\s+/).filter((token) => token.length > 0);
@@ -7737,6 +7633,12 @@ function tokenizeSegment(segment) {
7737
7633
  }
7738
7634
  return tokens;
7739
7635
  }
7636
+ function isRedirectionOperator(char, precedingText, nextChar) {
7637
+ if (char !== "&" && char !== "|") {
7638
+ return false;
7639
+ }
7640
+ return char === "&" && nextChar === ">" || /[<>]\s*$/.test(precedingText);
7641
+ }
7740
7642
 
7741
7643
  // src/default-scripts.ts
7742
7644
  var GATE_PROJECTS = "--project unit --project tool";
@@ -7822,12 +7724,12 @@ function buildRootScripts(workspaceGlobs) {
7822
7724
 
7823
7725
  // ../nmr-core/dist/esm/workspace.js
7824
7726
  import { existsSync, readFileSync } from "node:fs";
7825
- import path3 from "node:path";
7727
+ import path2 from "node:path";
7826
7728
  var import_yaml = __toESM(require_dist(), 1);
7827
7729
 
7828
7730
  // ../nmr-core/dist/esm/helpers/workspace-patterns.js
7829
- import { globSync } from "node:fs";
7830
- import path2 from "node:path";
7731
+ import { globSync as globSync2 } from "node:fs";
7732
+ import path from "node:path";
7831
7733
  var MANIFEST = "package.json";
7832
7734
  var ALWAYS_EXCLUDED = ["**/node_modules/**"];
7833
7735
  function matchPackageDirs(monorepoRoot, includedPatterns, excludedPatterns) {
@@ -7836,8 +7738,8 @@ function matchPackageDirs(monorepoRoot, includedPatterns, excludedPatterns) {
7836
7738
  exclude: [...excludedPatterns, ...ALWAYS_EXCLUDED],
7837
7739
  followSymlinks: true
7838
7740
  };
7839
- const matches = globSync([...includedPatterns], options);
7840
- const dirs = matches.map((match) => path2.dirname(path2.resolve(monorepoRoot, match)));
7741
+ const matches = globSync2([...includedPatterns], options);
7742
+ const dirs = matches.map((match) => path.dirname(path.resolve(monorepoRoot, match)));
7841
7743
  return [...new Set(dirs)].toSorted();
7842
7744
  }
7843
7745
  function resolvePackageDirs(monorepoRoot, patterns) {
@@ -7866,7 +7768,7 @@ function buildManifestPattern(pattern) {
7866
7768
  // ../nmr-core/dist/esm/workspace.js
7867
7769
  var WORKSPACE_MANIFEST = "pnpm-workspace.yaml";
7868
7770
  function isMonorepoRoot(dir) {
7869
- return existsSync(path3.join(dir, WORKSPACE_MANIFEST));
7771
+ return existsSync(path2.join(dir, WORKSPACE_MANIFEST));
7870
7772
  }
7871
7773
  function resolveWorkspace(monorepoRoot) {
7872
7774
  const manifestRead = readWorkspaceManifest(monorepoRoot);
@@ -7899,7 +7801,7 @@ function diagnoseEmptyCause(monorepoRoot, patterns) {
7899
7801
  return "no-package";
7900
7802
  }
7901
7803
  function readDeclaredPackages(parsedManifest) {
7902
- if (!isObject2(parsedManifest))
7804
+ if (!isObject(parsedManifest))
7903
7805
  return { kind: "absent" };
7904
7806
  const packages = parsedManifest["packages"];
7905
7807
  if (packages === void 0 || packages === null)
@@ -7910,11 +7812,11 @@ function readDeclaredPackages(parsedManifest) {
7910
7812
  return { kind: "unreadable" };
7911
7813
  return packages.length === 0 ? { kind: "absent" } : { kind: "patterns", patterns: packages };
7912
7814
  }
7913
- function isObject2(value) {
7815
+ function isObject(value) {
7914
7816
  return typeof value === "object" && value !== null && !Array.isArray(value);
7915
7817
  }
7916
7818
  function readWorkspaceManifest(monorepoRoot) {
7917
- const workspaceFile = path3.join(monorepoRoot, WORKSPACE_MANIFEST);
7819
+ const workspaceFile = path2.join(monorepoRoot, WORKSPACE_MANIFEST);
7918
7820
  if (!existsSync(workspaceFile)) {
7919
7821
  return { kind: "absent" };
7920
7822
  }
@@ -7944,9 +7846,9 @@ function getWorkspacePackageDirs(monorepoRoot) {
7944
7846
  }
7945
7847
 
7946
7848
  // src/workspace-globs.ts
7947
- import path4 from "node:path";
7849
+ import path3 from "node:path";
7948
7850
  function toWorkspacePackageGlobs(monorepoRoot, packageDirs) {
7949
- return packageDirs.map((dir) => path4.relative(monorepoRoot, dir).split(path4.sep).join("/")).filter((relativeDir) => relativeDir !== "").map((relativeDir) => `${relativeDir}/**`).toSorted();
7851
+ return packageDirs.map((dir) => path3.relative(monorepoRoot, dir).split(path3.sep).join("/")).filter((relativeDir) => relativeDir !== "").map((relativeDir) => `${relativeDir}/**`).toSorted();
7950
7852
  }
7951
7853
 
7952
7854
  // src/resolve-scripts.ts
@@ -7959,233 +7861,399 @@ function getDefaultRootScripts(monorepoRoot) {
7959
7861
  return buildRootScripts(toWorkspacePackageGlobs(monorepoRoot, packageDirs));
7960
7862
  }
7961
7863
 
7962
- // src/resolver.ts
7963
- import { readFileSync as readFileSync2 } from "node:fs";
7964
-
7965
- // ../nmr-core/dist/esm/hasErrnoCode.js
7966
- function hasErrnoCode(error, code) {
7967
- return isError(error) && "code" in error && error.code === code;
7968
- }
7969
-
7970
- // src/helpers/package-json.ts
7971
- import path5 from "node:path";
7972
- function parsePackageJson(rawText, file) {
7973
- try {
7974
- return JSON.parse(rawText);
7975
- } catch (error) {
7976
- throw new UserError(`Invalid package.json at ${file}: ${describeError(error)}`);
7864
+ // .readyup/lib/root-scripts.ts
7865
+ var INSTALL_LIFECYCLE_SCRIPTS = [
7866
+ "pnpm:devPreinstall",
7867
+ "preinstall",
7868
+ "install",
7869
+ "postinstall",
7870
+ "preprepare",
7871
+ "prepare",
7872
+ "postprepare"
7873
+ ];
7874
+ var LEFTHOOK_CHECK_INSTALL_PATTERN = /\blefthook\s+check-install\b/;
7875
+ var LEFTHOOK_INSTALL_PATTERN = /\blefthook\s+install\b/;
7876
+ var rootScriptChecks = [
7877
+ {
7878
+ name: "root package.json has no nmr-provided scripts",
7879
+ severity: "warn",
7880
+ quiet: true,
7881
+ check: noRedundantRootScripts,
7882
+ fix: "Remove scripts from root package.json that nmr provides as built-in root scripts, and invoke them through nmr directly"
7977
7883
  }
7978
- }
7979
- function readScriptRecord(dir, scripts) {
7980
- const result = {};
7981
- for (const [key, value] of Object.entries(scripts)) {
7982
- if (typeof value !== "string") {
7983
- throw new UserError(formatMalformedScript(dir, key, value));
7984
- }
7985
- result[key] = value;
7884
+ ];
7885
+ var gitHookChecks = [
7886
+ {
7887
+ name: "no root install script runs lefthook install unguarded",
7888
+ severity: "warn",
7889
+ quiet: true,
7890
+ check: () => noUnguardedLefthookInstall(),
7891
+ fix: "Guard each listed script as `lefthook check-install || lefthook install`, which writes hooks only when they are missing or stale. A bare `lefthook install` rewrites `.git/hooks` on every install, so when that directory is not writable, as in an agent's sandbox, `pnpm install` fails and pnpm's `verifyDepsBeforeRun` then reinstalls before every `pnpm exec`"
7986
7892
  }
7987
- return result;
7988
- }
7989
- function resolvePackageJsonPath(dir) {
7990
- return path5.join(dir, "package.json");
7991
- }
7992
- function formatMalformedScript(dir, key, value) {
7993
- const rejection = `Invalid package.json at ${resolvePackageJsonPath(dir)}: \`scripts.${key}\` must be a string.`;
7994
- if (!Array.isArray(value)) {
7995
- return rejection;
7893
+ ];
7894
+ var legacyRunnerChecks = [
7895
+ {
7896
+ name: "scripts/run-workspace-script.ts does not exist",
7897
+ severity: "error",
7898
+ quiet: true,
7899
+ check: () => !fileExists("scripts/run-workspace-script.ts"),
7900
+ fix: "Delete scripts/run-workspace-script.ts, because nmr replaces this custom script runner"
7901
+ },
7902
+ {
7903
+ name: 'no workspace packages reference run-workspace-script or "pnpm run ws"',
7904
+ severity: "error",
7905
+ quiet: true,
7906
+ check: noWorkspaceRunScriptReferences,
7907
+ fix: 'Remove "ws" script entries and replace any "pnpm run ws" invocations with nmr in each packages/*/package.json'
7996
7908
  }
7997
- const field = isMonorepoRoot(dir) ? "rootScripts" : "workspaceScripts";
7998
- return `${rejection} A step list belongs in \`${CONFIG_RELATIVE_PATH}\` under \`${field}\`.`;
7909
+ ];
7910
+ function noUnguardedLefthookInstall(cwd = process.cwd()) {
7911
+ const scripts = readRootScripts(cwd);
7912
+ const unguardedScripts = INSTALL_LIFECYCLE_SCRIPTS.flatMap((name) => {
7913
+ const command = scripts[name];
7914
+ if (typeof command !== "string") return [];
7915
+ const isUnguarded = LEFTHOOK_INSTALL_PATTERN.test(command) && !LEFTHOOK_CHECK_INSTALL_PATTERN.test(command);
7916
+ return isUnguarded ? `${name}: ${command}` : [];
7917
+ });
7918
+ if (unguardedScripts.length === 0) return true;
7919
+ return { ok: false, detail: formatPaths(unguardedScripts) };
7999
7920
  }
8000
-
8001
- // src/resolver.ts
8002
- function describeScript(script) {
8003
- return typeof script === "string" ? script : `[${script.map(describeElement).join(", ")}]`;
7921
+ function noRedundantRootScripts() {
7922
+ const pkg = readPackageJson();
7923
+ if (!pkg) return true;
7924
+ const scripts = pkg["scripts"];
7925
+ if (!isRecord2(scripts)) return true;
7926
+ const builtInNames = Object.keys(getDefaultRootScripts(process.cwd()));
7927
+ const redundantNames = Object.keys(scripts).filter((name) => builtInNames.includes(name));
7928
+ if (redundantNames.length === 0) return true;
7929
+ return {
7930
+ ok: false,
7931
+ detail: `redundant: ${redundantNames.join(", ")}`
7932
+ };
8004
7933
  }
8005
- function describeElement(element) {
8006
- if (typeof element === "string") {
8007
- return element;
7934
+ function noWorkspaceRunScriptReferences() {
7935
+ const packagesDir = join2(process.cwd(), "packages");
7936
+ if (!existsSync2(packagesDir)) return true;
7937
+ const legacyPattern = /run-workspace-script|"pnpm\s+run\s+ws\b/;
7938
+ const entries = readdirSync(packagesDir, { withFileTypes: true });
7939
+ const matches = [];
7940
+ for (const entry of entries) {
7941
+ if (!entry.isDirectory()) continue;
7942
+ const content = readFile2(`packages/${entry.name}/package.json`);
7943
+ if (content && legacyPattern.test(content)) {
7944
+ matches.push(entry.name);
7945
+ }
8008
7946
  }
8009
- const notes = [
8010
- ...element.shouldRunInBackground === true ? ["background"] : [],
8011
- ...element.shouldDeclineArguments === true ? ["no args"] : []
8012
- ];
8013
- return notes.length === 0 ? element.run : `${element.run} (${notes.join(", ")})`;
7947
+ if (matches.length === 0) return true;
7948
+ return {
7949
+ ok: false,
7950
+ detail: `found in: ${matches.join(", ")}`
7951
+ };
8014
7952
  }
8015
- function readPackageJsonScripts(packageDir) {
8016
- const file = resolvePackageJsonPath(packageDir);
8017
- let rawText;
7953
+ function readRootScripts(cwd) {
7954
+ const content = readFileIn(cwd, "package.json");
7955
+ if (content === void 0) return {};
7956
+ let parsedManifest;
8018
7957
  try {
8019
- rawText = readFileSync2(file, "utf8");
8020
- } catch (error) {
8021
- if (hasErrnoCode(error, "ENOENT")) {
8022
- return void 0;
8023
- }
8024
- throw error;
7958
+ parsedManifest = JSON.parse(content);
7959
+ } catch {
7960
+ return {};
8025
7961
  }
8026
- const parsedManifest = parsePackageJson(rawText, file);
8027
- if (!isObject(parsedManifest)) return void 0;
7962
+ if (!isRecord2(parsedManifest)) return {};
8028
7963
  const scripts = parsedManifest["scripts"];
8029
- if (!isObject(scripts)) return void 0;
8030
- return readScriptRecord(packageDir, scripts);
7964
+ return isRecord2(scripts) ? scripts : {};
8031
7965
  }
8032
- function buildWorkspaceRegistry(config) {
8033
- return {
8034
- ...getDefaultWorkspaceScripts(),
8035
- ...config.workspaceScripts
8036
- };
7966
+
7967
+ // .readyup/lib/self-hosted-runner.ts
7968
+ import { isRecord as isRecord4 } from "readyup/check-utils";
7969
+
7970
+ // src/config.ts
7971
+ import { existsSync as existsSync3 } from "node:fs";
7972
+ import { pathToFileURL } from "node:url";
7973
+
7974
+ // src/helpers/findClosestName.ts
7975
+ function findClosestName(name, candidates) {
7976
+ const ceiling = Math.max(1, Math.floor(name.length / 4));
7977
+ let closestName;
7978
+ let shortestDistance = Infinity;
7979
+ for (const candidate of candidates) {
7980
+ const distance = measureDistance(name, candidate);
7981
+ if (distance >= shortestDistance) {
7982
+ continue;
7983
+ }
7984
+ shortestDistance = distance;
7985
+ closestName = candidate;
7986
+ }
7987
+ return shortestDistance <= ceiling ? closestName : void 0;
8037
7988
  }
8038
- function buildRootRegistry(config, monorepoRoot) {
8039
- return {
8040
- ...getDefaultRootScripts(monorepoRoot),
8041
- ...config.rootScripts
8042
- };
7989
+ function measureDistance(first, second) {
7990
+ if (first.length === 0 || second.length === 0) {
7991
+ return Math.max(first.length, second.length);
7992
+ }
7993
+ let previousRow = Array.from({ length: second.length + 1 }, (_unused, column) => column);
7994
+ let currentRow = Array.from({ length: second.length + 1 }, () => 0);
7995
+ for (let row = 1; row <= first.length; row += 1) {
7996
+ currentRow[0] = row;
7997
+ for (let column = 1; column <= second.length; column += 1) {
7998
+ const substitution = readCell(previousRow, column - 1) + (first[row - 1] === second[column - 1] ? 0 : 1);
7999
+ currentRow[column] = Math.min(
8000
+ readCell(currentRow, column - 1) + 1,
8001
+ readCell(previousRow, column) + 1,
8002
+ substitution
8003
+ );
8004
+ }
8005
+ const completedRow = currentRow;
8006
+ currentRow = previousRow;
8007
+ previousRow = completedRow;
8008
+ }
8009
+ return readCell(previousRow, second.length);
8010
+ }
8011
+ function readCell(row, index) {
8012
+ const value = row[index];
8013
+ if (value === void 0) {
8014
+ throw new RangeError(`No cell at index ${index} in a row of ${row.length}`);
8015
+ }
8016
+ return value;
8043
8017
  }
8044
8018
 
8045
- // src/verbosity.ts
8046
- var COMMAND_VERBOSITIES = ["full", "quiet"];
8047
- function formatVerbosityRejection(source, value) {
8048
- return `${source} is \`${value}\`, which is not one of: ${COMMAND_VERBOSITIES.join(", ")}`;
8019
+ // src/helpers/hook-name.ts
8020
+ function isHookName(key) {
8021
+ return key.length > 4 && key.endsWith(":pre") || key.length > 5 && key.endsWith(":post");
8049
8022
  }
8050
- function isCommandVerbosity(value) {
8051
- const names = COMMAND_VERBOSITIES;
8052
- return names.includes(value);
8023
+
8024
+ // src/resolver.ts
8025
+ import { readFileSync as readFileSync2 } from "node:fs";
8026
+
8027
+ // ../nmr-core/dist/esm/hasErrnoCode.js
8028
+ function hasErrnoCode(error, code) {
8029
+ return isError(error) && "code" in error && error.code === code;
8053
8030
  }
8054
8031
 
8055
- // src/config.ts
8056
- var CONFIG_TIERS = {
8057
- root: {
8058
- label: "a monorepo-root config",
8059
- honoredKeys: ["checkCache", "devBin", "output", "rootScripts", "selfHostedRunner", "workspaceScripts"],
8060
- elsewhere: "the package's own config"
8061
- },
8062
- workspace: {
8063
- label: "a package config",
8064
- honoredKeys: ["build"],
8065
- elsewhere: "the monorepo-root config"
8066
- }
8067
- };
8068
- var RECOGNIZED_KEYS = [...CONFIG_TIERS.root.honoredKeys, ...CONFIG_TIERS.workspace.honoredKeys];
8069
- var RECOGNIZED_BUILD_KEYS = ["extraIgnorePatterns"];
8070
- var RECOGNIZED_CHECK_CACHE_KEYS = ["enabled", "excludeCommands", "extraCommands"];
8071
- var RECOGNIZED_OUTPUT_KEYS = ["commandVerbosity", "extraAgentEnvVars"];
8072
- var RECOGNIZED_SELF_HOSTED_RUNNER_KEYS = ["exempt"];
8073
- var RECOGNIZED_STEP_KEYS = ["run", "shouldDeclineArguments", "shouldRunInBackground"];
8074
- var SCRIPT_FIELDS = [
8075
- { field: "rootScripts", scope: "root" },
8076
- { field: "workspaceScripts", scope: "workspaceRegistry" }
8077
- ];
8078
- var RETIRED_STEP_KEYS = /* @__PURE__ */ new Map([["declinesArgs", "shouldDeclineArguments"]]);
8079
- function isScriptRecord(value) {
8080
- if (!isObject(value)) return false;
8081
- for (const v of Object.values(value)) {
8082
- if (typeof v === "string") continue;
8083
- if (!Array.isArray(v) || !v.every(isScriptElement)) return false;
8084
- }
8085
- return true;
8032
+ // src/helpers/package-json.ts
8033
+ import path5 from "node:path";
8034
+
8035
+ // src/helpers/config-path.ts
8036
+ import path4 from "node:path";
8037
+ var CONFIG_DIR = ".config";
8038
+ var CONFIG_FILENAME = "nmr.config.ts";
8039
+ var CONFIG_RELATIVE_PATH = path4.join(CONFIG_DIR, CONFIG_FILENAME);
8040
+ function resolveConfigPath(baseDir) {
8041
+ return path4.join(baseDir, CONFIG_DIR, CONFIG_FILENAME);
8086
8042
  }
8087
- function isScriptElement(value) {
8088
- if (typeof value === "string") return true;
8089
- if (!isObject(value) || typeof value["run"] !== "string") return false;
8090
- return isOptionalBoolean(value["shouldDeclineArguments"]) && isOptionalBoolean(value["shouldRunInBackground"]);
8043
+
8044
+ // src/helpers/type-guards.ts
8045
+ function isObject2(value) {
8046
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8091
8047
  }
8092
- function validateScriptField(value, fieldName, configPath) {
8093
- if (!Object.hasOwn(value, fieldName) || value[fieldName] === void 0) {
8094
- return void 0;
8095
- }
8096
- const scripts = value[fieldName];
8097
- if (!isScriptRecord(scripts)) {
8098
- throw new UserError(
8099
- `Invalid nmr config at ${configPath}: \`${fieldName}\` must be a Record<string, string | element[]>, where an element is a command string or \`{ run: string, shouldDeclineArguments?: boolean, shouldRunInBackground?: boolean }\``
8100
- );
8048
+ function isStringRecord(value) {
8049
+ return isObject2(value) && Object.values(value).every((entry) => typeof entry === "string");
8050
+ }
8051
+
8052
+ // src/helpers/package-json.ts
8053
+ function parsePackageJson(rawText, file) {
8054
+ try {
8055
+ return JSON.parse(rawText);
8056
+ } catch (error) {
8057
+ throw new UserError(`Invalid package.json at ${file}: ${describeError(error)}`);
8101
8058
  }
8102
- assertValidElements(scripts, fieldName, configPath);
8103
- return scripts;
8104
8059
  }
8105
- function assertValidElements(scripts, fieldName, configPath) {
8106
- for (const [command, script] of Object.entries(scripts)) {
8107
- if (typeof script === "string") continue;
8108
- for (const spec of script) {
8109
- if (typeof spec !== "string") {
8110
- assertNoRetiredKeys(spec, RETIRED_STEP_KEYS, configPath, `${fieldName}.${command}.`);
8111
- assertRecognizedKeys(spec, RECOGNIZED_STEP_KEYS, configPath, `${fieldName}.${command}.`);
8112
- }
8113
- const element = typeof spec === "string" ? spec : spec.run;
8114
- const token = findUnexpressibleToken(element);
8115
- if (token === void 0) continue;
8116
- throw new UserError(
8117
- `Invalid nmr config at ${configPath}: \`${fieldName}.${command}\` element \`${element}\` contains \`${token}\`, which is neither a command name nor an nmr flag. Name it as a script of its own and reference that name here.`
8118
- );
8060
+ function readScriptRecord(dir, scripts) {
8061
+ const result = {};
8062
+ for (const [key, value] of Object.entries(scripts)) {
8063
+ if (typeof value !== "string") {
8064
+ throw new UserError(formatMalformedScript(dir, key, value));
8119
8065
  }
8066
+ result[key] = value;
8120
8067
  }
8068
+ return result;
8121
8069
  }
8122
- function isOptionalBoolean(value) {
8123
- return value === void 0 || typeof value === "boolean";
8070
+ function resolvePackageJsonPath(dir) {
8071
+ return path5.join(dir, "package.json");
8124
8072
  }
8125
- function isStringArray(value) {
8126
- return Array.isArray(value) && value.every((item) => typeof item === "string");
8073
+ function formatMalformedScript(dir, key, value) {
8074
+ const rejection = `Invalid package.json at ${resolvePackageJsonPath(dir)}: \`scripts.${key}\` must be a string.`;
8075
+ if (!Array.isArray(value)) {
8076
+ return rejection;
8077
+ }
8078
+ const field = isMonorepoRoot(dir) ? "rootScripts" : "workspaceScripts";
8079
+ return `${rejection} A step list belongs in \`${CONFIG_RELATIVE_PATH}\` under \`${field}\`.`;
8127
8080
  }
8128
- function validateBuildField(value, configPath) {
8129
- const build = value["build"];
8130
- if (build === void 0) {
8081
+
8082
+ // src/steps.ts
8083
+ var LAUNCHERS = /* @__PURE__ */ new Map([
8084
+ ["bun", /* @__PURE__ */ new Set(["x"])],
8085
+ ["bunx", /* @__PURE__ */ new Set()],
8086
+ ["npm", /* @__PURE__ */ new Set(["exec"])],
8087
+ ["npx", /* @__PURE__ */ new Set()],
8088
+ ["pnpm", /* @__PURE__ */ new Set(["dlx", "exec"])],
8089
+ ["yarn", /* @__PURE__ */ new Set(["dlx", "exec"])]
8090
+ ]);
8091
+ function findUnexpressibleToken(element) {
8092
+ return tokenize(element).find((token) => !isShellSafeToken(token));
8093
+ }
8094
+ function readSelfReference(options) {
8095
+ const { commandName, scope, script } = options;
8096
+ const segments = splitSegments(script).filter((segment) => segment.trim() !== "");
8097
+ const doesReenter = segments.some((segment) => {
8098
+ const invocation = readNmrTail(segment);
8099
+ const target = invocation === void 0 ? void 0 : readNmrTarget(invocation.tail);
8100
+ if (invocation === void 0 || target === void 0) {
8101
+ return false;
8102
+ }
8103
+ const launcherScope = readLauncherScope(invocation.launcher, invocation.launcherArgs);
8104
+ const effectiveTarget = {
8105
+ command: target.command,
8106
+ isDelegate: target.isDelegate || launcherScope.isDelegate,
8107
+ isRecursive: target.isRecursive || launcherScope.isRecursive,
8108
+ isWorkspaceRoot: target.isWorkspaceRoot || launcherScope.isWorkspaceRoot
8109
+ };
8110
+ return isReentry(effectiveTarget, commandName, scope);
8111
+ });
8112
+ if (!doesReenter) {
8131
8113
  return void 0;
8132
8114
  }
8133
- if (!isObject(build)) {
8134
- throw new UserError(`Invalid nmr config at ${configPath}: \`build\` must be an object`);
8135
- }
8136
- assertRecognizedKeys(build, RECOGNIZED_BUILD_KEYS, configPath, "build.");
8137
- const extraIgnorePatterns = build["extraIgnorePatterns"];
8138
- if (extraIgnorePatterns !== void 0 && !isStringArray(extraIgnorePatterns)) {
8139
- throw new UserError(`Invalid nmr config at ${configPath}: \`build.extraIgnorePatterns\` must be a string[]`);
8140
- }
8141
- return extraIgnorePatterns === void 0 ? {} : { extraIgnorePatterns };
8115
+ return segments.length === 1 ? "sole" : "chained";
8142
8116
  }
8143
- function validateCheckCacheField(value, configPath) {
8144
- const checkCache = value["checkCache"];
8145
- if (checkCache === void 0) {
8117
+ function readStepListSelfReference(options) {
8118
+ const { commandName, elements, scope } = options;
8119
+ const doesReenter = elements.some((element) => {
8120
+ const target = readNmrTarget(tokenize(element));
8121
+ return target !== void 0 && isReentry(target, commandName, scope);
8122
+ });
8123
+ if (!doesReenter) {
8146
8124
  return void 0;
8147
8125
  }
8148
- if (!isObject(checkCache)) {
8149
- throw new UserError(`Invalid nmr config at ${configPath}: \`checkCache\` must be an object`);
8150
- }
8151
- assertRecognizedKeys(checkCache, RECOGNIZED_CHECK_CACHE_KEYS, configPath, "checkCache.");
8152
- const config = {};
8153
- const enabledValue = checkCache["enabled"];
8154
- if (enabledValue !== void 0) {
8155
- if (typeof enabledValue !== "boolean") {
8156
- throw new UserError(`Invalid nmr config at ${configPath}: \`checkCache.enabled\` must be a boolean`);
8157
- }
8158
- config.enabled = enabledValue;
8126
+ return elements.length === 1 ? "sole" : "chained";
8127
+ }
8128
+ function isReentry(target, commandName, scope) {
8129
+ if (target.command !== commandName) {
8130
+ return false;
8159
8131
  }
8160
- for (const field of ["excludeCommands", "extraCommands"]) {
8161
- const commands = checkCache[field];
8162
- if (commands === void 0) {
8163
- continue;
8164
- }
8165
- if (!isStringArray(commands)) {
8166
- throw new UserError(`Invalid nmr config at ${configPath}: \`checkCache.${field}\` must be a string[]`);
8132
+ switch (scope) {
8133
+ case "root":
8134
+ return !target.isDelegate;
8135
+ case "package":
8136
+ return target.isRecursive || !target.isDelegate && !target.isWorkspaceRoot;
8137
+ case "workspaceRegistry":
8138
+ return target.isDelegate || !target.isWorkspaceRoot;
8139
+ }
8140
+ }
8141
+ function readLauncherScope(launcher, launcherArgs) {
8142
+ const scope = { isDelegate: false, isRecursive: false, isWorkspaceRoot: false };
8143
+ if (launcher !== "pnpm") {
8144
+ return scope;
8145
+ }
8146
+ for (const token of launcherArgs) {
8147
+ if (token === "-r" || token === "--recursive") {
8148
+ scope.isDelegate = true;
8149
+ scope.isRecursive = true;
8150
+ } else if (token === "-F" || token === "--filter" || token.startsWith("--filter=")) {
8151
+ scope.isDelegate = true;
8152
+ } else if (token === "-w" || token === "--workspace-root") {
8153
+ scope.isWorkspaceRoot = true;
8167
8154
  }
8168
- config[field] = commands;
8169
8155
  }
8170
- return config;
8156
+ return scope;
8171
8157
  }
8172
- function validateSelfHostedRunnerField(value, configPath) {
8173
- const selfHostedRunner = value["selfHostedRunner"];
8174
- if (selfHostedRunner === void 0) {
8158
+ function readNmrTail(segment) {
8159
+ const [head, ...rest] = dropLeadingAssignments(tokenizeSegment(segment));
8160
+ if (head === void 0) {
8175
8161
  return void 0;
8176
8162
  }
8177
- if (!isObject(selfHostedRunner)) {
8178
- throw new UserError(`Invalid nmr config at ${configPath}: \`selfHostedRunner\` must be an object`);
8163
+ if (head === "nmr") {
8164
+ return { launcher: void 0, launcherArgs: [], tail: rest };
8179
8165
  }
8180
- assertRecognizedKeys(selfHostedRunner, RECOGNIZED_SELF_HOSTED_RUNNER_KEYS, configPath, "selfHostedRunner.");
8181
- const exempt = selfHostedRunner["exempt"];
8182
- if (typeof exempt !== "string" || exempt.trim() === "") {
8183
- throw new UserError(
8184
- `Invalid nmr config at ${configPath}: \`selfHostedRunner.exempt\` must be a non-empty string giving the reason`
8185
- );
8166
+ const subcommands = LAUNCHERS.get(head);
8167
+ if (subcommands === void 0) {
8168
+ return void 0;
8186
8169
  }
8187
- return { exempt };
8170
+ const nmrIndex = subcommands.size === 0 ? rest.findIndex((token) => !token.startsWith("-")) : rest.findIndex((token) => subcommands.has(token)) + 1;
8171
+ if (nmrIndex <= 0 && subcommands.size > 0) {
8172
+ return void 0;
8173
+ }
8174
+ return rest[nmrIndex] === "nmr" ? { launcher: head, launcherArgs: rest.slice(0, nmrIndex), tail: rest.slice(nmrIndex + 1) } : void 0;
8175
+ }
8176
+ function readNmrTarget(tokens) {
8177
+ let isDelegate = false;
8178
+ let isRecursive = false;
8179
+ let isWorkspaceRoot = false;
8180
+ let index = 0;
8181
+ while (index < tokens.length) {
8182
+ const token = tokens[index] ?? "";
8183
+ switch (token) {
8184
+ // The pattern is the flag's value, and reading it as a command name would name whichever package it
8185
+ // selects rather than the command that every selected scope runs.
8186
+ case "-F":
8187
+ case "--filter":
8188
+ isDelegate = true;
8189
+ index += 2;
8190
+ break;
8191
+ case "-R":
8192
+ case "--recursive":
8193
+ isDelegate = true;
8194
+ isRecursive = true;
8195
+ index += 1;
8196
+ break;
8197
+ case "-w":
8198
+ case "--workspace-root":
8199
+ isWorkspaceRoot = true;
8200
+ index += 1;
8201
+ break;
8202
+ default:
8203
+ if (!token.startsWith("-")) {
8204
+ return { command: token, isDelegate, isRecursive, isWorkspaceRoot };
8205
+ }
8206
+ index += 1;
8207
+ }
8208
+ }
8209
+ return void 0;
8210
+ }
8211
+
8212
+ // src/resolver.ts
8213
+ function describeScript(script) {
8214
+ return typeof script === "string" ? script : `[${script.map(describeElement).join(", ")}]`;
8215
+ }
8216
+ function describeElement(element) {
8217
+ if (typeof element === "string") {
8218
+ return element;
8219
+ }
8220
+ const notes = [
8221
+ ...element.shouldRunInBackground === true ? ["background"] : [],
8222
+ ...element.shouldDeclineArguments === true ? ["no args"] : []
8223
+ ];
8224
+ return notes.length === 0 ? element.run : `${element.run} (${notes.join(", ")})`;
8225
+ }
8226
+ function readPackageJsonScripts(packageDir) {
8227
+ const file = resolvePackageJsonPath(packageDir);
8228
+ let rawText;
8229
+ try {
8230
+ rawText = readFileSync2(file, "utf8");
8231
+ } catch (error) {
8232
+ if (hasErrnoCode(error, "ENOENT")) {
8233
+ return void 0;
8234
+ }
8235
+ throw error;
8236
+ }
8237
+ const parsedManifest = parsePackageJson(rawText, file);
8238
+ if (!isObject2(parsedManifest)) return void 0;
8239
+ const scripts = parsedManifest["scripts"];
8240
+ if (!isObject2(scripts)) return void 0;
8241
+ return readScriptRecord(packageDir, scripts);
8242
+ }
8243
+ function buildWorkspaceRegistry(config) {
8244
+ return {
8245
+ ...getDefaultWorkspaceScripts(),
8246
+ ...config.workspaceScripts
8247
+ };
8248
+ }
8249
+ function buildRootRegistry(config, monorepoRoot) {
8250
+ return {
8251
+ ...getDefaultRootScripts(monorepoRoot),
8252
+ ...config.rootScripts
8253
+ };
8188
8254
  }
8255
+
8256
+ // src/config-check-cache.ts
8189
8257
  function assertResolvableCheckCacheCommands(config, configPath, baseDir) {
8190
8258
  const { checkCache } = config;
8191
8259
  if (checkCache === void 0 || !isMonorepoRoot(baseDir)) {
@@ -8244,46 +8312,48 @@ function readPackageScripts(packageDir) {
8244
8312
  throw error;
8245
8313
  }
8246
8314
  }
8247
- function validateOutputField(value, configPath) {
8248
- const output = value["output"];
8249
- if (output === void 0) {
8250
- return void 0;
8251
- }
8252
- if (!isObject(output)) {
8253
- throw new UserError(`Invalid nmr config at ${configPath}: \`output\` must be an object`);
8254
- }
8255
- assertRecognizedKeys(output, RECOGNIZED_OUTPUT_KEYS, configPath, "output.");
8256
- const config = {};
8257
- const commandVerbosity = output["commandVerbosity"];
8258
- if (commandVerbosity !== void 0) {
8259
- if (typeof commandVerbosity !== "string" || !isCommandVerbosity(commandVerbosity)) {
8260
- const renderedValue = typeof commandVerbosity === "string" ? commandVerbosity : JSON.stringify(commandVerbosity);
8261
- throw new UserError(
8262
- `Invalid nmr config at ${configPath}: ${formatVerbosityRejection("`output.commandVerbosity`", renderedValue)}`
8263
- );
8264
- }
8265
- config.commandVerbosity = commandVerbosity;
8266
- }
8267
- const extraAgentEnvVars = output["extraAgentEnvVars"];
8268
- if (extraAgentEnvVars !== void 0) {
8269
- if (!isStringArray(extraAgentEnvVars)) {
8270
- throw new UserError(`Invalid nmr config at ${configPath}: \`output.extraAgentEnvVars\` must be a string[]`);
8271
- }
8272
- config.extraAgentEnvVars = extraAgentEnvVars;
8273
- }
8274
- return config;
8315
+
8316
+ // src/verbosity.ts
8317
+ var COMMAND_VERBOSITIES = ["full", "quiet"];
8318
+ function formatVerbosityRejection(source, value) {
8319
+ return `${source} is \`${value}\`, which is not one of: ${COMMAND_VERBOSITIES.join(", ")}`;
8275
8320
  }
8276
- function validateStringRecordField(value, fieldName, configPath) {
8277
- if (!Object.hasOwn(value, fieldName) || value[fieldName] === void 0) {
8278
- return void 0;
8321
+ function isCommandVerbosity(value) {
8322
+ const names = COMMAND_VERBOSITIES;
8323
+ return names.includes(value);
8324
+ }
8325
+
8326
+ // src/config-validation.ts
8327
+ var CONFIG_TIERS = {
8328
+ root: {
8329
+ label: "a monorepo-root config",
8330
+ honoredKeys: ["checkCache", "delegate", "devBin", "output", "rootScripts", "selfHostedRunner", "workspaceScripts"],
8331
+ elsewhere: "the package's own config"
8332
+ },
8333
+ workspace: {
8334
+ label: "a package config",
8335
+ honoredKeys: ["build"],
8336
+ elsewhere: "the monorepo-root config"
8279
8337
  }
8280
- if (!isStringRecord(value[fieldName])) {
8281
- throw new UserError(`Invalid nmr config at ${configPath}: \`${fieldName}\` must be a Record<string, string>`);
8338
+ };
8339
+ var RECOGNIZED_KEYS = [...CONFIG_TIERS.root.honoredKeys, ...CONFIG_TIERS.workspace.honoredKeys];
8340
+ var RECOGNIZED_BUILD_KEYS = ["extraIgnorePatterns"];
8341
+ var RECOGNIZED_CHECK_CACHE_KEYS = ["enabled", "excludeCommands", "extraCommands"];
8342
+ var RECOGNIZED_OUTPUT_KEYS = ["commandVerbosity", "extraAgentEnvVars"];
8343
+ var RECOGNIZED_SELF_HOSTED_RUNNER_KEYS = ["exempt"];
8344
+ var RECOGNIZED_STEP_KEYS = ["run", "shouldDeclineArguments", "shouldRunInBackground"];
8345
+ var RETIRED_STEP_KEYS = /* @__PURE__ */ new Map([["declinesArgs", "shouldDeclineArguments"]]);
8346
+ function assertRecognizedKeys(value, recognizedKeys, configPath, prefix = "") {
8347
+ const unrecognizedKeys = Object.keys(value).filter((key) => !recognizedKeys.includes(key));
8348
+ if (unrecognizedKeys.length === 0) {
8349
+ return;
8282
8350
  }
8283
- return value[fieldName];
8351
+ throw new UserError(
8352
+ `Invalid nmr config at ${configPath}: unrecognized ${unrecognizedKeys.length === 1 ? "key" : "keys"} ${formatKeyList(unrecognizedKeys, prefix)}. Recognized: ${formatKeyList(recognizedKeys, prefix)}.`
8353
+ );
8284
8354
  }
8285
8355
  function validateConfig(value, configPath, baseDir) {
8286
- if (!isObject(value)) {
8356
+ if (!isObject2(value)) {
8287
8357
  throw new UserError(`Invalid nmr config at ${configPath}: expected an object, got ${typeof value}`);
8288
8358
  }
8289
8359
  assertRecognizedKeys(value, RECOGNIZED_KEYS, configPath);
@@ -8292,6 +8362,7 @@ function validateConfig(value, configPath, baseDir) {
8292
8362
  if (build) config.build = build;
8293
8363
  const checkCache = validateCheckCacheField(value, configPath);
8294
8364
  if (checkCache) config.checkCache = checkCache;
8365
+ if (value["delegate"] !== void 0) config.delegate = validateDelegateField(value["delegate"], configPath);
8295
8366
  const devBin = validateStringRecordField(value, "devBin", configPath);
8296
8367
  if (devBin) config.devBin = devBin;
8297
8368
  const output = validateOutputField(value, configPath);
@@ -8305,22 +8376,6 @@ function validateConfig(value, configPath, baseDir) {
8305
8376
  assertResolvableCheckCacheCommands(config, configPath, baseDir);
8306
8377
  return config;
8307
8378
  }
8308
- async function loadConfig(baseDir) {
8309
- const configPath = resolveConfigPath(baseDir);
8310
- if (!existsSync2(configPath)) {
8311
- return {};
8312
- }
8313
- const importedModule = await import(pathToFileURL(configPath).href);
8314
- const loadedConfig = isObject(importedModule) ? importedModule["default"] : void 0;
8315
- return validateConfig(loadedConfig, configPath, baseDir);
8316
- }
8317
- async function loadRootConfig(monorepoRoot) {
8318
- const config = await loadConfig(monorepoRoot);
8319
- const configPath = resolveConfigPath(monorepoRoot);
8320
- assertTierKeys(config, CONFIG_TIERS.root, configPath);
8321
- assertNoSelfReferences(config, configPath, monorepoRoot);
8322
- return config;
8323
- }
8324
8379
  function assertNoRetiredKeys(value, retiredKeys, configPath, prefix) {
8325
8380
  for (const key of Object.keys(value)) {
8326
8381
  const replacement = retiredKeys.get(key);
@@ -8330,48 +8385,231 @@ function assertNoRetiredKeys(value, retiredKeys, configPath, prefix) {
8330
8385
  );
8331
8386
  }
8332
8387
  }
8333
- function assertNoSelfReferences(config, configPath, monorepoRoot) {
8334
- for (const { field, scope } of SCRIPT_FIELDS) {
8335
- const scripts = config[field] ?? {};
8336
- for (const [key, script] of Object.entries(scripts)) {
8337
- const selfReference = typeof script === "string" ? readSelfReference({ commandName: key, scope, script }) : readStepListSelfReference({
8338
- commandName: key,
8339
- elements: script.map((element) => typeof element === "string" ? element : element.run),
8340
- scope
8341
- });
8342
- if (selfReference === void 0) continue;
8343
- const defaults = field === "rootScripts" ? getDefaultRootScripts(monorepoRoot) : getDefaultWorkspaceScripts();
8344
- const remedy = formatSelfReferenceRemedy({
8345
- isDefault: !isHookName(key) && Object.hasOwn(defaults, key),
8346
- key,
8347
- selfReference
8348
- });
8388
+ function assertValidElements(scripts, fieldName, configPath) {
8389
+ for (const [command, script] of Object.entries(scripts)) {
8390
+ if (typeof script === "string") continue;
8391
+ for (const spec of script) {
8392
+ if (typeof spec !== "string") {
8393
+ assertNoRetiredKeys(spec, RETIRED_STEP_KEYS, configPath, `${fieldName}.${command}.`);
8394
+ assertRecognizedKeys(spec, RECOGNIZED_STEP_KEYS, configPath, `${fieldName}.${command}.`);
8395
+ }
8396
+ const element = typeof spec === "string" ? spec : spec.run;
8397
+ const token = findUnexpressibleToken(element);
8398
+ if (token === void 0) continue;
8349
8399
  throw new UserError(
8350
- `Invalid nmr config at ${configPath}: \`${field}.${key}\` re-invokes \`${key}\` (\`${escapeControlCharacters(describeScript(script))}\`), which would re-enter nmr without bound. ${remedy}`
8400
+ `Invalid nmr config at ${configPath}: \`${fieldName}.${command}\` element \`${element}\` contains \`${token}\`, which is neither a command name nor an nmr flag. Name it as a script of its own and reference that name here.`
8351
8401
  );
8352
8402
  }
8353
8403
  }
8354
8404
  }
8355
- function assertRecognizedKeys(value, recognizedKeys, configPath, prefix = "") {
8356
- const unrecognizedKeys = Object.keys(value).filter((key) => !recognizedKeys.includes(key));
8357
- if (unrecognizedKeys.length === 0) {
8358
- return;
8359
- }
8360
- throw new UserError(
8361
- `Invalid nmr config at ${configPath}: unrecognized ${unrecognizedKeys.length === 1 ? "key" : "keys"} ${formatKeyList(unrecognizedKeys, prefix)}. Recognized: ${formatKeyList(recognizedKeys, prefix)}.`
8362
- );
8405
+ function formatKeyList(keys, prefix) {
8406
+ return keys.toSorted().map((key) => `\`${prefix}${key}\``).join(", ");
8363
8407
  }
8364
- function assertTierKeys(config, tier, configPath) {
8365
- const unsupportedKeys = Object.keys(config).filter((key) => !tier.honoredKeys.includes(key));
8366
- if (unsupportedKeys.length === 0) {
8367
- return;
8368
- }
8369
- throw new UserError(
8370
- `Invalid nmr config at ${configPath}: ${tier.label} honors ${tier.honoredKeys.toSorted().join(", ")} alone, not ${unsupportedKeys.toSorted().join(", ")}. Move those keys to ${tier.elsewhere}.`
8408
+ function isOptionalBoolean(value) {
8409
+ return value === void 0 || typeof value === "boolean";
8410
+ }
8411
+ function isScriptRecord(value) {
8412
+ if (!isObject2(value)) return false;
8413
+ for (const v of Object.values(value)) {
8414
+ if (typeof v === "string") continue;
8415
+ if (!Array.isArray(v) || !v.every(isScriptElement)) return false;
8416
+ }
8417
+ return true;
8418
+ }
8419
+ function isScriptElement(value) {
8420
+ if (typeof value === "string") return true;
8421
+ if (!isObject2(value) || typeof value["run"] !== "string") return false;
8422
+ return isOptionalBoolean(value["shouldDeclineArguments"]) && isOptionalBoolean(value["shouldRunInBackground"]);
8423
+ }
8424
+ function isStringArray(value) {
8425
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
8426
+ }
8427
+ function validateBuildField(value, configPath) {
8428
+ const build = value["build"];
8429
+ if (build === void 0) {
8430
+ return void 0;
8431
+ }
8432
+ if (!isObject2(build)) {
8433
+ throw new UserError(`Invalid nmr config at ${configPath}: \`build\` must be an object`);
8434
+ }
8435
+ assertRecognizedKeys(build, RECOGNIZED_BUILD_KEYS, configPath, "build.");
8436
+ const extraIgnorePatterns = build["extraIgnorePatterns"];
8437
+ if (extraIgnorePatterns !== void 0 && !isStringArray(extraIgnorePatterns)) {
8438
+ throw new UserError(`Invalid nmr config at ${configPath}: \`build.extraIgnorePatterns\` must be a string[]`);
8439
+ }
8440
+ return extraIgnorePatterns === void 0 ? {} : { extraIgnorePatterns };
8441
+ }
8442
+ function validateCheckCacheField(value, configPath) {
8443
+ const checkCache = value["checkCache"];
8444
+ if (checkCache === void 0) {
8445
+ return void 0;
8446
+ }
8447
+ if (!isObject2(checkCache)) {
8448
+ throw new UserError(`Invalid nmr config at ${configPath}: \`checkCache\` must be an object`);
8449
+ }
8450
+ assertRecognizedKeys(checkCache, RECOGNIZED_CHECK_CACHE_KEYS, configPath, "checkCache.");
8451
+ const config = {};
8452
+ const enabledValue = checkCache["enabled"];
8453
+ if (enabledValue !== void 0) {
8454
+ if (typeof enabledValue !== "boolean") {
8455
+ throw new UserError(`Invalid nmr config at ${configPath}: \`checkCache.enabled\` must be a boolean`);
8456
+ }
8457
+ config.enabled = enabledValue;
8458
+ }
8459
+ for (const field of ["excludeCommands", "extraCommands"]) {
8460
+ const commands = checkCache[field];
8461
+ if (commands === void 0) {
8462
+ continue;
8463
+ }
8464
+ if (!isStringArray(commands)) {
8465
+ throw new UserError(`Invalid nmr config at ${configPath}: \`checkCache.${field}\` must be a string[]`);
8466
+ }
8467
+ config[field] = commands;
8468
+ }
8469
+ return config;
8470
+ }
8471
+ function validateDelegateField(delegate, configPath) {
8472
+ if (delegate === false) {
8473
+ return false;
8474
+ }
8475
+ throw new UserError(
8476
+ `Invalid nmr config at ${configPath}: \`delegate\` accepts only \`false\`, which keeps the repo's checks on the machine that has the tree. Hosts and \`when\` belong in the user config, \`~/.config/nmr/config.ts\`.`
8371
8477
  );
8372
8478
  }
8373
- function formatKeyList(keys, prefix) {
8374
- return keys.toSorted().map((key) => `\`${prefix}${key}\``).join(", ");
8479
+ function validateOutputField(value, configPath) {
8480
+ const output = value["output"];
8481
+ if (output === void 0) {
8482
+ return void 0;
8483
+ }
8484
+ if (!isObject2(output)) {
8485
+ throw new UserError(`Invalid nmr config at ${configPath}: \`output\` must be an object`);
8486
+ }
8487
+ assertRecognizedKeys(output, RECOGNIZED_OUTPUT_KEYS, configPath, "output.");
8488
+ const config = {};
8489
+ const commandVerbosity = output["commandVerbosity"];
8490
+ if (commandVerbosity !== void 0) {
8491
+ if (typeof commandVerbosity !== "string" || !isCommandVerbosity(commandVerbosity)) {
8492
+ const renderedValue = typeof commandVerbosity === "string" ? commandVerbosity : JSON.stringify(commandVerbosity);
8493
+ throw new UserError(
8494
+ `Invalid nmr config at ${configPath}: ${formatVerbosityRejection("`output.commandVerbosity`", renderedValue)}`
8495
+ );
8496
+ }
8497
+ config.commandVerbosity = commandVerbosity;
8498
+ }
8499
+ const extraAgentEnvVars = output["extraAgentEnvVars"];
8500
+ if (extraAgentEnvVars !== void 0) {
8501
+ if (!isStringArray(extraAgentEnvVars)) {
8502
+ throw new UserError(`Invalid nmr config at ${configPath}: \`output.extraAgentEnvVars\` must be a string[]`);
8503
+ }
8504
+ config.extraAgentEnvVars = extraAgentEnvVars;
8505
+ }
8506
+ return config;
8507
+ }
8508
+ function validateSelfHostedRunnerField(value, configPath) {
8509
+ const selfHostedRunner = value["selfHostedRunner"];
8510
+ if (selfHostedRunner === void 0) {
8511
+ return void 0;
8512
+ }
8513
+ if (!isObject2(selfHostedRunner)) {
8514
+ throw new UserError(`Invalid nmr config at ${configPath}: \`selfHostedRunner\` must be an object`);
8515
+ }
8516
+ assertRecognizedKeys(selfHostedRunner, RECOGNIZED_SELF_HOSTED_RUNNER_KEYS, configPath, "selfHostedRunner.");
8517
+ const exempt = selfHostedRunner["exempt"];
8518
+ if (typeof exempt !== "string" || exempt.trim() === "") {
8519
+ throw new UserError(
8520
+ `Invalid nmr config at ${configPath}: \`selfHostedRunner.exempt\` must be a non-empty string giving the reason`
8521
+ );
8522
+ }
8523
+ return { exempt };
8524
+ }
8525
+ function validateScriptField(value, fieldName, configPath) {
8526
+ if (!Object.hasOwn(value, fieldName) || value[fieldName] === void 0) {
8527
+ return void 0;
8528
+ }
8529
+ const scripts = value[fieldName];
8530
+ if (!isScriptRecord(scripts)) {
8531
+ throw new UserError(
8532
+ `Invalid nmr config at ${configPath}: \`${fieldName}\` must be a Record<string, string | element[]>, where an element is a command string or \`{ run: string, shouldDeclineArguments?: boolean, shouldRunInBackground?: boolean }\``
8533
+ );
8534
+ }
8535
+ assertValidElements(scripts, fieldName, configPath);
8536
+ return scripts;
8537
+ }
8538
+ function validateStringRecordField(value, fieldName, configPath) {
8539
+ if (!Object.hasOwn(value, fieldName) || value[fieldName] === void 0) {
8540
+ return void 0;
8541
+ }
8542
+ if (!isStringRecord(value[fieldName])) {
8543
+ throw new UserError(`Invalid nmr config at ${configPath}: \`${fieldName}\` must be a Record<string, string>`);
8544
+ }
8545
+ return value[fieldName];
8546
+ }
8547
+
8548
+ // src/helpers/escapeControlCharacters.ts
8549
+ var NAMED_ESCAPES = /* @__PURE__ */ new Map([
8550
+ ["\n", String.raw`\n`],
8551
+ ["\r", String.raw`\r`],
8552
+ [" ", String.raw`\t`]
8553
+ ]);
8554
+ function escapeControlCharacters(text) {
8555
+ let escapedText = text;
8556
+ for (const [char, escape] of NAMED_ESCAPES) {
8557
+ escapedText = escapedText.split(char).join(escape);
8558
+ }
8559
+ return escapedText;
8560
+ }
8561
+
8562
+ // src/config.ts
8563
+ var SCRIPT_FIELDS = [
8564
+ { field: "rootScripts", scope: "root" },
8565
+ { field: "workspaceScripts", scope: "workspaceRegistry" }
8566
+ ];
8567
+ async function loadConfig(baseDir) {
8568
+ const configPath = resolveConfigPath(baseDir);
8569
+ if (!existsSync3(configPath)) {
8570
+ return {};
8571
+ }
8572
+ const importedModule = await import(pathToFileURL(configPath).href);
8573
+ const loadedConfig = isObject2(importedModule) ? importedModule["default"] : void 0;
8574
+ return validateConfig(loadedConfig, configPath, baseDir);
8575
+ }
8576
+ async function loadRootConfig(monorepoRoot) {
8577
+ const config = await loadConfig(monorepoRoot);
8578
+ const configPath = resolveConfigPath(monorepoRoot);
8579
+ assertTierKeys(config, CONFIG_TIERS.root, configPath);
8580
+ assertNoSelfReferences(config, configPath, monorepoRoot);
8581
+ return config;
8582
+ }
8583
+ function assertNoSelfReferences(config, configPath, monorepoRoot) {
8584
+ for (const { field, scope } of SCRIPT_FIELDS) {
8585
+ const scripts = config[field] ?? {};
8586
+ for (const [key, script] of Object.entries(scripts)) {
8587
+ const selfReference = typeof script === "string" ? readSelfReference({ commandName: key, scope, script }) : readStepListSelfReference({
8588
+ commandName: key,
8589
+ elements: script.map((element) => typeof element === "string" ? element : element.run),
8590
+ scope
8591
+ });
8592
+ if (selfReference === void 0) continue;
8593
+ const defaults = field === "rootScripts" ? getDefaultRootScripts(monorepoRoot) : getDefaultWorkspaceScripts();
8594
+ const remedy = formatSelfReferenceRemedy({
8595
+ isDefault: !isHookName(key) && Object.hasOwn(defaults, key),
8596
+ key,
8597
+ selfReference
8598
+ });
8599
+ throw new UserError(
8600
+ `Invalid nmr config at ${configPath}: \`${field}.${key}\` re-invokes \`${key}\` (\`${escapeControlCharacters(describeScript(script))}\`), which would re-enter nmr without bound. ${remedy}`
8601
+ );
8602
+ }
8603
+ }
8604
+ }
8605
+ function assertTierKeys(config, tier, configPath) {
8606
+ const unsupportedKeys = Object.keys(config).filter((key) => !tier.honoredKeys.includes(key));
8607
+ if (unsupportedKeys.length === 0) {
8608
+ return;
8609
+ }
8610
+ throw new UserError(
8611
+ `Invalid nmr config at ${configPath}: ${tier.label} honors ${tier.honoredKeys.toSorted().join(", ")} alone, not ${unsupportedKeys.toSorted().join(", ")}. Move those keys to ${tier.elsewhere}.`
8612
+ );
8375
8613
  }
8376
8614
  function formatSelfReferenceRemedy(options) {
8377
8615
  const { isDefault, key, selfReference } = options;
@@ -8382,7 +8620,7 @@ function formatSelfReferenceRemedy(options) {
8382
8620
  }
8383
8621
 
8384
8622
  // src/tiers.ts
8385
- import { readdirSync } from "node:fs";
8623
+ import { readdirSync as readdirSync2 } from "node:fs";
8386
8624
  import path6 from "node:path";
8387
8625
 
8388
8626
  // src/git-ignored-paths.ts
@@ -8419,7 +8657,7 @@ function hasTierInfix(filePath) {
8419
8657
  var TEST_COLLECTION_EXCLUDE = [".git", "coverage", "dist", "node_modules"];
8420
8658
  var TIER_NAMES = ["unit", "tool", "localhost", "remote"];
8421
8659
  function collectTestFiles(dir, relativeDir, isInTestDir, context) {
8422
- const entries = readdirSync(dir, { withFileTypes: true });
8660
+ const entries = readdirSync2(dir, { withFileTypes: true });
8423
8661
  for (const entry of entries) {
8424
8662
  const relativePath = relativeDir === "" ? entry.name : `${relativeDir}/${entry.name}`;
8425
8663
  if (entry.isDirectory()) {
@@ -8441,600 +8679,373 @@ function walkTestFiles(rootDir, { excludedBasenames = [] }, misplaced) {
8441
8679
  return context.foundPaths.toSorted();
8442
8680
  }
8443
8681
 
8444
- // .readyup/kits/default.ts
8445
- var default_default = defineRdyKit({
8446
- checklists: [
8447
- {
8448
- name: "nmr",
8449
- checks: [
8450
- // -- Setup ---------------------------------------------------------------
8451
- {
8452
- name: "@williamthorsen/nmr in devDependencies",
8453
- severity: "error",
8454
- check: () => hasDevDependency("@williamthorsen/nmr"),
8455
- fix: "pnpm add --save-dev @williamthorsen/nmr",
8456
- checks: [
8457
- {
8458
- get name() {
8459
- return `@williamthorsen/nmr >= ${getMinVersion()}`;
8460
- },
8461
- severity: "error",
8462
- check: () => hasMinDevDependencyVersion("@williamthorsen/nmr", getMinVersion(), {
8463
- exempt: resolvesVersionViaWorkspace
8464
- }),
8465
- get fix() {
8466
- return `pnpm add --save-dev @williamthorsen/nmr@^${getMinVersion()}`;
8467
- }
8468
- }
8469
- ]
8470
- },
8471
- {
8472
- name: "pnpm-workspace.yaml exists",
8473
- severity: "error",
8474
- check: () => fileExists("pnpm-workspace.yaml"),
8475
- fix: "Create pnpm-workspace.yaml with workspace package globs"
8476
- },
8477
- {
8478
- name: "catalogued dependencies use catalog:",
8479
- severity: "warn",
8480
- quiet: true,
8481
- check: cataloguedDependenciesUseCatalog,
8482
- fix: "Replace each listed specifier with `catalog:`, so that pnpm-workspace.yaml stays the one place that declares the version. A version that must differ belongs in a named catalog under `catalogs`, referenced as `catalog:<name>`"
8483
- },
8484
- {
8485
- name: "package.json has packageManager field",
8486
- severity: "warn",
8487
- check: () => hasPackageJsonField("packageManager"),
8488
- fix: 'Add "packageManager" field to package.json (e.g., "pnpm@10.33.0")'
8489
- },
8490
- {
8491
- name: ".tool-versions does not list pnpm",
8492
- severity: "warn",
8493
- quiet: true,
8494
- check: toolVersionsHasNoPnpm,
8495
- fix: "Remove pnpm from .tool-versions, and manage it through the packageManager field"
8496
- },
8497
- {
8498
- name: "no package.json declares a pnpm field",
8499
- severity: "error",
8500
- quiet: true,
8501
- check: () => noPnpmFieldInPackageJson(),
8502
- fix: "Move these settings into pnpm-workspace.yaml, quoting each version under `overrides`, or run `pnpx codemod run pnpm-v10-to-v11`. pnpm 11 does not read any key from the `pnpm` field, so an override left there pins nothing while an upgrade run with `--write` goes on rewriting it"
8503
- },
8504
- {
8505
- name: "no package.json declares a workspaces field",
8506
- severity: "warn",
8507
- quiet: true,
8508
- check: () => noWorkspacesFieldInPackageJson(),
8509
- fix: "Delete the `workspaces` field. pnpm reads workspace globs only from pnpm-workspace.yaml, so the field can drift from them without any error"
8510
- },
8511
- {
8512
- name: ".config/nmr.config.ts uses defineConfig",
8513
- severity: "recommend",
8514
- skip: () => !fileExists(".config/nmr.config.ts") && "no nmr config file",
8515
- check: () => fileContains(".config/nmr.config.ts", /defineConfig/),
8516
- fix: "Wrap the config export with defineConfig() from @williamthorsen/nmr/config for type safety"
8517
- },
8518
- // `error` because falling short produces wrong results rather than a failure. Names and fixes are
8519
- // getters because their version constants are declared below the kit.
8520
- {
8521
- get name() {
8522
- return `eslint >= ${MIN_ESLINT_VERSION}`;
8523
- },
8524
- severity: "error",
8525
- skip: () => !hasDevDependency("eslint") && "eslint not installed",
8526
- check: hasSupportedEslintVersion,
8527
- get fix() {
8528
- return `pnpm add --save-dev eslint@^${MIN_ESLINT_VERSION}. Earlier releases resolve config from the working directory, so nmr's root lint and lint:check would apply the root config to every package`;
8529
- }
8530
- },
8531
- {
8532
- get name() {
8533
- return `@williamthorsen/strict-lint >= ${MIN_STRICT_LINT_VERSION}`;
8534
- },
8535
- severity: "error",
8536
- skip: () => !hasDevDependency("@williamthorsen/strict-lint") && "strict-lint not installed",
8537
- check: hasSupportedStrictLintVersion,
8538
- get fix() {
8539
- return `pnpm add --save-dev @williamthorsen/strict-lint@^${MIN_STRICT_LINT_VERSION}. Earlier releases pin ESLint to one config and resolve ceilings from the working directory, so nmr's root lint:strict would report the wrong rules for every package`;
8540
- }
8541
- },
8542
- // -- Root script cleanup -------------------------------------------------
8543
- {
8544
- name: "root package.json has no nmr-provided scripts",
8545
- severity: "warn",
8546
- quiet: true,
8547
- check: noRedundantRootScripts,
8548
- fix: "Remove scripts from root package.json that nmr provides as built-in root scripts, and invoke them through nmr directly"
8549
- },
8550
- // -- Git hooks -----------------------------------------------------------
8551
- {
8552
- name: "no root install script runs lefthook install unguarded",
8553
- severity: "warn",
8554
- quiet: true,
8555
- check: () => noUnguardedLefthookInstall(),
8556
- fix: "Guard each listed script as `lefthook check-install || lefthook install`, which writes hooks only when they are missing or stale. A bare `lefthook install` rewrites `.git/hooks` on every install, so when that directory is not writable, as in an agent's sandbox, `pnpm install` fails and pnpm's `verifyDepsBeforeRun` then reinstalls before every `pnpm exec`"
8557
- },
8558
- // -- Workspace build readiness -------------------------------------------
8559
- {
8560
- name: "all workspace packages can build",
8561
- severity: "warn",
8562
- check: allWorkspacePackagesCanBuild,
8563
- fix: `Add "build": ":" to packages that don't need a build, or ensure packages that use the default nmr build have a tsconfig.json and a src/ directory`
8564
- },
8565
- // -- Bin targets ---------------------------------------------------------
8566
- {
8567
- name: "every bin target is a committed wrapper",
8568
- severity: "error",
8569
- check: () => everyBinTargetIsACommittedWrapper(),
8570
- fix: `Point each listed entry at a committed wrapper under bin/ that loads the build output at runtime. pnpm links a workspace package's bins during the install's link phase, which runs before anything is built, so a target that is not committed does not exist when pnpm tries to link it. pnpm never retries, which leaves the link missing for the life of the node_modules tree`
8571
- },
8572
- {
8573
- name: "every bin wrapper's build-output target is covered by files",
8574
- severity: "warn",
8575
- check: () => everyBinWrapperTargetIsCoveredByFiles(),
8576
- fix: "Add the build output directory to `files` in each listed package. npm and pnpm publish the bin target itself whatever `files` says, so the wrapper is published pointing at build output missing from the tarball"
8577
- },
8578
- // -- Vitest projects -----------------------------------------------------
8579
- {
8580
- name: "no retired Vitest config variants",
8581
- severity: "error",
8582
- quiet: true,
8583
- check: () => noRetiredVitestConfigs(),
8584
- fix: "Delete every vitest.standalone.config.* and vitest.integration.config.*. nmr's test scripts select Vitest projects instead of naming config files"
8585
- },
8586
- {
8587
- name: "every vitest.config builds on @williamthorsen/nmr/vitest",
8588
- severity: "error",
8589
- check: () => vitestConfigBuildsOnSharedConfig(),
8590
- fix: "Replace each listed config with: import { defineVitestConfig } from '@williamthorsen/nmr/vitest'; export default defineVitestConfig(); -- a config that does not call the factory does not declare any projects, so every tier-selecting test command fails against it. Pass the repo's own settings to the factory as layers to keep them"
8591
- },
8592
- {
8593
- name: "vitest.root.config.ts builds on @williamthorsen/nmr/vitest",
8594
- severity: "error",
8595
- check: () => vitestRootConfigBuildsOnSharedConfig(),
8596
- fix: "Replace vitest.root.config.ts with: import { defineRootVitestConfig } from '@williamthorsen/nmr/vitest'; export default defineRootVitestConfig({ monorepoRoot: import.meta.dirname });"
8597
- },
8598
- {
8599
- name: "every workspace with a Vite config has a Vitest config",
8600
- severity: "error",
8601
- check: () => everyViteConfigHasVitestConfig(),
8602
- fix: "Add a vitest.config.ts calling defineVitestConfig() from @williamthorsen/nmr/vitest beside each listed vite.config -- Vitest stops its config search at the first directory containing either name, so the Vite config otherwise wins and the projects model is never reached"
8603
- },
8604
- {
8605
- name: "the test suite gates the test-file conventions",
8606
- severity: "warn",
8607
- check: () => testSuiteGatesTestFileConventions(),
8608
- fix: "Add a test of the repo's own under a __tests__ directory that declares the check: import { checkTestFileConventions } from '@williamthorsen/nmr/tests'; checkTestFileConventions(); -- passing `excludedBasenames` the directory names that the repo passes to `testCollectionExclude`. Without it, the repo's test run does not report an untiered or misplaced test file, and this kit reports them in its place against only nmr's built-in exclusions and the paths that git ignores"
8609
- },
8610
- {
8611
- name: "every test file names its isolation tier",
8612
- severity: "error",
8613
- skip: () => describeConventionsGuardSkip(),
8614
- check: () => everyTestFileNamesItsTier(),
8615
- fix: `Rename each to <subject>[.<aspect>].<tier>.test.ts, naming one of ${TIER_NAMES.join(", ")}. Use tool for a test that reaches a program supplied by the environment, which is the tier for a retired .int. or .integration. file. Only the segment before .test. selects a project, so an untiered file runs under the residual unit project and reports success`
8616
- },
8617
- {
8618
- name: "every test file is under a __tests__ directory",
8619
- severity: "error",
8620
- skip: () => describeConventionsGuardSkip(),
8621
- check: () => everyTestFileSitsUnderTestsDir(),
8622
- fix: "Move each into a __tests__ directory, the only place from which the shared Vitest config collects. Vitest does not collect a file outside one, so it runs nowhere and reports nothing. For a file that is not a test, declare checkTestFileConventions with its directory in `excludedBasenames` instead, which this check then defers to"
8623
- },
8624
- {
8625
- name: "no package re-exports the ancestor Vitest config",
8626
- severity: "recommend",
8627
- quiet: true,
8628
- check: () => noReExportOnlyVitestConfigs(),
8629
- fix: "Delete these files. Vitest resolves config by walking up from the run root, so a per-package re-export is redundant"
8630
- },
8631
- // -- Shared Prettier config ----------------------------------------------
8632
- {
8633
- name: "Prettier config builds on @williamthorsen/nmr/prettier",
8634
- severity: "error",
8635
- check: () => prettierConfigBuildsOnSharedConfig(),
8636
- fix: "Replace the Prettier config with: import { definePrettierConfig } from '@williamthorsen/nmr/prettier'; export default definePrettierConfig();"
8637
- },
8638
- // -- Shared upgrade policy -----------------------------------------------
8639
- {
8640
- name: "taze.config.ts builds on @williamthorsen/nmr/taze",
8641
- severity: "warn",
8642
- check: () => tazeConfigBuildsOnSharedConfig(),
8643
- fix: "Replace the taze config with: import { defineConfig } from '@williamthorsen/nmr/taze'; export default defineConfig(); -- nmr's upgrade policy applies to a repo only through this file, so without it `nmr upgrade` does not report a dependency pinned to an exact version"
8644
- },
8645
- {
8646
- name: "taze config declares no option taze discards",
8647
- severity: "warn",
8648
- quiet: true,
8649
- check: () => tazeConfigAvoidsClobberedOptions(),
8650
- fix: "Set these through the upgrade script instead, as rootScripts: { upgrade: 'nmr-report-overrides && nmr-taze --recursive --request-timeout 90000' } in .config/nmr.config.ts, keeping the rest of the default script. taze's CLI writes a default for each of them over whatever the config file declares, so taze never uses the file's value (antfu-collective/taze#317). nmr already forwards a 30-second request timeout"
8651
- },
8652
- // -- Audit dependency --------------------------------------------------------
8653
- {
8654
- name: "v11y-check in devDependencies",
8655
- severity: "warn",
8656
- check: () => hasDevDependency("v11y-check"),
8657
- fix: "pnpm add --save-dev v11y-check"
8658
- },
8659
- // -- Legacy script runner ------------------------------------------------
8660
- {
8661
- name: "scripts/run-workspace-script.ts does not exist",
8662
- severity: "error",
8663
- quiet: true,
8664
- check: () => !fileExists("scripts/run-workspace-script.ts"),
8665
- fix: "Delete scripts/run-workspace-script.ts, because nmr replaces this custom script runner"
8666
- },
8667
- {
8668
- name: 'no workspace packages reference run-workspace-script or "pnpm run ws"',
8669
- severity: "error",
8670
- quiet: true,
8671
- check: noWorkspaceRunScriptReferences,
8672
- fix: 'Remove "ws" script entries and replace any "pnpm run ws" invocations with nmr in each packages/*/package.json'
8682
+ // .readyup/lib/github.ts
8683
+ var import_yaml2 = __toESM(require_dist(), 1);
8684
+ import { execFile } from "node:child_process";
8685
+ import { existsSync as existsSync4, readdirSync as readdirSync3 } from "node:fs";
8686
+ import { join as join3, posix as posix2 } from "node:path";
8687
+ import { isRecord as isRecord3, readFile as readFile3 } from "readyup/check-utils";
8688
+ var CODE_QUALITY_WORKFLOW = "williamthorsen/.github/.github/workflows/code-quality-pnpm-workflow.yaml";
8689
+ var MIN_CODE_QUALITY_MAJOR = 8;
8690
+ var GH_TIMEOUT_MS = 15e3;
8691
+ var IMAGE_ABSENT_PROGRAM_PATTERN = /(?:^|[\s;&|(])(corepack|gh|node|npm|npx|pnpm)(?=\s|$)/gm;
8692
+ var NEVER_INSTALLED_PROGRAMS = /* @__PURE__ */ new Set(["corepack", "gh"]);
8693
+ function countOnlineSelfHostedRunners(response) {
8694
+ if (!isRecord3(response) || !Array.isArray(response["runners"])) return 0;
8695
+ return response["runners"].filter(
8696
+ (runner) => isRecord3(runner) && runner["status"] === "online" && Array.isArray(runner["labels"]) && runner["labels"].some((label) => isRecord3(label) && label["name"] === "self-hosted")
8697
+ ).length;
8698
+ }
8699
+ function describeGhFailure(result, context) {
8700
+ if (result.isMissing) return "gh is not installed";
8701
+ if (/gh auth login|not logged in/i.test(result.stderr)) return "gh is not authenticated";
8702
+ const firstLine = result.stderr.trim().split("\n", 1)[0] ?? "";
8703
+ return firstLine === "" ? `gh ${context}` : `gh ${context}: ${firstLine}`;
8704
+ }
8705
+ function findCodeQualityCallerDefects(workflows) {
8706
+ const defects = [];
8707
+ for (const { file, jobs } of workflows) {
8708
+ for (const [jobName, job] of jobs) {
8709
+ const uses = job["uses"];
8710
+ if (typeof uses !== "string" || !uses.startsWith(`${CODE_QUALITY_WORKFLOW}@`)) continue;
8711
+ const ref = uses.slice(CODE_QUALITY_WORKFLOW.length + 1);
8712
+ const major = /^v(\d+)(?:\.\d+){0,2}$/.exec(ref)?.[1];
8713
+ if (major === void 0) {
8714
+ defects.push(
8715
+ `${file} job ${jobName} pins the code-quality workflow at ${ref}, whose release cannot be determined`
8716
+ );
8717
+ } else if (Number(major) < MIN_CODE_QUALITY_MAJOR) {
8718
+ defects.push(
8719
+ `${file} job ${jobName} pins the code-quality workflow at ${ref}, which predates CI_RUNS_ON; pin v${MIN_CODE_QUALITY_MAJOR} or later`
8720
+ );
8721
+ }
8722
+ const inputs = job["with"];
8723
+ if (isRecord3(inputs) && inputs["runs-on"] !== void 0) {
8724
+ defects.push(`${file} job ${jobName} passes runs-on, which overrides CI_RUNS_ON`);
8725
+ }
8726
+ }
8727
+ }
8728
+ return defects;
8729
+ }
8730
+ function findImageIncompatibleSteps(workflows) {
8731
+ const defects = [];
8732
+ for (const { file, jobs } of workflows) {
8733
+ for (const [jobName, job] of jobs) {
8734
+ if (!/\bvars\.CI_RUNS_ON\b/.test(JSON.stringify(job["runs-on"] ?? ""))) continue;
8735
+ const steps = job["steps"];
8736
+ if (!Array.isArray(steps)) continue;
8737
+ const installed = /* @__PURE__ */ new Set();
8738
+ for (const [index, step] of steps.entries()) {
8739
+ if (!isRecord3(step)) continue;
8740
+ const stepLabel = typeof step["name"] === "string" ? step["name"] : `step ${index + 1}`;
8741
+ const run = step["run"];
8742
+ if (typeof run === "string") {
8743
+ const missing = findUninstalledPrograms(run, installed);
8744
+ if (missing.length > 0) {
8745
+ defects.push(
8746
+ `${file} job ${jobName}, ${stepLabel}: runs ${missing.join(", ")}, which the runner image lacks at that step`
8747
+ );
8748
+ }
8673
8749
  }
8674
- ]
8675
- },
8750
+ for (const program of readInstalledPrograms(step)) installed.add(program);
8751
+ }
8752
+ }
8753
+ }
8754
+ return defects;
8755
+ }
8756
+ function isNotFound(result) {
8757
+ return /HTTP 404/.test(result.stderr);
8758
+ }
8759
+ function parseJson(text) {
8760
+ try {
8761
+ return JSON.parse(text);
8762
+ } catch {
8763
+ return void 0;
8764
+ }
8765
+ }
8766
+ async function readOrganizationValue(nameWithOwner, cwd, runGh) {
8767
+ const result = await runGh(["api", `repos/${nameWithOwner}/actions/organization-variables`], cwd);
8768
+ if (!result.ok) return void 0;
8769
+ const response = parseJson(result.stdout);
8770
+ if (!isRecord3(response) || !Array.isArray(response["variables"])) return void 0;
8771
+ const variable = response["variables"].find(
8772
+ (entry) => isRecord3(entry) && entry["name"] === "CI_RUNS_ON"
8773
+ );
8774
+ return readVariableValue(variable);
8775
+ }
8776
+ function readVariableValue(variable) {
8777
+ return isRecord3(variable) && typeof variable["value"] === "string" ? variable["value"] : void 0;
8778
+ }
8779
+ function readWorkflows(cwd) {
8780
+ const dir = join3(cwd, ".github", "workflows");
8781
+ if (!existsSync4(dir)) return [];
8782
+ const workflows = [];
8783
+ const files = readdirSync3(dir).filter((name) => /\.ya?ml$/.test(name)).toSorted();
8784
+ for (const file of files) {
8785
+ let document;
8786
+ try {
8787
+ document = (0, import_yaml2.parse)(readFile3(join3(dir, file)) ?? "");
8788
+ } catch {
8789
+ continue;
8790
+ }
8791
+ if (!isRecord3(document) || !isRecord3(document["jobs"])) continue;
8792
+ const jobs = Object.entries(document["jobs"]).filter(
8793
+ (entry) => isRecord3(entry[1])
8794
+ );
8795
+ workflows.push({ file: posix2.join(".github/workflows", file), jobs });
8796
+ }
8797
+ return workflows;
8798
+ }
8799
+ function runGhCommand(args, cwd) {
8800
+ return new Promise((resolve) => {
8801
+ void execFile("gh", args, { cwd, timeout: GH_TIMEOUT_MS }, (error, stdout, stderr) => {
8802
+ if (error === null) {
8803
+ resolve({ ok: true, stdout });
8804
+ return;
8805
+ }
8806
+ resolve({ ok: false, isMissing: "code" in error && error.code === "ENOENT", stderr: stderr || error.message });
8807
+ });
8808
+ });
8809
+ }
8810
+ function findUninstalledPrograms(run, installed) {
8811
+ const programs = /* @__PURE__ */ new Set();
8812
+ for (const match of run.matchAll(IMAGE_ABSENT_PROGRAM_PATTERN)) {
8813
+ const program = match[1];
8814
+ if (program === void 0) continue;
8815
+ const provider = program === "npm" || program === "npx" ? "node" : program;
8816
+ if (NEVER_INSTALLED_PROGRAMS.has(program) || !installed.has(provider)) programs.add(program);
8817
+ }
8818
+ return [...programs].toSorted();
8819
+ }
8820
+ function readInstalledPrograms(step) {
8821
+ const uses = step["uses"];
8822
+ if (typeof uses !== "string") return [];
8823
+ const action = uses.split("@", 1)[0];
8824
+ if (action === "actions/setup-node") return ["node"];
8825
+ if (action === "pnpm/action-setup") return ["pnpm"];
8826
+ if (action !== "pnpm/setup") return [];
8827
+ const inputs = isRecord3(step["with"]) ? step["with"] : {};
8828
+ const runtime = inputs["runtime"];
8829
+ const installsNode = typeof runtime === "string" ? /^node\b/.test(runtime) : String(inputs["node-version-file"]) !== "false";
8830
+ return installsNode ? ["pnpm", "node"] : ["pnpm"];
8831
+ }
8832
+
8833
+ // .readyup/lib/self-hosted-runner.ts
8834
+ var SET_CI_RUNS_ON_COMMAND = `gh variable set CI_RUNS_ON --body '"self-hosted"'`;
8835
+ var statusByCwd = /* @__PURE__ */ new Map();
8836
+ var selfHostedRunnerChecklist = {
8837
+ name: "self-hosted runner",
8838
+ checks: [
8676
8839
  {
8677
- name: "self-hosted runner",
8840
+ name: "GitHub reports the repo's runner settings",
8841
+ severity: "recommend",
8842
+ quiet: true,
8843
+ skip: () => describeSelfHostedRunnerSkip(),
8844
+ check: () => true,
8678
8845
  checks: [
8679
8846
  {
8680
- name: "GitHub reports the repo's runner settings",
8847
+ name: "CI_RUNS_ON selects the self-hosted runner",
8681
8848
  severity: "recommend",
8682
- quiet: true,
8683
- skip: () => describeSelfHostedRunnerSkip(),
8684
- check: () => true,
8685
- checks: [
8686
- {
8687
- name: "CI_RUNS_ON selects the self-hosted runner",
8688
- severity: "recommend",
8689
- skip: () => describeRecommendationSkip(),
8690
- check: () => recommendSelfHostedRunner()
8691
- },
8692
- {
8693
- name: "the self-hosted runner can run CI",
8694
- severity: "warn",
8695
- skip: () => describeReadinessSkip(),
8696
- check: () => selfHostedRunnerCanRunCi()
8697
- }
8698
- ]
8849
+ skip: () => describeRecommendationSkip(),
8850
+ check: () => recommendSelfHostedRunner()
8851
+ },
8852
+ {
8853
+ name: "the self-hosted runner can run CI",
8854
+ severity: "warn",
8855
+ skip: () => describeReadinessSkip(),
8856
+ check: () => selfHostedRunnerCanRunCi()
8699
8857
  }
8700
8858
  ]
8701
8859
  }
8702
8860
  ]
8703
- });
8704
- var SCAN_EXCLUDE_DIRS = /* @__PURE__ */ new Set([".git", "coverage", "dist", "node_modules"]);
8705
- var BUILD_OUTPUT_DIR = "dist";
8706
- var WRAPPER_TARGET_PATTERN = /['"](\.\.?\/[^'"]+)['"]/;
8707
- var CONFIG_EXTENSIONS = "{ts,mts,cts,js,mjs,cjs}";
8708
- var VITE_CONFIG_PATTERN = `vite.config.${CONFIG_EXTENSIONS}`;
8709
- var VITEST_CONFIG_PATTERN = `vitest.config.${CONFIG_EXTENSIONS}`;
8710
- var SHARED_VITEST_MODULE = "@williamthorsen/nmr/vitest";
8711
- var SHARED_PRETTIER_MODULE = "@williamthorsen/nmr/prettier";
8712
- var INERT_PRETTIER_CONFIGS = [".prettierrc", ".prettierrc.{json,json5,yaml,yml,toml}"];
8713
- var SHARED_TAZE_MODULE = "@williamthorsen/nmr/taze";
8714
- var SHARED_TESTS_MODULE = "@williamthorsen/nmr/tests";
8715
- var INERT_TAZE_CONFIGS = [".tazerc", ".tazerc.json", "taze.config.json"];
8716
- var CLOBBERED_TAZE_OPTIONS = [
8717
- { key: "concurrency", pattern: /\bconcurrency\s*:/ },
8718
- { key: "githubActions", pattern: /\bgithubActions\s*:\s*(?:false|\{)/ },
8719
- { key: "ignoreOtherWorkspaces", pattern: /\bignoreOtherWorkspaces\s*:\s*false/ },
8720
- { key: "nodeVersion", pattern: /\bnodeVersion\s*:\s*false/ },
8721
- { key: "requestTimeout", pattern: /\brequestTimeout\s*:/ }
8722
- ];
8723
- var RE_EXPORT_LINE_PATTERN = /^export\s*(?:\{\s*default\s*}|\*)\s*from\s*['"]\.\.\/[^'"]*['"];?$/;
8724
- var INSTALL_LIFECYCLE_SCRIPTS = [
8725
- "pnpm:devPreinstall",
8726
- "preinstall",
8727
- "install",
8728
- "postinstall",
8729
- "preprepare",
8730
- "prepare",
8731
- "postprepare"
8732
- ];
8733
- var LEFTHOOK_CHECK_INSTALL_PATTERN = /\blefthook\s+check-install\b/;
8734
- var LEFTHOOK_INSTALL_PATTERN = /\blefthook\s+install\b/;
8735
- var MIN_ESLINT_VERSION = "10.0.0";
8736
- var MIN_STRICT_LINT_VERSION = "9.3.0";
8737
- var WORKSPACE_VERSION_MARKERS = ["catalog:", "workspace:"];
8738
- var CATALOGUE_CHECKED_FIELDS = ["dependencies", "devDependencies", "optionalDependencies"];
8739
- var PROTOCOL_PATTERN = /^[a-z][a-z0-9+.-]*:/i;
8740
- function allWorkspacePackagesCanBuild() {
8741
- const packagesDir = join(process.cwd(), "packages");
8742
- if (!existsSync3(packagesDir)) return true;
8743
- const entries = readdirSync2(packagesDir, { withFileTypes: true });
8744
- const failingPackages = [];
8745
- for (const entry of entries) {
8746
- if (!entry.isDirectory()) continue;
8747
- const pkgPath = `packages/${entry.name}/package.json`;
8748
- const content = readFile(pkgPath);
8749
- if (!content) continue;
8750
- const hasBuildOverride = /"build"\s*:/.test(content);
8751
- const hasDefaultBuildInputs = fileExists(`packages/${entry.name}/tsconfig.json`) && existsSync3(join(packagesDir, entry.name, "src"));
8752
- if (!hasBuildOverride && !hasDefaultBuildInputs) {
8753
- failingPackages.push(entry.name);
8754
- }
8861
+ };
8862
+ async function describeReadinessSkip(cwd = process.cwd(), readFacts) {
8863
+ const status = await getSelfHostedRunnerStatus(cwd, readFacts);
8864
+ if ("skip" in status) return status.skip;
8865
+ if (status.selection === "unset") return "CI_RUNS_ON is unset";
8866
+ if (status.selection === "other") return `CI_RUNS_ON selects ${status.facts.ciRunsOn ?? ""}`;
8867
+ return false;
8868
+ }
8869
+ async function describeRecommendationSkip(cwd = process.cwd(), readFacts) {
8870
+ const status = await getSelfHostedRunnerStatus(cwd, readFacts);
8871
+ if ("skip" in status) return status.skip;
8872
+ if (status.selection !== "unset") return `CI_RUNS_ON is already set to ${status.facts.ciRunsOn ?? ""}`;
8873
+ if (status.exempt !== void 0) return `the repo is exempt: ${status.exempt}`;
8874
+ if (status.failures.length > 0) return `not ready: ${status.failures.join("; ")}`;
8875
+ return false;
8876
+ }
8877
+ async function describeSelfHostedRunnerSkip(cwd = process.cwd(), readFacts) {
8878
+ const status = await getSelfHostedRunnerStatus(cwd, readFacts);
8879
+ return "skip" in status && status.skip;
8880
+ }
8881
+ function findSelfHostedRunnerFailures(facts, cwd) {
8882
+ const failures = [];
8883
+ if (!facts.isPrivate) {
8884
+ failures.push(
8885
+ `${facts.nameWithOwner} is public: A self-hosted runner must never run code from a fork's pull request`
8886
+ );
8755
8887
  }
8756
- if (failingPackages.length === 0) return true;
8888
+ if (facts.onlineSelfHostedRunners === 0) {
8889
+ failures.push(
8890
+ `${facts.nameWithOwner} does not have an online runner labelled self-hosted. When williamthorsen/hq allocates the repo slots in apps/runners/config/repos.yaml, the runners have not registered yet`
8891
+ );
8892
+ }
8893
+ const workflows = readWorkflows(cwd);
8894
+ failures.push(...findCodeQualityCallerDefects(workflows), ...findImageIncompatibleSteps(workflows));
8895
+ return failures;
8896
+ }
8897
+ async function readGitHubFacts(cwd, runGh = runGhCommand) {
8898
+ const repo = await runGh(["repo", "view", "--json", "nameWithOwner,visibility"], cwd);
8899
+ if (!repo.ok) return { skip: describeGhFailure(repo, "cannot resolve this repo on GitHub") };
8900
+ const repoView = parseJson(repo.stdout);
8901
+ if (!isRecord4(repoView) || typeof repoView["nameWithOwner"] !== "string") {
8902
+ return { skip: "gh repo view returned an unexpected shape" };
8903
+ }
8904
+ const nameWithOwner = repoView["nameWithOwner"];
8905
+ const runners = await runGh(["api", `repos/${nameWithOwner}/actions/runners?per_page=100`], cwd);
8906
+ if (!runners.ok) {
8907
+ return {
8908
+ skip: describeGhFailure(runners, `cannot list the runners of ${nameWithOwner}, which needs admin access`)
8909
+ };
8910
+ }
8911
+ const repoVariable = await runGh(["api", `repos/${nameWithOwner}/actions/variables/CI_RUNS_ON`], cwd);
8912
+ if (!repoVariable.ok && !isNotFound(repoVariable)) {
8913
+ return { skip: describeGhFailure(repoVariable, `cannot read the CI_RUNS_ON variable of ${nameWithOwner}`) };
8914
+ }
8915
+ const repoValue = repoVariable.ok ? readVariableValue(parseJson(repoVariable.stdout)) : void 0;
8757
8916
  return {
8758
- ok: false,
8759
- detail: `missing build override or tsconfig.json + src/: ${failingPackages.join(", ")}`
8917
+ nameWithOwner,
8918
+ isPrivate: repoView["visibility"] !== "PUBLIC",
8919
+ onlineSelfHostedRunners: countOnlineSelfHostedRunners(parseJson(runners.stdout)),
8920
+ ciRunsOn: repoValue ?? await readOrganizationValue(nameWithOwner, cwd, runGh)
8760
8921
  };
8761
8922
  }
8762
- function cataloguedDependenciesUseCatalog() {
8763
- const catalogRead = readCataloguedNames(process.cwd());
8764
- if (!catalogRead.ok) return catalogRead;
8765
- if (catalogRead.names.size === 0) return true;
8766
- const workspaceDiscovery = discoverWorkspacesOrFailure();
8767
- if (!workspaceDiscovery.ok) return workspaceDiscovery;
8768
- const findings = workspaceDiscovery.workspaces.flatMap((workspace) => findLiteralCataloguedSpecifiers(workspace, catalogRead.names)).toSorted((a, b) => a.manifestPath.localeCompare(b.manifestPath) || a.name.localeCompare(b.name));
8769
- if (findings.length === 0) return true;
8770
- const lines = findings.map(({ manifestPath, name, specifier }) => `${manifestPath}: ${name} ${specifier}`);
8771
- return { ok: false, detail: formatPaths([...new Set(lines)]) };
8772
- }
8773
- function checkNoMatchingFiles(patterns, cwd) {
8774
- const foundFiles = findFiles(patterns, cwd);
8775
- if (foundFiles.length === 0) return true;
8776
- return { ok: false, detail: formatPaths(foundFiles) };
8923
+ function readRunnerSelection(value) {
8924
+ if (value === void 0 || value.trim() === "") return "unset";
8925
+ const parsed = parseJson(value);
8926
+ if (parsed === void 0) return "invalid";
8927
+ const labels = Array.isArray(parsed) ? parsed : [parsed];
8928
+ return labels.includes("self-hosted") ? "self-hosted" : "other";
8777
8929
  }
8778
- function checkRootVitestConfig(baseName, exportName, cwd) {
8779
- const matches = findFiles([`${baseName}.${CONFIG_EXTENSIONS}`], cwd);
8780
- if (matches.length === 0) {
8781
- return { ok: false, detail: `${baseName}.ts is missing` };
8782
- }
8783
- const staleConfigs = matches.filter(
8784
- (relativePath) => !importsSharedExport(readFileIn(cwd, relativePath), exportName, SHARED_VITEST_MODULE)
8785
- );
8786
- if (staleConfigs.length === 0) return true;
8930
+ async function recommendSelfHostedRunner(cwd = process.cwd(), readFacts) {
8931
+ const status = await getSelfHostedRunnerStatus(cwd, readFacts);
8932
+ if ("skip" in status) return true;
8933
+ const toolTests = status.toolTestFiles.length === 0 ? "The repo does not have any tool-tier tests." : `Before switching, confirm that the image provides every program that these tool-tier tests spawn: ${formatPaths(status.toolTestFiles)}`;
8787
8934
  return {
8788
8935
  ok: false,
8789
- detail: `does not import ${exportName} from ${SHARED_VITEST_MODULE}: ${staleConfigs.join(", ")}`
8936
+ detail: `${status.facts.nameWithOwner} can run its CI on the self-hosted runner. ${toolTests}`,
8937
+ fix: `${SET_CI_RUNS_ON_COMMAND} --repo ${status.facts.nameWithOwner}`
8790
8938
  };
8791
8939
  }
8792
- function prettierConfigBuildsOnSharedConfig(cwd = process.cwd()) {
8793
- const configs = findFiles(
8794
- [`.prettierrc.${CONFIG_EXTENSIONS}`, `prettier.config.${CONFIG_EXTENSIONS}`],
8795
- //
8796
- cwd
8797
- );
8798
- if (configs.length === 0) {
8799
- return { ok: false, detail: describeMissingPrettierConfig(cwd) };
8940
+ async function selfHostedRunnerCanRunCi(cwd = process.cwd(), readFacts) {
8941
+ const status = await getSelfHostedRunnerStatus(cwd, readFacts);
8942
+ if ("skip" in status) return true;
8943
+ const { facts } = status;
8944
+ const conditions = status.exempt === void 0 ? status.failures : [`the repo is exempt: ${status.exempt}`, ...status.failures];
8945
+ if (status.selection !== "invalid" && conditions.length === 0) return true;
8946
+ const invalidValue = `CI_RUNS_ON is ${facts.ciRunsOn ?? ""}, which is not JSON, so every workflow that reads it with fromJSON fails`;
8947
+ if (conditions.length === 0) {
8948
+ return { ok: false, detail: invalidValue, fix: `${SET_CI_RUNS_ON_COMMAND} --repo ${facts.nameWithOwner}` };
8800
8949
  }
8801
- const staleConfigs = configs.filter(
8802
- (relativePath) => !importsSharedExport(readFileIn(cwd, relativePath), "definePrettierConfig", SHARED_PRETTIER_MODULE)
8803
- );
8804
- if (staleConfigs.length === 0) return true;
8950
+ const problems = status.selection === "invalid" ? [invalidValue, ...conditions] : conditions;
8805
8951
  return {
8806
8952
  ok: false,
8807
- detail: `does not import definePrettierConfig from ${SHARED_PRETTIER_MODULE}: ${staleConfigs.join(", ")}`
8953
+ detail: problems.map((problem) => ` ${problem}`).join("\n"),
8954
+ fix: `Fix each condition, or return the repo's CI to ubuntu-latest: gh variable delete CI_RUNS_ON --repo ${facts.nameWithOwner}`
8808
8955
  };
8809
8956
  }
8810
- function describeMissingPrettierConfig(cwd) {
8811
- const inertConfigs = findFiles(INERT_PRETTIER_CONFIGS, cwd);
8812
- if (inertConfigs.length > 0) return `does not contain code that calls the factory: ${inertConfigs.join(", ")}`;
8813
- if (hasPrettierConfigKey(cwd)) {
8814
- return 'does not contain code that calls the factory: the "prettier" key in package.json';
8815
- }
8816
- return ".prettierrc.js is missing";
8817
- }
8818
- function hasPrettierConfigKey(cwd) {
8819
- const manifest = readFileIn(cwd, "package.json");
8820
- if (manifest === void 0) return false;
8821
- try {
8822
- const parsedManifest = JSON.parse(manifest);
8823
- return isRecord(parsedManifest) && parsedManifest["prettier"] !== void 0;
8824
- } catch {
8825
- return false;
8826
- }
8827
- }
8828
- function discoverWorkspacesOrFailure(filter) {
8957
+ async function gatherSelfHostedRunnerStatus(cwd, readFacts) {
8958
+ const facts = await readFacts(cwd);
8959
+ if ("skip" in facts) return facts;
8960
+ const failures = findSelfHostedRunnerFailures(facts, cwd);
8961
+ let exempt;
8829
8962
  try {
8830
- return { ok: true, workspaces: discoverWorkspaces(filter === void 0 ? void 0 : { filter }) };
8963
+ exempt = (await loadRootConfig(cwd)).selfHostedRunner?.exempt;
8831
8964
  } catch (error) {
8832
- return { ok: false, detail: `cannot enumerate workspaces: ${describeError(error)}` };
8965
+ failures.push(`the nmr config cannot be loaded: ${describeError(error)}`);
8833
8966
  }
8967
+ return {
8968
+ facts,
8969
+ selection: readRunnerSelection(facts.ciRunsOn),
8970
+ failures,
8971
+ exempt,
8972
+ toolTestFiles: findTestFiles(cwd).filter(
8973
+ (file) => file.endsWith(".tool.test.ts") || file.endsWith(".tool.test.tsx")
8974
+ )
8975
+ };
8834
8976
  }
8835
- async function everyBinTargetIsACommittedWrapper() {
8836
- const workspaceDiscovery = discoverWorkspacesOrFailure((workspace) => !workspace.isRoot);
8837
- if (!workspaceDiscovery.ok) return workspaceDiscovery;
8838
- const trackedFiles = await listTrackedFiles();
8839
- const trackedPaths = trackedFiles === void 0 ? void 0 : new Set(trackedFiles);
8840
- const offenders = workspaceDiscovery.workspaces.flatMap(
8841
- (workspace) => readBinEntries(workspace).flatMap((entry) => {
8842
- const defect = describeBinTargetDefect(workspace, entry, trackedPaths);
8843
- return defect === void 0 ? [] : [`${describeBinEntry(workspace, entry)} (${defect})`];
8844
- })
8845
- );
8846
- if (offenders.length === 0) return true;
8847
- return { ok: false, detail: formatPaths(offenders) };
8848
- }
8849
- function everyBinWrapperTargetIsCoveredByFiles() {
8850
- const workspaceDiscovery = discoverWorkspacesOrFailure((workspace) => !workspace.isRoot);
8851
- if (!workspaceDiscovery.ok) return workspaceDiscovery;
8852
- const cwd = process.cwd();
8853
- const offenders = workspaceDiscovery.workspaces.flatMap((workspace) => {
8854
- const files = workspace.packageJson["files"];
8855
- if (!Array.isArray(files)) return [];
8856
- const publishedSegments = new Set(
8857
- files.flatMap((entry) => typeof entry === "string" ? [readFirstSegment(entry)] : [])
8858
- );
8859
- return readBinEntries(workspace).flatMap((entry) => {
8860
- const target = readWrapperTarget(cwd, workspace, entry);
8861
- if (target === void 0 || publishedSegments.has(readFirstSegment(target))) return [];
8862
- return [`${describeBinEntry(workspace, entry)} -> ${target}`];
8863
- });
8864
- });
8865
- if (offenders.length === 0) return true;
8866
- return { ok: false, detail: formatPaths(offenders) };
8867
- }
8868
- function describeBinEntry(workspace, entry) {
8869
- return `${workspace.name ?? workspace.dir}:${entry.command} -> ${entry.target}`;
8870
- }
8871
- function describeBinTargetDefect(workspace, entry, trackedPaths) {
8872
- if (readFirstSegment(entry.target) === BUILD_OUTPUT_DIR) return `names a path under ${BUILD_OUTPUT_DIR}/`;
8873
- if (trackedPaths === void 0) return void 0;
8874
- return trackedPaths.has(`${workspace.dir}/${entry.target}`) ? void 0 : "untracked";
8875
- }
8876
- function normalizeBinTarget(target) {
8877
- return target.replace(/^\.\//, "");
8878
- }
8879
- function readBinEntries(workspace) {
8880
- const bin = workspace.packageJson["bin"];
8881
- if (typeof bin === "string") {
8882
- const command = workspace.name?.split("/").at(-1) ?? basename(workspace.dir);
8883
- return [{ command, target: normalizeBinTarget(bin) }];
8977
+ function getSelfHostedRunnerStatus(cwd, readFacts) {
8978
+ if (readFacts !== void 0) return gatherSelfHostedRunnerStatus(cwd, readFacts);
8979
+ let status = statusByCwd.get(cwd);
8980
+ if (status === void 0) {
8981
+ status = gatherSelfHostedRunnerStatus(cwd, (dir) => readGitHubFacts(dir));
8982
+ statusByCwd.set(cwd, status);
8884
8983
  }
8885
- if (!isRecord(bin)) return [];
8886
- return Object.entries(bin).flatMap(
8887
- ([command, target]) => typeof target === "string" ? [{ command, target: normalizeBinTarget(target) }] : []
8888
- );
8889
- }
8890
- function readFirstSegment(entry) {
8891
- return normalizeBinTarget(entry).split("/", 1).at(0) ?? "";
8892
- }
8893
- function readWrapperTarget(cwd, workspace, entry) {
8894
- if (readFirstSegment(entry.target) === BUILD_OUTPUT_DIR) return void 0;
8895
- const content = readFileIn(cwd, `${workspace.dir}/${entry.target}`);
8896
- if (content === void 0) return void 0;
8897
- const specifier = WRAPPER_TARGET_PATTERN.exec(content)?.[1];
8898
- if (specifier === void 0) return void 0;
8899
- return posix.normalize(posix.join(posix.dirname(entry.target), specifier));
8900
- }
8901
- function everyTestFileNamesItsTier(cwd = process.cwd()) {
8902
- const untieredFiles = findUntieredTestFiles(cwd);
8903
- if (untieredFiles.length === 0) return true;
8904
- return { ok: false, detail: formatPaths(untieredFiles) };
8905
- }
8906
- function everyTestFileSitsUnderTestsDir(cwd = process.cwd()) {
8907
- const misplacedFiles = findMisplacedTestFiles(cwd);
8908
- if (misplacedFiles.length === 0) return true;
8909
- return { ok: false, detail: formatPaths(misplacedFiles) };
8984
+ return status;
8910
8985
  }
8911
- function everyViteConfigHasVitestConfig() {
8912
- const workspaceDiscovery = discoverWorkspacesOrFailure((workspace) => !workspace.isRoot);
8986
+
8987
+ // .readyup/lib/setup.ts
8988
+ import {
8989
+ fileContains,
8990
+ fileExists as fileExists2,
8991
+ hasDevDependency,
8992
+ hasMinDevDependencyVersion,
8993
+ hasPackageJsonField,
8994
+ readFile as readFile4
8995
+ } from "readyup/check-utils";
8996
+
8997
+ // .readyup/lib/catalog.ts
8998
+ import { posix as posix3 } from "node:path";
8999
+ var import_yaml3 = __toESM(require_dist(), 1);
9000
+ import { isRecord as isRecord5 } from "readyup/check-utils";
9001
+ var CATALOGUE_CHECKED_FIELDS = ["dependencies", "devDependencies", "optionalDependencies"];
9002
+ var PROTOCOL_PATTERN = /^[a-z][a-z0-9+.-]*:/i;
9003
+ function cataloguedDependenciesUseCatalog() {
9004
+ const catalogRead = readCataloguedNames(process.cwd());
9005
+ if (!catalogRead.ok) return catalogRead;
9006
+ if (catalogRead.names.size === 0) return true;
9007
+ const workspaceDiscovery = discoverWorkspacesOrFailure();
8913
9008
  if (!workspaceDiscovery.ok) return workspaceDiscovery;
8914
- const unpairedConfigs = workspaceDiscovery.workspaces.flatMap((workspace) => {
8915
- const viteConfigs = findWorkspaceConfigs(workspace, VITE_CONFIG_PATTERN);
8916
- if (viteConfigs.length === 0) return [];
8917
- return findWorkspaceConfigs(workspace, VITEST_CONFIG_PATTERN).length > 0 ? [] : viteConfigs;
8918
- });
8919
- if (unpairedConfigs.length === 0) return true;
8920
- return { ok: false, detail: formatPaths(unpairedConfigs) };
8921
- }
8922
- function findFiles(patterns, cwd) {
8923
- return globSync2(patterns, { cwd, exclude: (path7) => SCAN_EXCLUDE_DIRS.has(basename(path7)) }).map((path7) => path7.split(sep).join("/")).toSorted();
9009
+ const findings = workspaceDiscovery.workspaces.flatMap((workspace) => findLiteralCataloguedSpecifiers(workspace, catalogRead.names)).toSorted((a, b) => a.manifestPath.localeCompare(b.manifestPath) || a.name.localeCompare(b.name));
9010
+ if (findings.length === 0) return true;
9011
+ const lines = findings.map(({ manifestPath, name, specifier }) => `${manifestPath}: ${name} ${specifier}`);
9012
+ return { ok: false, detail: formatPaths([...new Set(lines)]) };
8924
9013
  }
8925
9014
  function findLiteralCataloguedSpecifiers(workspace, names) {
8926
- const manifestPath = posix.join(workspace.dir, "package.json");
9015
+ const manifestPath = posix3.join(workspace.dir, "package.json");
8927
9016
  return CATALOGUE_CHECKED_FIELDS.flatMap((field) => {
8928
9017
  const declared = workspace.packageJson[field];
8929
- if (!isRecord(declared)) return [];
9018
+ if (!isRecord5(declared)) return [];
8930
9019
  return Object.entries(declared).flatMap(
8931
- ([name, specifier]) => names.has(name) && typeof specifier === "string" && !PROTOCOL_PATTERN.test(specifier) ? [{ manifestPath, name, specifier }] : []
9020
+ ([name, specifier]) => names.has(name) && typeof specifier === "string" && !PROTOCOL_PATTERN.test(specifier) ? { manifestPath, name, specifier } : []
8932
9021
  );
8933
9022
  });
8934
9023
  }
8935
- function findWorkspaceConfigs(workspace, pattern) {
8936
- return findFiles([pattern], workspace.absolutePath).map((name) => `${workspace.dir}/${name}`);
8937
- }
8938
- function formatPaths(paths) {
8939
- return `${paths.length} found:
8940
- ${paths.map((path7) => ` ${path7}`).join("\n")}`;
8941
- }
8942
- function getMinVersion() {
8943
- const pickedFields = { "version": "0.48.0" };
8944
- if (typeof pickedFields["version"] !== "string") {
8945
- throw new TypeError("nmr/package.json: 'version' must be a string");
8946
- }
8947
- return pickedFields["version"];
8948
- }
8949
- function hasSupportedEslintVersion() {
8950
- return hasMinDevDependencyVersion("eslint", MIN_ESLINT_VERSION, {
8951
- exempt: resolvesVersionViaWorkspace
8952
- });
8953
- }
8954
- function hasSupportedStrictLintVersion() {
8955
- return hasMinDevDependencyVersion("@williamthorsen/strict-lint", MIN_STRICT_LINT_VERSION, {
8956
- exempt: resolvesVersionViaWorkspace
8957
- });
8958
- }
8959
- function hasViteConfigBeside(cwd, relativePath) {
8960
- return findFiles([VITE_CONFIG_PATTERN], join(cwd, dirname(relativePath))).length > 0;
8961
- }
8962
- function importsSharedExport(content, exportName, moduleSpecifier) {
8963
- if (content === void 0) return false;
8964
- const pattern = new RegExp(String.raw`import\s*\{[^}]*\b${exportName}\b[^}]*\}\s*from\s*['"]${moduleSpecifier}['"]`);
8965
- return pattern.test(content);
8966
- }
8967
- function isOwnedByReExportCheck(cwd, relativePath) {
8968
- return isReExportOnly(readFileIn(cwd, relativePath)) && !hasViteConfigBeside(cwd, relativePath);
8969
- }
8970
- function isReExportOnly(content) {
8971
- if (content === void 0) return false;
8972
- const statements = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "").split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
8973
- return statements.length > 0 && statements.every((line) => RE_EXPORT_LINE_PATTERN.test(line));
8974
- }
8975
- function noPnpmFieldInPackageJson(cwd = process.cwd()) {
8976
- const declaringFiles = findFiles(["**/package.json"], cwd).flatMap((relativePath) => {
8977
- const keys = readPnpmFieldKeys(readFileIn(cwd, relativePath));
8978
- if (keys === void 0) return [];
8979
- return [keys.length > 0 ? `${relativePath} (${keys.join(", ")})` : relativePath];
8980
- });
8981
- if (declaringFiles.length === 0) return true;
8982
- return { ok: false, detail: formatPaths(declaringFiles) };
8983
- }
8984
- function noReExportOnlyVitestConfigs(cwd = process.cwd()) {
8985
- const nonRootConfigs = findFiles([`**/${VITEST_CONFIG_PATTERN}`], cwd).filter((path7) => path7.includes("/"));
8986
- const reExports = nonRootConfigs.filter((path7) => isOwnedByReExportCheck(cwd, path7));
8987
- if (reExports.length === 0) return true;
8988
- return { ok: false, detail: formatPaths(reExports) };
8989
- }
8990
- function noRedundantRootScripts() {
8991
- const pkg = readPackageJson();
8992
- if (!pkg) return true;
8993
- const scripts = pkg["scripts"];
8994
- if (!isRecord(scripts)) return true;
8995
- const builtInNames = Object.keys(getDefaultRootScripts(process.cwd()));
8996
- const redundantNames = Object.keys(scripts).filter((name) => builtInNames.includes(name));
8997
- if (redundantNames.length === 0) return true;
8998
- return {
8999
- ok: false,
9000
- detail: `redundant: ${redundantNames.join(", ")}`
9001
- };
9002
- }
9003
- function noRetiredVitestConfigs(cwd = process.cwd()) {
9004
- return checkNoMatchingFiles(
9005
- [`**/vitest.standalone.config.${CONFIG_EXTENSIONS}`, `**/vitest.integration.config.${CONFIG_EXTENSIONS}`],
9006
- cwd
9007
- );
9008
- }
9009
- function noUnguardedLefthookInstall(cwd = process.cwd()) {
9010
- const scripts = readRootScripts(cwd);
9011
- const unguardedScripts = INSTALL_LIFECYCLE_SCRIPTS.flatMap((name) => {
9012
- const command = scripts[name];
9013
- if (typeof command !== "string") return [];
9014
- const isUnguarded = LEFTHOOK_INSTALL_PATTERN.test(command) && !LEFTHOOK_CHECK_INSTALL_PATTERN.test(command);
9015
- return isUnguarded ? [`${name}: ${command}`] : [];
9016
- });
9017
- if (unguardedScripts.length === 0) return true;
9018
- return { ok: false, detail: formatPaths(unguardedScripts) };
9019
- }
9020
- function noWorkspaceRunScriptReferences() {
9021
- const packagesDir = join(process.cwd(), "packages");
9022
- if (!existsSync3(packagesDir)) return true;
9023
- const legacyPattern = /run-workspace-script|"pnpm\s+run\s+ws\b/;
9024
- const entries = readdirSync2(packagesDir, { withFileTypes: true });
9025
- const matches = [];
9026
- for (const entry of entries) {
9027
- if (!entry.isDirectory()) continue;
9028
- const content = readFile(`packages/${entry.name}/package.json`);
9029
- if (content && legacyPattern.test(content)) {
9030
- matches.push(entry.name);
9031
- }
9024
+ function readCataloguedNames(cwd) {
9025
+ const content = readFileIn(cwd, "pnpm-workspace.yaml");
9026
+ if (content === void 0) return { ok: true, names: /* @__PURE__ */ new Set() };
9027
+ let workspaceManifest;
9028
+ try {
9029
+ workspaceManifest = (0, import_yaml3.parse)(content);
9030
+ } catch (error) {
9031
+ return { ok: false, detail: `cannot parse pnpm-workspace.yaml: ${describeError(error)}` };
9032
9032
  }
9033
- if (matches.length === 0) return true;
9034
- return {
9035
- ok: false,
9036
- detail: `found in: ${matches.join(", ")}`
9037
- };
9033
+ if (!isRecord5(workspaceManifest)) return { ok: true, names: /* @__PURE__ */ new Set() };
9034
+ const namedCatalogs = workspaceManifest["catalogs"];
9035
+ const catalogs = [workspaceManifest["catalog"], ...isRecord5(namedCatalogs) ? Object.values(namedCatalogs) : []];
9036
+ return { ok: true, names: new Set(catalogs.filter(isRecord5).flatMap((catalog) => Object.keys(catalog))) };
9037
+ }
9038
+
9039
+ // .readyup/lib/manifest-fields.ts
9040
+ import { isRecord as isRecord6 } from "readyup/check-utils";
9041
+ function noPnpmFieldInPackageJson(cwd = process.cwd()) {
9042
+ const declaringFiles = findFiles(["**/package.json"], cwd).flatMap((relativePath) => {
9043
+ const keys = readPnpmFieldKeys(readFileIn(cwd, relativePath));
9044
+ if (keys === void 0) return [];
9045
+ return keys.length > 0 ? `${relativePath} (${keys.join(", ")})` : relativePath;
9046
+ });
9047
+ if (declaringFiles.length === 0) return true;
9048
+ return { ok: false, detail: formatPaths(declaringFiles) };
9038
9049
  }
9039
9050
  function noWorkspacesFieldInPackageJson(cwd = process.cwd()) {
9040
9051
  const declaringFiles = findFiles(["**/package.json"], cwd).filter(
@@ -9047,28 +9058,11 @@ function declaresWorkspacesField(content) {
9047
9058
  if (content === void 0) return false;
9048
9059
  try {
9049
9060
  const parsedManifest = JSON.parse(content);
9050
- return isRecord(parsedManifest) && "workspaces" in parsedManifest;
9061
+ return isRecord6(parsedManifest) && "workspaces" in parsedManifest;
9051
9062
  } catch {
9052
9063
  return false;
9053
9064
  }
9054
9065
  }
9055
- function readCataloguedNames(cwd) {
9056
- const content = readFileIn(cwd, "pnpm-workspace.yaml");
9057
- if (content === void 0) return { ok: true, names: /* @__PURE__ */ new Set() };
9058
- let workspaceManifest;
9059
- try {
9060
- workspaceManifest = (0, import_yaml2.parse)(content);
9061
- } catch (error) {
9062
- return { ok: false, detail: `cannot parse pnpm-workspace.yaml: ${describeError(error)}` };
9063
- }
9064
- if (!isRecord(workspaceManifest)) return { ok: true, names: /* @__PURE__ */ new Set() };
9065
- const namedCatalogs = workspaceManifest["catalogs"];
9066
- const catalogs = [workspaceManifest["catalog"], ...isRecord(namedCatalogs) ? Object.values(namedCatalogs) : []];
9067
- return { ok: true, names: new Set(catalogs.filter(isRecord).flatMap((catalog) => Object.keys(catalog))) };
9068
- }
9069
- function readFileIn(cwd, relativePath) {
9070
- return readFile(join(cwd, relativePath));
9071
- }
9072
9066
  function readPnpmFieldKeys(content) {
9073
9067
  if (content === void 0) return void 0;
9074
9068
  let parsedManifest;
@@ -9077,36 +9071,187 @@ function readPnpmFieldKeys(content) {
9077
9071
  } catch {
9078
9072
  return void 0;
9079
9073
  }
9080
- if (!isRecord(parsedManifest)) return void 0;
9074
+ if (!isRecord6(parsedManifest)) return void 0;
9081
9075
  const pnpm = parsedManifest["pnpm"];
9082
- return isRecord(pnpm) ? Object.keys(pnpm).toSorted() : void 0;
9076
+ return isRecord6(pnpm) ? Object.keys(pnpm).toSorted() : void 0;
9083
9077
  }
9084
- function readRootScripts(cwd) {
9085
- const content = readFileIn(cwd, "package.json");
9086
- if (content === void 0) return {};
9087
- let parsedManifest;
9088
- try {
9089
- parsedManifest = JSON.parse(content);
9090
- } catch {
9091
- return {};
9078
+
9079
+ // .readyup/lib/setup.ts
9080
+ var MIN_ESLINT_VERSION = "10.0.0";
9081
+ var MIN_STRICT_LINT_VERSION = "9.3.0";
9082
+ var WORKSPACE_VERSION_MARKERS = ["catalog:", "workspace:"];
9083
+ var setupChecks = [
9084
+ {
9085
+ name: "@williamthorsen/nmr in devDependencies",
9086
+ severity: "error",
9087
+ check: () => hasDevDependency("@williamthorsen/nmr"),
9088
+ fix: "pnpm add --save-dev @williamthorsen/nmr",
9089
+ checks: [
9090
+ {
9091
+ get name() {
9092
+ return `@williamthorsen/nmr >= ${getMinVersion()}`;
9093
+ },
9094
+ severity: "error",
9095
+ check: () => hasMinDevDependencyVersion("@williamthorsen/nmr", getMinVersion(), {
9096
+ exempt: resolvesVersionViaWorkspace
9097
+ }),
9098
+ get fix() {
9099
+ return `pnpm add --save-dev @williamthorsen/nmr@^${getMinVersion()}`;
9100
+ }
9101
+ }
9102
+ ]
9103
+ },
9104
+ {
9105
+ name: "pnpm-workspace.yaml exists",
9106
+ severity: "error",
9107
+ check: () => fileExists2("pnpm-workspace.yaml"),
9108
+ fix: "Create pnpm-workspace.yaml with workspace package globs"
9109
+ },
9110
+ {
9111
+ name: "catalogued dependencies use catalog:",
9112
+ severity: "warn",
9113
+ quiet: true,
9114
+ check: cataloguedDependenciesUseCatalog,
9115
+ fix: "Replace each listed specifier with `catalog:`, so that pnpm-workspace.yaml stays the one place that declares the version. A version that must differ belongs in a named catalog under `catalogs`, referenced as `catalog:<name>`"
9116
+ },
9117
+ {
9118
+ name: "package.json has packageManager field",
9119
+ severity: "warn",
9120
+ check: () => hasPackageJsonField("packageManager"),
9121
+ fix: 'Add "packageManager" field to package.json (e.g., "pnpm@10.33.0")'
9122
+ },
9123
+ {
9124
+ name: ".tool-versions does not list pnpm",
9125
+ severity: "warn",
9126
+ quiet: true,
9127
+ check: toolVersionsHasNoPnpm,
9128
+ fix: "Remove pnpm from .tool-versions, and manage it through the packageManager field"
9129
+ },
9130
+ {
9131
+ name: "no package.json declares a pnpm field",
9132
+ severity: "error",
9133
+ quiet: true,
9134
+ check: () => noPnpmFieldInPackageJson(),
9135
+ fix: "Move these settings into pnpm-workspace.yaml, quoting each version under `overrides`, or run `pnpx codemod run pnpm-v10-to-v11`. pnpm 11 does not read any key from the `pnpm` field, so an override left there pins nothing while an upgrade run with `--write` goes on rewriting it"
9136
+ },
9137
+ {
9138
+ name: "no package.json declares a workspaces field",
9139
+ severity: "warn",
9140
+ quiet: true,
9141
+ check: () => noWorkspacesFieldInPackageJson(),
9142
+ fix: "Delete the `workspaces` field. pnpm reads workspace globs only from pnpm-workspace.yaml, so the field can drift from them without any error"
9143
+ },
9144
+ {
9145
+ name: ".config/nmr.config.ts uses defineConfig",
9146
+ severity: "recommend",
9147
+ skip: () => !fileExists2(".config/nmr.config.ts") && "no nmr config file",
9148
+ check: () => fileContains(".config/nmr.config.ts", /defineConfig/),
9149
+ fix: "Wrap the config export with defineConfig() from @williamthorsen/nmr/config for type safety"
9150
+ },
9151
+ // `error` because falling short produces wrong results rather than a failure. Names and fixes are
9152
+ // getters because their version constants are declared below the kit.
9153
+ {
9154
+ get name() {
9155
+ return `eslint >= ${MIN_ESLINT_VERSION}`;
9156
+ },
9157
+ severity: "error",
9158
+ skip: () => !hasDevDependency("eslint") && "eslint not installed",
9159
+ check: hasSupportedEslintVersion,
9160
+ get fix() {
9161
+ return `pnpm add --save-dev eslint@^${MIN_ESLINT_VERSION}. Earlier releases resolve config from the working directory, so nmr's root lint and lint:check would apply the root config to every package`;
9162
+ }
9163
+ },
9164
+ {
9165
+ get name() {
9166
+ return `@williamthorsen/strict-lint >= ${MIN_STRICT_LINT_VERSION}`;
9167
+ },
9168
+ severity: "error",
9169
+ skip: () => !hasDevDependency("@williamthorsen/strict-lint") && "strict-lint not installed",
9170
+ check: hasSupportedStrictLintVersion,
9171
+ get fix() {
9172
+ return `pnpm add --save-dev @williamthorsen/strict-lint@^${MIN_STRICT_LINT_VERSION}. Earlier releases pin ESLint to one config and resolve ceilings from the working directory, so nmr's root lint:strict would report the wrong rules for every package`;
9173
+ }
9092
9174
  }
9093
- if (!isRecord(parsedManifest)) return {};
9094
- const scripts = parsedManifest["scripts"];
9095
- return isRecord(scripts) ? scripts : {};
9175
+ ];
9176
+ function hasSupportedEslintVersion() {
9177
+ return hasMinDevDependencyVersion("eslint", MIN_ESLINT_VERSION, {
9178
+ exempt: resolvesVersionViaWorkspace
9179
+ });
9180
+ }
9181
+ function hasSupportedStrictLintVersion() {
9182
+ return hasMinDevDependencyVersion("@williamthorsen/strict-lint", MIN_STRICT_LINT_VERSION, {
9183
+ exempt: resolvesVersionViaWorkspace
9184
+ });
9185
+ }
9186
+ function getMinVersion() {
9187
+ const pickedFields = { "version": "0.50.0" };
9188
+ if (typeof pickedFields["version"] !== "string") {
9189
+ throw new TypeError("nmr/package.json: 'version' must be a string");
9190
+ }
9191
+ return pickedFields["version"];
9096
9192
  }
9097
9193
  function resolvesVersionViaWorkspace(range) {
9098
9194
  return WORKSPACE_VERSION_MARKERS.some((marker) => range.startsWith(marker));
9099
9195
  }
9100
- function tazeConfigBuildsOnSharedConfig(cwd = process.cwd()) {
9101
- const configs = findFiles([`taze.config.${CONFIG_EXTENSIONS}`], cwd);
9196
+ function toolVersionsHasNoPnpm() {
9197
+ const content = readFile4(".tool-versions");
9198
+ if (content === void 0) return true;
9199
+ return !/^pnpm\s/m.test(content);
9200
+ }
9201
+
9202
+ // .readyup/lib/shared-configs.ts
9203
+ import { isRecord as isRecord7 } from "readyup/check-utils";
9204
+ var SHARED_PRETTIER_MODULE = "@williamthorsen/nmr/prettier";
9205
+ var INERT_PRETTIER_CONFIGS = [".prettierrc", ".prettierrc.{json,json5,yaml,yml,toml}"];
9206
+ var SHARED_TAZE_MODULE = "@williamthorsen/nmr/taze";
9207
+ var INERT_TAZE_CONFIGS = [".tazerc", ".tazerc.json", "taze.config.json"];
9208
+ var CLOBBERED_TAZE_OPTIONS = [
9209
+ { key: "concurrency", pattern: /\bconcurrency\s*:/ },
9210
+ { key: "githubActions", pattern: /\bgithubActions\s*:\s*(?:false|\{)/ },
9211
+ { key: "ignoreOtherWorkspaces", pattern: /\bignoreOtherWorkspaces\s*:\s*false/ },
9212
+ { key: "nodeVersion", pattern: /\bnodeVersion\s*:\s*false/ },
9213
+ { key: "requestTimeout", pattern: /\brequestTimeout\s*:/ }
9214
+ ];
9215
+ var prettierChecks = [
9216
+ {
9217
+ name: "Prettier config builds on @williamthorsen/nmr/prettier",
9218
+ severity: "error",
9219
+ check: () => prettierConfigBuildsOnSharedConfig(),
9220
+ fix: "Replace the Prettier config with: import { definePrettierConfig } from '@williamthorsen/nmr/prettier'; export default definePrettierConfig();"
9221
+ }
9222
+ ];
9223
+ var tazeChecks = [
9224
+ {
9225
+ name: "taze.config.ts builds on @williamthorsen/nmr/taze",
9226
+ severity: "warn",
9227
+ check: () => tazeConfigBuildsOnSharedConfig(),
9228
+ fix: "Replace the taze config with: import { defineConfig } from '@williamthorsen/nmr/taze'; export default defineConfig(); -- nmr's upgrade policy applies to a repo only through this file, so without it `nmr upgrade` does not report a dependency pinned to an exact version"
9229
+ },
9230
+ {
9231
+ name: "taze config declares no option taze discards",
9232
+ severity: "warn",
9233
+ quiet: true,
9234
+ check: () => tazeConfigAvoidsClobberedOptions(),
9235
+ fix: "Set these through the upgrade script instead, as rootScripts: { upgrade: 'nmr-report-overrides && nmr-taze --recursive --request-timeout 90000' } in .config/nmr.config.ts, keeping the rest of the default script. taze's CLI writes a default for each of them over whatever the config file declares, so taze never uses the file's value (antfu-collective/taze#317). nmr already forwards a 30-second request timeout"
9236
+ }
9237
+ ];
9238
+ function prettierConfigBuildsOnSharedConfig(cwd = process.cwd()) {
9239
+ const configs = findFiles(
9240
+ [`.prettierrc.${CONFIG_EXTENSIONS}`, `prettier.config.${CONFIG_EXTENSIONS}`],
9241
+ //
9242
+ cwd
9243
+ );
9102
9244
  if (configs.length === 0) {
9103
- return { ok: false, detail: describeMissingTazeConfig(cwd) };
9245
+ return { ok: false, detail: describeMissingPrettierConfig(cwd) };
9104
9246
  }
9105
9247
  const staleConfigs = configs.filter(
9106
- (relativePath) => !importsSharedExport(readFileIn(cwd, relativePath), "defineConfig", SHARED_TAZE_MODULE)
9248
+ (relativePath) => !importsSharedExport(readFileIn(cwd, relativePath), "definePrettierConfig", SHARED_PRETTIER_MODULE)
9107
9249
  );
9108
9250
  if (staleConfigs.length === 0) return true;
9109
- return { ok: false, detail: `does not import defineConfig from ${SHARED_TAZE_MODULE}: ${staleConfigs.join(", ")}` };
9251
+ return {
9252
+ ok: false,
9253
+ detail: `does not import definePrettierConfig from ${SHARED_PRETTIER_MODULE}: ${staleConfigs.join(", ")}`
9254
+ };
9110
9255
  }
9111
9256
  function tazeConfigAvoidsClobberedOptions(cwd = process.cwd()) {
9112
9257
  const configs = findFiles([`taze.config.${CONFIG_EXTENSIONS}`], cwd);
@@ -9120,345 +9265,267 @@ function tazeConfigAvoidsClobberedOptions(cwd = process.cwd()) {
9120
9265
  if (findings.length === 0) return true;
9121
9266
  return { ok: false, detail: formatPaths(findings) };
9122
9267
  }
9268
+ function tazeConfigBuildsOnSharedConfig(cwd = process.cwd()) {
9269
+ const configs = findFiles([`taze.config.${CONFIG_EXTENSIONS}`], cwd);
9270
+ if (configs.length === 0) {
9271
+ return { ok: false, detail: describeMissingTazeConfig(cwd) };
9272
+ }
9273
+ const staleConfigs = configs.filter(
9274
+ (relativePath) => !importsSharedExport(readFileIn(cwd, relativePath), "defineConfig", SHARED_TAZE_MODULE)
9275
+ );
9276
+ if (staleConfigs.length === 0) return true;
9277
+ return { ok: false, detail: `does not import defineConfig from ${SHARED_TAZE_MODULE}: ${staleConfigs.join(", ")}` };
9278
+ }
9279
+ function describeMissingPrettierConfig(cwd) {
9280
+ const inertConfigs = findFiles(INERT_PRETTIER_CONFIGS, cwd);
9281
+ if (inertConfigs.length > 0) return `does not contain code that calls the factory: ${inertConfigs.join(", ")}`;
9282
+ if (hasPrettierConfigKey(cwd)) {
9283
+ return 'does not contain code that calls the factory: the "prettier" key in package.json';
9284
+ }
9285
+ return ".prettierrc.js is missing";
9286
+ }
9123
9287
  function describeMissingTazeConfig(cwd) {
9124
9288
  const inertConfigs = findFiles(INERT_TAZE_CONFIGS, cwd);
9125
9289
  if (inertConfigs.length > 0) return `does not contain code that calls the factory: ${inertConfigs.join(", ")}`;
9126
9290
  return "taze.config.ts is missing";
9127
9291
  }
9128
- function testSuiteGatesTestFileConventions(cwd = process.cwd()) {
9129
- if (findConventionsGuard(cwd) !== void 0) return true;
9130
- return {
9131
- ok: false,
9132
- detail: `the test files under __tests__ do not import checkTestFileConventions from ${SHARED_TESTS_MODULE}`
9133
- };
9134
- }
9135
- function describeConventionsGuardSkip() {
9136
- const guard = findConventionsGuard(process.cwd());
9137
- return guard !== void 0 && `reported by checkTestFileConventions in ${guard}`;
9292
+ function hasPrettierConfigKey(cwd) {
9293
+ const manifest = readFileIn(cwd, "package.json");
9294
+ if (manifest === void 0) return false;
9295
+ try {
9296
+ const parsedManifest = JSON.parse(manifest);
9297
+ return isRecord7(parsedManifest) && parsedManifest["prettier"] !== void 0;
9298
+ } catch {
9299
+ return false;
9300
+ }
9138
9301
  }
9139
- function findConventionsGuard(cwd) {
9140
- return findTestFiles(cwd).find(
9141
- (relativePath) => importsSharedExport(readFileIn(cwd, relativePath), "checkTestFileConventions", SHARED_TESTS_MODULE)
9142
- );
9302
+
9303
+ // .readyup/lib/vitest.ts
9304
+ import { dirname, join as join4 } from "node:path";
9305
+ var VITE_CONFIG_PATTERN = `vite.config.${CONFIG_EXTENSIONS}`;
9306
+ var VITEST_CONFIG_PATTERN = `vitest.config.${CONFIG_EXTENSIONS}`;
9307
+ var SHARED_VITEST_MODULE = "@williamthorsen/nmr/vitest";
9308
+ var SHARED_TESTS_MODULE = "@williamthorsen/nmr/tests";
9309
+ var RE_EXPORT_LINE_PATTERN = /^export\s*(?:\{\s*default\s*}|\*)\s*from\s*['"]\.\.\/[^'"]*['"];?$/;
9310
+ var vitestChecks = [
9311
+ {
9312
+ name: "no retired Vitest config variants",
9313
+ severity: "error",
9314
+ quiet: true,
9315
+ check: () => noRetiredVitestConfigs(),
9316
+ fix: "Delete every vitest.standalone.config.* and vitest.integration.config.*. nmr's test scripts select Vitest projects instead of naming config files"
9317
+ },
9318
+ {
9319
+ name: "every vitest.config builds on @williamthorsen/nmr/vitest",
9320
+ severity: "error",
9321
+ check: () => vitestConfigBuildsOnSharedConfig(),
9322
+ fix: "Replace each listed config with: import { defineVitestConfig } from '@williamthorsen/nmr/vitest'; export default defineVitestConfig(); -- a config that does not call the factory does not declare any projects, so every tier-selecting test command fails against it. Pass the repo's own settings to the factory as layers to keep them"
9323
+ },
9324
+ {
9325
+ name: "vitest.root.config.ts builds on @williamthorsen/nmr/vitest",
9326
+ severity: "error",
9327
+ check: () => vitestRootConfigBuildsOnSharedConfig(),
9328
+ fix: "Replace vitest.root.config.ts with: import { defineRootVitestConfig } from '@williamthorsen/nmr/vitest'; export default defineRootVitestConfig({ monorepoRoot: import.meta.dirname });"
9329
+ },
9330
+ {
9331
+ name: "every workspace with a Vite config has a Vitest config",
9332
+ severity: "error",
9333
+ check: () => everyViteConfigHasVitestConfig(),
9334
+ fix: "Add a vitest.config.ts calling defineVitestConfig() from @williamthorsen/nmr/vitest beside each listed vite.config -- Vitest stops its config search at the first directory containing either name, so the Vite config otherwise wins and the projects model is never reached"
9335
+ },
9336
+ {
9337
+ name: "the test suite gates the test-file conventions",
9338
+ severity: "warn",
9339
+ check: () => testSuiteGatesTestFileConventions(),
9340
+ fix: "Add a test of the repo's own under a __tests__ directory that declares the check: import { checkTestFileConventions } from '@williamthorsen/nmr/tests'; checkTestFileConventions(); -- passing `excludedBasenames` the directory names that the repo passes to `testCollectionExclude`. Without it, the repo's test run does not report an untiered or misplaced test file, and this kit reports them in its place against only nmr's built-in exclusions and the paths that git ignores"
9341
+ },
9342
+ {
9343
+ name: "every test file names its isolation tier",
9344
+ severity: "error",
9345
+ skip: () => describeConventionsGuardSkip(),
9346
+ check: () => everyTestFileNamesItsTier(),
9347
+ fix: `Rename each to <subject>[.<aspect>].<tier>.test.ts, naming one of ${TIER_NAMES.join(", ")}. Use tool for a test that reaches a program supplied by the environment, which is the tier for a retired .int. or .integration. file. Only the segment before .test. selects a project, so an untiered file runs under the residual unit project and reports success`
9348
+ },
9349
+ {
9350
+ name: "every test file is under a __tests__ directory",
9351
+ severity: "error",
9352
+ skip: () => describeConventionsGuardSkip(),
9353
+ check: () => everyTestFileSitsUnderTestsDir(),
9354
+ fix: "Move each into a __tests__ directory, the only place from which the shared Vitest config collects. Vitest does not collect a file outside one, so it runs nowhere and reports nothing. For a file that is not a test, declare checkTestFileConventions with its directory in `excludedBasenames` instead, which this check then defers to"
9355
+ },
9356
+ {
9357
+ name: "no package re-exports the ancestor Vitest config",
9358
+ severity: "recommend",
9359
+ quiet: true,
9360
+ check: () => noReExportOnlyVitestConfigs(),
9361
+ fix: "Delete these files. Vitest resolves config by walking up from the run root, so a per-package re-export is redundant"
9362
+ }
9363
+ ];
9364
+ function everyTestFileNamesItsTier(cwd = process.cwd()) {
9365
+ const untieredFiles = findUntieredTestFiles(cwd);
9366
+ if (untieredFiles.length === 0) return true;
9367
+ return { ok: false, detail: formatPaths(untieredFiles) };
9143
9368
  }
9144
- function toolVersionsHasNoPnpm() {
9145
- const content = readFile(".tool-versions");
9146
- if (content === void 0) return true;
9147
- return !/^pnpm\s/m.test(content);
9369
+ function everyTestFileSitsUnderTestsDir(cwd = process.cwd()) {
9370
+ const misplacedFiles = findMisplacedTestFiles(cwd);
9371
+ if (misplacedFiles.length === 0) return true;
9372
+ return { ok: false, detail: formatPaths(misplacedFiles) };
9148
9373
  }
9149
- function vitestConfigBuildsOnSharedConfig() {
9150
- const cwd = process.cwd();
9151
- const rootConfigs = findFiles([VITEST_CONFIG_PATTERN], cwd);
9152
- if (rootConfigs.length === 0) return { ok: false, detail: "vitest.config.ts is missing" };
9374
+ function everyViteConfigHasVitestConfig() {
9153
9375
  const workspaceDiscovery = discoverWorkspacesOrFailure((workspace) => !workspace.isRoot);
9154
9376
  if (!workspaceDiscovery.ok) return workspaceDiscovery;
9155
- const workspaceConfigs = workspaceDiscovery.workspaces.flatMap((workspace) => findWorkspaceConfigs(workspace, VITEST_CONFIG_PATTERN)).filter((relativePath) => !isOwnedByReExportCheck(cwd, relativePath));
9156
- const stale = [...rootConfigs, ...workspaceConfigs].filter(
9157
- (relativePath) => !importsSharedExport(readFileIn(cwd, relativePath), "defineVitestConfig", SHARED_VITEST_MODULE)
9158
- );
9159
- if (stale.length === 0) return true;
9160
- return { ok: false, detail: formatPaths(stale) };
9161
- }
9162
- function vitestRootConfigBuildsOnSharedConfig(cwd = process.cwd()) {
9163
- return checkRootVitestConfig("vitest.root.config", "defineRootVitestConfig", cwd);
9164
- }
9165
- var SET_CI_RUNS_ON_COMMAND = `gh variable set CI_RUNS_ON --body '"self-hosted"'`;
9166
- var CODE_QUALITY_WORKFLOW = "williamthorsen/.github/.github/workflows/code-quality-pnpm-workflow.yaml";
9167
- var MIN_CODE_QUALITY_MAJOR = 8;
9168
- var GH_TIMEOUT_MS = 15e3;
9169
- var IMAGE_ABSENT_PROGRAM_PATTERN = /(?:^|[\s;&|(])(corepack|gh|node|npm|npx|pnpm)(?=\s|$)/gm;
9170
- var NEVER_INSTALLED_PROGRAMS = /* @__PURE__ */ new Set(["corepack", "gh"]);
9171
- var statusByCwd = /* @__PURE__ */ new Map();
9172
- async function describeSelfHostedRunnerSkip(cwd = process.cwd(), readFacts) {
9173
- const status = await getSelfHostedRunnerStatus(cwd, readFacts);
9174
- return "skip" in status && status.skip;
9175
- }
9176
- function findSelfHostedRunnerFailures(facts, cwd) {
9177
- const failures = [];
9178
- if (!facts.isPrivate) {
9179
- failures.push(
9180
- `${facts.nameWithOwner} is public: A self-hosted runner must never run code from a fork's pull request`
9181
- );
9182
- }
9183
- if (facts.onlineSelfHostedRunners === 0) {
9184
- failures.push(
9185
- `${facts.nameWithOwner} does not have an online runner labelled self-hosted. When williamthorsen/hq allocates the repo slots in apps/runners/config/repos.yaml, the runners have not registered yet`
9186
- );
9187
- }
9188
- const workflows = readWorkflows(cwd);
9189
- failures.push(...findCodeQualityCallerDefects(workflows), ...findImageIncompatibleSteps(workflows));
9190
- return failures;
9191
- }
9192
- async function readGitHubFacts(cwd, runGh = runGhCommand) {
9193
- const repo = await runGh(["repo", "view", "--json", "nameWithOwner,visibility"], cwd);
9194
- if (!repo.ok) return { skip: describeGhFailure(repo, "cannot resolve this repo on GitHub") };
9195
- const repoView = parseJson(repo.stdout);
9196
- if (!isRecord(repoView) || typeof repoView["nameWithOwner"] !== "string") {
9197
- return { skip: "gh repo view returned an unexpected shape" };
9198
- }
9199
- const nameWithOwner = repoView["nameWithOwner"];
9200
- const runners = await runGh(["api", `repos/${nameWithOwner}/actions/runners?per_page=100`], cwd);
9201
- if (!runners.ok) {
9202
- return {
9203
- skip: describeGhFailure(runners, `cannot list the runners of ${nameWithOwner}, which needs admin access`)
9204
- };
9205
- }
9206
- const repoVariable = await runGh(["api", `repos/${nameWithOwner}/actions/variables/CI_RUNS_ON`], cwd);
9207
- if (!repoVariable.ok && !isNotFound(repoVariable)) {
9208
- return { skip: describeGhFailure(repoVariable, `cannot read the CI_RUNS_ON variable of ${nameWithOwner}`) };
9209
- }
9210
- const repoValue = repoVariable.ok ? readVariableValue(parseJson(repoVariable.stdout)) : void 0;
9211
- return {
9212
- nameWithOwner,
9213
- isPrivate: repoView["visibility"] !== "PUBLIC",
9214
- onlineSelfHostedRunners: countOnlineSelfHostedRunners(parseJson(runners.stdout)),
9215
- ciRunsOn: repoValue ?? await readOrganizationValue(nameWithOwner, cwd, runGh)
9216
- };
9377
+ const unpairedConfigs = workspaceDiscovery.workspaces.flatMap((workspace) => {
9378
+ const viteConfigs = findWorkspaceConfigs(workspace, VITE_CONFIG_PATTERN);
9379
+ if (viteConfigs.length === 0) return [];
9380
+ return findWorkspaceConfigs(workspace, VITEST_CONFIG_PATTERN).length > 0 ? [] : viteConfigs;
9381
+ });
9382
+ if (unpairedConfigs.length === 0) return true;
9383
+ return { ok: false, detail: formatPaths(unpairedConfigs) };
9217
9384
  }
9218
- function readRunnerSelection(value) {
9219
- if (value === void 0 || value.trim() === "") return "unset";
9220
- const parsed = parseJson(value);
9221
- if (parsed === void 0) return "invalid";
9222
- const labels = Array.isArray(parsed) ? parsed : [parsed];
9223
- return labels.includes("self-hosted") ? "self-hosted" : "other";
9385
+ function noReExportOnlyVitestConfigs(cwd = process.cwd()) {
9386
+ const nonRootConfigs = findFiles([`**/${VITEST_CONFIG_PATTERN}`], cwd).filter((path7) => path7.includes("/"));
9387
+ const reExports = nonRootConfigs.filter((path7) => isOwnedByReExportCheck(cwd, path7));
9388
+ if (reExports.length === 0) return true;
9389
+ return { ok: false, detail: formatPaths(reExports) };
9224
9390
  }
9225
- async function recommendSelfHostedRunner(cwd = process.cwd(), readFacts) {
9226
- const status = await getSelfHostedRunnerStatus(cwd, readFacts);
9227
- if ("skip" in status) return true;
9228
- const toolTests = status.toolTestFiles.length === 0 ? "The repo does not have any tool-tier tests." : `Before switching, confirm that the image provides every program that these tool-tier tests spawn: ${formatPaths(status.toolTestFiles)}`;
9391
+ function noRetiredVitestConfigs(cwd = process.cwd()) {
9392
+ return checkNoMatchingFiles(
9393
+ [`**/vitest.standalone.config.${CONFIG_EXTENSIONS}`, `**/vitest.integration.config.${CONFIG_EXTENSIONS}`],
9394
+ cwd
9395
+ );
9396
+ }
9397
+ function testSuiteGatesTestFileConventions(cwd = process.cwd()) {
9398
+ if (findConventionsGuard(cwd) !== void 0) return true;
9229
9399
  return {
9230
9400
  ok: false,
9231
- detail: `${status.facts.nameWithOwner} can run its CI on the self-hosted runner. ${toolTests}`,
9232
- fix: `${SET_CI_RUNS_ON_COMMAND} --repo ${status.facts.nameWithOwner}`
9401
+ detail: `the test files under __tests__ do not import checkTestFileConventions from ${SHARED_TESTS_MODULE}`
9233
9402
  };
9234
9403
  }
9235
- async function describeRecommendationSkip(cwd = process.cwd(), readFacts) {
9236
- const status = await getSelfHostedRunnerStatus(cwd, readFacts);
9237
- if ("skip" in status) return status.skip;
9238
- if (status.selection !== "unset") return `CI_RUNS_ON is already set to ${status.facts.ciRunsOn ?? ""}`;
9239
- if (status.exempt !== void 0) return `the repo is exempt: ${status.exempt}`;
9240
- if (status.failures.length > 0) return `not ready: ${status.failures.join("; ")}`;
9241
- return false;
9404
+ function vitestConfigBuildsOnSharedConfig() {
9405
+ const cwd = process.cwd();
9406
+ const rootConfigs = findFiles([VITEST_CONFIG_PATTERN], cwd);
9407
+ if (rootConfigs.length === 0) return { ok: false, detail: "vitest.config.ts is missing" };
9408
+ const workspaceDiscovery = discoverWorkspacesOrFailure((workspace) => !workspace.isRoot);
9409
+ if (!workspaceDiscovery.ok) return workspaceDiscovery;
9410
+ const workspaceConfigs = workspaceDiscovery.workspaces.flatMap((workspace) => findWorkspaceConfigs(workspace, VITEST_CONFIG_PATTERN)).filter((relativePath) => !isOwnedByReExportCheck(cwd, relativePath));
9411
+ const stale = [...rootConfigs, ...workspaceConfigs].filter(
9412
+ (relativePath) => !importsSharedExport(readFileIn(cwd, relativePath), "defineVitestConfig", SHARED_VITEST_MODULE)
9413
+ );
9414
+ if (stale.length === 0) return true;
9415
+ return { ok: false, detail: formatPaths(stale) };
9242
9416
  }
9243
- async function describeReadinessSkip(cwd = process.cwd(), readFacts) {
9244
- const status = await getSelfHostedRunnerStatus(cwd, readFacts);
9245
- if ("skip" in status) return status.skip;
9246
- if (status.selection === "unset") return "CI_RUNS_ON is unset";
9247
- if (status.selection === "other") return `CI_RUNS_ON selects ${status.facts.ciRunsOn ?? ""}`;
9248
- return false;
9417
+ function vitestRootConfigBuildsOnSharedConfig(cwd = process.cwd()) {
9418
+ return checkRootVitestConfig("vitest.root.config", "defineRootVitestConfig", cwd);
9249
9419
  }
9250
- async function selfHostedRunnerCanRunCi(cwd = process.cwd(), readFacts) {
9251
- const status = await getSelfHostedRunnerStatus(cwd, readFacts);
9252
- if ("skip" in status) return true;
9253
- const { facts } = status;
9254
- const conditions = status.exempt === void 0 ? status.failures : [`the repo is exempt: ${status.exempt}`, ...status.failures];
9255
- if (status.selection !== "invalid" && conditions.length === 0) return true;
9256
- const invalidValue = `CI_RUNS_ON is ${facts.ciRunsOn ?? ""}, which is not JSON, so every workflow that reads it with fromJSON fails`;
9257
- if (conditions.length === 0) {
9258
- return { ok: false, detail: invalidValue, fix: `${SET_CI_RUNS_ON_COMMAND} --repo ${facts.nameWithOwner}` };
9420
+ function checkRootVitestConfig(baseName, exportName, cwd) {
9421
+ const matches = findFiles([`${baseName}.${CONFIG_EXTENSIONS}`], cwd);
9422
+ if (matches.length === 0) {
9423
+ return { ok: false, detail: `${baseName}.ts is missing` };
9259
9424
  }
9260
- const problems = status.selection === "invalid" ? [invalidValue, ...conditions] : conditions;
9425
+ const staleConfigs = matches.filter(
9426
+ (relativePath) => !importsSharedExport(readFileIn(cwd, relativePath), exportName, SHARED_VITEST_MODULE)
9427
+ );
9428
+ if (staleConfigs.length === 0) return true;
9261
9429
  return {
9262
9430
  ok: false,
9263
- detail: problems.map((problem) => ` ${problem}`).join("\n"),
9264
- fix: `Fix each condition, or return the repo's CI to ubuntu-latest: gh variable delete CI_RUNS_ON --repo ${facts.nameWithOwner}`
9431
+ detail: `does not import ${exportName} from ${SHARED_VITEST_MODULE}: ${staleConfigs.join(", ")}`
9265
9432
  };
9266
9433
  }
9267
- function countOnlineSelfHostedRunners(response) {
9268
- if (!isRecord(response) || !Array.isArray(response["runners"])) return 0;
9269
- return response["runners"].filter(
9270
- (runner) => isRecord(runner) && runner["status"] === "online" && Array.isArray(runner["labels"]) && runner["labels"].some((label) => isRecord(label) && label["name"] === "self-hosted")
9271
- ).length;
9272
- }
9273
- function describeGhFailure(result, context) {
9274
- if (result.isMissing) return "gh is not installed";
9275
- if (/gh auth login|not logged in/i.test(result.stderr)) return "gh is not authenticated";
9276
- const firstLine = result.stderr.trim().split("\n", 1)[0] ?? "";
9277
- return firstLine === "" ? `gh ${context}` : `gh ${context}: ${firstLine}`;
9278
- }
9279
- function findCodeQualityCallerDefects(workflows) {
9280
- const defects = [];
9281
- for (const { file, jobs } of workflows) {
9282
- for (const [jobName, job] of jobs) {
9283
- const uses = job["uses"];
9284
- if (typeof uses !== "string" || !uses.startsWith(`${CODE_QUALITY_WORKFLOW}@`)) continue;
9285
- const ref = uses.slice(CODE_QUALITY_WORKFLOW.length + 1);
9286
- const major = /^v(\d+)(?:\.\d+){0,2}$/.exec(ref)?.[1];
9287
- if (major === void 0) {
9288
- defects.push(
9289
- `${file} job ${jobName} pins the code-quality workflow at ${ref}, whose release cannot be determined`
9290
- );
9291
- } else if (Number(major) < MIN_CODE_QUALITY_MAJOR) {
9292
- defects.push(
9293
- `${file} job ${jobName} pins the code-quality workflow at ${ref}, which predates CI_RUNS_ON; pin v${MIN_CODE_QUALITY_MAJOR} or later`
9294
- );
9295
- }
9296
- const inputs = job["with"];
9297
- if (isRecord(inputs) && inputs["runs-on"] !== void 0) {
9298
- defects.push(`${file} job ${jobName} passes runs-on, which overrides CI_RUNS_ON`);
9299
- }
9300
- }
9301
- }
9302
- return defects;
9434
+ function describeConventionsGuardSkip() {
9435
+ const guard = findConventionsGuard(process.cwd());
9436
+ return guard !== void 0 && `reported by checkTestFileConventions in ${guard}`;
9303
9437
  }
9304
- function findImageIncompatibleSteps(workflows) {
9305
- const defects = [];
9306
- for (const { file, jobs } of workflows) {
9307
- for (const [jobName, job] of jobs) {
9308
- if (!/\bvars\.CI_RUNS_ON\b/.test(JSON.stringify(job["runs-on"] ?? ""))) continue;
9309
- const steps = job["steps"];
9310
- if (!Array.isArray(steps)) continue;
9311
- const installed = /* @__PURE__ */ new Set();
9312
- for (const [index, step] of steps.entries()) {
9313
- if (!isRecord(step)) continue;
9314
- const stepLabel = typeof step["name"] === "string" ? step["name"] : `step ${index + 1}`;
9315
- const run = step["run"];
9316
- if (typeof run === "string") {
9317
- const missing = findUninstalledPrograms(run, installed);
9318
- if (missing.length > 0) {
9319
- defects.push(
9320
- `${file} job ${jobName}, ${stepLabel}: runs ${missing.join(", ")}, which the runner image lacks at that step`
9321
- );
9322
- }
9323
- }
9324
- for (const program of readInstalledPrograms(step)) installed.add(program);
9325
- }
9326
- }
9327
- }
9328
- return defects;
9438
+ function findConventionsGuard(cwd) {
9439
+ return findTestFiles(cwd).find(
9440
+ (relativePath) => importsSharedExport(readFileIn(cwd, relativePath), "checkTestFileConventions", SHARED_TESTS_MODULE)
9441
+ );
9329
9442
  }
9330
- function findUninstalledPrograms(run, installed) {
9331
- const programs = /* @__PURE__ */ new Set();
9332
- for (const match of run.matchAll(IMAGE_ABSENT_PROGRAM_PATTERN)) {
9333
- const program = match[1];
9334
- if (program === void 0) continue;
9335
- const provider = program === "npm" || program === "npx" ? "node" : program;
9336
- if (NEVER_INSTALLED_PROGRAMS.has(program) || !installed.has(provider)) programs.add(program);
9337
- }
9338
- return [...programs].toSorted();
9443
+ function findWorkspaceConfigs(workspace, pattern) {
9444
+ return findFiles([pattern], workspace.absolutePath).map((name) => `${workspace.dir}/${name}`);
9339
9445
  }
9340
- function getSelfHostedRunnerStatus(cwd, readFacts) {
9341
- if (readFacts !== void 0) return gatherSelfHostedRunnerStatus(cwd, readFacts);
9342
- let status = statusByCwd.get(cwd);
9343
- if (status === void 0) {
9344
- status = gatherSelfHostedRunnerStatus(cwd, (dir) => readGitHubFacts(dir));
9345
- statusByCwd.set(cwd, status);
9346
- }
9347
- return status;
9446
+ function hasViteConfigBeside(cwd, relativePath) {
9447
+ return findFiles([VITE_CONFIG_PATTERN], join4(cwd, dirname(relativePath))).length > 0;
9348
9448
  }
9349
- async function gatherSelfHostedRunnerStatus(cwd, readFacts) {
9350
- const facts = await readFacts(cwd);
9351
- if ("skip" in facts) return facts;
9352
- const failures = findSelfHostedRunnerFailures(facts, cwd);
9353
- let exempt;
9354
- try {
9355
- exempt = (await loadRootConfig(cwd)).selfHostedRunner?.exempt;
9356
- } catch (error) {
9357
- failures.push(`the nmr config cannot be loaded: ${describeError(error)}`);
9358
- }
9359
- return {
9360
- facts,
9361
- selection: readRunnerSelection(facts.ciRunsOn),
9362
- failures,
9363
- exempt,
9364
- toolTestFiles: findTestFiles(cwd).filter(
9365
- (file) => file.endsWith(".tool.test.ts") || file.endsWith(".tool.test.tsx")
9366
- )
9367
- };
9449
+ function isOwnedByReExportCheck(cwd, relativePath) {
9450
+ return isReExportOnly(readFileIn(cwd, relativePath)) && !hasViteConfigBeside(cwd, relativePath);
9368
9451
  }
9369
- function isNotFound(result) {
9370
- return /HTTP 404/.test(result.stderr);
9452
+ function isReExportOnly(content) {
9453
+ if (content === void 0) return false;
9454
+ const statements = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "").split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
9455
+ return statements.length > 0 && statements.every((line) => RE_EXPORT_LINE_PATTERN.test(line));
9371
9456
  }
9372
- function parseJson(text) {
9373
- try {
9374
- return JSON.parse(text);
9375
- } catch {
9376
- return void 0;
9457
+
9458
+ // .readyup/lib/workspace-build.ts
9459
+ import { existsSync as existsSync5, readdirSync as readdirSync4 } from "node:fs";
9460
+ import { join as join5 } from "node:path";
9461
+ import { fileExists as fileExists3, readFile as readFile5 } from "readyup/check-utils";
9462
+ var buildReadinessChecks = [
9463
+ {
9464
+ name: "all workspace packages can build",
9465
+ severity: "warn",
9466
+ check: allWorkspacePackagesCanBuild,
9467
+ fix: `Add "build": ":" to packages that don't need a build, or ensure packages that use the default nmr build have a tsconfig.json and a src/ directory`
9377
9468
  }
9378
- }
9379
- function readInstalledPrograms(step) {
9380
- const uses = step["uses"];
9381
- if (typeof uses !== "string") return [];
9382
- const action = uses.split("@", 1)[0];
9383
- if (action === "actions/setup-node") return ["node"];
9384
- if (action === "pnpm/action-setup") return ["pnpm"];
9385
- if (action !== "pnpm/setup") return [];
9386
- const inputs = isRecord(step["with"]) ? step["with"] : {};
9387
- const runtime = inputs["runtime"];
9388
- const installsNode = typeof runtime === "string" ? /^node\b/.test(runtime) : String(inputs["node-version-file"]) !== "false";
9389
- return installsNode ? ["pnpm", "node"] : ["pnpm"];
9390
- }
9391
- async function readOrganizationValue(nameWithOwner, cwd, runGh) {
9392
- const result = await runGh(["api", `repos/${nameWithOwner}/actions/organization-variables`], cwd);
9393
- if (!result.ok) return void 0;
9394
- const response = parseJson(result.stdout);
9395
- if (!isRecord(response) || !Array.isArray(response["variables"])) return void 0;
9396
- const variable = response["variables"].find(
9397
- (entry) => isRecord(entry) && entry["name"] === "CI_RUNS_ON"
9398
- );
9399
- return readVariableValue(variable);
9400
- }
9401
- function readVariableValue(variable) {
9402
- return isRecord(variable) && typeof variable["value"] === "string" ? variable["value"] : void 0;
9403
- }
9404
- function readWorkflows(cwd) {
9405
- const dir = join(cwd, ".github", "workflows");
9406
- if (!existsSync3(dir)) return [];
9407
- const workflows = [];
9408
- const files = readdirSync2(dir).filter((name) => /\.ya?ml$/.test(name)).toSorted();
9409
- for (const file of files) {
9410
- let document;
9411
- try {
9412
- document = (0, import_yaml2.parse)(readFile(join(dir, file)) ?? "");
9413
- } catch {
9414
- continue;
9469
+ ];
9470
+ function allWorkspacePackagesCanBuild() {
9471
+ const packagesDir = join5(process.cwd(), "packages");
9472
+ if (!existsSync5(packagesDir)) return true;
9473
+ const entries = readdirSync4(packagesDir, { withFileTypes: true });
9474
+ const failingPackages = [];
9475
+ for (const entry of entries) {
9476
+ if (!entry.isDirectory()) continue;
9477
+ const pkgPath = `packages/${entry.name}/package.json`;
9478
+ const content = readFile5(pkgPath);
9479
+ if (!content) continue;
9480
+ const hasBuildOverride = /"build"\s*:/.test(content);
9481
+ const hasDefaultBuildInputs = fileExists3(`packages/${entry.name}/tsconfig.json`) && existsSync5(join5(packagesDir, entry.name, "src"));
9482
+ if (!hasBuildOverride && !hasDefaultBuildInputs) {
9483
+ failingPackages.push(entry.name);
9415
9484
  }
9416
- if (!isRecord(document) || !isRecord(document["jobs"])) continue;
9417
- const jobs = Object.entries(document["jobs"]).filter(
9418
- (entry) => isRecord(entry[1])
9419
- );
9420
- workflows.push({ file: posix.join(".github/workflows", file), jobs });
9421
9485
  }
9422
- return workflows;
9423
- }
9424
- function runGhCommand(args, cwd) {
9425
- return new Promise((resolve) => {
9426
- void execFile("gh", args, { cwd, timeout: GH_TIMEOUT_MS }, (error, stdout, stderr) => {
9427
- if (error === null) {
9428
- resolve({ ok: true, stdout });
9429
- return;
9430
- }
9431
- resolve({ ok: false, isMissing: "code" in error && error.code === "ENOENT", stderr: stderr || error.message });
9432
- });
9433
- });
9486
+ if (failingPackages.length === 0) return true;
9487
+ return {
9488
+ ok: false,
9489
+ detail: `missing build override or tsconfig.json + src/: ${failingPackages.join(", ")}`
9490
+ };
9434
9491
  }
9492
+
9493
+ // .readyup/kits/default.ts
9494
+ var default_default = defineRdyKit({
9495
+ checklists: [
9496
+ {
9497
+ name: "nmr",
9498
+ checks: [
9499
+ // -- Setup --
9500
+ ...setupChecks,
9501
+ // -- Root script cleanup --
9502
+ ...rootScriptChecks,
9503
+ // -- Git hooks --
9504
+ ...gitHookChecks,
9505
+ // -- Workspace build readiness --
9506
+ ...buildReadinessChecks,
9507
+ // -- Bin targets --
9508
+ ...binTargetChecks,
9509
+ // -- Vitest projects --
9510
+ ...vitestChecks,
9511
+ // -- Shared Prettier config --
9512
+ ...prettierChecks,
9513
+ // -- Shared upgrade policy --
9514
+ ...tazeChecks,
9515
+ // -- Audit dependency --
9516
+ {
9517
+ name: "v11y-check in devDependencies",
9518
+ severity: "warn",
9519
+ check: () => hasDevDependency2("v11y-check"),
9520
+ fix: "pnpm add --save-dev v11y-check"
9521
+ },
9522
+ // -- Legacy script runner --
9523
+ ...legacyRunnerChecks
9524
+ ]
9525
+ },
9526
+ selfHostedRunnerChecklist
9527
+ ]
9528
+ });
9435
9529
  export {
9436
- cataloguedDependenciesUseCatalog,
9437
- default_default as default,
9438
- describeReadinessSkip,
9439
- describeRecommendationSkip,
9440
- describeSelfHostedRunnerSkip,
9441
- everyBinTargetIsACommittedWrapper,
9442
- everyBinWrapperTargetIsCoveredByFiles,
9443
- everyTestFileNamesItsTier,
9444
- everyTestFileSitsUnderTestsDir,
9445
- everyViteConfigHasVitestConfig,
9446
- findSelfHostedRunnerFailures,
9447
- hasSupportedEslintVersion,
9448
- hasSupportedStrictLintVersion,
9449
- noPnpmFieldInPackageJson,
9450
- noReExportOnlyVitestConfigs,
9451
- noRetiredVitestConfigs,
9452
- noUnguardedLefthookInstall,
9453
- noWorkspacesFieldInPackageJson,
9454
- prettierConfigBuildsOnSharedConfig,
9455
- readGitHubFacts,
9456
- readRunnerSelection,
9457
- recommendSelfHostedRunner,
9458
- selfHostedRunnerCanRunCi,
9459
- tazeConfigAvoidsClobberedOptions,
9460
- tazeConfigBuildsOnSharedConfig,
9461
- testSuiteGatesTestFileConventions,
9462
- vitestConfigBuildsOnSharedConfig,
9463
- vitestRootConfigBuildsOnSharedConfig
9530
+ default_default as default
9464
9531
  };