@williamthorsen/nmr 0.38.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 (64) hide show
  1. package/.meta/changelog.json +84 -0
  2. package/.readyup/kits/default.js +84 -79
  3. package/.readyup/manifest.json +5 -5
  4. package/CHANGELOG.md +70 -0
  5. package/README.md +37 -11
  6. package/dist/esm/check-cache.d.ts +2 -2
  7. package/dist/esm/check-cache.js +24 -24
  8. package/dist/esm/cli-ensure-prepublish-hooks.js +4 -4
  9. package/dist/esm/commands/build-output.d.ts +2 -2
  10. package/dist/esm/commands/build-output.js +9 -9
  11. package/dist/esm/commands/build.js +65 -63
  12. package/dist/esm/commands/clean.js +11 -11
  13. package/dist/esm/commands/ensure-prepublish-hooks.d.ts +2 -2
  14. package/dist/esm/commands/ensure-prepublish-hooks.js +41 -41
  15. package/dist/esm/commands/fmt.js +33 -33
  16. package/dist/esm/commands/report-catalog.js +9 -7
  17. package/dist/esm/commands/report-overrides.js +7 -7
  18. package/dist/esm/config.js +25 -25
  19. package/dist/esm/context.js +8 -8
  20. package/dist/esm/help.d.ts +2 -2
  21. package/dist/esm/help.js +21 -21
  22. package/dist/esm/helpers/clampToBytes.js +3 -3
  23. package/dist/esm/helpers/createBoundedBuffer.d.ts +1 -1
  24. package/dist/esm/helpers/createBoundedBuffer.js +2 -2
  25. package/dist/esm/helpers/duration.d.ts +2 -2
  26. package/dist/esm/helpers/duration.js +7 -7
  27. package/dist/esm/helpers/filter-selection.js +12 -12
  28. package/dist/esm/helpers/findClosestName.js +15 -15
  29. package/dist/esm/helpers/package-json.d.ts +2 -2
  30. package/dist/esm/helpers/package-json.js +26 -25
  31. package/dist/esm/helpers/transcript.d.ts +2 -2
  32. package/dist/esm/helpers/transcript.js +14 -14
  33. package/dist/esm/prettier.js +5 -5
  34. package/dist/esm/recording.d.ts +5 -5
  35. package/dist/esm/recording.js +21 -14
  36. package/dist/esm/report-format.d.ts +1 -1
  37. package/dist/esm/report-format.js +7 -7
  38. package/dist/esm/resolveBinStyles.js +3 -3
  39. package/dist/esm/resolver.d.ts +2 -2
  40. package/dist/esm/resolver.js +15 -15
  41. package/dist/esm/runCli.js +252 -186
  42. package/dist/esm/runner.d.ts +1 -1
  43. package/dist/esm/runner.js +18 -18
  44. package/dist/esm/steps.d.ts +13 -1
  45. package/dist/esm/steps.js +32 -27
  46. package/dist/esm/tests.d.ts +7 -7
  47. package/dist/esm/tests.js +20 -8
  48. package/dist/esm/tiers.d.ts +1 -1
  49. package/dist/esm/tiers.js +8 -8
  50. package/dist/esm/verbosity.d.ts +1 -1
  51. package/dist/esm/verbosity.js +7 -7
  52. package/dist/esm/verdict.d.ts +9 -2
  53. package/dist/esm/verdict.js +63 -50
  54. package/dist/esm/vitest-source-resolution.js +48 -46
  55. package/dist/esm/vitest.d.ts +2 -2
  56. package/dist/esm/vitest.js +74 -25
  57. package/dist/esm/workspace.d.ts +2 -18
  58. package/dist/esm/workspace.js +9 -67
  59. package/docs/reporting.md +6 -2
  60. package/docs/vitest.md +6 -6
  61. package/docs/workspace.md +24 -1
  62. package/package.json +3 -4
  63. package/dist/esm/helpers/workspace-patterns.d.ts +0 -8
  64. package/dist/esm/helpers/workspace-patterns.js +0 -29
@@ -1,76 +1,18 @@
1
- import { existsSync, readFileSync } from 'node:fs';
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 function isMonorepoRoot(dir) {
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
- let dir = path.resolve(startDir ?? process.cwd());
15
- for (;;) {
16
- if (isMonorepoRoot(dir)) {
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 workspaceFile = path.join(monorepoRoot, WORKSPACE_MANIFEST);
28
- if (!existsSync(workspaceFile)) {
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
- const content = readFileSync(workspaceFile, 'utf8');
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/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`, either `empty-override` or `noop-override` |
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
 
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
- `exclude` names directory basenames the sweep prunes at any depth, additive to the ones nmr always prunes:
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({ exclude: ['cypress', 'generated'] });
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 `exclude`, and a kit report beside it could only repeat its findings or name a directory that the repo has pruned.
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({ isolateGit: false, resolveFromSource: false });
109
+ export default defineVitestConfig({ shouldIsolateGit: false, shouldResolveFromSource: false });
110
110
  ```
111
111
 
112
- `resolveFromSource: false` is for a repo whose own packages declare a `source` condition that it does not want in tests; `isolateGit: false` is for a suite meant to read the developer's own git configuration.
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 `resolveFromSource` is set, so a layer adding a condition extends a complete list rather than replacing one.
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 two pnpm-workspace lookups that `@williamthorsen/nmr/workspace` publishes.
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.38.0",
3
+ "version": "0.39.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -77,12 +77,11 @@
77
77
  "!docs/*.v*.md"
78
78
  ],
79
79
  "dependencies": {
80
- "@williamthorsen/nmr-core": "0.12.0",
80
+ "@williamthorsen/nmr-core": "0.13.0",
81
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
87
  "@williamthorsen/toolbelt.filesystem": "0.9.1",
@@ -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
- }