@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.
- package/.readyup/kits/default.js +33 -22
- package/.readyup/manifest.json +2 -2
- package/README.md +327 -78
- package/agents/guidance/rulebooks/nmr.md +4 -2
- package/bin/nmr-report-catalog.js +11 -0
- package/dist/esm/UserError.d.ts +3 -0
- package/dist/esm/UserError.js +6 -0
- package/dist/esm/check-cache.d.ts +36 -1
- package/dist/esm/check-cache.js +93 -24
- package/dist/esm/cli-build.js +2 -1
- package/dist/esm/cli-clean.js +2 -1
- package/dist/esm/cli-ensure-prepublish-hooks.js +2 -1
- package/dist/esm/cli-report-catalog.d.ts +1 -0
- package/dist/esm/cli-report-catalog.js +11 -0
- package/dist/esm/cli-report-overrides.js +2 -1
- package/dist/esm/cli-taze.js +2 -1
- package/dist/esm/cli.js +2 -1
- package/dist/esm/commands/clean.js +9 -5
- package/dist/esm/commands/report-catalog.d.ts +1 -0
- package/dist/esm/commands/report-catalog.js +34 -0
- package/dist/esm/commands/report-overrides.js +22 -7
- package/dist/esm/commands/taze.js +2 -1
- package/dist/esm/config.d.ts +1 -0
- package/dist/esm/config.js +81 -16
- package/dist/esm/default-scripts.d.ts +5 -1
- package/dist/esm/default-scripts.js +19 -16
- package/dist/esm/defineConfig.d.ts +1 -1
- package/dist/esm/help.js +3 -1
- package/dist/esm/helpers/clampToBytes.d.ts +2 -0
- package/dist/esm/helpers/clampToBytes.js +20 -0
- package/dist/esm/helpers/createBoundedBuffer.js +1 -4
- package/dist/esm/helpers/deriveExcerpt.d.ts +1 -0
- package/dist/esm/helpers/deriveExcerpt.js +30 -0
- package/dist/esm/helpers/duration.js +16 -9
- package/dist/esm/helpers/package-json.d.ts +11 -4
- package/dist/esm/helpers/package-json.js +51 -22
- package/dist/esm/helpers/readStringValues.d.ts +1 -0
- package/dist/esm/helpers/readStringValues.js +8 -0
- package/dist/esm/helpers/transcript.d.ts +5 -0
- package/dist/esm/helpers/transcript.js +59 -0
- package/dist/esm/recording.d.ts +65 -0
- package/dist/esm/recording.js +96 -0
- package/dist/esm/replay-assembly.d.ts +9 -0
- package/dist/esm/replay-assembly.js +26 -0
- package/dist/esm/report-format.d.ts +18 -0
- package/dist/esm/report-format.js +25 -0
- package/dist/esm/reportCliFailure.d.ts +2 -0
- package/dist/esm/reportCliFailure.js +11 -0
- package/dist/esm/resolve-scripts.d.ts +1 -1
- package/dist/esm/resolver.d.ts +13 -3
- package/dist/esm/resolver.js +34 -19
- package/dist/esm/runCli.d.ts +1 -0
- package/dist/esm/runCli.js +372 -70
- package/dist/esm/runner.d.ts +20 -1
- package/dist/esm/runner.js +72 -12
- package/dist/esm/scripts.d.ts +1 -1
- package/dist/esm/steps.d.ts +18 -0
- package/dist/esm/steps.js +144 -0
- package/dist/esm/types.d.ts +11 -2
- package/dist/esm/verbosity.d.ts +22 -0
- package/dist/esm/verbosity.js +37 -0
- package/dist/esm/verdict.d.ts +29 -0
- package/dist/esm/verdict.js +150 -0
- package/dist/esm/workspace.d.ts +2 -0
- package/dist/esm/workspace.js +20 -3
- package/package.json +12 -5
package/dist/esm/config.js
CHANGED
|
@@ -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
|
|
38
|
-
|
|
39
|
-
if (Array.isArray(v)
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: [
|
|
5
|
-
'check:strict': [
|
|
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: '
|
|
30
|
-
check: [
|
|
31
|
-
'check:strict': [
|
|
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': [
|
|
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: '
|
|
54
|
-
'test:all': '
|
|
55
|
-
'test:coverage': '
|
|
56
|
-
'test:tool': '
|
|
57
|
-
'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:
|
|
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
|
-
|
|
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,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
|
|
3
|
-
if (
|
|
4
|
-
return `${
|
|
2
|
+
const total = Math.max(0, milliseconds);
|
|
3
|
+
if (total < MILLISECONDS_PER_MINUTE) {
|
|
4
|
+
return `${truncateToTenth(total)}s`;
|
|
5
5
|
}
|
|
6
|
-
const
|
|
7
|
-
if (
|
|
8
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
16
|
+
const file = resolvePackageJsonPath(dir);
|
|
17
|
+
const parsed = parsePackageJson(readFileSync(file, 'utf8'), file);
|
|
6
18
|
if (!isObject(parsed)) {
|
|
7
|
-
throw new
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
pkg
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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,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
|
+
}
|