@williamthorsen/nmr 0.39.0 → 0.40.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 (88) hide show
  1. package/.meta/changelog.json +128 -4
  2. package/.readyup/kits/default.js +47 -32
  3. package/.readyup/manifest.json +12 -7
  4. package/CHANGELOG.md +38 -0
  5. package/README.md +41 -73
  6. package/agents/README.md +8 -8
  7. package/agents/guidance/rulebooks/nmr.md +8 -8
  8. package/bin/ensure-prepublish-hooks.js +1 -1
  9. package/bin/nmr-clean.js +1 -1
  10. package/bin/nmr-compile.js +1 -1
  11. package/bin/nmr-fmt.js +1 -1
  12. package/bin/nmr-report-catalog.js +1 -1
  13. package/bin/nmr-report-overrides.js +1 -1
  14. package/bin/nmr-taze.js +1 -1
  15. package/bin/nmr.js +1 -1
  16. package/dist/esm/UserError.d.ts +1 -2
  17. package/dist/esm/check-cache.d.ts +59 -61
  18. package/dist/esm/commands/build-output.d.ts +35 -36
  19. package/dist/esm/commands/build.d.ts +13 -14
  20. package/dist/esm/commands/build.js +3 -3
  21. package/dist/esm/commands/clean.d.ts +6 -6
  22. package/dist/esm/commands/ensure-prepublish-hooks.d.ts +5 -5
  23. package/dist/esm/commands/fmt.d.ts +8 -8
  24. package/dist/esm/commands/fmt.js +1 -1
  25. package/dist/esm/commands/report-catalog.d.ts +5 -7
  26. package/dist/esm/commands/report-catalog.js +1 -1
  27. package/dist/esm/commands/report-overrides.d.ts +0 -2
  28. package/dist/esm/commands/report-overrides.js +1 -1
  29. package/dist/esm/commands/taze.d.ts +9 -8
  30. package/dist/esm/config.d.ts +5 -5
  31. package/dist/esm/config.js +3 -3
  32. package/dist/esm/context.d.ts +1 -2
  33. package/dist/esm/default-scripts.d.ts +5 -6
  34. package/dist/esm/defineConfig.d.ts +1 -1
  35. package/dist/esm/git-ignored-paths.d.ts +9 -0
  36. package/dist/esm/git-ignored-paths.js +10 -0
  37. package/dist/esm/glyphs.d.ts +3 -3
  38. package/dist/esm/help.d.ts +2 -2
  39. package/dist/esm/helpers/clampToBytes.d.ts +4 -5
  40. package/dist/esm/helpers/config-path.d.ts +0 -2
  41. package/dist/esm/helpers/createBoundedBuffer.d.ts +2 -2
  42. package/dist/esm/helpers/deriveExcerpt.d.ts +4 -5
  43. package/dist/esm/helpers/duration.d.ts +8 -9
  44. package/dist/esm/helpers/filter-selection.d.ts +19 -19
  45. package/dist/esm/helpers/findClosestName.d.ts +1 -1
  46. package/dist/esm/helpers/hook-name.d.ts +2 -5
  47. package/dist/esm/helpers/package-json.d.ts +9 -8
  48. package/dist/esm/helpers/readStringValues.d.ts +2 -3
  49. package/dist/esm/helpers/reportClosing.d.ts +8 -9
  50. package/dist/esm/helpers/transcript.d.ts +13 -13
  51. package/dist/esm/helpers/type-guards.d.ts +1 -0
  52. package/dist/esm/output-style.d.ts +12 -13
  53. package/dist/esm/prettier.d.ts +2 -2
  54. package/dist/esm/recording.d.ts +20 -15
  55. package/dist/esm/recording.js +7 -7
  56. package/dist/esm/replay-assembly.d.ts +10 -10
  57. package/dist/esm/report-format.d.ts +14 -19
  58. package/dist/esm/reportCliFailure.d.ts +2 -3
  59. package/dist/esm/resolve-scripts.d.ts +4 -5
  60. package/dist/esm/resolveBinStyles.d.ts +4 -5
  61. package/dist/esm/resolver.d.ts +14 -18
  62. package/dist/esm/runCli.d.ts +6 -6
  63. package/dist/esm/runCli.js +21 -21
  64. package/dist/esm/runner.d.ts +19 -19
  65. package/dist/esm/steps.d.ts +43 -42
  66. package/dist/esm/taze.d.ts +1 -1
  67. package/dist/esm/tests.d.ts +18 -13
  68. package/dist/esm/tests.js +2 -2
  69. package/dist/esm/tiers.d.ts +27 -23
  70. package/dist/esm/tiers.js +6 -2
  71. package/dist/esm/types.d.ts +5 -5
  72. package/dist/esm/verbosity.d.ts +22 -20
  73. package/dist/esm/verdict.d.ts +33 -33
  74. package/dist/esm/verdict.js +1 -1
  75. package/dist/esm/vitest-source-resolution.d.ts +11 -11
  76. package/dist/esm/vitest-source-resolution.js +2 -2
  77. package/dist/esm/vitest.d.ts +25 -24
  78. package/dist/esm/vitest.js +16 -8
  79. package/dist/esm/workspace.d.ts +4 -4
  80. package/docs/check-cache.md +57 -57
  81. package/docs/prettier.md +10 -10
  82. package/docs/reporting.md +46 -46
  83. package/docs/scripts.md +29 -29
  84. package/docs/upgrades.md +18 -18
  85. package/docs/utilities.md +13 -13
  86. package/docs/vitest.md +64 -62
  87. package/docs/workspace.md +19 -19
  88. package/package.json +5 -4
@@ -1,10 +1,10 @@
1
1
  import type { Plugin } from 'vite';
2
2
  /**
3
- * Builds the plugin that resolves a package's `source` export condition, for a package whose files sit outside
3
+ * Builds the plugin that resolves a package's `source` export condition, for a package whose files are outside
4
4
  * every `node_modules`.
5
5
  *
6
6
  * The condition cannot be emitted as a `resolve.conditions` entry instead: Vitest turns the server environment's
7
- * conditions into `--conditions` flags on the worker process, where Node applies them to every package it
7
+ * conditions into `--conditions` flags on the worker process, where Node applies them to every package that it
8
8
  * resolves natively. Node refuses to strip types from a file under `node_modules`, so a dependency declaring a
9
9
  * TypeScript `source` entry fails to load as soon as the condition reaches that far.
10
10
  *
@@ -22,16 +22,16 @@ export interface SourceResolutionContext {
22
22
  packageDirs?: Map<string, string | undefined>;
23
23
  }
24
24
  /**
25
- * Resolves a bare specifier to the file that its package's `source` condition names, or nothing where the condition
26
- * does not reach it.
25
+ * Resolves a bare specifier to the file that its package's `source` condition names, or to nothing when the
26
+ * condition does not reach it.
27
27
  *
28
- * Answers a bare specifier naming a package whose real directory sits outside every `node_modules`. Every other
29
- * specifier returns nothing -- such as a relative or absolute path, a virtual module, a builtin, or a package under
30
- * `node_modules` -- so Vite resolves the specifier through its own conditions. Throws where the condition reaches no
31
- * file, because falling through to the build output is the staleness that resolving from source exists to prevent,
32
- * and nothing in a run reports it.
28
+ * Answers a bare specifier naming a package whose real directory is outside every `node_modules`. Returns nothing
29
+ * for every other specifier -- such as a relative or absolute path, a virtual module, a builtin, or a package under
30
+ * `node_modules` -- so Vite resolves the specifier through its own conditions. Throws when the condition does not
31
+ * reach any file, because falling through to the build output is the staleness that resolving from source exists
32
+ * to prevent, and nothing in a run reports it.
33
33
  *
34
- * A query or hash suffix is split off before the `exports` lookup and re-appended to the resolved path, as Vite
35
- * does, so `pkg/icon.svg?raw` matches the `./icon.svg` entry and still reaches the plugin that reads the suffix.
34
+ * Because a query or hash suffix is split off before the `exports` lookup and re-appended to the resolved path, as
35
+ * Vite does, `pkg/icon.svg?raw` matches the `./icon.svg` entry and still reaches the plugin that reads the suffix.
36
36
  */
