@williamthorsen/nmr 0.32.0 → 0.32.1

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.
@@ -1,6 +1,6 @@
1
1
  /** @noformat — @generated. Do not edit. Compiled by rdy. */
2
2
  /* eslint-disable */
3
- export const __readyupVersion = "0.28.0";
3
+ export const __readyupVersion = "0.29.0";
4
4
 
5
5
 
6
6
  // .readyup/kits/default.ts
@@ -394,7 +394,7 @@ function formatPaths(paths) {
394
394
  ${paths.map((path2) => ` ${path2}`).join("\n")}`;
395
395
  }
396
396
  function getMinVersion() {
397
- const picked = { "version": "0.32.0" };
397
+ const picked = { "version": "0.32.1" };
398
398
  if (typeof picked["version"] !== "string") {
399
399
  throw new TypeError("nmr/package.json: 'version' must be a string");
400
400
  }
@@ -13,7 +13,7 @@
13
13
  "path": "kits/default.ts"
14
14
  },
15
15
  {
16
- "hash": "2bd89ec9",
16
+ "hash": "01e1f853",
17
17
  "kind": "inline",
18
18
  "path": "../package.json",
19
19
  "paths": [
@@ -38,10 +38,10 @@
38
38
  ],
39
39
  "name": "default",
40
40
  "path": "kits/default.js",
41
- "readyupVersion": "0.28.0",
41
+ "readyupVersion": "0.29.0",
42
42
  "source": "kits/default.ts",
43
43
  "sourceHash": "67afdcb4",
44
- "targetHash": "b923a4b4"
44
+ "targetHash": "1fad8728"
45
45
  }
46
46
  ]
47
47
  }
package/README.md CHANGED
@@ -3,31 +3,15 @@
3
3
  Context-aware script runner for pnpm monorepos. Ships an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
4
4
 
5
5
  <!-- section:release-notes -->
6
- ## Release notes — v0.32.0 (2026-08-17)
7
-
8
- ### 🎉 Features
9
-
10
- - Carry the test-tier convention in the ambient rulebook (#689)
11
-
12
- Adds the test-tier convention to `nmr.md`, the ambient rulebook `codeassembly sync` deploys to each consuming repo's agent guidance. The guidance describes the `<subject>[.<aspect>].<tier>.test.ts` grammar and the four tiers, and warns that tests outside a `__tests__` directory are ignored. Tests not explicitly attributed to a tier are flagged by the ReadyUp kit bundled with `nmr`.
13
-
14
- The previous guidance is removed from `AGENTS.md`.
15
-
16
- - Close each bin's output with a statement of what the run came to (#691)
17
-
18
- Five nmr bins now end their output with a statement of the run's final result, rather than on whichever line the run happened to reach last, so the last line a reader sees carries the finding.
19
-
20
- Separately, `ensure-prepublish-hooks` prints its whole report, including failure messages, on stdout, to avoid splitting the report across two streams. Failure still results in a non-zero exit code.
21
-
22
- - Reject a dead pnpm field in nmr's ReadyUp kit (#692)
23
-
24
- nmr's ReadyUp kit now checks for the presence of a `pnpm` field in any `package.json`. pnpm 11 no longer reads any key from that field, but taze (the engine underlying `nmr upgrade`) reads `pnpm.overrides`. To avoid confusion, the kit flags this as an error.
6
+ ## Release notes — v0.32.1 (2026-08-18)
25
7
 
26
8
  ### 🐛 Bug fixes
27
9
 
28
- - Fold the running nmr's fingerprint into the build cache key (#693)
10
+ - Reject a `package.json` entry that chains steps onto a self-reference (#707)
11
+
12
+ Fixes an issue where a `package.json` script re-invoking its own command alongside other steps (such as `"build": "nmr build && rdy compile"`) silently lost those steps: nmr discarded the entry and ran its own `build` in its place, so `rdy compile` never ran. nmr now refuses to run the entry and reports the error.
29
13
 
30
- Fixes the issue that build output could be stale, but not reported to be stale, after an edit to `nmr-compile`'s own emit logic. The build cache key now folds a fingerprint of the `nmr` running the build alongside the TypeScript version it already carried. Any build keys generated against an `nmr` with a different fingerprint will be invalidated. Repos consuming `nmr` get the same behavior.
14
+ Separately, fixes an issue where the same chain written the other way round (e.g., `"build": "rdy compile && nmr build"`) was honored as an ordinary override, causing infinite recursion.
31
15
  <!-- /section:release-notes -->
32
16
 
33
17
  ## Installation
@@ -115,6 +99,10 @@ If no per-package override exists, the highest-tier value that is set wins. Set
115
99
 
116
100
  > **Tip:** If your repo uses `eslint-plugin-package-json/valid-scripts`, empty strings are flagged as invalid. Use `":"` (the POSIX null command) instead — nmr treats it as a regular override that exits successfully and does nothing.
117
101
 
102
+ A `package.json` entry that re-invokes the command it is declared under is never honored. Honoring `"build": "nmr build"` would spawn a shell running `nmr build` in the same directory, reaching the same entry again without bound, so nmr discards the entry and resolves the command from the tier below. An entry that carries only the self-reference, trailing arguments included, is discarded in silence: it declares no step to lose. An entry that chains anything else onto it — `"build": "nmr build && rdy compile"`, or `"build": "rdy compile && nmr build"` — would lose those steps, so nmr rejects the invocation instead, naming the file, the key, and where the steps belong. For an ordinary command that is a `<command>:pre` or `<command>:post` script: `"build:pre": "rdy verify"` and `"build:post": "rdy compile"` wrap nmr's own `build` rather than replacing it. For a hook entry it is the entry itself, since nmr wraps a `:pre` or `:post` script in no hooks of its own — `"build:post": "nmr build:post && rdy compile"` becomes `"build:post": "rdy compile"`.
103
+
104
+ The re-invocation is recognized wherever it stands in the entry, on the grammar [the shelled-nmr line](#script-values) uses, and a segment carrying `-F` or `-R` is not one — it hands the command to other scopes rather than back to this one. Nor is `-w` from inside a package, which reaches the root's registry and the root's `package.json`. A filter that selects the declaring package is the hole in that exemption: `nmr -F own-pkg build` in `own-pkg`'s own `build` re-enters without bound, and nmr does not recognize it.
105
+
118
106
  ### Script values
119
107
 
120
108
  A script value is a string or an array of steps. Only the built-in defaults and `.config/nmr.config.ts` carry an array; a `package.json` script must be a string, and any other value is rejected when the scripts are read. Arrays expand to chained `nmr` sub-invocations:
@@ -145,9 +133,11 @@ An invocation carrying trailing arguments also serves no part of its work from t
145
133
 
146
134
  A string value is spawned through a shell as one command, so an `nmr` invocation inside it is invisible to the process above: a quiet failure surrenders the whole nested subtree rather than the failing step, and a loud run relays the nested tree through a forwarding pipe. nmr writes one line to stderr, in every verbosity, when a command resolves to a step reaching nmr through a shell.
147
135
 
148
- The line names the declaration it is an edit to — the file, the field, and the key — and its remedy follows from that site. A `.config/nmr.config.ts` entry keeps its nmr steps as a step list and moves anything else to a `<command>:pre` or `<command>:post` script: a step-list element is composed into `nmr <element>`, so it can name only an nmr command. A `package.json` entry has to go, since `package.json` holds no step list: delete it outright when it restates what nmr already runs for that command, and move the steps it adds to a `<command>:pre` or `<command>:post` script when they are the package's own. Where the registry defines no such command, both apply: name the command in `.config/nmr.config.ts`, and put the package-specific steps in a hook. The pair is named rather than `:post` alone because an entry's extra steps may run on either side of the chain: `rdy verify && nmr compile` puts them first.
136
+ The line names the declaration it is an edit to — the file, the field, and the key — and its remedy follows from that site. A `.config/nmr.config.ts` entry keeps its nmr steps as a step list and moves anything else to a `<command>:pre` or `<command>:post` script: a step-list element is composed into `nmr <element>`, so it can name only an nmr command. A `package.json` entry has to go, since `package.json` holds no step list: delete it outright when it restates what nmr already runs for that command, and move the steps it adds to a `<command>:pre` or `<command>:post` script when they are the package's own. Where the registry defines no such command, both apply: name the command in `.config/nmr.config.ts`, and put the package-specific steps in a hook. The pair is named rather than `:post` alone because an entry's extra steps may run on either side of the chain: `rdy verify && nmr compile` puts them first. A hook is the exception at both tiers: nmr wraps a `:pre` or `:post` script in no hooks of its own, so a hook's other steps become a script of their own that the step list names.
137
+
138
+ nmr recognizes `nmr` in command position: at the start of a step, after an unquoted `&&`, `||`, `;`, `|`, or a newline, and behind `npx`, `bunx`, or an `exec`, `dlx`, or `x` subcommand of `npm`, `pnpm`, `yarn`, or `bun`. This line reports a boundary rather than enforcing a rule — it covers a step nmr runs as written, and only the reporting of the nested run is degraded — and it stays partial in stated ways: a value-taking flag standing immediately before the program name hides it (`npx -p foo nmr`), a launcher outside that set goes unreported, and a `devBin` substitution that introduces `nmr` itself is not reported, the line being an edit to a declaration rather than to a substitution.
149
139
 
150
- nmr recognizes `nmr` in command position: at the start of a step, after an unquoted `&&`, `||`, `;`, or `|`, and behind `npx`, `bunx`, or an `exec`, `dlx`, or `x` subcommand of `npm`, `pnpm`, `yarn`, or `bun`. The line reports a boundary rather than enforcing a rule, and it stays partial in stated ways: a value-taking flag standing immediately before the program name hides it (`npx -p foo nmr`), a launcher outside that set goes unreported, and a `devBin` substitution that introduces `nmr` itself is not reported, the line being an edit to a declaration rather than to a substitution.
140
+ A [self-referential `package.json` entry](#three-tier-override-system) is the shape nmr cannot run as written, and it is rejected rather than reported. The same recognition finds it, so the same residuals apply — what goes unrecognized there re-enters nmr without bound, which hangs rather than passing quietly.
151
141
 
152
142
  ## Configuration
153
143
 
package/dist/esm/help.js CHANGED
@@ -43,7 +43,7 @@ function collectOverrides(packageDir) {
43
43
  for (const [name, value] of Object.entries(scripts)) {
44
44
  if (isHookName(name))
45
45
  continue;
46
- if (isSelfReferential(value, name))
46
+ if (isSelfReferential(value, name, packageDir))
47
47
  continue;
48
48
  filtered[name] = value;
49
49
  }
@@ -20,5 +20,6 @@ export declare function describeScript(script: ScriptValue): string;
20
20
  export declare function readPackageJsonScripts(packageDir: string): Record<string, string> | undefined;
21
21
  export declare function buildWorkspaceRegistry(config: NmrConfig): ScriptRegistry;
22
22
  export declare function buildRootRegistry(config: NmrConfig): ScriptRegistry;
23
- export declare function isSelfReferential(script: string, commandName: string): boolean;
23
+ export declare function findChainedSelfReference(packageDir: string | undefined, commandName: string): string | undefined;
24
+ export declare function isSelfReferential(script: string, commandName: string, packageDir: string): boolean;
24
25
  export declare function resolveScript(commandName: string, registry: ScriptRegistry, packageDir: string | undefined, workspaceRoot: boolean): ResolvedScript | undefined;
@@ -4,7 +4,8 @@ import { hasErrnoCode } from '@williamthorsen/nmr-core';
4
4
  import { parsePackageJson, readScriptRecord, resolvePackageJsonPath } from "./helpers/package-json.js";
5
5
  import { isObject } from "./helpers/type-guards.js";
6
6
  import { getDefaultRootScripts, getDefaultWorkspaceScripts } from "./resolve-scripts.js";
7
- import { composeNmrStep } from "./steps.js";
7
+ import { composeNmrStep, readSelfReference } from "./steps.js";
8
+ import { isMonorepoRoot } from "./workspace.js";
8
9
  export function applyDevBin(command, devBin, monorepoRoot) {
9
10
  if (!devBin) {
10
11
  return command;
@@ -88,16 +89,31 @@ export function buildRootRegistry(config) {
88
89
  ...config.rootScripts,
89
90
  };
90
91
  }
91
- export function isSelfReferential(script, commandName) {
92
- const prefix = `nmr ${commandName}`;
93
- return script === prefix || script.startsWith(`${prefix} `);
92
+ export function findChainedSelfReference(packageDir, commandName) {
93
+ if (packageDir === undefined) {
94
+ return undefined;
95
+ }
96
+ const scripts = readPackageJsonScripts(packageDir);
97
+ if (scripts === undefined || !Object.hasOwn(scripts, commandName)) {
98
+ return undefined;
99
+ }
100
+ const script = scripts[commandName];
101
+ if (script === undefined) {
102
+ return undefined;
103
+ }
104
+ return readSelfReference({ anchoredAtRoot: isMonorepoRoot(packageDir), commandName, script }) === 'chained'
105
+ ? script
106
+ : undefined;
107
+ }
108
+ export function isSelfReferential(script, commandName, packageDir) {
109
+ return readSelfReference({ anchoredAtRoot: isMonorepoRoot(packageDir), commandName, script }) !== undefined;
94
110
  }
95
111
  export function resolveScript(commandName, registry, packageDir, workspaceRoot) {
96
112
  if (packageDir) {
97
113
  const pkgScripts = readPackageJsonScripts(packageDir);
98
114
  if (pkgScripts && Object.hasOwn(pkgScripts, commandName)) {
99
115
  const override = pkgScripts[commandName];
100
- if (override !== undefined && !isSelfReferential(override, commandName)) {
116
+ if (override !== undefined && !isSelfReferential(override, commandName, packageDir)) {
101
117
  return {
102
118
  origin: { tier: 'package', file: resolvePackageJsonPath(packageDir), key: commandName },
103
119
  steps: [{ kind: 'opaque', command: override }],
@@ -7,17 +7,24 @@ import { resolveContext } from "./context.js";
7
7
  import { generateHelp } from "./help.js";
8
8
  import { deriveExcerpt } from "./helpers/deriveExcerpt.js";
9
9
  import { isHookName } from "./helpers/hook-name.js";
10
+ import { resolvePackageJsonPath } from "./helpers/package-json.js";
10
11
  import { composeTranscript } from "./helpers/transcript.js";
11
12
  import { renderRecording, renderRefusal, resolveRecording } from "./recording.js";
12
13
  import { assembleReplay } from "./replay-assembly.js";
13
14
  import { readReportFormatEnv, REPORT_FORMAT_ENV_VAR, resolveReportFormat } from "./report-format.js";
14
- import { applyDevBinToSteps, buildRootRegistry, buildWorkspaceRegistry, expandScript, resolveScript, } from "./resolver.js";
15
+ import { applyDevBinToSteps, buildRootRegistry, buildWorkspaceRegistry, expandScript, findChainedSelfReference, resolveScript, } from "./resolver.js";
15
16
  import { resolveChannel, runSteps } from "./runner.js";
16
17
  import { composeNmrStep, findNmrCrossing, renderChain } from "./steps.js";
18
+ import { UserError } from "./UserError.js";
17
19
  import { COMMAND_VERBOSITY_ENV_VAR, readVerbosityEnv, resolveVerbosity } from "./verbosity.js";
18
20
  import { writeVerdict } from "./verdict.js";
19
21
  const VERSION = readPackageVersion(import.meta.url);
20
22
  const CROSSING_CONSEQUENCE = "so nmr handles the nested run's output as a tool's.";
23
+ const NAMED_ESCAPES = new Map([
24
+ ['\n', String.raw `\n`],
25
+ ['\r', String.raw `\r`],
26
+ ['\t', String.raw `\t`],
27
+ ]);
21
28
  export const RUN_IF_PRESENT_ENV_VAR = 'NMR_RUN_IF_PRESENT';
22
29
  export async function runCli(options) {
23
30
  const { args, cwd, env, stdout, stderr } = options;
@@ -84,6 +91,14 @@ export async function runCli(options) {
84
91
  return runSteps([delegation.step], context.monorepoRoot, { ...runOptions, env: delegation.env });
85
92
  }
86
93
  const registry = useRoot ? buildRootRegistry(context.config) : buildWorkspaceRegistry(context.config);
94
+ assertNoSelfReference({
95
+ anchorDir,
96
+ command,
97
+ isReading: parsed.log,
98
+ monorepoRoot: context.monorepoRoot,
99
+ registry,
100
+ workspaceRoot: parsed.workspaceRoot,
101
+ });
87
102
  const resolved = resolveScript(command, registry, anchorDir, parsed.workspaceRoot);
88
103
  if (!resolved) {
89
104
  if (env[RUN_IF_PRESENT_ENV_VAR] === '1') {
@@ -255,8 +270,7 @@ function describeCrossingRemedy(options) {
255
270
  };
256
271
  case 'config':
257
272
  return {
258
- remedy: `Write the nmr steps as a step list, and move any others to a \`${origin.key}:pre\` or ` +
259
- `\`${origin.key}:post\` script.`,
273
+ remedy: `Write the nmr steps as a step list, and move any others to ${describeStepDestination(origin.key)}.`,
260
274
  subject: `${configSite}: \`${origin.field}.${origin.key}\``,
261
275
  };
