@williamthorsen/nmr 0.37.0 → 0.39.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 (82) hide show
  1. package/.meta/changelog.json +114 -0
  2. package/.readyup/kits/default.js +92 -87
  3. package/.readyup/manifest.json +8 -8
  4. package/CHANGELOG.md +89 -0
  5. package/README.md +37 -28
  6. package/agents/guidance/rulebooks/nmr.md +2 -2
  7. package/dist/esm/check-cache.d.ts +4 -3
  8. package/dist/esm/check-cache.js +28 -28
  9. package/dist/esm/cli-build.js +3 -0
  10. package/dist/esm/cli-clean.js +3 -1
  11. package/dist/esm/cli-ensure-prepublish-hooks.js +7 -5
  12. package/dist/esm/cli-report-catalog.js +2 -1
  13. package/dist/esm/cli-report-overrides.js +2 -1
  14. package/dist/esm/commands/build-output.d.ts +2 -2
  15. package/dist/esm/commands/build-output.js +9 -9
  16. package/dist/esm/commands/build.d.ts +6 -1
  17. package/dist/esm/commands/build.js +73 -72
  18. package/dist/esm/commands/clean.d.ts +3 -2
  19. package/dist/esm/commands/clean.js +25 -24
  20. package/dist/esm/commands/ensure-prepublish-hooks.d.ts +5 -4
  21. package/dist/esm/commands/ensure-prepublish-hooks.js +44 -42
  22. package/dist/esm/commands/fmt.js +33 -33
  23. package/dist/esm/commands/report-catalog.d.ts +2 -1
  24. package/dist/esm/commands/report-catalog.js +14 -11
  25. package/dist/esm/commands/report-overrides.d.ts +2 -1
  26. package/dist/esm/commands/report-overrides.js +11 -10
  27. package/dist/esm/config.js +39 -29
  28. package/dist/esm/context.js +8 -8
  29. package/dist/esm/default-scripts.d.ts +2 -2
  30. package/dist/esm/default-scripts.js +5 -5
  31. package/dist/esm/glyphs.d.ts +9 -0
  32. package/dist/esm/glyphs.js +9 -0
  33. package/dist/esm/help.d.ts +2 -2
  34. package/dist/esm/help.js +36 -30
  35. package/dist/esm/helpers/clampToBytes.js +3 -3
  36. package/dist/esm/helpers/createBoundedBuffer.d.ts +1 -1
  37. package/dist/esm/helpers/createBoundedBuffer.js +2 -2
  38. package/dist/esm/helpers/duration.d.ts +2 -2
  39. package/dist/esm/helpers/duration.js +7 -7
  40. package/dist/esm/helpers/filter-selection.js +12 -12
  41. package/dist/esm/helpers/findClosestName.js +15 -15
  42. package/dist/esm/helpers/package-json.d.ts +2 -2
  43. package/dist/esm/helpers/package-json.js +26 -25
  44. package/dist/esm/helpers/transcript.d.ts +2 -2
  45. package/dist/esm/helpers/transcript.js +14 -14
  46. package/dist/esm/output-style.d.ts +41 -0
  47. package/dist/esm/output-style.js +17 -0
  48. package/dist/esm/prettier.js +5 -5
  49. package/dist/esm/recording.d.ts +8 -5
  50. package/dist/esm/recording.js +27 -17
  51. package/dist/esm/report-format.d.ts +1 -1
  52. package/dist/esm/report-format.js +7 -7
  53. package/dist/esm/resolveBinStyles.d.ts +12 -0
  54. package/dist/esm/resolveBinStyles.js +16 -0
  55. package/dist/esm/resolver.d.ts +2 -2
  56. package/dist/esm/resolver.js +16 -16
  57. package/dist/esm/runCli.js +309 -221
  58. package/dist/esm/runner.d.ts +1 -1
  59. package/dist/esm/runner.js +18 -18
  60. package/dist/esm/steps.d.ts +17 -5
  61. package/dist/esm/steps.js +37 -28
  62. package/dist/esm/tests.d.ts +7 -7
  63. package/dist/esm/tests.js +20 -8
  64. package/dist/esm/tiers.d.ts +1 -1
  65. package/dist/esm/tiers.js +8 -8
  66. package/dist/esm/verbosity.d.ts +1 -1
  67. package/dist/esm/verbosity.js +7 -7
  68. package/dist/esm/verdict.d.ts +15 -4
  69. package/dist/esm/verdict.js +78 -59
  70. package/dist/esm/vitest-source-resolution.js +48 -46
  71. package/dist/esm/vitest.d.ts +2 -2
  72. package/dist/esm/vitest.js +74 -25
  73. package/dist/esm/workspace.d.ts +2 -18
  74. package/dist/esm/workspace.js +9 -67
  75. package/docs/check-cache.md +5 -5
  76. package/docs/reporting.md +20 -2
  77. package/docs/scripts.md +2 -2
  78. package/docs/vitest.md +6 -6
  79. package/docs/workspace.md +24 -1
  80. package/package.json +9 -10
  81. package/dist/esm/helpers/workspace-patterns.d.ts +0 -8
  82. package/dist/esm/helpers/workspace-patterns.js +0 -29
