@williamthorsen/nmr 0.35.0 → 0.37.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 (41) hide show
  1. package/.meta/changelog.json +1499 -0
  2. package/.readyup/kits/default.js +176 -6
  3. package/.readyup/manifest.json +6 -6
  4. package/CHANGELOG.md +957 -0
  5. package/README.md +116 -1120
  6. package/agents/README.md +1 -1
  7. package/agents/guidance/rulebooks/nmr.md +2 -2
  8. package/dist/esm/commands/build.js +1 -1
  9. package/dist/esm/config.d.ts +0 -8
  10. package/dist/esm/config.js +66 -9
  11. package/dist/esm/context.d.ts +2 -0
  12. package/dist/esm/context.js +1 -0
  13. package/dist/esm/help.js +1 -1
  14. package/dist/esm/helpers/config-path.d.ts +8 -0
  15. package/dist/esm/helpers/config-path.js +7 -0
  16. package/dist/esm/helpers/filter-selection.d.ts +42 -0
  17. package/dist/esm/helpers/filter-selection.js +61 -0
  18. package/dist/esm/helpers/findClosestName.d.ts +7 -0
  19. package/dist/esm/helpers/findClosestName.js +38 -0
  20. package/dist/esm/helpers/package-json.js +1 -1
  21. package/dist/esm/runCli.js +71 -3
  22. package/dist/esm/runner.js +6 -5
  23. package/dist/esm/steps.d.ts +4 -0
  24. package/dist/esm/types.d.ts +3 -2
  25. package/dist/esm/verdict.d.ts +1 -1
  26. package/dist/esm/vitest-git-isolation.js +6 -0
  27. package/dist/esm/vitest-source-resolution.d.ts +37 -0
  28. package/dist/esm/vitest-source-resolution.js +205 -0
  29. package/dist/esm/vitest.d.ts +3 -2
  30. package/dist/esm/vitest.js +7 -6
  31. package/dist/esm/workspace.d.ts +9 -0
  32. package/dist/esm/workspace.js +17 -0
  33. package/docs/check-cache.md +176 -0
  34. package/docs/prettier.md +47 -0
  35. package/docs/reporting.md +90 -0
  36. package/docs/scripts.md +168 -0
  37. package/docs/upgrades.md +110 -0
  38. package/docs/utilities.md +64 -0
  39. package/docs/vitest.md +233 -0
  40. package/docs/workspace.md +11 -0
  41. package/package.json +12 -8
@@ -1,18 +1,18 @@
1
1
  /** @noformat -- @generated. Do not edit. Compiled by rdy. */
2
2
  /* eslint-disable */
3
- export const __readyupVersion = "0.35.0";
3
+ export const __readyupVersion = "0.36.0";
4
4
 
5
5
 
6
6
  // .readyup/kits/default.ts
7
7
  import { existsSync, globSync, readdirSync as readdirSync2 } from "node:fs";
8
- import { basename, dirname, join, sep } from "node:path";
8
+ import { basename, dirname, join, posix, sep } from "node:path";
9
9
 
10
- // ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.6.3/node_modules/@williamthorsen/toolbelt.errors/dist/esm/4-release/isError.js
10
+ // ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.7.0/node_modules/@williamthorsen/toolbelt.errors/dist/esm/4-release/isError.js
11
11
  function isError(error) {
12
12
  return error instanceof Error || Object.prototype.toString.call(error) === "[object Error]";
13
13
  }
14
14
 
