@williamthorsen/nmr 0.29.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 (66) hide show
  1. package/.readyup/kits/default.js +33 -22
  2. package/.readyup/manifest.json +2 -2
  3. package/README.md +327 -78
  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 +93 -24
  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/clean.js +9 -5
  19. package/dist/esm/commands/report-catalog.d.ts +1 -0
  20. package/dist/esm/commands/report-catalog.js +34 -0
  21. package/dist/esm/commands/report-overrides.js +22 -7
  22. package/dist/esm/commands/taze.js +2 -1
  23. package/dist/esm/config.d.ts +1 -0
  24. package/dist/esm/config.js +81 -16
  25. package/dist/esm/default-scripts.d.ts +5 -1
  26. package/dist/esm/default-scripts.js +19 -16
  27. package/dist/esm/defineConfig.d.ts +1 -1
  28. package/dist/esm/help.js +3 -1
  29. package/dist/esm/helpers/clampToBytes.d.ts +2 -0
  30. package/dist/esm/helpers/clampToBytes.js +20 -0
  31. package/dist/esm/helpers/createBoundedBuffer.js +1 -4
  32. package/dist/esm/helpers/deriveExcerpt.d.ts +1 -0
  33. package/dist/esm/helpers/deriveExcerpt.js +30 -0
  34. package/dist/esm/helpers/duration.js +16 -9
  35. package/dist/esm/helpers/package-json.d.ts +11 -4
  36. package/dist/esm/helpers/package-json.js +51 -22
  37. package/dist/esm/helpers/readStringValues.d.ts +1 -0
  38. package/dist/esm/helpers/readStringValues.js +8 -0
  39. package/dist/esm/helpers/transcript.d.ts +5 -0
  40. package/dist/esm/helpers/transcript.js +59 -0
  41. package/dist/esm/recording.d.ts +65 -0
  42. package/dist/esm/recording.js +96 -0
  43. package/dist/esm/replay-assembly.d.ts +9 -0
  44. package/dist/esm/replay-assembly.js +26 -0
  45. package/dist/esm/report-format.d.ts +18 -0
  46. package/dist/esm/report-format.js +25 -0
  47. package/dist/esm/reportCliFailure.d.ts +2 -0
  48. package/dist/esm/reportCliFailure.js +11 -0
  49. package/dist/esm/resolve-scripts.d.ts +1 -1
  50. package/dist/esm/resolver.d.ts +13 -3
  51. package/dist/esm/resolver.js +34 -19
  52. package/dist/esm/runCli.d.ts +1 -0
  53. package/dist/esm/runCli.js +372 -70
  54. package/dist/esm/runner.d.ts +20 -1
  55. package/dist/esm/runner.js +72 -12
  56. package/dist/esm/scripts.d.ts +1 -1
  57. package/dist/esm/steps.d.ts +18 -0
  58. package/dist/esm/steps.js +144 -0
  59. package/dist/esm/types.d.ts +11 -2
  60. package/dist/esm/verbosity.d.ts +22 -0
  61. package/dist/esm/verbosity.js +37 -0
  62. package/dist/esm/verdict.d.ts +29 -0
  63. package/dist/esm/verdict.js +150 -0
  64. package/dist/esm/workspace.d.ts +2 -0
  65. package/dist/esm/workspace.js +20 -3
  66. package/package.json +12 -5
@@ -10,12 +10,16 @@ import { existsSync } from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
12
  import { isObject, isStringRecord } from "./helpers/type-guards.js";
13
+ import { findUnexpressibleToken } from "./steps.js";
14
+ import { UserError } from "./UserError.js";
15
+ import { formatVerbosityRejection, isCommandVerbosity } from "./verbosity.js";
13
16
  const CONFIG_FILENAME = 'nmr.config.ts';
14
17
  const CONFIG_DIR = '.config';