37
37
  export declare function resolveSourceTarget(specifier: string, importer: string | undefined, context: SourceResolutionContext): string | undefined;
@@ -114,9 +114,9 @@ function findTargetFile(declaredPath) {
114
114
  }
115
115
  function formatMissingTarget(name, subpath, declaredTarget, target) {
116
116
  return [
117
- `Package "${name}" declares a \`source\` export for "${subpath}" at "${declaredTarget}", which reaches no file.`,
117
+ `Package "${name}" declares a \`source\` export for "${subpath}" at "${declaredTarget}", which does not reach any file.`,
118
118
  `Looked for ${target}, the same path under each of ${TARGET_EXTENSIONS.join(', ')}, and an index under it.`,
119
- 'Point the condition at a file that exists, or remove it so the package resolves through its other conditions.',
119
+ 'Point the condition at a file that exists, or remove it so that the package resolves through its other conditions.',
120
120
  ].join(' ');
121
121
  }
122
122
  function isFile(candidate) {
@@ -3,8 +3,8 @@ import type { InlineConfig, ProjectConfig } from 'vitest/node';
3
3
  import { type TierName } from './tiers.js';
4
4
  export type { TierName } from './tiers.js';
5
5
  /**
6
- * Test options Vitest honours only at the root of a `projects` config. Derived from Vitest's own types rather than
7
- * hand-listed, so an option that changes scope in a later release changes scope here too.
6
+ * Test options that Vitest honours only at the root of a `projects` config. Derived from Vitest's own types, so an
7
+ * option that changes scope in a later release changes scope here too.
8
8
  */
9
9
  export type RootTestOptions = Omit<InlineConfig, keyof ProjectConfig>;
10
10
  /** Root-scoped overrides: Vite-level options plus the test options that survive at the root. */
@@ -16,7 +16,7 @@ export interface VitestConfigOptions {
16
16
  * Merged into the root config, which every project inherits because each declares `extends: true`.
17
17
  *
18
18
  * `resolve` is per-environment. Vitest resolves a test's own imports through the server environment, so a
19
- * condition meant for them goes under `ssr`; a top-level `resolve.conditions` entry reaches the client
19
+ * condition meant for them goes under `ssr`; a top-level `resolve.conditions` entry applies to the client
20
20
  * environment, which only browser-mode tests resolve through.
21
21
  */
22
22
  root?: RootOverrides;
@@ -29,41 +29,42 @@ export interface VitestConfigOptions {
29
29
  */
30
30
  tiers?: Partial<Record<TierName, ProjectConfig>>;
31
31
  /**
32
- * Directory basenames kept out of every project's collection, additive to the ones the shared config always
33
- * prunes. Each name is matched at any depth, and every layer's entries are concatenated rather than the last
34
- * winning, matching the config's rule for arrays.
32
+ * Directory basenames kept out of every project's collection, additive to the ones that the shared config always
33
+ * prunes and to the paths that git ignores. Each name is matched at any depth, and every layer's entries are
34
+ * concatenated rather than the last winning, matching the config's rule for arrays.
35
35
  *
36
36
  * The same array declares the scope of nmr's exported test-file conventions check, so the sweep and the
37
37
  * collection glob cannot drift. Excluding a directory from the sweep alone leaves a test file that runs and
38
- * reports nothing; excluding it from collection alone leaves a report a consumer cannot act on. A repo wanting
38
+ * reports nothing; excluding it from collection alone leaves a report that a consumer cannot act on. A repo wanting
39
39
  * a glob rather than a directory name has the `project` seam's own `exclude`.
40
40
  */
41
41
  testCollectionExclude?: readonly string[];
42
42
  /**
43
- * Loads nmr's git-isolation setup file into every project, ahead of any the layers supply. Defaults to `true`.
44
- * Turn it off only where a suite is meant to read the developer's own git configuration.
43
+ * Loads nmr's git-isolation setup file into every project, ahead of any that the layers supply. Defaults to `true`.
44
+ * Turn it off only when a suite is meant to read the developer's own git configuration.
45
45
  */
46
46
  shouldIsolateGit?: boolean;
47
47
  /**
48
- * Resolves a package whose files sit outside every `node_modules` through its `source` export condition, so a
49
- * suite runs without a prior build. Defaults to `true`. A package declaring no such condition is unaffected,
50
- * and so is every package under `node_modules`, which Node resolves and which the condition never reaches.
48
+ * Resolves a package whose files are outside every `node_modules` through its `source` export condition, so a
49
+ * suite runs without a prior build. Defaults to `true`. A package that does not declare a `source` condition is
50
+ * unaffected, and so is every package under `node_modules`, which Node resolves and to which the condition never
51
+ * applies.
51
52
  */
52
53
  shouldResolveFromSource?: boolean;
53
54
  /**
54
- * Resolves a specifier through the `paths` a `tsconfig.json` declares, so a test reaches an alias the way `tsc`
55
- * does. Defaults to `false`, matching Vite, and requires Vite 8.
55
+ * Resolves a test's aliased specifier through the `paths` that a `tsconfig.json` declares, the way `tsc` does.
56
+ * Defaults to `false`, matching Vite, and requires Vite 8.
56
57
  *
57
- * Not a default, because both directions are safe to leave to the consumer: omitting it fails loudly with an
58
- * unresolved import, while turning it on for a repo that declares `paths` for `tsc` alone changes which module a
59
- * specifier reaches with nothing in the run reporting it. A repo declaring no `paths` is unaffected either way.
58
+ * Without it, an alias fails loudly as an unresolved import. With it, a repo that declares `paths` for `tsc` alone
59
+ * changes which module a specifier reaches, and nothing in the run reports the change. A repo that does not declare
60
+ * `paths` is unaffected either way.
60
61
  */
61
62
  tsconfigPaths?: boolean;
62
63
  }
63
64
  export interface RootVitestConfigOptions extends VitestConfigOptions {
64
65
  /**
65
- * An absolute path to the monorepo root, which must hold `pnpm-workspace.yaml`. A root config sits at that
66
- * directory by construction, so this is `import.meta.dirname`. Stated rather than searched for: resolving it
66
+ * An absolute path to the monorepo root, which must contain `pnpm-workspace.yaml`. A root config is in that
67
+ * directory by construction, so this is `import.meta.dirname`. Stated rather than searched for: Resolving it
67
68
  * from the working directory would make the config describe whichever monorepo the run happened to start in.
68
69
  */
69
70
  monorepoRoot: string;
@@ -75,18 +76,18 @@ type RootConfigLayers = [...(VitestConfigOptions | undefined)[], RootVitestConfi
75
76
  * projects. Select them at run time with `--project`, which unions when repeated and accepts negation.
76
77
  *
77
78
  * Layers fold left to right, later winning and arrays composing, so a config file shares settings by passing a
78
- * layer ahead of its own. Merging two of this function's *outputs* is not the way: both declare the same four
79
+ * layer ahead of its own. Merging two of this function's *outputs* is not the way: Both declare the same four
79
80
  * project names, which Vitest rejects at startup.
80
81
  *
81
- * An `undefined` layer is skipped, so `defineVitestConfig(shared, isCI ? ciLayer : undefined)` composes.
82
+ * An `undefined` layer is skipped, which lets `defineVitestConfig(shared, isCI ? ciLayer : undefined)` compose.
82
83
  */
83
84
  export declare function defineVitestConfig(...layers: (VitestConfigOptions | undefined)[]): ViteUserConfig;
84
85
  /**
85
86
  * Builds the shared Vitest config for repo-root tests. Excludes every workspace package from all projects, and
86
- * reports no coverage of its own — packages cover their own sources.
87
+ * does not report any coverage of its own; packages cover their own sources.
87
88
  *
88
- * `monorepoRoot` rides on the last layer, which is the config file's own: a shared layer describes settings, not
89
+ * `monorepoRoot` is set on the last layer, which is the config file's own: A shared layer describes settings, not
89
90
  * which repo they belong to, and only the root config's `import.meta.dirname` states this one. The guard below
90
- * still catches the JavaScript config that types never reach.
91
+ * still catches the JavaScript config that type checking never covers.
91
92
  */
92
93
  export declare function defineRootVitestConfig(...layers: RootConfigLayers): ViteUserConfig;
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { defaultExclude, mergeConfig } from 'vitest/config';
4
+ import { listGitIgnoredPaths } from "./git-ignored-paths.js";
4
5
  import { isObject } from "./helpers/type-guards.js";
5
6
  import { ALL_TEST_PATTERNS, buildTierPatterns, TEST_COLLECTION_EXCLUDE, TIER_NAMES } from "./tiers.js";
6
7
  import { createSourceResolutionPlugin } from "./vitest-source-resolution.js";
@@ -13,6 +14,7 @@ const CLIENT_CONDITIONS = ['module', 'browser', 'development|production'];
13
14
  const SERVER_CONDITIONS = ['module', 'node', 'development|production'];
14
15
  const COVERAGE_EXCLUDE = ['**/__{fixtures,mocks,tests}__/**', '**/index.ts', '**/*.d.ts'];
15
16
  const PACKAGE_COVERAGE_INCLUDE = ['**/src/**/*.{ts,tsx}'];
17
+ const GLOB_SYNTAX_PATTERN = /[!()*+?@[\]{}]/g;
16
18
  const RECOGNIZED_OPTIONS = {
17
19
  project: true,
18
20
  root: true,
@@ -32,10 +34,10 @@ const RETIRED_OPTION_KEYS = new Map([
32
34
  ['isolateGit', 'shouldIsolateGit'],
33
35
  ['resolveFromSource', 'shouldResolveFromSource'],
34
36
  ]);
35
- const MISSING_MONOREPO_ROOT = 'defineRootVitestConfig requires `monorepoRoot`, an absolute path to the directory holding pnpm-workspace.yaml. Pass `import.meta.dirname` in the last options layer, from the root config.';
37
+ const MISSING_MONOREPO_ROOT = 'defineRootVitestConfig requires `monorepoRoot`, an absolute path to the directory that contains pnpm-workspace.yaml. Pass `import.meta.dirname` in the last options layer, from the root config.';
36
38
  export function defineVitestConfig(...layers) {
37
39
  assertOptionKeys(layers, RECOGNIZED_OPTION_KEYS);
38
- return buildConfig(layers, { coverageInclude: PACKAGE_COVERAGE_INCLUDE });
40
+ return buildConfig(layers, { coverageInclude: PACKAGE_COVERAGE_INCLUDE, ignoredPathsRoot: process.cwd() });
39
41
  }
40
42
  export function defineRootVitestConfig(...layers) {
41
43
  const lastLayer = layers.at(-1);
@@ -46,11 +48,12 @@ export function defineRootVitestConfig(...layers) {
46
48
  assertOptionKeys(layers, RECOGNIZED_ROOT_OPTION_KEYS);
47
49
  return buildConfig(layers, {
48
50
  coverageInclude: [],
51
+ ignoredPathsRoot: monorepoRoot,
49
52
  projectExclude: getWorkspaceExcludePatterns(monorepoRoot),
50
53
  projectRoot: monorepoRoot,
51
54
  });
52
55
  }
53
- function buildConfig(declaredLayers, { coverageInclude, projectExclude = [], projectRoot }) {
56
+ function buildConfig(declaredLayers, { coverageInclude, ignoredPathsRoot, projectExclude = [], projectRoot }) {
54
57
  const layers = declaredLayers.filter((layer) => layer !== undefined);
55
58
  assertKnownTiers(layers);
56
59
  const resolve = {
@@ -69,7 +72,7 @@ function buildConfig(declaredLayers, { coverageInclude, projectExclude = [], pro
69
72
  provider: 'v8',
70
73
  },
71
74
  passWithNoTests: true,
72
- projects: buildProjects(layers, projectExclude, projectRoot),
75
+ projects: buildProjects(layers, projectExclude, projectRoot, ignoredPathsRoot),
73
76
  silent: 'passed-only',
74
77
  watch: false,
75
78
  },
@@ -81,7 +84,7 @@ function buildConfig(declaredLayers, { coverageInclude, projectExclude = [], pro
81
84
  }
82
85
  return mergedConfig;
83
86
  }
84
- function buildProjects(layers, extraExclude, projectRoot) {
87
+ function buildProjects(layers, extraExclude, projectRoot, ignoredPathsRoot) {
85
88
  const projectTiers = [
86
89
  { exclude: TIERED_PATTERNS, include: ALL_TEST_PATTERNS, name: RESIDUAL_TIER },
87
90
  ...NAMED_TIERS.map((tier) => ({
@@ -91,7 +94,7 @@ function buildProjects(layers, extraExclude, projectRoot) {
91
94
  timeoutMs: TIER_TIMEOUT_MS,
92
95
  })),
93
96
  ];
94
- const collectionExclude = buildCollectionExclude(layers);
97
+ const collectionExclude = buildCollectionExclude(layers, ignoredPathsRoot);
95
98
  const shouldIsolateGit = resolveFlag(layers, 'shouldIsolateGit', true);
96
99
  return projectTiers.map(({ exclude, include, name, timeoutMs }) => {
97
100
  const project = {
@@ -150,10 +153,15 @@ function assertOptionKeys(layers, lastLayerKeys) {
150
153
  `${formatOptionKeys(unrecognizedKeys)}. Recognized: ${formatOptionKeys(recognizedKeys)}.`);
151
154
  });
152
155
  }
153
- function buildCollectionExclude(layers) {
156
+ function buildCollectionExclude(layers, ignoredPathsRoot) {
154
157
  const declaredDirs = layers.flatMap((layer) => layer.testCollectionExclude ?? []);
155
158
  const globs = [...TEST_COLLECTION_EXCLUDE, ...declaredDirs].map((dir) => `**/${dir}/**`);
156
- return [...new Set([...defaultExclude, ...globs])];
159
+ const ignoredGlobs = listGitIgnoredPaths(ignoredPathsRoot).map(buildIgnoredPathGlob);
160
+ return [...new Set([...defaultExclude, ...globs, ...ignoredGlobs])];
161
+ }
162
+ function buildIgnoredPathGlob(ignoredPath) {
163
+ const escapedPath = ignoredPath.replaceAll(GLOB_SYNTAX_PATTERN, String.raw `\$&`);
164
+ return escapedPath.endsWith('/') ? `${escapedPath}**` : escapedPath;
157
165
  }
158
166
  function formatOptionKeys(keys) {
159
167
  return keys
@@ -2,14 +2,14 @@ export type { EmptyWorkspaceCause, WorkspaceResolution } from '@williamthorsen/n
2
2
  export { isMonorepoRoot, readWorkspaceOverrides, readWorkspacePackageNames, resolveWorkspace, } from '@williamthorsen/nmr-core/workspace';
3
3
  /**
4
4
  * Finds the monorepo root by walking up from `startDir` to find `pnpm-workspace.yaml`.
5
- * Throws if no workspace root is found.
5
+ * Throws if it does not find a workspace root in `startDir` or any of its parent directories.
6
6
  */
7
7
  export declare function findMonorepoRoot(startDir?: string): string;
8
8
  /**
9
9
  * Reads the workspace patterns from `pnpm-workspace.yaml` and resolves them to absolute package
10
- * directories, applying pnpm's pattern semantics — including `!`-prefixed exclusions.
10
+ * directories, applying pnpm's pattern semantics, including `!`-prefixed exclusions.
11
11
  *
12
- * Returns an empty array when the manifest declares no usable `packages` list, and throws when
13
- * `monorepoRoot` holds no manifest at all — the caller named a directory that is not a monorepo root.
12
+ * Returns an empty array when the manifest does not declare a usable `packages` list, and throws when
13
+ * `monorepoRoot` does not contain a manifest at all.
14
14
  */
15
15
  export declare function getWorkspacePackageDirs(monorepoRoot: string): string[];
@@ -4,9 +4,9 @@ How nmr skips a check that already passed on the same working tree, what decides
4
4
 
5
5
  The [README](../README.md#check-result-cache) shows a skip in action.
6
6
 
7
- Composite commands expand into child `nmr` processes, so one green `nmr ci` records a pass for every cacheable command in the chain, at every scope it ran at. A later `nmr check`, `nmr typecheck`, or `nmr -F core test` on the same tree skips too. The skip is [reported](reporting.md#what-nmr-reports) in every verbosity.
7
+ Composite commands expand into child `nmr` processes, so one green `nmr ci` records a pass for every cacheable command in the chain, at every scope at which it ran. A later `nmr check`, `nmr typecheck`, or `nmr -F core test` on the same tree skips too. The skip is [reported](reporting.md#what-nmr-reports) in every verbosity.
8
8
 
9
- The cache is a working-tree cache, not a build cache: it answers "has this exact tree already passed this check?" and nothing else. It is also local to one checkout (the install fingerprint in its key is machine-specific), so it saves repeated work in a working copy rather than sharing results across machines or with CI.
9
+ The cache is a working-tree cache, not a build cache: It answers "has this exact tree already passed this check?" and nothing else. It is also local to one checkout (the install fingerprint in its key is machine-specific), so it saves repeated work in a working copy rather than sharing results across machines or with CI.
10
10
 
11
11
  ## What is cached
12
12
 
@@ -14,18 +14,18 @@ Cacheable by default: `check`, `check:strict`, `ci`, `fix:check`, `fmt:check`, `
14
14
 
15
15
  Everything else runs every time:
16
16
 
17
- | Command | Why it is never skipped |
18
- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
19
- | `audit`, `prepush` | Consult a vulnerability database that moves without the tree. `prepush`'s `ci` constituent still skips while its `audit` runs every time. |
20
- | `build`, `compile` | Carry a cache of their own. |
21
- | `fix`, `fmt`, `lint`, `upgrade` | Rewrite the tree they were asked about, so a recorded pass would describe a tree that no longer exists. |
22
- | `test:all` | Reaches the `localhost` and `remote` tiers, which the environment supplies rather than the tree. |
17
+ | Command | Why it is never skipped |
18
+ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
19
+ | `audit`, `prepush` | Consult a vulnerability database that changes independently of the tree. `prepush`'s `ci` constituent still skips while its `audit` runs every time. |
20
+ | `build`, `compile` | Have a cache of their own. |
21
+ | `fix`, `fmt`, `lint`, `upgrade` | Rewrite the tree that they were asked about, so a recorded pass would describe a tree that no longer exists. |
22
+ | `test:all` | Reaches the `localhost` and `remote` tiers, which the environment supplies rather than the tree. |
23
23
 
24
24
  ## The exit-status-only contract
25
25
 
26
- **A cacheable name promises that its whole contribution is an exit status**, through its entire chain, `:pre` and `:post` hooks included. A hit skips that chain wholesale, so any file a cacheable command was relied on to produce is a file that will not appear.
26
+ **A cacheable name promises that its whole contribution is an exit status**, through its entire chain, `:pre` and `:post` hooks included. A hit skips that chain wholesale, so any file that a cacheable command was relied on to produce will not appear.
27
27
 
28
- `test:coverage` is cacheable on exactly this reading: what it contributes is the pass, not the `coverage/` directory, which a skipped run leaves as whatever the last real run wrote. Read `coverage/` after a `test:coverage` that may have skipped, and it may be stale; `--no-cache` is how you insist on a fresh one.
28
+ `test:coverage` is cacheable on exactly this reading: It contributes the pass, not the `coverage/` directory, which a skipped run leaves as whatever the last real run wrote. After a `test:coverage` that may have skipped, `coverage/` may be stale; `--no-cache` forces a fresh one.
29
29
 
30
30
  Overriding a cacheable name, in `package.json` or in `workspaceScripts`, inherits its cacheability. An override that writes something anyone depends on belongs in `excludeCommands`.
31
31
 
@@ -33,44 +33,44 @@ Overriding a cacheable name, in `package.json` or in `workspaceScripts`, inherit
33
33
 
34
34
  A hit requires all of these to match the run that recorded the pass:
35
35
 
36
- - **The working tree's content**, hashed from the commit's tree object folded with the current content of every path git reports as changed or untracked. Timestamps are not content, so a `touch` that rewrites no bytes leaves the hash alone.
37
- - **The command string that would run**, hooks and all, and the scope it would run in.
36
+ - **The working tree's content**, hashed from the commit's tree object folded with the current content of every path that git reports as changed or untracked. Timestamps are not content, so a `touch` that does not rewrite any bytes leaves the hash alone.
37
+ - **The command string that would run**, hooks and all, and the scope in which it would run.
38
38
  - **nmr's version**, the **Node version**, and the **platform and architecture**.
39
- - **What pnpm has installed**, so an install, a prune, or a lockfile change forces a re-run.
40
- - **`TZ`, `LANG`, `LC_ALL`, and `NODE_OPTIONS`.** This set is fixed, so two machines that differ only in shell decoration still agree.
39
+ - **What pnpm has installed.** An install, a prune, or a lockfile change forces a re-run.
40
+ - **`TZ`, `LANG`, `LC_ALL`, and `NODE_OPTIONS`.** This set is fixed, so two machines that differ only in other environment variables still agree.
41
41
 
42
- A hit additionally requires the build output of every package nmr's own build covers to be present, and to have been built from this tree. Output is git-ignored, so neither its removal nor its replacement moves the hash: restoring a tree with `git stash` or `git checkout` restores none of the output that tree was built with. nmr compares each covered package's recorded build digest against the one on disk, so a `dist` compiled from another tree is a miss the following run repairs. A package that overrides `build` or `compile` emits on terms nmr does not know and is exempt.
42
+ A hit additionally requires the build output of every package that nmr's own build covers to be present, and to have been built from this tree. Because output is git-ignored, neither its removal nor its replacement changes the hash: Restoring a tree with `git stash` or `git checkout` does not restore any of the output with which that tree was built. nmr compares each covered package's recorded build digest against the one on disk, and treats a `dist` compiled from another tree as a miss that the following run repairs. A package that overrides `build` or `compile` emits on terms that nmr does not know and is exempt.
43
43
 
44
- A pass is recorded only when the command exits 0, only when the tree still matches the one the run started against, only when every covered package's output is present and unchanged since the run started, and never for a run that executed nothing (a `""`/`":"` skip override, or an `NMR_RUN_IF_PRESENT` miss). Output that moves mid-run leaves no answer to which output the pass was earned over, whether another process rebuilt it or the chain built it itself: `nmr ci` is the one default command whose chain does, and it records nothing on a run whose build did work. The `nmr check:strict` inside that chain still records, because its own window opens after the build.
44
+ A pass is recorded only when the command exits 0, only when the tree still matches the one against which the run started, only when every covered package's output is present and unchanged since the run started, and never for a run that executed nothing (a `""`/`":"` skip override, or an `NMR_RUN_IF_PRESENT` miss). When output changes mid-run, whether another process rebuilt it or the chain built it itself, nmr cannot tell over which output the pass was earned: `nmr ci` is the one default command whose chain builds its own output, and it does not record a pass on a run whose build did work. The `nmr check:strict` inside that chain still records a pass, because it starts after the build.
45
45
 
46
46
  ## Out of contract
47
47
 
48
- These change what a check concludes without moving the hash. Where one applies, exclude the affected command:
48
+ These change what a check concludes without changing the hash. When one applies, exclude the affected command:
49
49
 
50
- - **Content filters.** `core.autocrlf` and `.gitattributes` filters mean the bytes on disk differ from the bytes git records; the hash follows what is on disk, but a checkout on another platform may not reproduce it.
51
- - **Symlinked inputs.** A symlink is hashed by the path it names, not by the content at the far end.
50
+ - **Content filters.** `core.autocrlf` and `.gitattributes` filters mean the bytes on disk differ from the bytes that git records; the hash follows what is on disk, but a checkout on another platform may not reproduce it.
51
+ - **Symlinked inputs.** A symlink is hashed by the path that it names, not by the content of its target.
52
52
  - **Gitignored inputs.** Anything git does not report is invisible to the hash. The build-output probe is the one exception, and covers only nmr's own build.
53
- - **`passWithNoTests`.** A suite that collects no files exits 0, and that green is recorded like any other.
53
+ - **`passWithNoTests`.** A suite that does not collect any files exits 0, and that pass is recorded like any other.
54
54
 
55
- Committing an already-checked tree also moves the hash, because the commit's tree object is the base of the fold; the next run does the work again. The reverse holds and is useful: a rebase or an amended message that preserves content leaves the hash alone, as does checking out a branch whose tree is identical.
55
+ Committing an already-checked tree also changes the hash, because the commit's tree object is the base of the fold; the next run does the work again. The reverse holds and is useful: A rebase or an amended message that preserves content leaves the hash alone, as does checking out a branch whose tree is identical.
56
56
 
57
- ## When the gate stands aside
57
+ ## When the cache does not apply
58
58
 
59
- The gate never wrongly skips. Where it cannot be sure, it does nothing and the command runs:
59
+ The gate never wrongly skips. When it cannot be sure, it does nothing and the command runs:
60
60
 
61
- - Outside a git repository, when git fails, or when there is no commit at `HEAD`.
61
+ - Outside a git repository, when git fails, or when `HEAD` does not resolve to a commit.
62
62
  - When the repository declares or contains submodules, whose content the hash does not cover.
63
63
  - When the monorepo root is not the git toplevel.
64
64
  - When a changed path cannot be read, or is neither a file nor a symlink (an untracked nested repository, for instance).
65
- - When there is no pnpm install to fingerprint.
66
- - Under a `devBin` substitution, which runs a binary built from somewhere the hash does not describe.
65
+ - When the repository does not have a pnpm install to fingerprint.
66
+ - Under a `devBin` substitution, which runs a binary built from a location that the hash does not describe.
67
67
  - For any invocation carrying arguments after the command name, and for every step below it: the arguments narrow what runs, and a step served from a recorded pass is a step that did not.
68
68
 
69
- `NMR_DEBUG=1` reports why a run did not skip and why the gate stood aside.
69
+ `NMR_DEBUG=1` reports why a run did not skip and why the cache did not apply.
70
70
 
71
71
  ## Replaying a skipped run's output
72
72
 
73
- A skip reports an excerpt of the run it recalls, marked as a recording rather than as this run's output:
73
+ A skip reports an excerpt of the run that it recalls, marked as a recording rather than as this run's output:
74
74
 
75
75
  ```console
76
76
  $ nmr test
@@ -80,11 +80,11 @@ $ nmr test
80
80
  ⏩ nmr: test: passed 2m ago on this tree, saved ~12s — replayed: Test Files 6 passed (6) Tests 41 passed (41)
81
81
  ```
82
82
 
83
- The excerpt is the last blank-line-delimited block of what the command wrote, which is the closing statement a tool separates from its progress output. It is flattened onto one line, with escape sequences stripped, table rules dropped, and a redrawn progress line reduced to what a reader was left looking at. Nothing is parsed and no figure is computed: nmr replays bytes it recorded. A block wider than a few kilobytes is cut and marked with `…`, so a command whose closing statement is one long line cannot carry its whole output into the cache.
83
+ The excerpt is the last blank-line-delimited block of what the command wrote, which is the closing statement that a tool separates from its progress output. It is flattened onto one line, with escape sequences stripped, table rules dropped, and a redrawn progress line reduced to what a reader was left looking at. nmr does not parse anything or compute any figure: It replays bytes that it recorded. A block wider than a few kilobytes is cut and marked with `…`, so a command whose closing statement is one long line cannot put its whole output in the cache.
84
84
 
85
- The excerpt and the whole transcript it is drawn from are written by the same operation that records the pass, so a pass declined because the tree moved or build output changed leaves neither behind, and `nmr clean` clears both along with the passes they belong to.
85
+ The operation that records the pass also writes the excerpt and the whole transcript from which it is drawn. A pass that nmr declines to record because the tree or the build output changed leaves neither behind, and `nmr clean` clears both along with the passes to which they belong.
86
86
 
87
- **Capture happens at the command, and only where nmr can see its output.** A command whose stdout or stderr is a terminal writes where nmr never reads, so an interactive run retains nothing and its progress display is untouched; a piped or redirected run, and any run under `-q`, retains both streams. A composite hands its descriptors to the child `nmr` processes below it and captures nothing of its own, and a `:pre` or `:post` hook contributes nothing to the command it wraps: an excerpt is the command's own output or none.
87
+ **nmr captures output at the command, and only when it can see that output.** A command whose stdout or stderr is a terminal writes to a stream that nmr never reads, so an interactive run retains nothing and its progress display is untouched; a piped or redirected run, and any run under `-q`, retains both streams. A composite passes its file descriptors to the child `nmr` processes below it and captures nothing of its own, and a `:pre` or `:post` hook contributes nothing to the command that it wraps: An excerpt is the command's own output or none.
88
88
 
89
89
  **A composite replays its constituents' excerpts.** Capturing nothing of its own, a composite assembles what its constituents recorded, each line attributed to the scope and command that produced it:
90
90
 
@@ -93,19 +93,19 @@ $ nmr check
93
93
  ⏩ nmr-core: check: passed 3m ago on this tree, saved ~48s — replayed: nmr-core: fmt:check: All matched files use Prettier code style!; nmr-core: test: Test Files 6 passed (6) Tests 41 passed (41)
94
94
  ```
95
95
 
96
- The assembly is flat: a constituent that is itself a composite contributes its own leaves' lines, so a skipped `ci` replays a package's `test` rather than one opaque `check:strict` line. A delegate expands into the scopes it fans out to, and a constituent that recorded no excerpt is absent rather than inferred: `typecheck` and `lint:check` print nothing on success and contribute nothing above, and a command outside the [cacheable set](#what-is-cached) records nothing at all, so `ci` replays what `check:strict` earned and nothing for `build`.
96
+ The assembly is flat: A constituent that is itself a composite contributes its own leaves' lines, so a skipped `ci` replays a package's `test` rather than one opaque `check:strict` line. A delegate expands into the scopes to which it fans out, and a constituent that did not record an excerpt is absent rather than inferred: `typecheck` and `lint:check` print nothing on success and contribute nothing above, and a command outside the [cacheable set](#what-is-cached) records nothing at all. `ci` replays what `check:strict` earned and nothing for `build`.
97
97
 
98
- It is assembled when the composite records its pass rather than when it skips, so every excerpt in it was certified during that one run, on that one tree. `NMR_RUN_ID` is what makes that provable; see [reserved environment variables](#reserved-environment-variables).
98
+ Every excerpt in the assembly was certified during a single run, on a single tree, because nmr assembles it when the composite records its pass rather than when it skips. `NMR_RUN_ID` makes that provable; see [reserved environment variables](#reserved-environment-variables).
99
99
 
100
100
  **A skip certifies what it replays.** Recalling a pass proves the excerpt describes this tree, and a matching retention key proves it describes this presentation environment, so a skip restamps the recalled entry with the run replaying it. A constituent already warm when a composite ran therefore still contributes its line: `nmr check` after `nmr test` replays the test summary. The restamp touches nothing else -- the instant and the duration belong to the run that earned the pass.
101
101
 
102
- **A replay is held to more than the pass is.** Retention carries its own key: the [pass key](#what-the-key-is-made-of) folded with the channel each output stream ran on and with `CI`, `COLUMNS`, `FORCE_COLOR`, `NO_COLOR`, and `TERM`. `NMR_OUTPUT_STYLE` is in neither key, so a rich run and a plain one share a recorded pass and replay each other's excerpts as recorded. A recording made under a different one is recalled as a pass all the same; its excerpt is simply not replayed, and the verdict prints alone, as it does for a pass that retained nothing.
102
+ **A replay is held to more than the pass is.** Retention has its own key: the [pass key](#what-the-key-is-made-of) folded with the channel on which each output stream ran and with `CI`, `COLUMNS`, `FORCE_COLOR`, `NO_COLOR`, and `TERM`. `NMR_OUTPUT_STYLE` is in neither key, so a rich run and a plain one share a recorded pass and replay each other's excerpts as recorded. A recording made under a different one is recalled as a pass all the same; its excerpt is simply not replayed, and the verdict prints alone, as it does for a pass that retained nothing.
103
103
 
104
- The whole line, excerpt included, is held to the same [512-byte ceiling](reporting.md#what-nmr-reports) every verdict is, so one write still carries it whole under concurrent fan-out. An assembly wide enough to overrun it is cut and marked with `…` rather than spread over several lines.
104
+ Because the whole line, excerpt included, is held to the same [512-byte ceiling](reporting.md#what-nmr-reports) as every verdict, one write still emits it whole under concurrent fan-out. An assembly wide enough to overrun it is cut and marked with `…` rather than spread over several lines.
105
105
 
106
106
  ## Reading a retained run
107
107
 
108
- The excerpt is a line; the run behind it is kept whole. `nmr --log <command>` prints it, led by a header dating what follows:
108
+ The excerpt is a line; nmr also keeps the run's whole transcript. `nmr --log <command>` prints that transcript after a header that dates it:
109
109
 
110
110
  ```console
111
111
  $ nmr --log test
@@ -118,43 +118,43 @@ $ pnpm exec vitest --project unit --project tool
118
118
  Tests 41 passed (41)
119
119
  ```
120
120
 
121
- The header is what presents the body as a recording rather than as this invocation's output. The command string is the whole chain, hooks included, so it names what earned the pass and not merely what was typed. A flag rather than a subcommand, since `log` would collide with the script registry.
121
+ The header presents the body as a recording rather than as this invocation's output. The command string is the whole chain, hooks included, so it names what earned the pass and not merely what was typed. A flag rather than a subcommand, since `log` would collide with the script registry.
122
122
 
123
- **Reading a recording runs nothing.** No hook runs, no verdict prints, and nothing is recorded or restamped. `--no-cache` governs running rather than reading and is ignored.
123
+ **Reading a recording runs nothing.** nmr does not run any hook or print any verdict, and it does not record or restamp anything. `--no-cache` governs running rather than reading and is ignored.
124
124
 
125
- **A recording is held to the tree, not to the terminal.** `--log` prints exactly what a skip would have recalled: the [pass key](#what-the-key-is-made-of) has to match. The [retention key](#replaying-a-skipped-runs-output) is not consulted, so a recording made under a different terminal width, or through a pipe, still prints -- a reader at a terminal being shown what a piped run wrote is the case the flag exists for.
125
+ **A recording is held to the tree, not to the terminal.** `--log` prints exactly what a skip would have recalled: The [pass key](#what-the-key-is-made-of) has to match. `--log` does not consult the [retention key](#replaying-a-skipped-runs-output), so a recording made under a different terminal width, or through a pipe, still prints -- the flag exists to show a reader at a terminal what a piped run wrote.
126
126
 
127
- **A composite prints its assembly.** Retaining nothing of its own, a composite prints the excerpts its constituents recorded, one attributed line each. The transcript of any one of them is that constituent's own `--log` to print.
127
+ **A composite prints its assembly.** Retaining nothing of its own, a composite prints the excerpts that its constituents recorded, one attributed line each. To print the transcript of any one of them, run `--log` for that constituent.
128
128
 
129
- **Nothing to show is said rather than left blank.** A refusal names which it is -- the command is outside the [cacheable set](#what-is-cached), the gate is [standing aside](#when-the-gate-stands-aside), nothing has recorded a pass, the last pass was recorded under something this run does not share, or the pass retained no output, having printed none or written to a terminal -- and exits non-zero, so a caller can tell an empty `stdout` from a recording. A mismatch names the ingredient that moved, the tree or the chain or a version, so a reader whose `git status` is clean is not sent looking there.
129
+ **When nmr has nothing to show, it says so rather than printing a blank.** A refusal names which case applies -- the command is outside the [cacheable set](#what-is-cached), the [cache does not apply](#when-the-cache-does-not-apply), nothing has recorded a pass, the last pass was recorded under something this run does not share, or the pass did not retain any output, having printed none or written to a terminal -- and nmr exits non-zero, so that a caller can tell an empty `stdout` from a recording. A mismatch names the ingredient that changed, whether the tree, the chain, or a version, to keep a reader whose `git status` is clean from looking there.
130
130
 
131
131
  ```console
132
132
  $ nmr --log test
133
- 🟠 nmr: test: no recording; the last pass was 3m ago, on a tree this is not
133
+ 🟠 nmr: test: no recording; the last pass was 3m ago, on a tree that this is not
134
134
  ```
135
135
 
136
- **`--log` reaches the scopes `-F` and `-R` select.** The flag rides the delegate, so each scope prints its own recording. There a scope with nothing to show reports its gap and exits 0: partial coverage is a survey's normal shape, and failing on the first gap would hide every scope that had something. A selection of no scope at all is the other case and fails, as it does for a run: there is no survey to be partial.
136
+ **`--log` applies to the scopes that `-F` and `-R` select.** nmr passes the flag to the delegate. Each scope prints its own recording. There a scope with nothing to show reports its gap and exits 0: Partial coverage is normal for a survey, and failing on the first gap would hide every scope that had something to show. A selection that does not include any scope is the other case and fails, as it does for a run: An empty selection is not a partial survey.
137
137
 
138
- **Retention has a ceiling of 256 KiB per recorded pass**, distinct from the 512 bytes a verdict line is held to. A transcript overrunning it keeps 128 KiB from each end, because a run's opening and its closing statement each carry what the other does not, and the drop is marked with a line naming the bytes it stands for rather than being cut silently.
138
+ **Retention has a ceiling of 256 KiB per recorded pass**, distinct from the 512 bytes to which a verdict line is held. nmr keeps 128 KiB from each end of a transcript that overruns it, because a run's opening and its closing statement each contain what the other does not, and it marks the drop with a line that names the dropped bytes, rather than cutting silently.
139
139
 
140
140
  ## Bypassing and clearing
141
141
 
142
- Reach for these in order; the first is almost always the right one.
142
+ Try these in order; the first is almost always the right one.
143
143
 
144
- | Step | Effect |
145
- | -------------------------------------- | ------------------------------------------------------------------------- |
146
- | `nmr --no-cache <command>` | Runs the command, then records the fresh result. Reaches the whole chain. |
147
- | `NMR_NO_CACHE=1` | The same, for every nmr invocation in the shell. |
148
- | `rm -rf node_modules/.cache/nmr-check` | Forgets every recorded pass, leaving build output alone. |
149
- | `nmr clean` | Forgets every recorded pass and removes build output, at any scope. |
144
+ | Step | Effect |
145
+ | -------------------------------------- | ---------------------------------------------------------------------------- |
146
+ | `nmr --no-cache <command>` | Runs the command, then records the fresh result. Applies to the whole chain. |
147
+ | `NMR_NO_CACHE=1` | The same, for every nmr invocation in the shell. |
148
+ | `rm -rf node_modules/.cache/nmr-check` | Forgets every recorded pass, leaving build output alone. |
149
+ | `nmr clean` | Forgets every recorded pass and removes build output, at any scope. |
150
150
 
151
- `--no-cache` belongs before the command name. After it, it is an argument to the command rather than a flag to nmr; nmr says so rather than letting the bypass silently not happen.
151
+ `--no-cache` belongs before the command name. After it, it is an argument to the command rather than a flag to nmr; nmr reports this rather than silently ignoring the bypass.
152
152
 
153
153
  ## Reserved environment variables
154
154
 
155
- `NMR_TREE_SNAPSHOT` is nmr's own: it carries one observation of the tree from a top-level invocation down to the processes it spawns, so a chain hashes the tree once rather than at every link. nmr trusts an inherited value only while `HEAD` still stands where it did when the observation was taken, which bounds a process that outlives the run that spawned it. That bound does not extend to a tree edited without committing, so **a process that survives its run and later invokes nmr should clear `NMR_TREE_SNAPSHOT`** — a test suite that shells out to `nmr` is the case worth checking.
155
+ `NMR_TREE_SNAPSHOT` is nmr's own: It passes one observation of the tree from a top-level invocation down to the processes that it spawns, so a chain hashes the tree once rather than at every link. nmr trusts an inherited value only while `HEAD` still points to the same commit as when the observation was taken, which bounds a process that outlives the run that spawned it. Because that bound does not extend to a tree edited without committing, **a process that survives its run and later invokes nmr should clear `NMR_TREE_SNAPSHOT`**. A test suite that shells out to `nmr` is the case worth checking.
156
156
 
157
- `NMR_RUN_ID` is nmr's own too: it carries one run's identity from a top-level invocation down to the processes it spawns, so the excerpts recorded at every scope during one run are recognizable as that run's, and a composite can assemble from them. Where `NMR_TREE_SNAPSHOT` is bounded by `HEAD`, this is bounded by the tree hash each entry records, so a process that outlives its run and carries the identity into a later one contributes nothing to that run's assemblies.
157
+ `NMR_RUN_ID` is nmr's own too: It passes one run's identity from a top-level invocation down to the processes that it spawns, so that the excerpts recorded at every scope during one run are recognizable as that run's, and a composite can assemble from them. Whereas `NMR_TREE_SNAPSHOT` is bounded by `HEAD`, this one is bounded by the tree hash that each entry records. A process that outlives its run and carries the identity into a later one contributes nothing to that run's assemblies.
158
158
 
159
159
  ## Configuring
160
160
 
@@ -169,8 +169,8 @@ export default defineConfig({
169
169
  });
170
170
  ```
171
171
 
172
- `extraCommands` extends the default set rather than replacing it, so naming one command cannot silently drop the rest; `excludeCommands` is applied afterwards, so a name in both is excluded. `enabled: false` turns the gate off entirely. The key is `checkCache`, in the monorepo-root config.
172
+ `extraCommands` extends the default set rather than replacing it, so naming one command cannot silently drop the rest. nmr applies `excludeCommands` afterwards and excludes a name that appears in both. `enabled: false` turns the gate off entirely. The key is `checkCache`, in the monorepo-root config.
173
173
 
174
- **A name in either list must resolve to a command**, whether through nmr's defaults, the repo config's script records, or a package's own `package.json` scripts. Both lists are read by name alone, so a misspelt entry would otherwise be inert: the command it meant to name would go on running, indistinguishable from one that cannot be cached at all. A name that resolves nowhere fails config load, naming the closest command where there is one. A hook name is rejected too, since a hook is never gated on its own -- it runs as part of the chain of the command it wraps, and is skipped or run with it.
174
+ **A name in either list must resolve to a command**, whether through nmr's defaults, the repo config's script records, or a package's own `package.json` scripts. Both lists are read by name alone, so a misspelt entry would otherwise be inert: The command that it meant to name would go on running, indistinguishable from one that cannot be cached at all. A name that resolves nowhere fails config load, naming the closest command if one exists. A hook name is rejected too, since a hook is never gated on its own -- it runs as part of the chain of the command that it wraps, and is skipped or run with it.
175
175
 
176
- The test is resolvability rather than membership of the cacheable set, so an `excludeCommands` entry keeps standing after a release moves its name out of the defaults.
176
+ Because the test is resolvability rather than membership of the cacheable set, an `excludeCommands` entry stays valid after a release moves its name out of the defaults.
package/docs/prettier.md CHANGED
@@ -2,23 +2,23 @@
2
2
 
3
3
  The Prettier config that `@williamthorsen/nmr/prettier` publishes: what it formats, how it scopes the shell plugin, and how to customize and adopt it.
4
4
 
5
- `prettier` is a peer dependency (`>=3.9.5 <4`), declared optional — the consuming repo provides it. The shell and Dockerfile plugin is a dependency of nmr rather than a peer, so its version is pinned centrally and two repos cannot format the same script differently.
5
+ `prettier` is a peer dependency (`>=3.9.5 <4`), declared optional; the consuming repo provides it. The shell and Dockerfile plugin is a dependency of nmr rather than a peer, so its version is pinned centrally and two repos cannot format the same script differently.
6
6
 
7
7
  ## What it formats
8
8
 
9
- Beyond everything Prettier already handles, this config formats `.sh`, `.bash`, `.zsh`, and the other extensions and dotfile names of the Shell language, plus `Dockerfile` and `Containerfile`. `nmr fmt` therefore covers shell scripts with no extra script, glob, or workflow entry.
9
+ Beyond everything Prettier already handles, this config formats `.sh`, `.bash`, `.zsh`, and the other extensions and dotfile names of the Shell language, plus `Dockerfile` and `Containerfile`. `nmr fmt` therefore covers shell scripts without an extra script, glob, or workflow entry.
10
10
 
11
- Shell output matches `shfmt` run with no flags: `binaryNextLine`, `spaceRedirects`, and `switchCaseIndent` are pinned to shfmt's CLI defaults, which the underlying plugin inverts. A repo whose scripts shfmt already formatted sees no diff on adoption.
11
+ Shell output matches `shfmt` run without flags: `binaryNextLine`, `spaceRedirects`, and `switchCaseIndent` are pinned to shfmt's CLI defaults, which the underlying plugin inverts. Adoption does not produce any diff in a repo whose scripts shfmt already formatted.
12
12
 
13
- Code fences inside Markdown are left alone, in every language rather than shell alone. Registering the shell plugin also routes a fence tagged `bash` to shfmt, where a documented command's angle-bracket placeholders are valid redirections: `cmd --type <type> --harness claude` is reprinted as `cmd --type claude <type >--harness`, which still runs but no longer does what it documents. It cannot be narrowed back to shell, because Prettier matches a fence tag against the same `extensions` its file inference reads, so the `.bash` routing a script also claims the tag `bash`.
13
+ Code fences inside Markdown are left alone, in every language rather than shell alone. Registering the shell plugin also sends a fence tagged `bash` to shfmt, which reads a documented command's angle-bracket placeholders as valid redirections: `cmd --type <type> --harness claude` is reprinted as `cmd --type claude <type >--harness`, which still runs but no longer does what it documents. It cannot be narrowed back to shell, because Prettier matches a fence tag against the same `extensions` that its file inference reads, so the `.bash` extension that sends a script to the shell parser also matches the tag `bash`.
14
14
 
15
15
  ## Language scoping
16
16
 
17
- `prettier-plugin-sh` routes 20 further file types to the same shell parser, among them `.gitignore`, `.env`, `.csh`, `.nu`, `.properties`, `.ics`, `.vcf`, `CODEOWNERS`, and `hosts`. Since `nmr fmt` hands git's whole file list to Prettier, registering the plugin as shipped would make every one of them formattable — and the shell parser either fails on them or silently rewrites them. A `.gitignore` pattern such as `a(b)c` fails to parse, and an unquoted `&` in a `.env` or `.properties` value is split across two lines.
17
+ `prettier-plugin-sh` assigns 20 further file types to the same shell parser, among them `.gitignore`, `.env`, `.csh`, `.nu`, `.properties`, `.ics`, `.vcf`, `CODEOWNERS`, and `hosts`. Since `nmr fmt` passes git's whole file list to Prettier, registering the plugin unmodified would make every one of them formattable, and the shell parser either fails on them or silently rewrites them. A `.gitignore` pattern such as `a(b)c` fails to parse, and an unquoted `&` in a `.env` or `.properties` value is split across two lines.
18
18
 
19
- This config registers a narrowed plugin, so Prettier infers a parser for the Shell and Dockerfile languages alone. `.flaskenv`, `gradlew`, and `mvnw` are dropped from Shell as well: the first holds dotenv content the plugin misfiles as shell, and the other two are vendored wrappers their generators would overwrite.
19
+ This config registers a narrowed plugin, so Prettier infers a parser for the Shell and Dockerfile languages alone. `.flaskenv`, `gradlew`, and `mvnw` are dropped from Shell as well: The first contains dotenv content that the plugin misidentifies as shell, and the other two are vendored wrappers that their generators would overwrite.
20
20
 
21
- The parsers themselves stay registered, so a repo that wants a dropped language back assigns it explicitly:
21
+ The parsers themselves stay registered. A repo that wants a dropped language back assigns it explicitly:
22
22
 
23
23
  ```js
24
24
  export default definePrettierConfig({
@@ -38,10 +38,10 @@ export default definePrettierConfig({
38
38
  });
39
39
  ```
40
40
 
41
- `additionalPlugins` and `additionalOverrides` append to what the config declares, so a later override wins over nmr's own and no seam can drop shell support. Passing `plugins` or `overrides` throws, and both are typed `never` — replacement is inexpressible rather than merely discouraged, because a silently ignored key would produce no diff to notice.
41
+ `additionalPlugins` and `additionalOverrides` append to what the config declares, so a later override wins over nmr's own, and a repo cannot use either option to drop shell support. Passing `plugins` or `overrides` throws, and both are typed `never`: Replacement is inexpressible rather than merely discouraged, because a silently ignored key would not produce any diff to notice.
42
42
 
43
43
  ## Adoption caveats
44
44
 
45
- - **Indentation follows `.editorconfig`**, as it did under shfmt: Prettier maps `indent_style` and `indent_size` to `useTabs` and `tabWidth`, and the plugin honours both. With no `.editorconfig`, the two disagree — shfmt indents shell with tabs, Prettier with two spaces. Add `indent_style = tab` under `[*.sh]` to keep tabs.
46
- - **Markdown fences stop being tidied.** The carve-out that protects documented shell commands costs the `ts`, `json`, and `yaml` fences their formatting too. Fences already formatted stay as they are, so nothing churns on adoption; new ones are simply left as written. A repo whose docs hold no shell can take them back for the Markdown paths it actually uses: `additionalOverrides: [{ files: ['*.md'], options: { embeddedLanguageFormatting: 'auto' } }]` restores `.md` alone, and the carve-out still covers every other path Prettier reads as Markdown.
45
+ - **Indentation follows `.editorconfig`**, as it did under shfmt: Prettier maps `indent_style` and `indent_size` to `useTabs` and `tabWidth`, and the plugin honours both. Without an `.editorconfig`, the two disagree: shfmt indents shell with tabs, Prettier with two spaces. Add `indent_style = tab` under `[*.sh]` to keep tabs.
46
+ - **Markdown fences stop being tidied.** The carve-out that protects documented shell commands leaves the `ts`, `json`, and `yaml` fences unformatted too. Fences already formatted stay as they are, so adoption does not change them; new ones are simply left as written. A repo whose docs do not contain any shell can restore fence formatting for the Markdown paths that it actually uses: `additionalOverrides: [{ files: ['*.md'], options: { embeddedLanguageFormatting: 'auto' } }]` restores `.md` alone, and the carve-out still covers every other path that Prettier reads as Markdown.
47
47
  - **Adopting the house options reformats the repo.** `singleQuote`, `trailingComma`, and the rest apply on the first run; review that diff separately from the shell one.