15
- // ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.6.3/node_modules/@williamthorsen/toolbelt.errors/dist/esm/4-release/describeError.js
15
+ // ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.7.0/node_modules/@williamthorsen/toolbelt.errors/dist/esm/4-release/describeError.js
16
16
  function describeError(error) {
17
17
  try {
18
18
  if (isError(error) && typeof error.message === "string" && error.message !== "") {
@@ -34,6 +34,7 @@ import {
34
34
  hasMinDevDependencyVersion,
35
35
  hasPackageJsonField,
36
36
  isRecord,
37
+ listTrackedFiles,
37
38
  readFile,
38
39
  readPackageJson
39
40
  } from "readyup/check-utils";
@@ -128,9 +129,15 @@ var TEST_EXTENSIONS = "{ts,tsx}";
128
129
  var TEST_GLOB_PREFIX = `**/${TEST_DIR}/**`;
129
130
  var TEST_FILE_PATTERN = /\.test\.tsx?$/;
130
131
  var ALL_TEST_PATTERNS = [`${TEST_GLOB_PREFIX}/*.test.${TEST_EXTENSIONS}`];
132
+ function findMisplacedTestFiles(rootDir, options = {}) {
133
+ return walkTestFiles(rootDir, options, true);
134
+ }
131
135
  function findTestFiles(rootDir, options = {}) {
132
136
  return walkTestFiles(rootDir, options, false);
133
137
  }
138
+ function findUntieredTestFiles(rootDir, options = {}) {
139
+ return findTestFiles(rootDir, options).filter((file) => !hasTierInfix(file));
140
+ }
134
141
  function hasTierInfix(filePath) {
135
142
  const tiers = TIER_NAMES;
136
143
  return tiers.includes(path.basename(filePath).split(".").at(-3) ?? "");
@@ -251,6 +258,14 @@ var default_default = defineRdyKit({
251
258
  check: noRedundantRootScripts,
252
259
  fix: "Remove scripts from root package.json that nmr provides as built-in root scripts \u2014 invoke via nmr directly"
253
260
  },
261
+ // -- Git hooks -----------------------------------------------------------
262
+ {
263
+ name: "no root install script runs lefthook install unguarded",
264
+ severity: "warn",
265
+ quiet: true,
266
+ check: () => noUnguardedLefthookInstall(),
267
+ 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`"
268
+ },
254
269
  // -- Workspace build readiness -------------------------------------------
255
270
  {
256
271
  name: "all workspace packages can build",
@@ -258,6 +273,19 @@ var default_default = defineRdyKit({
258
273
  check: allWorkspacePackagesCanBuild,
259
274
  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`
260
275
  },
276
+ // -- Bin targets ---------------------------------------------------------
277
+ {
278
+ name: "every bin target is a committed wrapper",
279
+ severity: "error",
280
+ check: () => everyBinTargetIsACommittedWrapper(),
281
+ 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 reaches for it \u2014 and pnpm never retries, leaving the link missing for the life of the node_modules tree`
282
+ },
283
+ {
284
+ name: "every bin wrapper's build-output target is covered by files",
285
+ severity: "warn",
286
+ check: () => everyBinWrapperTargetIsCoveredByFiles(),
287
+ 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 ships pointing at build output missing from the tarball"
288
+ },
261
289
  // -- Vitest projects -----------------------------------------------------
262
290
  {
263
291
  name: "no retired Vitest config variants",
@@ -284,12 +312,26 @@ var default_default = defineRdyKit({
284
312
  check: () => everyViteConfigHasVitestConfig(),
285
313
  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 holding either name, so the Vite config otherwise wins and the projects model is never reached"
286
314
  },
315
+ {
316
+ name: "the test suite gates the test-file conventions",
317
+ severity: "warn",
318
+ check: () => testSuiteGatesTestFileConventions(),
319
+ 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 `exclude` the directory names that the repo passes to `testCollectionExclude`. Without it no test run reports an untiered or misplaced test file, and this kit reports them in its place against nmr's built-in exclusions alone"
320
+ },
287
321
  {
288
322
  name: "every test file names its isolation tier",
289
323
  severity: "error",
324
+ skip: () => describeConventionsGuardSkip(),
290
325
  check: () => everyTestFileNamesItsTier(),
291
326
  fix: `Rename each to <subject>[.<aspect>].<tier>.test.ts, naming one of ${TIER_NAMES.join(", ")}. Use tool for a test that reaches a program the environment supplies, which is where a retired .int. or .integration. file belongs. Only the segment before .test. selects a project, so an untiered file runs under the residual unit project and reports success`
292
327
  },
328
+ {
329
+ name: "every test file sits under a __tests__ directory",
330
+ severity: "error",
331
+ skip: () => describeConventionsGuardSkip(),
332
+ check: () => everyTestFileSitsUnderTestsDir(),
333
+ fix: "Move each into a __tests__ directory, the only place from which the shared Vitest config collects. No project collects 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 `exclude` instead, which this check then defers to"
334
+ },
293
335
  {
294
336
  name: "no package re-exports the ancestor Vitest config",
295
337
  severity: "recommend",
@@ -345,6 +387,8 @@ var default_default = defineRdyKit({
345
387
  ]
346
388
  });
347
389
  var SCAN_EXCLUDE_DIRS = /* @__PURE__ */ new Set([".git", "coverage", "dist", "node_modules"]);
390
+ var BUILD_OUTPUT_DIR = "dist";
391
+ var WRAPPER_TARGET_PATTERN = /['"](\.\.?\/[^'"]+)['"]/;
348
392
  var CONFIG_EXTENSIONS = "{ts,mts,cts,js,mjs,cjs}";
349
393
  var VITE_CONFIG_PATTERN = `vite.config.${CONFIG_EXTENSIONS}`;
350
394
  var VITEST_CONFIG_PATTERN = `vitest.config.${CONFIG_EXTENSIONS}`;
@@ -352,6 +396,7 @@ var SHARED_VITEST_MODULE = "@williamthorsen/nmr/vitest";
352
396
  var SHARED_PRETTIER_MODULE = "@williamthorsen/nmr/prettier";
353
397
  var INERT_PRETTIER_CONFIGS = [".prettierrc", ".prettierrc.{json,json5,yaml,yml,toml}"];
354
398
  var SHARED_TAZE_MODULE = "@williamthorsen/nmr/taze";
399
+ var SHARED_TESTS_MODULE = "@williamthorsen/nmr/tests";
355
400
  var INERT_TAZE_CONFIGS = [".tazerc", ".tazerc.json", "taze.config.json"];
356
401
  var CLOBBERED_TAZE_OPTIONS = [
357
402
  { key: "concurrency", pattern: /\bconcurrency\s*:/ },
@@ -361,6 +406,17 @@ var CLOBBERED_TAZE_OPTIONS = [
361
406
  { key: "requestTimeout", pattern: /\brequestTimeout\s*:/ }
362
407
  ];
363
408
  var RE_EXPORT_LINE_PATTERN = /^export\s*(?:\{\s*default\s*}|\*)\s*from\s*['"]\.\.\/[^'"]*['"];?$/;
409
+ var INSTALL_LIFECYCLE_SCRIPTS = [
410
+ "pnpm:devPreinstall",
411
+ "preinstall",
412
+ "install",
413
+ "postinstall",
414
+ "preprepare",
415
+ "prepare",
416
+ "postprepare"
417
+ ];
418
+ var LEFTHOOK_CHECK_INSTALL_PATTERN = /\blefthook\s+check-install\b/;
419
+ var LEFTHOOK_INSTALL_PATTERN = /\blefthook\s+install\b/;
364
420
  var MIN_ESLINT_VERSION = "10.0.0";
365
421
  var MIN_STRICT_LINT_VERSION = "9.3.0";
366
422
  var WORKSPACE_VERSION_MARKERS = ["catalog:", "workspace:"];
@@ -445,11 +501,80 @@ function discoverMemberWorkspaces() {
445
501
  return { ok: false, detail: `cannot enumerate workspaces: ${describeError(error)}` };
446
502
  }
447
503
  }
504
+ async function everyBinTargetIsACommittedWrapper() {
505
+ const discovery = discoverMemberWorkspaces();
506
+ if (!discovery.ok) return discovery;
507
+ const tracked = await listTrackedFiles();
508
+ const trackedPaths = tracked === void 0 ? void 0 : new Set(tracked);
509
+ const offenders = discovery.workspaces.flatMap(
510
+ (workspace) => readBinEntries(workspace).flatMap((entry) => {
511
+ const defect = describeBinTargetDefect(workspace, entry, trackedPaths);
512
+ return defect === void 0 ? [] : [`${describeBinEntry(workspace, entry)} (${defect})`];
513
+ })
514
+ );
515
+ if (offenders.length === 0) return true;
516
+ return { ok: false, detail: formatPaths(offenders) };
517
+ }
518
+ function everyBinWrapperTargetIsCoveredByFiles() {
519
+ const discovery = discoverMemberWorkspaces();
520
+ if (!discovery.ok) return discovery;
521
+ const cwd = process.cwd();
522
+ const offenders = discovery.workspaces.flatMap((workspace) => {
523
+ const files = workspace.packageJson["files"];
524
+ if (!Array.isArray(files)) return [];
525
+ const published = new Set(files.flatMap((entry) => typeof entry === "string" ? [readFirstSegment(entry)] : []));
526
+ return readBinEntries(workspace).flatMap((entry) => {
527
+ const target = readWrapperTarget(cwd, workspace, entry);
528
+ if (target === void 0 || published.has(readFirstSegment(target))) return [];
529
+ return [`${describeBinEntry(workspace, entry)} -> ${target}`];
530
+ });
531
+ });
532
+ if (offenders.length === 0) return true;
533
+ return { ok: false, detail: formatPaths(offenders) };
534
+ }
535
+ function describeBinEntry(workspace, entry) {
536
+ return `${workspace.name ?? workspace.dir}:${entry.command} -> ${entry.target}`;
537
+ }
538
+ function describeBinTargetDefect(workspace, entry, trackedPaths) {
539
+ if (readFirstSegment(entry.target) === BUILD_OUTPUT_DIR) return `names a path under ${BUILD_OUTPUT_DIR}/`;
540
+ if (trackedPaths === void 0) return void 0;
541
+ return trackedPaths.has(`${workspace.dir}/${entry.target}`) ? void 0 : "untracked";
542
+ }
543
+ function normalizeBinTarget(target) {
544
+ return target.replace(/^\.\//, "");
545
+ }
546
+ function readBinEntries(workspace) {
547
+ const bin = workspace.packageJson["bin"];
548
+ if (typeof bin === "string") {
549
+ const command = workspace.name?.split("/").at(-1) ?? basename(workspace.dir);
550
+ return [{ command, target: normalizeBinTarget(bin) }];
551
+ }
552
+ if (!isRecord(bin)) return [];
553
+ return Object.entries(bin).flatMap(
554
+ ([command, target]) => typeof target === "string" ? [{ command, target: normalizeBinTarget(target) }] : []
555
+ );
556
+ }
557
+ function readFirstSegment(entry) {
558
+ return normalizeBinTarget(entry).split("/", 1).at(0) ?? "";
559
+ }
560
+ function readWrapperTarget(cwd, workspace, entry) {
561
+ if (readFirstSegment(entry.target) === BUILD_OUTPUT_DIR) return void 0;
562
+ const content = readFileIn(cwd, `${workspace.dir}/${entry.target}`);
563
+ if (content === void 0) return void 0;
564
+ const specifier = WRAPPER_TARGET_PATTERN.exec(content)?.[1];
565
+ if (specifier === void 0) return void 0;
566
+ return posix.normalize(posix.join(posix.dirname(entry.target), specifier));
567
+ }
448
568
  function everyTestFileNamesItsTier(cwd = process.cwd()) {
449
- const untiered = findTestFiles(cwd).filter((path2) => !hasTierInfix(path2));
569
+ const untiered = findUntieredTestFiles(cwd);
450
570
  if (untiered.length === 0) return true;
451
571
  return { ok: false, detail: formatPaths(untiered) };
452
572
  }
573
+ function everyTestFileSitsUnderTestsDir(cwd = process.cwd()) {
574
+ const misplaced = findMisplacedTestFiles(cwd);
575
+ if (misplaced.length === 0) return true;
576
+ return { ok: false, detail: formatPaths(misplaced) };
577
+ }
453
578
  function everyViteConfigHasVitestConfig() {
454
579
  const discovery = discoverMemberWorkspaces();
455
580
  if (!discovery.ok) return discovery;
@@ -472,7 +597,7 @@ function formatPaths(paths) {
472
597
  ${paths.map((path2) => ` ${path2}`).join("\n")}`;
473
598
  }
474
599
  function getMinVersion() {
475
- const picked = { "version": "0.35.0" };
600
+ const picked = { "version": "0.37.0" };
476
601
  if (typeof picked["version"] !== "string") {
477
602
  throw new TypeError("nmr/package.json: 'version' must be a string");
478
603
  }
@@ -538,6 +663,17 @@ function noRetiredVitestConfigs(cwd = process.cwd()) {
538
663
  cwd
539
664
  );
540
665
  }
666
+ function noUnguardedLefthookInstall(cwd = process.cwd()) {
667
+ const scripts = readRootScripts(cwd);
668
+ const unguarded = INSTALL_LIFECYCLE_SCRIPTS.flatMap((name) => {
669
+ const command = scripts[name];
670
+ if (typeof command !== "string") return [];
671
+ const isUnguarded = LEFTHOOK_INSTALL_PATTERN.test(command) && !LEFTHOOK_CHECK_INSTALL_PATTERN.test(command);
672
+ return isUnguarded ? [`${name}: ${command}`] : [];
673
+ });
674
+ if (unguarded.length === 0) return true;
675
+ return { ok: false, detail: formatPaths(unguarded) };
676
+ }
541
677
  function noWorkspaceRunScriptReferences() {
542
678
  const packagesDir = join(process.cwd(), "packages");
543
679
  if (!existsSync(packagesDir)) return true;
@@ -572,6 +708,19 @@ function readPnpmFieldKeys(content) {
572
708
  const pnpm = parsed["pnpm"];
573
709
  return isRecord(pnpm) ? Object.keys(pnpm).toSorted() : void 0;
574
710
  }
711
+ function readRootScripts(cwd) {
712
+ const content = readFileIn(cwd, "package.json");
713
+ if (content === void 0) return {};
714
+ let parsed;
715
+ try {
716
+ parsed = JSON.parse(content);
717
+ } catch {
718
+ return {};
719
+ }
720
+ if (!isRecord(parsed)) return {};
721
+ const scripts = parsed["scripts"];
722
+ return isRecord(scripts) ? scripts : {};
723
+ }
575
724
  function resolvesVersionViaWorkspace(range) {
576
725
  return WORKSPACE_VERSION_MARKERS.some((marker) => range.startsWith(marker));
577
726
  }
@@ -603,6 +752,22 @@ function describeMissingTazeConfig(cwd) {
603
752
  if (inert.length > 0) return `holds no code to call the factory: ${inert.join(", ")}`;
604
753
  return "taze.config.ts is missing";
605
754
  }
755
+ function testSuiteGatesTestFileConventions(cwd = process.cwd()) {
756
+ if (findConventionsGuard(cwd) !== void 0) return true;
757
+ return {
758
+ ok: false,
759
+ detail: `no test file under __tests__ imports checkTestFileConventions from ${SHARED_TESTS_MODULE}`
760
+ };
761
+ }
762
+ function describeConventionsGuardSkip() {
763
+ const guard = findConventionsGuard(process.cwd());
764
+ return guard === void 0 ? false : `reported by checkTestFileConventions in ${guard}`;
765
+ }
766
+ function findConventionsGuard(cwd) {
767
+ return findTestFiles(cwd).find(
768
+ (relativePath) => importsSharedExport(readFileIn(cwd, relativePath), "checkTestFileConventions", SHARED_TESTS_MODULE)
769
+ );
770
+ }
606
771
  function toolVersionsHasNoPnpm() {
607
772
  const content = readFile(".tool-versions");
608
773
  if (content === void 0) return true;
@@ -626,16 +791,21 @@ function vitestRootConfigBuildsOnSharedConfig(cwd = process.cwd()) {
626
791
  }
627
792
  export {
628
793
  default_default as default,
794
+ everyBinTargetIsACommittedWrapper,
795
+ everyBinWrapperTargetIsCoveredByFiles,
629
796
  everyTestFileNamesItsTier,
797
+ everyTestFileSitsUnderTestsDir,
630
798
  everyViteConfigHasVitestConfig,
631
799
  hasSupportedEslintVersion,
632
800
  hasSupportedStrictLintVersion,
633
801
  noPnpmFieldInPackageJson,
634
802
  noReExportOnlyVitestConfigs,
635
803
  noRetiredVitestConfigs,
804
+ noUnguardedLefthookInstall,
636
805
  prettierConfigBuildsOnSharedConfig,
637
806
  tazeConfigAvoidsClobberedOptions,
638
807
  tazeConfigBuildsOnSharedConfig,
808
+ testSuiteGatesTestFileConventions,
639
809
  vitestConfigBuildsOnSharedConfig,
640
810
  vitestRootConfigBuildsOnSharedConfig
641
811
  };
@@ -3,7 +3,7 @@
3
3
  "kits": [
4
4
  {
5
5
  "bundledDependencies": {
6
- "@williamthorsen/toolbelt.errors": "0.6.3"
6
+ "@williamthorsen/toolbelt.errors": "0.7.0"
7
7
  },
8
8
  "checklists": [
9
9
  "nmr"
@@ -11,12 +11,12 @@
11
11
  "esbuildVersion": "0.28.2",
12
12
  "inputs": [
13
13
  {
14
- "hash": "b67d9796",
14
+ "hash": "488459c0",
15
15
  "kind": "module",
16
16
  "path": "kits/default.ts"
17
17
  },
18
18
  {
19
- "hash": "7a897007",
19
+ "hash": "d4b2e208",
20
20
  "kind": "inline",
21
21
  "path": "../package.json",
22
22
  "paths": [
@@ -41,10 +41,10 @@
41
41
  ],
42
42
  "name": "default",
43
43
  "path": "kits/default.js",
44
- "readyupVersion": "0.35.0",
44
+ "readyupVersion": "0.36.0",
45
45
  "source": "kits/default.ts",
46
- "sourceHash": "b67d9796",
47
- "targetHash": "3cd6b2d0"
46
+ "sourceHash": "488459c0",
47
+ "targetHash": "d1c8d02c"
48
48
  }
49
49
  ]
50
50
  }