@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/.meta/changelog.json +83 -0
- package/.readyup/kits/default.js +91 -3
- package/.readyup/manifest.json +5 -5
- package/CHANGELOG.md +63 -0
- package/README.md +116 -1127
- package/agents/README.md +1 -1
- package/agents/guidance/rulebooks/nmr.md +2 -2
- package/dist/esm/context.d.ts +2 -0
- package/dist/esm/context.js +1 -0
- package/dist/esm/help.js +1 -1
- package/dist/esm/helpers/filter-selection.d.ts +42 -0
- package/dist/esm/helpers/filter-selection.js +61 -0
- package/dist/esm/runCli.js +70 -2
- package/dist/esm/runner.js +6 -5
- package/dist/esm/steps.d.ts +4 -0
- package/dist/esm/verdict.d.ts +1 -1
- package/dist/esm/vitest-source-resolution.d.ts +37 -0
- package/dist/esm/vitest-source-resolution.js +205 -0
- package/dist/esm/vitest.d.ts +3 -2
- package/dist/esm/vitest.js +7 -6
- package/dist/esm/workspace.d.ts +9 -0
- package/dist/esm/workspace.js +17 -0
- package/docs/check-cache.md +176 -0
- package/docs/prettier.md +47 -0
- package/docs/reporting.md +90 -0
- package/docs/scripts.md +168 -0
- package/docs/upgrades.md +110 -0
- package/docs/utilities.md +64 -0
- package/docs/vitest.md +233 -0
- package/docs/workspace.md +11 -0
- package/package.json +6 -4
package/docs/upgrades.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Dependency upgrades
|
|
2
|
+
|
|
3
|
+
What `nmr upgrade` covers from each scope, how to declare the upgrade policy and its ceilings, and the subcommands and binary that the upgrade chains run.
|
|
4
|
+
|
|
5
|
+
`nmr upgrade` reports the dependency upgrades available to your repo. What it covers depends on where you run it:
|
|
6
|
+
|
|
7
|
+
| Invocation | Covers | Catalogs |
|
|
8
|
+
| ---------------------------- | ------------------------------------------- | -------- |
|
|
9
|
+
| `nmr upgrade` (from root) | the root `package.json` and every workspace | yes |
|
|
10
|
+
| `nmr upgrade` (in a package) | that package | no |
|
|
11
|
+
| `nmr -F <package> upgrade` | that package, from anywhere | no |
|
|
12
|
+
| `nmr root:upgrade` | the root `package.json` | yes |
|
|
13
|
+
|
|
14
|
+
A [pnpm catalog](https://pnpm.io/catalogs) is declared in `pnpm-workspace.yaml`, and the upgrade tool reads that file only when it sits at the working directory, irrespective of `--recursive`, which is why `root:upgrade` covers every catalog too. A package-scoped invocation therefore cannot see a dependency the catalog declares, and a package whose dependencies are all catalogued would otherwise report itself up to date. It runs [`report-catalog`](#report-catalog) first instead, which names each catalogued dependency and the root a covering pass runs from.
|
|
15
|
+
|
|
16
|
+
Both root-scoped invocations precede their report with any active override, which pnpm declares in `pnpm-workspace.yaml`. An override pins a transitive dependency, so an upgrade masked by one never appears in the report.
|
|
17
|
+
|
|
18
|
+
A `pnpm.overrides` block left in the root `package.json` fails the command instead. pnpm reads no setting from that field as of pnpm 11, so the block pins nothing, while the upgrade tool keeps its own list of dependency fields and goes on rewriting the versions in it under `--write` — a block that looks maintained and governs nothing. Failing ahead of the tool is what keeps that write from happening, on `root:upgrade` as much as on `upgrade`. nmr supports pnpm 11 and later.
|
|
19
|
+
|
|
20
|
+
The upgrade tool ([taze](https://github.com/antfu-collective/taze)) arrives with nmr, so your repo declares no dependency on it. Everything after the command name is passed through, including the range mode:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
nmr upgrade # minor and patch upgrades, regardless of the range each dependency declares
|
|
24
|
+
nmr upgrade major # major upgrades, for dependencies carrying no per-package ceiling
|
|
25
|
+
nmr upgrade --write # apply the proposals to package.json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Configuring upgrades
|
|
29
|
+
|
|
30
|
+
Declare version ceilings in a `taze.config.ts` at the monorepo root:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { defineConfig } from '@williamthorsen/nmr/taze';
|
|
34
|
+
|
|
35
|
+
export default defineConfig({
|
|
36
|
+
packageMode: {
|
|
37
|
+
// Hold @types/node at the Node major your `engines` floor requires.
|
|
38
|
+
'@types/node': 'minor',
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`defineConfig` supplies nmr's shared upgrade policy — a seven-day quarantine on brand-new releases, and the pair of settings that report a dependency pinned to an exact version: locked dependencies are included, and the range searched is `minor` — so your config carries only what is specific to your repo. Any setting you declare wins over nmr's default. Passing `undefined` clears a default rather than falling back to it, which is how you hand the quarantine policy back to `pnpm-workspace.yaml`'s `minimumReleaseAge`. `mode` is the exception: the upgrade tool carries a default of its own for it, and clearing nmr's leaves no valid range to search, so name a mode instead.
|
|
44
|
+
|
|
45
|
+
Everything the [taze configuration](https://github.com/antfu-collective/taze#config-file) accepts is accepted here, with five exceptions that reach the upgrade tool from no config file at all: `concurrency`, `githubActions`, `ignoreOtherWorkspaces`, `nodeVersion`, and `requestTimeout`. The tool's CLI writes a default for each of them over whatever the config declares ([taze#317](https://github.com/antfu-collective/taze/issues/317)), so the command line is the only place they can be set. nmr's ReadyUp kit warns when your config declares one, and [request timeout](#request-timeout) below shows how to set it instead.
|
|
46
|
+
|
|
47
|
+
> **Note:** This file is what activates nmr's upgrade policy at all. A repo without one gets neither the release quarantine nor the settings that report a dependency pinned to an exact version, and `nmr upgrade` reports nothing in a repo using pnpm's `savePrefix: ''`. nmr's ReadyUp kit warns when the file is missing.
|
|
48
|
+
|
|
49
|
+
Two consequences of the `minor` range mode the policy declares:
|
|
50
|
+
|
|
51
|
+
- A `packageMode` entry is honored only by the pass whose mode matches it, or by a `default` pass; every other pass drops that dependency rather than narrowing it. Under the policy's `minor` mode, a `patch` ceiling hides its dependency until you run `nmr upgrade patch`, and a `minor` ceiling hides it from `nmr upgrade major`.
|
|
52
|
+
- A `~` range is searched as `^`, so minor upgrades are proposed for it and `--write` applies them under the original `~`. To hold one dependency to patches, give it a `packageMode` entry of `patch` and read it from `nmr upgrade patch`; to hold the whole repo there, declare `mode: 'patch'`.
|
|
53
|
+
|
|
54
|
+
## Request timeout
|
|
55
|
+
|
|
56
|
+
Every `upgrade` script reaches the upgrade tool with a 30-second request timeout, in place of the tool's own 5 seconds.
|
|
57
|
+
|
|
58
|
+
Five seconds is too tight for a registry that proxies npm. The tool fetches a full packument whenever the resolved registry is not `registry.npmjs.org`, and a large one runs to tens of megabytes; the budget covers the whole retry chain rather than a single attempt, so one slow response ends the run with `Timeout requesting "<package>"`. The symptom is an upgrade pass that succeeds or fails according to how warm the tool's cache happens to be.
|
|
59
|
+
|
|
60
|
+
Pass a different value for one run:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
nmr upgrade --request-timeout 90000
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
To change it for every run, override the `upgrade` script in `.config/nmr.config.ts`, keeping the rest of the default. This is nmr's own config, not the `taze.config.ts` above; taze ignores a `rootScripts` key, and nothing reports it:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { defineConfig } from '@williamthorsen/nmr/config';
|
|
70
|
+
|
|
71
|
+
export default defineConfig({
|
|
72
|
+
rootScripts: {
|
|
73
|
+
upgrade: 'nmr-report-overrides && nmr-taze --recursive --request-timeout 90000',
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The override is one command string rather than a list of steps, because a composite element names an nmr command and both halves here are binaries.
|
|
79
|
+
|
|
80
|
+
## Additional subcommands
|
|
81
|
+
|
|
82
|
+
These commands are available as `nmr` subcommands and as standalone `nmr-`-prefixed binaries (for use in lifecycle hooks).
|
|
83
|
+
|
|
84
|
+
### `report-catalog`
|
|
85
|
+
|
|
86
|
+
Report the dependencies the current package takes from a [pnpm catalog](https://pnpm.io/catalogs), which a package-scoped `upgrade` cannot reach (see [dependency upgrades](#dependency-upgrades) for why). Each line names the dependency, its `catalog:` specifier, and the monorepo root a covering pass runs from. The workspace `upgrade` script runs it automatically, so no per-repo wiring is needed. The subcommand belongs to the workspace registry, so it runs from a package directory; the `nmr-report-catalog` bin reports nothing when run from the monorepo root, because a root-scoped pass reads the catalog itself.
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
nmr report-catalog
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### `report-overrides`
|
|
93
|
+
|
|
94
|
+
Report any active pnpm override, declared in the root `pnpm-workspace.yaml`, reminding developers of overrides that may need cleanup. The root `upgrade` script runs it automatically, so no per-repo wiring is needed. Invoking it directly is useful for a one-off report.
|
|
95
|
+
|
|
96
|
+
A `pnpm.overrides` block in the root `package.json` fails the command, naming every entry it holds and where to move it — see [dependency upgrades](#dependency-upgrades) for why the block is inert. `pnpx codemod run pnpm-v10-to-v11` performs the move.
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
nmr report-overrides
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## `nmr-taze`
|
|
103
|
+
|
|
104
|
+
Run the [taze](https://github.com/antfu-collective/taze) dependency-upgrade tool, forwarding every argument to it untouched and adding one of its own: a 30-second request timeout, unless the invocation already carries one. This is what every `upgrade` chain ends with, `root:upgrade` included — see [dependency upgrades](#dependency-upgrades) for the workflow, and [request timeout](#request-timeout) for why the timeout is set here.
|
|
105
|
+
|
|
106
|
+
Under pnpm's isolated `node_modules`, a transitive package's binary is absent from the consuming repo's `node_modules/.bin`, so a repo that depends on nmr cannot run `taze` directly. `nmr-taze` can, because nmr is a direct dependency, and it resolves the tool from the tree nmr controls.
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
nmr-taze --recursive
|
|
110
|
+
```
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Standalone utilities
|
|
2
|
+
|
|
3
|
+
The binaries behind nmr's default `clean`, `compile`, and `fmt` commands, and a check for the `prepublishOnly` hooks of publishable packages.
|
|
4
|
+
|
|
5
|
+
## `nmr-clean`
|
|
6
|
+
|
|
7
|
+
Remove a package's build output (`dist`) and its `nmr-compile` cache entry, leaving no state behind for the next build to skip on. Run from a package directory it cleans that package; run from the monorepo root it sweeps every workspace package in a single pass, running each package's resolved `clean` — so a package that overrides `clean`, in `.config/nmr.config.ts` or in its own `package.json`, gets its own command rather than the sweep. Removal is idempotent — cleaning an unbuilt package is a silent no-op. This is the default `clean` script at both levels.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
nmr-clean
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## `nmr-compile`
|
|
14
|
+
|
|
15
|
+
Compile a single package's `src` tree to `dist/esm` with the TypeScript compiler API, emitting `.js` from one program and `.d.ts` from a second that reuses it. The package's own `removeComments` governs the `.js` emit; the declarations emit forces it off, so `.d.ts` files carry their doc comments either way. Because the compiler parses each source file, every relative import form — static, re-export, dynamic `import()`, and bare side-effect — is rewritten from `.ts` to `.js` in both outputs, and `.ts` occurrences inside strings and comments are left intact. tsconfig `paths` aliases are resolved to runnable relative `.js` specifiers in both outputs, sourced from the package's tsconfig. An aliased import whose target resolves outside the package's `src/` and is not resolvable without the alias mapping fails the build with a diagnostic, rather than being emitted verbatim to produce output that fails at runtime. The build is skipped when no input has changed and the previous output is still on disk (the key is cached under `node_modules/.cache/nmr-compile/`, outside the published output). Deleting the output by any means — `nmr clean`, `rm -rf dist`, `git clean` — therefore forces a rebuild rather than a skip. This is the default `compile` script — run it from a package directory.
|
|
16
|
+
|
|
17
|
+
**What it compiles.** Entry points are `src/**/*.ts` less the directories that hold test scaffolding rather than shipped code: `__fixtures__/`, `__mocks__/`, `__tests__/`, and `test-utils/`. Add to that list per package with [`build.extraIgnorePatterns`](scripts.md#package-level-configuration). Ignoring a directory drops it as an _entry point_, not from the emit: the compiler still emits whatever the surviving entry points import, so a helper that production code uses is still compiled and its importer never emits a dangling specifier. This list is deliberately not the Vitest [coverage exclusions](vitest.md#what-the-config-excludes) — helpers live in `test-utils/` precisely so they stay covered.
|
|
18
|
+
|
|
19
|
+
**The build owns its output directory.** Every rebuild replaces `dist/esm` wholesale, so the directory holds exactly the current emit: a source that was deleted, or that a widened ignore set now covers, leaves nothing behind. Assets therefore belong outside the output directory, or in a `build:post` hook, which runs after the output is published and so composes correctly. Note that a bare `nmr compile` runs no hook, so assets a `build:post` step copies in are absent until a full `nmr build` restores them. An `outdir` that does not resolve inside the package is rejected rather than replaced.
|
|
20
|
+
|
|
21
|
+
**The replacement is atomic.** The emit is buffered in memory, written to a staging directory beside the output directory, and swapped into place by rename. A build that fails for any reason -- a malformed tsconfig, an unresolvable alias, a full disk -- therefore leaves the previous output exactly as it was. `dist/esm` is never observed mid-write: it holds the previous build or the new one, and is absent only between the two renames. This matters most when the package being rebuilt supplies a binary that other packages in the same recursive build invoke.
|
|
22
|
+
|
|
23
|
+
**What busts the cache.** The key folds each input's content and path, the emit options, the resolved TypeScript version, and a fingerprint of the nmr running the build -- its own build digest in a workspace checkout, its package version otherwise. Upgrading nmr in a consuming repo therefore rebuilds every package nmr compiles, and an edit to nmr's own build sources rebuilds each sibling once nmr itself has been rebuilt. A build of nmr's own package folds the version rather than the digest, which is the cache entry that build is about to write.
|
|
24
|
+
|
|
25
|
+
`typescript` is a peer dependency (`>=5.7.0 <7`); the consuming repo provides it. The floor is what `rewriteRelativeImportExtensions` requires; the ceiling is because TypeScript 7 ships no compiler API — its root export is a version constant, so `nmr-compile` cannot run on it. Relative imports in source must carry explicit `.ts` extensions for them to be rewritten.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
nmr-compile
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## `nmr-fmt`
|
|
32
|
+
|
|
33
|
+
Format, or check the formatting of, the files git reports for the working directory. Exactly one of `--check` and `--write` is required; a bare invocation prints usage and exits non-zero rather than defaulting to a mutation. `--write` also lists the files it rewrote. This is what `fmt` and `fmt:check` resolve to, in both registries.
|
|
34
|
+
|
|
35
|
+
`prettier` is an optional peer dependency (`>=3.9.5 <4`); the consuming repo provides it, and it is resolved through the module graph rather than from `PATH`, so the copy that runs is the one the repo declares and not whichever `prettier` happens to come first. A repository's formatter has to be the one its editor and pre-commit hook also run, which is why nmr takes it from the consumer instead of bundling a copy. It is optional because a repo can use nmr purely as a script runner; one that formats without a resolvable Prettier gets a message naming the package and the range.
|
|
36
|
+
|
|
37
|
+
The floor is a currency policy, not a capability boundary. What the design requires is `--ignore-path` honouring every flag rather than only the last, so a repository-root ignore file passed alongside a package-level one is not silently dropped — Prettier has done that since 3.0.0, while 2.x honoured only the final flag. The floor sits at the current release because every consuming repo tracks it; lowering it to `>=3.0.0` would cost correctness nothing.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
nmr-fmt --check
|
|
41
|
+
nmr-fmt --write packages/nmr
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### File selection
|
|
45
|
+
|
|
46
|
+
`fmt` and `fmt:check` format the files git reports: tracked files, plus untracked files git does not ignore. Prettier reads `.gitignore` and `.prettierignore` from the working directory alone, so a pattern in `packages/<pkg>/.gitignore` never reaches a run started at the monorepo root. git knows the whole hierarchy, including `.git/info/exclude` and `core.excludesFile`, and every `.prettierignore` in the tree is discovered from the repository root, so package-level exclusions apply from any directory as well.
|
|
47
|
+
|
|
48
|
+
A file git ignores is never formatted, even when named directly. Trailing arguments are git pathspecs rather than Prettier flags, so `nmr fmt:check packages/nmr` narrows the run, while an unrecognized option and a pathspec matching no formattable file both fail it. Running outside a git repository fails rather than reporting a clean run.
|
|
49
|
+
|
|
50
|
+
Paths the index names but the filesystem does not have (a file deleted but not yet staged) and submodule gitlinks are both dropped, so neither reaches Prettier.
|
|
51
|
+
|
|
52
|
+
## `ensure-prepublish-hooks`
|
|
53
|
+
|
|
54
|
+
Verify that all publishable workspace packages have a `prepublishOnly` script. Exits non-zero if any are missing.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
ensure-prepublish-hooks
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
| Flag | Description | Default |
|
|
61
|
+
| --------------------- | ----------------------------- | ----------------- |
|
|
62
|
+
| `--fix` | Add missing hooks | — |
|
|
63
|
+
| `--dry-run` | Preview what `--fix` would do | — |
|
|
64
|
+
| `--command <command>` | Custom hook command | `"npm run build"` |
|
package/docs/vitest.md
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# Shared Vitest config
|
|
2
|
+
|
|
3
|
+
The Vitest config that `@williamthorsen/nmr/vitest` publishes: its test tiers and the commands that select them, the conventions check, what the factory supplies, and how to customize and share it.
|
|
4
|
+
|
|
5
|
+
The [README](../README.md#shared-vitest-config) shows the one-line config file.
|
|
6
|
+
|
|
7
|
+
`vitest` is a peer dependency (`>=4.0.0 <5`), declared optional — the consuming repo provides it, and repos that never import this subpath are unaffected. `vite` is an optional peer as well (`>=8.0.0 <9`), required only by the [`tsconfigPaths` setting](#resolving-through-tsconfig-paths); a repo that declares a `vite` of its own below that range sees an unmet-peer warning whether or not it sets the flag.
|
|
8
|
+
|
|
9
|
+
## Test tiers
|
|
10
|
+
|
|
11
|
+
The config declares four projects, an ordered ladder named for the furthest thing a test reaches:
|
|
12
|
+
|
|
13
|
+
| Project | Matches | Reaches |
|
|
14
|
+
| ----------- | -------------------------------------- | ---------------------------------------------------- |
|
|
15
|
+
| `unit` | every test file the others don't claim | nothing outside the package's own dependency closure |
|
|
16
|
+
| `tool` | `*.tool.test.{ts,tsx}` | a program the environment must supply |
|
|
17
|
+
| `localhost` | `*.localhost.test.{ts,tsx}` | a service running on this machine |
|
|
18
|
+
| `remote` | `*.remote.test.{ts,tsx}` | a machine that isn't this one |
|
|
19
|
+
|
|
20
|
+
All four match only under a `__tests__` directory.
|
|
21
|
+
|
|
22
|
+
**A tier names what a test reaches, never how it invokes it.** A test driving a compiler through its JavaScript API is `tool`, exactly as one spawning the compiler binary is; whether a program ships as a library or an executable is an accident of packaging, not a property of the test. A package's `peerDependencies`, plus whatever binaries it spawns, are a good statement of where its `tool` boundary sits: a bundled dependency ships inside the package's own closure and is always present, so reaching one is `unit`.
|
|
23
|
+
|
|
24
|
+
**A tier is not a statement about preconditions.** A `unit` test may still need something set up before it runs: a build, a generated fixture, a seeded file. What makes it `unit` is that it reaches nothing beyond the test process _while running_. The filesystem sits in `unit` for the same reason the tiers exist: they gate on what must be available, and the filesystem always is.
|
|
25
|
+
|
|
26
|
+
**Every test file names its tier**, in the form `<subject>[.<aspect>].<tier>.test.ts`. Only the segment immediately before `.test.` selects a project, so an earlier one is free for documentation: `resolveConfig.packaged.unit.test.ts` reads as a companion to `resolveConfig.unit.test.ts` and still names `unit`.
|
|
27
|
+
|
|
28
|
+
`unit` is defined by subtracting the tiers rather than by an allow-list of infixes, so a file such as `parser.smoke.test.ts` runs under `unit` instead of being silently dropped. That is a safety net, not a licence to omit the tier: a file whose tier segment is missing or misspelt runs under `unit` and reports success, so no test run distinguishes it from a conformant one until the repo declares the [conventions check](#gating-the-test-file-conventions). In a repo that has not, [nmr's readyup kit](../README.md#conformance-checks) is what reports those files.
|
|
29
|
+
|
|
30
|
+
`localhost` and `remote` have no test script of their own. They are declared anyway, so that a `*.remote.test.ts` file cannot fall into `unit` and run in the default gate unnoticed.
|
|
31
|
+
|
|
32
|
+
Every tier above `unit` carries a 30-second `testTimeout` and `hookTimeout`, where `unit` keeps Vitest's defaults of 5 and 10 seconds. A tier test waits on something it doesn't control, and coverage instrumentation multiplies that wait, so the defaults turn a green suite flaky the moment `nmr test:coverage` collects it. Both budgets move together because a tier that scaffolds in `beforeAll` moves that wait out from under `testTimeout` entirely, where raising the test budget alone never reaches it. `unit` keeps the tight budgets, which is what makes a hung unit test fail fast.
|
|
33
|
+
|
|
34
|
+
To raise a budget for one tier and no other, use the `tiers` seam below. The `project` seam merges over both budgets as well, but like every option passed through it the value reaches all four projects at once -- raising a tier's budget raises `unit`'s with it. To lift the ceiling for a single file rather than a whole tier, pass a timeout to the individual test or hook, which stays the narrower tool.
|
|
35
|
+
|
|
36
|
+
### Test selections
|
|
37
|
+
|
|
38
|
+
Select the tiers at run time with `--project`, which unions when repeated and accepts negation.
|
|
39
|
+
|
|
40
|
+
Every package resolves the same six test commands. Nothing is detected on disk: the commands select [Vitest projects](#shared-vitest-config), so a package separates its tool-tier tests by naming them `*.tool.test.ts`, not by carrying extra config files.
|
|
41
|
+
|
|
42
|
+
| To run | Command |
|
|
43
|
+
| --------------------------------------------- | ----------- |
|
|
44
|
+
| Everything that runs on a bare install | `test` |
|
|
45
|
+
| The fastest tier alone | `test:unit` |
|
|
46
|
+
| Tests reaching a program alone | `test:tool` |
|
|
47
|
+
| Every tier, including those needing a service | `test:all` |
|
|
48
|
+
|
|
49
|
+
`test` names the tiers it runs rather than negating the ones it skips, so a tier added in a later release is opt-in rather than joining the default run on arrival. It covers `unit` and `tool`, the two that need nothing beyond a checkout and an install; `localhost` and `remote` are reached only through `test:all` or an explicit `--project`.
|
|
50
|
+
|
|
51
|
+
`test:coverage` and `test:watch` are the same selection as `test` in a different mode. There are no `test:tool --coverage` equivalents because none are needed: everything after a command name is forwarded untouched, so `nmr test:tool --coverage` already works.
|
|
52
|
+
|
|
53
|
+
To narrow to a single project, go through `test:all`: `nmr test:all --project remote`. Narrowing through `test` does not work, because a second `--project` widens the selection rather than restricting it.
|
|
54
|
+
|
|
55
|
+
A run that collects no test files passes. That is what lets `nmr test:tool` fan out across a monorepo in which most packages hold no tool-tier tests, and it means a package with no tests at all needs no `"test": ""` override.
|
|
56
|
+
|
|
57
|
+
The root registry carries the same six names, so a command means the same thing from the monorepo root as from inside a package. Each fans out to root-level files and every workspace package.
|
|
58
|
+
|
|
59
|
+
`test:coverage` chains `root:test` rather than a `root:test:coverage`, because the root config reports no coverage of its own; packages cover their own sources.
|
|
60
|
+
|
|
61
|
+
`test:watch` is the exception to the fan-out shape, and deliberately omits `--config`: bare `vitest` at the monorepo root resolves the root `vitest.config.ts`, whose projects then cover the whole tree in one process. A chain like the others would never advance past its first watcher. To watch root-level files alone, run `nmr root:test --watch`.
|
|
62
|
+
|
|
63
|
+
## Gating the test-file conventions
|
|
64
|
+
|
|
65
|
+
Both halves of the convention are silent on their own: an untiered file runs under the residual `unit` and passes, and a file outside `__tests__` is collected by nothing at all. `@williamthorsen/nmr/tests` exports a check that reports both, which a repo declares from a one-line test of its own:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
// __tests__/test-file-conventions.unit.test.ts
|
|
69
|
+
import { checkTestFileConventions } from '@williamthorsen/nmr/tests';
|
|
70
|
+
|
|
71
|
+
checkTestFileConventions();
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
It declares one suite with an assertion per half, each carrying the remedy that fixes it: the four tier names and the rename form, or the `__tests__` directory the file belongs in. The guard names its own tier like any other test file, so a misnamed guard reports itself.
|
|
75
|
+
|
|
76
|
+
A repo linting with `vitest/require-hook` needs a disable directive on that call, which the rule reads as setup work where it declares the suite:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
// eslint-disable-next-line vitest/require-hook -- the call declares the suite, where the rule reads it as setup work.
|
|
80
|
+
checkTestFileConventions();
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The sweep starts at the monorepo root rather than at the directory Vitest supplies, so one call covers the whole repo from whichever package the run began in. `rootDir` overrides that, for a check pointed at a tree other than the repo's own.
|
|
84
|
+
|
|
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
|
+
|
|
87
|
+
`exclude` names directory basenames the sweep prunes at any depth, additive to the ones nmr always prunes:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
checkTestFileConventions({ exclude: ['cypress', 'generated'] });
|
|
91
|
+
```
|
|
92
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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.
|
|
98
|
+
|
|
99
|
+
## What the config supplies
|
|
100
|
+
|
|
101
|
+
Two settings a workspace test runner needs almost universally are part of what the factory produces, so a config file declares neither:
|
|
102
|
+
|
|
103
|
+
- **A package outside `node_modules` resolves from source.** A cross-package import reaches the dependency's `.ts` rather than its `dist/`, through the `source` condition declared by its `exports` map, so a suite runs without a prior build. The reach is what the boundary names: a workspace package, or one linked into the repo from elsewhere, whose real path sits outside every `node_modules`. A package declaring no such condition is unaffected, and so is every package under `node_modules`, whatever its `exports` map declares.
|
|
104
|
+
- **Git subprocesses are isolated.** Every project loads a setup file that points `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` at the null device, sets `GIT_CONFIG_NOSYSTEM` and `GIT_ATTR_NOSYSTEM`, and injects `core.excludesFile` and `core.attributesFile` through `GIT_CONFIG_COUNT` and its key/value pairs, so a test that spawns `git` reads neither the developer's identity, nor a signing config that can block on a passphrase, nor their global ignore rules and attributes. Those last two need a step of their own: neither path is config-derived, so git resolves each from `$XDG_CONFIG_HOME/git/`, or from `~/.config/git/` where that variable is unset, whatever the config variables say. `GIT_CONFIG_GLOBAL` already replaces `$XDG_CONFIG_HOME/git/config`, so those two are the rest of what that directory supplies; the system attributes file at `$(prefix)/etc/gitattributes` is reached by no config variable at all, which is what `GIT_ATTR_NOSYSTEM` covers. Redirecting `XDG_CONFIG_HOME` would reach the per-user pair as well, and is not used, because other tools spawned by a test read that variable for configuration of their own: pnpm fails to start under `XDG_CONFIG_HOME=/dev/null`. An injected key outranks repository-local config, so a repository setting its own `core.excludesFile` is overridden under the isolation; a test needing its own value passes it on the invocation as `git -c core.excludesFile=...`, which outranks the injection. The setup file leads each project's `setupFiles`, ahead of every entry the layers add, because a shared setup establishes the environment the rest run in.
|
|
105
|
+
|
|
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
|
+
|
|
108
|
+
```ts
|
|
109
|
+
export default defineVitestConfig({ isolateGit: false, resolveFromSource: false });
|
|
110
|
+
```
|
|
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.
|
|
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.
|
|
115
|
+
|
|
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
|
+
|
|
118
|
+
**The boundary is Vite's resolution, not the package's role.** A workspace package that an externalized dependency imports resolves through Node, which carries no `source` condition, so it reaches that package's build output. Nothing else in a suite resolves that way.
|
|
119
|
+
|
|
120
|
+
## Resolving through tsconfig paths
|
|
121
|
+
|
|
122
|
+
A third resolution setting sits alongside those two, and this one is off. `tsconfigPaths: true` emits Vite's `resolve.tsconfigPaths`, so a test reaches an aliased specifier through the `paths` its `tsconfig.json` declares, the way `tsc` does:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
export default defineVitestConfig({ tsconfigPaths: true });
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
It folds across layers like the other two, the last layer to declare it winning. It needs no `ssr` counterpart the way a condition does: Vite holds this setting outside its per-environment resolve options, so the single key reaches the environment a test's own imports resolve through.
|
|
129
|
+
|
|
130
|
+
**It is an opt-in because its silent direction is the reverse of the two defaults'.** Leaving it off fails loudly, with an unresolved import naming the specifier. Turning it on is the silent direction, for a repo that declares `paths` for `tsc` alone: a specifier that resolved through a package's `exports` starts resolving through the alias instead, and nothing in the run reports the switch. A repo declaring no `paths` is unaffected either way.
|
|
131
|
+
|
|
132
|
+
**It requires Vite 8**, where `resolve.tsconfigPaths` arrived. nmr declares `vite` as an optional peer at `>=8.0.0 <9`, so a repo holding a stale `vite` of its own hears about it at install time. That check does not reach a repo whose only Vite comes in through Vitest's own dependency, and Vitest 4 accepts Vite 6 and 7: on those, the emitted key does nothing and the aliased import fails exactly as it would with the flag off.
|
|
133
|
+
|
|
134
|
+
## Customizing by scope
|
|
135
|
+
|
|
136
|
+
Vitest applies some options at the root of a `projects` config and others per project, and placing one at the wrong level is silent rather than loud. The factory therefore takes separate override surfaces instead of one merged config:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
export default defineVitestConfig({
|
|
140
|
+
// Vite-level options, plus the test options Vitest honours only at the root.
|
|
141
|
+
root: { ssr: { resolve: { conditions: ['my-condition'] } } },
|
|
142
|
+
// Applied to every project.
|
|
143
|
+
project: { setupFiles: ['./vitest.setup.ts'] },
|
|
144
|
+
// Applied to one tier, after the `project` block above.
|
|
145
|
+
tiers: { tool: { testTimeout: 120_000 } },
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`root` is typed to accept only the options that work at the root, so writing a per-project option there is a compile error rather than a setting that never runs. Every surface merges into the generated config rather than replacing it, so overriding one coverage field leaves the rest intact.
|
|
150
|
+
|
|
151
|
+
`tiers` is keyed by tier name and reaches all four, `unit` included. A key naming no tier throws and names the valid ones: ignoring it would leave the suite green on the budget the key failed to change, which nothing in the run reports. A tier target sets whichever keys it names and no others, so raising `testTimeout` alone leaves that tier's `hookTimeout` at 30 seconds.
|
|
152
|
+
|
|
153
|
+
Arrays concatenate rather than replace. `exclude` and `setupFiles` therefore add to what the config already declares, and no surface can narrow `include` or drop a default exclusion. Adding an `include` pattern through `project` widens all four projects at once, so a file matching it is collected by each and runs four times.
|
|
154
|
+
|
|
155
|
+
`resolve.conditions` concatenates too, onto the Vite defaults the factory already emits, but layer order carries no meaning there: Vite consumes conditions as a set, and which one wins is decided by the key order of the consumed package's own `exports`. A later layer can add a condition and can never remove or outrank one an earlier layer contributed. Every entry also reaches Node, which resolves each package reached by a test's imports under `node_modules`, so a condition added here is one that the package's `exports` map may answer. `resolve.alias` is the one key that merges override-first, so a later alias takes precedence over an earlier one.
|
|
156
|
+
|
|
157
|
+
**A condition for the tests' own resolution goes under `ssr`.** `resolve` is per-environment, and Vitest resolves a test's imports through the server environment, so `root: { ssr: { resolve: { conditions: ['my-condition'] } } }` is the seam that reaches them. A top-level `root: { resolve: { conditions: [...] } }` entry reaches the client environment, which only browser-mode tests resolve through; it composes into that array and never reports that a node test did not see it. `resolve.alias` is not per-environment, so a top-level alias reaches both.
|
|
158
|
+
|
|
159
|
+
## Sharing options across config files
|
|
160
|
+
|
|
161
|
+
Vitest resolves one config per run, so a package that adds its own `vitest.config.ts` stops seeing the repo's root config entirely -- not the one setting it meant to change, all of them. Pass the shared settings as a layer instead:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
// vitest.shared.ts
|
|
165
|
+
import { fileURLToPath } from 'node:url';
|
|
166
|
+
|
|
167
|
+
import type { VitestConfigOptions } from '@williamthorsen/nmr/vitest';
|
|
168
|
+
|
|
169
|
+
export const shared: VitestConfigOptions = {
|
|
170
|
+
root: { resolve: { alias: { '~': fileURLToPath(new URL('.', import.meta.url)) } } },
|
|
171
|
+
project: { setupFiles: [fileURLToPath(new URL('./vitest.setup.ts', import.meta.url))] },
|
|
172
|
+
};
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
// packages/web/vitest.config.ts
|
|
177
|
+
import { defineVitestConfig } from '@williamthorsen/nmr/vitest';
|
|
178
|
+
|
|
179
|
+
import { shared } from '../../vitest.shared.ts';
|
|
180
|
+
|
|
181
|
+
export default defineVitestConfig(shared, { project: { environment: 'jsdom' } });
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Both factories fold any number of layers left to right: a later layer wins on a scalar, arrays concatenate in layer order, and an `undefined` layer is skipped, so `defineVitestConfig(shared, isCI ? ciLayer : undefined)` composes without a spread. `defineRootVitestConfig` takes the same layers, with `monorepoRoot` on the last one -- the config file's own, the only place `import.meta.dirname` names this repo.
|
|
185
|
+
|
|
186
|
+
Order is the point where `setupFiles` is concerned, since a shared setup file establishes the environment the package's own then runs in. A later layer's `project` block likewise wins over an earlier layer's `tiers` target: the nearer config is the more deliberate.
|
|
187
|
+
|
|
188
|
+
**A package's own `vite.config.ts` fills that one slot too.** Vitest searches for a config by ascending from the run root and stopping at the first directory holding any of `vitest.config.*` or `vite.config.*`, so a package carrying only a Vite config ends the search there and never reaches the root config. Within a directory `vitest.config.*` is tried first, which makes the fix a Vitest config beside the Vite one, calling `defineVitestConfig()` as any other package config does. Without it the package declares no projects and every tier-selecting command fails with `No projects matched the filter`, `nmr ci` among them. [nmr's readyup kit](../README.md#conformance-checks) reports a package in that state.
|
|
189
|
+
|
|
190
|
+
**A path in a shared layer must be absolute.** Vitest resolves `setupFiles` against each project's own root, not against the module that declared the path, so a bare `'./vitest.setup.ts'` in a shared module names a different file in every package that consumes it -- and a package that happens to own a file by that name loads the wrong one instead of failing. The co-located form stays correct in a config file that declares the path directly.
|
|
191
|
+
|
|
192
|
+
**Do not merge two returned configs.** `mergeConfig(defineVitestConfig(), defineVitestConfig(mine))` looks like the idiomatic recovery and fails at startup: both sides declare the same four project names, which Vitest rejects with `Project name "unit" ... is not unique`. Layers merge the factory's inputs instead, which is why they yield four projects however many fold.
|
|
193
|
+
|
|
194
|
+
A config file that omits the shared layer still loses those settings, silently -- Vitest's own resolution contract, not something the factory can intercept. Guard it with a test that fails when a package's suite goes missing, which also catches a shared `exclude` pattern swallowing one package's tests. Source resolution and git isolation need no such guard: the factory supplies both, so a config file cannot omit them by forgetting a layer.
|
|
195
|
+
|
|
196
|
+
## What the config excludes
|
|
197
|
+
|
|
198
|
+
Collection skips `**/node_modules/**`, `**/.git/**`, `**/coverage/**`, and `**/dist/**`. Coverage skips `**/__{fixtures,mocks,tests}__/**`, `**/index.ts`, and `**/*.d.ts`.
|
|
199
|
+
|
|
200
|
+
`testCollectionExclude` adds to the collection list. It takes directory basenames rather than globs, matched at any depth, so the same array serves the [conventions check](#gating-the-test-file-conventions):
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
export default defineVitestConfig({ testCollectionExclude: ['cypress', 'generated'] });
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Every layer's entries are concatenated, and a name the shared config already prunes is emitted once. A repo needing a glob rather than a directory name has the `project` seam's own `exclude`.
|
|
207
|
+
|
|
208
|
+
Because both seams concatenate, a consumer can add an exclusion but never remove one. A pattern therefore earns its place in these lists only by preventing a _silent_ failure, one a consumer cannot self-diagnose. A visible failure, such as a stray file sitting at 0% in the coverage report, is left to the consumer's own `project` seam.
|
|
209
|
+
|
|
210
|
+
Excluding build output from collection is the clearest case. A build that copies `.ts` sources rather than compiling them puts a second copy of the suite under `dist/`, where it is collected and passes green against stale code, and nothing in the run says so. (`nmr build` emits only `.js` and `.d.ts`, neither of which the include matches, so an nmr-built package was never exposed.) `dist/` is deliberately absent from the coverage list, because build output reaching a coverage report shows up as a diagnosable 0% entry.
|
|
211
|
+
|
|
212
|
+
### Where to put fixtures
|
|
213
|
+
|
|
214
|
+
A `__fixtures__/` directory is excluded from coverage at any depth, so fixture data stops counting against a package's numbers whether or not a test imports it.
|
|
215
|
+
|
|
216
|
+
Placing it outside `__tests__/` settles a second problem: anything named `*.test.{ts,tsx}` under `__tests__/` is collected and run, whatever it holds, so a fixture carrying that name becomes a failing test. `src/__fixtures__/` resolves both halves; `src/__tests__/__fixtures__/` resolves the coverage half alone.
|
|
217
|
+
|
|
218
|
+
No collection pattern names fixtures, and that asymmetry is intentional. A coverage exclusion cannot hide a real test, whereas a collection exclusion can; since it could not be removed, a consumer who legitimately keeps a test under `__fixtures__/` would have no recourse.
|
|
219
|
+
|
|
220
|
+
## Root-scoped tests
|
|
221
|
+
|
|
222
|
+
A monorepo's own root-level tests need a second config, because the package config is found by walking up from a package directory:
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
// vitest.root.config.ts
|
|
226
|
+
import { defineRootVitestConfig } from '@williamthorsen/nmr/vitest';
|
|
227
|
+
|
|
228
|
+
export default defineRootVitestConfig({ monorepoRoot: import.meta.dirname });
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
This variant reads `pnpm-workspace.yaml` and excludes every workspace package from all four projects, so a root run covers only root-level files. It reports no coverage of its own — packages cover their own sources.
|
|
232
|
+
|
|
233
|
+
`monorepoRoot` is required, and because the config sits at the monorepo root, it is always `import.meta.dirname`. Stating the root rather than searching for it is what makes the exclusions describe this repo: a search from the working directory would resolve whichever monorepo the run started in, and every project is then pinned to that one. A directory holding no `pnpm-workspace.yaml` throws, naming the directory.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Workspace introspection
|
|
2
|
+
|
|
3
|
+
The two pnpm-workspace lookups that `@williamthorsen/nmr/workspace` publishes.
|
|
4
|
+
|
|
5
|
+
`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
|
+
|
|
7
|
+
`getWorkspacePackageDirs(monorepoRoot)` reads the workspace patterns from that repo's `pnpm-workspace.yaml` and resolves them to absolute package directories, sorted and free of duplicates. It throws if `monorepoRoot` holds no `pnpm-workspace.yaml`. Patterns carry pnpm's own semantics: `packages/*`, deeper globs such as `packages/**`, exact paths such as `tools/cli`, and `!`-prefixed exclusions such as `!packages/legacy` or `!**/test/**`, which filter every directory the positive patterns matched regardless of where they appear in the list. Nothing under `node_modules` is ever returned.
|
|
8
|
+
|
|
9
|
+
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
|
+
|
|
11
|
+
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.37.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Context-aware script runner for pnpm monorepos",
|
|
6
6
|
"keywords": [
|
|
@@ -72,10 +72,12 @@
|
|
|
72
72
|
"CHANGELOG.md",
|
|
73
73
|
"agents",
|
|
74
74
|
"bin",
|
|
75
|
-
"dist"
|
|
75
|
+
"dist",
|
|
76
|
+
"docs",
|
|
77
|
+
"!docs/*.v*.md"
|
|
76
78
|
],
|
|
77
79
|
"dependencies": {
|
|
78
|
-
"@williamthorsen/nmr-core": "0.11.
|
|
80
|
+
"@williamthorsen/nmr-core": "0.11.1",
|
|
79
81
|
"@williamthorsen/toolbelt.errors": "0.7.0",
|
|
80
82
|
"glob": "13.0.6",
|
|
81
83
|
"prettier-plugin-sh": "0.19.0",
|
|
@@ -87,7 +89,7 @@
|
|
|
87
89
|
"@williamthorsen/toolbelt.testing": "0.5.1",
|
|
88
90
|
"@williamthorsen/toolbelt.vitest": "0.9.0",
|
|
89
91
|
"prettier": "3.9.6",
|
|
90
|
-
"readyup": "0.
|
|
92
|
+
"readyup": "0.36.0",
|
|
91
93
|
"tinyglobby": "0.2.17",
|
|
92
94
|
"typescript": "6.0.3",
|
|
93
95
|
"vite": "8.2.2",
|