@@ -1,46 +1,63 @@
1
+ import { STATUS_GLYPHS } from '@williamthorsen/nmr-core';
2
+ import { NMR_GLYPHS } from "./glyphs.js";
1
3
  import { clampToBytes, TRUNCATION_MARK } from "./helpers/clampToBytes.js";
2
4
  import { formatDuration, formatSaving } from "./helpers/duration.js";
3
- export const VERDICT_LINE_LIMIT = 512;
4
- export function renderVerdict(verdict) {
5
- const { icon, phrase } = describeOutcome(verdict);
5
+ export const VERDICT_LINE_LIMIT_BYTES = 512;
6
+ export function renderVerdict(verdict, style) {
7
+ const { marker, phrase } = describeOutcome(verdict, style);
6
8
  const detail = flattenDetail(verdict.detail ?? renderReplay(verdict) ?? '');
7
9
  const detailClause = detail === '' ? '' : ` — ${detail}`;
8
- return clampToBytes(`${icon} ${verdict.scope}: ${verdict.command}: ${phrase}${detailClause}`, LINE_BUDGET_BYTES);
10
+ return clampToBytes(`${marker} ${verdict.scope}: ${verdict.command}: ${phrase}${detailClause}`, LINE_BUDGET_BYTES);
9
11
  }
10
12
  export function serializeVerdict(verdict) {
11
- const rendered = JSON.stringify(verdict);
12
- return isWithinBudget(rendered) ? rendered : renderWithinBudget(verdict);
13
+ const renderedLine = JSON.stringify(verdict);
14
+ return isWithinBudget(renderedLine) ? renderedLine : renderWithinBudget(verdict);
13
15
  }
14
- export function writeVerdict(verdict, stream, format) {
15
- const line = format === 'json' ? serializeVerdict(verdict) : renderVerdict(verdict);
16
+ export function writeVerdict(verdict, stream, format, style) {
17
+ const line = format === 'json' ? serializeVerdict(verdict) : renderVerdict(verdict, style);
16
18
  stream.write(`${line}\n`);
17
19
  }
18
20
  const NEWLINE_BYTES = 1;
19
- const LINE_BUDGET_BYTES = VERDICT_LINE_LIMIT - NEWLINE_BYTES;
21
+ const LINE_BUDGET_BYTES = VERDICT_LINE_LIMIT_BYTES - NEWLINE_BYTES;
20
22
  const MIN_CUT_BYTES = Buffer.byteLength(TRUNCATION_MARK);
21
23
  const REPLAY_MARKER = 'replayed:';
22
- function describeOutcome(verdict) {
24
+ function describeOutcome(verdict, style) {
25
+ const statuses = STATUS_GLYPHS[style];
23
26
  switch (verdict.outcome) {
24
27
  case 'passed':
25
- return { icon: '✅', phrase: `passed in ${formatDuration(verdict.durationMs)}` };
28
+ return { marker: statuses.passed.text, phrase: `passed in ${formatDuration(verdict.durationMs)}` };
26
29
  case 'failed':
27
30
  return {
28
- icon: '❌',
31
+ marker: statuses.failed.text,
29
32
  phrase: `failed in ${formatDuration(verdict.durationMs)} (exit ${verdict.exitCode})`,
30
33
  };
31
34
  case 'recalled': {
32
35
  const saving = formatSaving(verdict.savedMs);
33
36
  const savingClause = saving === undefined ? '' : `, ${saving}`;
34
- return { icon: '⏭️', phrase: `passed ${formatDuration(verdict.ageMs)} ago on this tree${savingClause}` };
35
- }
36
- case 'no-op':
37
37
  return {
38
- icon: '⛔',
39
- phrase: `skipped, the override is ${verdict.reason === 'empty-override' ? 'empty' : 'a no-op'}`,
38
+ marker: statuses.skipped.text,
39
+ phrase: `passed ${formatDuration(verdict.ageMs)} ago on this tree${savingClause}`,
40
40
  };
41
+ }
42
+ case 'no-op':
43
+ return { marker: NMR_GLYPHS[style].noop.text, phrase: `skipped, ${describeNoOpReason(verdict.reason)}` };
44
+ default: {
45
+ const unhandledVerdict = verdict;
46
+ throw new Error(`Unhandled verdict outcome: ${JSON.stringify(unhandledVerdict)}`);
47
+ }
48
+ }
49
+ }
50
+ function describeNoOpReason(reason) {
51
+ switch (reason) {
52
+ case 'empty-override':
53
+ return 'the override is empty';
54
+ case 'empty-workspace':
55
+ return 'the workspace declares no package';
56
+ case 'noop-override':
57
+ return 'the override is a no-op';
41
58
  default: {
42
- const unhandled = verdict;
43
- throw new Error(`Unhandled verdict outcome: ${JSON.stringify(unhandled)}`);
59
+ const unhandledReason = reason;
60
+ throw new Error(`Unhandled no-op reason: ${String(unhandledReason)}`);
44
61
  }
45
62
  }
46
63
  }
@@ -58,14 +75,16 @@ function renderReplay(verdict) {
58
75
  function flattenDetail(detail) {
59
76
  return detail.replaceAll(/[\r\n]+/gu, ' ').trim();
60
77
  }
61
- function findCutTarget(sizes, longest, overrunBytes) {
62
- const below = sizes.filter((size) => size < longest && size > MIN_CUT_BYTES);
63
- const share = Math.ceil(overrunBytes / sizes.filter((size) => size === longest).length);
64
- const target = below.length === 0 ? longest - share : Math.max(...below, longest - overrunBytes);
78
+ function findCutTarget(sizesBytes, longestSizeBytes, overrunBytes) {
79
+ const smallerSizesBytes = sizesBytes.filter((size) => size < longestSizeBytes && size > MIN_CUT_BYTES);
80
+ const share = Math.ceil(overrunBytes / sizesBytes.filter((size) => size === longestSizeBytes).length);
81
+ const target = smallerSizesBytes.length === 0
82
+ ? longestSizeBytes - share
83
+ : Math.max(...smallerSizesBytes, longestSizeBytes - overrunBytes);
65
84
  return Math.max(MIN_CUT_BYTES, target);
66
85
  }
67
- function isWithinBudget(rendered) {
68
- return Buffer.byteLength(rendered) <= LINE_BUDGET_BYTES;
86
+ function isWithinBudget(renderedLine) {
87
+ return Buffer.byteLength(renderedLine) <= LINE_BUDGET_BYTES;
69
88
  }
70
89
  function readAttribution(verdict) {
71
90
  if (verdict.outcome !== 'recalled' || verdict.replay === undefined) {
@@ -78,64 +97,64 @@ function readCuttableText(verdict) {
78
97
  return [verdict.detail ?? '', ...excerpts];
79
98
  }
80
99
  function renderClamped(record) {
81
- const clamped = { ...record };
82
- let rendered = JSON.stringify(clamped);
83
- while (!isWithinBudget(rendered)) {
100
+ const clampedRecord = { ...record };
101
+ let renderedLine = JSON.stringify(clampedRecord);
102
+ while (!isWithinBudget(renderedLine)) {
84
103
  const fields = ['command', 'scope'];
85
- const sizes = fields.map((field) => Buffer.byteLength(typeof clamped[field] === 'string' ? clamped[field] : ''));
86
- const longest = Math.max(...sizes);
87
- if (longest <= MIN_CUT_BYTES) {
88
- return rendered;
104
+ const sizesBytes = fields.map((field) => Buffer.byteLength(typeof clampedRecord[field] === 'string' ? clampedRecord[field] : ''));
105
+ const longestSizeBytes = Math.max(...sizesBytes);
106
+ if (longestSizeBytes <= MIN_CUT_BYTES) {
107
+ return renderedLine;
89
108
  }
90
- const target = findCutTarget(sizes, longest, Buffer.byteLength(rendered) - LINE_BUDGET_BYTES);
91
- const field = fields[sizes.indexOf(longest)] ?? 'command';
92
- clamped[field] = clampToBytes(typeof clamped[field] === 'string' ? clamped[field] : '', target);
93
- rendered = JSON.stringify(clamped);
109
+ const target = findCutTarget(sizesBytes, longestSizeBytes, Buffer.byteLength(renderedLine) - LINE_BUDGET_BYTES);
110
+ const field = fields[sizesBytes.indexOf(longestSizeBytes)] ?? 'command';
111
+ clampedRecord[field] = clampToBytes(typeof clampedRecord[field] === 'string' ? clampedRecord[field] : '', target);
112
+ renderedLine = JSON.stringify(clampedRecord);
94
113
  }
95
- return rendered;
114
+ return renderedLine;
96
115
  }
97
116
  function renderWithinBudget(verdict) {
98
- const shortened = shortenCuttableText(verdict);
99
- const shortenedLine = JSON.stringify(shortened);
117
+ const shortenedVerdict = shortenCuttableText(verdict);
118
+ const shortenedLine = JSON.stringify(shortenedVerdict);
100
119
  if (isWithinBudget(shortenedLine)) {
101
120
  return shortenedLine;
102
121
  }
103
- const record = { ...shortened };
122
+ const record = { ...shortenedVerdict };
104
123
  delete record['detail'];
105
- const attribution = readAttribution(shortened);
106
- for (let kept = attribution.length; kept > 0; kept--) {
107
- record['replay'] = attribution.slice(0, kept);
108
- const rendered = JSON.stringify(record);
109
- if (isWithinBudget(rendered)) {
110
- return rendered;
124
+ const attribution = readAttribution(shortenedVerdict);
125
+ for (let keptCount = attribution.length; keptCount > 0; keptCount--) {
126
+ record['replay'] = attribution.slice(0, keptCount);
127
+ const renderedLine = JSON.stringify(record);
128
+ if (isWithinBudget(renderedLine)) {
129
+ return renderedLine;
111
130
  }
112
131
  }
113
132
  delete record['replay'];
114
- const bare = JSON.stringify(record);
115
- return isWithinBudget(bare) ? bare : renderClamped(record);
133
+ const bareLine = JSON.stringify(record);
134
+ return isWithinBudget(bareLine) ? bareLine : renderClamped(record);
116
135
  }
117
136
  function shortenCuttableText(verdict) {
118
137
  let candidate = verdict;
119
- let rendered = JSON.stringify(candidate);
120
- while (!isWithinBudget(rendered)) {
121
- const shortened = shortenLongestText(candidate, Buffer.byteLength(rendered) - LINE_BUDGET_BYTES);
122
- if (shortened === undefined) {
138
+ let renderedLine = JSON.stringify(candidate);
139
+ while (!isWithinBudget(renderedLine)) {
140
+ const shortenedVerdict = shortenLongestText(candidate, Buffer.byteLength(renderedLine) - LINE_BUDGET_BYTES);
141
+ if (shortenedVerdict === undefined) {
123
142
  return candidate;
124
143
  }
125
- candidate = shortened;
126
- rendered = JSON.stringify(candidate);
144
+ candidate = shortenedVerdict;
145
+ renderedLine = JSON.stringify(candidate);
127
146
  }
128
147
  return candidate;
129
148
  }
130
149
  function shortenLongestText(verdict, overrunBytes) {
131
150
  const texts = readCuttableText(verdict);
132
- const sizes = texts.map((text) => Buffer.byteLength(text));
133
- const longest = Math.max(...sizes);
134
- if (longest <= MIN_CUT_BYTES) {
151
+ const sizesBytes = texts.map((text) => Buffer.byteLength(text));
152
+ const longestSizeBytes = Math.max(...sizesBytes);
153
+ if (longestSizeBytes <= MIN_CUT_BYTES) {
135
154
  return undefined;
136
155
  }
137
- const index = sizes.indexOf(longest);
138
- const target = findCutTarget(sizes, longest, overrunBytes);
156
+ const index = sizesBytes.indexOf(longestSizeBytes);
157
+ const target = findCutTarget(sizesBytes, longestSizeBytes, overrunBytes);
139
158
  return writeCuttableText(verdict, index, clampToBytes(texts[index] ?? '', target));
140
159
  }
141
160
  function writeCuttableText(verdict, index, value) {
@@ -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,9 +112,9 @@ 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 reaches no file.`,
116
118
  `Looked for ${target}, the same path under each of ${TARGET_EXTENSIONS.join(', ')}, and an index under it.`,
117
119
  'Point the condition at a file that exists, or remove it so the package resolves through its other conditions.',
118
120
  ].join(' ');
@@ -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
  }
@@ -43,13 +43,13 @@ export interface VitestConfigOptions {
43
43
  * Loads nmr's git-isolation setup file into every project, ahead of any the layers supply. Defaults to `true`.
44
44
  * Turn it off only where a suite is meant to read the developer's own git configuration.
45
45
  */
46
- isolateGit?: boolean;
46
+ shouldIsolateGit?: boolean;
47
47
  /**
48
48
  * Resolves a package whose files sit outside every `node_modules` through its `source` export condition, so a
49
49
  * suite runs without a prior build. Defaults to `true`. A package declaring no such condition is unaffected,
50
50
  * and so is every package under `node_modules`, which Node resolves and which the condition never reaches.
51
51
  */
52
- resolveFromSource?: boolean;
52
+ shouldResolveFromSource?: boolean;
53
53
  /**
54
54
  * Resolves a specifier through the `paths` a `tsconfig.json` declares, so a test reaches an alias the way `tsc`
55
55
  * does. Defaults to `false`, matching Vite, and requires Vite 8.
@@ -7,14 +7,34 @@ import { createSourceResolutionPlugin } from "./vitest-source-resolution.js";
7
7
  import { getWorkspacePackageDirs } from "./workspace.js";
8
8
  const [RESIDUAL_TIER, ...NAMED_TIERS] = TIER_NAMES;
9
9
  const TIERED_PATTERNS = NAMED_TIERS.flatMap(buildTierPatterns);
10
- const TIER_TIMEOUT = 30_000;
10
+ const TIER_TIMEOUT_MS = 30_000;
11
11
  const GIT_ISOLATION_SETUP_FILE = resolveGitIsolationSetupFile();
12
12
  const CLIENT_CONDITIONS = ['module', 'browser', 'development|production'];
13
13
  const SERVER_CONDITIONS = ['module', 'node', 'development|production'];
14
14
  const COVERAGE_EXCLUDE = ['**/__{fixtures,mocks,tests}__/**', '**/index.ts', '**/*.d.ts'];
15
15
  const PACKAGE_COVERAGE_INCLUDE = ['**/src/**/*.{ts,tsx}'];
16
+ const RECOGNIZED_OPTIONS = {
17
+ project: true,
18
+ root: true,
19
+ shouldIsolateGit: true,
20
+ shouldResolveFromSource: true,
21
+ testCollectionExclude: true,
22
+ tiers: true,
23
+ tsconfigPaths: true,
24
+ };
25
+ const RECOGNIZED_ROOT_OPTIONS = {
26
+ ...RECOGNIZED_OPTIONS,
27
+ monorepoRoot: true,
28
+ };
29
+ const RECOGNIZED_OPTION_KEYS = Object.keys(RECOGNIZED_OPTIONS);
30
+ const RECOGNIZED_ROOT_OPTION_KEYS = Object.keys(RECOGNIZED_ROOT_OPTIONS);
31
+ const RETIRED_OPTION_KEYS = new Map([
32
+ ['isolateGit', 'shouldIsolateGit'],
33
+ ['resolveFromSource', 'shouldResolveFromSource'],
34
+ ]);
16
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.';
17
36
  export function defineVitestConfig(...layers) {
37
+ assertOptionKeys(layers, RECOGNIZED_OPTION_KEYS);
18
38
  return buildConfig(layers, { coverageInclude: PACKAGE_COVERAGE_INCLUDE });
19
39
  }
20
40
  export function defineRootVitestConfig(...layers) {
@@ -23,6 +43,7 @@ export function defineRootVitestConfig(...layers) {
23
43
  if (typeof monorepoRoot !== 'string' || !path.isAbsolute(monorepoRoot)) {
24
44
  throw new TypeError(MISSING_MONOREPO_ROOT);
25
45
  }
46
+ assertOptionKeys(layers, RECOGNIZED_ROOT_OPTION_KEYS);
26
47
  return buildConfig(layers, {
27
48
  coverageInclude: [],
28
49
  projectExclude: getWorkspaceExcludePatterns(monorepoRoot),
@@ -37,7 +58,7 @@ function buildConfig(declaredLayers, { coverageInclude, projectExclude = [], pro
37
58
  ...(resolveFlag(layers, 'tsconfigPaths', false) && { tsconfigPaths: true }),
38
59
  };
39
60
  const config = {
40
- ...(resolveFlag(layers, 'resolveFromSource', true) && { plugins: [createSourceResolutionPlugin()] }),
61
+ ...(resolveFlag(layers, 'shouldResolveFromSource', true) && { plugins: [createSourceResolutionPlugin()] }),
41
62
  resolve,
42
63
  ssr: { resolve: { conditions: SERVER_CONDITIONS } },
43
64
  test: {
@@ -53,12 +74,12 @@ function buildConfig(declaredLayers, { coverageInclude, projectExclude = [], pro
53
74
  watch: false,
54
75
  },
55
76
  };
56
- let merged = config;
77
+ let mergedConfig = config;
57
78
  for (const { root } of layers) {
58
79
  if (root)
59
- merged = mergeConfig(merged, root);
80
+ mergedConfig = mergeConfig(mergedConfig, root);
60
81
  }
61
- return merged;
82
+ return mergedConfig;
62
83
  }
63
84
  function buildProjects(layers, extraExclude, projectRoot) {
64
85
  const projectTiers = [
@@ -67,12 +88,12 @@ function buildProjects(layers, extraExclude, projectRoot) {
67
88
  exclude: [],
68
89
  include: buildTierPatterns(tier),
69
90
  name: tier,
70
- timeout: TIER_TIMEOUT,
91
+ timeoutMs: TIER_TIMEOUT_MS,
71
92
  })),
72
93
  ];
73
94
  const collectionExclude = buildCollectionExclude(layers);
74
- const isolateGit = resolveFlag(layers, 'isolateGit', true);
75
- return projectTiers.map(({ exclude, include, name, timeout }) => {
95
+ const shouldIsolateGit = resolveFlag(layers, 'shouldIsolateGit', true);
96
+ return projectTiers.map(({ exclude, include, name, timeoutMs }) => {
76
97
  const project = {
77
98
  extends: true,
78
99
  ...(projectRoot !== undefined && { root: projectRoot }),
@@ -80,51 +101,79 @@ function buildProjects(layers, extraExclude, projectRoot) {
80
101
  exclude: [...collectionExclude, ...exclude, ...extraExclude],
81
102
  include,
82
103
  name,
83
- ...(isolateGit && { setupFiles: [GIT_ISOLATION_SETUP_FILE] }),
84
- ...(timeout !== undefined && { hookTimeout: timeout, testTimeout: timeout }),
104
+ ...(shouldIsolateGit && { setupFiles: [GIT_ISOLATION_SETUP_FILE] }),
105
+ ...(timeoutMs !== undefined && { hookTimeout: timeoutMs, testTimeout: timeoutMs }),
85
106
  },
86
107
  };
87
- let merged = project;
108
+ let mergedProject = project;
88
109
  for (const layer of layers) {
89
- merged = applyLayer(merged, layer, name);
110
+ mergedProject = applyLayer(mergedProject, layer, name);
90
111
  }
91
- return merged;
112
+ return mergedProject;
92
113
  });
93
114
  }
94
115
  function applyLayer(project, layer, tier) {
95
116
  const withProject = layer.project ? mergeConfig(project, { test: layer.project }) : project;
96
- const targeted = layer.tiers?.[tier];
97
- return targeted ? mergeConfig(withProject, { test: targeted }) : withProject;
117
+ const targetedOverrides = layer.tiers?.[tier];
118
+ return targetedOverrides ? mergeConfig(withProject, { test: targetedOverrides }) : withProject;
98
119
  }
99
120
  function assertKnownTiers(layers) {
100
- const known = TIER_NAMES;
121
+ const knownTiers = TIER_NAMES;
101
122
  for (const { tiers } of layers) {
102
123
  const declaredTiers = Object.keys(tiers ?? {});
103
124
  for (const name of declaredTiers) {
104
- if (!known.includes(name)) {
125
+ if (!knownTiers.includes(name)) {
105
126
  throw new TypeError(`Unknown tier "${name}" in \`tiers\`. Valid tiers: ${TIER_NAMES.join(', ')}.`);
106
127
  }
107
128
  }
108
129
  }
109
130
  }
131
+ function assertNoRetiredOptions(layer) {
132
+ for (const key of Object.keys(layer)) {
133
+ const replacement = RETIRED_OPTION_KEYS.get(key);
134
+ if (replacement !== undefined) {
135
+ throw new TypeError(`Invalid Vitest config: \`${key}\` was renamed to \`${replacement}\`.`);
136
+ }
137
+ }
138
+ }
139
+ function assertOptionKeys(layers, lastLayerKeys) {
140
+ const lastIndex = layers.length - 1;
141
+ layers.forEach((layer, index) => {
142
+ if (layer === undefined)
143
+ return;
144
+ assertNoRetiredOptions(layer);
145
+ const recognizedKeys = index === lastIndex ? lastLayerKeys : RECOGNIZED_OPTION_KEYS;
146
+ const unrecognizedKeys = Object.keys(layer).filter((key) => !recognizedKeys.includes(key));
147
+ if (unrecognizedKeys.length === 0)
148
+ return;
149
+ throw new TypeError(`Invalid Vitest config: unrecognized ${unrecognizedKeys.length === 1 ? 'option' : 'options'} ` +
150
+ `${formatOptionKeys(unrecognizedKeys)}. Recognized: ${formatOptionKeys(recognizedKeys)}.`);
151
+ });
152
+ }
110
153
  function buildCollectionExclude(layers) {
111
- const declared = layers.flatMap((layer) => layer.testCollectionExclude ?? []);
112
- const globs = [...TEST_COLLECTION_EXCLUDE, ...declared].map((dir) => `**/${dir}/**`);
154
+ const declaredDirs = layers.flatMap((layer) => layer.testCollectionExclude ?? []);
155
+ const globs = [...TEST_COLLECTION_EXCLUDE, ...declaredDirs].map((dir) => `**/${dir}/**`);
113
156
  return [...new Set([...defaultExclude, ...globs])];
114
157
  }
158
+ function formatOptionKeys(keys) {
159
+ return keys
160
+ .toSorted()
161
+ .map((key) => `\`${key}\``)
162
+ .join(', ');
163
+ }
115
164
  function getWorkspaceExcludePatterns(monorepoRoot) {
116
165
  return getWorkspacePackageDirs(monorepoRoot)
117
166
  .map((dir) => `${path.relative(monorepoRoot, dir).split(path.sep).join('/')}/**`)
118
167
  .toSorted();
119
168
  }
120
- function resolveFlag(layers, name, fallback) {
121
- let resolved = fallback;
169
+ function resolveFlag(layers, name, isOnByDefault) {
170
+ let resolvedFlag = isOnByDefault;
122
171
  for (const layer of layers) {
123
- const declared = layer[name];
124
- if (declared !== undefined)
125
- resolved = declared;
172
+ const declaredFlag = layer[name];
173
+ if (declaredFlag !== undefined)
174
+ resolvedFlag = declaredFlag;
126
175
  }
127
- return resolved;
176
+ return resolvedFlag;
128
177
  }
129
178
  function resolveGitIsolationSetupFile() {
130
179
  const thisFile = fileURLToPath(import.meta.url);
@@ -1,5 +1,5 @@
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
5
  * Throws if no workspace root is found.
@@ -13,19 +13,3 @@ export declare function findMonorepoRoot(startDir?: string): string;
13
13
  * `monorepoRoot` holds no manifest at all — the caller named a directory that is not a monorepo root.
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[];