@williamthorsen/nmr 0.34.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.meta/changelog.json +1416 -0
  2. package/.readyup/kits/default.js +174 -19
  3. package/.readyup/manifest.json +11 -8
  4. package/CHANGELOG.md +894 -0
  5. package/README.md +91 -18
  6. package/agents/guidance/rulebooks/nmr.md +1 -1
  7. package/dist/esm/UserError.d.ts +6 -0
  8. package/dist/esm/check-cache.d.ts +145 -0
  9. package/dist/esm/commands/build-output.d.ts +64 -0
  10. package/dist/esm/commands/build.d.ts +29 -1
  11. package/dist/esm/commands/build.js +26 -6
  12. package/dist/esm/commands/clean.d.ts +15 -0
  13. package/dist/esm/commands/ensure-prepublish-hooks.d.ts +11 -0
  14. package/dist/esm/commands/fmt.d.ts +32 -0
  15. package/dist/esm/commands/report-catalog.d.ts +10 -0
  16. package/dist/esm/commands/report-overrides.d.ts +6 -0
  17. package/dist/esm/commands/taze.d.ts +18 -0
  18. package/dist/esm/config.d.ts +19 -2
  19. package/dist/esm/config.js +66 -9
  20. package/dist/esm/context.d.ts +11 -0
  21. package/dist/esm/default-scripts.d.ts +15 -0
  22. package/dist/esm/defineConfig.d.ts +9 -0
  23. package/dist/esm/help.d.ts +13 -0
  24. package/dist/esm/helpers/clampToBytes.d.ts +12 -0
  25. package/dist/esm/helpers/config-path.d.ts +8 -0
  26. package/dist/esm/helpers/config-path.js +7 -0
  27. package/dist/esm/helpers/createBoundedBuffer.d.ts +7 -0
  28. package/dist/esm/helpers/deriveExcerpt.d.ts +10 -0
  29. package/dist/esm/helpers/duration.d.ts +15 -0
  30. package/dist/esm/helpers/findClosestName.d.ts +7 -0
  31. package/dist/esm/helpers/findClosestName.js +38 -0
  32. package/dist/esm/helpers/hook-name.d.ts +8 -0
  33. package/dist/esm/helpers/package-json.d.ts +25 -0
  34. package/dist/esm/helpers/package-json.js +1 -1
  35. package/dist/esm/helpers/readStringValues.d.ts +7 -0
  36. package/dist/esm/helpers/reportClosing.d.ts +15 -0
  37. package/dist/esm/helpers/transcript.d.ts +25 -0
  38. package/dist/esm/helpers/type-guards.d.ts +1 -0
  39. package/dist/esm/helpers/workspace-patterns.d.ts +7 -0
  40. package/dist/esm/prettier.d.ts +8 -0
  41. package/dist/esm/recording.d.ts +39 -1
  42. package/dist/esm/replay-assembly.d.ts +18 -2
  43. package/dist/esm/report-format.d.ts +33 -0
  44. package/dist/esm/reportCliFailure.d.ts +8 -0
  45. package/dist/esm/resolve-scripts.d.ts +10 -1
  46. package/dist/esm/resolver.d.ts +67 -2
  47. package/dist/esm/resolver.js +1 -1
  48. package/dist/esm/runCli.d.ts +17 -0
  49. package/dist/esm/runCli.js +2 -2
  50. package/dist/esm/runner.d.ts +52 -0
  51. package/dist/esm/scripts.d.ts +1 -0
  52. package/dist/esm/steps.d.ts +71 -0
  53. package/dist/esm/taze.d.ts +16 -0
  54. package/dist/esm/tests.d.ts +40 -0
  55. package/dist/esm/tests.js +25 -0
  56. package/dist/esm/tiers.d.ts +56 -1
  57. package/dist/esm/tiers.js +22 -9
  58. package/dist/esm/types.d.ts +28 -0
  59. package/dist/esm/verbosity.d.ts +37 -0
  60. package/dist/esm/verdict.d.ts +45 -0
  61. package/dist/esm/vitest-git-isolation.js +6 -0
  62. package/dist/esm/vitest.d.ts +72 -2
  63. package/dist/esm/vitest.js +17 -9
  64. package/dist/esm/workspace.d.ts +18 -0
  65. package/package.json +20 -10
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import { readCacheEntry, writeCacheEntry } from '@williamthorsen/nmr-core';
6
6
  import { glob } from 'glob';
7
7
  import * as ts from 'typescript';
8
- import { resolveConfigPath } from "../config.js";
8
+ import { resolveConfigPath } from "../helpers/config-path.js";
9
9
  import { DEFAULT_ENTRY_GLOBS, DEFAULT_IGNORE_PATTERNS, DEFAULT_OUTDIR, hasExpectedBuildOutput, resolveBuildCachePath, resolveScratchDirs, resolveToolchainFingerprint, } from "./build-output.js";
