@williamthorsen/nmr 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/agents/README.md CHANGED
@@ -11,7 +11,7 @@ A line earns a place in it only if both hold:
11
11
  1. **It is absent from nmr's own output.** Bare `nmr` already prints the flags, every command, and the shell command each resolves to; failing checks already name their own fix. Repeating any of that costs launch tokens to say something the agent will be told anyway, at the moment it matters.
12
12
  2. **The obvious action goes wrong without it.** Guidance that prevents a silent mistake earns its place; guidance that prevents a mistake the next error message would diagnose does not.
13
13
 
14
- Everything else belongs in `packages/nmr/README.md`, reachable on demand at `node_modules/@williamthorsen/nmr/README.md`, with a pointer from the cheatsheet naming the topic so an agent knows to look. When adding a feature, document it there and extend that pointer rather than the cheatsheet.
14
+ Everything else belongs in the topic document under `packages/nmr/docs/` that covers it, reachable on demand at `node_modules/@williamthorsen/nmr/docs/`, with a pointer from the cheatsheet naming the topic and its document so an agent knows where to look. When adding a feature, document it there and extend that pointer rather than the cheatsheet.
15
15
 
16
16
  ## Constraints on rulebook bodies
17
17
 
@@ -13,5 +13,5 @@ delivery: ambient
13
13
  - A check that already passed on the current working tree skips, printing `⏭️ <scope>: <command>: passed …` and, where the recalled run left one, `— replayed: <excerpt>` of what that run concluded. Any edit, addition, or deletion re-runs it, so read the replayed excerpt rather than paying for the answer again: never pass `--no-cache` to get a "real" run, which costs the command's full runtime to reach the same answer. Its one use is a file rather than an exit status -- a skipped command writes none, so a fresh `coverage/` needs `nmr --no-cache test:coverage`. A composite replays its constituents' excerpts, each attributed as `<scope>: <command>: <excerpt>` and cut at the line's ceiling where there are too many to fit. A skip carrying no excerpt is one whose run wrote to a terminal, or one whose constituents recorded none.
14
14
  - `nmr --log <command>` prints the whole run behind that excerpt -- up to 256 KiB, led by when it was recorded, how long it ran, and the command string that produced it -- so output you need in full is a recording to read rather than a run to pay for again. It runs nothing, and it is held to the tree rather than to the terminal: a recording a piped run made prints for a reader at one. A composite prints its constituents' excerpts, so the transcript of one of them is `nmr -F <pkg> --log <command>` or that constituent's own name. Nothing to show is said on one line and exits non-zero, naming which it is.
15
15
  - Every command nmr runs reports one line -- `✅ <scope>: <command>: passed in 12.4s`, `❌ … failed in 1.2s (exit 1)`, `⏭️ … passed 4m ago on this tree`, or `⛔ … skipped, the override is empty` -- and a composite reports alongside every command it expands into. The lines print in every verbosity, so `-q` is the low-noise way to run rather than a silent one: it withholds the output of the commands nmr runs and keeps nmr's own verdicts, which is how a pass, a cached skip, and a `""`/`":"` override stay distinguishable. Expect a run to be quiet already without the flag: nmr recognizes a known agent harness in the environment and takes quiet from that, as it does from `NMR_COMMAND_VERBOSITY` or the repo's `output.commandVerbosity`. A command whose output you need in full is one to run with `NMR_COMMAND_VERBOSITY=full`, which outranks both.
