@williamthorsen/nmr 0.30.0 → 0.31.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 (64) hide show
  1. package/.readyup/kits/default.js +27 -16
  2. package/.readyup/manifest.json +2 -2
  3. package/README.md +299 -62
  4. package/agents/guidance/rulebooks/nmr.md +4 -2
  5. package/bin/nmr-report-catalog.js +11 -0
  6. package/dist/esm/UserError.d.ts +3 -0
  7. package/dist/esm/UserError.js +6 -0
  8. package/dist/esm/check-cache.d.ts +36 -1
  9. package/dist/esm/check-cache.js +91 -23
  10. package/dist/esm/cli-build.js +2 -1
  11. package/dist/esm/cli-clean.js +2 -1
  12. package/dist/esm/cli-ensure-prepublish-hooks.js +2 -1
  13. package/dist/esm/cli-report-catalog.d.ts +1 -0
  14. package/dist/esm/cli-report-catalog.js +11 -0
  15. package/dist/esm/cli-report-overrides.js +2 -1
  16. package/dist/esm/cli-taze.js +2 -1
  17. package/dist/esm/cli.js +2 -1
  18. package/dist/esm/commands/report-catalog.d.ts +1 -0
  19. package/dist/esm/commands/report-catalog.js +34 -0
  20. package/dist/esm/commands/report-overrides.js +22 -7
  21. package/dist/esm/commands/taze.js +2 -1
  22. package/dist/esm/config.d.ts +1 -0
  23. package/dist/esm/config.js +66 -18
  24. package/dist/esm/default-scripts.d.ts +5 -1
  25. package/dist/esm/default-scripts.js +13 -10
  26. package/dist/esm/defineConfig.d.ts +1 -1
  27. package/dist/esm/help.js +3 -1
  28. package/dist/esm/helpers/clampToBytes.d.ts +2 -0
  29. package/dist/esm/helpers/clampToBytes.js +20 -0
  30. package/dist/esm/helpers/createBoundedBuffer.js +1 -4
  31. package/dist/esm/helpers/deriveExcerpt.d.ts +1 -0
  32. package/dist/esm/helpers/deriveExcerpt.js +30 -0
  33. package/dist/esm/helpers/duration.js +16 -9
  34. package/dist/esm/helpers/package-json.d.ts +11 -4
  35. package/dist/esm/helpers/package-json.js +51 -22
  36. package/dist/esm/helpers/readStringValues.d.ts +1 -0
  37. package/dist/esm/helpers/readStringValues.js +8 -0
  38. package/dist/esm/helpers/transcript.d.ts +5 -0
  39. package/dist/esm/helpers/transcript.js +59 -0
  40. package/dist/esm/recording.d.ts +65 -0
  41. package/dist/esm/recording.js +96 -0
  42. package/dist/esm/replay-assembly.d.ts +9 -0
  43. package/dist/esm/replay-assembly.js +26 -0
  44. package/dist/esm/report-format.d.ts +18 -0
  45. package/dist/esm/report-format.js +25 -0
  46. package/dist/esm/reportCliFailure.d.ts +2 -0
  47. package/dist/esm/reportCliFailure.js +11 -0
  48. package/dist/esm/resolve-scripts.d.ts +1 -1
  49. package/dist/esm/resolver.d.ts +9 -1
  50. package/dist/esm/resolver.js +26 -17
  51. package/dist/esm/runCli.js +339 -74
  52. package/dist/esm/runner.d.ts +9 -3
  53. package/dist/esm/runner.js +25 -4
  54. package/dist/esm/scripts.d.ts +1 -1
  55. package/dist/esm/steps.d.ts +9 -2
  56. package/dist/esm/steps.js +117 -4
  57. package/dist/esm/types.d.ts +11 -2
  58. package/dist/esm/verbosity.d.ts +15 -4
  59. package/dist/esm/verbosity.js +32 -12
  60. package/dist/esm/verdict.d.ts +29 -0
  61. package/dist/esm/verdict.js +150 -0
  62. package/dist/esm/workspace.d.ts +2 -0
  63. package/dist/esm/workspace.js +20 -3
  64. package/package.json +12 -5
