@williamthorsen/nmr 0.36.0 → 0.38.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 (62) hide show
  1. package/.meta/changelog.json +113 -0
  2. package/.readyup/kits/default.js +98 -10
  3. package/.readyup/manifest.json +7 -7
  4. package/CHANGELOG.md +82 -0
  5. package/README.md +107 -1135
  6. package/agents/README.md +1 -1
  7. package/agents/guidance/rulebooks/nmr.md +4 -4
  8. package/dist/esm/check-cache.d.ts +2 -1
  9. package/dist/esm/check-cache.js +4 -4
  10. package/dist/esm/cli-build.js +3 -0
  11. package/dist/esm/cli-clean.js +3 -1
  12. package/dist/esm/cli-ensure-prepublish-hooks.js +3 -1
  13. package/dist/esm/cli-report-catalog.js +2 -1
  14. package/dist/esm/cli-report-overrides.js +2 -1
  15. package/dist/esm/commands/build.d.ts +6 -1
  16. package/dist/esm/commands/build.js +9 -10
  17. package/dist/esm/commands/clean.d.ts +3 -2
  18. package/dist/esm/commands/clean.js +15 -14
  19. package/dist/esm/commands/ensure-prepublish-hooks.d.ts +3 -2
  20. package/dist/esm/commands/ensure-prepublish-hooks.js +8 -6
  21. package/dist/esm/commands/report-catalog.d.ts +2 -1
  22. package/dist/esm/commands/report-catalog.js +5 -4
  23. package/dist/esm/commands/report-overrides.d.ts +2 -1
  24. package/dist/esm/commands/report-overrides.js +5 -4
  25. package/dist/esm/config.js +14 -4
  26. package/dist/esm/context.d.ts +2 -0
  27. package/dist/esm/context.js +1 -0
  28. package/dist/esm/default-scripts.d.ts +2 -2
  29. package/dist/esm/default-scripts.js +5 -5
  30. package/dist/esm/glyphs.d.ts +9 -0
  31. package/dist/esm/glyphs.js +9 -0
  32. package/dist/esm/help.js +15 -9
  33. package/dist/esm/helpers/filter-selection.d.ts +42 -0
  34. package/dist/esm/helpers/filter-selection.js +61 -0
  35. package/dist/esm/output-style.d.ts +41 -0
  36. package/dist/esm/output-style.js +17 -0
  37. package/dist/esm/recording.d.ts +3 -0
  38. package/dist/esm/recording.js +6 -3
  39. package/dist/esm/resolveBinStyles.d.ts +12 -0
  40. package/dist/esm/resolveBinStyles.js +16 -0
  41. package/dist/esm/resolver.js +2 -2
  42. package/dist/esm/runCli.js +152 -62
  43. package/dist/esm/runner.js +6 -5
  44. package/dist/esm/steps.d.ts +8 -4
  45. package/dist/esm/steps.js +6 -2
  46. package/dist/esm/verdict.d.ts +7 -3
  47. package/dist/esm/verdict.js +16 -10
  48. package/dist/esm/vitest-source-resolution.d.ts +37 -0
  49. package/dist/esm/vitest-source-resolution.js +205 -0
  50. package/dist/esm/vitest.d.ts +3 -2
  51. package/dist/esm/vitest.js +7 -6
  52. package/dist/esm/workspace.d.ts +9 -0
  53. package/dist/esm/workspace.js +17 -0
  54. package/docs/check-cache.md +176 -0
  55. package/docs/prettier.md +47 -0
  56. package/docs/reporting.md +104 -0
  57. package/docs/scripts.md +168 -0
  58. package/docs/upgrades.md +110 -0
  59. package/docs/utilities.md +64 -0
  60. package/docs/vitest.md +233 -0
  61. package/docs/workspace.md +11 -0
  62. package/package.json +11 -9
package/agents/README.md CHANGED
@@ -11,7 +11,7 @@ A line earns a place in it only if both hold:
11
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
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
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.
14
+ Everything else belongs in the topic document under `packages/nmr/docs/` that covers it, reachable on demand at `node_modules/@williamthorsen/nmr/docs/`, with a pointer from the cheatsheet naming the topic and its document so an agent knows where to look. When adding a feature, document it there and extend that pointer rather than the cheatsheet.
15
15
 
16
16
  ## Constraints on rulebook bodies
17
17
 
@@ -10,8 +10,8 @@ delivery: ambient
10
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.
11
11
  - Never `npx nmr`: inside a git worktree it can resolve a different nmr from outside the tree. Fall back to `pnpm exec nmr`.
12
12
  - `nmr ci` runs what a code-quality workflow runs (build, then strict checks) and not the dependency audit, which belongs to a workflow of its own; `nmr prepush` runs the audit first, then `ci`, so a vulnerability stops the run before the long gate. Neither installs or binds a git hook.
13
- - A check that already passed on the current working tree skips, printing `⏭️ <scope>: <command>: passed …` and, where the recalled run left one, `— replayed: <excerpt>` of what that run concluded. Any edit, addition, or deletion re-runs it, so read the replayed excerpt rather than paying for the answer again: never pass `--no-cache` to get a "real" run, which costs the command's full runtime to reach the same answer. Its one use is a file rather than an exit status -- a skipped command writes none, so a fresh `coverage/` needs `nmr --no-cache test:coverage`. A composite replays its constituents' excerpts, each attributed as `<scope>: <command>: <excerpt>` and cut at the line's ceiling where there are too many to fit. A skip carrying no excerpt is one whose run wrote to a terminal, or one whose constituents recorded none.
13
+ - A check that already passed on the current working tree skips, printing `SKIP <scope>: <command>: passed …` and, where the recalled run left one, `— replayed: <excerpt>` of what that run concluded. Any edit, addition, or deletion re-runs it, so read the replayed excerpt rather than paying for the answer again: never pass `--no-cache` to get a "real" run, which costs the command's full runtime to reach the same answer. Its one use is a file rather than an exit status -- a skipped command writes none, so a fresh `coverage/` needs `nmr --no-cache test:coverage`. A composite replays its constituents' excerpts, each attributed as `<scope>: <command>: <excerpt>` and cut at the line's ceiling where there are too many to fit. A skip carrying no excerpt is one whose run wrote to a terminal, or one whose constituents recorded none.
14
14
  - `nmr --log <command>` prints the whole run behind that excerpt -- up to 256 KiB, led by when it was recorded, how long it ran, and the command string that produced it -- so output you need in full is a recording to read rather than a run to pay for again. It runs nothing, and it is held to the tree rather than to the terminal: a recording a piped run made prints for a reader at one. A composite prints its constituents' excerpts, so the transcript of one of them is `nmr -F <pkg> --log <command>` or that constituent's own name. Nothing to show is said on one line and exits non-zero, naming which it is.
