@williamthorsen/nmr 0.24.0 → 0.25.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/.readyup/kits/default.js +479 -0
- package/.readyup/manifest.json +16 -0
- package/README.md +37 -39
- package/dist/esm/check-cache.js +4 -16
- package/dist/esm/helpers/duration.d.ts +2 -0
- package/dist/esm/helpers/duration.js +21 -0
- package/dist/esm/tiers.d.ts +7 -0
- package/dist/esm/tiers.js +35 -0
- package/dist/esm/vitest.d.ts +2 -3
- package/dist/esm/vitest.js +3 -7
- package/package.json +6 -3
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/** @noformat — @generated. Do not edit. Compiled by rdy. */
|
|
2
|
+
/* eslint-disable */
|
|
3
|
+
export const __readyupVersion = "0.23.0";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
// .readyup/kits/default.ts
|
|
7
|
+
import { existsSync, globSync, readdirSync as readdirSync2, readFileSync } from "node:fs";
|
|
8
|
+
import { basename, join, sep } from "node:path";
|
|
9
|
+
import { defineRdyKit } from "readyup";
|
|
10
|
+
import {
|
|
11
|
+
fileContains,
|
|
12
|
+
fileExists,
|
|
13
|
+
hasDevDependency,
|
|
14
|
+
hasMinDevDependencyVersion,
|
|
15
|
+
hasPackageJsonField,
|
|
16
|
+
isRecord,
|
|
17
|
+
readFile,
|
|
18
|
+
readPackageJson
|
|
19
|
+
} from "readyup/check-utils";
|
|
20
|
+
|
|
21
|
+
// src/default-scripts.ts
|
|
22
|
+
var GATE_PROJECTS = "--project unit --project tool";
|
|
23
|
+
var workspaceScripts = {
|
|
24
|
+
build: ["compile"],
|
|
25
|
+
check: ["typecheck", "fmt:check", "lint:check", "test"],
|
|
26
|
+
"check:strict": ["typecheck", "fmt:check", "lint:strict", "test:coverage"],
|
|
27
|
+
clean: "nmr-clean",
|
|
28
|
+
compile: "nmr-compile",
|
|
29
|
+
fix: ["lint", "fmt"],
|
|
30
|
+
"fix:check": ["fmt:check", "lint:check"],
|
|
31
|
+
fmt: "nmr-fmt --write",
|
|
32
|
+
"fmt:check": "nmr-fmt --check",
|
|
33
|
+
lint: "eslint --fix .",
|
|
34
|
+
"lint:check": "eslint .",
|
|
35
|
+
"lint:strict": "strict-lint",
|
|
36
|
+
test: `pnpm exec vitest ${GATE_PROJECTS}`,
|
|
37
|
+
"test:all": "pnpm exec vitest",
|
|
38
|
+
"test:coverage": `pnpm exec vitest ${GATE_PROJECTS} --coverage`,
|
|
39
|
+
"test:tool": "pnpm exec vitest --project tool",
|
|
40
|
+
"test:unit": "pnpm exec vitest --project unit",
|
|
41
|
+
"test:watch": `pnpm exec vitest ${GATE_PROJECTS} --watch`,
|
|
42
|
+
typecheck: "tsgo --noEmit",
|
|
43
|
+
// `--include-locked` is required rather than stylistic: a repo that pins exactly (pnpm's
|
|
44
|
+
// `savePrefix: ''`) has no dependency taze considers unlocked, so without it nothing is reported.
|
|
45
|
+
upgrade: "nmr-taze --include-locked",
|
|
46
|
+
"view-coverage": "open coverage/index.html"
|
|
47
|
+
};
|
|
48
|
+
var rootScripts = {
|
|
49
|
+
audit: ["audit:prod", "audit:dev"],
|
|
50
|
+
"audit:dev": "pnpm exec v11y --dev",
|
|
51
|
+
"audit:prod": "pnpm exec v11y --prod",
|
|
52
|
+
build: "pnpm --recursive exec nmr build",
|
|
53
|
+
check: ["typecheck", "fmt:check", "lint:check", "test"],
|
|
54
|
+
"check:agent-files": "nmr-sync-agent-files --check",
|
|
55
|
+
"check:strict": ["typecheck", "fmt:check", "lint:strict", "test:coverage", "check:agent-files"],
|
|
56
|
+
// Excludes the audit, which has a workflow of its own.
|
|
57
|
+
ci: ["build", "check:strict"],
|
|
58
|
+
clean: "nmr-clean",
|
|
59
|
+
fix: ["lint", "fmt"],
|
|
60
|
+
"fix:check": ["fmt:check", "lint:check"],
|
|
61
|
+
// Identical to the workspace entries: the bin scopes its own selection to the working directory, so
|
|
62
|
+
// neither registry needs the `sh -c '… "${@:-.}"' --` wrapper that gave the root form a default path.
|
|
63
|
+
fmt: "nmr-fmt --write",
|
|
64
|
+
"fmt:check": "nmr-fmt --check",
|
|
65
|
+
lint: "nmr root:lint && pnpm --recursive exec nmr lint",
|
|
66
|
+
"lint:check": "nmr root:lint:check && pnpm --recursive exec nmr lint:check",
|
|
67
|
+
"lint:strict": "nmr root:lint:strict && pnpm --recursive exec nmr lint:strict",
|
|
68
|
+
// Everything the remote runs: the code-quality gate plus the audit workflow.
|
|
69
|
+
prepush: ["ci", "audit"],
|
|
70
|
+
"report-overrides": "nmr-report-overrides",
|
|
71
|
+
"root:check": ["root:typecheck", "fmt:check", "root:lint:check", "root:test"],
|
|
72
|
+
"root:lint": "eslint --fix --ignore-pattern 'packages/**' .",
|
|
73
|
+
"root:lint:check": "eslint --ignore-pattern 'packages/**' .",
|
|
74
|
+
"root:lint:strict": "strict-lint --ignore-pattern 'packages/**' .",
|
|
75
|
+
"root:test": `vitest --config ./vitest.root.config.ts ${GATE_PROJECTS}`,
|
|
76
|
+
"root:test:all": "vitest --config ./vitest.root.config.ts",
|
|
77
|
+
"root:test:tool": "vitest --config ./vitest.root.config.ts --project tool",
|
|
78
|
+
"root:test:unit": "vitest --config ./vitest.root.config.ts --project unit",
|
|
79
|
+
"root:typecheck": "tsgo --noEmit",
|
|
80
|
+
"root:upgrade": "nmr-taze --include-locked",
|
|
81
|
+
"sync-agent-files": "nmr-sync-agent-files",
|
|
82
|
+
test: "nmr root:test && pnpm --recursive exec nmr test",
|
|
83
|
+
"test:all": "nmr root:test:all && pnpm --recursive exec nmr test:all",
|
|
84
|
+
// Chains `root:test`, not a `root:test:coverage`: the root config reports no coverage of its own.
|
|
85
|
+
"test:coverage": "nmr root:test && pnpm --recursive exec nmr test:coverage",
|
|
86
|
+
"test:tool": "nmr root:test:tool && pnpm --recursive exec nmr test:tool",
|
|
87
|
+
"test:unit": "nmr root:test:unit && pnpm --recursive exec nmr test:unit",
|
|
88
|
+
// Omitting `--config` is deliberate: bare `vitest` at the monorepo root resolves the root `vitest.config.ts`,
|
|
89
|
+
// covering the whole tree in one process. A chain like the others would never advance past its first watcher.
|
|
90
|
+
"test:watch": `vitest ${GATE_PROJECTS} --watch`,
|
|
91
|
+
typecheck: "nmr root:typecheck && pnpm --recursive exec nmr typecheck",
|
|
92
|
+
// A string rather than a composite because passthrough args attach to the chain's last command — as a
|
|
93
|
+
// composite, `nmr upgrade major` would hand `major` to the override report instead of the upgrade tool.
|
|
94
|
+
//
|
|
95
|
+
// Overrides print first, as context for the report that follows: a pinned transitive dependency is why
|
|
96
|
+
// an expected upgrade may be missing from it. taze handles pnpm workspaces natively, so this needs no
|
|
97
|
+
// `pnpm --recursive` fan-out: one process covers the root and every package.
|
|
98
|
+
upgrade: "nmr-report-overrides && nmr-taze --include-locked --recursive"
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// src/resolve-scripts.ts
|
|
102
|
+
function getDefaultRootScripts() {
|
|
103
|
+
return { ...rootScripts };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/tiers.ts
|
|
107
|
+
import { readdirSync } from "node:fs";
|
|
108
|
+
import path from "node:path";
|
|
109
|
+
var TEST_DIR = "__tests__";
|
|
110
|
+
var TEST_EXTENSIONS = "{ts,tsx}";
|
|
111
|
+
var TEST_GLOB_PREFIX = `**/${TEST_DIR}/**`;
|
|
112
|
+
var TEST_FILE_PATTERN = /\.test\.tsx?$/;
|
|
113
|
+
var ALL_TEST_PATTERNS = [`${TEST_GLOB_PREFIX}/*.test.${TEST_EXTENSIONS}`];
|
|
114
|
+
function findTestFiles(rootDir) {
|
|
115
|
+
const found = [];
|
|
116
|
+
collectTestFiles(rootDir, "", false, found);
|
|
117
|
+
return found.toSorted();
|
|
118
|
+
}
|
|
119
|
+
function hasTierInfix(filePath) {
|
|
120
|
+
const tiers = TIER_NAMES;
|
|
121
|
+
return tiers.includes(path.basename(filePath).split(".").at(-3) ?? "");
|
|
122
|
+
}
|
|
123
|
+
var TEST_COLLECTION_EXCLUDE = [".git", "coverage", "dist", "node_modules"];
|
|
124
|
+
var TIER_NAMES = ["unit", "tool", "localhost", "remote"];
|
|
125
|
+
function collectTestFiles(dir, relativeDir, inTestDir, found) {
|
|
126
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
const relativePath = relativeDir === "" ? entry.name : `${relativeDir}/${entry.name}`;
|
|
129
|
+
if (entry.isDirectory()) {
|
|
130
|
+
if (TEST_COLLECTION_EXCLUDE.includes(entry.name)) continue;
|
|
131
|
+
collectTestFiles(path.join(dir, entry.name), relativePath, inTestDir || entry.name === TEST_DIR, found);
|
|
132
|
+
} else if (inTestDir && TEST_FILE_PATTERN.test(entry.name)) {
|
|
133
|
+
found.push(relativePath);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// .readyup/kits/default.ts
|
|
139
|
+
var default_default = defineRdyKit({
|
|
140
|
+
checklists: [
|
|
141
|
+
{
|
|
142
|
+
name: "nmr",
|
|
143
|
+
checks: [
|
|
144
|
+
// -- Setup ---------------------------------------------------------------
|
|
145
|
+
{
|
|
146
|
+
name: "@williamthorsen/nmr in devDependencies",
|
|
147
|
+
severity: "error",
|
|
148
|
+
check: () => hasDevDependency("@williamthorsen/nmr"),
|
|
149
|
+
fix: "pnpm add --save-dev @williamthorsen/nmr",
|
|
150
|
+
checks: [
|
|
151
|
+
{
|
|
152
|
+
get name() {
|
|
153
|
+
return `@williamthorsen/nmr >= ${getMinVersion()}`;
|
|
154
|
+
},
|
|
155
|
+
severity: "error",
|
|
156
|
+
check: () => hasMinDevDependencyVersion("@williamthorsen/nmr", getMinVersion(), {
|
|
157
|
+
exempt: (range) => range.startsWith("workspace:")
|
|
158
|
+
}),
|
|
159
|
+
get fix() {
|
|
160
|
+
return `pnpm add --save-dev @williamthorsen/nmr@^${getMinVersion()}`;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
]
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: "pnpm-workspace.yaml exists",
|
|
167
|
+
severity: "error",
|
|
168
|
+
check: () => fileExists("pnpm-workspace.yaml"),
|
|
169
|
+
fix: "Create pnpm-workspace.yaml with workspace package globs"
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "package.json has packageManager field",
|
|
173
|
+
severity: "warn",
|
|
174
|
+
check: () => hasPackageJsonField("packageManager"),
|
|
175
|
+
fix: 'Add "packageManager" field to package.json (e.g., "pnpm@10.33.0")'
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: ".tool-versions does not list pnpm",
|
|
179
|
+
severity: "warn",
|
|
180
|
+
check: toolVersionsHasNoPnpm,
|
|
181
|
+
fix: "Remove pnpm from .tool-versions \u2014 manage via packageManager field and corepack"
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: ".config/nmr.config.ts uses defineConfig",
|
|
185
|
+
severity: "recommend",
|
|
186
|
+
skip: () => !fileExists(".config/nmr.config.ts") ? "no nmr config file" : false,
|
|
187
|
+
check: () => fileContains(".config/nmr.config.ts", /defineConfig/),
|
|
188
|
+
fix: "Wrap your config export with defineConfig() from @williamthorsen/nmr/config for type safety"
|
|
189
|
+
},
|
|
190
|
+
// -- Root script cleanup -------------------------------------------------
|
|
191
|
+
{
|
|
192
|
+
name: "root package.json has no nmr-provided scripts",
|
|
193
|
+
severity: "warn",
|
|
194
|
+
check: noRedundantRootScripts,
|
|
195
|
+
fix: "Remove scripts from root package.json that nmr provides as built-in root scripts \u2014 invoke via nmr directly"
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: "root:lint:strict does not use echo fallback",
|
|
199
|
+
severity: "warn",
|
|
200
|
+
skip: () => !scriptExists("root:lint:strict") ? "no root:lint:strict script" : false,
|
|
201
|
+
check: () => !scriptMatches("root:lint:strict", /\becho\b/),
|
|
202
|
+
fix: "Replace the echo fallback in root:lint:strict \u2014 strict-lint now supports path arguments"
|
|
203
|
+
},
|
|
204
|
+
// -- Workspace build readiness -------------------------------------------
|
|
205
|
+
{
|
|
206
|
+
name: "all workspace packages can build",
|
|
207
|
+
severity: "warn",
|
|
208
|
+
check: allWorkspacePackagesCanBuild,
|
|
209
|
+
fix: `Add "build": ":" to packages that don't need a build, or ensure packages that use the default nmr build have a tsconfig.json and a src/ directory`
|
|
210
|
+
},
|
|
211
|
+
// -- Vitest projects -----------------------------------------------------
|
|
212
|
+
{
|
|
213
|
+
name: "no retired Vitest config variants",
|
|
214
|
+
severity: "error",
|
|
215
|
+
check: () => noRetiredVitestConfigs(),
|
|
216
|
+
fix: "Delete every vitest.standalone.config.* and vitest.integration.config.*. nmr's test scripts select Vitest projects instead of naming config files"
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: "vitest.config.ts builds on @williamthorsen/nmr/vitest",
|
|
220
|
+
severity: "error",
|
|
221
|
+
check: () => vitestConfigBuildsOnSharedConfig(),
|
|
222
|
+
fix: "Replace vitest.config.ts with: import { defineVitestConfig } from '@williamthorsen/nmr/vitest'; export default defineVitestConfig();"
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
name: "vitest.root.config.ts builds on @williamthorsen/nmr/vitest",
|
|
226
|
+
severity: "error",
|
|
227
|
+
check: () => vitestRootConfigBuildsOnSharedConfig(),
|
|
228
|
+
fix: "Replace vitest.root.config.ts with: import { defineRootVitestConfig } from '@williamthorsen/nmr/vitest'; export default defineRootVitestConfig({ monorepoRoot: import.meta.dirname });"
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
name: "every test file names its isolation tier",
|
|
232
|
+
severity: "error",
|
|
233
|
+
check: () => everyTestFileNamesItsTier(),
|
|
234
|
+
fix: `Rename each to <subject>[.<aspect>].<tier>.test.ts, naming one of ${TIER_NAMES.join(", ")}. Use tool for a test that reaches a program the environment supplies, which is where a retired .int. or .integration. file belongs. Only the segment before .test. selects a project, so an untiered file runs under the residual unit project and reports success`
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
name: "no package re-exports the ancestor Vitest config",
|
|
238
|
+
severity: "recommend",
|
|
239
|
+
check: () => noReExportOnlyVitestConfigs(),
|
|
240
|
+
fix: "Delete these files. Vitest resolves config by walking up from the run root, so a per-package re-export is redundant"
|
|
241
|
+
},
|
|
242
|
+
// -- Shared Prettier config ----------------------------------------------
|
|
243
|
+
{
|
|
244
|
+
name: "Prettier config builds on @williamthorsen/nmr/prettier",
|
|
245
|
+
severity: "error",
|
|
246
|
+
check: () => prettierConfigBuildsOnSharedConfig(),
|
|
247
|
+
fix: "Replace the Prettier config with: import { definePrettierConfig } from '@williamthorsen/nmr/prettier'; export default definePrettierConfig();"
|
|
248
|
+
},
|
|
249
|
+
// -- Audit dependency --------------------------------------------------------
|
|
250
|
+
{
|
|
251
|
+
name: "v11y-check in devDependencies",
|
|
252
|
+
severity: "warn",
|
|
253
|
+
check: () => hasDevDependency("v11y-check"),
|
|
254
|
+
fix: "pnpm add --save-dev v11y-check"
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: "code-quality workflow does not use nmr prepush",
|
|
258
|
+
severity: "warn",
|
|
259
|
+
skip: () => !fileExists(".github/workflows/code-quality.yaml") ? "no code-quality workflow" : false,
|
|
260
|
+
check: codeQualityWorkflowDoesNotUseNmrPrepush,
|
|
261
|
+
fix: 'Change the check-command in .github/workflows/code-quality.yaml from "pnpm exec nmr prepush" to "pnpm exec nmr ci", which runs the same checks without the audit that audit.yaml already runs'
|
|
262
|
+
},
|
|
263
|
+
// -- Legacy script runner ------------------------------------------------
|
|
264
|
+
{
|
|
265
|
+
name: "scripts/run-workspace-script.ts does not exist",
|
|
266
|
+
severity: "error",
|
|
267
|
+
check: () => !fileExists("scripts/run-workspace-script.ts"),
|
|
268
|
+
fix: "Delete scripts/run-workspace-script.ts \u2014 nmr replaces this custom script runner"
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
name: 'no workspace packages reference run-workspace-script or "pnpm run ws"',
|
|
272
|
+
severity: "error",
|
|
273
|
+
check: noWorkspaceRunScriptReferences,
|
|
274
|
+
fix: 'Remove "ws" script entries and replace any "pnpm run ws" invocations with nmr in each packages/*/package.json'
|
|
275
|
+
}
|
|
276
|
+
]
|
|
277
|
+
}
|
|
278
|
+
]
|
|
279
|
+
});
|
|
280
|
+
var SCAN_EXCLUDE_DIRS = /* @__PURE__ */ new Set([".git", "coverage", "dist", "node_modules"]);
|
|
281
|
+
var CONFIG_EXTENSIONS = "{ts,mts,cts,js,mjs,cjs}";
|
|
282
|
+
var SHARED_VITEST_MODULE = "@williamthorsen/nmr/vitest";
|
|
283
|
+
var SHARED_PRETTIER_MODULE = "@williamthorsen/nmr/prettier";
|
|
284
|
+
var INERT_PRETTIER_CONFIGS = [".prettierrc", ".prettierrc.{json,json5,yaml,yml,toml}"];
|
|
285
|
+
var RE_EXPORT_LINE_PATTERN = /^export\s*(?:\{\s*default\s*\}|\*)\s*from\s*['"]\.\.\/[^'"]*['"];?$/;
|
|
286
|
+
function allWorkspacePackagesCanBuild() {
|
|
287
|
+
const packagesDir = join(process.cwd(), "packages");
|
|
288
|
+
if (!existsSync(packagesDir)) return true;
|
|
289
|
+
const entries = readdirSync2(packagesDir, { withFileTypes: true });
|
|
290
|
+
const failing = [];
|
|
291
|
+
for (const entry of entries) {
|
|
292
|
+
if (!entry.isDirectory()) continue;
|
|
293
|
+
const pkgPath = `packages/${entry.name}/package.json`;
|
|
294
|
+
const content = readFile(pkgPath);
|
|
295
|
+
if (!content) continue;
|
|
296
|
+
const hasBuildOverride = /"build"\s*:/.test(content);
|
|
297
|
+
const hasDefaultBuildInputs = fileExists(`packages/${entry.name}/tsconfig.json`) && existsSync(join(packagesDir, entry.name, "src"));
|
|
298
|
+
if (!hasBuildOverride && !hasDefaultBuildInputs) {
|
|
299
|
+
failing.push(entry.name);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (failing.length === 0) return true;
|
|
303
|
+
return {
|
|
304
|
+
ok: false,
|
|
305
|
+
detail: `missing build override or tsconfig.json + src/: ${failing.join(", ")}`
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function checkNoMatchingFiles(patterns, cwd) {
|
|
309
|
+
const found = findFiles(patterns, cwd);
|
|
310
|
+
if (found.length === 0) return true;
|
|
311
|
+
return { ok: false, detail: formatPaths(found) };
|
|
312
|
+
}
|
|
313
|
+
function checkRootVitestConfig(baseName, exportName, cwd) {
|
|
314
|
+
const matches = findFiles([`${baseName}.${CONFIG_EXTENSIONS}`], cwd);
|
|
315
|
+
if (matches.length === 0) {
|
|
316
|
+
return { ok: false, detail: `${baseName}.ts is missing` };
|
|
317
|
+
}
|
|
318
|
+
const stale = matches.filter(
|
|
319
|
+
(relativePath) => !importsSharedExport(readFileIn(cwd, relativePath), exportName, SHARED_VITEST_MODULE)
|
|
320
|
+
);
|
|
321
|
+
if (stale.length === 0) return true;
|
|
322
|
+
return { ok: false, detail: `does not import ${exportName} from ${SHARED_VITEST_MODULE}: ${stale.join(", ")}` };
|
|
323
|
+
}
|
|
324
|
+
function prettierConfigBuildsOnSharedConfig(cwd = process.cwd()) {
|
|
325
|
+
const configs = findFiles(
|
|
326
|
+
[`.prettierrc.${CONFIG_EXTENSIONS}`, `prettier.config.${CONFIG_EXTENSIONS}`],
|
|
327
|
+
//
|
|
328
|
+
cwd
|
|
329
|
+
);
|
|
330
|
+
if (configs.length === 0) {
|
|
331
|
+
return { ok: false, detail: describeMissingPrettierConfig(cwd) };
|
|
332
|
+
}
|
|
333
|
+
const stale = configs.filter(
|
|
334
|
+
(relativePath) => !importsSharedExport(readFileIn(cwd, relativePath), "definePrettierConfig", SHARED_PRETTIER_MODULE)
|
|
335
|
+
);
|
|
336
|
+
if (stale.length === 0) return true;
|
|
337
|
+
return {
|
|
338
|
+
ok: false,
|
|
339
|
+
detail: `does not import definePrettierConfig from ${SHARED_PRETTIER_MODULE}: ${stale.join(", ")}`
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function describeMissingPrettierConfig(cwd) {
|
|
343
|
+
const inert = findFiles(INERT_PRETTIER_CONFIGS, cwd);
|
|
344
|
+
if (inert.length > 0) return `holds no code to call the factory: ${inert.join(", ")}`;
|
|
345
|
+
if (hasPrettierConfigKey(cwd)) {
|
|
346
|
+
return 'holds no code to call the factory: the "prettier" key in package.json';
|
|
347
|
+
}
|
|
348
|
+
return ".prettierrc.js is missing";
|
|
349
|
+
}
|
|
350
|
+
function hasPrettierConfigKey(cwd) {
|
|
351
|
+
const manifest = readFileIn(cwd, "package.json");
|
|
352
|
+
if (manifest === void 0) return false;
|
|
353
|
+
try {
|
|
354
|
+
const parsed = JSON.parse(manifest);
|
|
355
|
+
return isRecord(parsed) && parsed.prettier !== void 0;
|
|
356
|
+
} catch {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function codeQualityWorkflowDoesNotUseNmrPrepush() {
|
|
361
|
+
const content = readFile(".github/workflows/code-quality.yaml");
|
|
362
|
+
if (content === void 0) return true;
|
|
363
|
+
return !/check-command:\s*pnpm exec nmr prepush(\s|$)/.test(content);
|
|
364
|
+
}
|
|
365
|
+
function everyTestFileNamesItsTier(cwd = process.cwd()) {
|
|
366
|
+
const untiered = findTestFiles(cwd).filter((path2) => !hasTierInfix(path2));
|
|
367
|
+
if (untiered.length === 0) return true;
|
|
368
|
+
return { ok: false, detail: formatPaths(untiered) };
|
|
369
|
+
}
|
|
370
|
+
function findFiles(patterns, cwd) {
|
|
371
|
+
return globSync(patterns, { cwd, exclude: (path2) => SCAN_EXCLUDE_DIRS.has(basename(path2)) }).map((path2) => path2.split(sep).join("/")).toSorted();
|
|
372
|
+
}
|
|
373
|
+
function formatPaths(paths) {
|
|
374
|
+
return `${paths.length} found:
|
|
375
|
+
${paths.map((path2) => ` ${path2}`).join("\n")}`;
|
|
376
|
+
}
|
|
377
|
+
function getMinVersion() {
|
|
378
|
+
const picked = { "version": "0.25.0" };
|
|
379
|
+
if (typeof picked.version !== "string") {
|
|
380
|
+
throw new TypeError("nmr/package.json: 'version' must be a string");
|
|
381
|
+
}
|
|
382
|
+
return picked.version;
|
|
383
|
+
}
|
|
384
|
+
function importsSharedExport(content, exportName, moduleSpecifier) {
|
|
385
|
+
if (content === void 0) return false;
|
|
386
|
+
const pattern = new RegExp(String.raw`import\s*\{[^}]*\b${exportName}\b[^}]*\}\s*from\s*['"]${moduleSpecifier}['"]`);
|
|
387
|
+
return pattern.test(content);
|
|
388
|
+
}
|
|
389
|
+
function isReExportOnly(content) {
|
|
390
|
+
if (content === void 0) return false;
|
|
391
|
+
const statements = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "").split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
392
|
+
return statements.length > 0 && statements.every((line) => RE_EXPORT_LINE_PATTERN.test(line));
|
|
393
|
+
}
|
|
394
|
+
function noReExportOnlyVitestConfigs(cwd = process.cwd()) {
|
|
395
|
+
const nonRootConfigs = findFiles([`**/vitest.config.${CONFIG_EXTENSIONS}`], cwd).filter((path2) => path2.includes("/"));
|
|
396
|
+
const reExports = nonRootConfigs.filter((path2) => isReExportOnly(readFileIn(cwd, path2)));
|
|
397
|
+
if (reExports.length === 0) return true;
|
|
398
|
+
return { ok: false, detail: formatPaths(reExports) };
|
|
399
|
+
}
|
|
400
|
+
function noRedundantRootScripts() {
|
|
401
|
+
const pkg = readPackageJson();
|
|
402
|
+
if (!pkg) return true;
|
|
403
|
+
const scripts = pkg.scripts;
|
|
404
|
+
if (!isRecord(scripts)) return true;
|
|
405
|
+
const builtInNames = Object.keys(getDefaultRootScripts());
|
|
406
|
+
const redundant = Object.keys(scripts).filter((name) => builtInNames.includes(name));
|
|
407
|
+
if (redundant.length === 0) return true;
|
|
408
|
+
return {
|
|
409
|
+
ok: false,
|
|
410
|
+
detail: `redundant: ${redundant.join(", ")}`
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
function noRetiredVitestConfigs(cwd = process.cwd()) {
|
|
414
|
+
return checkNoMatchingFiles(
|
|
415
|
+
[`**/vitest.standalone.config.${CONFIG_EXTENSIONS}`, `**/vitest.integration.config.${CONFIG_EXTENSIONS}`],
|
|
416
|
+
cwd
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
function noWorkspaceRunScriptReferences() {
|
|
420
|
+
const packagesDir = join(process.cwd(), "packages");
|
|
421
|
+
if (!existsSync(packagesDir)) return true;
|
|
422
|
+
const legacyPattern = /run-workspace-script|"pnpm\s+run\s+ws\b/;
|
|
423
|
+
const entries = readdirSync2(packagesDir, { withFileTypes: true });
|
|
424
|
+
const matches = [];
|
|
425
|
+
for (const entry of entries) {
|
|
426
|
+
if (!entry.isDirectory()) continue;
|
|
427
|
+
const content = readFile(`packages/${entry.name}/package.json`);
|
|
428
|
+
if (content && legacyPattern.test(content)) {
|
|
429
|
+
matches.push(entry.name);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (matches.length === 0) return true;
|
|
433
|
+
return {
|
|
434
|
+
ok: false,
|
|
435
|
+
detail: `found in: ${matches.join(", ")}`
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
function readFileIn(cwd, relativePath) {
|
|
439
|
+
try {
|
|
440
|
+
return readFileSync(join(cwd, relativePath), "utf8");
|
|
441
|
+
} catch {
|
|
442
|
+
return void 0;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function scriptExists(name) {
|
|
446
|
+
const pkg = readPackageJson();
|
|
447
|
+
if (!pkg) return false;
|
|
448
|
+
const scripts = pkg.scripts;
|
|
449
|
+
return isRecord(scripts) && name in scripts;
|
|
450
|
+
}
|
|
451
|
+
function scriptMatches(name, pattern) {
|
|
452
|
+
const pkg = readPackageJson();
|
|
453
|
+
if (!pkg) return false;
|
|
454
|
+
const scripts = pkg.scripts;
|
|
455
|
+
if (!isRecord(scripts)) return false;
|
|
456
|
+
const value = scripts[name];
|
|
457
|
+
return typeof value === "string" && pattern.test(value);
|
|
458
|
+
}
|
|
459
|
+
function toolVersionsHasNoPnpm() {
|
|
460
|
+
const content = readFile(".tool-versions");
|
|
461
|
+
if (content === void 0) return true;
|
|
462
|
+
return !/^pnpm\s/m.test(content);
|
|
463
|
+
}
|
|
464
|
+
function vitestConfigBuildsOnSharedConfig(cwd = process.cwd()) {
|
|
465
|
+
return checkRootVitestConfig("vitest.config", "defineVitestConfig", cwd);
|
|
466
|
+
}
|
|
467
|
+
function vitestRootConfigBuildsOnSharedConfig(cwd = process.cwd()) {
|
|
468
|
+
return checkRootVitestConfig("vitest.root.config", "defineRootVitestConfig", cwd);
|
|
469
|
+
}
|
|
470
|
+
export {
|
|
471
|
+
codeQualityWorkflowDoesNotUseNmrPrepush,
|
|
472
|
+
default_default as default,
|
|
473
|
+
everyTestFileNamesItsTier,
|
|
474
|
+
noReExportOnlyVitestConfigs,
|
|
475
|
+
noRetiredVitestConfigs,
|
|
476
|
+
prettierConfigBuildsOnSharedConfig,
|
|
477
|
+
vitestConfigBuildsOnSharedConfig,
|
|
478
|
+
vitestRootConfigBuildsOnSharedConfig
|
|
479
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"kits": [
|
|
4
|
+
{
|
|
5
|
+
"checklists": [
|
|
6
|
+
"nmr"
|
|
7
|
+
],
|
|
8
|
+
"name": "default",
|
|
9
|
+
"path": "kits/default.js",
|
|
10
|
+
"readyupVersion": "0.23.0",
|
|
11
|
+
"source": "kits/default.ts",
|
|
12
|
+
"sourceHash": "f646e7fb",
|
|
13
|
+
"targetHash": "514c9b6f"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
package/README.md
CHANGED
|
@@ -3,47 +3,20 @@
|
|
|
3
3
|
Context-aware script runner for pnpm monorepos. Ships an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
|
|
4
4
|
|
|
5
5
|
<!-- section:release-notes -->
|
|
6
|
-
## Release notes — v0.
|
|
6
|
+
## Release notes — v0.25.0 (2026-08-03)
|
|
7
7
|
|
|
8
8
|
### 🎉 Features
|
|
9
9
|
|
|
10
|
-
-
|
|
10
|
+
- Mark a cache hit's time saving with a speed-boost icon (#579)
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Time saved due to `nmr`'s cache feature now earns a rocket emoji in the terminal output.
|
|
13
|
+
Savings less than 1 second are no longer reported.
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
- Publish nmr's readyup kit with a test-tier conformance check (#580)
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
`@williamthorsen/nmr` is now bundled with ReadyUp kits that check a repo's alignment with `nmr` requirements and recommendations.
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
- 🚨 **Breaking:** Redefine ci as the code-quality gate and add prepush (#559)
|
|
21
|
-
|
|
22
|
-
Repurposes the `nmr ci` command to align with its name; it now runs the code-quality checks that run in CI: `nmr build && nmr check:strict`. A new `nmr prepush` command, combining code-quality checks and the security audit, takes the place of `nmr ci`. nmr's readyup kit is adjusted to account for the changes.
|
|
23
|
-
|
|
24
|
-
- Exclude test scaffolding from builds and make the ignore set configurable (#564)
|
|
25
|
-
|
|
26
|
-
The `build` command now ignores the directories typically used for test scaffolding, such as fixtures and mocks. The exclusion includes `test-utils/`, which is treated as the conventional name for a directory containing test utilities. A package can declare additional ignore patterns in its config file.
|
|
27
|
-
|
|
28
|
-
- Skip checks that already passed on the current working tree (#568)
|
|
29
|
-
|
|
30
|
-
`nmr` commands now skip any pure check (types, lint, tests) that has already passed on an unchanged working tree; instead, success is reported immediately. `nmr --no-cache` causes the cache check to be skipped, and `NMR_NO_CACHE=1` does the same for a whole shell. Cached results are cleared by `nmr clean`. Which commands can skip is configurable at the monorepo root, and `NMR_DEBUG=1` reports why a run didn't skip.
|
|
31
|
-
|
|
32
|
-
### 🪦 Removed
|
|
33
|
-
|
|
34
|
-
- 🚨 **Breaking:** Remove the package root entry in favor of /config (#569)
|
|
35
|
-
|
|
36
|
-
`defineConfig` is now imported from `@williamthorsen/nmr/config` instead of the bare package.
|
|
37
|
-
|
|
38
|
-
### 🐛 Bug fixes
|
|
39
|
-
|
|
40
|
-
- Stop excluding *.types.ts and mock* files from coverage
|
|
41
|
-
|
|
42
|
-
Coverage now measures modules matching `*.types.ts` and `mock*`, which the shared Vitest config previously excluded. A type module that grows a type guard, or a mock helper placed in `test-utils/` where helpers are meant to stay measured, was left out of the report with nothing saying so, and because exclusions concatenate, no consuming repo could add it back.
|
|
43
|
-
|
|
44
|
-
What remains excluded names something that cannot hold runtime code by construction: a directory of test scaffolding, a barrel, and a declaration file. A pattern resting on a filename alone suppresses nothing it could claim to, since a file with no executable statements is absent from the report either way.
|
|
45
|
-
|
|
46
|
-
A repo enforcing its own coverage thresholds may see a lower figure once these files enter the denominator.
|
|
19
|
+
`rdy run --from npm:@williamthorsen/nmr` checks the repo against the kits without prior configuration. Once the package is added to the ReadyUp config, `rdy run --packages` will run the `nmr` kits along with those of any other registered package. Requires `readyup` v0.23 or greater.
|
|
47
20
|
<!-- /section:release-notes -->
|
|
48
21
|
|
|
49
22
|
## Installation
|
|
@@ -255,7 +228,7 @@ $ nmr ci
|
|
|
255
228
|
# ... four minutes of build, typecheck, lint, and coverage ...
|
|
256
229
|
|
|
257
230
|
$ nmr ci
|
|
258
|
-
⏭️ ci: passed 2m ago on this tree (saved ~4m). Re-run with --no-cache.
|
|
231
|
+
⏭️ ci: passed 2m ago on this tree (🚀 saved ~4m). Re-run with --no-cache.
|
|
259
232
|
```
|
|
260
233
|
|
|
261
234
|
Composite commands expand into child `nmr` processes, so one green `nmr ci` records a pass for every cacheable command in the chain, at every scope it ran at. A later `nmr check`, `nmr typecheck`, or `nmr -F core test` on the same tree skips too. The skip line is suppressed by `-q`.
|
|
@@ -707,6 +680,29 @@ ensure-prepublish-hooks
|
|
|
707
680
|
| `--dry-run` | Preview what `--fix` would do | — |
|
|
708
681
|
| `--command <command>` | Custom hook command | `"npm run build"` |
|
|
709
682
|
|
|
683
|
+
## Conformance checks
|
|
684
|
+
|
|
685
|
+
nmr publishes a `readyup` kit that checks a consuming repo against the current release: the shared Vitest and Prettier configs, the workspace layout, the root script registry, and the [test-tier convention](#test-tiers). The kit ships inside the package, so it checks against the nmr version installed rather than whatever a repository ref happens to point at, and a tier added or renamed in nmr reaches the repo on upgrade.
|
|
686
|
+
|
|
687
|
+
Add `readyup` as a devDependency, then name nmr in its config:
|
|
688
|
+
|
|
689
|
+
```ts
|
|
690
|
+
// .config/readyup.config.ts
|
|
691
|
+
import { defineRdyConfig } from 'readyup';
|
|
692
|
+
|
|
693
|
+
export default defineRdyConfig({
|
|
694
|
+
packages: ['@williamthorsen/nmr'],
|
|
695
|
+
});
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
```bash
|
|
699
|
+
rdy run --packages # every kit each listed package publishes
|
|
700
|
+
rdy run --from npm:@williamthorsen/nmr # nmr's kit alone, without the config entry
|
|
701
|
+
rdy list --from npm:@williamthorsen/nmr # what nmr publishes
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
`--packages` is the form that survives nmr publishing further kits. Both need `readyup` 0.23 or later, and `@williamthorsen/nmr` as a _direct_ devDependency: a strict pnpm layout links nothing else into the project, so a transitive copy is unreachable.
|
|
705
|
+
|
|
710
706
|
## Consumer migration
|
|
711
707
|
|
|
712
708
|
After installing, a consuming repo's root `package.json` scripts shrink to lifecycle hooks:
|
|
@@ -833,7 +829,9 @@ vitest # every project
|
|
|
833
829
|
|
|
834
830
|
**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.
|
|
835
831
|
|
|
836
|
-
|
|
832
|
+
**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`.
|
|
833
|
+
|
|
834
|
+
`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. [nmr's readyup kit](#conformance-checks) reports those files, which is the only thing that does.
|
|
837
835
|
|
|
838
836
|
`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.
|
|
839
837
|
|
|
@@ -934,11 +932,11 @@ This variant reads `pnpm-workspace.yaml` and excludes every workspace package fr
|
|
|
934
932
|
|
|
935
933
|
The projects were once named `unit`, `integration`, and `app`, for what a test _covered_ rather than what it _reached_. `integration` and `app` are gone.
|
|
936
934
|
|
|
937
|
-
1. Rename every `*.int.test.ts`. A test that reaches a program the environment supplies becomes `*.tool.test.ts`; one that reaches nothing beyond the test process
|
|
938
|
-
2.
|
|
935
|
+
1. Rename every `*.int.test.ts`. A test that reaches a program the environment supplies becomes `*.tool.test.ts`; one that reaches nothing beyond the test process becomes `*.unit.test.ts`. Re-read each file rather than renaming in bulk -- the axis changed, so the old bucket does not map onto one new tier.
|
|
936
|
+
2. Rename every `*.app.test.ts` onto the convention. `app` was never a tier, so keep it as the aspect segment and add the tier after it: `scaffold.app.test.ts` becomes `scaffold.app.unit.test.ts`.
|
|
939
937
|
3. Replace `nmr test:integration` with `nmr test:tool`, and `nmr root:test:integration` with `nmr root:test:tool`, wherever a script or workflow names them.
|
|
940
938
|
|
|
941
|
-
**
|
|
939
|
+
**A file left without a tier is a silent failure.** The residual `unit` claims it and it runs in the default gate -- green, with nothing reporting that the separation was lost. [nmr's readyup kit](#conformance-checks) reports every such file; a passing test run does not. Run it before treating the migration as done.
|
|
942
940
|
|
|
943
941
|
`nmr test` also runs the `tool` tier, which the old `test` excluded. Those tests previously ran in no default selection at all: `check`, `check:strict`, and `ci` all inherited the exclusion, so a green pipeline said nothing about them. Expect `nmr test` to take longer and to surface failures that were never gated.
|
|
944
942
|
|
package/dist/esm/check-cache.js
CHANGED
|
@@ -5,6 +5,7 @@ import process from 'node:process';
|
|
|
5
5
|
import { hashWorkingTree, readHeadSha, readJsonCacheEntry, removeCacheDir, resolveCacheEntryPath, writeCacheEntry, } from '@williamthorsen/nmr-core';
|
|
6
6
|
import { hasBuildOutput, readBuildDigest } from "./commands/build-output.js";
|
|
7
7
|
import { loadWorkspaceConfig } from "./config.js";
|
|
8
|
+
import { formatDuration, formatSaving } from "./helpers/duration.js";
|
|
8
9
|
import { isObject, isStringRecord } from "./helpers/type-guards.js";
|
|
9
10
|
import { getDefaultWorkspaceScripts } from "./resolve-scripts.js";
|
|
10
11
|
import { buildWorkspaceRegistry, resolveScript } from "./resolver.js";
|
|
@@ -91,8 +92,9 @@ export function formatMisplacedNoCacheWarning(command) {
|
|
|
91
92
|
}
|
|
92
93
|
export function formatSkipLine(command, entry, now) {
|
|
93
94
|
const age = formatDuration(Math.max(0, now - Date.parse(entry.recordedAt)));
|
|
94
|
-
const
|
|
95
|
-
|
|
95
|
+
const saving = formatSaving(entry.durationMs);
|
|
96
|
+
const savingClause = saving === undefined ? '' : ` (${saving})`;
|
|
97
|
+
return `⏭️ ${command}: passed ${age} ago on this tree${savingClause}. Re-run with --no-cache.`;
|
|
96
98
|
}
|
|
97
99
|
export async function readBuildOutputState(monorepoRoot, config) {
|
|
98
100
|
const state = { missing: [], digests: {} };
|
|
@@ -165,9 +167,6 @@ export function writeDebugNote(message, env, stderr) {
|
|
|
165
167
|
stderr.write(`nmr check-cache: ${message}\n`);
|
|
166
168
|
}
|
|
167
169
|
}
|
|
168
|
-
const MILLISECONDS_PER_SECOND = 1000;
|
|
169
|
-
const SECONDS_PER_MINUTE = 60;
|
|
170
|
-
const MINUTES_PER_HOUR = 60;
|
|
171
170
|
function decodeTreeSnapshot(encoded) {
|
|
172
171
|
if (encoded === undefined) {
|
|
173
172
|
return undefined;
|
|
@@ -178,17 +177,6 @@ function decodeTreeSnapshot(encoded) {
|
|
|
178
177
|
}
|
|
179
178
|
return { hash, headSha };
|
|
180
179
|
}
|
|
181
|
-
function formatDuration(milliseconds) {
|
|
182
|
-
const seconds = Math.round(milliseconds / MILLISECONDS_PER_SECOND);
|
|
183
|
-
if (seconds < SECONDS_PER_MINUTE) {
|
|
184
|
-
return `${seconds}s`;
|
|
185
|
-
}
|
|
186
|
-
const minutes = Math.round(seconds / SECONDS_PER_MINUTE);
|
|
187
|
-
if (minutes < MINUTES_PER_HOUR) {
|
|
188
|
-
return `${minutes}m`;
|
|
189
|
-
}
|
|
190
|
-
return `${Math.round(minutes / MINUTES_PER_HOUR)}h`;
|
|
191
|
-
}
|
|
192
180
|
function isCheckCacheEntry(value) {
|
|
193
181
|
if (!isObject(value)) {
|
|
194
182
|
return false;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function formatDuration(milliseconds) {
|
|
2
|
+
const seconds = Math.round(milliseconds / MILLISECONDS_PER_SECOND);
|
|
3
|
+
if (seconds < SECONDS_PER_MINUTE) {
|
|
4
|
+
return `${seconds}s`;
|
|
5
|
+
}
|
|
6
|
+
const minutes = Math.round(seconds / SECONDS_PER_MINUTE);
|
|
7
|
+
if (minutes < MINUTES_PER_HOUR) {
|
|
8
|
+
return `${minutes}m`;
|
|
9
|
+
}
|
|
10
|
+
return `${Math.round(minutes / MINUTES_PER_HOUR)}h`;
|
|
11
|
+
}
|
|
12
|
+
export function formatSaving(milliseconds) {
|
|
13
|
+
if (!Number.isFinite(milliseconds) || milliseconds < MILLISECONDS_PER_SECOND) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
return `${SPEED_BOOST_ICON} saved ~${formatDuration(milliseconds)}`;
|
|
17
|
+
}
|
|
18
|
+
const MILLISECONDS_PER_SECOND = 1000;
|
|
19
|
+
const MINUTES_PER_HOUR = 60;
|
|
20
|
+
const SECONDS_PER_MINUTE = 60;
|
|
21
|
+
const SPEED_BOOST_ICON = '🚀';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const ALL_TEST_PATTERNS: string[];
|
|
2
|
+
export declare function buildTierPatterns(tier: string): string[];
|
|
3
|
+
export declare function findTestFiles(rootDir: string): string[];
|
|
4
|
+
export declare function hasTierInfix(filePath: string): boolean;
|
|
5
|
+
export declare const TEST_COLLECTION_EXCLUDE: string[];
|
|
6
|
+
export declare const TIER_NAMES: readonly ["unit", "tool", "localhost", "remote"];
|
|
7
|
+
export type TierName = (typeof TIER_NAMES)[number];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const TEST_DIR = '__tests__';
|
|
4
|
+
const TEST_EXTENSIONS = '{ts,tsx}';
|
|
5
|
+
const TEST_GLOB_PREFIX = `**/${TEST_DIR}/**`;
|
|
6
|
+
const TEST_FILE_PATTERN = /\.test\.tsx?$/;
|
|
7
|
+
export const ALL_TEST_PATTERNS = [`${TEST_GLOB_PREFIX}/*.test.${TEST_EXTENSIONS}`];
|
|
8
|
+
export function buildTierPatterns(tier) {
|
|
9
|
+
return [`${TEST_GLOB_PREFIX}/*.${tier}.test.${TEST_EXTENSIONS}`];
|
|
10
|
+
}
|
|
11
|
+
export function findTestFiles(rootDir) {
|
|
12
|
+
const found = [];
|
|
13
|
+
collectTestFiles(rootDir, '', false, found);
|
|
14
|
+
return found.toSorted();
|
|
15
|
+
}
|
|
16
|
+
export function hasTierInfix(filePath) {
|
|
17
|
+
const tiers = TIER_NAMES;
|
|
18
|
+
return tiers.includes(path.basename(filePath).split('.').at(-3) ?? '');
|
|
19
|
+
}
|
|
20
|
+
export const TEST_COLLECTION_EXCLUDE = ['.git', 'coverage', 'dist', 'node_modules'];
|
|
21
|
+
export const TIER_NAMES = ['unit', 'tool', 'localhost', 'remote'];
|
|
22
|
+
function collectTestFiles(dir, relativeDir, inTestDir, found) {
|
|
23
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
const relativePath = relativeDir === '' ? entry.name : `${relativeDir}/${entry.name}`;
|
|
26
|
+
if (entry.isDirectory()) {
|
|
27
|
+
if (TEST_COLLECTION_EXCLUDE.includes(entry.name))
|
|
28
|
+
continue;
|
|
29
|
+
collectTestFiles(path.join(dir, entry.name), relativePath, inTestDir || entry.name === TEST_DIR, found);
|
|
30
|
+
}
|
|
31
|
+
else if (inTestDir && TEST_FILE_PATTERN.test(entry.name)) {
|
|
32
|
+
found.push(relativePath);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
package/dist/esm/vitest.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ViteUserConfig } from 'vitest/config';
|
|
2
2
|
import type { InlineConfig, ProjectConfig } from 'vitest/node';
|
|
3
|
+
import type { TierName } from './tiers.js';
|
|
4
|
+
export type { TierName } from './tiers.js';
|
|
3
5
|
export type RootTestOptions = Omit<InlineConfig, keyof ProjectConfig>;
|
|
4
6
|
export type RootOverrides = Omit<ViteUserConfig, 'test'> & {
|
|
5
7
|
test?: RootTestOptions;
|
|
@@ -13,8 +15,5 @@ export interface RootVitestConfigOptions extends VitestConfigOptions {
|
|
|
13
15
|
monorepoRoot: string;
|
|
14
16
|
}
|
|
15
17
|
type RootConfigLayers = [...(VitestConfigOptions | undefined)[], RootVitestConfigOptions];
|
|
16
|
-
declare const TIER_NAMES: readonly ["unit", "tool", "localhost", "remote"];
|
|
17
|
-
export type TierName = (typeof TIER_NAMES)[number];
|
|
18
18
|
export declare function defineVitestConfig(...layers: (VitestConfigOptions | undefined)[]): ViteUserConfig;
|
|
19
19
|
export declare function defineRootVitestConfig(...layers: RootConfigLayers): ViteUserConfig;
|
|
20
|
-
export {};
|
package/dist/esm/vitest.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { defaultExclude, mergeConfig } from 'vitest/config';
|
|
3
3
|
import { isObject } from "./helpers/type-guards.js";
|
|
4
|
+
import { ALL_TEST_PATTERNS, buildTierPatterns, TEST_COLLECTION_EXCLUDE, TIER_NAMES } from "./tiers.js";
|
|
4
5
|
import { getWorkspacePackageDirs } from "./workspace.js";
|
|
5
|
-
const TIER_NAMES = ['unit', 'tool', 'localhost', 'remote'];
|
|
6
6
|
const [RESIDUAL_TIER, ...NAMED_TIERS] = TIER_NAMES;
|
|
7
7
|
const TIERED_PATTERNS = NAMED_TIERS.flatMap(buildTierPatterns);
|
|
8
8
|
const TIER_TIMEOUT = 30_000;
|
|
9
|
-
const ALL_TEST_PATTERNS = ['**/__tests__/**/*.test.{ts,tsx}'];
|
|
10
9
|
const COVERAGE_EXCLUDE = ['**/__{fixtures,mocks,tests}__/**', '**/index.ts', '**/*.d.ts'];
|
|
11
|
-
const
|
|
10
|
+
const COLLECTION_EXCLUDE = [...new Set([...defaultExclude, ...TEST_COLLECTION_EXCLUDE.map((dir) => `**/${dir}/**`)])];
|
|
12
11
|
const PACKAGE_COVERAGE_INCLUDE = ['**/src/**/*.{ts,tsx}'];
|
|
13
12
|
const MISSING_MONOREPO_ROOT = 'defineRootVitestConfig requires `monorepoRoot`, an absolute path to the directory holding pnpm-workspace.yaml. Pass `import.meta.dirname` in the last options layer, from the root config.';
|
|
14
13
|
export function defineVitestConfig(...layers) {
|
|
@@ -60,7 +59,7 @@ function buildProjects(layers, extraExclude, projectRoot) {
|
|
|
60
59
|
extends: true,
|
|
61
60
|
...(projectRoot !== undefined && { root: projectRoot }),
|
|
62
61
|
test: {
|
|
63
|
-
exclude: [...
|
|
62
|
+
exclude: [...COLLECTION_EXCLUDE, ...exclude, ...extraExclude],
|
|
64
63
|
include,
|
|
65
64
|
name,
|
|
66
65
|
...(timeout !== undefined && { hookTimeout: timeout, testTimeout: timeout }),
|
|
@@ -84,9 +83,6 @@ function assertKnownTiers(layers) {
|
|
|
84
83
|
}
|
|
85
84
|
}
|
|
86
85
|
}
|
|
87
|
-
function buildTierPatterns(tier) {
|
|
88
|
-
return [`**/__tests__/**/*.${tier}.test.{ts,tsx}`];
|
|
89
|
-
}
|
|
90
86
|
function getWorkspaceExcludePatterns(monorepoRoot) {
|
|
91
87
|
return getWorkspacePackageDirs(monorepoRoot)
|
|
92
88
|
.map((dir) => `${path.relative(monorepoRoot, dir).split(path.sep).join('/')}/**`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@williamthorsen/nmr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Context-aware script runner for pnpm monorepos",
|
|
6
6
|
"keywords": [
|
|
@@ -58,6 +58,8 @@
|
|
|
58
58
|
"nmr-taze": "bin/nmr-taze.js"
|
|
59
59
|
},
|
|
60
60
|
"files": [
|
|
61
|
+
".readyup/kits/*.js",
|
|
62
|
+
".readyup/manifest.json",
|
|
61
63
|
"AGENTS.md",
|
|
62
64
|
"bin",
|
|
63
65
|
"dist"
|
|
@@ -67,10 +69,11 @@
|
|
|
67
69
|
"prettier-plugin-sh": "0.19.0",
|
|
68
70
|
"taze": "19.16.0",
|
|
69
71
|
"yaml": "2.9.0",
|
|
70
|
-
"@williamthorsen/nmr-core": "0.
|
|
72
|
+
"@williamthorsen/nmr-core": "0.9.0"
|
|
71
73
|
},
|
|
72
74
|
"devDependencies": {
|
|
73
75
|
"prettier": "3.9.6",
|
|
76
|
+
"readyup": "0.23.0",
|
|
74
77
|
"tinyglobby": "0.2.17",
|
|
75
78
|
"vitest": "4.1.10"
|
|
76
79
|
},
|
|
@@ -94,6 +97,6 @@
|
|
|
94
97
|
"access": "public"
|
|
95
98
|
},
|
|
96
99
|
"scripts": {
|
|
97
|
-
"build:post": "nmr-sync-agent-files"
|
|
100
|
+
"build:post": "nmr-sync-agent-files && rdy compile"
|
|
98
101
|
}
|
|
99
102
|
}
|