10
10
  const PACKAGE_ICON = '📦';
11
11
  const SKIPPED_ICON = '⏭️';
@@ -85,14 +85,15 @@ async function emitPackage(packageDir, entryPoints, outdir) {
85
85
  const emitDir = resolveEmitDir(packageDir, outdir);
86
86
  const scratchDirs = resolveScratchDirs(emitDir);
87
87
  await removeScratchDirs(scratchDirs);
88
- const program = ts.createProgram(rootNames, compilerOptions);
89
88
  const emitted = new Map();
90
- const emitResult = program.emit(undefined, (fileName, text, writeByteOrderMark) => {
89
+ function collect(fileName, text, writeByteOrderMark) {
91
90
  emitted.set(fileName, { text, writeByteOrderMark });
92
- });
93
- if (emitResult.emitSkipped) {
94
- throw new Error(`nmr-compile: emit failed.\n${formatDiagnostics(emitResult.diagnostics)}`);
95
91
  }
92
+ const host = createCachingCompilerHost(compilerOptions);
93
+ const scriptProgram = ts.createProgram(rootNames, { ...compilerOptions, declaration: false }, host);
94
+ assertEmitSucceeded(scriptProgram.emit(undefined, collect));
95
+ const declarationProgram = ts.createProgram(rootNames, { ...compilerOptions, emitDeclarationOnly: true, removeComments: false }, host, scriptProgram);
96
+ assertEmitSucceeded(declarationProgram.emit(undefined, collect));
96
97
  const staged = new Map();
97
98
  for (const [fileName, file] of emitted) {
98
99
  staged.set(fileName, { ...file, text: rewriteSpecifiers(fileName, file.text, compilerOptions, sourceRoot) });
@@ -105,6 +106,25 @@ async function emitPackage(packageDir, entryPoints, outdir) {
105
106
  await swapIntoPlace(emitDir, scratchDirs);
106
107
  return staged.size;
107
108
  }
109
+ function assertEmitSucceeded(result) {
110
+ if (result.emitSkipped) {
111
+ throw new Error(`nmr-compile: emit failed.\n${formatDiagnostics(result.diagnostics)}`);
112
+ }
113
+ }
114
+ function createCachingCompilerHost(compilerOptions) {
115
+ const host = ts.createCompilerHost(compilerOptions);
116
+ const parsed = new Map();
117
+ const readSourceFile = host.getSourceFile.bind(host);
118
+ host.getSourceFile = (fileName, languageVersionOrOptions, onError, shouldCreateNewSourceFile) => {
119
+ if (parsed.has(fileName)) {
120
+ return parsed.get(fileName);
121
+ }
122
+ const sourceFile = readSourceFile(fileName, languageVersionOrOptions, onError, shouldCreateNewSourceFile);
123
+ parsed.set(fileName, sourceFile);
124
+ return sourceFile;
125
+ };
126
+ return host;
127
+ }
108
128
  async function discardScratchDirs(scratchDirs) {
109
129
  try {
110
130
  await removeScratchDirs(scratchDirs);
@@ -1,2 +1,17 @@
1
+ /**
2
+ * Removes a package's build output and the build-cache entry that describes it, so no state survives to
3
+ * make the next build skip. Removal is idempotent: an already-clean package is a silent no-op.
4
+ */
1
5
  export declare function cleanPackage(packageDir: string): Promise<void>;
6
+ /**
7
+ * Cleans the package containing `cwd`, or every workspace package when run from the monorepo root.
8
+ *
9
+ * From within a package this is the built-in clean itself: `nmr` has already resolved `clean` to this bin
10
+ * before invoking it, so resolving again would apply the package's own override twice.
11
+ *
12
+ * This is the default `clean` script, shipped as a bin rather than delegating to `rimraf`. Under pnpm's
13
+ * isolated layout, `pnpm exec` resolves only bins of the consuming project's own direct dependencies, so
14
+ * a `rimraf` in nmr's dependency tree would be unreachable — while nmr's own bins are linked into the
15
+ * consumer's `node_modules/.bin`, nmr being a direct dependency.
16
+ */
2
17
  export declare function runClean(cwd?: string): Promise<void>;
@@ -10,9 +10,20 @@ export interface EnsurePrepublishHooksResult {
10
10
  hasFailures: boolean;
11
11
  }
12
12
  export declare const DEFAULT_HOOK = "npm run build";
13
+ /**
14
+ * Check (and optionally fix) whether all publishable workspace packages
15
+ * have a `prepublishOnly` script.
16
+ */
13
17
  export declare function ensurePrepublishHooks(monorepoRoot: string, options: {
14
18
  fix: boolean;
15
19
  dryRun: boolean;
16
20
  command?: string;
17
21
  }): EnsurePrepublishHooksResult;
22
+ /**
23
+ * Reports one line per publishable package and closes with what the run came to. Private packages appear in
24
+ * neither: they publish nothing, so a `prepublishOnly` is not theirs to carry.
25
+ *
26
+ * Every line goes to stdout, the `✗` included, and the exit code is what carries a failure. A report split
27
+ * across two streams leaves no reader holding the whole list.
28
+ */
18
29
  export declare function reportPrepublishHooks(result: EnsurePrepublishHooksResult, hookCommand: string): void;
@@ -1,6 +1,8 @@
1
1
  export type FormatMode = 'check' | 'write';
2
2
  export interface FormatTargets {
3
+ /** Paths relative to the working directory, as `git ls-files` emits them. */
3
4
  files: string[];
5
+ /** Absolute paths to every `.prettierignore` governing the repository, root-most first. */
4
6
  ignorePaths: string[];
5
7
  }
6
8
  export type ResolveTargetsResult = {
@@ -10,14 +12,44 @@ export type ResolveTargetsResult = {
10
12
  ok: false;
11
13
  error: string;
12
14
  };
15
+ /**
16
+ * Formats or checks the files git reports for `cwd`, and returns the exit code to report.
17
+ *
18
+ * Trailing arguments are git pathspecs, not Prettier flags; an unrecognized option is rejected rather
19
+ * than passed along, since git would read it as a pathspec matching nothing and the run would report
20
+ * success over an empty selection.
21
+ */
13
22
  export declare function runFmt(argv: string[], cwd?: string): number;
23
+ /**
24
+ * Resolves what Prettier should format and which ignore files govern it, using git rather than Prettier's
25
+ * own discovery. Prettier reads `.gitignore` and `.prettierignore` from the working directory only, so a
26
+ * pattern in `packages/<pkg>/.gitignore` is invisible to a root-level run; git knows the whole hierarchy,
27
+ * along with `.git/info/exclude` and `core.excludesFile`, which Prettier cannot read under any flag.
28
+ *
29
+ * File selection is anchored at `cwd` and ignore discovery at the repository root. That split is the point:
30
+ * scoping still follows where the caller stands, while the rules applied to a given file no longer do.
31
+ *
32
+ * `pathspecs` narrow the selection and are passed to git verbatim, so they carry git pathspec semantics
33
+ * rather than shell glob semantics.
34
+ */
14
35
  export declare function resolveFormatTargets(cwd: string, pathspecs?: string[]): ResolveTargetsResult;
36
+ /** @internal */
15
37
  export interface RunPrettierOptions {
16
38
  cliPath: string;
17
39
  mode: FormatMode;
18
40
  files: string[];
19
41
  ignorePaths: string[];
20
42
  cwd: string;
43
+ /** Overridable so the multi-batch path is reachable without a fixture that hits the real ceiling. */
21
44
  budgetBytes?: number;
22
45
  }
46
+ /**
47
+ * Runs Prettier over the selection, returning the first non-zero exit code. Every batch runs even after
48
+ * one fails, so a check reports every offending file rather than only those in the first batch.
49
+ *
50
+ * The CLI runs under this process's own Node rather than through its shebang, so it does not depend on
51
+ * the file's execute bit or on PATH holding a `node`.
52
+ *
53
+ * @internal
54
+ */
23
55
  export declare function runPrettier(options: RunPrettierOptions): number;
@@ -1 +1,11 @@
1
+ /**
2
+ * Reports the catalogued dependencies a package-scoped upgrade pass cannot see.
3
+ *
4
+ * The upgrade tool reads `pnpm-workspace.yaml` only when it sits at the working directory, so a pass run
5
+ * inside a package drops every dependency a catalog declares -- and a package whose dependencies are all
6
+ * catalogued reports itself up to date. Naming them, and the root a covering pass runs from, is what keeps
7
+ * that from reading as a clean bill of health.
8
+ *
9
+ * Runs ahead of the report produced by the workspace `upgrade` script.
10
+ */
1
11
  export declare function reportCatalog(cwd: string): void;
@@ -1 +1,7 @@
1
+ /**
2
+ * Reports the pnpm dependency overrides declared in the monorepo root's `pnpm-workspace.yaml`, and rejects a
3
+ * `pnpm.overrides` block left behind in the root `package.json`.
4
+ *
5
+ * Runs ahead of the report produced by the root `upgrade` script.
6
+ */
1
7
  export declare function reportOverrides(monorepoRoot: string): void;
@@ -4,9 +4,27 @@ export interface SpawnOutcome {
4
4
  error?: Error | undefined;
5
5
  }
6
6
  export interface RunTazeOptions {
7
+ /** Resolves the absolute path of taze's CLI entry. Injected by tests. */
7
8
  resolveCliPath?: () => string;
9
+ /** Runs the CLI and reports how it exited. Injected by tests. */
8
10
  spawn?: (nodePath: string, argv: string[]) => SpawnOutcome;
11
+ /** Stream that error output is written to. Defaults to `process.stderr`. */
9
12
  stderr?: Writable;
10
13
  }
14
+ /**
15
+ * Runs taze from nmr's own dependency tree, forwarding `args`, and returns its exit code.
16
+ *
17
+ * Consumers depend on nmr, not on taze, so taze is transitive and its bin is absent from the consumer's
18
+ * root `node_modules/.bin`. This launcher is what bridges that gap: pnpm links nmr's own bins into the
19
+ * consumer root, and nmr resolves taze from the tree it does control.
20
+ *
21
+ * A request timeout is the one argument added, and only where `args` carries none. Neither other site can hold
22
+ * it: an invocation's trailing arguments are appended to the resolved command string, so the same flag in the
23
+ * `upgrade` script would be doubled by `nmr upgrade --request-timeout ...`, and taze's parser collects the pair
24
+ * into an array it reads as a near-zero deadline; a `taze.config.ts` setting lands in the object taze's own CLI
25
+ * defaults overwrite. Every other argument is forwarded untouched, so invocation policy (`--recursive`) stays in
26
+ * the script registry, visible in `nmr` help output and overridable per repo, and upgrade policy in `taze.ts`.
27
+ */
11
28
  export declare function runTaze(args: string[], options?: RunTazeOptions): number;
29
+ /** Resolves the absolute path of taze's CLI entry from nmr's own dependency tree. */
12
30
  export declare function resolveTazeCliPath(): string;
@@ -1,6 +1,23 @@
1
1
  import type { NmrConfig } from './types.js';
2
- export declare const CONFIG_RELATIVE_PATH: string;
3
- export declare function resolveConfigPath(baseDir: string): string;
2
+ /**
3
+ * Loads the nmr configuration from `.config/nmr.config.ts` under `baseDir`, which is the monorepo root for
4
+ * the repo-wide config and a package directory for the workspace tier. Returns an empty config if the file
5
+ * doesn't exist.
6
+ */
4
7
  export declare function loadConfig(baseDir: string): Promise<NmrConfig>;
8
+ /**
9
+ * Loads the monorepo-root `.config/nmr.config.ts`, the tier that feeds script resolution and `devBin`.
10
+ * Returns an empty config if the file doesn't exist.
11
+ *
12
+ * Throws on a recognized key this tier does not honor. `build` governs one package's compile, which reads the
13
+ * package's own config alone, so a `build` accepted here would apply its patterns nowhere.
14
+ */
5
15
  export declare function loadRootConfig(monorepoRoot: string): Promise<NmrConfig>;
16
+ /**
17
+ * Loads a single package's `.config/nmr.config.ts`, the tier `nmr-compile` reads from its working directory.
18
+ * Returns an empty config if the file doesn't exist.
19
+ *
20
+ * Throws on a recognized key this tier does not honor. Dropping one would leave a package building on
21
+ * settings its own config file appears to change, which nothing in the build's output would reveal.
22
+ */
6
23
  export declare function loadWorkspaceConfig(packageDir: string): Promise<NmrConfig>;
@@ -7,15 +7,16 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
7
7
  return path;
8
8
  };
9
9
  import { existsSync } from 'node:fs';
10
- import path from 'node:path';
11
10
  import { pathToFileURL } from 'node:url';
11
+ import { resolveConfigPath } from "./helpers/config-path.js";
12
+ import { findClosestName } from "./helpers/findClosestName.js";
13
+ import { isHookName } from "./helpers/hook-name.js";
12
14
  import { isObject, isStringRecord } from "./helpers/type-guards.js";
15
+ import { buildRootRegistry, buildWorkspaceRegistry, readPackageJsonScripts } from "./resolver.js";
13
16
  import { findUnexpressibleToken } from "./steps.js";
14
17
  import { UserError } from "./UserError.js";
15
18
  import { formatVerbosityRejection, isCommandVerbosity } from "./verbosity.js";
16
- const CONFIG_FILENAME = 'nmr.config.ts';
17
- const CONFIG_DIR = '.config';
18
- export const CONFIG_RELATIVE_PATH = path.join(CONFIG_DIR, CONFIG_FILENAME);
19
+ import { getWorkspacePackageDirs, isMonorepoRoot } from "./workspace.js";
19
20
  const CONFIG_TIERS = {
20
21
  root: {
21
22
  label: 'a monorepo-root config',
@@ -33,9 +34,6 @@ const RECOGNIZED_BUILD_KEYS = ['extraIgnorePatterns'];
33
34
  const RECOGNIZED_CHECK_CACHE_KEYS = ['enabled', 'excludeCommands', 'extraCommands'];
34
35
  const RECOGNIZED_OUTPUT_KEYS = ['commandVerbosity', 'extraAgentEnvVars'];
35
36
  const RECOGNIZED_STEP_KEYS = ['declinesArgs', 'run'];
36
- export function resolveConfigPath(baseDir) {
37
- return path.join(baseDir, CONFIG_DIR, CONFIG_FILENAME);
38
- }
39
37
  function isScriptRecord(value) {
40
38
  if (!isObject(value))
41
39
  return false;
@@ -132,6 +130,64 @@ function validateCheckCacheField(value, configPath) {
132
130
  }
133
131
  return config;
134
132
  }
133
+ function assertResolvableCheckCacheCommands(config, configPath, baseDir) {
134
+ const { checkCache } = config;
135
+ if (checkCache === undefined || !isMonorepoRoot(baseDir)) {
136
+ return;
137
+ }
138
+ const fields = ['excludeCommands', 'extraCommands'];
139
+ const entries = fields.flatMap((field) => (checkCache[field] ?? []).map((command) => ({ command, field })));
140
+ const hook = entries.find(({ command }) => isHookName(command));
141
+ if (hook !== undefined) {
142
+ throw new UserError(`Invalid nmr config at ${configPath}: \`checkCache.${hook.field}\` names the hook \`${hook.command}\`, ` +
143
+ 'which is never gated on its own: it runs as part of the chain of the command it wraps.');
144
+ }
145
+ const registered = new Set([
146
+ ...Object.keys(buildRootRegistry(config)),
147
+ ...Object.keys(buildWorkspaceRegistry(config)),
148
+ ]);
149
+ const missing = entries.filter(({ command }) => !registered.has(command));
150
+ if (missing.length === 0) {
151
+ return;
152
+ }
153
+ const declared = readDeclaredScriptNames(baseDir);
154
+ const unresolvable = missing.find(({ command }) => !declared.has(command));
155
+ if (unresolvable === undefined) {
156
+ return;
157
+ }
158
+ const closest = findClosestName(unresolvable.command, [...registered, ...declared]);
159
+ throw new UserError(`Invalid nmr config at ${configPath}: \`checkCache.${unresolvable.field}\` names no command: ` +
160
+ `\`${unresolvable.command}\`.${closest === undefined ? '' : ` Did you mean \`${closest}\`?`}`);
161
+ }
162
+ function readDeclaredScriptNames(monorepoRoot) {
163
+ let packageDirs;
164
+ try {
165
+ packageDirs = getWorkspacePackageDirs(monorepoRoot);
166
+ }
167
+ catch {
168
+ packageDirs = [];
169
+ }
170
+ const names = new Set();
171
+ const dirs = [monorepoRoot, ...packageDirs];
172
+ for (const dir of dirs) {
173
+ const scripts = readPackageScripts(dir);
174
+ for (const name of Object.keys(scripts)) {
175
+ names.add(name);
176
+ }
177
+ }
178
+ return names;
179
+ }
180
+ function readPackageScripts(packageDir) {
181
+ try {
182
+ return readPackageJsonScripts(packageDir) ?? {};
183
+ }
184
+ catch (error) {
185
+ if (error instanceof UserError) {
186
+ return {};
187
+ }
188
+ throw error;
189
+ }
190
+ }
135
191
  function validateOutputField(value, configPath) {
136
192
  const output = value['output'];
137
193
  if (output === undefined) {
@@ -168,7 +224,7 @@ function validateStringRecordField(value, fieldName, configPath) {
168
224
  }
169
225
  return value[fieldName];
170
226
  }
171
- function validateConfig(value, configPath) {
227
+ function validateConfig(value, configPath, baseDir) {
172
228
  if (!isObject(value)) {
173
229
  throw new UserError(`Invalid nmr config at ${configPath}: expected an object, got ${typeof value}`);
174
230
  }
@@ -192,6 +248,7 @@ function validateConfig(value, configPath) {
192
248
  const rootScripts = validateScriptField(value, 'rootScripts', configPath);
193
249
  if (rootScripts)
194
250
  config.rootScripts = rootScripts;
251
+ assertResolvableCheckCacheCommands(config, configPath, baseDir);
195
252
  return config;
196
253
  }
197
254
  export async function loadConfig(baseDir) {
@@ -201,7 +258,7 @@ export async function loadConfig(baseDir) {
201
258
  }
202
259
  const imported = await import(__rewriteRelativeImportExtension(pathToFileURL(configPath).href));
203
260
  const loaded = isObject(imported) ? imported['default'] : undefined;
204
- return validateConfig(loaded, configPath);
261
+ return validateConfig(loaded, configPath, baseDir);
205
262
  }
206
263
  export async function loadRootConfig(monorepoRoot) {
207
264
  const config = await loadConfig(monorepoRoot);
@@ -5,5 +5,16 @@ export interface ResolvedContext {
5
5
  packageDir?: string;
6
6
  config: NmrConfig;
7
7
  }
8
+ /**
9
+ * Determines whether a directory is inside a workspace package.
10
+ * Returns the package directory if so, or `undefined` if in root context.
11
+ *
12
+ * A workspace may nest one package inside another, so the deepest containing directory wins — matching
13
+ * pnpm, which resolves a cwd to the package that encloses it most nearly.
14
+ */
8
15
  export declare function findContainingPackageDir(dir: string, workspacePackageDirs: string[]): string | undefined;
16
+ /**
17
+ * Resolves the full execution context: monorepo root, whether we're in a
18
+ * workspace package or root context, and the loaded configuration.
19
+ */
9
20
  export declare function resolveContext(cwd?: string): Promise<ResolvedContext>;
@@ -1,8 +1,23 @@
1
+ /**
2
+ * One element of a composite, paired with what it does with the invocation's trailing arguments. The bare
3
+ * string form of an element is this spec with `declinesArgs` left at its default.
4
+ */
1
5
  export interface StepSpec {
6
+ /** A command name, optionally preceded by nmr's own flags, as it would be typed after `nmr`. */
2
7
  run: string;
8
+ /**
9
+ * Set where the trailing arguments cannot narrow this step's work: it is a prerequisite the narrowed steps
10
+ * run against, or the tool it reaches would be misled by them. A declining step runs unnarrowed.
11
+ */
3
12
  declinesArgs?: boolean;
4
13
  }
5
14
  export type ScriptValue = string | ReadonlyArray<string | StepSpec>;
6
15
  export type ScriptRegistry = Record<string, ScriptValue>;
16
+ /**
17
+ * Workspace scripts, identical for every package.
18
+ * Four Vitest projects are recognized: `tool`, `localhost`, `remote`, and `unit`. The latter is also a catch-all.
19
+ * Name a test file with the matching infix to associate it with a project.
20
+ * Example: `nmr test:tool` runs every file carrying the `tool` infix, such as `my-file.tool.test.ts`.
21
+ */
7
22
  export declare const workspaceScripts: ScriptRegistry;
8
23
  export declare const rootScripts: ScriptRegistry;
@@ -1,3 +1,12 @@
1
1
  import type { NmrConfig } from './types.js';
2
2
  export type { BuildConfig, CheckCacheConfig, CommandVerbosity, NmrConfig, OutputConfig, ScriptValue, StepSpec, } from './types.js';
3
+ /**
4
+ * Type-safe identity function for configuration files.
5
+ *
6
+ * Usage in `.config/nmr.config.ts`:
7
+ * ```ts
8
+ * import { defineConfig } from '@williamthorsen/nmr/config';
9
+ * export default defineConfig({ ... });
10
+ * ```
11
+ */
3
12
  export declare function defineConfig(config: NmrConfig): NmrConfig;
@@ -1,2 +1,15 @@
1
1
  import type { NmrConfig } from './types.js';
2
+ /**
3
+ * Generates the help text for the `nmr` CLI. Renders only nmr commands —
4
+ * names from the workspace and root registries, excluding hooks (`*:pre`,
5
+ * `*:post`).
6
+ *
7
+ * When `packageDir` is provided, tier-3 entries from that package's
8
+ * `package.json:scripts` that match a registry name in the active section
9
+ * are inlined as overrides: the registry value is replaced with the
10
+ * override value and the row's command name is suffixed with `*`. The
11
+ * active section is the root section when `useRoot` is true (root cwd
12
+ * or `-w`), otherwise the workspace section. A footnote is appended once
13
+ * if any override marker was rendered.
14
+ */
2
15
  export declare function generateHelp(config: NmrConfig, packageDir: string | undefined, useRoot: boolean): string;
@@ -1,2 +1,14 @@
1
+ /** Marks a value that was cut, so a reader can tell a truncated one from a complete one. */
1
2
  export declare const TRUNCATION_MARK = "\u2026";
3
+ /**
4
+ * Cuts a string that would overrun a byte budget, marking the cut. What comes back is at most `budgetBytes`
5
+ * long, the mark included.
6
+ *
7
+ * Cuts between code points rather than between bytes, so a multi-byte character is never left in halves.
8
+ * Graphemes are left unconsidered, which can separate an emoji from a modifier following it -- a cost paid
9
+ * only at a budget no caller here comes near.
10
+ *
11
+ * A budget too small to hold the mark yields nothing at all, rather than a return that overruns what the
12
+ * caller asked for.
13
+ */
2
14
  export declare function clampToBytes(value: string, budgetBytes: number): string;
@@ -0,0 +1,8 @@
1
+ /** The config's path relative to the directory holding it, for a message composed without one in hand. */
2
+ export declare const CONFIG_RELATIVE_PATH: string;
3
+ /**
4
+ * Resolves the config-file path for a directory, whether that is the monorepo root or a package.
5
+ * Callers that only need to know whether a config exists -- the build, which folds it into its cache digest -- go
6
+ * through this rather than spelling the path again, so that the two cannot drift into hashing a file nothing reads.
7
+ */
8
+ export declare function resolveConfigPath(baseDir: string): string;
@@ -0,0 +1,7 @@
1
+ import path from 'node:path';
2
+ const CONFIG_DIR = '.config';
3
+ const CONFIG_FILENAME = 'nmr.config.ts';
4
+ export const CONFIG_RELATIVE_PATH = path.join(CONFIG_DIR, CONFIG_FILENAME);
5
+ export function resolveConfigPath(baseDir) {
6
+ return path.join(baseDir, CONFIG_DIR, CONFIG_FILENAME);
7
+ }
@@ -3,7 +3,14 @@ export interface BoundedBufferOptions {
3
3
  tailLimitBytes?: number;
4
4
  }
5
5
  export interface BoundedBuffer {
6
+ /** Retains a chunk, dropping bytes from the middle once both ends are full. */
6
7
  append: (chunk: Buffer) => void;
8
+ /** Renders the retained bytes, with a marker naming the dropped count standing in for what was dropped. */
7
9
  toBuffer: () => Buffer;
8
10
  }
11
+ /**
12
+ * Creates an accumulator that retains the first and last bytes it is fed and drops the middle.
13
+ * Both ends are kept because a failing command's first error and its trailing summary each carry signal
14
+ * that the other end does not.
15
+ */
9
16
  export declare function createBoundedBuffer(options?: BoundedBufferOptions): BoundedBuffer;
@@ -1 +1,11 @@
1
+ /**
2
+ * Reduces a transcript to the one line a verdict's detail slot carries, or `undefined` when it held nothing.
3
+ *
4
+ * The excerpt is the last blank-line-delimited block: a tool that prints progress separates its closing
5
+ * statement with a blank line, so the blank line is the tool naming where its summary starts. A fixed count of
6
+ * trailing lines fits vitest's four-line summary and v8's six-line coverage summary only by coincidence.
7
+ *
8
+ * The line carries a byte bound of its own, so what a caller persists is bounded. The 512-byte ceiling a
9
+ * verdict line is held to is not applied here, which leaves that line's grammar with the module that owns it.
10
+ */
1
11
  export declare function deriveExcerpt(transcript: string): string | undefined;
@@ -1,2 +1,17 @@
1
+ /**
2
+ * Renders a duration at the two coarsest units that say something, for a line a reader skims.
3
+ *
4
+ * Truncates at every unit rather than rounding, so a value never reads as more time than elapsed: 90 seconds is
5
+ * `1m 30s`, where rounding would claim `2m`. Below a minute the tenth is kept, since that is the scale at which
6
+ * one check's runtime differs from another's; a whole number sheds the `.0` rather than reading `12.0s`.
7
+ */
1
8
  export declare function formatDuration(milliseconds: number): string;
9
+ /**
10
+ * Renders the clause a skip spends on the time it saved, or `undefined` when there is no saving worth naming.
11
+ * A skip that can say what it saved earns the clause; one that cannot says nothing, and the caller drops it
12
+ * rather than printing a saving of `~0s`.
13
+ *
14
+ * Guards its own input rather than trusting the caller's, because the clause is shared: a non-finite duration
15
+ * compares false against the threshold, and would reach a reader as `saved ~NaNs`.
16
+ */
2
17
  export declare function formatSaving(milliseconds: number): string | undefined;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Finds the candidate closest to a name, or `undefined` where none is near enough to be worth naming.
3
+ *
4
+ * The ceiling scales with the name's length, so a short name admits one slip and a long one a few. A fixed
5
+ * ceiling would either miss a typo in `root:test:coverage` or offer `fix` for `ci`.
6
+ */
7
+ export declare function findClosestName(name: string, candidates: Iterable<string>): string | undefined;
@@ -0,0 +1,38 @@
1
+ export function findClosestName(name, candidates) {
2
+ const ceiling = Math.max(1, Math.floor(name.length / 4));
3
+ let closest;
4
+ let shortest = Infinity;
5
+ for (const candidate of candidates) {
6
+ const distance = measureDistance(name, candidate);
7
+ if (distance < shortest) {
8
+ shortest = distance;
9
+ closest = candidate;
10
+ }
11
+ }
12
+ return shortest <= ceiling ? closest : undefined;
13
+ }
14
+ function measureDistance(first, second) {
15
+ if (first.length === 0 || second.length === 0) {
16
+ return Math.max(first.length, second.length);
17
+ }
18
+ let previous = Array.from({ length: second.length + 1 }, (_unused, column) => column);
19
+ let current = Array.from({ length: second.length + 1 }, () => 0);
20
+ for (let row = 1; row <= first.length; row += 1) {
21
+ current[0] = row;
22
+ for (let column = 1; column <= second.length; column += 1) {
23
+ const substitution = readCell(previous, column - 1) + (first[row - 1] === second[column - 1] ? 0 : 1);
24
+ current[column] = Math.min(readCell(current, column - 1) + 1, readCell(previous, column) + 1, substitution);
25
+ }
26
+ const completed = current;
27
+ current = previous;
28
+ previous = completed;
29
+ }
30
+ return readCell(previous, second.length);
31
+ }
32
+ function readCell(row, index) {
33
+ const value = row[index];
34
+ if (value === undefined) {
35
+ throw new RangeError(`No cell at index ${index} in a row of ${row.length}`);
36
+ }
37
+ return value;
38
+ }
@@ -1 +1,9 @@
1
+ /**
2
+ * Returns true when `key` is a hook script name — that is, ends with the
3
+ * `:pre` or `:post` suffix preceded by at least one character.
4
+ *
5
+ * Bare `:pre` and `:post` (no parent name) return false. The predicate is
6
+ * purely about suffix shape; it does not consult the registry or care
7
+ * whether the parent command exists.
8
+ */
1
9
  export declare function isHookName(key: string): boolean;
@@ -1,3 +1,9 @@
1
+ /**
2
+ * The `package.json` fields that carry a dependency's version specifier.
3
+ *
4
+ * pnpm accepts the `catalog:` protocol in all four, so a reader looking for catalogued dependencies has to
5
+ * sweep the whole set rather than the two that carry most of them.
6
+ */
1
7
  export declare const DEPENDENCY_FIELDS: readonly ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
2
8
  export type DependencyField = (typeof DEPENDENCY_FIELDS)[number];
3
9
  export type PackageJson = {
@@ -12,8 +18,27 @@ export type PackageJson = {
12
18
  } & {
13
19
  [K in DependencyField]?: Record<string, string>;
14
20
  };
21
+ /**
22
+ * Reads and parses the package.json at the given directory, keeping only the fields nmr reads.
23
+ */
15
24
  export declare function readPackageJson(dir: string): PackageJson;
25
+ /**
26
+ * Returns the `pnpm.overrides` block a package.json declares, every key it holds and each value as written.
27
+ */
16
28
  export declare function getPnpmOverrides(pkg: PackageJson): Record<string, unknown> | undefined;
29
+ /**
30
+ * Parses a `package.json`'s text, rejecting what does not parse.
31
+ *
32
+ * A file the user wrote by hand is theirs to fix, so the failure names it rather than reporting the parser's
33
+ * own stack.
34
+ */
17
35
  export declare function parsePackageJson(raw: string, file: string): unknown;
36
+ /**
37
+ * Narrows a `scripts` record to strings, rejecting any value that is not one.
38
+ *
39
+ * npm and pnpm read a script as a string too, so a value of any other type is malformed however it got there.
40
+ * Dropping one silently would let a caller run something else in its place and report nothing.
41
+ */
18
42
  export declare function readScriptRecord(dir: string, scripts: Record<string, unknown>): Record<string, string>;
43
+ /** Resolves the `package.json` path for a directory: the file tier-3 scripts are read from. */
19
44
  export declare function resolvePackageJsonPath(dir: string): string;
@@ -1,9 +1,9 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { describeError } from '@williamthorsen/toolbelt.errors';
4
- import { CONFIG_RELATIVE_PATH } from "../config.js";
5
4
  import { UserError } from "../UserError.js";
6
5
  import { isMonorepoRoot } from "../workspace.js";
6
+ import { CONFIG_RELATIVE_PATH } from "./config-path.js";
7
7
  import { readStringValues } from "./readStringValues.js";
8
8
  import { isObject } from "./type-guards.js";
9
9
  export const DEPENDENCY_FIELDS = [
@@ -1 +1,8 @@
1
+ /**
2
+ * Keeps the entries whose value is a string, dropping the rest.
3
+ *
4
+ * A manifest reader that rejected the whole record over one bad value would report nothing about the entries
5
+ * beside it, which for a reporter is silence where there is something to say. YAML's implicit typing makes
6
+ * that easy to reach without malformed intent: an unquoted `18` parses as a number.
7
+ */
1
8
  export declare function readStringValues(record: Record<string, unknown>): Record<string, string>;
@@ -1 +1,16 @@
1
+ /**
2
+ * Reports the line that closes a bin's output, separated from the items above it by a blank line.
3
+ *
4
+ * A bin that prints one line per item leaves a reader who reaches the end holding whichever item happened to
5
+ * be last. The convention this writes: the header says what the list is, and the closing statement says what
6
+ * the run came to, so the count and the remedy live here rather than in the header. The blank line is what
7
+ * makes the statement findable once the lines are flattened.
8
+ *
9
+ * The blank line rides in the same call as the statement, so the closer is one write and cannot be
10
+ * interleaved. `log` is what puts the closer on the stream its items went to: a bin reporting to stderr
11
+ * passes `console.warn`.
12
+ *
13
+ * A bin whose whole output is one or two statements about one subject has no items to be separated from, and
14
+ * closes by printing plainly rather than through here.
15
+ */
1
16
  export declare function reportClosing(message: string, log?: (line: string) => void): void;