15
- - Every command nmr runs reports one line -- `✅ <scope>: <command>: passed in 12.4s`, `❌ … failed in 1.2s (exit 1)`, `⏭️ … passed 4m ago on this tree`, or `⛔ … skipped, the override is empty` -- and a composite reports alongside every command it expands into. The lines print in every verbosity, so `-q` is the low-noise way to run rather than a silent one: it withholds the output of the commands nmr runs and keeps nmr's own verdicts, which is how a pass, a cached skip, and a `""`/`":"` override stay distinguishable. Expect a run to be quiet already without the flag: nmr recognizes a known agent harness in the environment and takes quiet from that, as it does from `NMR_COMMAND_VERBOSITY` or the repo's `output.commandVerbosity`. A command whose output you need in full is one to run with `NMR_COMMAND_VERBOSITY=full`, which outranks both.
16
- - Every test file lives under a `__tests__` directory and names its isolation tier last: `<subject>[.<aspect>].<tier>.test.ts`, where any earlier segment is free-form documentation. A tier is the furthest thing the test reaches -- `tool` (a program the environment supplies), `localhost` (a service on this machine), `remote` (a machine that isn't this one), or `unit`, the residual claiming everything else. A file outside `__tests__`, or one whose tier segment is missing or misspelt, fails the test run in a repo that declares nmr's exported conventions check, which reports both. In a repo that has not, neither fails anything -- the first is collected by nothing and the second runs under `unit` -- and `rdy run --packages` reports the misspelt tier alone.
17
- - The rest is in `node_modules/@williamthorsen/nmr/README.md`: pre/post hooks (every `nmr X` auto-wraps as `nmr X:pre && nmr X && nmr X:post`), script overrides and their `""`/`":"` skip values, `nmr upgrade` ceilings (report-only until `nmr upgrade --write`), the `nmr-compile` build (there is no repo-local build script), the test tiers' timeout budgets and the `tiers` seam that raises one, which script reaches which tier, and the check-result cache's key, the retention key a replayed excerpt is additionally held to, the retention ceiling `--log` prints within, its `checkCache` config, and every condition that disables it.
15
+ - Every command nmr runs reports one line -- `PASS <scope>: <command>: passed in 12.4s`, `FAIL … failed in 1.2s (exit 1)`, `SKIP … passed 4m ago on this tree`, or `NOOP … skipped, the override is empty` -- and a composite reports alongside every command it expands into. Those are the words a pipe and a CI log get; a terminal gets ✅, ❌, ⏩, and ⚪ in their place, and `--output-style rich` or `NMR_OUTPUT_STYLE=rich` forces the emoji on any stream. The lines print in every verbosity, so `-q` is the low-noise way to run rather than a silent one: it withholds the output of the commands nmr runs and keeps nmr's own verdicts, which is how a pass, a cached skip, and a `""`/`":"` override stay distinguishable. Expect a run to be quiet already without the flag: nmr recognizes a known agent harness in the environment and takes quiet from that, as it does from `NMR_COMMAND_VERBOSITY` or the repo's `output.commandVerbosity`. A command whose output you need in full is one to run with `NMR_COMMAND_VERBOSITY=full`, which outranks both.
16
+ - Every test file lives under a `__tests__` directory and names its isolation tier last: `<subject>[.<aspect>].<tier>.test.ts`, where any earlier segment is free-form documentation. A tier is the furthest thing the test reaches -- `tool` (a program the environment supplies), `localhost` (a service on this machine), `remote` (a machine that isn't this one), or `unit`, the residual claiming everything else. A file outside `__tests__`, or one whose tier segment is missing or misspelt, fails the test run in a repo that declares nmr's exported conventions check, which reports both. In a repo that has not, neither fails anything -- the first is collected by nothing and the second runs under `unit` -- and `rdy run --packages` reports both and warns that the check is missing.
17
+ - The rest is in `node_modules/@williamthorsen/nmr/docs/`: `scripts.md` for pre/post hooks (every `nmr X` auto-wraps as `nmr X:pre && nmr X && nmr X:post`) and script overrides and their `""`/`":"` skip values; `upgrades.md` for `nmr upgrade` ceilings (report-only until `nmr upgrade --write`); `utilities.md` for the `nmr-compile` build (there is no repo-local build script); `vitest.md` for the test tiers' timeout budgets, the `tiers` seam that raises one, and which script reaches which tier; and `check-cache.md` for the check-result cache's key, the retention key a replayed excerpt is additionally held to, the retention ceiling `--log` prints within, its `checkCache` config, and every condition that disables it.
@@ -1,4 +1,5 @@
1
1
  import type { Writable } from 'node:stream';
2
+ import { type OutputStyle } from '@williamthorsen/nmr-core';
2
3
  import type { OutputChannels } from './runner.js';
3
4
  import type { CheckCacheConfig, NmrConfig } from './types.js';
4
5
  /** A recorded pass: what ran, on which tree, and what it cost. */
@@ -159,7 +160,7 @@ export declare function findStaleBuildOutput(earlier: Record<string, string>, la
159
160
  * command rather than a flag to nmr. Passing it on unchanged is the honest thing to do with an argument, so
160
161
  * the warning is all that separates this from a silently un-bypassed run.
161
162
  */
162
- export declare function formatMisplacedNoCacheWarning(command: string): string;
163
+ export declare function formatMisplacedNoCacheWarning(command: string, style: OutputStyle): string;
163
164
  /**
164
165
  * Reports whether a command's passes are recorded at all, which is what separates a command with no recording
165
166
  * from one that could never have had one.
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { readFileSync, realpathSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import process from 'node:process';
5
- import { hashWorkingTree, readCacheEntry, readHeadSha, readJsonCacheEntry, removeCacheDir, removeCacheEntry, resolveCacheEntryPath, writeCacheEntry, } from '@williamthorsen/nmr-core';
5
+ import { hashWorkingTree, readCacheEntry, readHeadSha, readJsonCacheEntry, removeCacheDir, removeCacheEntry, resolveCacheEntryPath, STATUS_GLYPHS, writeCacheEntry, } from '@williamthorsen/nmr-core';
6
6
  import { describeError } from '@williamthorsen/toolbelt.errors';
7
7
  import { hasBuildOutput, readBuildDigest } from "./commands/build-output.js";
8
8
  import { loadWorkspaceConfig } from "./config.js";
@@ -107,9 +107,9 @@ export function findStaleBuildOutput(earlier, later) {
107
107
  const names = [...new Set([...Object.keys(earlier), ...Object.keys(later)])].toSorted();
108
108
  return names.find((name) => earlier[name] !== later[name]);
109
109
  }
110
- export function formatMisplacedNoCacheWarning(command) {
111
- return (`⚠️ --no-cache after the command name is passed to \`${command}\`, not read by nmr. ` +
112
- `Did you mean \`nmr --no-cache ${command}\`?`);
110
+ export function formatMisplacedNoCacheWarning(command, style) {
111
+ return (`${STATUS_GLYPHS[style].warning.text} --no-cache after the command name is passed to \`${command}\`, ` +
112
+ `not read by nmr. Did you mean \`nmr --no-cache ${command}\`?`);
113
113
  }
114
114
  export function isCacheableCommand(checkCache, command) {
115
115
  return !isHookName(command) && checkCache?.enabled !== false && resolveCacheableCommands(checkCache).has(command);
@@ -2,10 +2,13 @@ import { reportError } from '@williamthorsen/nmr-core';
2
2
  import { describeError } from '@williamthorsen/toolbelt.errors';
3
3
  import { buildPackage } from "./commands/build.js";
4
4
  import { loadWorkspaceConfig } from "./config.js";
5
+ import { resolveBinStyles } from "./resolveBinStyles.js";
5
6
  try {
7
+ const { stdout } = resolveBinStyles();
6
8
  const packageDir = process.cwd();
7
9
  const { build } = await loadWorkspaceConfig(packageDir);
8
10
  await buildPackage(packageDir, {
11
+ style: stdout,
9
12
  ...(build?.extraIgnorePatterns !== undefined && { extraIgnorePatterns: build.extraIgnorePatterns }),
10
13
  });
11
14
  }
@@ -1,8 +1,10 @@
1
+ import process from 'node:process';
1
2
  import { reportError } from '@williamthorsen/nmr-core';
2
3
  import { describeError } from '@williamthorsen/toolbelt.errors';
3
4
  import { runClean } from "./commands/clean.js";
5
+ import { resolveBinStyles } from "./resolveBinStyles.js";
4
6
  try {
5
- await runClean(process.cwd());
7
+ await runClean(process.cwd(), resolveBinStyles().stdout);
6
8
  }
7
9
  catch (error) {
8
10
  reportError(describeError(error));
@@ -1,6 +1,7 @@
1
1
  import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
2
2
  import { describeError } from '@williamthorsen/toolbelt.errors';
3
3
  import { DEFAULT_HOOK, ensurePrepublishHooks, reportPrepublishHooks } from "./commands/ensure-prepublish-hooks.js";
4
+ import { resolveBinStyles } from "./resolveBinStyles.js";
4
5
  import { findMonorepoRoot } from "./workspace.js";
5
6
  const flagSchema = {
6
7
  fix: { long: '--fix', type: 'boolean' },
@@ -9,10 +10,11 @@ const flagSchema = {
9
10
  };
10
11
  const { fix, dryRun, command } = parseArgsOrExit(process.argv.slice(2), flagSchema).flags;
11
12
  try {
13
+ const { stdout } = resolveBinStyles();
12
14
  const monorepoRoot = findMonorepoRoot();
13
15
  const options = command ? { fix, dryRun, command } : { fix, dryRun };
14
16
  const result = ensurePrepublishHooks(monorepoRoot, options);
15
- reportPrepublishHooks(result, command ?? DEFAULT_HOOK);
17
+ reportPrepublishHooks(result, command ?? DEFAULT_HOOK, stdout);
16
18
  if (result.hasFailures) {
17
19
  process.exitCode = 1;
18
20
  }
@@ -2,8 +2,9 @@ import process from 'node:process';
2
2
  import { reportError } from '@williamthorsen/nmr-core';
3
3
  import { describeError } from '@williamthorsen/toolbelt.errors';
4
4
  import { reportCatalog } from "./commands/report-catalog.js";
5
+ import { resolveBinStyles } from "./resolveBinStyles.js";
5
6
  try {
6
- reportCatalog(process.cwd());
7
+ reportCatalog(process.cwd(), resolveBinStyles().stderr);
7
8
  }
8
9
  catch (error) {
9
10
  reportError(describeError(error));
@@ -1,10 +1,11 @@
1
1
  import { reportError } from '@williamthorsen/nmr-core';
2
2
  import { describeError } from '@williamthorsen/toolbelt.errors';
3
3
  import { reportOverrides } from "./commands/report-overrides.js";
4
+ import { resolveBinStyles } from "./resolveBinStyles.js";
4
5
  import { findMonorepoRoot } from "./workspace.js";
5
6
  try {
6
7
  const monorepoRoot = findMonorepoRoot();
7
- reportOverrides(monorepoRoot);
8
+ reportOverrides(monorepoRoot, resolveBinStyles().stderr);
8
9
  }
9
10
  catch (error) {
10
11
  reportError(describeError(error));
@@ -1,3 +1,4 @@
1
+ import { type OutputStyle } from '@williamthorsen/nmr-core';
1
2
  import { type BuildOptions } from './build-output.js';
2
3
  /**
3
4
  * The toolchain a build's emit comes from: the resolved compiler and the nmr running it. Both join the build
@@ -7,6 +8,10 @@ export interface BuildToolchain {
7
8
  compilerVersion: string;
8
9
  fingerprint: string;
9
10
  }
11
+ /** What a build needs beyond its sources: the options shaping the emit, and the style it reports in. */
12
+ export interface BuildPackageOptions extends BuildOptions {
13
+ style: OutputStyle;
14
+ }
10
15
  /**
11
16
  * Compiles a package's `src` tree to `dist/esm` with the TypeScript compiler API, emitting `.js` and
12
17
  * `.d.ts` from two programs and rewriting relative `.ts` specifiers and tsconfig `paths` aliases to
@@ -17,7 +22,7 @@ export interface BuildToolchain {
17
22
  * current inputs rather than an accumulation of every build that ever ran. Assets belong outside it, or in a
18
23
  * `build:post` hook, which runs after the output is published.
19
24
  */
20
- export declare function buildPackage(packageDir: string, options?: BuildOptions): Promise<void>;
25
+ export declare function buildPackage(packageDir: string, options: BuildPackageOptions): Promise<void>;
21
26
  /**
22
27
  * Produces a digest of the given files (paths and contents), the emit config, and the toolchain.
23
28
  * The file list is sorted so the digest is order-invariant, and each path is folded in so renames are detected.
@@ -2,19 +2,18 @@ import { createHash } from 'node:crypto';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { readFile, rename, rm } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
- import { readCacheEntry, writeCacheEntry } from '@williamthorsen/nmr-core';
5
+ import { formatGlyphLine, readCacheEntry, STATUS_GLYPHS, writeCacheEntry, } from '@williamthorsen/nmr-core';
6
6
  import { glob } from 'glob';
7
7
  import * as ts from 'typescript';
8
+ import { NMR_GLYPHS } from "../glyphs.js";
8
9
  import { resolveConfigPath } from "../helpers/config-path.js";
9
10
  import { DEFAULT_ENTRY_GLOBS, DEFAULT_IGNORE_PATTERNS, DEFAULT_OUTDIR, hasExpectedBuildOutput, resolveBuildCachePath, resolveScratchDirs, resolveToolchainFingerprint, } from "./build-output.js";
10
- const PACKAGE_ICON = '📦';
11
- const SKIPPED_ICON = '⏭️';
12
11
  const SOURCE_ROOT = 'src';
13
12
  const MINIMUM_TYPESCRIPT_MAJOR = 5;
14
13
  const MINIMUM_TYPESCRIPT_MINOR = 7;
15
14
  const TS_EXTENSION = '.ts';
16
15
  const JS_EXTENSION = '.js';
17
- export async function buildPackage(packageDir, options = {}) {
16
+ export async function buildPackage(packageDir, options) {
18
17
  assertSupportedTypeScript();
19
18
  const cachePath = resolveBuildCachePath(packageDir);
20
19
  const outdir = options.outdir ?? DEFAULT_OUTDIR;
@@ -34,14 +33,14 @@ export async function buildPackage(packageDir, options = {}) {
34
33
  compilerVersion: ts.version,
35
34
  fingerprint: await resolveToolchainFingerprint(packageDir),
36
35
  };
37
- const { changed, currentHash } = await detectBuildChanges(packageDir, [...entryPoints, ...dependencies], emitConfig, toolchain, cachePath, hasExpectedBuildOutput(packageDir, outdir, entryPoints));
36
+ const { changed, currentHash } = await detectBuildChanges(packageDir, [...entryPoints, ...dependencies], emitConfig, toolchain, cachePath, hasExpectedBuildOutput(packageDir, outdir, entryPoints), options.style);
38
37
  if (!changed) {
39
38
  await discardScratchDirs(resolveScratchDirs(emitDir));
40
39
  return;
41
40
  }
42
41
  const emittedFileCount = await emitPackage(packageDir, entryPoints, outdir);
43
42
  await writeCacheEntry(cachePath, currentHash);
44
- console.info(`${PACKAGE_ICON} ${path.basename(packageDir)}: ${describeEmit(emittedFileCount, outdir)}`);
43
+ console.info(formatGlyphLine(NMR_GLYPHS, options.style, 'package', `${path.basename(packageDir)}: ${describeEmit(emittedFileCount, outdir)}`));
45
44
  }
46
45
  export async function computeBuildHash(packageDir, files, emitConfig, toolchain) {
47
46
  const hash = createHash('sha256');
@@ -275,19 +274,19 @@ function getModuleSpecifier(node) {
275
274
  }
276
275
  return undefined;
277
276
  }
278
- async function detectBuildChanges(packageDir, files, emitConfig, toolchain, cachePath, outputPresent) {
277
+ async function detectBuildChanges(packageDir, files, emitConfig, toolchain, cachePath, outputPresent, style) {
279
278
  const packageName = path.basename(packageDir);
280
279
  const previousHash = await readCacheEntry(cachePath);
281
280
  const currentHash = await computeBuildHash(packageDir, files, emitConfig, toolchain);
282
281
  if (previousHash === currentHash) {
283
282
  if (outputPresent) {
284
- console.info(`${SKIPPED_ICON} ${packageName}: No changes detected. Skipping build.`);
283
+ console.info(`${STATUS_GLYPHS[style].skipped.text} ${packageName}: No changes detected. Skipping build.`);
285
284
  return { changed: false, currentHash };
286
285
  }
287
- console.info(`${PACKAGE_ICON} ${packageName}: Build output is missing. Rebuilding.`);
286
+ console.info(formatGlyphLine(NMR_GLYPHS, style, 'package', `${packageName}: Build output is missing. Rebuilding.`));
288
287
  return { changed: true, currentHash };
289
288
  }
290
- console.info(`${PACKAGE_ICON} ${packageName}: Changes detected.`);
289
+ console.info(formatGlyphLine(NMR_GLYPHS, style, 'package', `${packageName}: Changes detected.`));
291
290
  return { changed: true, currentHash };
292
291
  }
293
292
  function assertSupportedTypeScript() {
@@ -1,8 +1,9 @@
1
+ import { type OutputStyle } from '@williamthorsen/nmr-core';
1
2
  /**
2
3
  * Removes a package's build output and the build-cache entry that describes it, so no state survives to
3
4
  * make the next build skip. Removal is idempotent: an already-clean package is a silent no-op.
4
5
  */
5
- export declare function cleanPackage(packageDir: string): Promise<void>;
6
+ export declare function cleanPackage(packageDir: string, style: OutputStyle): Promise<void>;
6
7
  /**
7
8
  * Cleans the package containing `cwd`, or every workspace package when run from the monorepo root.
8
9
  *
@@ -14,4 +15,4 @@ export declare function cleanPackage(packageDir: string): Promise<void>;
14
15
  * a `rimraf` in nmr's dependency tree would be unreachable — while nmr's own bins are linked into the
15
16
  * consumer's `node_modules/.bin`, nmr being a direct dependency.
16
17
  */
17
- export declare function runClean(cwd?: string): Promise<void>;
18
+ export declare function runClean(cwd: string, style: OutputStyle): Promise<void>;
@@ -1,52 +1,53 @@
1
1
  import { rm } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { formatGlyphLine } from '@williamthorsen/nmr-core';
3
4
  import { removeCheckCache } from "../check-cache.js";
4
5
  import { loadRootConfig } from "../config.js";
5
6
  import { findContainingPackageDir } from "../context.js";
7
+ import { NMR_GLYPHS } from "../glyphs.js";
6
8
  import { reportClosing } from "../helpers/reportClosing.js";
7
9
  import { applyDevBin, buildWorkspaceRegistry, resolveScript } from "../resolver.js";
8
10
  import { resolveChannel, runCommand } from "../runner.js";
9
11
  import { renderChain } from "../steps.js";
10
12
  import { findMonorepoRoot, getWorkspacePackageDirs } from "../workspace.js";
11
13
  import { resolveBuildCachePath } from "./build-output.js";
12
- const CLEAN_ICON = '🧹';
13
14
  const CLEAN_COMMAND = 'clean';
14
15
  const BUILT_IN_CLEAN = 'nmr-clean';
15
16
  const OUTPUT_ROOT = 'dist';
16
- export async function cleanPackage(packageDir) {
17
+ export async function cleanPackage(packageDir, style) {
17
18
  await rm(path.resolve(packageDir, OUTPUT_ROOT), { recursive: true, force: true });
18
19
  await rm(resolveBuildCachePath(packageDir), { force: true });
19
- console.info(`${CLEAN_ICON} ${path.basename(packageDir)}: Removed build output and cache.`);
20
+ console.info(formatGlyphLine(NMR_GLYPHS, style, 'clean', `${path.basename(packageDir)}: Removed build output and cache.`));
20
21
  }
21
- export async function runClean(cwd = process.cwd()) {
22
+ export async function runClean(cwd, style) {
22
23
  let monorepoRoot;
23
24
  try {
24
25
  monorepoRoot = findMonorepoRoot(cwd);
25
26
  }
26
27
  catch {
27
- await clearCheckCache(cwd);
28
- await cleanPackage(cwd);
28
+ await clearCheckCache(cwd, style);
29
+ await cleanPackage(cwd, style);
29
30
  return;
30
31
  }
31
- await clearCheckCache(monorepoRoot);
32
+ await clearCheckCache(monorepoRoot, style);
32
33
  const workspacePackageDirs = getWorkspacePackageDirs(monorepoRoot);
33
34
  const packageDir = findContainingPackageDir(cwd, workspacePackageDirs);
34
35
  if (packageDir !== undefined) {
35
- await cleanPackage(packageDir);
36
+ await cleanPackage(packageDir, style);
36
37
  return;
37
38
  }
38
- await sweepWorkspace(monorepoRoot, workspacePackageDirs);
39
+ await sweepWorkspace(monorepoRoot, workspacePackageDirs, style);
39
40
  }
40
- async function clearCheckCache(scopeDir) {
41
+ async function clearCheckCache(scopeDir, style) {
41
42
  await removeCheckCache(scopeDir);
42
- console.info(`${CLEAN_ICON} Removed all recorded check results.`);
43
+ console.info(formatGlyphLine(NMR_GLYPHS, style, 'clean', 'Removed all recorded check results.'));
43
44
  }
44
45
  function describeSweep(cleaned, skipped) {
45
46
  const packages = cleaned === 1 ? '1 package' : `${cleaned} packages`;
46
47
  const skipClause = skipped === 0 ? '' : `, skipping ${skipped} with an empty clean override`;
47
48
  return `Cleaned ${packages}${skipClause}.`;
48
49
  }
49
- async function sweepWorkspace(monorepoRoot, workspacePackageDirs) {
50
+ async function sweepWorkspace(monorepoRoot, workspacePackageDirs, style) {
50
51
  const config = await loadRootConfig(monorepoRoot);
51
52
  const registry = buildWorkspaceRegistry(config);
52
53
  let cleaned = 0;
@@ -59,7 +60,7 @@ async function sweepWorkspace(monorepoRoot, workspacePackageDirs) {
59
60
  continue;
60
61
  }
61
62
  if (resolvedCommand === BUILT_IN_CLEAN) {
62
- await cleanPackage(packageDir);
63
+ await cleanPackage(packageDir, style);
63
64
  cleaned++;
64
65
  continue;
65
66
  }
@@ -72,5 +73,5 @@ async function sweepWorkspace(monorepoRoot, workspacePackageDirs) {
72
73
  }
73
74
  cleaned++;
74
75
  }
75
- reportClosing(`${CLEAN_ICON} ${describeSweep(cleaned, skipped)}`);
76
+ reportClosing(formatGlyphLine(NMR_GLYPHS, style, 'clean', describeSweep(cleaned, skipped)));
76
77
  }
@@ -1,3 +1,4 @@
1
+ import { type OutputStyle } from '@williamthorsen/nmr-core';
1
2
  export interface PackageHookStatus {
2
3
  packageName: string;
3
4
  packageDir: string;
@@ -23,7 +24,7 @@ export declare function ensurePrepublishHooks(monorepoRoot: string, options: {
23
24
  * Reports one line per publishable package and closes with what the run came to. Private packages appear in
24
25
  * neither: they publish nothing, so a `prepublishOnly` is not theirs to carry.
25
26
  *
26
- * Every line goes to stdout, the `✗` included, and the exit code is what carries a failure. A report split
27
+ * Every line goes to stdout, a failure's included, and the exit code is what carries a failure. A report split
27
28
  * across two streams leaves no reader holding the whole list.
28
29
  */
29
- export declare function reportPrepublishHooks(result: EnsurePrepublishHooksResult, hookCommand: string): void;
30
+ export declare function reportPrepublishHooks(result: EnsurePrepublishHooksResult, hookCommand: string, style: OutputStyle): void;
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { STATUS_GLYPHS } from '@williamthorsen/nmr-core';
3
4
  import { readPackageJson } from "../helpers/package-json.js";
4
5
  import { reportClosing } from "../helpers/reportClosing.js";
5
6
  import { isObject } from "../helpers/type-guards.js";
@@ -60,14 +61,14 @@ export function ensurePrepublishHooks(monorepoRoot, options) {
60
61
  const hasFailures = packages.some((p) => p.action === 'missing');
61
62
  return { packages, hasFailures };
62
63
  }
63
- export function reportPrepublishHooks(result, hookCommand) {
64
+ export function reportPrepublishHooks(result, hookCommand, style) {
64
65
  const publishable = result.packages.filter((pkg) => !pkg.isPrivate);
65
66
  if (publishable.length === 0) {
66
67
  console.info('No publishable packages found.');
67
68
  return;
68
69
  }
69
70
  for (const pkg of publishable) {
70
- console.info(renderHookStatus(pkg, hookCommand));
71
+ console.info(renderHookStatus(pkg, hookCommand, style));
71
72
  }
72
73
  reportClosing(describeHookRun(publishable));
73
74
  }
@@ -102,14 +103,15 @@ function describeHookRun(publishable) {
102
103
  }
103
104
  return `${packages} ${publishable.length === 1 ? 'has' : 'have'} prepublishOnly.`;
104
105
  }
105
- function renderHookStatus(pkg, hookCommand) {
106
+ function renderHookStatus(pkg, hookCommand, style) {
107
+ const statuses = STATUS_GLYPHS[style];
106
108
  switch (pkg.action) {
107
109
  case 'ok':
108
- return `✓ ${pkg.packageName}: prepublishOnly = "${pkg.prepublishOnly}"`;
110
+ return `${statuses.passed.text} ${pkg.packageName}: prepublishOnly = "${pkg.prepublishOnly}"`;
109
111
  case 'missing':
110
- return `✗ ${pkg.packageName}: missing prepublishOnly`;
112
+ return `${statuses.failed.text} ${pkg.packageName}: missing prepublishOnly`;
111
113
  case 'fixed':
112
- return `✓ ${pkg.packageName}: added prepublishOnly = "${hookCommand}"`;
114
+ return `${statuses.passed.text} ${pkg.packageName}: added prepublishOnly = "${hookCommand}"`;
113
115
  case 'would-fix':
114
116
  return `~ ${pkg.packageName}: would add prepublishOnly = "${hookCommand}"`;
115
117
  }
@@ -1,3 +1,4 @@
1
+ import { type OutputStyle } from '@williamthorsen/nmr-core';
1
2
  /**
2
3
  * Reports the catalogued dependencies a package-scoped upgrade pass cannot see.
3
4
  *
@@ -8,4 +9,4 @@
8
9
  *
9
10
  * Runs ahead of the report produced by the workspace `upgrade` script.
10
11
  */
11
- export declare function reportCatalog(cwd: string): void;
12
+ export declare function reportCatalog(cwd: string, style: OutputStyle): void;
@@ -1,10 +1,11 @@
1
+ import { formatGlyphLine } from '@williamthorsen/nmr-core';
1
2
  import { findContainingPackageDir } from "../context.js";
3
+ import { NMR_GLYPHS } from "../glyphs.js";
2
4
  import { DEPENDENCY_FIELDS, readPackageJson } from "../helpers/package-json.js";
3
5
  import { reportClosing } from "../helpers/reportClosing.js";
4
6
  import { findMonorepoRoot, getWorkspacePackageDirs } from "../workspace.js";
5
- const CATALOG_ICON = '📚';
6
7
  const CATALOG_PROTOCOL = 'catalog:';
7
- export function reportCatalog(cwd) {
8
+ export function reportCatalog(cwd, style) {
8
9
  const monorepoRoot = findMonorepoRoot(cwd);
9
10
  const packageDir = findContainingPackageDir(cwd, getWorkspacePackageDirs(monorepoRoot));
10
11
  if (packageDir === undefined) {
@@ -14,11 +15,11 @@ export function reportCatalog(cwd) {
14
15
  if (dependencies.length === 0) {
15
16
  return;
16
17
  }
17
- console.warn(`${CATALOG_ICON} WARN: A package-scoped upgrade does not read the catalogs these come from:`);
18
+ console.warn(formatGlyphLine(NMR_GLYPHS, style, 'catalog', 'WARN: A package-scoped upgrade does not read the catalogs these come from:'));
18
19
  for (const { name, specifier } of dependencies) {
19
20
  console.warn(`- ${name} → ${specifier}`);
20
21
  }
21
- reportClosing(`${CATALOG_ICON} ${describeCatalog(dependencies.length, monorepoRoot)}`, console.warn);
22
+ reportClosing(formatGlyphLine(NMR_GLYPHS, style, 'catalog', describeCatalog(dependencies.length, monorepoRoot)), console.warn);
22
23
  }
23
24
  function describeCatalog(count, monorepoRoot) {
24
25
  const subject = count === 1 ? '1 catalogued dependency went' : `${count} catalogued dependencies went`;
@@ -1,7 +1,8 @@
1
+ import { type OutputStyle } from '@williamthorsen/nmr-core';
1
2
  /**
2
3
  * Reports the pnpm dependency overrides declared in the monorepo root's `pnpm-workspace.yaml`, and rejects a
3
4
  * `pnpm.overrides` block left behind in the root `package.json`.
4
5
  *
5
6
  * Runs ahead of the report produced by the root `upgrade` script.
6
7
  */
7
- export declare function reportOverrides(monorepoRoot: string): void;
8
+ export declare function reportOverrides(monorepoRoot: string, style: OutputStyle): void;
@@ -1,16 +1,17 @@
1
+ import { formatGlyphLine } from '@williamthorsen/nmr-core';
2
+ import { NMR_GLYPHS } from "../glyphs.js";
1
3
  import { getPnpmOverrides, readPackageJson } from "../helpers/package-json.js";
2
4
  import { reportClosing } from "../helpers/reportClosing.js";
3
5
  import { UserError } from "../UserError.js";
4
6
  import { readWorkspaceOverrides } from "../workspace.js";
5
- const OVERRIDES_ICON = '🔒';
6
- export function reportOverrides(monorepoRoot) {
7
+ export function reportOverrides(monorepoRoot, style) {
7
8
  const declared = listEntries(readWorkspaceOverrides(monorepoRoot));
8
9
  if (declared.length > 0) {
9
- console.warn(`${OVERRIDES_ICON} WARN: pnpm overrides are active:`);
10
+ console.warn(formatGlyphLine(NMR_GLYPHS, style, 'overrides', 'WARN: pnpm overrides are active:'));
10
11
  for (const [name, version] of declared) {
11
12
  console.warn(`- ${name} → ${version}`);
12
13
  }
13
- reportClosing(`${OVERRIDES_ICON} ${describeOverrides(declared.length)}`, console.warn);
14
+ reportClosing(formatGlyphLine(NMR_GLYPHS, style, 'overrides', describeOverrides(declared.length)), console.warn);
14
15
  }
15
16
  rejectLegacyOverrides(monorepoRoot);
16
17
  }
@@ -33,7 +33,8 @@ const RECOGNIZED_KEYS = [...CONFIG_TIERS.root.honoredKeys, ...CONFIG_TIERS.works
33
33
  const RECOGNIZED_BUILD_KEYS = ['extraIgnorePatterns'];
34
34
  const RECOGNIZED_CHECK_CACHE_KEYS = ['enabled', 'excludeCommands', 'extraCommands'];
35
35
  const RECOGNIZED_OUTPUT_KEYS = ['commandVerbosity', 'extraAgentEnvVars'];
36
- const RECOGNIZED_STEP_KEYS = ['declinesArgs', 'run'];
36
+ const RECOGNIZED_STEP_KEYS = ['run', 'shouldDeclineArguments'];
37
+ const RETIRED_STEP_KEYS = new Map([['declinesArgs', 'shouldDeclineArguments']]);
37
38
  function isScriptRecord(value) {
38
39
  if (!isObject(value))
39
40
  return false;
@@ -50,8 +51,8 @@ function isScriptElement(value) {
50
51
  return true;
51
52
  if (!isObject(value) || typeof value['run'] !== 'string')
52
53
  return false;
53
- const declinesArgs = value['declinesArgs'];
54
- return declinesArgs === undefined || typeof declinesArgs === 'boolean';
54
+ const shouldDeclineArguments = value['shouldDeclineArguments'];
55
+ return shouldDeclineArguments === undefined || typeof shouldDeclineArguments === 'boolean';
55
56
  }
56
57
  function validateScriptField(value, fieldName, configPath) {
57
58
  if (!Object.hasOwn(value, fieldName) || value[fieldName] === undefined) {
@@ -60,7 +61,7 @@ function validateScriptField(value, fieldName, configPath) {
60
61
  const scripts = value[fieldName];
61
62
  if (!isScriptRecord(scripts)) {
62
63
  throw new UserError(`Invalid nmr config at ${configPath}: \`${fieldName}\` must be a Record<string, string | element[]>, ` +
63
- 'where an element is a command string or `{ run: string, declinesArgs?: boolean }`');
64
+ 'where an element is a command string or `{ run: string, shouldDeclineArguments?: boolean }`');
64
65
  }
65
66
  assertValidElements(scripts, fieldName, configPath);
66
67
  return scripts;
@@ -71,6 +72,7 @@ function assertValidElements(scripts, fieldName, configPath) {
71
72
  continue;
72
73
  for (const spec of script) {
73
74
  if (typeof spec !== 'string') {
75
+ assertNoRetiredKeys(spec, RETIRED_STEP_KEYS, configPath, `${fieldName}.${command}.`);
74
76
  assertRecognizedKeys(spec, RECOGNIZED_STEP_KEYS, configPath, `${fieldName}.${command}.`);
75
77
  }
76
78
  const element = typeof spec === 'string' ? spec : spec.run;
@@ -270,6 +272,14 @@ export async function loadWorkspaceConfig(packageDir) {
270
272
  assertTierKeys(config, CONFIG_TIERS.workspace, resolveConfigPath(packageDir));
271
273
  return config;
272
274
  }
275
+ function assertNoRetiredKeys(value, retiredKeys, configPath, prefix) {
276
+ for (const key of Object.keys(value)) {
277
+ const replacement = retiredKeys.get(key);
278
+ if (replacement === undefined)
279
+ continue;
280
+ throw new UserError(`Invalid nmr config at ${configPath}: \`${prefix}${key}\` was renamed to \`${prefix}${replacement}\`.`);
281
+ }
282
+ }
273
283
  function assertRecognizedKeys(value, recognizedKeys, configPath, prefix = '') {
274
284
  const unrecognized = Object.keys(value).filter((key) => !recognizedKeys.includes(key));
275
285
  if (unrecognized.length === 0) {
@@ -3,6 +3,8 @@ export interface ResolvedContext {
3
3
  monorepoRoot: string;
4
4
  isRoot: boolean;
5
5
  packageDir?: string;
6
+ /** The workspace's package directories, resolved once here rather than swept for again by each reader. */
7
+ workspacePackageDirs: string[];
6
8
  config: NmrConfig;
7
9
  }
8
10
  /**
@@ -24,6 +24,7 @@ export async function resolveContext(cwd) {
24
24
  monorepoRoot,
25
25
  isRoot: packageDir === undefined,
26
26
  ...(packageDir !== undefined && { packageDir }),
27
+ workspacePackageDirs: workspaceDirs,
27
28
  config,
28
29
  };
29
30
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
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.
3
+ * string form of an element is this spec with `shouldDeclineArguments` left at its default.
4
4
  */
5
5
  export interface StepSpec {
6
6
  /** A command name, optionally preceded by nmr's own flags, as it would be typed after `nmr`. */
@@ -9,7 +9,7 @@ export interface StepSpec {
9
9
  * Set where the trailing arguments cannot narrow this step's work: it is a prerequisite the narrowed steps
10
10
  * run against, or the tool it reaches would be misled by them. A declining step runs unnarrowed.
11
11
  */
12
- declinesArgs?: boolean;
12
+ shouldDeclineArguments?: boolean;
13
13
  }
14
14
  export type ScriptValue = string | ReadonlyArray<string | StepSpec>;
15
15
  export type ScriptRegistry = Record<string, ScriptValue>;
@@ -1,6 +1,6 @@
1
1
  const GATE_PROJECTS = '--project unit --project tool';
2
- const TYPECHECK_STEP = { run: 'typecheck', declinesArgs: true };
3
- const ROOT_TYPECHECK_STEP = { run: 'root:typecheck', declinesArgs: true };
2
+ const TYPECHECK_STEP = { run: 'typecheck', shouldDeclineArguments: true };
3
+ const ROOT_TYPECHECK_STEP = { run: 'root:typecheck', shouldDeclineArguments: true };
4
4
  export const workspaceScripts = {
5
5
  build: ['compile'],
6
6
  check: [TYPECHECK_STEP, 'fmt:check', 'lint:check', 'test'],
@@ -32,7 +32,7 @@ export const rootScripts = {
32
32
  build: ['-R build'],
33
33
  check: [TYPECHECK_STEP, 'fmt:check', 'lint:check', 'test'],
34
34
  'check:strict': [TYPECHECK_STEP, 'fmt:check', 'lint:strict', 'test'],
35
- ci: [{ run: 'build', declinesArgs: true }, 'check:strict'],
35
+ ci: [{ run: 'build', shouldDeclineArguments: true }, 'check:strict'],
36
36
  clean: 'nmr-clean',
37
37
  fix: ['lint', 'fmt'],
38
38
  'fix:check': ['fmt:check', 'lint:check'],
@@ -41,7 +41,7 @@ export const rootScripts = {
41
41
  lint: 'eslint --fix .',
42
42
  'lint:check': 'eslint .',
43
43
  'lint:strict': 'strict-lint',
44
- prepush: [{ run: 'audit', declinesArgs: true }, 'ci'],
44
+ prepush: [{ run: 'audit', shouldDeclineArguments: true }, 'ci'],
45
45
  'report-overrides': 'nmr-report-overrides',
46
46
  'root:check': [ROOT_TYPECHECK_STEP, 'fmt:check', 'root:lint:check', 'root:test'],
47
47
  'root:lint': "eslint --fix --ignore-pattern 'packages/**' .",
@@ -59,6 +59,6 @@ export const rootScripts = {
59
59
  'test:tool': ['root:test:tool', '-R test:tool'],
60
60
  'test:unit': ['root:test:unit', '-R test:unit'],
61
61
  'test:watch': `vitest ${GATE_PROJECTS} --watch`,
62
- typecheck: [ROOT_TYPECHECK_STEP, { run: '-R typecheck', declinesArgs: true }],
62
+ typecheck: [ROOT_TYPECHECK_STEP, { run: '-R typecheck', shouldDeclineArguments: true }],
63
63
  upgrade: 'nmr-report-overrides && nmr-taze --recursive',
64
64
  };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Glyphs that name no status; a status marker comes from nmr-core's `STATUS_GLYPHS`. A plain variant is empty
3
+ * where a word always follows the glyph, and a word where the glyph opens a line of its own.
4
+ *
5
+ * `noop` is a neutral circle: nothing ran because the repo asked for nothing to run, which is neither a
6
+ * failure nor a block.
7
+ */
8
+ export declare const NMR_GLYPHS: Readonly<Record<"plain" | "rich", Readonly<Record<"overrides" | "clean" | "package" | "catalog" | "noop" | "recording", import("@williamthorsen/toolbelt.terminal/candidate").Glyph>>>>;
9
+ export type NmrGlyphName = keyof (typeof NMR_GLYPHS)['rich'];
@@ -0,0 +1,9 @@
1
+ import { defineGlyphSet } from '@williamthorsen/nmr-core';
2
+ export const NMR_GLYPHS = defineGlyphSet({
3
+ catalog: { plain: '', rich: '\u{1F4DA}' },
4
+ clean: { plain: '', rich: '\u{1F9F9}' },
5
+ noop: { plain: 'NOOP', rich: '\u{26AA}' },
6
+ overrides: { plain: '', rich: '\u{1F512}' },
7
+ package: { plain: '', rich: '\u{1F4E6}' },
8
+ recording: { plain: '', rich: '\u{1F4BE}' },
9
+ });