@williamthorsen/nmr 0.38.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 (112) hide show
  1. package/.meta/changelog.json +212 -4
  2. package/.readyup/kits/default.js +124 -104
  3. package/.readyup/manifest.json +12 -7
  4. package/CHANGELOG.md +108 -0
  5. package/README.md +41 -47
  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 +61 -63
  18. package/dist/esm/check-cache.js +24 -24
  19. package/dist/esm/cli-ensure-prepublish-hooks.js +4 -4
  20. package/dist/esm/commands/build-output.d.ts +37 -38
  21. package/dist/esm/commands/build-output.js +9 -9
  22. package/dist/esm/commands/build.d.ts +13 -14
  23. package/dist/esm/commands/build.js +67 -65
  24. package/dist/esm/commands/clean.d.ts +6 -6
  25. package/dist/esm/commands/clean.js +11 -11
  26. package/dist/esm/commands/ensure-prepublish-hooks.d.ts +7 -7
  27. package/dist/esm/commands/ensure-prepublish-hooks.js +41 -41
  28. package/dist/esm/commands/fmt.d.ts +8 -8
  29. package/dist/esm/commands/fmt.js +33 -33
  30. package/dist/esm/commands/report-catalog.d.ts +5 -7
  31. package/dist/esm/commands/report-catalog.js +10 -8
  32. package/dist/esm/commands/report-overrides.d.ts +0 -2
  33. package/dist/esm/commands/report-overrides.js +8 -8
  34. package/dist/esm/commands/taze.d.ts +9 -8
  35. package/dist/esm/config.d.ts +5 -5
  36. package/dist/esm/config.js +27 -27
  37. package/dist/esm/context.d.ts +1 -2
  38. package/dist/esm/context.js +8 -8
  39. package/dist/esm/default-scripts.d.ts +5 -6
  40. package/dist/esm/defineConfig.d.ts +1 -1
  41. package/dist/esm/git-ignored-paths.d.ts +9 -0
  42. package/dist/esm/git-ignored-paths.js +10 -0
  43. package/dist/esm/glyphs.d.ts +3 -3
  44. package/dist/esm/help.d.ts +4 -4
  45. package/dist/esm/help.js +21 -21
  46. package/dist/esm/helpers/clampToBytes.d.ts +4 -5
  47. package/dist/esm/helpers/clampToBytes.js +3 -3
  48. package/dist/esm/helpers/config-path.d.ts +0 -2
  49. package/dist/esm/helpers/createBoundedBuffer.d.ts +3 -3
  50. package/dist/esm/helpers/createBoundedBuffer.js +2 -2
  51. package/dist/esm/helpers/deriveExcerpt.d.ts +4 -5
  52. package/dist/esm/helpers/duration.d.ts +10 -11
  53. package/dist/esm/helpers/duration.js +7 -7
  54. package/dist/esm/helpers/filter-selection.d.ts +19 -19
  55. package/dist/esm/helpers/filter-selection.js +12 -12
  56. package/dist/esm/helpers/findClosestName.d.ts +1 -1
  57. package/dist/esm/helpers/findClosestName.js +15 -15
  58. package/dist/esm/helpers/hook-name.d.ts +2 -5
  59. package/dist/esm/helpers/package-json.d.ts +11 -10
  60. package/dist/esm/helpers/package-json.js +26 -25
  61. package/dist/esm/helpers/readStringValues.d.ts +2 -3
  62. package/dist/esm/helpers/reportClosing.d.ts +8 -9
  63. package/dist/esm/helpers/transcript.d.ts +15 -15
  64. package/dist/esm/helpers/transcript.js +14 -14
  65. package/dist/esm/helpers/type-guards.d.ts +1 -0
  66. package/dist/esm/output-style.d.ts +12 -13
  67. package/dist/esm/prettier.d.ts +2 -2
  68. package/dist/esm/prettier.js +5 -5
  69. package/dist/esm/recording.d.ts +25 -20
  70. package/dist/esm/recording.js +28 -21
  71. package/dist/esm/replay-assembly.d.ts +10 -10
  72. package/dist/esm/report-format.d.ts +15 -20
  73. package/dist/esm/report-format.js +7 -7
  74. package/dist/esm/reportCliFailure.d.ts +2 -3
  75. package/dist/esm/resolve-scripts.d.ts +4 -5
  76. package/dist/esm/resolveBinStyles.d.ts +4 -5
  77. package/dist/esm/resolveBinStyles.js +3 -3
  78. package/dist/esm/resolver.d.ts +16 -20
  79. package/dist/esm/resolver.js +15 -15
  80. package/dist/esm/runCli.d.ts +6 -6
  81. package/dist/esm/runCli.js +261 -195
  82. package/dist/esm/runner.d.ts +20 -20
  83. package/dist/esm/runner.js +18 -18
  84. package/dist/esm/steps.d.ts +51 -38
  85. package/dist/esm/steps.js +32 -27
  86. package/dist/esm/taze.d.ts +1 -1
  87. package/dist/esm/tests.d.ts +23 -18
  88. package/dist/esm/tests.js +21 -9
  89. package/dist/esm/tiers.d.ts +28 -24
  90. package/dist/esm/tiers.js +13 -9
  91. package/dist/esm/types.d.ts +5 -5
  92. package/dist/esm/verbosity.d.ts +23 -21
  93. package/dist/esm/verbosity.js +7 -7
  94. package/dist/esm/verdict.d.ts +39 -32
  95. package/dist/esm/verdict.js +63 -50
  96. package/dist/esm/vitest-source-resolution.d.ts +11 -11
  97. package/dist/esm/vitest-source-resolution.js +49 -47
  98. package/dist/esm/vitest.d.ts +27 -26
  99. package/dist/esm/vitest.js +90 -33
  100. package/dist/esm/workspace.d.ts +6 -22
  101. package/dist/esm/workspace.js +9 -67
  102. package/docs/check-cache.md +57 -57
  103. package/docs/prettier.md +10 -10
  104. package/docs/reporting.md +48 -44
  105. package/docs/scripts.md +29 -29
  106. package/docs/upgrades.md +18 -18
  107. package/docs/utilities.md +13 -13
  108. package/docs/vitest.md +68 -66
  109. package/docs/workspace.md +27 -4
  110. package/package.json +6 -6
  111. package/dist/esm/helpers/workspace-patterns.d.ts +0 -8
  112. package/dist/esm/helpers/workspace-patterns.js +0 -29