16
- - Every test file lives under a `__tests__` directory and names its isolation tier last: `<subject>[.<aspect>].<tier>.test.ts`, where any earlier segment is free-form documentation. A tier is the furthest thing the test reaches -- `tool` (a program the environment supplies), `localhost` (a service on this machine), `remote` (a machine that isn't this one), or `unit`, the residual claiming everything else. A file outside `__tests__`, or one whose tier segment is missing or misspelt, fails the test run in a repo that declares nmr's exported conventions check, which reports both. In a repo that has not, neither fails anything -- the first is collected by nothing and the second runs under `unit` -- and `rdy run --packages` reports the misspelt tier alone.
17
- - The rest is in `node_modules/@williamthorsen/nmr/README.md`: pre/post hooks (every `nmr X` auto-wraps as `nmr X:pre && nmr X && nmr X:post`), script overrides and their `""`/`":"` skip values, `nmr upgrade` ceilings (report-only until `nmr upgrade --write`), the `nmr-compile` build (there is no repo-local build script), the test tiers' timeout budgets and the `tiers` seam that raises one, which script reaches which tier, and the check-result cache's key, the retention key a replayed excerpt is additionally held to, the retention ceiling `--log` prints within, its `checkCache` config, and every condition that disables it.
16
+ - Every test file lives under a `__tests__` directory and names its isolation tier last: `<subject>[.<aspect>].<tier>.test.ts`, where any earlier segment is free-form documentation. A tier is the furthest thing the test reaches -- `tool` (a program the environment supplies), `localhost` (a service on this machine), `remote` (a machine that isn't this one), or `unit`, the residual claiming everything else. A file outside `__tests__`, or one whose tier segment is missing or misspelt, fails the test run in a repo that declares nmr's exported conventions check, which reports both. In a repo that has not, neither fails anything -- the first is collected by nothing and the second runs under `unit` -- and `rdy run --packages` reports both and warns that the check is missing.
17
+ - The rest is in `node_modules/@williamthorsen/nmr/docs/`: `scripts.md` for pre/post hooks (every `nmr X` auto-wraps as `nmr X:pre && nmr X && nmr X:post`) and script overrides and their `""`/`":"` skip values; `upgrades.md` for `nmr upgrade` ceilings (report-only until `nmr upgrade --write`); `utilities.md` for the `nmr-compile` build (there is no repo-local build script); `vitest.md` for the test tiers' timeout budgets, the `tiers` seam that raises one, and which script reaches which tier; and `check-cache.md` for the check-result cache's key, the retention key a replayed excerpt is additionally held to, the retention ceiling `--log` prints within, its `checkCache` config, and every condition that disables it.
@@ -3,6 +3,8 @@ export interface ResolvedContext {
3
3
  monorepoRoot: string;
4
4
  isRoot: boolean;
5
5
  packageDir?: string;
6
+ /** The workspace's package directories, resolved once here rather than swept for again by each reader. */
7
+ workspacePackageDirs: string[];
6
8
  config: NmrConfig;
7
9
  }
8
10
  /**
@@ -24,6 +24,7 @@ export async function resolveContext(cwd) {
24
24
  monorepoRoot,
25
25
  isRoot: packageDir === undefined,
26
26
  ...(packageDir !== undefined && { packageDir }),
27
+ workspacePackageDirs: workspaceDirs,
27
28
  config,
28
29
  };
29
30
  }
package/dist/esm/help.js CHANGED
@@ -5,7 +5,7 @@ export function generateHelp(config, packageDir, useRoot) {
5
5
  'Usage: nmr [flags] <command> [args...]',
6
6
  '',
7
7
  'Flags:',
8
- ' -F, --filter <pattern> Run command in matching packages',
8
+ ' -F, --filter <pattern> Run command in packages whose manifest name matches',
9
9
  ' -R, --recursive Run command in all packages',
10
10
  ' -w, --workspace-root Use root scripts, running at the monorepo root',
11
11
  " -q, --quiet Suppress command output, keeping nmr's verdicts",
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Asks pnpm what a `-F` pattern selects, so an invocation that would select nothing is caught before it
3
+ * delegates, and one selecting several packages is told from one selecting a single package.
4
+ *
5
+ * pnpm's own selector answers rather than a matcher of nmr's: `-F` means the whole selector language, names
6
+ * and globs alongside `./path`, `{dir}`, `pkg...`, `!negation`, and `[since]`, and a matcher here would leave
7
+ * every form but the simplest silently unchecked.
8
+ *
9
+ * Runs from the monorepo root, where the delegate runs, so a path pattern resolves against the same directory
10
+ * in the probe as in the run it stands in for.
11
+ *
12
+ * The listing counts the root project wherever the filter leaves it standing, while the `exec` a delegation
13
+ * composes runs there only where the pattern selects the root positively: `-F '!./packages/*'` lists the root
14
+ * and executes nowhere. A listing of the root alone is therefore the one reading the listing cannot settle,
15
+ * and it is put to `exec` itself. Every other listing settles on its own, and counts the packages beside the
16
+ * root: two of them run in two scopes, while one beside the root may run in that package alone.
17
+ */
18
+ export declare function readFilterSelection(pattern: string, monorepoRoot: string): FilterSelection;
19
+ /**
20
+ * Reduces a listing to what it proves about the selection.
21
+ *
22
+ * Every outcome the probe cannot read resolves to `unresolved`, which claims nothing about the selection, and the
23
+ * invocation delegates as it would have. A pattern pnpm rejects rather than resolves, such as a bad git ref in
24
+ * a changed-since selector, is pnpm's to report, and reporting it here as a match failure would name a
25
+ * different fault than the one the user has.
26
+ */
27
+ export declare function interpretSelectionProbe(probe: SelectionProbe, monorepoRoot: string): ProbeReading;
28
+ /**
29
+ * Reduces a marked delegate run to what it proves: a scope that wrote the marker is a scope the run reached. The
30
+ * run is made only for a listing of the root alone, so that scope is the only one.
31
+ */
32
+ export declare function interpretDelegateProbe(probe: SelectionProbe): FilterSelection;
33
+ /** What a `-F` pattern selected, as far as pnpm could be asked. `multiple` is two or more packages beside the root. */
34
+ export type FilterSelection = 'empty' | 'multiple' | 'single' | 'unresolved';
35
+ /** What a listing proves, `root-only` being the reading that only the delegate can settle. */
36
+ export type ProbeReading = FilterSelection | 'root-only';
37
+ /** The part of a probe's completion a selection is read from. */
38
+ export interface SelectionProbe {
39
+ error: Error | undefined;
40
+ status: number | null;
41
+ stdout: string;
42
+ }
@@ -0,0 +1,61 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { realpathSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+ import { isObject } from "./type-guards.js";
6
+ const SELECTION_MARKER = 'nmr-selected-scope';
7
+ export function readFilterSelection(pattern, monorepoRoot) {
8
+ const listing = spawnSync('pnpm', ['ls', '--filter', pattern, '--depth', '-1', '--json'], {
9
+ cwd: monorepoRoot,
10
+ encoding: 'utf8',
11
+ });
12
+ const reading = interpretSelectionProbe({ error: listing.error, status: listing.status, stdout: listing.stdout }, monorepoRoot);
13
+ return reading === 'root-only' ? readDelegateSelection(pattern, monorepoRoot) : reading;
14
+ }
15
+ export function interpretSelectionProbe(probe, monorepoRoot) {
16
+ if (probe.error !== undefined || probe.status !== 0) {
17
+ return 'unresolved';
18
+ }
19
+ let parsed;
20
+ try {
21
+ parsed = JSON.parse(probe.stdout);
22
+ }
23
+ catch {
24
+ return 'unresolved';
25
+ }
26
+ if (!Array.isArray(parsed)) {
27
+ return 'unresolved';
28
+ }
29
+ if (parsed.length === 0) {
30
+ return 'empty';
31
+ }
32
+ const packageCount = parsed.filter((entry) => !isRootEntry(entry, monorepoRoot)).length;
33
+ if (packageCount === 0) {
34
+ return 'root-only';
35
+ }
36
+ return packageCount === 1 ? 'single' : 'multiple';
37
+ }
38
+ export function interpretDelegateProbe(probe) {
39
+ if (probe.error !== undefined || probe.status !== 0) {
40
+ return 'unresolved';
41
+ }
42
+ return probe.stdout.includes(SELECTION_MARKER) ? 'single' : 'empty';
43
+ }
44
+ function isRootEntry(entry, monorepoRoot) {
45
+ if (!isObject(entry) || typeof entry['path'] !== 'string') {
46
+ return false;
47
+ }
48
+ return readRealPath(entry['path']) === readRealPath(monorepoRoot);
49
+ }
50
+ function readRealPath(target) {
51
+ try {
52
+ return realpathSync(target);
53
+ }
54
+ catch {
55
+ return path.resolve(target);
56
+ }
57
+ }
58
+ function readDelegateSelection(pattern, monorepoRoot) {
59
+ const probe = spawnSync('pnpm', ['--filter', pattern, 'exec', process.execPath, '--eval', `process.stdout.write('${SELECTION_MARKER}')`], { cwd: monorepoRoot, encoding: 'utf8' });
60
+ return interpretDelegateProbe({ error: probe.error, status: probe.status, stdout: probe.stdout });
61
+ }
@@ -6,6 +6,8 @@ import { resolveContext } from "./context.js";
6
6
  import { generateHelp } from "./help.js";
7
7
  import { resolveConfigPath } from "./helpers/config-path.js";
8
8
  import { deriveExcerpt } from "./helpers/deriveExcerpt.js";
9
+ import { readFilterSelection } from "./helpers/filter-selection.js";
10
+ import { findClosestName } from "./helpers/findClosestName.js";
9
11
  import { isHookName } from "./helpers/hook-name.js";
10
12
  import { resolvePackageJsonPath } from "./helpers/package-json.js";
11
13
  import { composeTranscript } from "./helpers/transcript.js";
@@ -18,8 +20,10 @@ import { composeNmrStep, findNmrCrossing, renderChain } from "./steps.js";
18
20
  import { UserError } from "./UserError.js";
19
21
  import { COMMAND_VERBOSITY_ENV_VAR, readVerbosityEnv, resolveVerbosity, } from "./verbosity.js";
20
22
  import { writeVerdict } from "./verdict.js";
23
+ import { readWorkspacePackageNames } from "./workspace.js";
21
24
  const VERSION = readPackageVersion(import.meta.url);
22
25
  const CROSSING_CONSEQUENCE = "so nmr handles the nested run's output as a tool's.";
26
+ const NAME_CEILING = 10;
23
27
  const NAMED_ESCAPES = new Map([
24
28
  ['\n', String.raw `\n`],
25
29
  ['\r', String.raw `\r`],
@@ -88,7 +92,7 @@ export async function runCli(options) {
88
92
  };
89
93
  const delegation = composeDelegation({ childEnv, command, parsed });
90
94
  if (delegation !== undefined) {
91
- return runSteps([delegation.step], context.monorepoRoot, { ...runOptions, env: delegation.env });
95
+ return runDelegation({ context, delegation, parsed, runOptions, stderr });
92
96
  }
93
97
  const registry = useRoot ? buildRootRegistry(context.config) : buildWorkspaceRegistry(context.config);
94
98
  assertNoSelfReference({
@@ -346,6 +350,58 @@ function findArgError(parsed) {
346
350
  }
347
351
  return undefined;
348
352
  }
353
+ function findEmptySelectionRefusal(options) {
354
+ const { context, parsed, selection } = options;
355
+ if (parsed.filter !== undefined) {
356
+ if (selection !== 'empty') {
357
+ return undefined;
358
+ }
359
+ return formatEmptyFilterError(parsed.filter, readWorkspacePackageNames(context.workspacePackageDirs));
360
+ }
361
+ return context.workspacePackageDirs.length === 0 ? formatEmptyWorkspaceError() : undefined;
362
+ }
363
+ function formatEmptyFilterError(pattern, names) {
364
+ const rejection = `-F/--filter matched no workspace: \`${pattern}\`.`;
365
+ if (pattern.startsWith('!')) {
366
+ return `${rejection} A pattern beginning with \`!\` excludes what it matches, and this one leaves no package standing.`;
367
+ }
368
+ if (pattern.includes('[')) {
369
+ return `${rejection} A pattern carrying \`[<ref>]\` selects the packages changed since a git ref, and none has changed.`;
370
+ }
371
+ if (pattern.startsWith('.') || pattern.startsWith('/') || pattern.startsWith('{')) {
372
+ return (`${rejection} ` +
373
+ 'A pattern written `./dir`, `/dir`, or `{dir}` selects the packages under a directory, ' +
374
+ 'and no package sits under this one.');
375
+ }
376
+ return (`${rejection} A pattern matches a package's manifest \`name\`, ` +
377
+ `not its directory name.${suggestWorkspaceNames(pattern, names)}`);
378
+ }
379
+ function formatEmptyWorkspaceError() {
380
+ return ('-R/--recursive matched no workspace: pnpm-workspace.yaml declares no package directory, ' +
381
+ 'so there is no scope to fan out to.');
382
+ }
383
+ function suggestWorkspaceNames(pattern, names) {
384
+ if (names.length === 0) {
385
+ return '';
386
+ }
387
+ const containing = names.filter((name) => name.toLowerCase().includes(pattern.toLowerCase()));
388
+ if (containing.length > 0) {
389
+ return ` Did you mean ${renderNames(containing)}?`;
390
+ }
391
+ const closest = findClosestName(pattern, names);
392
+ if (closest !== undefined) {
393
+ return ` Did you mean \`${closest}\`?`;
394
+ }
395
+ return ` The workspace declares ${renderNames(names)}.`;
396
+ }
397
+ function renderNames(names) {
398
+ const shown = names
399
+ .slice(0, NAME_CEILING)
400
+ .map((name) => `\`${name}\``)
401
+ .join(', ');
402
+ const remainder = names.length - NAME_CEILING;
403
+ return remainder > 0 ? `${shown}, and ${remainder} more` : shown;
404
+ }
349
405
  function findNoOpReason(resolvedCommand) {
350
406
  if (resolvedCommand === '') {
351
407
  return 'empty-override';
@@ -446,7 +502,7 @@ function parseArgs(args) {
446
502
  if (arg === '-F' || arg === '--filter') {
447
503
  i++;
448
504
  const filterValue = args[i];
449
- if (filterValue === undefined) {
505
+ if (!filterValue) {
450
506
  return { ok: false, error: '-F/--filter requires a pattern argument' };
451
507
  }
452
508
  parsed.filter = filterValue;
@@ -642,6 +698,18 @@ function resolveCacheKey(options) {
642
698
  }
643
699
  return result.key;
644
700
  }
701
+ async function runDelegation(options) {
702
+ const { context, delegation, parsed, runOptions, stderr } = options;
703
+ const selection = parsed.filter === undefined ? undefined : readFilterSelection(parsed.filter, context.monorepoRoot);
704
+ const refusal = findEmptySelectionRefusal({ context, parsed, selection });
705
+ if (refusal !== undefined) {
706
+ reportError(refusal, stderr);
707
+ return { exitCode: 1 };
708
+ }
709
+ const shouldWithholdInput = parsed.filter === undefined || selection === 'multiple';
710
+ const step = shouldWithholdInput ? { ...delegation.step, shouldWithholdInput } : delegation.step;
711
+ return runSteps([step], context.monorepoRoot, { ...runOptions, env: delegation.env });
712
+ }
645
713
  async function runGated(options) {
646
714
  const { anchorDir, command, commandString, env, key, monorepoRoot, snapshot, stderr, stdout } = options;
647
715
  const quiet = options.runOptions.quiet === true;
@@ -39,16 +39,16 @@ export async function runSteps(steps, cwd, options) {
39
39
  return { exitCode: 0, ...composeRetainedOutput(parts, isCaptureWhole) };
40
40
  }
41
41
  export async function runCommand(command, cwd, options) {
42
- return runSpawned({ args: [], file: command, useShell: true }, cwd, options);
42
+ return runSpawned({ args: [], file: command, input: 'inherit', useShell: true }, cwd, options);
43
43
  }
44
- async function runSpawned({ args, file, useShell }, cwd, options) {
44
+ async function runSpawned({ args, file, input, useShell }, cwd, options) {
45
45
  const quiet = options.quiet === true;
46
46
  const stdout = options.stdout ?? process.stdout;
47
47
  const stderr = options.stderr ?? process.stderr;
48
48
  const env = options.env ?? process.env;
49
49
  const child = spawn(file, args, {
50
50
  shell: useShell,
51
- stdio: ['inherit', options.channels.stdout, options.channels.stderr],
51
+ stdio: [input, options.channels.stdout, options.channels.stderr],
52
52
  cwd,
53
53
  env,
54
54
  });
@@ -78,13 +78,14 @@ async function runSpawned({ args, file, useShell }, cwd, options) {
78
78
  function runStep(step, cwd, options) {
79
79
  const { quiet, stderr, stdout } = options;
80
80
  if (step.kind === 'opaque') {
81
- return runSpawned({ args: [], file: step.command, useShell: true }, cwd, {
81
+ return runSpawned({ args: [], file: step.command, input: 'inherit', useShell: true }, cwd, {
82
82
  ...options,
83
83
  channels: { stderr: resolveChannel(stderr, quiet), stdout: resolveChannel(stdout, quiet) },
84
84
  });
85
85
  }
86
86
  const [file, ...args] = step.argv;
87
- return runSpawned({ args, file, useShell: false }, cwd, {
87
+ const input = step.shouldWithholdInput === true ? 'ignore' : 'inherit';
88
+ return runSpawned({ args, file, input, useShell: false }, cwd, {
88
89
  ...options,
89
90
  quiet: false,
90
91
  channels: { stderr: resolveInheritedChannel(stderr), stdout: resolveInheritedChannel(stdout) },
@@ -7,6 +7,9 @@
7
7
  * `declinesArgs` travels from the composite element that composed the step to the one reader that acts on it,
8
8
  * the binding of the invocation's trailing arguments. Every stage between the two -- the devBin substitution,
9
9
  * the chain rendering, the replay assembly, the runner -- passes it through and asks nothing of it.
10
+ *
11
+ * `shouldWithholdInput` is read by the runner alone, which gives the step's child the null device as stdin rather
12
+ * than nmr's own. Like `declinesArgs`, it is set only where it holds and leaves the rendered chain unchanged.
10
13
  */
11
14
  export type Step = {
12
15
  kind: 'opaque';
@@ -15,6 +18,7 @@ export type Step = {
15
18
  kind: 'structural';
16
19
  argv: readonly [string, ...(readonly string[])];
17
20
  declinesArgs?: boolean;
21
+ shouldWithholdInput?: boolean;
18
22
  };
19
23
  /** What a structural step asks of the nmr process it spawns. */
20
24
  export interface NmrStepTarget {
@@ -62,7 +62,7 @@ export declare function renderVerdict(verdict: Verdict): string;
62
62
  * Held to the ceiling the prose line is held to, so one write still reaches a pipe whole where concurrent
63
63
  * scopes share a descriptor. Cuts land inside the record's text rather than across its structure, which is
64
64
  * what leaves the line parseable, and a record is fitted by rungs, each shedding what a reader can better
65
- * spare than the rung below it. What the ceiling costs is [documented](../README.md#reporting-for-a-machine)
65
+ * spare than the rung below it. What the ceiling costs is [documented](../docs/reporting.md#reporting-for-a-machine)
66
66
  * in the same order.
67
67
  */
68
68
  export declare function serializeVerdict(verdict: Verdict): string;
@@ -0,0 +1,37 @@
1
+ import type { Plugin } from 'vite';
2
+ /**
3
+ * Builds the plugin that resolves a package's `source` export condition, for a package whose files sit outside
4
+ * every `node_modules`.
5
+ *
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
8
+ * resolves natively. Node refuses to strip types from a file under `node_modules`, so a dependency declaring a
9
+ * TypeScript `source` entry fails to load as soon as the condition reaches that far.
10
+ *
11
+ * Resolving here instead keeps the condition inside Vite, which transforms what it resolves. A package under
12
+ * `node_modules` resolves through its remaining conditions, whatever its `exports` map declares.
13
+ */
14
+ export declare function createSourceResolutionPlugin(): Plugin;
15
+ /** What a resolution needs beyond the specifier and its importer: the resolving environment, and the caches. */
16
+ export interface SourceResolutionContext {
17
+ /** The Vite environment resolving the specifier, which decides the environment-specific condition. */
18
+ environmentName: string;
19
+ /** Parsed manifests, keyed by package directory. Absent from a caller that resolves once. */
20
+ manifests?: Map<string, unknown>;
21
+ /** Package directories, keyed by specifier and importer directory. Absent from a caller that resolves once. */
22
+ packageDirs?: Map<string, string | undefined>;
23
+ }
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.
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.
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.
36
+ */
37
+ export declare function resolveSourceTarget(specifier: string, importer: string | undefined, context: SourceResolutionContext): string | undefined;
@@ -0,0 +1,205 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
+ import { isBuiltin } from 'node:module';
3
+ import path from 'node:path';
4
+ import { parsePackageJson } from "./helpers/package-json.js";
5
+ import { isObject } from "./helpers/type-guards.js";
6
+ import { UserError } from "./UserError.js";
7
+ const SHARED_CONDITIONS = ['module', 'import', 'development', 'default'];
8
+ const CLIENT_CONDITION = 'browser';
9
+ const SERVER_CONDITION = 'node';
10
+ const SOURCE_CONDITION = 'source';
11
+ const NODE_MODULES = 'node_modules';
12
+ const TARGET_EXTENSIONS = ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'];
13
+ export function createSourceResolutionPlugin() {
14
+ const packageDirs = new Map();
15
+ const manifests = new Map();
16
+ return {
17
+ name: 'nmr:resolve-from-source',
18
+ enforce: 'pre',
19
+ resolveId(id, importer) {
20
+ return resolveSourceTarget(id, importer, { environmentName: this.environment.name, manifests, packageDirs });
21
+ },
22
+ };
23
+ }
24
+ export function resolveSourceTarget(specifier, importer, context) {
25
+ if (importer === undefined)
26
+ return undefined;
27
+ const parsed = parseSpecifier(specifier);
28
+ if (!parsed)
29
+ return undefined;
30
+ const packageDir = findPackageDir(parsed.name, path.dirname(importer), context.packageDirs);
31
+ if (packageDir === undefined || isInsideNodeModules(packageDir))
32
+ return undefined;
33
+ const manifest = readManifest(packageDir, context.manifests);
34
+ if (!isObject(manifest))
35
+ return undefined;
36
+ const matched = matchSubpath(manifest['exports'], parsed.subpath);
37
+ if (!matched)
38
+ return undefined;
39
+ const environmentCondition = context.environmentName === 'client' ? CLIENT_CONDITION : SERVER_CONDITION;
40
+ const conditions = new Set([environmentCondition, ...SHARED_CONDITIONS]);
41
+ const selected = selectTarget(matched.value, conditions);
42
+ if (!selected?.usedSource)
43
+ return undefined;
44
+ const wildcard = matched.wildcard;
45
+ const declared = wildcard === undefined ? selected.target : selected.target.replaceAll('*', () => wildcard);
46
+ if (!declared.startsWith('./'))
47
+ return undefined;
48
+ const declaredPath = path.resolve(packageDir, declared);
49
+ const target = findTargetFile(declaredPath);
50
+ if (target === undefined) {
51
+ throw new UserError(formatMissingTarget(parsed.name, parsed.subpath, declared, declaredPath));
52
+ }
53
+ return `${target}${parsed.postfix}`;
54
+ }
55
+ function findPackageDir(name, fromDir, cache) {
56
+ const key = `${fromDir}\0${name}`;
57
+ if (cache?.has(key))
58
+ return cache.get(key);
59
+ let found = findSelfReferenceDir(name, fromDir);
60
+ let dir = fromDir;
61
+ while (found === undefined) {
62
+ const candidate = path.join(dir, NODE_MODULES, name);
63
+ if (existsSync(path.join(candidate, 'package.json'))) {
64
+ found = realpathSync(candidate);
65
+ break;
66
+ }
67
+ const parent = path.dirname(dir);
68
+ if (parent === dir)
69
+ break;
70
+ dir = parent;
71
+ }
72
+ cache?.set(key, found);
73
+ return found;
74
+ }
75
+ function findSelfReferenceDir(name, fromDir) {
76
+ let dir = fromDir;
77
+ for (;;) {
78
+ const file = path.join(dir, 'package.json');
79
+ if (existsSync(file)) {
80
+ const parsed = parsePackageJson(readFileSync(file, 'utf8'), file);
81
+ return isObject(parsed) && parsed['name'] === name && parsed['exports'] !== undefined ? dir : undefined;
82
+ }
83
+ const parent = path.dirname(dir);
84
+ if (parent === dir)
85
+ return undefined;
86
+ dir = parent;
87
+ }
88
+ }
89
+ function findSuffixIndex(specifier) {
90
+ const queryIndex = specifier.indexOf('?');
91
+ const hashIndex = specifier.indexOf('#');
92
+ if (queryIndex === -1)
93
+ return hashIndex;
94
+ if (hashIndex === -1)
95
+ return queryIndex;
96
+ return Math.min(queryIndex, hashIndex);
97
+ }
98
+ function findTargetFile(declaredPath) {
99
+ if (isFile(declaredPath))
100
+ return declaredPath;
101
+ for (const extension of TARGET_EXTENSIONS) {
102
+ const candidate = `${declaredPath}${extension}`;
103
+ if (isFile(candidate))
104
+ return candidate;
105
+ }
106
+ for (const extension of TARGET_EXTENSIONS) {
107
+ const candidate = path.join(declaredPath, `index${extension}`);
108
+ if (isFile(candidate))
109
+ return candidate;
110
+ }
111
+ return undefined;
112
+ }
113
+ function formatMissingTarget(name, subpath, declared, target) {
114
+ return [
115
+ `Package "${name}" declares a \`source\` export for "${subpath}" at "${declared}", which reaches no file.`,
116
+ `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.',
118
+ ].join(' ');
119
+ }
120
+ function isFile(candidate) {
121
+ return statSync(candidate, { throwIfNoEntry: false })?.isFile() === true;
122
+ }
123
+ function isInsideNodeModules(dir) {
124
+ return dir.split(path.sep).includes(NODE_MODULES);
125
+ }
126
+ function matchSubpath(exportsValue, subpath) {
127
+ if (exportsValue === undefined)
128
+ return undefined;
129
+ if (!isObject(exportsValue) || Object.keys(exportsValue).every((key) => !key.startsWith('.'))) {
130
+ return subpath === '.' ? { value: exportsValue } : undefined;
131
+ }
132
+ const exact = exportsValue[subpath];
133
+ if (exact !== undefined)
134
+ return { value: exact };
135
+ let best;
136
+ for (const [key, value] of Object.entries(exportsValue)) {
137
+ const star = key.indexOf('*');
138
+ if (star === -1)
139
+ continue;
140
+ const base = key.slice(0, star);
141
+ const suffix = key.slice(star + 1);
142
+ if (!subpath.startsWith(base) || !subpath.endsWith(suffix))
143
+ continue;
144
+ if (subpath.length < base.length + suffix.length)
145
+ continue;
146
+ if (best !== undefined && best.base.length >= base.length)
147
+ continue;
148
+ best = { base, value, wildcard: subpath.slice(base.length, subpath.length - suffix.length) };
149
+ }
150
+ return best && { value: best.value, wildcard: best.wildcard };
151
+ }
152
+ function parseSpecifier(specifier) {
153
+ if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('\0'))
154
+ return undefined;
155
+ const suffixIndex = findSuffixIndex(specifier);
156
+ const bare = suffixIndex === -1 ? specifier : specifier.slice(0, suffixIndex);
157
+ if (bare.includes(':') || isBuiltin(bare))
158
+ return undefined;
159
+ const segments = bare.split('/');
160
+ const nameLength = bare.startsWith('@') ? 2 : 1;
161
+ if (segments.length < nameLength || segments.includes(''))
162
+ return undefined;
163
+ const rest = segments.slice(nameLength);
164
+ return {
165
+ name: segments.slice(0, nameLength).join('/'),
166
+ postfix: suffixIndex === -1 ? '' : specifier.slice(suffixIndex),
167
+ subpath: rest.length === 0 ? '.' : `./${rest.join('/')}`,
168
+ };
169
+ }
170
+ function readManifest(packageDir, cache) {
171
+ if (cache?.has(packageDir))
172
+ return cache.get(packageDir);
173
+ const file = path.join(packageDir, 'package.json');
174
+ const parsed = parsePackageJson(readFileSync(file, 'utf8'), file);
175
+ cache?.set(packageDir, parsed);
176
+ return parsed;
177
+ }
178
+ function selectTarget(value, conditions) {
179
+ if (typeof value === 'string')
180
+ return { target: value, usedSource: false };
181
+ if (Array.isArray(value)) {
182
+ for (const entry of value) {
183
+ const selected = selectTarget(entry, conditions);
184
+ if (selected)
185
+ return selected;
186
+ }
187
+ return undefined;
188
+ }
189
+ if (!isObject(value))
190
+ return undefined;
191
+ const source = value[SOURCE_CONDITION];
192
+ if (source !== undefined) {
193
+ const selected = selectTarget(source, conditions);
194
+ if (selected)
195
+ return { target: selected.target, usedSource: true };
196
+ }
197
+ for (const [key, entry] of Object.entries(value)) {
198
+ if (key === SOURCE_CONDITION || !conditions.has(key))
199
+ continue;
200
+ const selected = selectTarget(entry, conditions);
201
+ if (selected)
202
+ return selected;
203
+ }
204
+ return undefined;
205
+ }
@@ -45,8 +45,9 @@ export interface VitestConfigOptions {
45
45
  */
46
46
  isolateGit?: boolean;
47
47
  /**
48
- * Resolves workspace packages through their `source` export condition, so a suite runs without a prior build.
49
- * Defaults to `true`. A package declaring no such condition is unaffected.
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.
50
51
  */
51
52
  resolveFromSource?: boolean;
52
53
  /**
@@ -3,13 +3,14 @@ import { fileURLToPath } from 'node:url';
3
3
  import { defaultExclude, mergeConfig } from 'vitest/config';
4
4
  import { isObject } from "./helpers/type-guards.js";
5
5
  import { ALL_TEST_PATTERNS, buildTierPatterns, TEST_COLLECTION_EXCLUDE, TIER_NAMES } from "./tiers.js";
6
+ import { createSourceResolutionPlugin } from "./vitest-source-resolution.js";
6
7
  import { getWorkspacePackageDirs } from "./workspace.js";
7
8
  const [RESIDUAL_TIER, ...NAMED_TIERS] = TIER_NAMES;
8
9
  const TIERED_PATTERNS = NAMED_TIERS.flatMap(buildTierPatterns);
9
10
  const TIER_TIMEOUT = 30_000;
10
11
  const GIT_ISOLATION_SETUP_FILE = resolveGitIsolationSetupFile();
11
- const SOURCE_CLIENT_CONDITIONS = ['source', 'module', 'browser', 'development|production'];
12
- const SOURCE_SERVER_CONDITIONS = ['source', 'module', 'node', 'development|production'];
12
+ const CLIENT_CONDITIONS = ['module', 'browser', 'development|production'];
13
+ const SERVER_CONDITIONS = ['module', 'node', 'development|production'];
13
14
  const COVERAGE_EXCLUDE = ['**/__{fixtures,mocks,tests}__/**', '**/index.ts', '**/*.d.ts'];
14
15
  const PACKAGE_COVERAGE_INCLUDE = ['**/src/**/*.{ts,tsx}'];
15
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.';
@@ -31,14 +32,14 @@ export function defineRootVitestConfig(...layers) {
31
32
  function buildConfig(declaredLayers, { coverageInclude, projectExclude = [], projectRoot }) {
32
33
  const layers = declaredLayers.filter((layer) => layer !== undefined);
33
34
  assertKnownTiers(layers);
34
- const resolveFromSource = resolveFlag(layers, 'resolveFromSource', true);
35
35
  const resolve = {
36
- ...(resolveFromSource && { conditions: SOURCE_CLIENT_CONDITIONS }),
36
+ conditions: CLIENT_CONDITIONS,
37
37
  ...(resolveFlag(layers, 'tsconfigPaths', false) && { tsconfigPaths: true }),
38
38
  };
39
39
  const config = {
40
- ...(Object.keys(resolve).length > 0 && { resolve }),
41
- ...(resolveFromSource && { ssr: { resolve: { conditions: SOURCE_SERVER_CONDITIONS } } }),
40
+ ...(resolveFlag(layers, 'resolveFromSource', true) && { plugins: [createSourceResolutionPlugin()] }),
41
+ resolve,
42
+ ssr: { resolve: { conditions: SERVER_CONDITIONS } },
42
43
  test: {
43
44
  coverage: {
44
45
  enabled: false,
@@ -20,3 +20,12 @@ export declare function getWorkspacePackageDirs(monorepoRoot: string): string[];
20
20
  * Returns nothing when the manifest or the block is missing, and drops an entry whose value is not a string.
21
21
  */
22
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[];