@williamthorsen/nmr 0.41.0 → 0.42.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.
@@ -3,7 +3,8 @@
3
3
  "kits": [
4
4
  {
5
5
  "bundledDependencies": {
6
- "@williamthorsen/toolbelt.errors": "0.8.1"
6
+ "@williamthorsen/toolbelt.errors": "0.8.2",
7
+ "yaml": "2.9.1"
7
8
  },
8
9
  "checklists": [
9
10
  "nmr"
@@ -11,12 +12,12 @@
11
12
  "esbuildVersion": "0.28.2",
12
13
  "inputs": [
13
14
  {
14
- "hash": "5f7bd316",
15
+ "hash": "50b5aac3",
15
16
  "kind": "module",
16
17
  "path": "kits/default.ts"
17
18
  },
18
19
  {
19
- "hash": "00e9a998",
20
+ "hash": "fa3a837f",
20
21
  "kind": "inline",
21
22
  "path": "../package.json",
22
23
  "paths": [
@@ -46,10 +47,10 @@
46
47
  ],
47
48
  "name": "default",
48
49
  "path": "kits/default.js",
49
- "readyupVersion": "0.38.0",
50
+ "readyupVersion": "0.40.0",
50
51
  "source": "kits/default.ts",
51
- "sourceHash": "5f7bd316",
52
- "targetHash": "69936c4f"
52
+ "sourceHash": "50b5aac3",
53
+ "targetHash": "3bc734de"
53
54
  }
54
55
  ]
55
56
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.42.0 — 2026-10-08
6
+
7
+ ### 🎉 Features
8
+
9
+ - Adds the kit check `catalogued dependencies use catalog:`, which warns about every manifest, the root's included, that declares a literal version or range for a package listed by the default `catalog` or a named `catalogs` entry in `pnpm-workspace.yaml`, and exempts `peerDependencies` and specifiers that name a protocol, such as `workspace:` or `npm:`. (#958)
10
+ - Adds the kit check `no package.json declares a workspaces field`, which warns about every `package.json` in the tree that declares `workspaces`, a field that pnpm ignores because it reads workspace globs only from `pnpm-workspace.yaml`. (#958)
11
+ - Adds a `Package scripts:` section to the `nmr` listing, which shows each script declared by the current scope's `package.json` under a name that the active registry does not contain, hooks such as `build:pre` included and npm and pnpm lifecycle scripts excluded. (#960)
12
+ - Makes Prettier a dependency of `@williamthorsen/nmr`, so that `pnpm dlx --package @williamthorsen/nmr nmr-fmt --check` (or `--write`) formats a git repository that has nothing installed, while a repository that installs its own Prettier keeps formatting with that copy. (#961)
13
+ - Makes `nmr-fmt` apply the house config from `@williamthorsen/nmr/prettier`, shell plugin included, when Prettier does not find a config searching from the working directory, which also gives the house config to a repository whose only Prettier configs are in subdirectories below that directory. (#961)
14
+ - Makes `typescript` an optional peer of `@williamthorsen/nmr`, so that installing nmr no longer requires it, and makes `nmr-compile` report a missing `typescript` together with the version range that it supports. (#961)
15
+
16
+ ### 🐛 Bug fixes
17
+
18
+ - Fixes the issue that `nmr fmt` and `nmr fmt:check` aborted with a load failure when a file listed by git could not be stat'ed because of `EACCES` or `EPERM`, such as a tracked `.envrc` under a sandbox that denies reads of `.env*` files; `nmr-fmt` now skips each such file and names it on stderr. (#959)
19
+ - Fixes the issue that `nmr-fmt` reported any other stat failure on a listed file, such as `ELOOP` from a symlink loop, as a load failure; the run now fails with an error that names the path. (#959)
20
+ - Fixes the issue that `nmr-compile` failed with a `TypeError` under TypeScript 7, which it now rejects with an error naming the supported range, `>=5.7 <7`. (#961)
21
+ - Fixes the issue that `nmr fmt` and `nmr fmt:check` failed in a repository that commits a symlink, because `nmr-fmt` passed the symlink to Prettier, which rejects an explicitly named symlink even when `.prettierignore` lists it. (#968)
22
+
23
+ ### ♻️ Refactoring
24
+
25
+ - Replaces the hand-written atomic cache write and directory walks in `nmr-core` and `nmr` with `writeAtomic` and `findDirectoryChainMatch` from `@williamthorsen/toolbelt.filesystem`, which both packages now declare in `dependencies`. (#966)
26
+
27
+ ### 📚 Documentation
28
+
29
+ - Updates the readyup examples in the `nmr`, `release-kit`, and `v11y-check` READMEs and the `nmr` rulebook to use the `sources` key and `rdy run --sources`, and states that `--sources` needs readyup 0.40 or later. (#977)
30
+
5
31
  ## 0.41.0 — 2026-10-02
6
32
 
7
33
  ### 🎉 Features
package/README.md CHANGED
@@ -5,11 +5,23 @@
5
5
  Context-aware script runner for pnpm monorepos. Includes an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
6
6
 
7
7
  <!-- section:release-notes -->
8
- ## Release notes — v0.41.0 (2026-10-02)
8
+ ## Release notes — v0.42.0 (2026-10-08)
9
9
 
10
10
  ### 🎉 Features
11
11
 
12
- - Adds a hint to `nmr` that, when a command fails, names any of nmr's own flags written after the command name and shows the corrected invocation, such as `nmr -q check`. (#956)
12
+ - Adds the kit check `catalogued dependencies use catalog:`, which warns about every manifest, the root's included, that declares a literal version or range for a package listed by the default `catalog` or a named `catalogs` entry in `pnpm-workspace.yaml`, and exempts `peerDependencies` and specifiers that name a protocol, such as `workspace:` or `npm:`. (#958)
13
+ - Adds the kit check `no package.json declares a workspaces field`, which warns about every `package.json` in the tree that declares `workspaces`, a field that pnpm ignores because it reads workspace globs only from `pnpm-workspace.yaml`. (#958)
14
+ - Adds a `Package scripts:` section to the `nmr` listing, which shows each script declared by the current scope's `package.json` under a name that the active registry does not contain, hooks such as `build:pre` included and npm and pnpm lifecycle scripts excluded. (#960)
15
+ - Makes Prettier a dependency of `@williamthorsen/nmr`, so that `pnpm dlx --package @williamthorsen/nmr nmr-fmt --check` (or `--write`) formats a git repository that has nothing installed, while a repository that installs its own Prettier keeps formatting with that copy. (#961)
16
+ - Makes `nmr-fmt` apply the house config from `@williamthorsen/nmr/prettier`, shell plugin included, when Prettier does not find a config searching from the working directory, which also gives the house config to a repository whose only Prettier configs are in subdirectories below that directory. (#961)
17
+ - Makes `typescript` an optional peer of `@williamthorsen/nmr`, so that installing nmr no longer requires it, and makes `nmr-compile` report a missing `typescript` together with the version range that it supports. (#961)
18
+
19
+ ### 🐛 Bug fixes
20
+
21
+ - Fixes the issue that `nmr fmt` and `nmr fmt:check` aborted with a load failure when a file listed by git could not be stat'ed because of `EACCES` or `EPERM`, such as a tracked `.envrc` under a sandbox that denies reads of `.env*` files; `nmr-fmt` now skips each such file and names it on stderr. (#959)
22
+ - Fixes the issue that `nmr-fmt` reported any other stat failure on a listed file, such as `ELOOP` from a symlink loop, as a load failure; the run now fails with an error that names the path. (#959)
23
+ - Fixes the issue that `nmr-compile` failed with a `TypeError` under TypeScript 7, which it now rejects with an error naming the supported range, `>=5.7 <7`. (#961)
24
+ - Fixes the issue that `nmr fmt` and `nmr fmt:check` failed in a repository that commits a symlink, because `nmr-fmt` passed the symlink to Prettier, which rejects an explicitly named symlink even when `.prettierignore` lists it. (#968)
13
25
  <!-- /section:release-notes -->
14
26
 
15
27
  ## Installation
@@ -29,6 +41,16 @@ nmr installs as a workspace bin, so the bare `nmr` command works only when the s
29
41
 
30
42
  > **Note:** Avoid `npx nmr`. Inside a git worktree, `npx` can resolve a different nmr binary from outside the working tree, so the command succeeds while running the wrong code.
31
43
 
44
+ ### Formatting without installing
45
+
46
+ A git repository that does not install nmr, or that has no `package.json` at all, can still format its files:
47
+
48
+ ```bash
49
+ pnpm dlx --package @williamthorsen/nmr nmr-fmt --check # or --write
50
+ ```
51
+
52
+ When the repository doesn't have a Prettier config, the [house config](docs/prettier.md) applies, shell scripts included.
53
+
32
54
  ## Quick start
33
55
 
34
56
  nmr works without any configuration. It includes built-in scripts for common monorepo tasks.
@@ -243,7 +265,7 @@ Wiring the sync to `postinstall` keeps it current without a hand-run command:
243
265
 
244
266
  ## Conformance checks
245
267
 
246
- 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, the test-file conventions ([tier names](docs/vitest.md#test-tiers) and `__tests__` placement), that the repo's own suite gates them, that every `bin` target is a committed wrapper rather than build output that pnpm cannot link at install time, that `files` covers the output that each wrapper loads, that the root install scripts do not run `lefthook install` without `lefthook check-install`, and that the `package.json` files in the tree do not declare a `pnpm` field, from which pnpm 11 does not read any key. Those settings belong in `pnpm-workspace.yaml`, and `pnpx codemod run pnpm-v10-to-v11` moves them. The kit is included in 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 takes effect in the repo when it upgrades nmr. A repo that declares the [conventions check](docs/vitest.md#gating-the-test-file-conventions) gates both conventions in its own test run, and the kit then leaves the test files to that check.
268
+ 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, the test-file conventions ([tier names](docs/vitest.md#test-tiers) and `__tests__` placement), that the repo's own suite gates them, that every `bin` target is a committed wrapper rather than build output that pnpm cannot link at install time, that `files` covers the output that each wrapper loads, that the root install scripts do not run `lefthook install` without `lefthook check-install`, and that the `package.json` files in the tree declare neither a `pnpm` field, from which pnpm 11 does not read any key, nor a `workspaces` field, which pnpm ignores. Those settings belong in `pnpm-workspace.yaml`, and `pnpx codemod run pnpm-v10-to-v11` moves the `pnpm` field's. It also checks that every manifest declares a package listed by a `pnpm-workspace.yaml` catalog as `catalog:` rather than as a literal version. A peer range is exempt, and a version that must differ belongs in a named catalog, referenced as `catalog:<name>`. The kit is included in 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 takes effect in the repo when it upgrades nmr. A repo that declares the [conventions check](docs/vitest.md#gating-the-test-file-conventions) gates both conventions in its own test run, and the kit then leaves the test files to that check.
247
269
 
248
270
  Add `readyup` as a devDependency, then name nmr in its config:
249
271
 
@@ -252,17 +274,17 @@ Add `readyup` as a devDependency, then name nmr in its config:
252
274
  import { defineRdyConfig } from 'readyup';
253
275
 
254
276
  export default defineRdyConfig({
255
- packages: ['@williamthorsen/nmr'],
277
+ sources: ['npm:@williamthorsen/nmr'],
256
278
  });
257
279
  ```
258
280
 
259
281
  ```bash
260
- rdy run --packages # every kit that each listed package publishes
282
+ rdy run --sources # every kit that each listed package publishes
261
283
  rdy run --from npm:@williamthorsen/nmr # nmr's kit alone, without the config entry
262
284
  rdy list --from npm:@williamthorsen/nmr # what nmr publishes
263
285
  ```
264
286
 
265
- `--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.
287
+ `--sources` is the form that survives nmr publishing further kits. `--sources` needs `readyup` 0.40 or later and `--from` needs 0.23 or later, and both need `@williamthorsen/nmr` as a _direct_ devDependency: A strict pnpm layout links nothing else into the project, so a transitive copy is unreachable.
266
288
 
267
289
  ## Documentation
268
290
 
@@ -13,5 +13,5 @@ delivery: ambient
13
13
  - A check that already passed on the current working tree skips, printing `SKIP <scope>: <command>: passed …` and, when the recalled run left one, `— replayed: <excerpt>` of what that run concluded. Any edit, addition, or deletion re-runs it. Read the replayed excerpt rather than running the check again: Never pass `--no-cache` to get a "real" run, which takes 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 when there are too many to fit. A skip without an 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. Output that you need in full is a recording to read rather than a command to run again. It runs nothing, and it is held to the tree rather than to the terminal: A recording made by a piped run prints for a reader at one. A composite prints its constituents' excerpts, not their transcripts, so the transcript of one of them is `nmr -F <pkg> --log <command>` or that constituent's own name. When there is nothing to show, `--log` says so on one line, names which case it is, and exits non-zero.
15
15
  - Every command that 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 into which it expands. Those are the words that 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 `nmr -q <command>` is the low-noise way to run rather than a silent one: It withholds the output of the commands that nmr runs and keeps nmr's own verdicts, which is how a pass, a cached skip, and a `""`/`":"` override stay distinguishable. nmr reads its own flags only before the command name and passes everything after the name to the commands that run: `nmr ci -q` hands `-q` to `nmr-fmt`, which rejects it. 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 that the test reaches -- `tool` (a program supplied by the environment), `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.
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 that the test reaches -- `tool` (a program supplied by the environment), `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 --sources` reports both and warns that the check is missing.
17
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 (the repo does not define a build script of its own); `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 to which a replayed excerpt is additionally held, the retention ceiling within which `--log` prints, its `checkCache` config, and every condition that disables it.
@@ -1,3 +1,3 @@
1
1
  import process from 'node:process';
2
2
  import { runFmt } from "./commands/fmt.js";
3
- process.exitCode = runFmt(process.argv.slice(2), process.cwd());
3
+ process.exitCode = await runFmt(process.argv.slice(2), process.cwd());
@@ -1,4 +1,5 @@
1
1
  import { type OutputStyle } from '@williamthorsen/nmr-core';
2
+ import type * as TypeScript from 'typescript';
2
3
  import { type BuildOptions } from './build-output.js';
3
4
  /**
4
5
  * The toolchain from which a build's emit comes: the resolved compiler and the nmr running it. `computeBuildHash`
@@ -12,6 +13,12 @@ export interface BuildToolchain {
12
13
  /** What a build needs beyond its sources: the options shaping the emit, and the style in which it reports. */
13
14
  export interface BuildPackageOptions extends BuildOptions {
14
15
  style: OutputStyle;
16
+ /**
17
+ * Overridable so that a missing `typescript` peer is reachable without uninstalling it.
18
+ *
19
+ * @internal
20
+ */
21
+ loadTypeScript?: () => Promise<typeof TypeScript>;
15
22
  }
16
23
  /**
17
24
  * Compiles a package's `src` tree to `dist/esm` with the TypeScript compiler API, emitting `.js` and
@@ -30,6 +37,14 @@ export declare function buildPackage(packageDir: string, options: BuildPackageOp
30
37
  * detected.
31
38
  */
32
39
  export declare function computeBuildHash(packageDir: string, files: string[], emitConfig: object, toolchain: BuildToolchain): Promise<string>;
40
+ /**
41
+ * Loads the compiler API on which every other function in this module relies, reporting a missing `typescript` peer
42
+ * with the version that `nmr-compile` requires. `buildPackage` calls it first; a caller of another export calls it
43
+ * beforehand.
44
+ *
45
+ * @internal
46
+ */
47
+ export declare function loadCompiler(loadTypeScript?: () => Promise<typeof TypeScript>): Promise<void>;
33
48
  /**
34
49
  * Resolves a package's full tsconfig `extends` chain, returning every config file in it (the leaf `tsconfig.json` and
35
50
  * each base that it transitively extends, up to the repo root) as paths relative to `packageDir`.
@@ -2,18 +2,22 @@ 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 { formatGlyphLine, readCacheEntry, STATUS_GLYPHS, writeCacheEntry, } from '@williamthorsen/nmr-core';
5
+ import { formatGlyphLine, hasErrnoCode, readCacheEntry, STATUS_GLYPHS, writeCacheEntry, } from '@williamthorsen/nmr-core';
6
6
  import { glob } from 'glob';
7
- import * as ts from 'typescript';
8
7
  import { NMR_GLYPHS } from "../glyphs.js";
9
8
  import { resolveConfigPath } from "../helpers/config-path.js";
10
9
  import { DEFAULT_ENTRY_GLOBS, DEFAULT_IGNORE_PATTERNS, DEFAULT_OUTDIR, hasExpectedBuildOutput, resolveBuildCachePath, resolveScratchDirs, resolveToolchainFingerprint, } from "./build-output.js";
11
10
  const SOURCE_ROOT = 'src';
12
11
  const MINIMUM_TYPESCRIPT_MAJOR = 5;
13
12
  const MINIMUM_TYPESCRIPT_MINOR = 7;
13
+ const UNSUPPORTED_TYPESCRIPT_MAJOR = 7;
14
+ const SUPPORTED_TYPESCRIPT_RANGE = `>=${MINIMUM_TYPESCRIPT_MAJOR}.${MINIMUM_TYPESCRIPT_MINOR} <${UNSUPPORTED_TYPESCRIPT_MAJOR}`;
15
+ const INSTALL_TYPESCRIPT_GUIDANCE = "Install a version in that range as the 'typescript' peer dependency.";
14
16
  const TS_EXTENSION = '.ts';
15
17
  const JS_EXTENSION = '.js';
18
+ let ts;
16
19
  export async function buildPackage(packageDir, options) {
20
+ await loadCompiler(options.loadTypeScript);
17
21
  assertSupportedTypeScript();
18
22
  const cachePath = resolveBuildCachePath(packageDir);
19
23
  const outdir = options.outdir ?? DEFAULT_OUTDIR;
@@ -56,6 +60,18 @@ export async function computeBuildHash(packageDir, files, emitConfig, toolchain)
56
60
  hash.update(toolchain.fingerprint);
57
61
  return hash.digest('hex');
58
62
  }
63
+ export async function loadCompiler(loadTypeScript = importTypeScript) {
64
+ try {
65
+ ts = await loadTypeScript();
66
+ }
67
+ catch (error) {
68
+ if (hasErrnoCode(error, 'ERR_MODULE_NOT_FOUND') && error.message.includes("'typescript'")) {
69
+ throw new Error(`nmr-compile requires TypeScript ${SUPPORTED_TYPESCRIPT_RANGE}, but 'typescript' could not be resolved. ` +
70
+ INSTALL_TYPESCRIPT_GUIDANCE, { cause: error });
71
+ }
72
+ throw error;
73
+ }
74
+ }
59
75
  export function resolveTsconfigChain(packageDir, configFileName = 'tsconfig.json') {
60
76
  const resolvedChain = [];
61
77
  const seen = new Set();
@@ -291,14 +307,19 @@ async function detectBuildChanges(packageDir, files, emitConfig, toolchain, cach
291
307
  console.info(formatGlyphLine(NMR_GLYPHS, style, 'package', `${packageName}: Changes detected.`));
292
308
  return { changed: true, currentHash };
293
309
  }
310
+ async function importTypeScript() {
311
+ return await import('typescript');
312
+ }
294
313
  function assertSupportedTypeScript() {
295
- const [majorPart, minorPart] = ts.versionMajorMinor.split('.', 2);
314
+ const versionMajorMinor = ts.versionMajorMinor;
315
+ const version = ts.version;
316
+ const [majorPart, minorPart] = typeof versionMajorMinor === 'string' ? versionMajorMinor.split('.', 2) : [];
296
317
  const major = majorPart === undefined ? 0 : Number(majorPart);
297
318
  const minor = minorPart === undefined ? 0 : Number(minorPart);
298
319
  const tooOld = major < MINIMUM_TYPESCRIPT_MAJOR || (major === MINIMUM_TYPESCRIPT_MAJOR && minor < MINIMUM_TYPESCRIPT_MINOR);
299
- if (tooOld) {
300
- throw new Error(`nmr-compile requires TypeScript >=${MINIMUM_TYPESCRIPT_MAJOR}.${MINIMUM_TYPESCRIPT_MINOR} for ` +
301
- `rewriteRelativeImportExtensions, but found ${ts.version}. Upgrade the 'typescript' peer dependency.`);
320
+ if (tooOld || major >= UNSUPPORTED_TYPESCRIPT_MAJOR) {
321
+ throw new Error(`nmr-compile requires TypeScript ${SUPPORTED_TYPESCRIPT_RANGE}, but found ` +
322
+ `${typeof version === 'string' ? version : 'a version without the compiler API'}. ${INSTALL_TYPESCRIPT_GUIDANCE}`);
302
323
  }
303
324
  }
304
325
  function collectAliasPrefixes(compilerOptions) {
@@ -4,6 +4,12 @@ export interface FormatTargets {
4
4
  files: string[];
5
5
  /** Absolute paths to every `.prettierignore` governing the repository, root-most first. */
6
6
  ignorePaths: string[];
7
+ /** Listed paths that this process is denied access to, each with the errno code that its stat raised. */
8
+ unreadableFiles: UnreadableFile[];
9
+ }
10
+ export interface UnreadableFile {
11
+ file: string;
12
+ code: string;
7
13
  }
8
14
  export type ResolveTargetsResult = {
9
15
  ok: true;
@@ -13,13 +19,14 @@ export type ResolveTargetsResult = {
13
19
  error: string;
14
20
  };
15
21
  /**
16
- * Formats or checks the files that git reports for `cwd`, and returns the exit code to report.
22
+ * Formats or checks the files that git reports for `cwd`, and returns the exit code to report. A repository
23
+ * without a Prettier config of its own is formatted with the house config.
17
24
  *
18
25
  * Trailing arguments are git pathspecs, not Prettier flags; an unrecognized option is rejected rather
19
26
  * than passed along, since git would read it as a pathspec matching nothing and the run would report
20
27
  * success over an empty selection.
21
28
  */
22
- export declare function runFmt(argv: string[], cwd?: string): number;
29
+ export declare function runFmt(argv: string[], cwd?: string): Promise<number>;
23
30
  /**
24
31
  * Resolves what Prettier should format and which ignore files govern it, using git rather than Prettier's
25
32
  * own discovery. Prettier reads `.gitignore` and `.prettierignore` from the working directory only, so a
@@ -31,11 +38,16 @@ export declare function runFmt(argv: string[], cwd?: string): number;
31
38
  *
32
39
  * `pathspecs` narrow the selection and are passed to git verbatim: They have git pathspec semantics rather
33
40
  * than shell glob semantics.
41
+ *
42
+ * A listed path that this process is denied access to is set aside in `unreadableFiles`; any other stat failure
43
+ * fails the resolution.
34
44
  */
35
45
  export declare function resolveFormatTargets(cwd: string, pathspecs?: string[]): ResolveTargetsResult;
36
46
  /** @internal */
37
47
  export interface RunPrettierOptions {
38
48
  cliPath: string;
49
+ /** Passed as `--config`, which replaces Prettier's own config discovery. */
50
+ configPath?: string | undefined;
39
51
  mode: FormatMode;
40
52
  files: string[];
41
53
  ignorePaths: string[];
@@ -1,23 +1,26 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { spawnSync } from 'node:child_process';
3
- import { readFileSync, statSync } from 'node:fs';
3
+ import { lstatSync, readFileSync } from 'node:fs';
4
4
  import { createRequire } from 'node:module';
5
5
  import path from 'node:path';
6
6
  import process from 'node:process';
7
- import { reportError } from '@williamthorsen/nmr-core';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { hasErrnoCode, reportError } from '@williamthorsen/nmr-core';
9
+ import { describeError } from '@williamthorsen/toolbelt.errors';
8
10
  import { isObject } from "../helpers/type-guards.js";
9
11
  const IGNORE_FILENAME = '.prettierignore';
10
12
  const LIST_FILES_ARGS = ['ls-files', '-z', '--cached', '--others', '--exclude-standard'];
11
13
  const IGNORE_FILE_PATHSPEC = `*${IGNORE_FILENAME}`;
14
+ const UNREADABLE_CODES = ['EACCES', 'EPERM'];
12
15
  const PRETTIER_PACKAGE = 'prettier';
13
- const PRETTIER_RANGE = '>=3.9.5 <4';
16
+ const DEFAULT_CONFIG_PATH = fileURLToPath(new URL(`../prettier-default-config${path.extname(fileURLToPath(import.meta.url))}`, import.meta.url));
14
17
  const ARGUMENT_BUDGET_BYTES = 100_000;
15
18
  const USAGE = 'Usage: nmr-fmt (--check | --write) [pathspec...]';
16
19
  const MODE_ARGS = {
17
20
  check: ['--check'],
18
21
  write: ['--list-different', '--write'],
19
22
  };
20
- export function runFmt(argv, cwd = process.cwd()) {
23
+ export async function runFmt(argv, cwd = process.cwd()) {
21
24
  const parsedArgs = parseFmtArgs(argv);
22
25
  if (!parsedArgs.ok) {
23
26
  reportError(`nmr-fmt: ${parsedArgs.error}`);
@@ -29,7 +32,10 @@ export function runFmt(argv, cwd = process.cwd()) {
29
32
  reportError(`nmr-fmt: ${resolvedTargets.error}`);
30
33
  return 1;
31
34
  }
32
- const { files, ignorePaths } = resolvedTargets.targets;
35
+ const { files, ignorePaths, unreadableFiles } = resolvedTargets.targets;
36
+ for (const { file, code } of unreadableFiles) {
37
+ process.stderr.write(`nmr-fmt: skipped ${file}, which cannot be read (${code})\n`);
38
+ }
33
39
  if (files.length === 0) {
34
40
  if (parsedArgs.pathspecs.length > 0) {
35
41
  reportError(`nmr-fmt: the pathspecs did not match any formattable file: ${parsedArgs.pathspecs.join(', ')}`);
@@ -37,12 +43,24 @@ export function runFmt(argv, cwd = process.cwd()) {
37
43
  }
38
44
  return 0;
39
45
  }
40
- const cli = resolvePrettierCli();
46
+ const cli = resolvePrettierCli(cwd);
41
47
  if (!cli.ok) {
42
48
  reportError(`nmr-fmt: ${cli.error}`);
43
49
  return 1;
44
50
  }
45
- return runPrettier({ cliPath: cli.cliPath, mode: parsedArgs.mode, files, ignorePaths, cwd });
51
+ const repositoryConfig = await findRepositoryConfig(cli.manifestPath, cwd);
52
+ if (!repositoryConfig.ok) {
53
+ reportError(`nmr-fmt: ${repositoryConfig.error}`);
54
+ return 1;
55
+ }
56
+ return runPrettier({
57
+ cliPath: cli.cliPath,
58
+ configPath: repositoryConfig.found ? undefined : DEFAULT_CONFIG_PATH,
59
+ mode: parsedArgs.mode,
60
+ files,
61
+ ignorePaths,
62
+ cwd,
63
+ });
46
64
  }
47
65
  export function resolveFormatTargets(cwd, pathspecs = []) {
48
66
  const toplevel = runGit(['rev-parse', '--show-toplevel'], cwd);
@@ -55,21 +73,47 @@ export function resolveFormatTargets(cwd, pathspecs = []) {
55
73
  const ignoreFiles = runGit([...LIST_FILES_ARGS, '--', IGNORE_FILE_PATHSPEC], repositoryRoot);
56
74
  if (!ignoreFiles.ok)
57
75
  return ignoreFiles;
76
+ const files = [];
77
+ const unreadableFiles = [];
78
+ const listedPaths = dedupe(splitNulSeparated(listedFiles.stdout));
79
+ for (const file of listedPaths) {
80
+ const status = classifyListedFile(cwd, file);
81
+ switch (status.kind) {
82
+ case 'excluded':
83
+ break;
84
+ case 'failed':
85
+ return { ok: false, error: status.error };
86
+ case 'formattable':
87
+ files.push(file);
88
+ break;
89
+ case 'unreadable':
90
+ unreadableFiles.push({ file, code: status.code });
91
+ break;
92
+ }
93
+ }
58
94
  const discoveredIgnorePaths = splitNulSeparated(ignoreFiles.stdout)
59
95
  .filter((file) => path.basename(file) === IGNORE_FILENAME)
60
96
  .map((file) => path.resolve(repositoryRoot, file));
61
97
  return {
62
98
  ok: true,
63
99
  targets: {
64
- files: dedupe(splitNulSeparated(listedFiles.stdout))
65
- .filter((file) => isFormattableFile(cwd, file))
66
- .toSorted(),
100
+ files: files.toSorted(),
67
101
  ignorePaths: dedupe([path.join(repositoryRoot, IGNORE_FILENAME), ...discoveredIgnorePaths]),
102
+ unreadableFiles,
68
103
  },
69
104
  };
70
105
  }
71
- function isFormattableFile(cwd, file) {
72
- return statSync(path.resolve(cwd, file), { throwIfNoEntry: false })?.isFile() === true;
106
+ function classifyListedFile(cwd, file) {
107
+ try {
108
+ const stats = lstatSync(path.resolve(cwd, file), { throwIfNoEntry: false });
109
+ return stats?.isFile() === true ? { kind: 'formattable' } : { kind: 'excluded' };
110
+ }
111
+ catch (error) {
112
+ const code = UNREADABLE_CODES.find((candidate) => hasErrnoCode(error, candidate));
113
+ if (code !== undefined)
114
+ return { kind: 'unreadable', code };
115
+ return { kind: 'failed', error: `could not stat ${file}: ${describeError(error)}` };
116
+ }
73
117
  }
74
118
  function parseFmtArgs(argv) {
75
119
  let mode;
@@ -93,30 +137,45 @@ function parseFmtArgs(argv) {
93
137
  }
94
138
  return { ok: true, mode, pathspecs };
95
139
  }
96
- function resolvePrettierCli() {
140
+ function resolvePrettierCli(cwd) {
97
141
  const missingResult = {
98
142
  ok: false,
99
- error: `\`${PRETTIER_PACKAGE}\` (${PRETTIER_RANGE}) could not be resolved. Install it in this repository.`,
143
+ error: `\`${PRETTIER_PACKAGE}\` could not be resolved from this repository or from nmr's own installation, which declares it as a dependency. Reinstall \`@williamthorsen/nmr\`.`,
100
144
  };
101
- let manifestPath;
102
- try {
103
- manifestPath = createRequire(import.meta.url).resolve(`${PRETTIER_PACKAGE}/package.json`);
104
- }
105
- catch {
145
+ const manifestPath = resolveFrom(path.join(cwd, 'package.json'), `${PRETTIER_PACKAGE}/package.json`) ??
146
+ resolveFrom(import.meta.url, `${PRETTIER_PACKAGE}/package.json`);
147
+ if (manifestPath === undefined)
106
148
  return missingResult;
107
- }
108
149
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
109
150
  if (!isObject(manifest))
110
151
  return missingResult;
111
152
  const bin = isObject(manifest['bin']) ? manifest['bin'][PRETTIER_PACKAGE] : manifest['bin'];
112
153
  if (typeof bin !== 'string')
113
154
  return missingResult;
114
- return { ok: true, cliPath: path.resolve(path.dirname(manifestPath), bin) };
155
+ return { ok: true, cliPath: path.resolve(path.dirname(manifestPath), bin), manifestPath };
156
+ }
157
+ async function findRepositoryConfig(manifestPath, cwd) {
158
+ try {
159
+ const prettier = createRequire(manifestPath)(PRETTIER_PACKAGE);
160
+ const resolveConfigFile = isObject(prettier) ? prettier['resolveConfigFile'] : undefined;
161
+ if (typeof resolveConfigFile !== 'function') {
162
+ return {
163
+ ok: false,
164
+ error: `\`${PRETTIER_PACKAGE}\` at ${path.dirname(manifestPath)} does not export \`resolveConfigFile\`.`,
165
+ };
166
+ }
167
+ const configFile = await Reflect.apply(resolveConfigFile, undefined, [path.join(cwd, 'placeholder')]);
168
+ return { ok: true, found: configFile !== null };
169
+ }
170
+ catch (error) {
171
+ return { ok: false, error: `could not search for a Prettier config: ${describeError(error)}` };
172
+ }
115
173
  }
116
174
  export function runPrettier(options) {
117
- const { cliPath, mode, files, ignorePaths, cwd, budgetBytes = ARGUMENT_BUDGET_BYTES } = options;
175
+ const { cliPath, configPath, mode, files, ignorePaths, cwd, budgetBytes = ARGUMENT_BUDGET_BYTES } = options;
118
176
  const prettierArgs = [
119
177
  cliPath,
178
+ ...(configPath === undefined ? [] : ['--config', configPath]),
120
179
  '--ignore-unknown',
121
180
  ...ignorePaths.flatMap((ignorePath) => ['--ignore-path', ignorePath]),
122
181
  ...MODE_ARGS[mode],
@@ -153,6 +212,14 @@ function batchWithinBudget(files, budgetBytes) {
153
212
  batches.push(batch);
154
213
  return batches;
155
214
  }
215
+ function resolveFrom(parent, specifier) {
216
+ try {
217
+ return createRequire(parent).resolve(specifier);
218
+ }
219
+ catch {
220
+ return undefined;
221
+ }
222
+ }
156
223
  function runGit(args, cwd) {
157
224
  const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
158
225
  if (result.error) {
@@ -1,8 +1,8 @@
1
1
  import type { NmrConfig } from './types.js';
2
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`).
3
+ * Generates the help text for the `nmr` CLI. Renders the names from the
4
+ * workspace and root registries, excluding hooks (`*:pre`, `*:post`), and
5
+ * the scripts that the package at `packageDir` adds to them.
6
6
  *
7
7
  * When `packageDir` is provided, tier-3 entries from that package's
8
8
  * `package.json:scripts` that match a registry name in the active section
@@ -11,5 +11,9 @@ import type { NmrConfig } from './types.js';
11
11
  * active section is the root section when `shouldUseRoot` is true (root cwd
12
12
  * or `-w`), otherwise the workspace section. A footnote is appended once
13
13
  * if any override marker was rendered.
14
+ *
15
+ * Every other entry, a hook included, is listed under `Package scripts:`
16
+ * unless it is an npm or pnpm lifecycle script or the active registry
17
+ * contains its name.
14
18
  */
15
19
  export declare function generateHelp(config: NmrConfig, packageDir: string | undefined, shouldUseRoot: boolean): string;
package/dist/esm/help.js CHANGED
@@ -1,6 +1,28 @@
1
1
  import { isHookName } from "./helpers/hook-name.js";
2
2
  import { OUTPUT_STYLE_ENV_VAR, OUTPUT_STYLE_FLAG } from "./output-style.js";
3
3
  import { buildRootRegistry, buildWorkspaceRegistry, describeScript, isSelfReferential, readPackageJsonScripts, } from "./resolver.js";
4
+ const LIFECYCLE_SCRIPT_NAMES = new Set([
5
+ 'dependencies',
6
+ 'install',
7
+ 'pnpm:devPreinstall',
8
+ 'postinstall',
9
+ 'postpack',
10
+ 'postprepare',
11
+ 'postpublish',
12
+ 'postuninstall',
13
+ 'postversion',
14
+ 'preinstall',
15
+ 'prepack',
16
+ 'prepare',
17
+ 'preprepare',
18
+ 'prepublish',
19
+ 'prepublishOnly',
20
+ 'preuninstall',
21
+ 'preversion',
22
+ 'publish',
23
+ 'uninstall',
24
+ 'version',
25
+ ]);
4
26
  export function generateHelp(config, packageDir, shouldUseRoot) {
5
27
  const lines = [
6
28
  'Usage: nmr [flags] <command> [args...]',
@@ -23,7 +45,8 @@ export function generateHelp(config, packageDir, shouldUseRoot) {
23
45
  '',
24
46
  'Workspace commands:',
25
47
  ];
26
- const overrides = packageDir === undefined ? {} : collectOverrides(packageDir);
48
+ const packageScripts = packageDir === undefined ? {} : readRunnableScripts(packageDir);
49
+ const overrides = collectOverrides(packageScripts);
27
50
  let hadOverride = false;
28
51
  const workspaceRegistry = filterHooks(buildWorkspaceRegistry(config));
29
52
  const workspaceMarkedNames = !shouldUseRoot ? applyOverrides(workspaceRegistry, overrides) : new Set();
@@ -36,24 +59,44 @@ export function generateHelp(config, packageDir, shouldUseRoot) {
36
59
  if (rootMarkedNames.size > 0)
37
60
  hadOverride = true;
38
61
  formatRegistry(rootRegistry, rootMarkedNames, lines);
62
+ const activeRegistry = shouldUseRoot ? buildRootRegistry(config) : buildWorkspaceRegistry(config);
63
+ const unregisteredScripts = collectUnregisteredScripts(packageScripts, activeRegistry);
64
+ if (Object.keys(unregisteredScripts).length > 0) {
65
+ lines.push('', 'Package scripts:');
66
+ formatRegistry(unregisteredScripts, new Set(), lines);
67
+ }
39
68
  if (hadOverride) {
40
69
  lines.push('', '* Overridden by package.json');
41
70
  }
42
71
  return lines.join('\n');
43
72
  }
44
- function collectOverrides(packageDir) {
73
+ function collectOverrides(packageScripts) {
74
+ const candidateOverrides = {};
75
+ for (const [name, value] of Object.entries(packageScripts)) {
76
+ if (!isHookName(name))
77
+ candidateOverrides[name] = value;
78
+ }
79
+ return candidateOverrides;
80
+ }
81
+ function collectUnregisteredScripts(packageScripts, registry) {
82
+ const unregisteredScripts = {};
83
+ for (const [name, value] of Object.entries(packageScripts)) {
84
+ if (Object.hasOwn(registry, name) || LIFECYCLE_SCRIPT_NAMES.has(name))
85
+ continue;
86
+ unregisteredScripts[name] = value;
87
+ }
88
+ return unregisteredScripts;
89
+ }
90
+ function readRunnableScripts(packageDir) {
45
91
  const scripts = readPackageJsonScripts(packageDir);
46
92
  if (!scripts)
47
93
  return {};
48
- const candidateOverrides = {};
94
+ const runnableScripts = {};
49
95
  for (const [name, value] of Object.entries(scripts)) {
50
- if (isHookName(name))
51
- continue;
52
- if (isSelfReferential(value, name, packageDir))
53
- continue;
54
- candidateOverrides[name] = value;
96
+ if (!isSelfReferential(value, name, packageDir))
97
+ runnableScripts[name] = value;
55
98
  }
56
- return candidateOverrides;
99
+ return runnableScripts;
57
100
  }
58
101
  function applyOverrides(registry, overrides) {
59
102
  const markedNames = new Set();
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The house config that `nmr-fmt` passes to Prettier by path when the repository does not have a config of its own.
3
+ * It is a default export because that is the export that Prettier reads from a config file.
4
+ */
5
+ declare const _default: import("prettier").Config;
6
+ export default _default;
@@ -0,0 +1,2 @@
1
+ import { definePrettierConfig } from "./prettier.js";
2
+ export default definePrettierConfig();