@@ -4,8 +4,15 @@ export type Step = {
4
4
  } | {
5
5
  kind: 'structural';
6
6
  argv: readonly [string, ...(readonly string[])];
7
+ declinesArgs?: boolean;
7
8
  };
8
- export declare function composeNmrStep(element: string, workspaceRoot: boolean): Step;
9
- export declare function findShelledNmrStep(steps: readonly Step[]): string | undefined;
9
+ export interface NmrStepTarget {
10
+ command: string;
11
+ isDelegate: boolean;
12
+ isWorkspaceRoot: boolean;
13
+ }
14
+ export declare function composeNmrStep(element: string, workspaceRoot: boolean, declinesArgs?: boolean): Step;
15
+ export declare function findNmrCrossing(steps: readonly Step[]): string | undefined;
10
16
  export declare function findUnexpressibleToken(element: string): string | undefined;
17
+ export declare function readNmrStep(step: Step): NmrStepTarget | undefined;
11
18
  export declare function renderChain(steps: readonly Step[]): string;
package/dist/esm/steps.js CHANGED
@@ -1,11 +1,22 @@
1
+ const ENV_ASSIGNMENT = /^\w+=/;
2
+ const LAUNCHERS = new Map([
3
+ ['bun', new Set(['x'])],
4
+ ['bunx', new Set()],
5
+ ['npm', new Set(['exec'])],
6
+ ['npx', new Set()],
7
+ ['pnpm', new Set(['dlx', 'exec'])],
8
+ ['yarn', new Set(['dlx', 'exec'])],
9
+ ]);
10
+ const QUOTES = new Set(['"', "'"]);
11
+ const SEGMENT_SEPARATORS = new Set([';', '|', '&']);
1
12
  const SHELL_SAFE_TOKEN = /^[\w@%+=:,./-]+$/;