@@ -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;
@@ -24,44 +24,44 @@ export function createSourceResolutionPlugin() {
24
24
  export function resolveSourceTarget(specifier, importer, context) {
25
25
  if (importer === undefined)
26
26
  return undefined;
27
- const parsed = parseSpecifier(specifier);
28
- if (!parsed)
27
+ const parsedSpecifier = parseSpecifier(specifier);
28
+ if (!parsedSpecifier)
29
29
  return undefined;
30
- const packageDir = findPackageDir(parsed.name, path.dirname(importer), context.packageDirs);
30
+ const packageDir = findPackageDir(parsedSpecifier.name, path.dirname(importer), context.packageDirs);
31
31
  if (packageDir === undefined || isInsideNodeModules(packageDir))
32
32
  return undefined;
33
33
  const manifest = readManifest(packageDir, context.manifests);
34
34
  if (!isObject(manifest))
35
35
  return undefined;
36
- const matched = matchSubpath(manifest['exports'], parsed.subpath);
37
- if (!matched)
36
+ const matchedSubpath = matchSubpath(manifest['exports'], parsedSpecifier.subpath);
37
+ if (!matchedSubpath)
38
38
  return undefined;
39
39
  const environmentCondition = context.environmentName === 'client' ? CLIENT_CONDITION : SERVER_CONDITION;
40
40
  const conditions = new Set([environmentCondition, ...SHARED_CONDITIONS]);
41
- const selected = selectTarget(matched.value, conditions);
42
- if (!selected?.usedSource)
41
+ const selectedTarget = selectTarget(matchedSubpath.value, conditions);
42
+ if (!selectedTarget?.didUseSource)
43
43
  return undefined;
44
- const wildcard = matched.wildcard;
45
- const declared = wildcard === undefined ? selected.target : selected.target.replaceAll('*', () => wildcard);
46
- if (!declared.startsWith('./'))
44
+ const wildcard = matchedSubpath.wildcard;
45
+ const declaredTarget = wildcard === undefined ? selectedTarget.target : selectedTarget.target.replaceAll('*', () => wildcard);
46
+ if (!declaredTarget.startsWith('./'))
47
47
  return undefined;
48
- const declaredPath = path.resolve(packageDir, declared);
48
+ const declaredPath = path.resolve(packageDir, declaredTarget);
49
49
  const target = findTargetFile(declaredPath);
50
50
  if (target === undefined) {
51
- throw new UserError(formatMissingTarget(parsed.name, parsed.subpath, declared, declaredPath));
51
+ throw new UserError(formatMissingTarget(parsedSpecifier.name, parsedSpecifier.subpath, declaredTarget, declaredPath));
52
52
  }
53
- return `${target}${parsed.postfix}`;
53
+ return `${target}${parsedSpecifier.postfix}`;
54
54
  }
55
55
  function findPackageDir(name, fromDir, cache) {
56
56
  const key = `${fromDir}\0${name}`;
57
57
  if (cache?.has(key))
58
58
  return cache.get(key);
59
- let found = findSelfReferenceDir(name, fromDir);
59
+ let foundDir = findSelfReferenceDir(name, fromDir);
60
60
  let dir = fromDir;
61
- while (found === undefined) {
61
+ while (foundDir === undefined) {
62
62
  const candidate = path.join(dir, NODE_MODULES, name);
63
63
  if (existsSync(path.join(candidate, 'package.json'))) {
64
- found = realpathSync(candidate);
64
+ foundDir = realpathSync(candidate);
65
65
  break;
66
66
  }
67
67
  const parent = path.dirname(dir);
@@ -69,16 +69,18 @@ function findPackageDir(name, fromDir, cache) {
69
69
  break;
70
70
  dir = parent;
71
71
  }
72
- cache?.set(key, found);
73
- return found;
72
+ cache?.set(key, foundDir);
73
+ return foundDir;
74
74
  }
75
75
  function findSelfReferenceDir(name, fromDir) {
76
76
  let dir = fromDir;
77
77
  for (;;) {
78
78
  const file = path.join(dir, 'package.json');
79
79
  if (existsSync(file)) {
80
- const parsed = parsePackageJson(readFileSync(file, 'utf8'), file);
81
- return isObject(parsed) && parsed['name'] === name && parsed['exports'] !== undefined ? dir : undefined;
80
+ const parsedManifest = parsePackageJson(readFileSync(file, 'utf8'), file);
81
+ return isObject(parsedManifest) && parsedManifest['name'] === name && parsedManifest['exports'] !== undefined
82
+ ? dir
83
+ : undefined;
82
84
  }
83
85
  const parent = path.dirname(dir);
84
86
  if (parent === dir)
@@ -110,11 +112,11 @@ function findTargetFile(declaredPath) {
110
112
  }
111
113
  return undefined;
112
114
  }
113
- function formatMissingTarget(name, subpath, declared, target) {
115
+ function formatMissingTarget(name, subpath, declaredTarget, target) {
114
116
  return [
115
- `Package "${name}" declares a \`source\` export for "${subpath}" at "${declared}", which reaches no file.`,
117
+ `Package "${name}" declares a \`source\` export for "${subpath}" at "${declaredTarget}", which does not reach any file.`,
116
118
  `Looked for ${target}, the same path under each of ${TARGET_EXTENSIONS.join(', ')}, and an index under it.`,
117
- '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.',
118
120
  ].join(' ');
119
121
  }
120
122
  function isFile(candidate) {
@@ -129,10 +131,10 @@ function matchSubpath(exportsValue, subpath) {
129
131
  if (!isObject(exportsValue) || Object.keys(exportsValue).every((key) => !key.startsWith('.'))) {
130
132
  return subpath === '.' ? { value: exportsValue } : undefined;
131
133
  }
132
- const exact = exportsValue[subpath];
133
- if (exact !== undefined)
134
- return { value: exact };
135
- let best;
134
+ const exactValue = exportsValue[subpath];
135
+ if (exactValue !== undefined)
136
+ return { value: exactValue };
137
+ let bestMatch;
136
138
  for (const [key, value] of Object.entries(exportsValue)) {
137
139
  const star = key.indexOf('*');
138
140
  if (star === -1)
@@ -143,21 +145,21 @@ function matchSubpath(exportsValue, subpath) {
143
145
  continue;
144
146
  if (subpath.length < base.length + suffix.length)
145
147
  continue;
146
- if (best !== undefined && best.base.length >= base.length)
148
+ if (bestMatch !== undefined && bestMatch.base.length >= base.length)
147
149
  continue;
148
- best = { base, value, wildcard: subpath.slice(base.length, subpath.length - suffix.length) };
150
+ bestMatch = { base, value, wildcard: subpath.slice(base.length, subpath.length - suffix.length) };
149
151
  }
150
- return best && { value: best.value, wildcard: best.wildcard };
152
+ return bestMatch && { value: bestMatch.value, wildcard: bestMatch.wildcard };
151
153
  }
152
154
  function parseSpecifier(specifier) {
153
155
  if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('\0'))
154
156
  return undefined;
155
157
  const suffixIndex = findSuffixIndex(specifier);
156
- const bare = suffixIndex === -1 ? specifier : specifier.slice(0, suffixIndex);
157
- if (bare.includes(':') || isBuiltin(bare))
158
+ const bareSpecifier = suffixIndex === -1 ? specifier : specifier.slice(0, suffixIndex);
159
+ if (bareSpecifier.includes(':') || isBuiltin(bareSpecifier))
158
160
  return undefined;
159
- const segments = bare.split('/');
160
- const nameLength = bare.startsWith('@') ? 2 : 1;
161
+ const segments = bareSpecifier.split('/');
162
+ const nameLength = bareSpecifier.startsWith('@') ? 2 : 1;
161
163
  if (segments.length < nameLength || segments.includes(''))
162
164
  return undefined;
163
165
  const rest = segments.slice(nameLength);
@@ -171,18 +173,18 @@ function readManifest(packageDir, cache) {
171
173
  if (cache?.has(packageDir))
172
174
  return cache.get(packageDir);
173
175
  const file = path.join(packageDir, 'package.json');
174
- const parsed = parsePackageJson(readFileSync(file, 'utf8'), file);
175
- cache?.set(packageDir, parsed);
176
- return parsed;
176
+ const parsedManifest = parsePackageJson(readFileSync(file, 'utf8'), file);
177
+ cache?.set(packageDir, parsedManifest);
178
+ return parsedManifest;
177
179
  }
178
180
  function selectTarget(value, conditions) {
179
181
  if (typeof value === 'string')
180
- return { target: value, usedSource: false };
182
+ return { target: value, didUseSource: false };
181
183
  if (Array.isArray(value)) {
182
184
  for (const entry of value) {
183
- const selected = selectTarget(entry, conditions);
184
- if (selected)
185
- return selected;
185
+ const selectedTarget = selectTarget(entry, conditions);
186
+ if (selectedTarget)
187
+ return selectedTarget;
186
188
  }
187
189
  return undefined;
188
190
  }
@@ -190,16 +192,16 @@ function selectTarget(value, conditions) {
190
192
  return undefined;
191
193
  const source = value[SOURCE_CONDITION];
192
194
  if (source !== undefined) {
193
- const selected = selectTarget(source, conditions);
194
- if (selected)
195
- return { target: selected.target, usedSource: true };
195
+ const selectedTarget = selectTarget(source, conditions);
196
+ if (selectedTarget)
197
+ return { target: selectedTarget.target, didUseSource: true };
196
198
  }
197
199
  for (const [key, entry] of Object.entries(value)) {
198
200
  if (key === SOURCE_CONDITION || !conditions.has(key))
199
201
  continue;
200
- const selected = selectTarget(entry, conditions);
201
- if (selected)
202
- return selected;
202
+ const selectedTarget = selectTarget(entry, conditions);
203
+ if (selectedTarget)
204
+ return selectedTarget;
203
205
  }
204
206
  return undefined;
205
207
  }
@@ -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
- isolateGit?: boolean;
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
- resolveFromSource?: boolean;
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,21 +1,43 @@
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";
7
8
  import { getWorkspacePackageDirs } from "./workspace.js";
8
9
  const [RESIDUAL_TIER, ...NAMED_TIERS] = TIER_NAMES;
9
10
  const TIERED_PATTERNS = NAMED_TIERS.flatMap(buildTierPatterns);
10
- const TIER_TIMEOUT = 30_000;
11
+ const TIER_TIMEOUT_MS = 30_000;
11
12
  const GIT_ISOLATION_SETUP_FILE = resolveGitIsolationSetupFile();
12
13
  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}'];
16
- 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.';
17
+ const GLOB_SYNTAX_PATTERN = /[!()*+?@[\]{}]/g;
18
+ const RECOGNIZED_OPTIONS = {
19
+ project: true,
20
+ root: true,
21
+ shouldIsolateGit: true,
22
+ shouldResolveFromSource: true,
23
+ testCollectionExclude: true,
24
+ tiers: true,
25
+ tsconfigPaths: true,
26
+ };
27
+ const RECOGNIZED_ROOT_OPTIONS = {
28
+ ...RECOGNIZED_OPTIONS,
29
+ monorepoRoot: true,
30
+ };
31
+ const RECOGNIZED_OPTION_KEYS = Object.keys(RECOGNIZED_OPTIONS);
32
+ const RECOGNIZED_ROOT_OPTION_KEYS = Object.keys(RECOGNIZED_ROOT_OPTIONS);
33
+ const RETIRED_OPTION_KEYS = new Map([
34
+ ['isolateGit', 'shouldIsolateGit'],
35
+ ['resolveFromSource', 'shouldResolveFromSource'],
36
+ ]);
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.';
17
38
  export function defineVitestConfig(...layers) {
18
- return buildConfig(layers, { coverageInclude: PACKAGE_COVERAGE_INCLUDE });
39
+ assertOptionKeys(layers, RECOGNIZED_OPTION_KEYS);
40
+ return buildConfig(layers, { coverageInclude: PACKAGE_COVERAGE_INCLUDE, ignoredPathsRoot: process.cwd() });
19
41
  }
20
42
  export function defineRootVitestConfig(...layers) {
21
43
  const lastLayer = layers.at(-1);
@@ -23,13 +45,15 @@ export function defineRootVitestConfig(...layers) {
23
45
  if (typeof monorepoRoot !== 'string' || !path.isAbsolute(monorepoRoot)) {
24
46
  throw new TypeError(MISSING_MONOREPO_ROOT);
25
47
  }
48
+ assertOptionKeys(layers, RECOGNIZED_ROOT_OPTION_KEYS);
26
49
  return buildConfig(layers, {
27
50
  coverageInclude: [],
51
+ ignoredPathsRoot: monorepoRoot,
28
52
  projectExclude: getWorkspaceExcludePatterns(monorepoRoot),
29
53
  projectRoot: monorepoRoot,
30
54
  });
31
55
  }
32
- function buildConfig(declaredLayers, { coverageInclude, projectExclude = [], projectRoot }) {
56
+ function buildConfig(declaredLayers, { coverageInclude, ignoredPathsRoot, projectExclude = [], projectRoot }) {
33
57
  const layers = declaredLayers.filter((layer) => layer !== undefined);
34
58
  assertKnownTiers(layers);
35
59
  const resolve = {
@@ -37,7 +61,7 @@ function buildConfig(declaredLayers, { coverageInclude, projectExclude = [], pro
37
61
  ...(resolveFlag(layers, 'tsconfigPaths', false) && { tsconfigPaths: true }),
38
62
  };
39
63
  const config = {
40
- ...(resolveFlag(layers, 'resolveFromSource', true) && { plugins: [createSourceResolutionPlugin()] }),
64
+ ...(resolveFlag(layers, 'shouldResolveFromSource', true) && { plugins: [createSourceResolutionPlugin()] }),
41
65
  resolve,
42
66
  ssr: { resolve: { conditions: SERVER_CONDITIONS } },
43
67
  test: {
@@ -48,31 +72,31 @@ function buildConfig(declaredLayers, { coverageInclude, projectExclude = [], pro
48
72
  provider: 'v8',
49
73
  },
50
74
  passWithNoTests: true,
51
- projects: buildProjects(layers, projectExclude, projectRoot),
75
+ projects: buildProjects(layers, projectExclude, projectRoot, ignoredPathsRoot),
52
76
  silent: 'passed-only',
53
77
  watch: false,
54
78
  },
55
79
  };
56
- let merged = config;
80
+ let mergedConfig = config;
57
81
  for (const { root } of layers) {
58
82
  if (root)
59
- merged = mergeConfig(merged, root);
83
+ mergedConfig = mergeConfig(mergedConfig, root);
60
84
  }
61
- return merged;
85
+ return mergedConfig;
62
86
  }
63
- function buildProjects(layers, extraExclude, projectRoot) {
87
+ function buildProjects(layers, extraExclude, projectRoot, ignoredPathsRoot) {
64
88
  const projectTiers = [
65
89
  { exclude: TIERED_PATTERNS, include: ALL_TEST_PATTERNS, name: RESIDUAL_TIER },
66
90
  ...NAMED_TIERS.map((tier) => ({
67
91
  exclude: [],
68
92
  include: buildTierPatterns(tier),
69
93
  name: tier,
70
- timeout: TIER_TIMEOUT,
94
+ timeoutMs: TIER_TIMEOUT_MS,
71
95
  })),
72
96
  ];
73
- const collectionExclude = buildCollectionExclude(layers);
74
- const isolateGit = resolveFlag(layers, 'isolateGit', true);
75
- return projectTiers.map(({ exclude, include, name, timeout }) => {
97
+ const collectionExclude = buildCollectionExclude(layers, ignoredPathsRoot);
98
+ const shouldIsolateGit = resolveFlag(layers, 'shouldIsolateGit', true);
99
+ return projectTiers.map(({ exclude, include, name, timeoutMs }) => {
76
100
  const project = {
77
101
  extends: true,
78
102
  ...(projectRoot !== undefined && { root: projectRoot }),
@@ -80,51 +104,84 @@ function buildProjects(layers, extraExclude, projectRoot) {
80
104
  exclude: [...collectionExclude, ...exclude, ...extraExclude],
81
105
  include,
82
106
  name,
83
- ...(isolateGit && { setupFiles: [GIT_ISOLATION_SETUP_FILE] }),
84
- ...(timeout !== undefined && { hookTimeout: timeout, testTimeout: timeout }),
107
+ ...(shouldIsolateGit && { setupFiles: [GIT_ISOLATION_SETUP_FILE] }),
108
+ ...(timeoutMs !== undefined && { hookTimeout: timeoutMs, testTimeout: timeoutMs }),
85
109
  },
86
110
  };
87
- let merged = project;
111
+ let mergedProject = project;
88
112
  for (const layer of layers) {
89
- merged = applyLayer(merged, layer, name);
113
+ mergedProject = applyLayer(mergedProject, layer, name);
90
114
  }
91
- return merged;
115
+ return mergedProject;
92
116
  });
93
117
  }
94
118
  function applyLayer(project, layer, tier) {
95
119
  const withProject = layer.project ? mergeConfig(project, { test: layer.project }) : project;
96
- const targeted = layer.tiers?.[tier];
97
- return targeted ? mergeConfig(withProject, { test: targeted }) : withProject;
120
+ const targetedOverrides = layer.tiers?.[tier];
121
+ return targetedOverrides ? mergeConfig(withProject, { test: targetedOverrides }) : withProject;
98
122
  }
99
123
  function assertKnownTiers(layers) {
100
- const known = TIER_NAMES;
124
+ const knownTiers = TIER_NAMES;
101
125
  for (const { tiers } of layers) {
102
126
  const declaredTiers = Object.keys(tiers ?? {});
103
127
  for (const name of declaredTiers) {
104
- if (!known.includes(name)) {
128
+ if (!knownTiers.includes(name)) {
105
129
  throw new TypeError(`Unknown tier "${name}" in \`tiers\`. Valid tiers: ${TIER_NAMES.join(', ')}.`);
106
130
  }
107
131
  }
108
132
  }
109
133
  }
110
- function buildCollectionExclude(layers) {
111
- const declared = layers.flatMap((layer) => layer.testCollectionExclude ?? []);
112
- const globs = [...TEST_COLLECTION_EXCLUDE, ...declared].map((dir) => `**/${dir}/**`);
113
- return [...new Set([...defaultExclude, ...globs])];
134
+ function assertNoRetiredOptions(layer) {
135
+ for (const key of Object.keys(layer)) {
136
+ const replacement = RETIRED_OPTION_KEYS.get(key);
137
+ if (replacement !== undefined) {
138
+ throw new TypeError(`Invalid Vitest config: \`${key}\` was renamed to \`${replacement}\`.`);
139
+ }
140
+ }
141
+ }
142
+ function assertOptionKeys(layers, lastLayerKeys) {
143
+ const lastIndex = layers.length - 1;
144
+ layers.forEach((layer, index) => {
145
+ if (layer === undefined)
146
+ return;
147
+ assertNoRetiredOptions(layer);
148
+ const recognizedKeys = index === lastIndex ? lastLayerKeys : RECOGNIZED_OPTION_KEYS;
149
+ const unrecognizedKeys = Object.keys(layer).filter((key) => !recognizedKeys.includes(key));
150
+ if (unrecognizedKeys.length === 0)
151
+ return;
152
+ throw new TypeError(`Invalid Vitest config: unrecognized ${unrecognizedKeys.length === 1 ? 'option' : 'options'} ` +
153
+ `${formatOptionKeys(unrecognizedKeys)}. Recognized: ${formatOptionKeys(recognizedKeys)}.`);
154
+ });
155
+ }
156
+ function buildCollectionExclude(layers, ignoredPathsRoot) {
157
+ const declaredDirs = layers.flatMap((layer) => layer.testCollectionExclude ?? []);
158
+ const globs = [...TEST_COLLECTION_EXCLUDE, ...declaredDirs].map((dir) => `**/${dir}/**`);
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;
165
+ }
166
+ function formatOptionKeys(keys) {
167
+ return keys
168
+ .toSorted()
169
+ .map((key) => `\`${key}\``)
170
+ .join(', ');
114
171
  }
115
172
  function getWorkspaceExcludePatterns(monorepoRoot) {
116
173
  return getWorkspacePackageDirs(monorepoRoot)
117
174
  .map((dir) => `${path.relative(monorepoRoot, dir).split(path.sep).join('/')}/**`)
118
175
  .toSorted();
119
176
  }
120
- function resolveFlag(layers, name, fallback) {
121
- let resolved = fallback;
177
+ function resolveFlag(layers, name, isOnByDefault) {
178
+ let resolvedFlag = isOnByDefault;
122
179
  for (const layer of layers) {
123
- const declared = layer[name];
124
- if (declared !== undefined)
125
- resolved = declared;
180
+ const declaredFlag = layer[name];
181
+ if (declaredFlag !== undefined)
182
+ resolvedFlag = declaredFlag;
126
183
  }
127
- return resolved;
184
+ return resolvedFlag;
128
185
  }
129
186
  function resolveGitIsolationSetupFile() {
130
187
  const thisFile = fileURLToPath(import.meta.url);
@@ -1,31 +1,15 @@
1
- /** Reports whether a directory is the monorepo root, which the workspace manifest's presence marks. */
2
- export declare function isMonorepoRoot(dir: string): boolean;
1
+ export type { EmptyWorkspaceCause, WorkspaceResolution } from '@williamthorsen/nmr-core/workspace';
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[];
16
- /**
17
- * Reads the `overrides` block from the monorepo root's `pnpm-workspace.yaml`, the site pnpm reads an override
18
- * from.
19
- *
20
- * Returns nothing when the manifest or the block is missing, and drops an entry whose value is not a string.
21
- */
22
- export declare function readWorkspaceOverrides(monorepoRoot: string): Record<string, string> | undefined;
23
- /**
24
- * Reads the manifest `name` each of the given package directories declares, which is what a `-F` pattern
25
- * matches.
26
- *
27
- * A directory whose manifest is missing, unparseable, or nameless contributes nothing. The sweep runs for a
28
- * diagnostic's sake, so one malformed manifest elsewhere in the workspace must not replace the diagnostic the
29
- * reader asked for with a failure of its own.
30
- */
31
- export declare function readWorkspacePackageNames(packageDirs: readonly string[]): string[];