18
+ export const CONFIG_RELATIVE_PATH = path.join(CONFIG_DIR, CONFIG_FILENAME);
15
19
  const CONFIG_TIERS = {
16
20
  root: {
17
21
  label: 'a monorepo-root config',
18
- honoredKeys: ['checkCache', 'devBin', 'rootScripts', 'workspaceScripts'],
22
+ honoredKeys: ['checkCache', 'devBin', 'output', 'rootScripts', 'workspaceScripts'],
19
23
  elsewhere: "the package's own config",
20
24
  },
21
25
  workspace: {
@@ -27,6 +31,8 @@ const CONFIG_TIERS = {
27
31
  const RECOGNIZED_KEYS = [...CONFIG_TIERS.root.honoredKeys, ...CONFIG_TIERS.workspace.honoredKeys];
28
32
  const RECOGNIZED_BUILD_KEYS = ['extraIgnorePatterns'];
29
33
  const RECOGNIZED_CHECK_CACHE_KEYS = ['enabled', 'excludeCommands', 'extraCommands'];
34
+ const RECOGNIZED_OUTPUT_KEYS = ['commandVerbosity', 'extraAgentEnvVars'];
35
+ const RECOGNIZED_STEP_KEYS = ['declinesArgs', 'run'];
30
36
  export function resolveConfigPath(baseDir) {
31
37
  return path.join(baseDir, CONFIG_DIR, CONFIG_FILENAME);
32
38
  }
@@ -34,21 +40,50 @@ function isScriptRecord(value) {
34
40
  if (!isObject(value))
35
41
  return false;
36
42
  for (const v of Object.values(value)) {
37
- if (typeof v !== 'string' && !Array.isArray(v))
38
- return false;
39
- if (Array.isArray(v) && v.some((item) => typeof item !== 'string'))
43
+ if (typeof v === 'string')
44
+ continue;
45
+ if (!Array.isArray(v) || !v.every(isScriptElement))
40
46
  return false;
41
47
  }
42
48
  return true;
43
49
  }
50
+ function isScriptElement(value) {
51
+ if (typeof value === 'string')
52
+ return true;
53
+ if (!isObject(value) || typeof value['run'] !== 'string')
54
+ return false;
55
+ const declinesArgs = value['declinesArgs'];
56
+ return declinesArgs === undefined || typeof declinesArgs === 'boolean';
57
+ }
44
58
  function validateScriptField(value, fieldName, configPath) {
45
59
  if (!Object.hasOwn(value, fieldName) || value[fieldName] === undefined) {
46
60
  return undefined;
47
61
  }
48
- if (!isScriptRecord(value[fieldName])) {
49
- throw new Error(`Invalid nmr config at ${configPath}: \`${fieldName}\` must be a Record<string, string | string[]>`);
62
+ const scripts = value[fieldName];
63
+ if (!isScriptRecord(scripts)) {
64
+ throw new UserError(`Invalid nmr config at ${configPath}: \`${fieldName}\` must be a Record<string, string | element[]>, ` +
65
+ 'where an element is a command string or `{ run: string, declinesArgs?: boolean }`');
66
+ }
67
+ assertValidElements(scripts, fieldName, configPath);
68
+ return scripts;
69
+ }
70
+ function assertValidElements(scripts, fieldName, configPath) {
71
+ for (const [command, script] of Object.entries(scripts)) {
72
+ if (typeof script === 'string')
73
+ continue;
74
+ for (const spec of script) {
75
+ if (typeof spec !== 'string') {
76
+ assertRecognizedKeys(spec, RECOGNIZED_STEP_KEYS, configPath, `${fieldName}.${command}.`);
77
+ }
78
+ const element = typeof spec === 'string' ? spec : spec.run;
79
+ const token = findUnexpressibleToken(element);
80
+ if (token === undefined)
81
+ continue;
82
+ throw new UserError(`Invalid nmr config at ${configPath}: \`${fieldName}.${command}\` element \`${element}\` carries ` +
83
+ `\`${token}\`, which is neither a command name nor an nmr flag. Name it as a script of its own and ` +
84
+ `reference that name here.`);
85
+ }
50
86
  }
51
- return value[fieldName];
52
87
  }
53
88
  function isStringArray(value) {
54
89
  return Array.isArray(value) && value.every((item) => typeof item === 'string');
@@ -59,12 +94,12 @@ function validateBuildField(value, configPath) {
59
94
  return undefined;
60
95
  }
61
96
  if (!isObject(build)) {
62
- throw new Error(`Invalid nmr config at ${configPath}: \`build\` must be an object`);
97
+ throw new UserError(`Invalid nmr config at ${configPath}: \`build\` must be an object`);
63
98
  }
64
99
  assertRecognizedKeys(build, RECOGNIZED_BUILD_KEYS, configPath, 'build.');
65
100
  const extraIgnorePatterns = build['extraIgnorePatterns'];
66
101
  if (extraIgnorePatterns !== undefined && !isStringArray(extraIgnorePatterns)) {
67
- throw new Error(`Invalid nmr config at ${configPath}: \`build.extraIgnorePatterns\` must be a string[]`);
102
+ throw new UserError(`Invalid nmr config at ${configPath}: \`build.extraIgnorePatterns\` must be a string[]`);
68
103
  }
69
104
  return extraIgnorePatterns === undefined ? {} : { extraIgnorePatterns };
70
105
  }
@@ -74,14 +109,14 @@ function validateCheckCacheField(value, configPath) {
74
109
  return undefined;
75
110
  }
76
111
  if (!isObject(checkCache)) {
77
- throw new Error(`Invalid nmr config at ${configPath}: \`checkCache\` must be an object`);
112
+ throw new UserError(`Invalid nmr config at ${configPath}: \`checkCache\` must be an object`);
78
113
  }
79
114
  assertRecognizedKeys(checkCache, RECOGNIZED_CHECK_CACHE_KEYS, configPath, 'checkCache.');
80
115
  const config = {};
81
116
  const enabled = checkCache['enabled'];
82
117
  if (enabled !== undefined) {
83
118
  if (typeof enabled !== 'boolean') {
84
- throw new TypeError(`Invalid nmr config at ${configPath}: \`checkCache.enabled\` must be a boolean`);
119
+ throw new UserError(`Invalid nmr config at ${configPath}: \`checkCache.enabled\` must be a boolean`);
85
120
  }
86
121
  config.enabled = enabled;
87
122
  }
@@ -91,24 +126,51 @@ function validateCheckCacheField(value, configPath) {
91
126
  continue;
92
127
  }
93
128
  if (!isStringArray(commands)) {
94
- throw new Error(`Invalid nmr config at ${configPath}: \`checkCache.${field}\` must be a string[]`);
129
+ throw new UserError(`Invalid nmr config at ${configPath}: \`checkCache.${field}\` must be a string[]`);
95
130
  }
96
131
  config[field] = commands;
97
132
  }
98
133
  return config;
99
134
  }
135
+ function validateOutputField(value, configPath) {
136
+ const output = value['output'];
137
+ if (output === undefined) {
138
+ return undefined;
139
+ }
140
+ if (!isObject(output)) {
141
+ throw new UserError(`Invalid nmr config at ${configPath}: \`output\` must be an object`);
142
+ }
143
+ assertRecognizedKeys(output, RECOGNIZED_OUTPUT_KEYS, configPath, 'output.');
144
+ const config = {};
145
+ const commandVerbosity = output['commandVerbosity'];
146
+ if (commandVerbosity !== undefined) {
147
+ if (typeof commandVerbosity !== 'string' || !isCommandVerbosity(commandVerbosity)) {
148
+ const rendered = typeof commandVerbosity === 'string' ? commandVerbosity : JSON.stringify(commandVerbosity);
149
+ throw new UserError(`Invalid nmr config at ${configPath}: ${formatVerbosityRejection('`output.commandVerbosity`', rendered)}`);
150
+ }
151
+ config.commandVerbosity = commandVerbosity;
152
+ }
153
+ const extraAgentEnvVars = output['extraAgentEnvVars'];
154
+ if (extraAgentEnvVars !== undefined) {
155
+ if (!isStringArray(extraAgentEnvVars)) {
156
+ throw new UserError(`Invalid nmr config at ${configPath}: \`output.extraAgentEnvVars\` must be a string[]`);
157
+ }
158
+ config.extraAgentEnvVars = extraAgentEnvVars;
159
+ }
160
+ return config;
161
+ }
100
162
  function validateStringRecordField(value, fieldName, configPath) {
101
163
  if (!Object.hasOwn(value, fieldName) || value[fieldName] === undefined) {
102
164
  return undefined;
103
165
  }
104
166
  if (!isStringRecord(value[fieldName])) {
105
- throw new Error(`Invalid nmr config at ${configPath}: \`${fieldName}\` must be a Record<string, string>`);
167
+ throw new UserError(`Invalid nmr config at ${configPath}: \`${fieldName}\` must be a Record<string, string>`);
106
168
  }
107
169
  return value[fieldName];
108
170
  }
109
171
  function validateConfig(value, configPath) {
110
172
  if (!isObject(value)) {
111
- throw new Error(`Invalid nmr config at ${configPath}: expected an object, got ${typeof value}`);
173
+ throw new UserError(`Invalid nmr config at ${configPath}: expected an object, got ${typeof value}`);
112
174
  }
113
175
  assertRecognizedKeys(value, RECOGNIZED_KEYS, configPath);
114
176
  const config = {};
@@ -121,6 +183,9 @@ function validateConfig(value, configPath) {
121
183
  const devBin = validateStringRecordField(value, 'devBin', configPath);
122
184
  if (devBin)
123
185
  config.devBin = devBin;
186
+ const output = validateOutputField(value, configPath);
187
+ if (output)
188
+ config.output = output;
124
189
  const workspaceScripts = validateScriptField(value, 'workspaceScripts', configPath);
125
190
  if (workspaceScripts)
126
191
  config.workspaceScripts = workspaceScripts;
@@ -153,7 +218,7 @@ function assertRecognizedKeys(value, recognizedKeys, configPath, prefix = '') {
153
218
  if (unrecognized.length === 0) {
154
219
  return;
155
220
  }
156
- throw new Error(`Invalid nmr config at ${configPath}: unrecognized ${unrecognized.length === 1 ? 'key' : 'keys'} ` +
221
+ throw new UserError(`Invalid nmr config at ${configPath}: unrecognized ${unrecognized.length === 1 ? 'key' : 'keys'} ` +
157
222
  `${formatKeyList(unrecognized, prefix)}. Recognized: ${formatKeyList(recognizedKeys, prefix)}.`);
158
223
  }
159
224
  function assertTierKeys(config, tier, configPath) {
@@ -161,7 +226,7 @@ function assertTierKeys(config, tier, configPath) {
161
226
  if (unsupported.length === 0) {
162
227
  return;
163
228
  }
164
- throw new Error(`Invalid nmr config at ${configPath}: ${tier.label} honors ${tier.honoredKeys.toSorted().join(', ')} alone, ` +
229
+ throw new UserError(`Invalid nmr config at ${configPath}: ${tier.label} honors ${tier.honoredKeys.toSorted().join(', ')} alone, ` +
165
230
  `not ${unsupported.toSorted().join(', ')}. Move those keys to ${tier.elsewhere}.`);
166
231
  }
167
232
  function formatKeyList(keys, prefix) {
@@ -1,4 +1,8 @@
1
- export type ScriptValue = string | string[];
1
+ export interface StepSpec {
2
+ run: string;
3
+ declinesArgs?: boolean;
4
+ }
5
+ export type ScriptValue = string | ReadonlyArray<string | StepSpec>;
2
6
  export type ScriptRegistry = Record<string, ScriptValue>;
3
7
  export declare const workspaceScripts: ScriptRegistry;
4
8
  export declare const rootScripts: ScriptRegistry;
@@ -1,8 +1,10 @@
1
1
  const GATE_PROJECTS = '--project unit --project tool';
2
+ const TYPECHECK_STEP = { run: 'typecheck', declinesArgs: true };
3
+ const ROOT_TYPECHECK_STEP = { run: 'root:typecheck', declinesArgs: true };
2
4
  export const workspaceScripts = {
3
5
  build: ['compile'],
4
- check: ['typecheck', 'fmt:check', 'lint:check', 'test'],
5
- 'check:strict': ['typecheck', 'fmt:check', 'lint:strict', 'test:coverage'],
6
+ check: [TYPECHECK_STEP, 'fmt:check', 'lint:check', 'test'],
7
+ 'check:strict': [TYPECHECK_STEP, 'fmt:check', 'lint:strict', 'test:coverage'],
6
8
  clean: 'nmr-clean',
7
9
  compile: 'nmr-compile',
8
10
  fix: ['lint', 'fmt'],
@@ -12,6 +14,7 @@ export const workspaceScripts = {
12
14
  lint: 'eslint --fix .',
13
15
  'lint:check': 'eslint .',
14
16
  'lint:strict': 'strict-lint',
17
+ 'report-catalog': 'nmr-report-catalog',
15
18
  test: `pnpm exec vitest ${GATE_PROJECTS}`,
16
19
  'test:all': 'pnpm exec vitest',
17
20
  'test:coverage': `pnpm exec vitest ${GATE_PROJECTS} --coverage`,
@@ -19,17 +22,17 @@ export const workspaceScripts = {
19
22
  'test:unit': 'pnpm exec vitest --project unit',
20
23
  'test:watch': `pnpm exec vitest ${GATE_PROJECTS} --watch`,
21
24
  typecheck: 'tsgo --noEmit',
22
- upgrade: 'nmr-taze --include-locked',
25
+ upgrade: 'nmr-report-catalog && nmr-taze --include-locked',
23
26
  'view-coverage': 'open coverage/index.html',
24
27
  };
25
28
  export const rootScripts = {
26
29
  audit: ['audit:prod', 'audit:dev'],
27
30
  'audit:dev': 'pnpm exec v11y --dev',
28
31
  'audit:prod': 'pnpm exec v11y --prod',
29
- build: 'pnpm --recursive exec nmr build',
30
- check: ['typecheck', 'fmt:check', 'lint:check', 'test'],
31
- 'check:strict': ['typecheck', 'fmt:check', 'lint:strict', 'test:coverage'],
32
- ci: ['build', 'check:strict'],
32
+ build: ['-R build'],
33
+ check: [TYPECHECK_STEP, 'fmt:check', 'lint:check', 'test'],
34
+ 'check:strict': [TYPECHECK_STEP, 'fmt:check', 'lint:strict', 'test:coverage'],
35
+ ci: [{ run: 'build', declinesArgs: true }, 'check:strict'],
33
36
  clean: 'nmr-clean',
34
37
  fix: ['lint', 'fmt'],
35
38
  'fix:check': ['fmt:check', 'lint:check'],
@@ -38,9 +41,9 @@ export const rootScripts = {
38
41
  lint: 'eslint --fix .',
39
42
  'lint:check': 'eslint .',
40
43
  'lint:strict': 'strict-lint',
41
- prepush: ['audit', 'ci'],
44
+ prepush: [{ run: 'audit', declinesArgs: true }, 'ci'],
42
45
  'report-overrides': 'nmr-report-overrides',
43
- 'root:check': ['root:typecheck', 'fmt:check', 'root:lint:check', 'root:test'],
46
+ 'root:check': [ROOT_TYPECHECK_STEP, 'fmt:check', 'root:lint:check', 'root:test'],
44
47
  'root:lint': "eslint --fix --ignore-pattern 'packages/**' .",
45
48
  'root:lint:check': "eslint --ignore-pattern 'packages/**' .",
46
49
  'root:lint:strict': "strict-lint --ignore-pattern 'packages/**' .",
@@ -49,13 +52,13 @@ export const rootScripts = {
49
52
  'root:test:tool': 'vitest --config ./vitest.root.config.ts --project tool',
50
53
  'root:test:unit': 'vitest --config ./vitest.root.config.ts --project unit',
51
54
  'root:typecheck': 'tsgo --noEmit',
52
- 'root:upgrade': 'nmr-taze --include-locked',
53
- test: 'nmr root:test && pnpm --recursive exec nmr test',
54
- 'test:all': 'nmr root:test:all && pnpm --recursive exec nmr test:all',
55
- 'test:coverage': 'nmr root:test && pnpm --recursive exec nmr test:coverage',
56
- 'test:tool': 'nmr root:test:tool && pnpm --recursive exec nmr test:tool',
57
- 'test:unit': 'nmr root:test:unit && pnpm --recursive exec nmr test:unit',
55
+ 'root:upgrade': 'nmr-report-overrides && nmr-taze --include-locked',
56
+ test: ['root:test', '-R test'],
57
+ 'test:all': ['root:test:all', '-R test:all'],
58
+ 'test:coverage': ['root:test', '-R test:coverage'],
59
+ 'test:tool': ['root:test:tool', '-R test:tool'],
60
+ 'test:unit': ['root:test:unit', '-R test:unit'],
58
61
  'test:watch': `vitest ${GATE_PROJECTS} --watch`,
59
- typecheck: 'nmr root:typecheck && pnpm --recursive exec nmr typecheck',
62
+ typecheck: [ROOT_TYPECHECK_STEP, { run: '-R typecheck', declinesArgs: true }],
60
63
  upgrade: 'nmr-report-overrides && nmr-taze --include-locked --recursive',
61
64
  };
@@ -1,3 +1,3 @@
1
1
  import type { NmrConfig } from './types.js';
2
- export type { BuildConfig, CheckCacheConfig, NmrConfig } from './types.js';
2
+ export type { BuildConfig, CheckCacheConfig, CommandVerbosity, NmrConfig, OutputConfig, ScriptValue, StepSpec, } from './types.js';
3
3
  export declare function defineConfig(config: NmrConfig): NmrConfig;
package/dist/esm/help.js CHANGED
@@ -8,7 +8,9 @@ export function generateHelp(config, packageDir, useRoot) {
8
8
  ' -F, --filter <pattern> Run command in matching packages',
9
9
  ' -R, --recursive Run command in all packages',
10
10
  ' -w, --workspace-root Use root scripts, running at the monorepo root',
11
- ' -q, --quiet Suppress output on success; show full output on failure',
11
+ " -q, --quiet Suppress command output, keeping nmr's verdicts",
12
+ ' --json Report one JSON object per command, withholding command output',
13
+ ' --log Print the recorded run instead of running (up to 256 KiB)',
12
14
  ' --no-cache Run even if this tree already passed; record the result',
13
15
  ' -?, --help Show this help',
14
16
  ' -V, --version Show version number',
@@ -0,0 +1,2 @@
1
+ export declare const TRUNCATION_MARK = "\u2026";
2
+ export declare function clampToBytes(value: string, budgetBytes: number): string;
@@ -0,0 +1,20 @@
1
+ export const TRUNCATION_MARK = '…';
2
+ export function clampToBytes(value, budgetBytes) {
3
+ if (Buffer.byteLength(value) <= budgetBytes) {
4
+ return value;
5
+ }
6
+ const budget = budgetBytes - Buffer.byteLength(TRUNCATION_MARK);
7
+ if (budget < 0) {
8
+ return '';
9
+ }
10
+ let kept = '';
11
+ let bytes = 0;
12
+ for (const character of value) {
13
+ const size = Buffer.byteLength(character);
14
+ if (bytes + size > budget)
15
+ break;
16
+ kept += character;
17
+ bytes += size;
18
+ }
19
+ return `${kept}${TRUNCATION_MARK}`;
20
+ }
@@ -1,3 +1,4 @@
1
+ import { formatElisionMarker } from "./transcript.js";
1
2
  const DEFAULT_HEAD_LIMIT_BYTES = 1_048_576;
2
3
  const DEFAULT_TAIL_LIMIT_BYTES = 1_048_576;
3
4
  export function createBoundedBuffer(options = {}) {
@@ -54,7 +55,3 @@ export function createBoundedBuffer(options = {}) {
54
55
  }
55
56
  return { append, toBuffer };
56
57
  }
57
- function formatElisionMarker(byteCount) {
58
- const unit = byteCount === 1 ? 'byte' : 'bytes';
59
- return `\n… nmr elided ${byteCount.toLocaleString('en-US')} ${unit} …\n`;
60
- }
@@ -0,0 +1 @@
1
+ export declare function deriveExcerpt(transcript: string): string | undefined;
@@ -0,0 +1,30 @@
1
+ import { clampToBytes } from "./clampToBytes.js";
2
+ import { cleanTranscript } from "./transcript.js";
3
+ const MAX_BLOCK_LINES = 8;
4
+ const MAX_EXCERPT_BYTES = 2_048;
5
+ const NON_RULE_CHARACTER = /[^-=_~*+.|:#]/u;
6
+ export function deriveExcerpt(transcript) {
7
+ const lines = cleanTranscript(transcript).split('\n');
8
+ let end = lines.length;
9
+ while (end > 0 && isBlank(lines[end - 1])) {
10
+ end--;
11
+ }
12
+ let start = end;
13
+ while (start > 0 && !isBlank(lines[start - 1])) {
14
+ start--;
15
+ }
16
+ const excerpt = lines
17
+ .slice(Math.max(start, end - MAX_BLOCK_LINES), end)
18
+ .filter((line) => !isRuleOnly(line))
19
+ .join(' ')
20
+ .replaceAll(/\s+/gu, ' ')
21
+ .trim();
22
+ return excerpt === '' ? undefined : clampToBytes(excerpt, MAX_EXCERPT_BYTES);
23
+ }
24
+ function isBlank(line) {
25
+ return line === undefined || line.trim() === '';
26
+ }
27
+ function isRuleOnly(line) {
28
+ const content = line.replaceAll(/\s/gu, '');
29
+ return content !== '' && !NON_RULE_CHARACTER.test(content);
30
+ }
@@ -1,21 +1,28 @@
1
1
  export function formatDuration(milliseconds) {
2
- const seconds = Math.round(milliseconds / MILLISECONDS_PER_SECOND);
3
- if (seconds < SECONDS_PER_MINUTE) {
4
- return `${seconds}s`;
2
+ const total = Math.max(0, milliseconds);
3
+ if (total < MILLISECONDS_PER_MINUTE) {
4
+ return `${truncateToTenth(total)}s`;
5
5
  }
6
- const minutes = Math.round(seconds / SECONDS_PER_MINUTE);
7
- if (minutes < MINUTES_PER_HOUR) {
8
- return `${minutes}m`;
6
+ const totalMinutes = Math.floor(total / MILLISECONDS_PER_MINUTE);
7
+ if (totalMinutes < MINUTES_PER_HOUR) {
8
+ const seconds = Math.floor(total / MILLISECONDS_PER_SECOND) % SECONDS_PER_MINUTE;
9
+ return joinUnits(`${totalMinutes}m`, seconds, 's');
9
10
  }
10
- return `${Math.round(minutes / MINUTES_PER_HOUR)}h`;
11
+ return joinUnits(`${Math.floor(totalMinutes / MINUTES_PER_HOUR)}h`, totalMinutes % MINUTES_PER_HOUR, 'm');
11
12
  }
12
13
  export function formatSaving(milliseconds) {
13
14
  if (!Number.isFinite(milliseconds) || milliseconds < MILLISECONDS_PER_SECOND) {
14
15
  return undefined;
15
16
  }
16
- return `${SPEED_BOOST_ICON} saved ~${formatDuration(milliseconds)}`;
17
+ return `saved ~${formatDuration(milliseconds)}`;
17
18
  }
19
+ const MILLISECONDS_PER_MINUTE = 60_000;
18
20
  const MILLISECONDS_PER_SECOND = 1_000;
19
21
  const MINUTES_PER_HOUR = 60;
20
22
  const SECONDS_PER_MINUTE = 60;
21
- const SPEED_BOOST_ICON = '🚀';
23
+ function joinUnits(major, minor, minorUnit) {
24
+ return minor === 0 ? major : `${major} ${minor}${minorUnit}`;
25
+ }
26
+ function truncateToTenth(milliseconds) {
27
+ return Math.floor(milliseconds / 100) / 10;
28
+ }
@@ -1,12 +1,19 @@
1
- export interface PackageJson {
1
+ export declare const DEPENDENCY_FIELDS: readonly ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
2
+ export type DependencyField = (typeof DEPENDENCY_FIELDS)[number];
3
+ export type PackageJson = {
2
4
  name?: string;
3
5
  private?: boolean;
4
6
  version?: string;
5
7
  packageManager?: string;
6
8
  scripts?: Record<string, string>;
7
9
  pnpm?: {
8
- overrides?: Record<string, string>;
10
+ overrides?: Record<string, unknown>;
9
11
  };
10
- }
12
+ } & {
13
+ [K in DependencyField]?: Record<string, string>;
14
+ };
11
15
  export declare function readPackageJson(dir: string): PackageJson;
12
- export declare function getPnpmOverrides(pkg: PackageJson): Record<string, string> | undefined;
16
+ export declare function getPnpmOverrides(pkg: PackageJson): Record<string, unknown> | undefined;
17
+ export declare function parsePackageJson(raw: string, file: string): unknown;
18
+ export declare function readScriptRecord(dir: string, scripts: Record<string, unknown>): Record<string, string>;
19
+ export declare function resolvePackageJsonPath(dir: string): string;
@@ -1,10 +1,22 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { describeError } from '@williamthorsen/toolbelt.errors';
4
+ import { CONFIG_RELATIVE_PATH } from "../config.js";
5
+ import { UserError } from "../UserError.js";
6
+ import { isMonorepoRoot } from "../workspace.js";
7
+ import { readStringValues } from "./readStringValues.js";
3
8
  import { isObject } from "./type-guards.js";
9
+ export const DEPENDENCY_FIELDS = [
10
+ 'dependencies',
11
+ 'devDependencies',
12
+ 'optionalDependencies',
13
+ 'peerDependencies',
14
+ ];
4
15
  export function readPackageJson(dir) {
5
- const parsed = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
16
+ const file = resolvePackageJsonPath(dir);
17
+ const parsed = parsePackageJson(readFileSync(file, 'utf8'), file);
6
18
  if (!isObject(parsed)) {
7
- throw new TypeError(`Invalid package.json in ${dir}: expected an object`);
19
+ throw new UserError(`Invalid package.json in ${dir}: expected an object`);
8
20
  }
9
21
  const pkg = {};
10
22
  if (typeof parsed['name'] === 'string')
@@ -16,24 +28,18 @@ export function readPackageJson(dir) {
16
28
  if (typeof parsed['packageManager'] === 'string')
17
29
  pkg.packageManager = parsed['packageManager'];
18
30
  if (isObject(parsed['scripts'])) {
19
- const scripts = {};
20
- const scriptEntries = Object.entries(parsed['scripts']);
21
- for (const [key, val] of scriptEntries) {
22
- if (typeof val === 'string')
23
- scripts[key] = val;
24
- }
25
- pkg.scripts = scripts;
31
+ pkg.scripts = readScriptRecord(dir, parsed['scripts']);
26
32
  }
27
33
  if (isObject(parsed['pnpm'])) {
28
34
  const pnpm = parsed['pnpm'];
29
35
  if (isObject(pnpm['overrides'])) {
30
- const overrides = {};
31
- const overrideEntries = Object.entries(pnpm['overrides']);
32
- for (const [key, val] of overrideEntries) {
33
- if (typeof val === 'string')
34
- overrides[key] = val;
35
- }
36
- pkg.pnpm = { overrides };
36
+ pkg.pnpm = { overrides: pnpm['overrides'] };
37
+ }
38
+ }
39
+ for (const field of DEPENDENCY_FIELDS) {
40
+ const declared = parsed[field];
41
+ if (isObject(declared)) {
42
+ pkg[field] = readStringValues(declared);
37
43
  }
38
44
  }
39
45
  return pkg;
@@ -42,11 +48,34 @@ export function getPnpmOverrides(pkg) {
42
48
  if (!isObject(pkg.pnpm))
43
49
  return undefined;
44
50
  const overrides = pkg.pnpm.overrides;
45
- if (!isObject(overrides))
46
- return undefined;
47
- for (const value of Object.values(overrides)) {
48
- if (typeof value !== 'string')
49
- return undefined;
51
+ return isObject(overrides) ? overrides : undefined;
52
+ }
53
+ export function parsePackageJson(raw, file) {
54
+ try {
55
+ return JSON.parse(raw);
56
+ }
57
+ catch (error) {
58
+ throw new UserError(`Invalid package.json at ${file}: ${describeError(error)}`);
59
+ }
60
+ }
61
+ export function readScriptRecord(dir, scripts) {
62
+ const result = {};
63
+ for (const [key, value] of Object.entries(scripts)) {
64
+ if (typeof value !== 'string') {
65
+ throw new UserError(formatMalformedScript(dir, key, value));
66
+ }
67
+ result[key] = value;
68
+ }
69
+ return result;
70
+ }
71
+ export function resolvePackageJsonPath(dir) {
72
+ return path.join(dir, 'package.json');
73
+ }
74
+ function formatMalformedScript(dir, key, value) {
75
+ const rejection = `Invalid package.json at ${resolvePackageJsonPath(dir)}: \`scripts.${key}\` must be a string.`;
76
+ if (!Array.isArray(value)) {
77
+ return rejection;
50
78
  }
51
- return overrides;
79
+ const field = isMonorepoRoot(dir) ? 'rootScripts' : 'workspaceScripts';
80
+ return `${rejection} A step list belongs in \`${CONFIG_RELATIVE_PATH}\` under \`${field}\`.`;
52
81
  }
@@ -0,0 +1 @@
1
+ export declare function readStringValues(record: Record<string, unknown>): Record<string, string>;
@@ -0,0 +1,8 @@
1
+ export function readStringValues(record) {
2
+ const result = {};
3
+ for (const [key, value] of Object.entries(record)) {
4
+ if (typeof value === 'string')
5
+ result[key] = value;
6
+ }
7
+ return result;
8
+ }
@@ -0,0 +1,5 @@
1
+ import type { RetainedOutput } from '../runner.js';
2
+ export declare const TRANSCRIPT_LIMIT_BYTES = 262144;
3
+ export declare function cleanTranscript(raw: string): string;
4
+ export declare function composeTranscript(retained: RetainedOutput): string | undefined;
5
+ export declare function formatElisionMarker(byteCount: number): string;
@@ -0,0 +1,59 @@
1
+ export const TRANSCRIPT_LIMIT_BYTES = 262_144;
2
+ const ANSI_PATTERN = /\u{1B}(?:\[[\u{30}-\u{3F}]*[\u{20}-\u{2F}]*[\u{40}-\u{7E}]|\][^\u{7}\u{1B}]*(?:\u{7}|\u{1B}\\))/gu;
3
+ const END_LIMIT_BYTES = TRANSCRIPT_LIMIT_BYTES / 2;
4
+ const STREAM_MARKER = '\n… nmr: stderr …\n';
5
+ export function cleanTranscript(raw) {
6
+ return raw
7
+ .replaceAll(ANSI_PATTERN, '')
8
+ .split('\n')
9
+ .map((line) => renderCarriageReturns(line))
10
+ .join('\n');
11
+ }
12
+ export function composeTranscript(retained) {
13
+ const streams = [retained.stdout, retained.stderr]
14
+ .map((stream) => cleanTranscript(stream.toString('utf8')))
15
+ .filter((stream) => stream.trim() !== '');
16
+ if (streams.length === 0) {
17
+ return undefined;
18
+ }
19
+ return clampTranscript(streams.join(STREAM_MARKER));
20
+ }
21
+ export function formatElisionMarker(byteCount) {
22
+ const unit = byteCount === 1 ? 'byte' : 'bytes';
23
+ return `\n… nmr elided ${byteCount.toLocaleString('en-US')} ${unit} …\n`;
24
+ }
25
+ function alignEnd(buffer, end) {
26
+ let aligned = Math.min(buffer.length, end);
27
+ while (aligned > 0 && isContinuationByte(buffer[aligned])) {
28
+ aligned--;
29
+ }
30
+ return aligned;
31
+ }
32
+ function alignStart(buffer, start) {
33
+ let aligned = Math.max(0, start);
34
+ while (aligned < buffer.length && isContinuationByte(buffer[aligned])) {
35
+ aligned++;
36
+ }
37
+ return aligned;
38
+ }
39
+ function clampTranscript(transcript) {
40
+ const buffer = Buffer.from(transcript, 'utf8');
41
+ if (buffer.length <= TRANSCRIPT_LIMIT_BYTES) {
42
+ return transcript;
43
+ }
44
+ const headEnd = alignEnd(buffer, END_LIMIT_BYTES);
45
+ const tailStart = alignStart(buffer, buffer.length - END_LIMIT_BYTES);
46
+ const head = buffer.subarray(0, headEnd).toString('utf8');
47
+ const tail = buffer.subarray(tailStart).toString('utf8');
48
+ return `${head}${formatElisionMarker(tailStart - headEnd)}${tail}`;
49
+ }
50
+ function isContinuationByte(byte) {
51
+ return byte !== undefined && (byte & 0b1100_0000) === 0b1000_0000;
52
+ }
53
+ function renderCarriageReturns(line) {
54
+ if (!line.includes('\r')) {
55
+ return line;
56
+ }
57
+ const segments = line.split('\r').filter((segment) => segment.trim() !== '');
58
+ return segments.at(-1) ?? '';
59
+ }