2
- export function composeNmrStep(element, workspaceRoot) {
13
+ export function composeNmrStep(element, workspaceRoot, declinesArgs = false) {
3
14
  const flags = workspaceRoot ? ['-w'] : [];
4
- return { kind: 'structural', argv: ['nmr', ...flags, ...tokenize(element)] };
15
+ return { kind: 'structural', argv: ['nmr', ...flags, ...tokenize(element)], ...(declinesArgs && { declinesArgs }) };
5
16
  }
6
- export function findShelledNmrStep(steps) {
17
+ export function findNmrCrossing(steps) {
7
18
  for (const step of steps) {
8
- if (step.kind === 'opaque' && tokenize(step.command)[0] === 'nmr') {
19
+ if (step.kind === 'opaque' && splitSegments(step.command).some(reachesNmr)) {
9
20
  return step.command;
10
21
  }
11
22
  }
@@ -14,18 +25,120 @@ export function findShelledNmrStep(steps) {
14
25
  export function findUnexpressibleToken(element) {
15
26
  return tokenize(element).find((token) => !SHELL_SAFE_TOKEN.test(token));
16
27
  }
28
+ export function readNmrStep(step) {
29
+ if (step.kind !== 'structural') {
30
+ return undefined;
31
+ }
32
+ const [file, ...rest] = step.argv;
33
+ if (file !== 'nmr') {
34
+ return undefined;
35
+ }
36
+ let isDelegate = false;
37
+ let isWorkspaceRoot = false;
38
+ let index = 0;
39
+ while (index < rest.length) {
40
+ const token = rest[index] ?? '';
41
+ switch (token) {
42
+ case '-F':
43
+ case '--filter':
44
+ isDelegate = true;
45
+ index += 2;
46
+ break;
47
+ case '-R':
48
+ case '--recursive':
49
+ isDelegate = true;
50
+ index += 1;
51
+ break;
52
+ case '-w':
53
+ case '--workspace-root':
54
+ isWorkspaceRoot = true;
55
+ index += 1;
56
+ break;
57
+ default:
58
+ if (!token.startsWith('-')) {
59
+ return { command: token, isDelegate, isWorkspaceRoot };
60
+ }
61
+ index += 1;
62
+ }
63
+ }
64
+ return undefined;
65
+ }
17
66
  export function renderChain(steps) {
18
67
  return steps.map(renderStep).join(' && ');
19
68
  }
69
+ function dropLeadingAssignments(tokens) {
70
+ const start = tokens.findIndex((token) => !ENV_ASSIGNMENT.test(token));
71
+ return start === -1 ? [] : tokens.slice(start);
72
+ }
20
73
  function quoteToken(token) {
21
74
  if (SHELL_SAFE_TOKEN.test(token)) {
22
75
  return token;
23
76
  }
24
77
  return "'" + token.replaceAll("'", String.raw `'\''`) + "'";
25
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
+ }
26
97
  function renderStep(step) {
27
98
  return step.kind === 'opaque' ? step.command : step.argv.map(quoteToken).join(' ');
28
99
  }
100
+ function splitSegments(command) {
101
+ const segments = [];
102
+ let current = '';
103
+ let quote;
104
+ let index = 0;
105
+ while (index < command.length) {
106
+ const char = command[index] ?? '';
107
+ if (quote !== undefined) {
108
+ if (char === '\\' && quote === '"') {
109
+ current += char + (command[index + 1] ?? '');
110
+ index += 2;
111
+ continue;
112
+ }
113
+ current += char;
114
+ if (char === quote)
115
+ quote = undefined;
116
+ index += 1;
117
+ continue;
118
+ }
119
+ if (char === '\\') {
120
+ current += char + (command[index + 1] ?? '');
121
+ index += 2;
122
+ continue;
123
+ }
124
+ if (QUOTES.has(char)) {
125
+ quote = char;
126
+ current += char;
127
+ index += 1;
128
+ continue;
129
+ }
130
+ if (SEGMENT_SEPARATORS.has(char)) {
131
+ index += command[index + 1] === char ? 2 : 1;
132
+ segments.push(current);
133
+ current = '';
134
+ continue;
135
+ }
136
+ current += char;
137
+ index += 1;
138
+ }
139
+ segments.push(current);
140
+ return segments;
141
+ }
29
142
  function tokenize(element) {
30
143
  return element.split(/\s+/).filter((token) => token.length > 0);
31
144
  }
@@ -1,3 +1,7 @@
1
+ import type { ScriptValue } from './default-scripts.js';
2
+ import type { CommandVerbosity } from './verbosity.js';
3
+ export type { ScriptValue, StepSpec } from './default-scripts.js';
4
+ export type { CommandVerbosity } from './verbosity.js';
1
5
  export interface BuildConfig {
2
6
  extraIgnorePatterns?: string[];
3
7
  }
@@ -6,10 +10,15 @@ export interface CheckCacheConfig {
6
10
  excludeCommands?: string[];
7
11
  extraCommands?: string[];
8
12
  }
13
+ export interface OutputConfig {
14
+ commandVerbosity?: CommandVerbosity;
15
+ extraAgentEnvVars?: string[];
16
+ }
9
17
  export interface NmrConfig {
10
18
  build?: BuildConfig;
11
19
  checkCache?: CheckCacheConfig;
12
20
  devBin?: Record<string, string>;
13
- workspaceScripts?: Record<string, string | string[]>;
14
- rootScripts?: Record<string, string | string[]>;
21
+ output?: OutputConfig;
22
+ workspaceScripts?: Record<string, ScriptValue>;
23
+ rootScripts?: Record<string, ScriptValue>;
15
24
  }
@@ -1,11 +1,22 @@
1
- export declare const COMMAND_VERBOSITY_ENV_VAR = "NMR_COMMAND_VERBOSITY";
1
+ import type { OutputConfig } from './types.js';
2
+ export declare const AGENT_ENV_VARS: readonly ["CLAUDECODE", "ROVODEV_CLI", "ROVO_CLI"];
2
3
  export declare const COMMAND_VERBOSITIES: readonly ["full", "quiet"];
4
+ export declare const COMMAND_VERBOSITY_ENV_VAR = "NMR_COMMAND_VERBOSITY";
3
5
  export type CommandVerbosity = (typeof COMMAND_VERBOSITIES)[number];
4
- export type VerbosityResolution = {
6
+ export declare function formatVerbosityRejection(source: string, value: string): string;
7
+ export declare function isCommandVerbosity(value: string): value is CommandVerbosity;
8
+ export declare function readVerbosityEnv(env: NodeJS.ProcessEnv): VerbosityRead;
9
+ export interface ResolveVerbosityOptions {
10
+ env: NodeJS.ProcessEnv;
11
+ envVerbosity: CommandVerbosity | undefined;
12
+ output: OutputConfig | undefined;
13
+ quietFlag: boolean;
14
+ }
15
+ export declare function resolveVerbosity(options: ResolveVerbosityOptions): CommandVerbosity;
16
+ export type VerbosityRead = {
5
17
  ok: true;
6
- verbosity: CommandVerbosity;
18
+ verbosity?: CommandVerbosity;
7
19
  } | {
8
20
  ok: false;
9
21
  error: string;
10
22
  };
11
- export declare function resolveVerbosity(env: NodeJS.ProcessEnv, quietFlag: boolean): VerbosityResolution;
@@ -1,17 +1,37 @@
1
- export const COMMAND_VERBOSITY_ENV_VAR = 'NMR_COMMAND_VERBOSITY';
1
+ export const AGENT_ENV_VARS = ['CLAUDECODE', 'ROVODEV_CLI', 'ROVO_CLI'];
2
2
  export const COMMAND_VERBOSITIES = ['full', 'quiet'];
3
- export function resolveVerbosity(env, quietFlag) {
4
- const raw = env[COMMAND_VERBOSITY_ENV_VAR];
5
- const inherited = raw === undefined || raw === '' ? 'full' : raw;
6
- if (!isCommandVerbosity(inherited)) {
7
- return {
8
- ok: false,
9
- error: `${COMMAND_VERBOSITY_ENV_VAR} is \`${inherited}\`, which is not one of: ${COMMAND_VERBOSITIES.join(', ')}`,
10
- };
11
- }
12
- return { ok: true, verbosity: quietFlag ? 'quiet' : inherited };
3
+ export const COMMAND_VERBOSITY_ENV_VAR = 'NMR_COMMAND_VERBOSITY';
4
+ export function formatVerbosityRejection(source, value) {
5
+ return `${source} is \`${value}\`, which is not one of: ${COMMAND_VERBOSITIES.join(', ')}`;
13
6
  }
14
- function isCommandVerbosity(value) {
7
+ export function isCommandVerbosity(value) {
15
8
  const names = COMMAND_VERBOSITIES;
16
9
  return names.includes(value);
17
10
  }
11
+ export function readVerbosityEnv(env) {
12
+ const raw = env[COMMAND_VERBOSITY_ENV_VAR];
13
+ if (raw === undefined || raw === '') {
14
+ return { ok: true };
15
+ }
16
+ if (!isCommandVerbosity(raw)) {
17
+ return { ok: false, error: formatVerbosityRejection(COMMAND_VERBOSITY_ENV_VAR, raw) };
18
+ }
19
+ return { ok: true, verbosity: raw };
20
+ }
21
+ export function resolveVerbosity(options) {
22
+ const { env, envVerbosity, output, quietFlag } = options;
23
+ if (quietFlag)
24
+ return 'quiet';
25
+ if (envVerbosity !== undefined)
26
+ return envVerbosity;
27
+ if (output?.commandVerbosity !== undefined)
28
+ return output.commandVerbosity;
29
+ return hasAgentMarker(env, output?.extraAgentEnvVars) ? 'quiet' : 'full';
30
+ }
31
+ function hasAgentMarker(env, extraNames) {
32
+ const names = extraNames === undefined ? AGENT_ENV_VARS : [...AGENT_ENV_VARS, ...extraNames];
33
+ return names.some((name) => {
34
+ const value = env[name];
35
+ return value !== undefined && value !== '';
36
+ });
37
+ }
@@ -0,0 +1,29 @@
1
+ import type { Writable } from 'node:stream';
2
+ import type { ReplayLine } from './check-cache.js';
3
+ import type { ReportFormat } from './report-format.js';
4
+ export declare const VERDICT_LINE_LIMIT = 512;
5
+ export type Verdict = {
6
+ command: string;
7
+ scope: string;
8
+ } & VerdictOutcome;
9
+ export type VerdictOutcome = {
10
+ detail?: string;
11
+ } & ({
12
+ outcome: 'passed';
13
+ durationMs: number;
14
+ } | {
15
+ outcome: 'failed';
16
+ durationMs: number;
17
+ exitCode: number;
18
+ } | {
19
+ outcome: 'recalled';
20
+ ageMs: number;
21
+ savedMs: number;
22
+ replay?: ReplayLine[];
23
+ } | {
24
+ outcome: 'no-op';
25
+ reason: 'empty-override' | 'noop-override';
26
+ });
27
+ export declare function renderVerdict(verdict: Verdict): string;
28
+ export declare function serializeVerdict(verdict: Verdict): string;
29
+ export declare function writeVerdict(verdict: Verdict, stream: Writable, format: ReportFormat): void;
@@ -0,0 +1,150 @@
1
+ import { clampToBytes, TRUNCATION_MARK } from "./helpers/clampToBytes.js";
2
+ import { formatDuration, formatSaving } from "./helpers/duration.js";
3
+ export const VERDICT_LINE_LIMIT = 512;
4
+ export function renderVerdict(verdict) {
5
+ const { icon, phrase } = describeOutcome(verdict);
6
+ const detail = flattenDetail(verdict.detail ?? renderReplay(verdict) ?? '');
7
+ const detailClause = detail === '' ? '' : ` — ${detail}`;
8
+ return clampToBytes(`${icon} ${verdict.scope}: ${verdict.command}: ${phrase}${detailClause}`, LINE_BUDGET_BYTES);
9
+ }
10
+ export function serializeVerdict(verdict) {
11
+ const rendered = JSON.stringify(verdict);
12
+ return isWithinBudget(rendered) ? rendered : renderWithinBudget(verdict);
13
+ }
14
+ export function writeVerdict(verdict, stream, format) {
15
+ const line = format === 'json' ? serializeVerdict(verdict) : renderVerdict(verdict);
16
+ stream.write(`${line}\n`);
17
+ }
18
+ const NEWLINE_BYTES = 1;
19
+ const LINE_BUDGET_BYTES = VERDICT_LINE_LIMIT - NEWLINE_BYTES;
20
+ const MIN_CUT_BYTES = Buffer.byteLength(TRUNCATION_MARK);
21
+ const REPLAY_MARKER = 'replayed:';
22
+ function describeOutcome(verdict) {
23
+ switch (verdict.outcome) {
24
+ case 'passed':
25
+ return { icon: '✅', phrase: `passed in ${formatDuration(verdict.durationMs)}` };
26
+ case 'failed':
27
+ return {
28
+ icon: '❌',
29
+ phrase: `failed in ${formatDuration(verdict.durationMs)} (exit ${verdict.exitCode})`,
30
+ };
31
+ case 'recalled': {
32
+ const saving = formatSaving(verdict.savedMs);
33
+ const savingClause = saving === undefined ? '' : `, ${saving}`;
34
+ return { icon: '⏭️', phrase: `passed ${formatDuration(verdict.ageMs)} ago on this tree${savingClause}` };
35
+ }
36
+ case 'no-op':
37
+ return {
38
+ icon: '⛔',
39
+ phrase: `skipped, the override is ${verdict.reason === 'empty-override' ? 'empty' : 'a no-op'}`,
40
+ };
41
+ default: {
42
+ const unhandled = verdict;
43
+ throw new Error(`Unhandled verdict outcome: ${JSON.stringify(unhandled)}`);
44
+ }
45
+ }
46
+ }
47
+ function renderReplay(verdict) {
48
+ if (verdict.outcome !== 'recalled' || verdict.replay === undefined || verdict.replay.length === 0) {
49
+ return undefined;
50
+ }
51
+ const [first] = verdict.replay;
52
+ if (verdict.replay.length === 1 && first?.command === verdict.command && first.scope === verdict.scope) {
53
+ return `${REPLAY_MARKER} ${first.excerpt}`;
54
+ }
55
+ const lines = verdict.replay.map((line) => `${line.scope}: ${line.command}: ${line.excerpt}`);
56
+ return `${REPLAY_MARKER} ${lines.join('; ')}`;
57
+ }
58
+ function flattenDetail(detail) {
59
+ return detail.replaceAll(/[\r\n]+/gu, ' ').trim();
60
+ }
61
+ function findCutTarget(sizes, longest, overrunBytes) {
62
+ const below = sizes.filter((size) => size < longest && size > MIN_CUT_BYTES);
63
+ const share = Math.ceil(overrunBytes / sizes.filter((size) => size === longest).length);
64
+ const target = below.length === 0 ? longest - share : Math.max(...below, longest - overrunBytes);
65
+ return Math.max(MIN_CUT_BYTES, target);
66
+ }
67
+ function isWithinBudget(rendered) {
68
+ return Buffer.byteLength(rendered) <= LINE_BUDGET_BYTES;
69
+ }
70
+ function readAttribution(verdict) {
71
+ if (verdict.outcome !== 'recalled' || verdict.replay === undefined) {
72
+ return [];
73
+ }
74
+ return verdict.replay.map((line) => ({ command: line.command, scope: line.scope }));
75
+ }
76
+ function readCuttableText(verdict) {
77
+ const excerpts = verdict.outcome === 'recalled' && verdict.replay !== undefined ? verdict.replay.map((line) => line.excerpt) : [];
78
+ return [verdict.detail ?? '', ...excerpts];
79
+ }
80
+ function renderClamped(record) {
81
+ const clamped = { ...record };
82
+ let rendered = JSON.stringify(clamped);
83
+ while (!isWithinBudget(rendered)) {
84
+ const fields = ['command', 'scope'];
85
+ const sizes = fields.map((field) => Buffer.byteLength(typeof clamped[field] === 'string' ? clamped[field] : ''));
86
+ const longest = Math.max(...sizes);
87
+ if (longest <= MIN_CUT_BYTES) {
88
+ return rendered;
89
+ }
90
+ const target = findCutTarget(sizes, longest, Buffer.byteLength(rendered) - LINE_BUDGET_BYTES);
91
+ const field = fields[sizes.indexOf(longest)] ?? 'command';
92
+ clamped[field] = clampToBytes(typeof clamped[field] === 'string' ? clamped[field] : '', target);
93
+ rendered = JSON.stringify(clamped);
94
+ }
95
+ return rendered;
96
+ }
97
+ function renderWithinBudget(verdict) {
98
+ const shortened = shortenCuttableText(verdict);
99
+ const shortenedLine = JSON.stringify(shortened);
100
+ if (isWithinBudget(shortenedLine)) {
101
+ return shortenedLine;
102
+ }
103
+ const record = { ...shortened };
104
+ delete record['detail'];
105
+ const attribution = readAttribution(shortened);
106
+ for (let kept = attribution.length; kept > 0; kept--) {
107
+ record['replay'] = attribution.slice(0, kept);
108
+ const rendered = JSON.stringify(record);
109
+ if (isWithinBudget(rendered)) {
110
+ return rendered;
111
+ }
112
+ }
113
+ delete record['replay'];
114
+ const bare = JSON.stringify(record);
115
+ return isWithinBudget(bare) ? bare : renderClamped(record);
116
+ }
117
+ function shortenCuttableText(verdict) {
118
+ let candidate = verdict;
119
+ let rendered = JSON.stringify(candidate);
120
+ while (!isWithinBudget(rendered)) {
121
+ const shortened = shortenLongestText(candidate, Buffer.byteLength(rendered) - LINE_BUDGET_BYTES);
122
+ if (shortened === undefined) {
123
+ return candidate;
124
+ }
125
+ candidate = shortened;
126
+ rendered = JSON.stringify(candidate);
127
+ }
128
+ return candidate;
129
+ }
130
+ function shortenLongestText(verdict, overrunBytes) {
131
+ const texts = readCuttableText(verdict);
132
+ const sizes = texts.map((text) => Buffer.byteLength(text));
133
+ const longest = Math.max(...sizes);
134
+ if (longest <= MIN_CUT_BYTES) {
135
+ return undefined;
136
+ }
137
+ const index = sizes.indexOf(longest);
138
+ const target = findCutTarget(sizes, longest, overrunBytes);
139
+ return writeCuttableText(verdict, index, clampToBytes(texts[index] ?? '', target));
140
+ }
141
+ function writeCuttableText(verdict, index, value) {
142
+ if (index === 0) {
143
+ return { ...verdict, detail: value };
144
+ }
145
+ if (verdict.outcome !== 'recalled' || verdict.replay === undefined) {
146
+ return verdict;
147
+ }
148
+ const replay = verdict.replay.map((line, position) => (position === index - 1 ? { ...line, excerpt: value } : line));
149
+ return { ...verdict, replay };
150
+ }
@@ -1,2 +1,4 @@
1
+ export declare function isMonorepoRoot(dir: string): boolean;
1
2
  export declare function findMonorepoRoot(startDir?: string): string;
2
3
  export declare function getWorkspacePackageDirs(monorepoRoot: string): string[];
4
+ export declare function readWorkspaceOverrides(monorepoRoot: string): Record<string, string> | undefined;
@@ -1,18 +1,23 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { parse } from 'yaml';
4
+ import { readStringValues } from "./helpers/readStringValues.js";
4
5
  import { isObject } from "./helpers/type-guards.js";
5
6
  import { resolvePackageDirs } from "./helpers/workspace-patterns.js";
7
+ import { UserError } from "./UserError.js";
6
8
  const WORKSPACE_MANIFEST = 'pnpm-workspace.yaml';
9
+ export function isMonorepoRoot(dir) {
10
+ return existsSync(path.join(dir, WORKSPACE_MANIFEST));
11
+ }
7
12
  export function findMonorepoRoot(startDir) {
8
13
  let dir = path.resolve(startDir ?? process.cwd());
9
14
  for (;;) {
10
- if (existsSync(path.join(dir, WORKSPACE_MANIFEST))) {
15
+ if (isMonorepoRoot(dir)) {
11
16
  return dir;
12
17
  }
13
18
  const parent = path.dirname(dir);
14
19
  if (parent === dir) {
15
- throw new Error(`Could not find monorepo root: no ${WORKSPACE_MANIFEST} found in any parent directory`);
20
+ throw new UserError(`Could not find monorepo root: no ${WORKSPACE_MANIFEST} found in any parent directory`);
16
21
  }
17
22
  dir = parent;
18
23
  }
@@ -20,7 +25,7 @@ export function findMonorepoRoot(startDir) {
20
25
  export function getWorkspacePackageDirs(monorepoRoot) {
21
26
  const workspaceFile = path.join(monorepoRoot, WORKSPACE_MANIFEST);
22
27
  if (!existsSync(workspaceFile)) {
23
- throw new Error(`Not a monorepo root: no ${WORKSPACE_MANIFEST} in ${monorepoRoot}`);
28
+ throw new UserError(`Not a monorepo root: no ${WORKSPACE_MANIFEST} in ${monorepoRoot}`);
24
29
  }
25
30
  const content = readFileSync(workspaceFile, 'utf8');
26
31
  const parsed = parse(content);
@@ -30,6 +35,18 @@ export function getWorkspacePackageDirs(monorepoRoot) {
30
35
  }
31
36
  return resolvePackageDirs(monorepoRoot, packages);
32
37
  }
38
+ export function readWorkspaceOverrides(monorepoRoot) {
39
+ const workspaceFile = path.join(monorepoRoot, WORKSPACE_MANIFEST);
40
+ if (!existsSync(workspaceFile)) {
41
+ return undefined;
42
+ }
43
+ const parsed = parse(readFileSync(workspaceFile, 'utf8'));
44
+ if (!isObject(parsed)) {
45
+ return undefined;
46
+ }
47
+ const overrides = parsed['overrides'];
48
+ return isObject(overrides) ? readStringValues(overrides) : undefined;
49
+ }
33
50
  function getPackagesFromParsedYaml(parsed) {
34
51
  if (!isObject(parsed))
35
52
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -20,6 +20,9 @@
20
20
  },
21
21
  "license": "ISC",
22
22
  "author": "William Thorsen <william@thorsen.dev> (https://github.com/williamthorsen)",
23
+ "sideEffects": [
24
+ "./dist/esm/cli*.js"
25
+ ],
23
26
  "type": "module",
24
27
  "exports": {
25
28
  "./config": {
@@ -53,6 +56,7 @@
53
56
  "nmr-clean": "bin/nmr-clean.js",
54
57
  "nmr-compile": "bin/nmr-compile.js",
55
58
  "nmr-fmt": "bin/nmr-fmt.js",
59
+ "nmr-report-catalog": "bin/nmr-report-catalog.js",
56
60
  "nmr-report-overrides": "bin/nmr-report-overrides.js",
57
61
  "nmr-taze": "bin/nmr-taze.js"
58
62
  },
@@ -64,16 +68,18 @@
64
68
  "dist"
65
69
  ],
66
70
  "dependencies": {
71
+ "@williamthorsen/toolbelt.errors": "0.4.0",
67
72
  "glob": "13.0.6",
68
73
  "prettier-plugin-sh": "0.19.0",
69
- "taze": "19.17.1",
74
+ "taze": "20.0.0",
70
75
  "yaml": "2.9.0",
71
- "@williamthorsen/nmr-core": "0.9.3"
76
+ "@williamthorsen/nmr-core": "0.10.0"
72
77
  },
73
78
  "devDependencies": {
74
79
  "prettier": "3.9.6",
75
- "readyup": "0.26.0",
80
+ "readyup": "0.27.0",
76
81
  "tinyglobby": "0.2.17",
82
+ "typescript": "6.0.3",
77
83
  "vitest": "4.1.10"
78
84
  },
79
85
  "peerDependencies": {
@@ -90,7 +96,8 @@
90
96
  }
91
97
  },
92
98
  "engines": {
93
- "node": ">=24.16.0"
99
+ "node": ">=24.16.0",
100
+ "pnpm": ">=11"
94
101
  },
95
102
  "publishConfig": {
96
103
  "access": "public"