@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.
- package/.meta/changelog.json +114 -0
- package/.readyup/kits/default.js +92 -87
- package/.readyup/manifest.json +8 -8
- package/CHANGELOG.md +89 -0
- package/README.md +37 -28
- package/agents/guidance/rulebooks/nmr.md +2 -2
- package/dist/esm/check-cache.d.ts +4 -3
- package/dist/esm/check-cache.js +28 -28
- package/dist/esm/cli-build.js +3 -0
- package/dist/esm/cli-clean.js +3 -1
- package/dist/esm/cli-ensure-prepublish-hooks.js +7 -5
- package/dist/esm/cli-report-catalog.js +2 -1
- package/dist/esm/cli-report-overrides.js +2 -1
- package/dist/esm/commands/build-output.d.ts +2 -2
- package/dist/esm/commands/build-output.js +9 -9
- package/dist/esm/commands/build.d.ts +6 -1
- package/dist/esm/commands/build.js +73 -72
- package/dist/esm/commands/clean.d.ts +3 -2
- package/dist/esm/commands/clean.js +25 -24
- package/dist/esm/commands/ensure-prepublish-hooks.d.ts +5 -4
- package/dist/esm/commands/ensure-prepublish-hooks.js +44 -42
- package/dist/esm/commands/fmt.js +33 -33
- package/dist/esm/commands/report-catalog.d.ts +2 -1
- package/dist/esm/commands/report-catalog.js +14 -11
- package/dist/esm/commands/report-overrides.d.ts +2 -1
- package/dist/esm/commands/report-overrides.js +11 -10
- package/dist/esm/config.js +39 -29
- package/dist/esm/context.js +8 -8
- package/dist/esm/default-scripts.d.ts +2 -2
- package/dist/esm/default-scripts.js +5 -5
- package/dist/esm/glyphs.d.ts +9 -0
- package/dist/esm/glyphs.js +9 -0
- package/dist/esm/help.d.ts +2 -2
- package/dist/esm/help.js +36 -30
- package/dist/esm/helpers/clampToBytes.js +3 -3
- package/dist/esm/helpers/createBoundedBuffer.d.ts +1 -1
- package/dist/esm/helpers/createBoundedBuffer.js +2 -2
- package/dist/esm/helpers/duration.d.ts +2 -2
- package/dist/esm/helpers/duration.js +7 -7
- package/dist/esm/helpers/filter-selection.js +12 -12
- package/dist/esm/helpers/findClosestName.js +15 -15
- package/dist/esm/helpers/package-json.d.ts +2 -2
- package/dist/esm/helpers/package-json.js +26 -25
- package/dist/esm/helpers/transcript.d.ts +2 -2
- package/dist/esm/helpers/transcript.js +14 -14
- package/dist/esm/output-style.d.ts +41 -0
- package/dist/esm/output-style.js +17 -0
- package/dist/esm/prettier.js +5 -5
- package/dist/esm/recording.d.ts +8 -5
- package/dist/esm/recording.js +27 -17
- package/dist/esm/report-format.d.ts +1 -1
- package/dist/esm/report-format.js +7 -7
- package/dist/esm/resolveBinStyles.d.ts +12 -0
- package/dist/esm/resolveBinStyles.js +16 -0
- package/dist/esm/resolver.d.ts +2 -2
- package/dist/esm/resolver.js +16 -16
- package/dist/esm/runCli.js +309 -221
- package/dist/esm/runner.d.ts +1 -1
- package/dist/esm/runner.js +18 -18
- package/dist/esm/steps.d.ts +17 -5
- package/dist/esm/steps.js +37 -28
- package/dist/esm/tests.d.ts +7 -7
- package/dist/esm/tests.js +20 -8
- package/dist/esm/tiers.d.ts +1 -1
- package/dist/esm/tiers.js +8 -8
- package/dist/esm/verbosity.d.ts +1 -1
- package/dist/esm/verbosity.js +7 -7
- package/dist/esm/verdict.d.ts +15 -4
- package/dist/esm/verdict.js +78 -59
- package/dist/esm/vitest-source-resolution.js +48 -46
- package/dist/esm/vitest.d.ts +2 -2
- package/dist/esm/vitest.js +74 -25
- package/dist/esm/workspace.d.ts +2 -18
- package/dist/esm/workspace.js +9 -67
- package/docs/check-cache.md +5 -5
- package/docs/reporting.md +20 -2
- package/docs/scripts.md +2 -2
- package/docs/vitest.md +6 -6
- package/docs/workspace.md +24 -1
- package/package.json +9 -10
- package/dist/esm/helpers/workspace-patterns.d.ts +0 -8
- package/dist/esm/helpers/workspace-patterns.js +0 -29
package/dist/esm/workspace.js
CHANGED
|
@@ -1,76 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { parse } from 'yaml';
|
|
4
|
-
import { readStringValues } from "./helpers/readStringValues.js";
|
|
5
|
-
import { isObject } from "./helpers/type-guards.js";
|
|
6
|
-
import { resolvePackageDirs } from "./helpers/workspace-patterns.js";
|
|
1
|
+
import { findMonorepoRoot as findMonorepoRootOrNothing, resolveWorkspace } from '@williamthorsen/nmr-core/workspace';
|
|
7
2
|
import { UserError } from "./UserError.js";
|
|
8
|
-
const PACKAGE_MANIFEST = 'package.json';
|
|
9
3
|
const WORKSPACE_MANIFEST = 'pnpm-workspace.yaml';
|
|
10
|
-
export
|
|
11
|
-
return existsSync(path.join(dir, WORKSPACE_MANIFEST));
|
|
12
|
-
}
|
|
4
|
+
export { isMonorepoRoot, readWorkspaceOverrides, readWorkspacePackageNames, resolveWorkspace, } from '@williamthorsen/nmr-core/workspace';
|
|
13
5
|
export function findMonorepoRoot(startDir) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return dir;
|
|
18
|
-
}
|
|
19
|
-
const parent = path.dirname(dir);
|
|
20
|
-
if (parent === dir) {
|
|
21
|
-
throw new UserError(`Could not find monorepo root: no ${WORKSPACE_MANIFEST} found in any parent directory`);
|
|
22
|
-
}
|
|
23
|
-
dir = parent;
|
|
6
|
+
const monorepoRoot = findMonorepoRootOrNothing(startDir);
|
|
7
|
+
if (monorepoRoot === undefined) {
|
|
8
|
+
throw new UserError(`Could not find monorepo root: no ${WORKSPACE_MANIFEST} found in any parent directory`);
|
|
24
9
|
}
|
|
10
|
+
return monorepoRoot;
|
|
25
11
|
}
|
|
26
12
|
export function getWorkspacePackageDirs(monorepoRoot) {
|
|
27
|
-
const
|
|
28
|
-
if (
|
|
13
|
+
const resolution = resolveWorkspace(monorepoRoot);
|
|
14
|
+
if (resolution.kind === 'not-a-workspace') {
|
|
29
15
|
throw new UserError(`Not a monorepo root: no ${WORKSPACE_MANIFEST} in ${monorepoRoot}`);
|
|
30
16
|
}
|
|
31
|
-
|
|
32
|
-
const parsed = parse(content);
|
|
33
|
-
const packages = getPackagesFromParsedYaml(parsed);
|
|
34
|
-
if (!packages) {
|
|
35
|
-
return [];
|
|
36
|
-
}
|
|
37
|
-
return resolvePackageDirs(monorepoRoot, packages);
|
|
38
|
-
}
|
|
39
|
-
export function readWorkspaceOverrides(monorepoRoot) {
|
|
40
|
-
const workspaceFile = path.join(monorepoRoot, WORKSPACE_MANIFEST);
|
|
41
|
-
if (!existsSync(workspaceFile)) {
|
|
42
|
-
return undefined;
|
|
43
|
-
}
|
|
44
|
-
const parsed = parse(readFileSync(workspaceFile, 'utf8'));
|
|
45
|
-
if (!isObject(parsed)) {
|
|
46
|
-
return undefined;
|
|
47
|
-
}
|
|
48
|
-
const overrides = parsed['overrides'];
|
|
49
|
-
return isObject(overrides) ? readStringValues(overrides) : undefined;
|
|
50
|
-
}
|
|
51
|
-
export function readWorkspacePackageNames(packageDirs) {
|
|
52
|
-
const names = [];
|
|
53
|
-
for (const dir of packageDirs) {
|
|
54
|
-
let parsed;
|
|
55
|
-
try {
|
|
56
|
-
parsed = JSON.parse(readFileSync(path.join(dir, PACKAGE_MANIFEST), 'utf8'));
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
|
-
if (isObject(parsed) && typeof parsed['name'] === 'string') {
|
|
62
|
-
names.push(parsed['name']);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return names;
|
|
66
|
-
}
|
|
67
|
-
function getPackagesFromParsedYaml(parsed) {
|
|
68
|
-
if (!isObject(parsed))
|
|
69
|
-
return undefined;
|
|
70
|
-
const packages = parsed['packages'];
|
|
71
|
-
if (!Array.isArray(packages))
|
|
72
|
-
return undefined;
|
|
73
|
-
if (!packages.every((p) => typeof p === 'string'))
|
|
74
|
-
return undefined;
|
|
75
|
-
return packages;
|
|
17
|
+
return resolution.kind === 'packages' ? resolution.packageDirs : [];
|
|
76
18
|
}
|
package/docs/check-cache.md
CHANGED
|
@@ -77,7 +77,7 @@ $ nmr test
|
|
|
77
77
|
# ... the suite runs ...
|
|
78
78
|
|
|
79
79
|
$ nmr test
|
|
80
|
-
|
|
80
|
+
⏩ nmr: test: passed 2m ago on this tree, saved ~12s — replayed: Test Files 6 passed (6) Tests 41 passed (41)
|
|
81
81
|
```
|
|
82
82
|
|
|
83
83
|
The excerpt is the last blank-line-delimited block of what the command wrote, which is the closing statement a tool separates from its progress output. It is flattened onto one line, with escape sequences stripped, table rules dropped, and a redrawn progress line reduced to what a reader was left looking at. Nothing is parsed and no figure is computed: nmr replays bytes it recorded. A block wider than a few kilobytes is cut and marked with `…`, so a command whose closing statement is one long line cannot carry its whole output into the cache.
|
|
@@ -90,7 +90,7 @@ The excerpt and the whole transcript it is drawn from are written by the same op
|
|
|
90
90
|
|
|
91
91
|
```console
|
|
92
92
|
$ nmr check
|
|
93
|
-
|
|
93
|
+
⏩ nmr-core: check: passed 3m ago on this tree, saved ~48s — replayed: nmr-core: fmt:check: All matched files use Prettier code style!; nmr-core: test: Test Files 6 passed (6) Tests 41 passed (41)
|
|
94
94
|
```
|
|
95
95
|
|
|
96
96
|
The assembly is flat: a constituent that is itself a composite contributes its own leaves' lines, so a skipped `ci` replays a package's `test` rather than one opaque `check:strict` line. A delegate expands into the scopes it fans out to, and a constituent that recorded no excerpt is absent rather than inferred: `typecheck` and `lint:check` print nothing on success and contribute nothing above, and a command outside the [cacheable set](#what-is-cached) records nothing at all, so `ci` replays what `check:strict` earned and nothing for `build`.
|
|
@@ -99,7 +99,7 @@ It is assembled when the composite records its pass rather than when it skips, s
|
|
|
99
99
|
|
|
100
100
|
**A skip certifies what it replays.** Recalling a pass proves the excerpt describes this tree, and a matching retention key proves it describes this presentation environment, so a skip restamps the recalled entry with the run replaying it. A constituent already warm when a composite ran therefore still contributes its line: `nmr check` after `nmr test` replays the test summary. The restamp touches nothing else -- the instant and the duration belong to the run that earned the pass.
|
|
101
101
|
|
|
102
|
-
**A replay is held to more than the pass is.** Retention carries its own key: the [pass key](#what-the-key-is-made-of) folded with the channel each output stream ran on and with `CI`, `COLUMNS`, `FORCE_COLOR`, `NO_COLOR`, and `TERM`. A recording made under a different one is recalled as a pass all the same; its excerpt is simply not replayed, and the verdict prints alone, as it does for a pass that retained nothing.
|
|
102
|
+
**A replay is held to more than the pass is.** Retention carries its own key: the [pass key](#what-the-key-is-made-of) folded with the channel each output stream ran on and with `CI`, `COLUMNS`, `FORCE_COLOR`, `NO_COLOR`, and `TERM`. `NMR_OUTPUT_STYLE` is in neither key, so a rich run and a plain one share a recorded pass and replay each other's excerpts as recorded. A recording made under a different one is recalled as a pass all the same; its excerpt is simply not replayed, and the verdict prints alone, as it does for a pass that retained nothing.
|
|
103
103
|
|
|
104
104
|
The whole line, excerpt included, is held to the same [512-byte ceiling](reporting.md#what-nmr-reports) every verdict is, so one write still carries it whole under concurrent fan-out. An assembly wide enough to overrun it is cut and marked with `…` rather than spread over several lines.
|
|
105
105
|
|
|
@@ -109,7 +109,7 @@ The excerpt is a line; the run behind it is kept whole. `nmr --log <command>` pr
|
|
|
109
109
|
|
|
110
110
|
```console
|
|
111
111
|
$ nmr --log test
|
|
112
|
-
|
|
112
|
+
💾 nmr: test — recorded 2026-08-12T15:04:05.412Z (12m ago), ran in 12.4s
|
|
113
113
|
$ pnpm exec vitest --project unit --project tool
|
|
114
114
|
|
|
115
115
|
✓ src/__tests__/resolver.unit.test.ts (18 tests) 12ms
|
|
@@ -130,7 +130,7 @@ The header is what presents the body as a recording rather than as this invocati
|
|
|
130
130
|
|
|
131
131
|
```console
|
|
132
132
|
$ nmr --log test
|
|
133
|
-
|
|
133
|
+
🟠 nmr: test: no recording; the last pass was 3m ago, on a tree this is not
|
|
134
134
|
```
|
|
135
135
|
|
|
136
136
|
**`--log` reaches the scopes `-F` and `-R` select.** The flag rides the delegate, so each scope prints its own recording. There a scope with nothing to show reports its gap and exits 0: partial coverage is a survey's normal shape, and failing on the first gap would hide every scope that had something. A selection of no scope at all is the other case and fails, as it does for a run: there is no survey to be partial.
|
package/docs/reporting.md
CHANGED
|
@@ -10,7 +10,11 @@ The scope is the directory the command's registry belongs to: a package's own di
|
|
|
10
10
|
|
|
11
11
|
Verdicts nest. A composite reports, and so does every command it expands into; a `:pre` or `:post` hook reports nothing of its own, since the command it wraps already reports for the chain, though a hook that delegates to a named command reports under that name. A `-R` or `-F` invocation reports nothing either, every scope it fans out to reporting instead. A cold repo-wide `nmr ci` in this package's own monorepo spends 26 lines on a run that prints roughly 250.
|
|
12
12
|
|
|
13
|
-
**A fan-out that selects no scope fails.** A `-F` pattern matches a package's manifest `name` rather than its directory name, so a plausible directory name selects nothing, and pnpm exits 0 over an empty selection as it does over a passing one. nmr asks pnpm what the pattern selects and refuses the invocation where the answer is nothing, naming the pattern and, where the pattern carries a name, the names it could have named. A pattern of another form states the rule its own shape answers to: a directory (`./dir`, `/dir`, `{dir}`), an exclusion (`!`), or a changed-since selector (`[<ref>]`), none of which a workspace name repairs. A pattern pnpm rejects rather than resolves, such as a bad git ref in a `[since]` selector, is left to pnpm to report. `-R` is refused on the same ground where the workspace declares no package at all, which is its only empty selection.
|
|
13
|
+
**A fan-out that selects no scope fails.** A `-F` pattern matches a package's manifest `name` rather than its directory name, so a plausible directory name selects nothing, and pnpm exits 0 over an empty selection as it does over a passing one. nmr asks pnpm what the pattern selects and refuses the invocation where the answer is nothing, naming the pattern and, where the pattern carries a name, the names it could have named. A pattern of another form states the rule its own shape answers to: a directory (`./dir`, `/dir`, `{dir}`), an exclusion (`!`), or a changed-since selector (`[<ref>]`), none of which a workspace name repairs. A pattern pnpm rejects rather than resolves, such as a bad git ref in a `[since]` selector, is left to pnpm to report. A `-R` typed on the invocation is refused on the same ground where the workspace declares no package at all, which is its only empty selection. That condition answers ahead of the pattern rules above: where the workspace holds no package, no `-F` pattern would have matched, so the refusal reports the workspace rather than the shape of the pattern.
|
|
14
|
+
|
|
15
|
+
**A workspace that holds no package is reported as one of four conditions.** `pnpm-workspace.yaml` may declare no positive pattern, declare patterns that match no directory holding a `package.json`, declare `!` exclusions that remove every directory matched by the positive patterns, or hold no valid YAML at all. Each refusal names its own condition and states a remedy, and each quotes the `packages` list the manifest declares, apart from the last, whose manifest nmr could not read. The second is the likeliest, because nmr [counts a directory as a package](workspace.md) only where it holds `package.json` and recognizes neither `package.yaml` nor `package.json5`.
|
|
16
|
+
|
|
17
|
+
**A `-R` that nmr composed into a script is not a fan-out the caller asked for.** In a workspace that holds no package, such a step -- in nmr's own default script or in a repo override alike -- is dropped from the chain, the steps beside it run, and the dropped step reports nothing of its own, as the rule above gives a `-R` invocation whose scopes report instead. Where the drop leaves the command with no step at all, as it does the root `build`, the command reports a no-op naming the workspace. A repo that holds no workspace package therefore runs the default root commands with no entry in `.config/nmr.config.ts`.
|
|
14
18
|
|
|
15
19
|
A verdict is one write of at most 512 bytes, the smallest `PIPE_BUF` POSIX permits, so packages running concurrently under `pnpm --recursive` cannot interleave within a line. A line longer than that is cut and marked with `…`; nothing nmr reports today comes near it.
|
|
16
20
|
|
|
@@ -32,7 +36,7 @@ Every object carries `command`, `scope`, and `outcome`, and then what that outco
|
|
|
32
36
|
| `passed` | `durationMs` |
|
|
33
37
|
| `failed` | `durationMs`, `exitCode` |
|
|
34
38
|
| `recalled` | `ageMs`, `savedMs`, and `replay` where the skip has excerpts to [replay](check-cache.md#replaying-a-skipped-runs-output) |
|
|
35
|
-
| `no-op` | `reason`,
|
|
39
|
+
| `no-op` | `reason`, one of `empty-override`, `empty-workspace`, or `noop-override` |
|
|
36
40
|
|
|
37
41
|
Both renderings are produced from one record, so neither reports what the other does not. Where the prose line makes a presentation decision the record does not — a saving too small to be worth a clause, say — the object carries the fact and leaves the decision to whoever reads it.
|
|
38
42
|
|
|
@@ -57,8 +61,22 @@ The format is nmr's own reporting and not a mode the commands it runs are told a
|
|
|
57
61
|
|
|
58
62
|
`-q` sets it for the run and outranks an inherited value. Both values are spelled out so either direction is expressible: export `NMR_COMMAND_VERBOSITY=quiet` for a quiet shell, and set `full` on one invocation to opt back out.
|
|
59
63
|
|
|
64
|
+
`NMR_OUTPUT_STYLE` is nmr's own as well, and carries the style every marker nmr prints is rendered in; see [output style](#output-style) below.
|
|
65
|
+
|
|
60
66
|
`NMR_REPORT_FORMAT` is nmr's own as well, and carries how nmr renders its own verdicts. Its values are `text` and `json`; any other non-empty value is reported and exits 1, and an empty value reads as unset. `--json` sets it for the run and outranks an inherited value, and both values are spelled out so either direction is expressible: export `NMR_REPORT_FORMAT=json` for a shell whose consumer parses, and set `text` on one invocation to opt back out. Two sources, then, where a verbosity has four: no repo-level default and no harness detection, since detection would hand every agent JSON in place of the prose the [shipped guidance](../README.md#agent-guidance) teaches. See [reporting for a machine](#reporting-for-a-machine) for what the objects carry.
|
|
61
67
|
|
|
68
|
+
### Output style
|
|
69
|
+
|
|
70
|
+
`--output-style <auto|plain|rich>` sets the style for one invocation, and `NMR_OUTPUT_STYLE` sets it for a shell: `rich` marks each verdict with an emoji, such as ✅ or ❌, and `plain` prints a word in its place, such as `PASS` or `FAIL`, which a reader of a log can search for. `auto`, the default, defers to detection. Any other value is a usage error that exits with code 1, whichever source named it. The flag outranks the variable, and both name `auto` as well as a style, so a shell exporting one style is overridable on one invocation in either direction.
|
|
71
|
+
|
|
72
|
+
Detection decides stdout and stderr separately. A stream is plain when `CI` is set to anything other than an empty string or `false`, when the stream is not a terminal, or when `TERM` is `linux`; otherwise it is rich. `--json` output carries no marker in either style.
|
|
73
|
+
|
|
74
|
+
**The flag belongs to `nmr`.** A standalone bin -- `nmr-clean`, `nmr-compile`, `nmr-report-catalog`, `nmr-report-overrides`, `nmr-ensure-prepublish-hooks` -- takes the variable alone, and passing it the flag gets that bin's unknown-option error. `nmr <command>` exports the style it resolved, so a bin run through nmr renders as nmr does.
|
|
75
|
+
|
|
76
|
+
**A nested run follows the one that spawned it.** The resolved style, never `auto`, travels down as `NMR_OUTPUT_STYLE`, so a child whose own stdout is a pipe still renders rich where the parent resolved rich. The style exported is the one stdout resolved to, which is where the verdicts and nearly every other line go; a parent at a terminal whose stderr alone is redirected therefore hands its children `rich`.
|
|
77
|
+
|
|
78
|
+
The style is out of the [pass key](check-cache.md#what-the-key-is-made-of) and the [retention key](check-cache.md#replaying-a-skipped-runs-output) alike, as the verbosity is: it changes how a run renders and never what a command concludes. Selecting it neither invalidates a recorded pass nor costs a replay, and an excerpt recorded rich replays as recorded in a plain run.
|
|
79
|
+
|
|
62
80
|
### Where a verbosity comes from
|
|
63
81
|
|
|
64
82
|
Four sources, in precedence order. Each is read once, at the top of a chain: the resolved value travels down as `NMR_COMMAND_VERBOSITY`, so a nested process reads a decision already made rather than making its own.
|
package/docs/scripts.md
CHANGED
|
@@ -40,11 +40,11 @@ Each array element is a command name, optionally preceded by nmr's own flags, sp
|
|
|
40
40
|
Trailing arguments reach every step of a composite, so `nmr fix --dry-run` runs `nmr lint --dry-run`, then `nmr fmt --dry-run`. A step whose work the arguments cannot narrow declines them by taking the long form of an element, and runs unnarrowed:
|
|
41
41
|
|
|
42
42
|
```ts
|
|
43
|
-
// "ci": [{ run: "build",
|
|
43
|
+
// "ci": [{ run: "build", shouldDeclineArguments: true }, "check:strict"]
|
|
44
44
|
// nmr ci src/foo.ts -> nmr build && nmr check:strict src/foo.ts
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
`{ run }` is the bare string element with room for the declaration; `
|
|
47
|
+
`{ run }` is the bare string element with room for the declaration; `shouldDeclineArguments` defaults to `false`, and an element that accepts is written as the string. Decline where the step is a prerequisite the narrowed steps run against, or where the tool it reaches would be misled — `tsgo --noEmit foo.ts` abandons the tsconfig, which is why every default `typecheck` step declines. A leaf tool that merely ignores what it is handed is not a reason to decline: nmr forwards, and the tool decides.
|
|
48
48
|
|
|
49
49
|
Where no step of a command accepts them, nmr rejects the invocation and runs nothing, rather than running the whole command unnarrowed. That is what `nmr typecheck src/foo.ts` gets at the root, whose two steps both decline.
|
|
50
50
|
|
package/docs/vitest.md
CHANGED
|
@@ -84,17 +84,17 @@ The sweep starts at the monorepo root rather than at the directory Vitest suppli
|
|
|
84
84
|
|
|
85
85
|
An exported check rather than a step nmr runs for you, because nothing nmr runs reaches the whole tree: `nmr test` fans out per package, and a direct `vitest` invocation runs no nmr script at all. A test the repo owns also lets the repo scope what the sweep covers.
|
|
86
86
|
|
|
87
|
-
`
|
|
87
|
+
`excludedBasenames` names directory basenames the sweep prunes at any depth, additive to the ones nmr always prunes:
|
|
88
88
|
|
|
89
89
|
```ts
|
|
90
|
-
checkTestFileConventions({
|
|
90
|
+
checkTestFileConventions({ excludedBasenames: ['cypress', 'generated'] });
|
|
91
91
|
```
|
|
92
92
|
|
|
93
93
|
**Pass the same array to [`testCollectionExclude`](#what-the-config-excludes).** The two describe one scope, and naming a directory in only one is a defect in either direction. Pruned from the sweep alone, the directory's test files still run and still report nothing, which is the silence this check exists to end. Excluded from collection alone, the sweep reports files a consumer has no reason to act on.
|
|
94
94
|
|
|
95
95
|
Expect the first run to fail in a repo that has never gated this. Vitest 4 excludes only `node_modules` and `.git` by default, so a `__tests__` tree under a generated or vendored directory is collected and runs today; naming that directory in both lists is the fix, rather than widening what nmr prunes for everyone.
|
|
96
96
|
|
|
97
|
-
In a repo that has not declared the check, [nmr's readyup kit](../README.md#conformance-checks) reports both halves and warns that the check is missing. Once a test file under `__tests__` imports it, the kit skips both reports and names that file as the reason. Only the check reads the repo's `
|
|
97
|
+
In a repo that has not declared the check, [nmr's readyup kit](../README.md#conformance-checks) reports both halves and warns that the check is missing. Once a test file under `__tests__` imports it, the kit skips both reports and names that file as the reason. Only the check reads the repo's `excludedBasenames`, and a kit report beside it could only repeat its findings or name a directory that the repo has pruned.
|
|
98
98
|
|
|
99
99
|
## What the config supplies
|
|
100
100
|
|
|
@@ -106,12 +106,12 @@ Two settings a workspace test runner needs almost universally are part of what t
|
|
|
106
106
|
Both defaults exist because omitting either is silent rather than loud: the tiers still exist and the suite still runs green, testing `dist/` and the developer's git identity instead. Turn either off through its own option, which folds across layers like any other, the last layer to declare it winning:
|
|
107
107
|
|
|
108
108
|
```ts
|
|
109
|
-
export default defineVitestConfig({
|
|
109
|
+
export default defineVitestConfig({ shouldIsolateGit: false, shouldResolveFromSource: false });
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
`
|
|
112
|
+
`shouldResolveFromSource: false` is for a repo whose own packages declare a `source` condition that it does not want in tests; `shouldIsolateGit: false` is for a suite meant to read the developer's own git configuration.
|
|
113
113
|
|
|
114
|
-
**Vite replaces its condition defaults rather than extending them.** A config writing `conditions: ['my-condition']` by hand therefore drops `module`, so a dependency exposing a `module` entry falls through to whatever its `exports` lists next. A replaced list still resolves `node` and `development` under Vitest, which is why nothing in a test run reports the loss. The factory emits Vite's defaults for each environment, whichever way `
|
|
114
|
+
**Vite replaces its condition defaults rather than extending them.** A config writing `conditions: ['my-condition']` by hand therefore drops `module`, so a dependency exposing a `module` entry falls through to whatever its `exports` lists next. A replaced list still resolves `node` and `development` under Vitest, which is why nothing in a test run reports the loss. The factory emits Vite's defaults for each environment, whichever way `shouldResolveFromSource` is set, so a layer adding a condition extends a complete list rather than replacing one.
|
|
115
115
|
|
|
116
116
|
**The condition is resolved by a plugin rather than named as a Vite condition, which is what bounds its reach.** Vitest turns `ssr.resolve.conditions` into `--conditions` flags on the worker process, where Node applies every entry to each package that it resolves natively. Node refuses to strip types from a file under `node_modules`, so a `source` condition reaching that far makes any dependency declaring a TypeScript entry fail to load, the dependency of a dependency included. Resolving inside Vite keeps the condition where nmr decides its reach, and leaves a published package to resolve through its other conditions however it declares `source`.
|
|
117
117
|
|
package/docs/workspace.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Workspace introspection
|
|
2
2
|
|
|
3
|
-
The
|
|
3
|
+
The pnpm-workspace lookups that `@williamthorsen/nmr/workspace` publishes. The resolution itself lives in
|
|
4
|
+
`@williamthorsen/nmr-core/workspace`, which release-kit reads too; nmr re-exports the total primitives and adds
|
|
5
|
+
two projections that throw, so a CLI caller keeps nmr's error boundary.
|
|
4
6
|
|
|
5
7
|
`findMonorepoRoot(startDir?)` walks up from `startDir`, defaulting to `process.cwd()`, until it reaches a directory containing `pnpm-workspace.yaml`. It throws if it runs out of parent directories without finding one.
|
|
6
8
|
|
|
@@ -8,4 +10,25 @@ The two pnpm-workspace lookups that `@williamthorsen/nmr/workspace` publishes.
|
|
|
8
10
|
|
|
9
11
|
One divergence from pnpm: a directory counts as a package only if it holds a `package.json`, not a `package.yaml` or `package.json5`.
|
|
10
12
|
|
|
13
|
+
`resolveWorkspace(monorepoRoot)` is the primitive both projections read, and the one a caller reaches for when an empty workspace is not a failure. It throws nothing, returning a discriminated result instead:
|
|
14
|
+
|
|
15
|
+
| `kind` | What it carries |
|
|
16
|
+
| ----------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
17
|
+
| `not-a-workspace` | Nothing. The directory holds no `pnpm-workspace.yaml`, so it declares no workspace at all. |
|
|
18
|
+
| `packages` | `packageDirs`, the resolved absolute directories, and `patterns`, the `packages` list the manifest declares. |
|
|
19
|
+
| `empty` | `cause`, which of four conditions emptied the resolution, and the same `patterns`. |
|
|
20
|
+
|
|
21
|
+
| `cause` | What left the workspace empty |
|
|
22
|
+
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
23
|
+
| `no-packages-list` | The manifest declares no `packages` list: it holds no document, no `packages` key, a null value, or an empty list. `patterns` is empty. |
|
|
24
|
+
| `no-pattern` | The list reaches the matcher with nothing positive, holding only `!` entries or entries YAML left empty. |
|
|
25
|
+
| `no-package` | The patterns reach the matcher and match no directory holding a `package.json`, which is the divergence above. |
|
|
26
|
+
| `all-excluded` | The `!` entries exclude every directory matched by the positive patterns. |
|
|
27
|
+
| `unreadable-packages` | The `packages` value is not a list of strings, so nothing it declares reaches the matcher. `patterns` is empty. |
|
|
28
|
+
| `unreadable-manifest` | The manifest holds no valid YAML, so nothing it declares reaches the matcher. `patterns` is empty, and no other cause is decided. |
|
|
29
|
+
|
|
30
|
+
Three causes separate what the manifest declares from what the reader could make of it, because one remedy does not answer them all: `no-packages-list` is a manifest asking for nothing, which pnpm resolves to the root package alone; `unreadable-packages` is a manifest asking for something the reader refused; and `no-pattern` is a list read in full whose every entry failed to become a positive pattern. A caller treating an empty resolution as a failure needs the first held apart from the other two. A manifest the parser rejects outright is `unreadable-manifest`, because the pattern remedies repair nothing for a reader whose manifest declares a pattern above a syntax error. `all-excluded` is decided by matching the positive patterns a second time without the exclusion set, which reaches the filesystem only on this path, and only where the first resolution came back empty.
|
|
31
|
+
|
|
32
|
+
`isMonorepoRoot(dir)`, `readWorkspaceOverrides(monorepoRoot)`, and `readWorkspacePackageNames(packageDirs)` are re-exported unchanged. Each returns a value rather than throwing: `readWorkspaceOverrides` returns nothing where the manifest is missing, unreadable, unparseable, or declares no `overrides` block, and `readWorkspacePackageNames` passes over a manifest it cannot read.
|
|
33
|
+
|
|
11
34
|
Quote exclusion patterns in the manifest — `- '!packages/legacy'`. An unquoted `!` opens a YAML tag rather than a string, so the entry never reaches nmr (or pnpm) as a pattern.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@williamthorsen/nmr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Context-aware script runner for pnpm monorepos",
|
|
6
6
|
"keywords": [
|
|
@@ -77,22 +77,21 @@
|
|
|
77
77
|
"!docs/*.v*.md"
|
|
78
78
|
],
|
|
79
79
|
"dependencies": {
|
|
80
|
-
"@williamthorsen/nmr-core": "0.
|
|
81
|
-
"@williamthorsen/toolbelt.errors": "0.
|
|
80
|
+
"@williamthorsen/nmr-core": "0.13.0",
|
|
81
|
+
"@williamthorsen/toolbelt.errors": "0.8.1",
|
|
82
82
|
"glob": "13.0.6",
|
|
83
83
|
"prettier-plugin-sh": "0.19.0",
|
|
84
|
-
"taze": "21.1.0"
|
|
85
|
-
"yaml": "2.9.0"
|
|
84
|
+
"taze": "21.1.0"
|
|
86
85
|
},
|
|
87
86
|
"devDependencies": {
|
|
88
|
-
"@williamthorsen/toolbelt.filesystem": "0.
|
|
89
|
-
"@williamthorsen/toolbelt.testing": "0.
|
|
90
|
-
"@williamthorsen/toolbelt.vitest": "0.
|
|
87
|
+
"@williamthorsen/toolbelt.filesystem": "0.9.1",
|
|
88
|
+
"@williamthorsen/toolbelt.testing": "0.6.1",
|
|
89
|
+
"@williamthorsen/toolbelt.vitest": "0.10.1",
|
|
91
90
|
"prettier": "3.9.6",
|
|
92
|
-
"readyup": "0.
|
|
91
|
+
"readyup": "0.38.0",
|
|
93
92
|
"tinyglobby": "0.2.17",
|
|
94
93
|
"typescript": "6.0.3",
|
|
95
|
-
"vite": "8.
|
|
94
|
+
"vite": "8.3.0",
|
|
96
95
|
"vitest": "4.1.11"
|
|
97
96
|
},
|
|
98
97
|
"peerDependencies": {
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Resolves pnpm workspace patterns to absolute package directories.
|
|
3
|
-
*
|
|
4
|
-
* Applies pnpm's algorithm: every pattern is rewritten to match a manifest, `!`-prefixed patterns become
|
|
5
|
-
* exclusions, and the matcher decides the rest. Exclusions filter the entire positive match set
|
|
6
|
-
* irrespective of declaration order.
|
|
7
|
-
*/
|
|
8
|
-
export declare function resolvePackageDirs(monorepoRoot: string, patterns: string[]): string[];
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { globSync } from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
const MANIFEST = 'package.json';
|
|
4
|
-
const ALWAYS_EXCLUDED = ['**/node_modules/**'];
|
|
5
|
-
export function resolvePackageDirs(monorepoRoot, patterns) {
|
|
6
|
-
const included = [];
|
|
7
|
-
const excluded = [];
|
|
8
|
-
for (const pattern of patterns) {
|
|
9
|
-
if (pattern.trim() === '')
|
|
10
|
-
continue;
|
|
11
|
-
const isNegated = pattern.startsWith('!');
|
|
12
|
-
const target = isNegated ? excluded : included;
|
|
13
|
-
target.push(toManifestPattern(isNegated ? pattern.slice(1) : pattern));
|
|
14
|
-
}
|
|
15
|
-
if (included.length === 0) {
|
|
16
|
-
return [];
|
|
17
|
-
}
|
|
18
|
-
const options = {
|
|
19
|
-
cwd: monorepoRoot,
|
|
20
|
-
exclude: [...excluded, ...ALWAYS_EXCLUDED],
|
|
21
|
-
followSymlinks: true,
|
|
22
|
-
};
|
|
23
|
-
const matches = globSync(included, options);
|
|
24
|
-
const dirs = matches.map((match) => path.dirname(path.resolve(monorepoRoot, match)));
|
|
25
|
-
return [...new Set(dirs)].toSorted();
|
|
26
|
-
}
|
|
27
|
-
function toManifestPattern(pattern) {
|
|
28
|
-
return `${pattern.replace(/\/?$/, '')}/${MANIFEST}`;
|
|
29
|
-
}
|