262
276
  case 'package':
@@ -270,6 +284,12 @@ function describeCrossingRemedy(options) {
270
284
  }
271
285
  }
272
286
  }
287
+ function describeStepDestination(key) {
288
+ if (isHookName(key)) {
289
+ return 'a script of their own that the step list names, because a hook has no `:pre` or `:post` of its own';
290
+ }
291
+ return `a \`${key}:pre\` or \`${key}:post\` script`;
292
+ }
273
293
  function describeOrigin(origin, config, useRoot) {
274
294
  if (origin.tier === 'package') {
275
295
  return origin;
@@ -280,9 +300,17 @@ function describeOrigin(origin, config, useRoot) {
280
300
  ? { tier: 'config', field, key: origin.key }
281
301
  : { tier: 'default', key: origin.key };
282
302
  }
303
+ function escapeControlCharacters(text) {
304
+ let escaped = text;
305
+ for (const [char, escape] of NAMED_ESCAPES) {
306
+ escaped = escaped.split(char).join(escape);
307
+ }
308
+ return escaped;
309
+ }
283
310
  function formatNmrCrossingWarning(options) {
284
311
  const { remedy, subject } = describeCrossingRemedy(options);
285
- return `⚠️ ${subject} reaches nmr through a shell (\`${options.crossing}\`), ${CROSSING_CONSEQUENCE} ${remedy}`;
312
+ return (`⚠️ ${subject} reaches nmr through a shell (\`${escapeControlCharacters(options.crossing)}\`), ` +
313
+ `${CROSSING_CONSEQUENCE} ${remedy}`);
286
314
  }
287
315
  function formatOverrideNotice(resolved, registry, command, anchorDir, quiet) {
288
316
  const registryEntry = Object.hasOwn(registry, command) ? registry[command] : undefined;
@@ -291,18 +319,26 @@ function formatOverrideNotice(resolved, registry, command, anchorDir, quiet) {
291
319
  }
292
320
  return `📦 ${path.basename(anchorDir)}: Using override script: ${renderChain(resolved.steps)}\n`;
293
321
  }
322
+ function formatSelfReferenceRemedy(options) {
323
+ const { command, monorepoRoot, registry, script, workspaceRoot } = options;
324
+ if (isHookName(command)) {
325
+ return `Delete the re-invocation: \`${command}\` runs the steps standing beside it.`;
326
+ }
327
+ const configSite = path.relative(monorepoRoot, resolveConfigPath(monorepoRoot));
328
+ return formatPackageRemedy({ configSite, crossing: script, key: command, registry, workspaceRoot });
329
+ }
294
330
  function formatPackageRemedy(options) {
295
331
  const { configSite, crossing, key, registry, workspaceRoot } = options;
296
332
  const registryEntry = Object.hasOwn(registry, key) ? registry[key] : undefined;
297
333
  if (registryEntry === undefined) {
298
334
  return (`A \`package.json\` script holds no step list: define \`${key}\` in \`${configSite}\` and move the ` +
299
- `package-specific steps to a \`${key}:pre\` or \`${key}:post\` script.`);
335
+ `package-specific steps to ${describeStepDestination(key)}.`);
300
336
  }
301
337
  const registryChain = renderChain(expandScript(registryEntry, workspaceRoot));
302
338
  if (registryChain === crossing) {
303
- return `Delete the entry: nmr's own \`${key}\` already runs \`${registryChain}\`.`;
339
+ return `Delete the entry: nmr's own \`${key}\` already runs \`${escapeControlCharacters(registryChain)}\`.`;
304
340
  }
305
- return `Delete the entry and move the steps it adds to a \`${key}:pre\` or \`${key}:post\` script.`;
341
+ return `Delete the entry and move the steps it adds to ${describeStepDestination(key)}.`;
306
342
  }
307
343
  function findArgError(parsed) {
308
344
  if (parsed.log && parsed.command === undefined && !parsed.help && !parsed.version) {
@@ -319,7 +355,24 @@ function findNoOpReason(resolvedCommand) {
319
355
  }
320
356
  return undefined;
321
357
  }
358
+ function assertNoSelfReference(options) {
359
+ const { anchorDir, command, isReading, monorepoRoot, registry, workspaceRoot } = options;
360
+ if (isReading) {
361
+ return;
362
+ }
363
+ const script = findChainedSelfReference(anchorDir, command);
364
+ if (script === undefined) {
365
+ return;
366
+ }
367
+ const remedy = formatSelfReferenceRemedy({ command, monorepoRoot, registry, script, workspaceRoot });
368
+ const site = path.relative(monorepoRoot, resolvePackageJsonPath(anchorDir));
369
+ throw new UserError(`${site}: \`scripts.${command}\` re-invokes \`nmr ${command}\` (\`${escapeControlCharacters(script)}\`), ` +
370
+ `so nmr cannot run the steps it chains. ${remedy}`);
371
+ }
322
372
  function hasRunnableHook(hookName, registry, anchorDir, workspaceRoot) {
373
+ if (findChainedSelfReference(anchorDir, hookName) !== undefined) {
374
+ return true;
375
+ }
323
376
  const resolved = resolveScript(hookName, registry, anchorDir, workspaceRoot);
324
377
  if (!resolved)
325
378
  return false;
@@ -11,8 +11,14 @@ export interface NmrStepTarget {
11
11
  isDelegate: boolean;
12
12
  isWorkspaceRoot: boolean;
13
13
  }
14
+ export type SelfReference = 'chained' | 'sole';
14
15
  export declare function composeNmrStep(element: string, workspaceRoot: boolean, declinesArgs?: boolean): Step;
15
16
  export declare function findNmrCrossing(steps: readonly Step[]): string | undefined;
16
17
  export declare function findUnexpressibleToken(element: string): string | undefined;
17
18
  export declare function readNmrStep(step: Step): NmrStepTarget | undefined;
19
+ export declare function readSelfReference(options: {
20
+ anchoredAtRoot: boolean;
21
+ commandName: string;
22
+ script: string;
23
+ }): SelfReference | undefined;
18
24
  export declare function renderChain(steps: readonly Step[]): string;
package/dist/esm/steps.js CHANGED
@@ -8,7 +8,7 @@ const LAUNCHERS = new Map([
8
8
  ['yarn', new Set(['dlx', 'exec'])],
9
9
  ]);
10
10
  const QUOTES = new Set(['"', "'"]);
11
- const SEGMENT_SEPARATORS = new Set([';', '|', '&']);
11
+ const SEGMENT_SEPARATORS = new Set([';', '|', '&', '\n', '\r']);
12
12
  const SHELL_SAFE_TOKEN = /^[\w@%+=:,./-]+$/;
13
13
  export function composeNmrStep(element, workspaceRoot, declinesArgs = false) {
14
14
  const flags = workspaceRoot ? ['-w'] : [];
@@ -16,7 +16,7 @@ export function composeNmrStep(element, workspaceRoot, declinesArgs = false) {
16
16
  }
17
17
  export function findNmrCrossing(steps) {
18
18
  for (const step of steps) {
19
- if (step.kind === 'opaque' && splitSegments(step.command).some(reachesNmr)) {
19
+ if (step.kind === 'opaque' && splitSegments(step.command).some((segment) => readNmrTail(segment) !== undefined)) {
20
20
  return step.command;
21
21
  }
22
22
  }
@@ -30,14 +30,65 @@ export function readNmrStep(step) {
30
30
  return undefined;
31
31
  }
32
32
  const [file, ...rest] = step.argv;
33
- if (file !== 'nmr') {
33
+ return file === 'nmr' ? readNmrTarget(rest) : undefined;
34
+ }
35
+ export function readSelfReference(options) {
36
+ const { anchoredAtRoot, commandName, script } = options;
37
+ const segments = splitSegments(script).filter((segment) => segment.trim() !== '');
38
+ const reenters = segments.some((segment) => {
39
+ const tail = readNmrTail(segment);
40
+ const target = tail === undefined ? undefined : readNmrTarget(tail);
41
+ return target?.command === commandName && !target.isDelegate && (anchoredAtRoot || !target.isWorkspaceRoot);
42
+ });
43
+ if (!reenters) {
34
44
  return undefined;
35
45
  }
46
+ return segments.length === 1 ? 'sole' : 'chained';
47
+ }
48
+ export function renderChain(steps) {
49
+ return steps.map(renderStep).join(' && ');
50
+ }
51
+ function dropLeadingAssignments(tokens) {
52
+ const start = tokens.findIndex((token) => !ENV_ASSIGNMENT.test(token));
53
+ return start === -1 ? [] : tokens.slice(start);
54
+ }
55
+ function isRedirectionOperator(char, precedingText, nextChar) {
56
+ if (char !== '&' && char !== '|') {
57
+ return false;
58
+ }
59
+ return (char === '&' && nextChar === '>') || /[<>]\s*$/.test(precedingText);
60
+ }
61
+ function quoteToken(token) {
62
+ if (SHELL_SAFE_TOKEN.test(token)) {
63
+ return token;
64
+ }
65
+ return "'" + token.replaceAll("'", String.raw `'\''`) + "'";
66
+ }
67
+ function readNmrTail(segment) {
68
+ const [head, ...rest] = dropLeadingAssignments(tokenize(segment));
69
+ if (head === undefined) {
70
+ return undefined;
71
+ }
72
+ if (head === 'nmr') {
73
+ return rest;
74
+ }
75
+ const subcommands = LAUNCHERS.get(head);
76
+ if (subcommands === undefined) {
77
+ return undefined;
78
+ }
79
+ if (subcommands.size === 0) {
80
+ const nameIndex = rest.findIndex((token) => !token.startsWith('-'));
81
+ return nameIndex !== -1 && rest[nameIndex] === 'nmr' ? rest.slice(nameIndex + 1) : undefined;
82
+ }
83
+ const subcommandIndex = rest.findIndex((token) => subcommands.has(token));
84
+ return subcommandIndex !== -1 && rest[subcommandIndex + 1] === 'nmr' ? rest.slice(subcommandIndex + 2) : undefined;
85
+ }
86
+ function readNmrTarget(tokens) {
36
87
  let isDelegate = false;
37
88
  let isWorkspaceRoot = false;
38
89
  let index = 0;
39
- while (index < rest.length) {
40
- const token = rest[index] ?? '';
90
+ while (index < tokens.length) {
91
+ const token = tokens[index] ?? '';
41
92
  switch (token) {
42
93
  case '-F':
43
94
  case '--filter':
@@ -63,37 +114,6 @@ export function readNmrStep(step) {
63
114
  }
64
115
  return undefined;
65
116
  }
66
- export function renderChain(steps) {
67
- return steps.map(renderStep).join(' && ');
68
- }
69
- function dropLeadingAssignments(tokens) {
70
- const start = tokens.findIndex((token) => !ENV_ASSIGNMENT.test(token));
71
- return start === -1 ? [] : tokens.slice(start);
72
- }
73
- function quoteToken(token) {
74
- if (SHELL_SAFE_TOKEN.test(token)) {
75
- return token;
76
- }
77
- return "'" + token.replaceAll("'", String.raw `'\''`) + "'";
78
- }
79
- function reachesNmr(segment) {
80
- const [head, ...rest] = dropLeadingAssignments(tokenize(segment));
81
- if (head === undefined) {
82
- return false;
83
- }
84
- if (head === 'nmr') {
85
- return true;
86
- }
87
- const subcommands = LAUNCHERS.get(head);
88
- if (subcommands === undefined) {
89
- return false;
90
- }
91
- if (subcommands.size === 0) {
92
- return rest.find((token) => !token.startsWith('-')) === 'nmr';
93
- }
94
- const subcommandIndex = rest.findIndex((token) => subcommands.has(token));
95
- return subcommandIndex !== -1 && rest[subcommandIndex + 1] === 'nmr';
96
- }
97
117
  function renderStep(step) {
98
118
  return step.kind === 'opaque' ? step.command : step.argv.map(quoteToken).join(' ');
99
119
  }
@@ -128,6 +148,11 @@ function splitSegments(command) {
128
148
  continue;
129
149
  }
130
150
  if (SEGMENT_SEPARATORS.has(char)) {
151
+ if (isRedirectionOperator(char, current, command[index + 1])) {
152
+ current += char;
153
+ index += 1;
154
+ continue;
155
+ }
131
156
  index += command[index + 1] === char ? 2 : 1;
132
157
  segments.push(current);
133
158
  current = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.32.0",
3
+ "version": "0.32.1",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -79,7 +79,7 @@
79
79
  "@williamthorsen/toolbelt.testing": "0.3.0",
80
80
  "@williamthorsen/toolbelt.vitest": "0.4.0",
81
81
  "prettier": "3.9.6",
82
- "readyup": "0.28.0",
82
+ "readyup": "0.29.0",
83
83
  "tinyglobby": "0.2.17",
84
84
  "typescript": "6.0.3",
85
85
  "vitest": "4.1.10"