@williamthorsen/nmr 0.24.0 → 0.26.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.
@@ -0,0 +1,467 @@
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
+ // Without `--include-locked`, nothing would be reported in a repo that pins exact version numbers.
44
+ upgrade: "nmr-taze --include-locked",
45
+ "view-coverage": "open coverage/index.html"
46
+ };
47
+ var rootScripts = {
48
+ audit: ["audit:prod", "audit:dev"],
49
+ "audit:dev": "pnpm exec v11y --dev",
50
+ "audit:prod": "pnpm exec v11y --prod",
51
+ build: "pnpm --recursive exec nmr build",
52
+ check: ["typecheck", "fmt:check", "lint:check", "test"],
53
+ "check:strict": ["typecheck", "fmt:check", "lint:strict", "test:coverage"],
54
+ // Excludes the audit, which in CI has a workflow of its own.
55
+ ci: ["build", "check:strict"],
56
+ clean: "nmr-clean",
57
+ fix: ["lint", "fmt"],
58
+ "fix:check": ["fmt:check", "lint:check"],
59
+ fmt: "nmr-fmt --write",
60
+ "fmt:check": "nmr-fmt --check",
61
+ lint: "nmr root:lint && pnpm --recursive exec nmr lint",
62
+ "lint:check": "nmr root:lint:check && pnpm --recursive exec nmr lint:check",
63
+ "lint:strict": "nmr root:lint:strict && pnpm --recursive exec nmr lint:strict",
64
+ prepush: ["ci", "audit"],
65
+ "report-overrides": "nmr-report-overrides",
66
+ "root:check": ["root:typecheck", "fmt:check", "root:lint:check", "root:test"],
67
+ "root:lint": "eslint --fix --ignore-pattern 'packages/**' .",
68
+ "root:lint:check": "eslint --ignore-pattern 'packages/**' .",
69
+ "root:lint:strict": "strict-lint --ignore-pattern 'packages/**' .",
70
+ "root:test": `vitest --config ./vitest.root.config.ts ${GATE_PROJECTS}`,
71
+ "root:test:all": "vitest --config ./vitest.root.config.ts",
72
+ "root:test:tool": "vitest --config ./vitest.root.config.ts --project tool",
73
+ "root:test:unit": "vitest --config ./vitest.root.config.ts --project unit",
74
+ "root:typecheck": "tsgo --noEmit",
75
+ "root:upgrade": "nmr-taze --include-locked",
76
+ test: "nmr root:test && pnpm --recursive exec nmr test",
77
+ "test:all": "nmr root:test:all && pnpm --recursive exec nmr test:all",
78
+ "test:coverage": "nmr root:test && pnpm --recursive exec nmr test:coverage",
79
+ "test:tool": "nmr root:test:tool && pnpm --recursive exec nmr test:tool",
80
+ "test:unit": "nmr root:test:unit && pnpm --recursive exec nmr test:unit",
81
+ // Bare `vitest` at the monorepo root resolves the root `vitest.config.ts`, covering the whole tree in one
82
+ // process; a chain like the others would never advance past its first watcher.
83
+ "test:watch": `vitest ${GATE_PROJECTS} --watch`,
84
+ typecheck: "nmr root:typecheck && pnpm --recursive exec nmr typecheck",
85
+ // A string rather than a composite: Passthrough args attach to the chain's last command.
86
+ upgrade: "nmr-report-overrides && nmr-taze --include-locked --recursive"
87
+ };
88
+
89
+ // src/resolve-scripts.ts
90
+ function getDefaultRootScripts() {
91
+ return { ...rootScripts };
92
+ }
93
+
94
+ // src/tiers.ts
95
+ import { readdirSync } from "node:fs";
96
+ import path from "node:path";
97
+ var TEST_DIR = "__tests__";
98
+ var TEST_EXTENSIONS = "{ts,tsx}";
99
+ var TEST_GLOB_PREFIX = `**/${TEST_DIR}/**`;
100
+ var TEST_FILE_PATTERN = /\.test\.tsx?$/;
101
+ var ALL_TEST_PATTERNS = [`${TEST_GLOB_PREFIX}/*.test.${TEST_EXTENSIONS}`];
102
+ function findTestFiles(rootDir) {
103
+ const found = [];
104
+ collectTestFiles(rootDir, "", false, found);
105
+ return found.toSorted();
106
+ }
107
+ function hasTierInfix(filePath) {
108
+ const tiers = TIER_NAMES;
109
+ return tiers.includes(path.basename(filePath).split(".").at(-3) ?? "");
110
+ }
111
+ var TEST_COLLECTION_EXCLUDE = [".git", "coverage", "dist", "node_modules"];
112
+ var TIER_NAMES = ["unit", "tool", "localhost", "remote"];
113
+ function collectTestFiles(dir, relativeDir, inTestDir, found) {
114
+ const entries = readdirSync(dir, { withFileTypes: true });
115
+ for (const entry of entries) {
116
+ const relativePath = relativeDir === "" ? entry.name : `${relativeDir}/${entry.name}`;
117
+ if (entry.isDirectory()) {
118
+ if (TEST_COLLECTION_EXCLUDE.includes(entry.name)) continue;
119
+ collectTestFiles(path.join(dir, entry.name), relativePath, inTestDir || entry.name === TEST_DIR, found);
120
+ } else if (inTestDir && TEST_FILE_PATTERN.test(entry.name)) {
121
+ found.push(relativePath);
122
+ }
123
+ }
124
+ }
125
+
126
+ // .readyup/kits/default.ts
127
+ var default_default = defineRdyKit({
128
+ checklists: [
129
+ {
130
+ name: "nmr",
131
+ checks: [
132
+ // -- Setup ---------------------------------------------------------------
133
+ {
134
+ name: "@williamthorsen/nmr in devDependencies",
135
+ severity: "error",
136
+ check: () => hasDevDependency("@williamthorsen/nmr"),
137
+ fix: "pnpm add --save-dev @williamthorsen/nmr",
138
+ checks: [
139
+ {
140
+ get name() {
141
+ return `@williamthorsen/nmr >= ${getMinVersion()}`;
142
+ },
143
+ severity: "error",
144
+ check: () => hasMinDevDependencyVersion("@williamthorsen/nmr", getMinVersion(), {
145
+ exempt: (range) => range.startsWith("workspace:")
146
+ }),
147
+ get fix() {
148
+ return `pnpm add --save-dev @williamthorsen/nmr@^${getMinVersion()}`;
149
+ }
150
+ }
151
+ ]
152
+ },
153
+ {
154
+ name: "pnpm-workspace.yaml exists",
155
+ severity: "error",
156
+ check: () => fileExists("pnpm-workspace.yaml"),
157
+ fix: "Create pnpm-workspace.yaml with workspace package globs"
158
+ },
159
+ {
160
+ name: "package.json has packageManager field",
161
+ severity: "warn",
162
+ check: () => hasPackageJsonField("packageManager"),
163
+ fix: 'Add "packageManager" field to package.json (e.g., "pnpm@10.33.0")'
164
+ },
165
+ {
166
+ name: ".tool-versions does not list pnpm",
167
+ severity: "warn",
168
+ check: toolVersionsHasNoPnpm,
169
+ fix: "Remove pnpm from .tool-versions \u2014 manage via packageManager field and corepack"
170
+ },
171
+ {
172
+ name: ".config/nmr.config.ts uses defineConfig",
173
+ severity: "recommend",
174
+ skip: () => !fileExists(".config/nmr.config.ts") ? "no nmr config file" : false,
175
+ check: () => fileContains(".config/nmr.config.ts", /defineConfig/),
176
+ fix: "Wrap your config export with defineConfig() from @williamthorsen/nmr/config for type safety"
177
+ },
178
+ // -- Root script cleanup -------------------------------------------------
179
+ {
180
+ name: "root package.json has no nmr-provided scripts",
181
+ severity: "warn",
182
+ check: noRedundantRootScripts,
183
+ fix: "Remove scripts from root package.json that nmr provides as built-in root scripts \u2014 invoke via nmr directly"
184
+ },
185
+ {
186
+ name: "root:lint:strict does not use echo fallback",
187
+ severity: "warn",
188
+ skip: () => !scriptExists("root:lint:strict") ? "no root:lint:strict script" : false,
189
+ check: () => !scriptMatches("root:lint:strict", /\becho\b/),
190
+ fix: "Replace the echo fallback in root:lint:strict \u2014 strict-lint now supports path arguments"
191
+ },
192
+ // -- Workspace build readiness -------------------------------------------
193
+ {
194
+ name: "all workspace packages can build",
195
+ severity: "warn",
196
+ check: allWorkspacePackagesCanBuild,
197
+ 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`
198
+ },
199
+ // -- Vitest projects -----------------------------------------------------
200
+ {
201
+ name: "no retired Vitest config variants",
202
+ severity: "error",
203
+ check: () => noRetiredVitestConfigs(),
204
+ fix: "Delete every vitest.standalone.config.* and vitest.integration.config.*. nmr's test scripts select Vitest projects instead of naming config files"
205
+ },
206
+ {
207
+ name: "vitest.config.ts builds on @williamthorsen/nmr/vitest",
208
+ severity: "error",
209
+ check: () => vitestConfigBuildsOnSharedConfig(),
210
+ fix: "Replace vitest.config.ts with: import { defineVitestConfig } from '@williamthorsen/nmr/vitest'; export default defineVitestConfig();"
211
+ },
212
+ {
213
+ name: "vitest.root.config.ts builds on @williamthorsen/nmr/vitest",
214
+ severity: "error",
215
+ check: () => vitestRootConfigBuildsOnSharedConfig(),
216
+ fix: "Replace vitest.root.config.ts with: import { defineRootVitestConfig } from '@williamthorsen/nmr/vitest'; export default defineRootVitestConfig({ monorepoRoot: import.meta.dirname });"
217
+ },
218
+ {
219
+ name: "every test file names its isolation tier",
220
+ severity: "error",
221
+ check: () => everyTestFileNamesItsTier(),
222
+ 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`
223
+ },
224
+ {
225
+ name: "no package re-exports the ancestor Vitest config",
226
+ severity: "recommend",
227
+ check: () => noReExportOnlyVitestConfigs(),
228
+ fix: "Delete these files. Vitest resolves config by walking up from the run root, so a per-package re-export is redundant"
229
+ },
230
+ // -- Shared Prettier config ----------------------------------------------
231
+ {
232
+ name: "Prettier config builds on @williamthorsen/nmr/prettier",
233
+ severity: "error",
234
+ check: () => prettierConfigBuildsOnSharedConfig(),
235
+ fix: "Replace the Prettier config with: import { definePrettierConfig } from '@williamthorsen/nmr/prettier'; export default definePrettierConfig();"
236
+ },
237
+ // -- Audit dependency --------------------------------------------------------
238
+ {
239
+ name: "v11y-check in devDependencies",
240
+ severity: "warn",
241
+ check: () => hasDevDependency("v11y-check"),
242
+ fix: "pnpm add --save-dev v11y-check"
243
+ },
244
+ {
245
+ name: "code-quality workflow does not use nmr prepush",
246
+ severity: "warn",
247
+ skip: () => !fileExists(".github/workflows/code-quality.yaml") ? "no code-quality workflow" : false,
248
+ check: codeQualityWorkflowDoesNotUseNmrPrepush,
249
+ 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'
250
+ },
251
+ // -- Legacy script runner ------------------------------------------------
252
+ {
253
+ name: "scripts/run-workspace-script.ts does not exist",
254
+ severity: "error",
255
+ check: () => !fileExists("scripts/run-workspace-script.ts"),
256
+ fix: "Delete scripts/run-workspace-script.ts \u2014 nmr replaces this custom script runner"
257
+ },
258
+ {
259
+ name: 'no workspace packages reference run-workspace-script or "pnpm run ws"',
260
+ severity: "error",
261
+ check: noWorkspaceRunScriptReferences,
262
+ fix: 'Remove "ws" script entries and replace any "pnpm run ws" invocations with nmr in each packages/*/package.json'
263
+ }
264
+ ]
265
+ }
266
+ ]
267
+ });
268
+ var SCAN_EXCLUDE_DIRS = /* @__PURE__ */ new Set([".git", "coverage", "dist", "node_modules"]);
269
+ var CONFIG_EXTENSIONS = "{ts,mts,cts,js,mjs,cjs}";
270
+ var SHARED_VITEST_MODULE = "@williamthorsen/nmr/vitest";
271
+ var SHARED_PRETTIER_MODULE = "@williamthorsen/nmr/prettier";
272
+ var INERT_PRETTIER_CONFIGS = [".prettierrc", ".prettierrc.{json,json5,yaml,yml,toml}"];
273
+ var RE_EXPORT_LINE_PATTERN = /^export\s*(?:\{\s*default\s*\}|\*)\s*from\s*['"]\.\.\/[^'"]*['"];?$/;
274
+ function allWorkspacePackagesCanBuild() {
275
+ const packagesDir = join(process.cwd(), "packages");
276
+ if (!existsSync(packagesDir)) return true;
277
+ const entries = readdirSync2(packagesDir, { withFileTypes: true });
278
+ const failing = [];
279
+ for (const entry of entries) {
280
+ if (!entry.isDirectory()) continue;
281
+ const pkgPath = `packages/${entry.name}/package.json`;
282
+ const content = readFile(pkgPath);
283
+ if (!content) continue;
284
+ const hasBuildOverride = /"build"\s*:/.test(content);
285
+ const hasDefaultBuildInputs = fileExists(`packages/${entry.name}/tsconfig.json`) && existsSync(join(packagesDir, entry.name, "src"));
286
+ if (!hasBuildOverride && !hasDefaultBuildInputs) {
287
+ failing.push(entry.name);
288
+ }
289
+ }
290
+ if (failing.length === 0) return true;
291
+ return {
292
+ ok: false,
293
+ detail: `missing build override or tsconfig.json + src/: ${failing.join(", ")}`
294
+ };
295
+ }
296
+ function checkNoMatchingFiles(patterns, cwd) {
297
+ const found = findFiles(patterns, cwd);
298
+ if (found.length === 0) return true;
299
+ return { ok: false, detail: formatPaths(found) };
300
+ }
301
+ function checkRootVitestConfig(baseName, exportName, cwd) {
302
+ const matches = findFiles([`${baseName}.${CONFIG_EXTENSIONS}`], cwd);
303
+ if (matches.length === 0) {
304
+ return { ok: false, detail: `${baseName}.ts is missing` };
305
+ }
306
+ const stale = matches.filter(
307
+ (relativePath) => !importsSharedExport(readFileIn(cwd, relativePath), exportName, SHARED_VITEST_MODULE)
308
+ );
309
+ if (stale.length === 0) return true;
310
+ return { ok: false, detail: `does not import ${exportName} from ${SHARED_VITEST_MODULE}: ${stale.join(", ")}` };
311
+ }
312
+ function prettierConfigBuildsOnSharedConfig(cwd = process.cwd()) {
313
+ const configs = findFiles(
314
+ [`.prettierrc.${CONFIG_EXTENSIONS}`, `prettier.config.${CONFIG_EXTENSIONS}`],
315
+ //
316
+ cwd
317
+ );
318
+ if (configs.length === 0) {
319
+ return { ok: false, detail: describeMissingPrettierConfig(cwd) };
320
+ }
321
+ const stale = configs.filter(
322
+ (relativePath) => !importsSharedExport(readFileIn(cwd, relativePath), "definePrettierConfig", SHARED_PRETTIER_MODULE)
323
+ );
324
+ if (stale.length === 0) return true;
325
+ return {
326
+ ok: false,
327
+ detail: `does not import definePrettierConfig from ${SHARED_PRETTIER_MODULE}: ${stale.join(", ")}`
328
+ };
329
+ }
330
+ function describeMissingPrettierConfig(cwd) {
331
+ const inert = findFiles(INERT_PRETTIER_CONFIGS, cwd);
332
+ if (inert.length > 0) return `holds no code to call the factory: ${inert.join(", ")}`;
333
+ if (hasPrettierConfigKey(cwd)) {
334
+ return 'holds no code to call the factory: the "prettier" key in package.json';
335
+ }
336
+ return ".prettierrc.js is missing";
337
+ }
338
+ function hasPrettierConfigKey(cwd) {
339
+ const manifest = readFileIn(cwd, "package.json");
340
+ if (manifest === void 0) return false;
341
+ try {
342
+ const parsed = JSON.parse(manifest);
343
+ return isRecord(parsed) && parsed.prettier !== void 0;
344
+ } catch {
345
+ return false;
346
+ }
347
+ }
348
+ function codeQualityWorkflowDoesNotUseNmrPrepush() {
349
+ const content = readFile(".github/workflows/code-quality.yaml");
350
+ if (content === void 0) return true;
351
+ return !/check-command:\s*pnpm exec nmr prepush(\s|$)/.test(content);
352
+ }
353
+ function everyTestFileNamesItsTier(cwd = process.cwd()) {
354
+ const untiered = findTestFiles(cwd).filter((path2) => !hasTierInfix(path2));
355
+ if (untiered.length === 0) return true;
356
+ return { ok: false, detail: formatPaths(untiered) };
357
+ }
358
+ function findFiles(patterns, cwd) {
359
+ return globSync(patterns, { cwd, exclude: (path2) => SCAN_EXCLUDE_DIRS.has(basename(path2)) }).map((path2) => path2.split(sep).join("/")).toSorted();
360
+ }
361
+ function formatPaths(paths) {
362
+ return `${paths.length} found:
363
+ ${paths.map((path2) => ` ${path2}`).join("\n")}`;
364
+ }
365
+ function getMinVersion() {
366
+ const picked = { "version": "0.26.0" };
367
+ if (typeof picked.version !== "string") {
368
+ throw new TypeError("nmr/package.json: 'version' must be a string");
369
+ }
370
+ return picked.version;
371
+ }
372
+ function importsSharedExport(content, exportName, moduleSpecifier) {
373
+ if (content === void 0) return false;
374
+ const pattern = new RegExp(String.raw`import\s*\{[^}]*\b${exportName}\b[^}]*\}\s*from\s*['"]${moduleSpecifier}['"]`);
375
+ return pattern.test(content);
376
+ }
377
+ function isReExportOnly(content) {
378
+ if (content === void 0) return false;
379
+ const statements = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "").split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
380
+ return statements.length > 0 && statements.every((line) => RE_EXPORT_LINE_PATTERN.test(line));
381
+ }
382
+ function noReExportOnlyVitestConfigs(cwd = process.cwd()) {
383
+ const nonRootConfigs = findFiles([`**/vitest.config.${CONFIG_EXTENSIONS}`], cwd).filter((path2) => path2.includes("/"));
384
+ const reExports = nonRootConfigs.filter((path2) => isReExportOnly(readFileIn(cwd, path2)));
385
+ if (reExports.length === 0) return true;
386
+ return { ok: false, detail: formatPaths(reExports) };
387
+ }
388
+ function noRedundantRootScripts() {
389
+ const pkg = readPackageJson();
390
+ if (!pkg) return true;
391
+ const scripts = pkg.scripts;
392
+ if (!isRecord(scripts)) return true;
393
+ const builtInNames = Object.keys(getDefaultRootScripts());
394
+ const redundant = Object.keys(scripts).filter((name) => builtInNames.includes(name));
395
+ if (redundant.length === 0) return true;
396
+ return {
397
+ ok: false,
398
+ detail: `redundant: ${redundant.join(", ")}`
399
+ };
400
+ }
401
+ function noRetiredVitestConfigs(cwd = process.cwd()) {
402
+ return checkNoMatchingFiles(
403
+ [`**/vitest.standalone.config.${CONFIG_EXTENSIONS}`, `**/vitest.integration.config.${CONFIG_EXTENSIONS}`],
404
+ cwd
405
+ );
406
+ }
407
+ function noWorkspaceRunScriptReferences() {
408
+ const packagesDir = join(process.cwd(), "packages");
409
+ if (!existsSync(packagesDir)) return true;
410
+ const legacyPattern = /run-workspace-script|"pnpm\s+run\s+ws\b/;
411
+ const entries = readdirSync2(packagesDir, { withFileTypes: true });
412
+ const matches = [];
413
+ for (const entry of entries) {
414
+ if (!entry.isDirectory()) continue;
415
+ const content = readFile(`packages/${entry.name}/package.json`);
416
+ if (content && legacyPattern.test(content)) {
417
+ matches.push(entry.name);
418
+ }
419
+ }
420
+ if (matches.length === 0) return true;
421
+ return {
422
+ ok: false,
423
+ detail: `found in: ${matches.join(", ")}`
424
+ };
425
+ }
426
+ function readFileIn(cwd, relativePath) {
427
+ try {
428
+ return readFileSync(join(cwd, relativePath), "utf8");
429
+ } catch {
430
+ return void 0;
431
+ }
432
+ }
433
+ function scriptExists(name) {
434
+ const pkg = readPackageJson();
435
+ if (!pkg) return false;
436
+ const scripts = pkg.scripts;
437
+ return isRecord(scripts) && name in scripts;
438
+ }
439
+ function scriptMatches(name, pattern) {
440
+ const pkg = readPackageJson();
441
+ if (!pkg) return false;
442
+ const scripts = pkg.scripts;
443
+ if (!isRecord(scripts)) return false;
444
+ const value = scripts[name];
445
+ return typeof value === "string" && pattern.test(value);
446
+ }
447
+ function toolVersionsHasNoPnpm() {
448
+ const content = readFile(".tool-versions");
449
+ if (content === void 0) return true;
450
+ return !/^pnpm\s/m.test(content);
451
+ }
452
+ function vitestConfigBuildsOnSharedConfig(cwd = process.cwd()) {
453
+ return checkRootVitestConfig("vitest.config", "defineVitestConfig", cwd);
454
+ }
455
+ function vitestRootConfigBuildsOnSharedConfig(cwd = process.cwd()) {
456
+ return checkRootVitestConfig("vitest.root.config", "defineRootVitestConfig", cwd);
457
+ }
458
+ export {
459
+ codeQualityWorkflowDoesNotUseNmrPrepush,
460
+ default_default as default,
461
+ everyTestFileNamesItsTier,
462
+ noReExportOnlyVitestConfigs,
463
+ noRetiredVitestConfigs,
464
+ prettierConfigBuildsOnSharedConfig,
465
+ vitestConfigBuildsOnSharedConfig,
466
+ vitestRootConfigBuildsOnSharedConfig
467
+ };
@@ -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": "56564046"
14
+ }
15
+ ]
16
+ }
package/README.md CHANGED
@@ -3,47 +3,21 @@
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.24.0 (2026-08-03)
6
+ ## Release notes — v0.26.0 (2026-08-04)
7
7
 
8
8
  ### 🎉 Features
9
9
 
10
- - 🚨 **Breaking:** Retire integration label in favor of an isolation-tier test ladder (#552)
10
+ - Publish each package's readyup kit from the package that owns it (#590)
11
11
 
12
- Test groups are now named for what a test reaches rather than for how much of the codebase it covers: `integration` and `app` are replaced by `unit`, `tool`, `localhost`, and `remote`.
12
+ ReadyUp checks for `@williamthorsen/nmr`, `@williamthorsen/release-kit`, and `v11y-check` are now run directly against these packages, guaranteeing a version-appropriate check, instead of against their host repo. The package must be installed as a direct dependency in the calling repo.
13
13
 
14
- `nmr test` now runs the `tool` group alongside `unit`, so the first run after upgrading may fail on tests that nothing was running before. Upgrading means renaming `*.int.test.ts` files onto the new groups and replacing `nmr test:integration` with `nmr test:tool`.
14
+ The syntax is `rdy run --from npm:<package>`. `rdy run --packages` runs the kits contained in any packages listed in `.config/readyup.config.ts`. Requires `readyup` 0.23 or later.
15
15
 
16
- - Let Vitest config files compose shared options and target one tier (#556)
16
+ - Ship agent guidance as CodeAssembly package content (#591)
17
17
 
18
- Vitest settings can now be shared across config files: A repo declares its common options once, and a workspace can use its own config file to supplement or override them. Shared setup files run first. An option can now target a single test tier instead of all tiers, allowing per-tier timeouts to be set.
18
+ A repo that consumes `@williamthorsen/nmr` can now adopt `nmr`'s agent guidance by naming the package in `.agents/codeassembly.yaml`. `codeassembly sync` then delivers that guidance to every agent harness. Guidance updates are thus delivered directly with each package, eliminating the need to keep a guidance file in sync. The `nmr sync-agent-files` command has been removed.
19
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.
20
+ Migration guide: Delete `.agents/nmr/`, remove the `@nmr/AGENTS.md` include line, and remove all uses of `check:agent-files`.
47
21
  <!-- /section:release-notes -->
48
22
 
49
23
  ## Installation
@@ -228,23 +202,23 @@ import { defineConfig } from '@williamthorsen/nmr/config';
228
202
 
229
203
  export default defineConfig({
230
204
  workspaceScripts: {
231
- 'build:pre': 'npx rdy compile',
205
+ 'build:pre': 'node scripts/generate-manifest.ts',
232
206
  },
233
207
  });
234
208
  ```
235
209
 
236
- Attach a post-build step to one package:
210
+ Attach a step to one package's hook:
237
211
 
238
212
  ```jsonc
239
- // packages/nmr/package.json
213
+ // packages/my-package/package.json
240
214
  {
241
215
  "scripts": {
242
- "build:post": "nmr-sync-agent-files",
216
+ "upgrade:post": "nmr-report-overrides",
243
217
  },
244
218
  }
245
219
  ```
246
220
 
247
- The second example calls the bin directly, which sidesteps the workspace-versus-root registry distinction.
221
+ The second example calls the bin directly: `report-overrides` is a root-registry command, so `nmr report-overrides` from inside a package fails with `Unknown command`, while the bin runs from either scope.
248
222
 
249
223
  ## Check-result cache
250
224
 
@@ -255,7 +229,7 @@ $ nmr ci
255
229
  # ... four minutes of build, typecheck, lint, and coverage ...
256
230
 
257
231
  $ nmr ci
258
- ⏭️ ci: passed 2m ago on this tree (saved ~4m). Re-run with --no-cache.
232
+ ⏭️ ci: passed 2m ago on this tree (🚀 saved ~4m). Re-run with --no-cache.
259
233
  ```
260
234
 
261
235
  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`.
@@ -264,7 +238,7 @@ The cache is a working-tree cache, not a build cache: it answers "has this exact
264
238
 
265
239
  ### What is cached
266
240
 
267
- Cacheable by default: `check`, `check:agent-files`, `check:strict`, `ci`, `fix:check`, `fmt:check`, `lint:check`, `lint:strict`, `test`, `test:coverage`, `test:tool`, `test:unit`, `typecheck`, and the `root:` variants `root:check`, `root:lint:check`, `root:lint:strict`, `root:test`, `root:test:tool`, `root:test:unit`, and `root:typecheck`.
241
+ Cacheable by default: `check`, `check:strict`, `ci`, `fix:check`, `fmt:check`, `lint:check`, `lint:strict`, `test`, `test:coverage`, `test:tool`, `test:unit`, `typecheck`, and the `root:` variants `root:check`, `root:lint:check`, `root:lint:strict`, `root:test`, `root:test:tool`, `root:test:unit`, and `root:typecheck`.
268
242
 
269
243
  Everything else runs every time:
270
244
 
@@ -464,11 +438,10 @@ Neither is bound to a git hook. `prepush` is named for when you run it, not for
464
438
 
465
439
  #### Check and quality
466
440
 
467
- | Command | Runs |
468
- | ------------------- | ----------------------------------------------------------------------------- |
469
- | `check` | `typecheck`, `fmt:check`, `lint:check`, `test` |
470
- | `check:agent-files` | `nmr-sync-agent-files --check` |
471
- | `check:strict` | `typecheck`, `fmt:check`, `lint:strict`, `test:coverage`, `check:agent-files` |
441
+ | Command | Runs |
442
+ | -------------- | -------------------------------------------------------- |
443
+ | `check` | `typecheck`, `fmt:check`, `lint:check`, `test` |
444
+ | `check:strict` | `typecheck`, `fmt:check`, `lint:strict`, `test:coverage` |
472
445
 
473
446
  #### Fix
474
447
 
@@ -558,7 +531,6 @@ These scripts operate on root-level code only (not workspace packages):
558
531
  | Command | Runs |
559
532
  | ------------------ | ---------------------- |
560
533
  | `report-overrides` | `nmr-report-overrides` |
561
- | `sync-agent-files` | `nmr-sync-agent-files` |
562
534
 
563
535
  ## CLI reference
564
536
 
@@ -612,35 +584,33 @@ Report any active `pnpm.overrides` in the root `package.json`, reminding develop
612
584
  nmr report-overrides
613
585
  ```
614
586
 
615
- ### `sync-agent-files`
587
+ ## Agent guidance
616
588
 
617
- Sync the agent-facing guidance shipped with nmr into the consuming repo.
589
+ nmr ships the rules an agent needs in order to invoke it, as [CodeAssembly](https://github.com/williamthorsen/codeassembly) package content. Adopt it by naming the package in the consuming repo's `.agents/codeassembly.yaml`:
618
590
 
619
- ```bash
620
- nmr sync-agent-files # write .agents/nmr/AGENTS.md, stamped with the installed nmr version
621
- nmr sync-agent-files --check # verify the stamp matches; exit 1 with a fix message if not
591
+ ```yaml
592
+ packages:
593
+ use:
594
+ - '@williamthorsen/nmr'
622
595
  ```
623
596
 
624
- Run `nmr sync-agent-files` once after upgrading nmr. The generated file is committed to the consuming repo; do not edit it by hand.
625
-
626
- The default root `check:strict` composite includes `check:agent-files`, which runs `--check` automatically — so any CI pipeline already running `check:strict` catches drift without per-consumer wiring.
597
+ Then add `codeassembly` as a devDependency and run `codeassembly sync`. The guidance is injected into the machine-local guidance file each harness loads at launch; nothing is copied into the repo or committed there. It resolves from the installed package, so upgrading nmr updates it with no second step.
627
598
 
628
- To expose the synced guidance to Claude Code sessions, add this include to the consuming repo's `.agents/PROJECT.md`:
599
+ Wiring the sync to `postinstall` keeps it current without a hand-run command:
629
600
 
630
- ```markdown
631
- @nmr/AGENTS.md
601
+ ```json
602
+ {
603
+ "scripts": {
604
+ "postinstall": "codeassembly sync --warn-only"
605
+ }
606
+ }
632
607
  ```
633
608
 
634
- #### What belongs in the synced file
635
-
636
- The synced file is injected at launch in every consuming repo, so every line is paid for on every task whether or not it is relevant. It is a cheatsheet, not a manual.
609
+ `--warn-only` reports a sync failure and exits 0; without it, a failed sync aborts `pnpm install`.
637
610
 
638
- A line earns a place in `AGENTS.md` only if both hold:
611
+ ### Migrating from `sync-agent-files`
639
612
 
640
- 1. It is absent from nmr's own output. Bare `nmr` already prints the flags, every command, and the shell command each resolves to; failing checks already name their own fix. Repeating any of that costs launch tokens to say something the agent will be told anyway, at the moment it matters.
641
- 2. The obvious action goes wrong without it. Guidance that prevents a silent mistake earns its place; guidance that prevents a mistake the next error message would diagnose does not.
642
-
643
- Everything else belongs in this README, reachable on demand at `node_modules/@williamthorsen/nmr/README.md`, with a pointer from `AGENTS.md` naming the topic so an agent knows to look. When adding a feature, document it here and extend that pointer rather than the cheatsheet.
613
+ The `nmr sync-agent-files` command, its `nmr-sync-agent-files` bin, and the `check:agent-files` script were removed in favor of the above. After upgrading, delete the generated `.agents/nmr/` directory and the `@nmr/AGENTS.md` line it was exposed through, drop `check:agent-files` from any `check:strict` override that enumerates it, and remove any direct call to the bin.
644
614
 
645
615
  ## Standalone utilities
646
616
 
@@ -707,6 +677,29 @@ ensure-prepublish-hooks
707
677
  | `--dry-run` | Preview what `--fix` would do | — |
708
678
  | `--command <command>` | Custom hook command | `"npm run build"` |
709
679
 
680
+ ## Conformance checks
681
+
682
+ 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.
683
+
684
+ Add `readyup` as a devDependency, then name nmr in its config:
685
+
686
+ ```ts
687
+ // .config/readyup.config.ts
688
+ import { defineRdyConfig } from 'readyup';
689
+
690
+ export default defineRdyConfig({
691
+ packages: ['@williamthorsen/nmr'],
692
+ });
693
+ ```
694
+
695
+ ```bash
696
+ rdy run --packages # every kit each listed package publishes
697
+ rdy run --from npm:@williamthorsen/nmr # nmr's kit alone, without the config entry
698
+ rdy list --from npm:@williamthorsen/nmr # what nmr publishes
699
+ ```
700
+
701
+ `--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.
702
+
710
703
  ## Consumer migration
711
704
 
712
705
  After installing, a consuming repo's root `package.json` scripts shrink to lifecycle hooks:
@@ -833,7 +826,9 @@ vitest # every project
833
826
 
834
827
  **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
828
 
836
- `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 also what makes an unrecognized infix safe to use as documentation: `resolveConfig.packaged.test.ts` runs under `unit` and reads as a companion to `resolveConfig.unit.test.ts`.
829
+ **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`.
830
+
831
+ `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
832
 
838
833
  `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
834
 
@@ -934,11 +929,11 @@ This variant reads `pnpm-workspace.yaml` and excludes every workspace package fr
934
929
 
935
930
  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
931
 
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 drops the infix. Re-read each file rather than renaming in bulk -- the axis changed, so the old bucket does not map onto one new tier.
938
- 2. Leave `*.app.test.ts` alone, or rename at your leisure. `app` was never a tier, and its files now land in `unit`.
932
+ 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.
933
+ 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
934
  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
935
 
941
- **An unrenamed `*.int.test.ts` file is a silent failure.** `.int.` no longer matches a project, so the residual `unit` claims those files and they run in the default gate -- green, with nothing reporting that the separation was lost. `rdy run nmr` reports them; a passing test run does not. Run it before treating the migration as done.
936
+ **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
937
 
943
938
  `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
939
 
@@ -0,0 +1,24 @@
1
+ # Authoring nmr's agent guidance
2
+
3
+ This directory is a CodeAssembly content root, declared by the `codeassembly.content` key in `packages/nmr/package.json`.
4
+
5
+ ## The ambient cheatsheet
6
+
7
+ `guidance/rulebooks/nmr.md` carries `delivery: ambient`, so its body is injected at launch in every consuming repo. Every line is paid for on every task, whether or not it is relevant. It is a cheatsheet, not a manual.
8
+
9
+ A line earns a place in it only if both hold:
10
+
11
+ 1. **It is absent from nmr's own output.** Bare `nmr` already prints the flags, every command, and the shell command each resolves to; failing checks already name their own fix. Repeating any of that costs launch tokens to say something the agent will be told anyway, at the moment it matters.
12
+ 2. **The obvious action goes wrong without it.** Guidance that prevents a silent mistake earns its place; guidance that prevents a mistake the next error message would diagnose does not.
13
+
14
+ Everything else belongs in `packages/nmr/README.md`, reachable on demand at `node_modules/@williamthorsen/nmr/README.md`, with a pointer from the cheatsheet naming the topic so an agent knows to look. When adding a feature, document it there and extend that pointer rather than the cheatsheet.
15
+
16
+ ## Constraints on rulebook bodies
17
+
18
+ Rulebook bodies carry no links to other rulebooks. CodeAssembly's renderer rewrites link targets under `skills/` and `scripts/` and rejects everything else, so a cross-reference names the deployed `consult-<slug>` in prose and declares the edge under `dependencies:`.
19
+
20
+ Anchor-only links are checked against the body they appear in, so a fragment naming a heading that lives in another document fails the run. Check `](#` before moving a section between bodies.
21
+
22
+ ## Publishing
23
+
24
+ The directory is published because `files` in `packages/nmr/package.json` lists it. Dropping that entry fails nothing locally, because this repo resolves the guidance through a `workspace:*` self-link that `files` does not govern. `packaging.tool.test.ts` is what catches it.
@@ -1,8 +1,10 @@
1
1
  ---
2
- source: '@williamthorsen/nmr@0.0.0-source'
2
+ slug: nmr
3
+ description: Invocation rules for the nmr script runner in a pnpm monorepo.
4
+ delivery: ambient
3
5
  ---
4
6
 
5
- # nmr: agent guidance (generated; do not edit)
7
+ # nmr: agent guidance
6
8
 
7
9
  - Use `nmr <command>`, not `pnpm run <command>`, for anything nmr provides. Run bare `nmr` to list every command and the shell command it resolves to; a wrong guess reports only `Unknown command`, with no list.
8
10
  - Scope follows cwd, and bare `nmr` lists both registries: from the repo root a command covers root files and every workspace; from inside a package, that package alone. `nmr -F <pkg> <command>` targets one package from anywhere; `nmr root:<command>` targets root files alone, which isolates a failure to root code.
@@ -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";
@@ -17,7 +18,6 @@ export const CURRENT_RUNTIME = {
17
18
  export const DEBUG_ENV_VAR = 'NMR_DEBUG';
18
19
  export const DEFAULT_CACHEABLE_COMMANDS = [
19
20
  'check',
20
- 'check:agent-files',
21
21
  'check:strict',
22
22
  'ci',
23
23
  'fix:check',
@@ -91,8 +91,9 @@ export function formatMisplacedNoCacheWarning(command) {
91
91
  }
92
92
  export function formatSkipLine(command, entry, now) {
93
93
  const age = formatDuration(Math.max(0, now - Date.parse(entry.recordedAt)));
94
- const saved = formatDuration(entry.durationMs);
95
- return `⏭️ ${command}: passed ${age} ago on this tree (saved ~${saved}). Re-run with --no-cache.`;
94
+ const saving = formatSaving(entry.durationMs);
95
+ const savingClause = saving === undefined ? '' : ` (${saving})`;
96
+ return `⏭️ ${command}: passed ${age} ago on this tree${savingClause}. Re-run with --no-cache.`;
96
97
  }
97
98
  export async function readBuildOutputState(monorepoRoot, config) {
98
99
  const state = { missing: [], digests: {} };
@@ -165,9 +166,6 @@ export function writeDebugNote(message, env, stderr) {
165
166
  stderr.write(`nmr check-cache: ${message}\n`);
166
167
  }
167
168
  }
168
- const MILLISECONDS_PER_SECOND = 1000;
169
- const SECONDS_PER_MINUTE = 60;
170
- const MINUTES_PER_HOUR = 60;
171
169
  function decodeTreeSnapshot(encoded) {
172
170
  if (encoded === undefined) {
173
171
  return undefined;
@@ -178,17 +176,6 @@ function decodeTreeSnapshot(encoded) {
178
176
  }
179
177
  return { hash, headSha };
180
178
  }
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
179
  function isCheckCacheEntry(value) {
193
180
  if (!isObject(value)) {
194
181
  return false;
@@ -28,8 +28,7 @@ export const rootScripts = {
28
28
  'audit:prod': 'pnpm exec v11y --prod',
29
29
  build: 'pnpm --recursive exec nmr build',
30
30
  check: ['typecheck', 'fmt:check', 'lint:check', 'test'],
31
- 'check:agent-files': 'nmr-sync-agent-files --check',
32
- 'check:strict': ['typecheck', 'fmt:check', 'lint:strict', 'test:coverage', 'check:agent-files'],
31
+ 'check:strict': ['typecheck', 'fmt:check', 'lint:strict', 'test:coverage'],
33
32
  ci: ['build', 'check:strict'],
34
33
  clean: 'nmr-clean',
35
34
  fix: ['lint', 'fmt'],
@@ -51,7 +50,6 @@ export const rootScripts = {
51
50
  'root:test:unit': 'vitest --config ./vitest.root.config.ts --project unit',
52
51
  'root:typecheck': 'tsgo --noEmit',
53
52
  'root:upgrade': 'nmr-taze --include-locked',
54
- 'sync-agent-files': 'nmr-sync-agent-files',
55
53
  test: 'nmr root:test && pnpm --recursive exec nmr test',
56
54
  'test:all': 'nmr root:test:all && pnpm --recursive exec nmr test:all',
57
55
  'test:coverage': 'nmr root:test && pnpm --recursive exec nmr test:coverage',
@@ -0,0 +1,2 @@
1
+ export declare function formatDuration(milliseconds: number): string;
2
+ export declare function formatSaving(milliseconds: number): string | undefined;
@@ -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
+ }
@@ -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 {};
@@ -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 BUILD_OUTPUT_EXCLUDE = ['**/dist/**'];
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: [...defaultExclude, ...BUILD_OUTPUT_EXCLUDE, ...exclude, ...extraExclude],
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.24.0",
3
+ "version": "0.26.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -54,11 +54,12 @@
54
54
  "nmr-compile": "bin/nmr-compile.js",
55
55
  "nmr-fmt": "bin/nmr-fmt.js",
56
56
  "nmr-report-overrides": "bin/nmr-report-overrides.js",
57
- "nmr-sync-agent-files": "bin/nmr-sync-agent-files.js",
58
57
  "nmr-taze": "bin/nmr-taze.js"
59
58
  },
60
59
  "files": [
61
- "AGENTS.md",
60
+ ".readyup/kits/*.js",
61
+ ".readyup/manifest.json",
62
+ "agents",
62
63
  "bin",
63
64
  "dist"
64
65
  ],
@@ -67,10 +68,11 @@
67
68
  "prettier-plugin-sh": "0.19.0",
68
69
  "taze": "19.16.0",
69
70
  "yaml": "2.9.0",
70
- "@williamthorsen/nmr-core": "0.8.0"
71
+ "@williamthorsen/nmr-core": "0.9.0"
71
72
  },
72
73
  "devDependencies": {
73
74
  "prettier": "3.9.6",
75
+ "readyup": "0.23.0",
74
76
  "tinyglobby": "0.2.17",
75
77
  "vitest": "4.1.10"
76
78
  },
@@ -93,7 +95,11 @@
93
95
  "publishConfig": {
94
96
  "access": "public"
95
97
  },
98
+ "codeassembly": {
99
+ "content": "agents"
100
+ },
96
101
  "scripts": {
97
- "build:post": "nmr-sync-agent-files"
102
+ "build:post": "rdy compile",
103
+ "build:pre": "rdy verify"
98
104
  }
99
105
  }
@@ -1,11 +0,0 @@
1
- #!/usr/bin/env node
2
- try {
3
- await import('../dist/esm/cli-sync-agent-files.js');
4
- } catch (error) {
5
- if (error.code === 'ERR_MODULE_NOT_FOUND') {
6
- process.stderr.write('nmr-sync-agent-files: build output not found — run `pnpm run build` first\n');
7
- } else {
8
- process.stderr.write(`nmr-sync-agent-files: failed to load: ${error.message}\n`);
9
- }
10
- process.exit(1);
11
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,29 +0,0 @@
1
- import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
2
- import { check, sync } from "./commands/sync-agent-files.js";
3
- import { findMonorepoRoot } from "./workspace.js";
4
- const flagSchema = {
5
- check: { long: '--check', type: 'boolean' },
6
- };
7
- const { flags } = parseArgsOrExit(process.argv.slice(2), flagSchema);
8
- try {
9
- runCommand(flags.check);
10
- }
11
- catch (error) {
12
- reportError(error instanceof Error ? error.message : String(error));
13
- process.exit(1);
14
- }
15
- function runCommand(checkOnly) {
16
- const monorepoRoot = findMonorepoRoot();
17
- if (!checkOnly) {
18
- const { path: destinationPath, packageSpecifier, changed } = sync(monorepoRoot);
19
- console.info(changed ? `✓ Wrote ${destinationPath} (${packageSpecifier})` : `✓ ${destinationPath} already up to date`);
20
- process.exit(0);
21
- }
22
- const result = check(monorepoRoot);
23
- if (result.ok) {
24
- console.info('✓ .agents/nmr/AGENTS.md is in sync');
25
- process.exit(0);
26
- }
27
- process.stderr.write(`${result.reason}\n`);
28
- process.exit(1);
29
- }
@@ -1,13 +0,0 @@
1
- export declare function check(destinationDir: string): CheckResult;
2
- export interface SyncResult {
3
- path: string;
4
- packageSpecifier: string;
5
- changed: boolean;
6
- }
7
- export declare function sync(destinationDir: string): SyncResult;
8
- export type CheckResult = {
9
- ok: true;
10
- } | {
11
- ok: false;
12
- reason: string;
13
- };
@@ -1,58 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- import { readPackageVersion } from '@williamthorsen/nmr-core';
5
- const VERSION = readPackageVersion(import.meta.url);
6
- const PACKAGE_NAME = '@williamthorsen/nmr';
7
- const DESTINATION_RELATIVE_PATH = '.agents/nmr/AGENTS.md';
8
- const SOURCE_FILENAME = 'AGENTS.md';
9
- const FRONTMATTER_REGEX = /^---\n((?:.*\n)*?)---\n/;
10
- export function check(destinationDir) {
11
- const destinationPath = getDestinationPath(destinationDir);
12
- if (!existsSync(destinationPath)) {
13
- return {
14
- ok: false,
15
- reason: `${DESTINATION_RELATIVE_PATH} is missing. Run \`nmr sync-agent-files\`.`,
16
- };
17
- }
18
- const expectedBody = stripFrontmatter(readFileSync(getSourcePath(), 'utf8'));
19
- const foundBody = stripFrontmatter(readFileSync(destinationPath, 'utf8'));
20
- if (foundBody !== expectedBody) {
21
- return {
22
- ok: false,
23
- reason: `${DESTINATION_RELATIVE_PATH} content is out of date. Run \`nmr sync-agent-files\`.`,
24
- };
25
- }
26
- return { ok: true };
27
- }
28
- export function sync(destinationDir) {
29
- const body = stripFrontmatter(readFileSync(getSourcePath(), 'utf8'));
30
- const packageSpecifier = buildPackageSpecifier();
31
- const destinationPath = getDestinationPath(destinationDir);
32
- if (existsSync(destinationPath) && stripFrontmatter(readFileSync(destinationPath, 'utf8')) === body) {
33
- return { path: destinationPath, packageSpecifier, changed: false };
34
- }
35
- const output = `---\nsource: '${packageSpecifier}'\n---\n${body}`;
36
- mkdirSync(path.dirname(destinationPath), { recursive: true });
37
- writeFileSync(destinationPath, output, 'utf8');
38
- return { path: destinationPath, packageSpecifier, changed: true };
39
- }
40
- function buildPackageSpecifier() {
41
- return `${PACKAGE_NAME}@${VERSION}`;
42
- }
43
- function getDestinationPath(destinationDir) {
44
- return path.join(destinationDir, DESTINATION_RELATIVE_PATH);
45
- }
46
- function getSourcePath() {
47
- let dir = path.dirname(fileURLToPath(import.meta.url));
48
- while (dir !== path.dirname(dir)) {
49
- const candidate = path.join(dir, SOURCE_FILENAME);
50
- if (existsSync(candidate))
51
- return candidate;
52
- dir = path.dirname(dir);
53
- }
54
- throw new Error(`Could not locate ${SOURCE_FILENAME} in any parent of ${fileURLToPath(import.meta.url)}`);
55
- }
56
- function stripFrontmatter(content) {
57
- return content.replace(FRONTMATTER_REGEX, '');
58
- }