@williamthorsen/nmr 0.35.0 → 0.37.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/.meta/changelog.json +1499 -0
- package/.readyup/kits/default.js +176 -6
- package/.readyup/manifest.json +6 -6
- package/CHANGELOG.md +957 -0
- package/README.md +116 -1120
- package/agents/README.md +1 -1
- package/agents/guidance/rulebooks/nmr.md +2 -2
- package/dist/esm/commands/build.js +1 -1
- package/dist/esm/config.d.ts +0 -8
- package/dist/esm/config.js +66 -9
- package/dist/esm/context.d.ts +2 -0
- package/dist/esm/context.js +1 -0
- package/dist/esm/help.js +1 -1
- package/dist/esm/helpers/config-path.d.ts +8 -0
- package/dist/esm/helpers/config-path.js +7 -0
- package/dist/esm/helpers/filter-selection.d.ts +42 -0
- package/dist/esm/helpers/filter-selection.js +61 -0
- package/dist/esm/helpers/findClosestName.d.ts +7 -0
- package/dist/esm/helpers/findClosestName.js +38 -0
- package/dist/esm/helpers/package-json.js +1 -1
- package/dist/esm/runCli.js +71 -3
- package/dist/esm/runner.js +6 -5
- package/dist/esm/steps.d.ts +4 -0
- package/dist/esm/types.d.ts +3 -2
- package/dist/esm/verdict.d.ts +1 -1
- package/dist/esm/vitest-git-isolation.js +6 -0
- package/dist/esm/vitest-source-resolution.d.ts +37 -0
- package/dist/esm/vitest-source-resolution.js +205 -0
- package/dist/esm/vitest.d.ts +3 -2
- package/dist/esm/vitest.js +7 -6
- package/dist/esm/workspace.d.ts +9 -0
- package/dist/esm/workspace.js +17 -0
- package/docs/check-cache.md +176 -0
- package/docs/prettier.md +47 -0
- package/docs/reporting.md +90 -0
- package/docs/scripts.md +168 -0
- package/docs/upgrades.md +110 -0
- package/docs/utilities.md +64 -0
- package/docs/vitest.md +233 -0
- package/docs/workspace.md +11 -0
- package/package.json +12 -8
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { isBuiltin } from 'node:module';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parsePackageJson } from "./helpers/package-json.js";
|
|
5
|
+
import { isObject } from "./helpers/type-guards.js";
|
|
6
|
+
import { UserError } from "./UserError.js";
|
|
7
|
+
const SHARED_CONDITIONS = ['module', 'import', 'development', 'default'];
|
|
8
|
+
const CLIENT_CONDITION = 'browser';
|
|
9
|
+
const SERVER_CONDITION = 'node';
|
|
10
|
+
const SOURCE_CONDITION = 'source';
|
|
11
|
+
const NODE_MODULES = 'node_modules';
|
|
12
|
+
const TARGET_EXTENSIONS = ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'];
|
|
13
|
+
export function createSourceResolutionPlugin() {
|
|
14
|
+
const packageDirs = new Map();
|
|
15
|
+
const manifests = new Map();
|
|
16
|
+
return {
|
|
17
|
+
name: 'nmr:resolve-from-source',
|
|
18
|
+
enforce: 'pre',
|
|
19
|
+
resolveId(id, importer) {
|
|
20
|
+
return resolveSourceTarget(id, importer, { environmentName: this.environment.name, manifests, packageDirs });
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function resolveSourceTarget(specifier, importer, context) {
|
|
25
|
+
if (importer === undefined)
|
|
26
|
+
return undefined;
|
|
27
|
+
const parsed = parseSpecifier(specifier);
|
|
28
|
+
if (!parsed)
|
|
29
|
+
return undefined;
|
|
30
|
+
const packageDir = findPackageDir(parsed.name, path.dirname(importer), context.packageDirs);
|
|
31
|
+
if (packageDir === undefined || isInsideNodeModules(packageDir))
|
|
32
|
+
return undefined;
|
|
33
|
+
const manifest = readManifest(packageDir, context.manifests);
|
|
34
|
+
if (!isObject(manifest))
|
|
35
|
+
return undefined;
|
|
36
|
+
const matched = matchSubpath(manifest['exports'], parsed.subpath);
|
|
37
|
+
if (!matched)
|
|
38
|
+
return undefined;
|
|
39
|
+
const environmentCondition = context.environmentName === 'client' ? CLIENT_CONDITION : SERVER_CONDITION;
|
|
40
|
+
const conditions = new Set([environmentCondition, ...SHARED_CONDITIONS]);
|
|
41
|
+
const selected = selectTarget(matched.value, conditions);
|
|
42
|
+
if (!selected?.usedSource)
|
|
43
|
+
return undefined;
|
|
44
|
+
const wildcard = matched.wildcard;
|
|
45
|
+
const declared = wildcard === undefined ? selected.target : selected.target.replaceAll('*', () => wildcard);
|
|
46
|
+
if (!declared.startsWith('./'))
|
|
47
|
+
return undefined;
|
|
48
|
+
const declaredPath = path.resolve(packageDir, declared);
|
|
49
|
+
const target = findTargetFile(declaredPath);
|
|
50
|
+
if (target === undefined) {
|
|
51
|
+
throw new UserError(formatMissingTarget(parsed.name, parsed.subpath, declared, declaredPath));
|
|
52
|
+
}
|
|
53
|
+
return `${target}${parsed.postfix}`;
|
|
54
|
+
}
|
|
55
|
+
function findPackageDir(name, fromDir, cache) {
|
|
56
|
+
const key = `${fromDir}\0${name}`;
|
|
57
|
+
if (cache?.has(key))
|
|
58
|
+
return cache.get(key);
|
|
59
|
+
let found = findSelfReferenceDir(name, fromDir);
|
|
60
|
+
let dir = fromDir;
|
|
61
|
+
while (found === undefined) {
|
|
62
|
+
const candidate = path.join(dir, NODE_MODULES, name);
|
|
63
|
+
if (existsSync(path.join(candidate, 'package.json'))) {
|
|
64
|
+
found = realpathSync(candidate);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
const parent = path.dirname(dir);
|
|
68
|
+
if (parent === dir)
|
|
69
|
+
break;
|
|
70
|
+
dir = parent;
|
|
71
|
+
}
|
|
72
|
+
cache?.set(key, found);
|
|
73
|
+
return found;
|
|
74
|
+
}
|
|
75
|
+
function findSelfReferenceDir(name, fromDir) {
|
|
76
|
+
let dir = fromDir;
|
|
77
|
+
for (;;) {
|
|
78
|
+
const file = path.join(dir, 'package.json');
|
|
79
|
+
if (existsSync(file)) {
|
|
80
|
+
const parsed = parsePackageJson(readFileSync(file, 'utf8'), file);
|
|
81
|
+
return isObject(parsed) && parsed['name'] === name && parsed['exports'] !== undefined ? dir : undefined;
|
|
82
|
+
}
|
|
83
|
+
const parent = path.dirname(dir);
|
|
84
|
+
if (parent === dir)
|
|
85
|
+
return undefined;
|
|
86
|
+
dir = parent;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function findSuffixIndex(specifier) {
|
|
90
|
+
const queryIndex = specifier.indexOf('?');
|
|
91
|
+
const hashIndex = specifier.indexOf('#');
|
|
92
|
+
if (queryIndex === -1)
|
|
93
|
+
return hashIndex;
|
|
94
|
+
if (hashIndex === -1)
|
|
95
|
+
return queryIndex;
|
|
96
|
+
return Math.min(queryIndex, hashIndex);
|
|
97
|
+
}
|
|
98
|
+
function findTargetFile(declaredPath) {
|
|
99
|
+
if (isFile(declaredPath))
|
|
100
|
+
return declaredPath;
|
|
101
|
+
for (const extension of TARGET_EXTENSIONS) {
|
|
102
|
+
const candidate = `${declaredPath}${extension}`;
|
|
103
|
+
if (isFile(candidate))
|
|
104
|
+
return candidate;
|
|
105
|
+
}
|
|
106
|
+
for (const extension of TARGET_EXTENSIONS) {
|
|
107
|
+
const candidate = path.join(declaredPath, `index${extension}`);
|
|
108
|
+
if (isFile(candidate))
|
|
109
|
+
return candidate;
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
function formatMissingTarget(name, subpath, declared, target) {
|
|
114
|
+
return [
|
|
115
|
+
`Package "${name}" declares a \`source\` export for "${subpath}" at "${declared}", which reaches no file.`,
|
|
116
|
+
`Looked for ${target}, the same path under each of ${TARGET_EXTENSIONS.join(', ')}, and an index under it.`,
|
|
117
|
+
'Point the condition at a file that exists, or remove it so the package resolves through its other conditions.',
|
|
118
|
+
].join(' ');
|
|
119
|
+
}
|
|
120
|
+
function isFile(candidate) {
|
|
121
|
+
return statSync(candidate, { throwIfNoEntry: false })?.isFile() === true;
|
|
122
|
+
}
|
|
123
|
+
function isInsideNodeModules(dir) {
|
|
124
|
+
return dir.split(path.sep).includes(NODE_MODULES);
|
|
125
|
+
}
|
|
126
|
+
function matchSubpath(exportsValue, subpath) {
|
|
127
|
+
if (exportsValue === undefined)
|
|
128
|
+
return undefined;
|
|
129
|
+
if (!isObject(exportsValue) || Object.keys(exportsValue).every((key) => !key.startsWith('.'))) {
|
|
130
|
+
return subpath === '.' ? { value: exportsValue } : undefined;
|
|
131
|
+
}
|
|
132
|
+
const exact = exportsValue[subpath];
|
|
133
|
+
if (exact !== undefined)
|
|
134
|
+
return { value: exact };
|
|
135
|
+
let best;
|
|
136
|
+
for (const [key, value] of Object.entries(exportsValue)) {
|
|
137
|
+
const star = key.indexOf('*');
|
|
138
|
+
if (star === -1)
|
|
139
|
+
continue;
|
|
140
|
+
const base = key.slice(0, star);
|
|
141
|
+
const suffix = key.slice(star + 1);
|
|
142
|
+
if (!subpath.startsWith(base) || !subpath.endsWith(suffix))
|
|
143
|
+
continue;
|
|
144
|
+
if (subpath.length < base.length + suffix.length)
|
|
145
|
+
continue;
|
|
146
|
+
if (best !== undefined && best.base.length >= base.length)
|
|
147
|
+
continue;
|
|
148
|
+
best = { base, value, wildcard: subpath.slice(base.length, subpath.length - suffix.length) };
|
|
149
|
+
}
|
|
150
|
+
return best && { value: best.value, wildcard: best.wildcard };
|
|
151
|
+
}
|
|
152
|
+
function parseSpecifier(specifier) {
|
|
153
|
+
if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('\0'))
|
|
154
|
+
return undefined;
|
|
155
|
+
const suffixIndex = findSuffixIndex(specifier);
|
|
156
|
+
const bare = suffixIndex === -1 ? specifier : specifier.slice(0, suffixIndex);
|
|
157
|
+
if (bare.includes(':') || isBuiltin(bare))
|
|
158
|
+
return undefined;
|
|
159
|
+
const segments = bare.split('/');
|
|
160
|
+
const nameLength = bare.startsWith('@') ? 2 : 1;
|
|
161
|
+
if (segments.length < nameLength || segments.includes(''))
|
|
162
|
+
return undefined;
|
|
163
|
+
const rest = segments.slice(nameLength);
|
|
164
|
+
return {
|
|
165
|
+
name: segments.slice(0, nameLength).join('/'),
|
|
166
|
+
postfix: suffixIndex === -1 ? '' : specifier.slice(suffixIndex),
|
|
167
|
+
subpath: rest.length === 0 ? '.' : `./${rest.join('/')}`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function readManifest(packageDir, cache) {
|
|
171
|
+
if (cache?.has(packageDir))
|
|
172
|
+
return cache.get(packageDir);
|
|
173
|
+
const file = path.join(packageDir, 'package.json');
|
|
174
|
+
const parsed = parsePackageJson(readFileSync(file, 'utf8'), file);
|
|
175
|
+
cache?.set(packageDir, parsed);
|
|
176
|
+
return parsed;
|
|
177
|
+
}
|
|
178
|
+
function selectTarget(value, conditions) {
|
|
179
|
+
if (typeof value === 'string')
|
|
180
|
+
return { target: value, usedSource: false };
|
|
181
|
+
if (Array.isArray(value)) {
|
|
182
|
+
for (const entry of value) {
|
|
183
|
+
const selected = selectTarget(entry, conditions);
|
|
184
|
+
if (selected)
|
|
185
|
+
return selected;
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
if (!isObject(value))
|
|
190
|
+
return undefined;
|
|
191
|
+
const source = value[SOURCE_CONDITION];
|
|
192
|
+
if (source !== undefined) {
|
|
193
|
+
const selected = selectTarget(source, conditions);
|
|
194
|
+
if (selected)
|
|
195
|
+
return { target: selected.target, usedSource: true };
|
|
196
|
+
}
|
|
197
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
198
|
+
if (key === SOURCE_CONDITION || !conditions.has(key))
|
|
199
|
+
continue;
|
|
200
|
+
const selected = selectTarget(entry, conditions);
|
|
201
|
+
if (selected)
|
|
202
|
+
return selected;
|
|
203
|
+
}
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
package/dist/esm/vitest.d.ts
CHANGED
|
@@ -45,8 +45,9 @@ export interface VitestConfigOptions {
|
|
|
45
45
|
*/
|
|
46
46
|
isolateGit?: boolean;
|
|
47
47
|
/**
|
|
48
|
-
* Resolves
|
|
49
|
-
* Defaults to `true`. A package declaring no such condition is unaffected
|
|
48
|
+
* Resolves a package whose files sit outside every `node_modules` through its `source` export condition, so a
|
|
49
|
+
* suite runs without a prior build. Defaults to `true`. A package declaring no such condition is unaffected,
|
|
50
|
+
* and so is every package under `node_modules`, which Node resolves and which the condition never reaches.
|
|
50
51
|
*/
|
|
51
52
|
resolveFromSource?: boolean;
|
|
52
53
|
/**
|
package/dist/esm/vitest.js
CHANGED
|
@@ -3,13 +3,14 @@ import { fileURLToPath } from 'node:url';
|
|
|
3
3
|
import { defaultExclude, mergeConfig } from 'vitest/config';
|
|
4
4
|
import { isObject } from "./helpers/type-guards.js";
|
|
5
5
|
import { ALL_TEST_PATTERNS, buildTierPatterns, TEST_COLLECTION_EXCLUDE, TIER_NAMES } from "./tiers.js";
|
|
6
|
+
import { createSourceResolutionPlugin } from "./vitest-source-resolution.js";
|
|
6
7
|
import { getWorkspacePackageDirs } from "./workspace.js";
|
|
7
8
|
const [RESIDUAL_TIER, ...NAMED_TIERS] = TIER_NAMES;
|
|
8
9
|
const TIERED_PATTERNS = NAMED_TIERS.flatMap(buildTierPatterns);
|
|
9
10
|
const TIER_TIMEOUT = 30_000;
|
|
10
11
|
const GIT_ISOLATION_SETUP_FILE = resolveGitIsolationSetupFile();
|
|
11
|
-
const
|
|
12
|
-
const
|
|
12
|
+
const CLIENT_CONDITIONS = ['module', 'browser', 'development|production'];
|
|
13
|
+
const SERVER_CONDITIONS = ['module', 'node', 'development|production'];
|
|
13
14
|
const COVERAGE_EXCLUDE = ['**/__{fixtures,mocks,tests}__/**', '**/index.ts', '**/*.d.ts'];
|
|
14
15
|
const PACKAGE_COVERAGE_INCLUDE = ['**/src/**/*.{ts,tsx}'];
|
|
15
16
|
const MISSING_MONOREPO_ROOT = 'defineRootVitestConfig requires `monorepoRoot`, an absolute path to the directory holding pnpm-workspace.yaml. Pass `import.meta.dirname` in the last options layer, from the root config.';
|
|
@@ -31,14 +32,14 @@ export function defineRootVitestConfig(...layers) {
|
|
|
31
32
|
function buildConfig(declaredLayers, { coverageInclude, projectExclude = [], projectRoot }) {
|
|
32
33
|
const layers = declaredLayers.filter((layer) => layer !== undefined);
|
|
33
34
|
assertKnownTiers(layers);
|
|
34
|
-
const resolveFromSource = resolveFlag(layers, 'resolveFromSource', true);
|
|
35
35
|
const resolve = {
|
|
36
|
-
|
|
36
|
+
conditions: CLIENT_CONDITIONS,
|
|
37
37
|
...(resolveFlag(layers, 'tsconfigPaths', false) && { tsconfigPaths: true }),
|
|
38
38
|
};
|
|
39
39
|
const config = {
|
|
40
|
-
...(
|
|
41
|
-
|
|
40
|
+
...(resolveFlag(layers, 'resolveFromSource', true) && { plugins: [createSourceResolutionPlugin()] }),
|
|
41
|
+
resolve,
|
|
42
|
+
ssr: { resolve: { conditions: SERVER_CONDITIONS } },
|
|
42
43
|
test: {
|
|
43
44
|
coverage: {
|
|
44
45
|
enabled: false,
|
package/dist/esm/workspace.d.ts
CHANGED
|
@@ -20,3 +20,12 @@ export declare function getWorkspacePackageDirs(monorepoRoot: string): string[];
|
|
|
20
20
|
* Returns nothing when the manifest or the block is missing, and drops an entry whose value is not a string.
|
|
21
21
|
*/
|
|
22
22
|
export declare function readWorkspaceOverrides(monorepoRoot: string): Record<string, string> | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Reads the manifest `name` each of the given package directories declares, which is what a `-F` pattern
|
|
25
|
+
* matches.
|
|
26
|
+
*
|
|
27
|
+
* A directory whose manifest is missing, unparseable, or nameless contributes nothing. The sweep runs for a
|
|
28
|
+
* diagnostic's sake, so one malformed manifest elsewhere in the workspace must not replace the diagnostic the
|
|
29
|
+
* reader asked for with a failure of its own.
|
|
30
|
+
*/
|
|
31
|
+
export declare function readWorkspacePackageNames(packageDirs: readonly string[]): string[];
|
package/dist/esm/workspace.js
CHANGED
|
@@ -5,6 +5,7 @@ import { readStringValues } from "./helpers/readStringValues.js";
|
|
|
5
5
|
import { isObject } from "./helpers/type-guards.js";
|
|
6
6
|
import { resolvePackageDirs } from "./helpers/workspace-patterns.js";
|
|
7
7
|
import { UserError } from "./UserError.js";
|
|
8
|
+
const PACKAGE_MANIFEST = 'package.json';
|
|
8
9
|
const WORKSPACE_MANIFEST = 'pnpm-workspace.yaml';
|
|
9
10
|
export function isMonorepoRoot(dir) {
|
|
10
11
|
return existsSync(path.join(dir, WORKSPACE_MANIFEST));
|
|
@@ -47,6 +48,22 @@ export function readWorkspaceOverrides(monorepoRoot) {
|
|
|
47
48
|
const overrides = parsed['overrides'];
|
|
48
49
|
return isObject(overrides) ? readStringValues(overrides) : undefined;
|
|
49
50
|
}
|
|
51
|
+
export function readWorkspacePackageNames(packageDirs) {
|
|
52
|
+
const names = [];
|
|
53
|
+
for (const dir of packageDirs) {
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(readFileSync(path.join(dir, PACKAGE_MANIFEST), 'utf8'));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (isObject(parsed) && typeof parsed['name'] === 'string') {
|
|
62
|
+
names.push(parsed['name']);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return names;
|
|
66
|
+
}
|
|
50
67
|
function getPackagesFromParsedYaml(parsed) {
|
|
51
68
|
if (!isObject(parsed))
|
|
52
69
|
return undefined;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Check-result cache
|
|
2
|
+
|
|
3
|
+
How nmr skips a check that already passed on the same working tree, what decides a hit, and how to read, bypass, and configure what it recorded.
|
|
4
|
+
|
|
5
|
+
The [README](../README.md#check-result-cache) shows a skip in action.
|
|
6
|
+
|
|
7
|
+
Composite commands expand into child `nmr` processes, so one green `nmr ci` records a pass for every cacheable command in the chain, at every scope it ran at. A later `nmr check`, `nmr typecheck`, or `nmr -F core test` on the same tree skips too. The skip is [reported](reporting.md#what-nmr-reports) in every verbosity.
|
|
8
|
+
|
|
9
|
+
The cache is a working-tree cache, not a build cache: it answers "has this exact tree already passed this check?" and nothing else. It is also local to one checkout (the install fingerprint in its key is machine-specific), so it saves repeated work in a working copy rather than sharing results across machines or with CI.
|
|
10
|
+
|
|
11
|
+
## What is cached
|
|
12
|
+
|
|
13
|
+
Cacheable by default: `check`, `check:strict`, `ci`, `fix:check`, `fmt:check`, `lint:check`, `lint:strict`, `test`, `test:coverage`, `test:tool`, `test:unit`, `typecheck`, and the `root:` variants `root:check`, `root:lint:check`, `root:lint:strict`, `root:test`, `root:test:tool`, `root:test:unit`, and `root:typecheck`.
|
|
14
|
+
|
|
15
|
+
Everything else runs every time:
|
|
16
|
+
|
|
17
|
+
| Command | Why it is never skipped |
|
|
18
|
+
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
19
|
+
| `audit`, `prepush` | Consult a vulnerability database that moves without the tree. `prepush`'s `ci` constituent still skips while its `audit` runs every time. |
|
|
20
|
+
| `build`, `compile` | Carry a cache of their own. |
|
|
21
|
+
| `fix`, `fmt`, `lint`, `upgrade` | Rewrite the tree they were asked about, so a recorded pass would describe a tree that no longer exists. |
|
|
22
|
+
| `test:all` | Reaches the `localhost` and `remote` tiers, which the environment supplies rather than the tree. |
|
|
23
|
+
|
|
24
|
+
## The exit-status-only contract
|
|
25
|
+
|
|
26
|
+
**A cacheable name promises that its whole contribution is an exit status**, through its entire chain, `:pre` and `:post` hooks included. A hit skips that chain wholesale, so any file a cacheable command was relied on to produce is a file that will not appear.
|
|
27
|
+
|
|
28
|
+
`test:coverage` is cacheable on exactly this reading: what it contributes is the pass, not the `coverage/` directory, which a skipped run leaves as whatever the last real run wrote. Read `coverage/` after a `test:coverage` that may have skipped, and it may be stale; `--no-cache` is how you insist on a fresh one.
|
|
29
|
+
|
|
30
|
+
Overriding a cacheable name, in `package.json` or in `workspaceScripts`, inherits its cacheability. An override that writes something anyone depends on belongs in `excludeCommands`.
|
|
31
|
+
|
|
32
|
+
## What the key is made of
|
|
33
|
+
|
|
34
|
+
A hit requires all of these to match the run that recorded the pass:
|
|
35
|
+
|
|
36
|
+
- **The working tree's content**, hashed from the commit's tree object folded with the current content of every path git reports as changed or untracked. Timestamps are not content, so a `touch` that rewrites no bytes leaves the hash alone.
|
|
37
|
+
- **The command string that would run**, hooks and all, and the scope it would run in.
|
|
38
|
+
- **nmr's version**, the **Node version**, and the **platform and architecture**.
|
|
39
|
+
- **What pnpm has installed**, so an install, a prune, or a lockfile change forces a re-run.
|
|
40
|
+
- **`TZ`, `LANG`, `LC_ALL`, and `NODE_OPTIONS`.** This set is fixed, so two machines that differ only in shell decoration still agree.
|
|
41
|
+
|
|
42
|
+
A hit additionally requires the build output of every package nmr's own build covers to be present, and to have been built from this tree. Output is git-ignored, so neither its removal nor its replacement moves the hash: restoring a tree with `git stash` or `git checkout` restores none of the output that tree was built with. nmr compares each covered package's recorded build digest against the one on disk, so a `dist` compiled from another tree is a miss the following run repairs. A package that overrides `build` or `compile` emits on terms nmr does not know and is exempt.
|
|
43
|
+
|
|
44
|
+
A pass is recorded only when the command exits 0, only when the tree still matches the one the run started against, only when every covered package's output is present and unchanged since the run started, and never for a run that executed nothing (a `""`/`":"` skip override, or an `NMR_RUN_IF_PRESENT` miss). Output that moves mid-run leaves no answer to which output the pass was earned over, whether another process rebuilt it or the chain built it itself: `nmr ci` is the one default command whose chain does, and it records nothing on a run whose build did work. The `nmr check:strict` inside that chain still records, because its own window opens after the build.
|
|
45
|
+
|
|
46
|
+
## Out of contract
|
|
47
|
+
|
|
48
|
+
These change what a check concludes without moving the hash. Where one applies, exclude the affected command:
|
|
49
|
+
|
|
50
|
+
- **Content filters.** `core.autocrlf` and `.gitattributes` filters mean the bytes on disk differ from the bytes git records; the hash follows what is on disk, but a checkout on another platform may not reproduce it.
|
|
51
|
+
- **Symlinked inputs.** A symlink is hashed by the path it names, not by the content at the far end.
|
|
52
|
+
- **Gitignored inputs.** Anything git does not report is invisible to the hash. The build-output probe is the one exception, and covers only nmr's own build.
|
|
53
|
+
- **`passWithNoTests`.** A suite that collects no files exits 0, and that green is recorded like any other.
|
|
54
|
+
|
|
55
|
+
Committing an already-checked tree also moves the hash, because the commit's tree object is the base of the fold; the next run does the work again. The reverse holds and is useful: a rebase or an amended message that preserves content leaves the hash alone, as does checking out a branch whose tree is identical.
|
|
56
|
+
|
|
57
|
+
## When the gate stands aside
|
|
58
|
+
|
|
59
|
+
The gate never wrongly skips. Where it cannot be sure, it does nothing and the command runs:
|
|
60
|
+
|
|
61
|
+
- Outside a git repository, when git fails, or when there is no commit at `HEAD`.
|
|
62
|
+
- When the repository declares or contains submodules, whose content the hash does not cover.
|
|
63
|
+
- When the monorepo root is not the git toplevel.
|
|
64
|
+
- When a changed path cannot be read, or is neither a file nor a symlink (an untracked nested repository, for instance).
|
|
65
|
+
- When there is no pnpm install to fingerprint.
|
|
66
|
+
- Under a `devBin` substitution, which runs a binary built from somewhere the hash does not describe.
|
|
67
|
+
- For any invocation carrying arguments after the command name, and for every step below it: the arguments narrow what runs, and a step served from a recorded pass is a step that did not.
|
|
68
|
+
|
|
69
|
+
`NMR_DEBUG=1` reports why a run did not skip and why the gate stood aside.
|
|
70
|
+
|
|
71
|
+
## Replaying a skipped run's output
|
|
72
|
+
|
|
73
|
+
A skip reports an excerpt of the run it recalls, marked as a recording rather than as this run's output:
|
|
74
|
+
|
|
75
|
+
```console
|
|
76
|
+
$ nmr test
|
|
77
|
+
# ... the suite runs ...
|
|
78
|
+
|
|
79
|
+
$ nmr test
|
|
80
|
+
⏭️ nmr: test: passed 2m ago on this tree, saved ~12s — replayed: Test Files 6 passed (6) Tests 41 passed (41)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The excerpt is the last blank-line-delimited block of what the command wrote, which is the closing statement a tool separates from its progress output. It is flattened onto one line, with escape sequences stripped, table rules dropped, and a redrawn progress line reduced to what a reader was left looking at. Nothing is parsed and no figure is computed: nmr replays bytes it recorded. A block wider than a few kilobytes is cut and marked with `…`, so a command whose closing statement is one long line cannot carry its whole output into the cache.
|
|
84
|
+
|
|
85
|
+
The excerpt and the whole transcript it is drawn from are written by the same operation that records the pass, so a pass declined because the tree moved or build output changed leaves neither behind, and `nmr clean` clears both along with the passes they belong to.
|
|
86
|
+
|
|
87
|
+
**Capture happens at the command, and only where nmr can see its output.** A command whose stdout or stderr is a terminal writes where nmr never reads, so an interactive run retains nothing and its progress display is untouched; a piped or redirected run, and any run under `-q`, retains both streams. A composite hands its descriptors to the child `nmr` processes below it and captures nothing of its own, and a `:pre` or `:post` hook contributes nothing to the command it wraps: an excerpt is the command's own output or none.
|
|
88
|
+
|
|
89
|
+
**A composite replays its constituents' excerpts.** Capturing nothing of its own, a composite assembles what its constituents recorded, each line attributed to the scope and command that produced it:
|
|
90
|
+
|
|
91
|
+
```console
|
|
92
|
+
$ nmr check
|
|
93
|
+
⏭️ nmr-core: check: passed 3m ago on this tree, saved ~48s — replayed: nmr-core: fmt:check: All matched files use Prettier code style!; nmr-core: test: Test Files 6 passed (6) Tests 41 passed (41)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The assembly is flat: a constituent that is itself a composite contributes its own leaves' lines, so a skipped `ci` replays a package's `test` rather than one opaque `check:strict` line. A delegate expands into the scopes it fans out to, and a constituent that recorded no excerpt is absent rather than inferred: `typecheck` and `lint:check` print nothing on success and contribute nothing above, and a command outside the [cacheable set](#what-is-cached) records nothing at all, so `ci` replays what `check:strict` earned and nothing for `build`.
|
|
97
|
+
|
|
98
|
+
It is assembled when the composite records its pass rather than when it skips, so every excerpt in it was certified during that one run, on that one tree. `NMR_RUN_ID` is what makes that provable; see [reserved environment variables](#reserved-environment-variables).
|
|
99
|
+
|
|
100
|
+
**A skip certifies what it replays.** Recalling a pass proves the excerpt describes this tree, and a matching retention key proves it describes this presentation environment, so a skip restamps the recalled entry with the run replaying it. A constituent already warm when a composite ran therefore still contributes its line: `nmr check` after `nmr test` replays the test summary. The restamp touches nothing else -- the instant and the duration belong to the run that earned the pass.
|
|
101
|
+
|
|
102
|
+
**A replay is held to more than the pass is.** Retention carries its own key: the [pass key](#what-the-key-is-made-of) folded with the channel each output stream ran on and with `CI`, `COLUMNS`, `FORCE_COLOR`, `NO_COLOR`, and `TERM`. A recording made under a different one is recalled as a pass all the same; its excerpt is simply not replayed, and the verdict prints alone, as it does for a pass that retained nothing.
|
|
103
|
+
|
|
104
|
+
The whole line, excerpt included, is held to the same [512-byte ceiling](reporting.md#what-nmr-reports) every verdict is, so one write still carries it whole under concurrent fan-out. An assembly wide enough to overrun it is cut and marked with `…` rather than spread over several lines.
|
|
105
|
+
|
|
106
|
+
## Reading a retained run
|
|
107
|
+
|
|
108
|
+
The excerpt is a line; the run behind it is kept whole. `nmr --log <command>` prints it, led by a header dating what follows:
|
|
109
|
+
|
|
110
|
+
```console
|
|
111
|
+
$ nmr --log test
|
|
112
|
+
📼 nmr: test — recorded 2026-08-12T15:04:05.412Z (12m ago), ran in 12.4s
|
|
113
|
+
$ pnpm exec vitest --project unit --project tool
|
|
114
|
+
|
|
115
|
+
✓ src/__tests__/resolver.unit.test.ts (18 tests) 12ms
|
|
116
|
+
|
|
117
|
+
Test Files 6 passed (6)
|
|
118
|
+
Tests 41 passed (41)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The header is what presents the body as a recording rather than as this invocation's output. The command string is the whole chain, hooks included, so it names what earned the pass and not merely what was typed. A flag rather than a subcommand, since `log` would collide with the script registry.
|
|
122
|
+
|
|
123
|
+
**Reading a recording runs nothing.** No hook runs, no verdict prints, and nothing is recorded or restamped. `--no-cache` governs running rather than reading and is ignored.
|
|
124
|
+
|
|
125
|
+
**A recording is held to the tree, not to the terminal.** `--log` prints exactly what a skip would have recalled: the [pass key](#what-the-key-is-made-of) has to match. The [retention key](#replaying-a-skipped-runs-output) is not consulted, so a recording made under a different terminal width, or through a pipe, still prints -- a reader at a terminal being shown what a piped run wrote is the case the flag exists for.
|
|
126
|
+
|
|
127
|
+
**A composite prints its assembly.** Retaining nothing of its own, a composite prints the excerpts its constituents recorded, one attributed line each. The transcript of any one of them is that constituent's own `--log` to print.
|
|
128
|
+
|
|
129
|
+
**Nothing to show is said rather than left blank.** A refusal names which it is -- the command is outside the [cacheable set](#what-is-cached), the gate is [standing aside](#when-the-gate-stands-aside), nothing has recorded a pass, the last pass was recorded under something this run does not share, or the pass retained no output, having printed none or written to a terminal -- and exits non-zero, so a caller can tell an empty `stdout` from a recording. A mismatch names the ingredient that moved, the tree or the chain or a version, so a reader whose `git status` is clean is not sent looking there.
|
|
130
|
+
|
|
131
|
+
```console
|
|
132
|
+
$ nmr --log test
|
|
133
|
+
📭 nmr: test: no recording; the last pass was 3m ago, on a tree this is not
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**`--log` reaches the scopes `-F` and `-R` select.** The flag rides the delegate, so each scope prints its own recording. There a scope with nothing to show reports its gap and exits 0: partial coverage is a survey's normal shape, and failing on the first gap would hide every scope that had something. A selection of no scope at all is the other case and fails, as it does for a run: there is no survey to be partial.
|
|
137
|
+
|
|
138
|
+
**Retention has a ceiling of 256 KiB per recorded pass**, distinct from the 512 bytes a verdict line is held to. A transcript overrunning it keeps 128 KiB from each end, because a run's opening and its closing statement each carry what the other does not, and the drop is marked with a line naming the bytes it stands for rather than being cut silently.
|
|
139
|
+
|
|
140
|
+
## Bypassing and clearing
|
|
141
|
+
|
|
142
|
+
Reach for these in order; the first is almost always the right one.
|
|
143
|
+
|
|
144
|
+
| Step | Effect |
|
|
145
|
+
| -------------------------------------- | ------------------------------------------------------------------------- |
|
|
146
|
+
| `nmr --no-cache <command>` | Runs the command, then records the fresh result. Reaches the whole chain. |
|
|
147
|
+
| `NMR_NO_CACHE=1` | The same, for every nmr invocation in the shell. |
|
|
148
|
+
| `rm -rf node_modules/.cache/nmr-check` | Forgets every recorded pass, leaving build output alone. |
|
|
149
|
+
| `nmr clean` | Forgets every recorded pass and removes build output, at any scope. |
|
|
150
|
+
|
|
151
|
+
`--no-cache` belongs before the command name. After it, it is an argument to the command rather than a flag to nmr; nmr says so rather than letting the bypass silently not happen.
|
|
152
|
+
|
|
153
|
+
## Reserved environment variables
|
|
154
|
+
|
|
155
|
+
`NMR_TREE_SNAPSHOT` is nmr's own: it carries one observation of the tree from a top-level invocation down to the processes it spawns, so a chain hashes the tree once rather than at every link. nmr trusts an inherited value only while `HEAD` still stands where it did when the observation was taken, which bounds a process that outlives the run that spawned it. That bound does not extend to a tree edited without committing, so **a process that survives its run and later invokes nmr should clear `NMR_TREE_SNAPSHOT`** — a test suite that shells out to `nmr` is the case worth checking.
|
|
156
|
+
|
|
157
|
+
`NMR_RUN_ID` is nmr's own too: it carries one run's identity from a top-level invocation down to the processes it spawns, so the excerpts recorded at every scope during one run are recognizable as that run's, and a composite can assemble from them. Where `NMR_TREE_SNAPSHOT` is bounded by `HEAD`, this is bounded by the tree hash each entry records, so a process that outlives its run and carries the identity into a later one contributes nothing to that run's assemblies.
|
|
158
|
+
|
|
159
|
+
## Configuring
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
export default defineConfig({
|
|
163
|
+
checkCache: {
|
|
164
|
+
// Names promising exit-status-only semantics through their whole chain.
|
|
165
|
+
extraCommands: ['verify:contracts'],
|
|
166
|
+
// Names that turned out to do more than report an exit status.
|
|
167
|
+
excludeCommands: ['test:coverage'],
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`extraCommands` extends the default set rather than replacing it, so naming one command cannot silently drop the rest; `excludeCommands` is applied afterwards, so a name in both is excluded. `enabled: false` turns the gate off entirely. The key is `checkCache`, in the monorepo-root config.
|
|
173
|
+
|
|
174
|
+
**A name in either list must resolve to a command**, whether through nmr's defaults, the repo config's script records, or a package's own `package.json` scripts. Both lists are read by name alone, so a misspelt entry would otherwise be inert: the command it meant to name would go on running, indistinguishable from one that cannot be cached at all. A name that resolves nowhere fails config load, naming the closest command where there is one. A hook name is rejected too, since a hook is never gated on its own -- it runs as part of the chain of the command it wraps, and is skipped or run with it.
|
|
175
|
+
|
|
176
|
+
The test is resolvability rather than membership of the cacheable set, so an `excludeCommands` entry keeps standing after a release moves its name out of the defaults.
|
package/docs/prettier.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Shared Prettier config
|
|
2
|
+
|
|
3
|
+
The Prettier config that `@williamthorsen/nmr/prettier` publishes: what it formats, how it scopes the shell plugin, and how to customize and adopt it.
|
|
4
|
+
|
|
5
|
+
`prettier` is a peer dependency (`>=3.9.5 <4`), declared optional — the consuming repo provides it. The shell and Dockerfile plugin is a dependency of nmr rather than a peer, so its version is pinned centrally and two repos cannot format the same script differently.
|
|
6
|
+
|
|
7
|
+
## What it formats
|
|
8
|
+
|
|
9
|
+
Beyond everything Prettier already handles, this config formats `.sh`, `.bash`, `.zsh`, and the other extensions and dotfile names of the Shell language, plus `Dockerfile` and `Containerfile`. `nmr fmt` therefore covers shell scripts with no extra script, glob, or workflow entry.
|
|
10
|
+
|
|
11
|
+
Shell output matches `shfmt` run with no flags: `binaryNextLine`, `spaceRedirects`, and `switchCaseIndent` are pinned to shfmt's CLI defaults, which the underlying plugin inverts. A repo whose scripts shfmt already formatted sees no diff on adoption.
|
|
12
|
+
|
|
13
|
+
Code fences inside Markdown are left alone, in every language rather than shell alone. Registering the shell plugin also routes a fence tagged `bash` to shfmt, where a documented command's angle-bracket placeholders are valid redirections: `cmd --type <type> --harness claude` is reprinted as `cmd --type claude <type >--harness`, which still runs but no longer does what it documents. It cannot be narrowed back to shell, because Prettier matches a fence tag against the same `extensions` its file inference reads, so the `.bash` routing a script also claims the tag `bash`.
|
|
14
|
+
|
|
15
|
+
## Language scoping
|
|
16
|
+
|
|
17
|
+
`prettier-plugin-sh` routes 20 further file types to the same shell parser, among them `.gitignore`, `.env`, `.csh`, `.nu`, `.properties`, `.ics`, `.vcf`, `CODEOWNERS`, and `hosts`. Since `nmr fmt` hands git's whole file list to Prettier, registering the plugin as shipped would make every one of them formattable — and the shell parser either fails on them or silently rewrites them. A `.gitignore` pattern such as `a(b)c` fails to parse, and an unquoted `&` in a `.env` or `.properties` value is split across two lines.
|
|
18
|
+
|
|
19
|
+
This config registers a narrowed plugin, so Prettier infers a parser for the Shell and Dockerfile languages alone. `.flaskenv`, `gradlew`, and `mvnw` are dropped from Shell as well: the first holds dotenv content the plugin misfiles as shell, and the other two are vendored wrappers their generators would overwrite.
|
|
20
|
+
|
|
21
|
+
The parsers themselves stay registered, so a repo that wants a dropped language back assigns it explicitly:
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
export default definePrettierConfig({
|
|
25
|
+
additionalOverrides: [{ files: ['APKBUILD', '*.ebuild'], options: { parser: 'sh' } }],
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Customizing
|
|
30
|
+
|
|
31
|
+
Any Prettier option passed to the factory spreads over the defaults:
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
export default definePrettierConfig({
|
|
35
|
+
printWidth: 100,
|
|
36
|
+
additionalPlugins: [await import('prettier-plugin-tailwindcss')],
|
|
37
|
+
additionalOverrides: [{ files: ['*.md'], options: { proseWrap: 'always' } }],
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`additionalPlugins` and `additionalOverrides` append to what the config declares, so a later override wins over nmr's own and no seam can drop shell support. Passing `plugins` or `overrides` throws, and both are typed `never` — replacement is inexpressible rather than merely discouraged, because a silently ignored key would produce no diff to notice.
|
|
42
|
+
|
|
43
|
+
## Adoption caveats
|
|
44
|
+
|
|
45
|
+
- **Indentation follows `.editorconfig`**, as it did under shfmt: Prettier maps `indent_style` and `indent_size` to `useTabs` and `tabWidth`, and the plugin honours both. With no `.editorconfig`, the two disagree — shfmt indents shell with tabs, Prettier with two spaces. Add `indent_style = tab` under `[*.sh]` to keep tabs.
|
|
46
|
+
- **Markdown fences stop being tidied.** The carve-out that protects documented shell commands costs the `ts`, `json`, and `yaml` fences their formatting too. Fences already formatted stay as they are, so nothing churns on adoption; new ones are simply left as written. A repo whose docs hold no shell can take them back for the Markdown paths it actually uses: `additionalOverrides: [{ files: ['*.md'], options: { embeddedLanguageFormatting: 'auto' } }]` restores `.md` alone, and the carve-out still covers every other path Prettier reads as Markdown.
|
|
47
|
+
- **Adopting the house options reformats the repo.** `singleQuote`, `trailingComma`, and the rest apply on the first run; review that diff separately from the shell one.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# What nmr reports
|
|
2
|
+
|
|
3
|
+
How nmr reports each command it runs, in prose or as JSON, and how loudly it relays the output of those commands.
|
|
4
|
+
|
|
5
|
+
The verdict line and its four outcomes are shown in the [README](../README.md#what-nmr-reports).
|
|
6
|
+
|
|
7
|
+
A recalled pass also carries an excerpt of the run it recalls, where that run left one; see [Replaying a skipped run's output](check-cache.md#replaying-a-skipped-runs-output).
|
|
8
|
+
|
|
9
|
+
The scope is the directory the command's registry belongs to: a package's own directory, or the monorepo root. Durations and ages truncate and carry at most two units, so a check that passed 90 seconds ago reports `1m 30s ago` and never `2m ago`.
|
|
10
|
+
|
|
11
|
+
Verdicts nest. A composite reports, and so does every command it expands into; a `:pre` or `:post` hook reports nothing of its own, since the command it wraps already reports for the chain, though a hook that delegates to a named command reports under that name. A `-R` or `-F` invocation reports nothing either, every scope it fans out to reporting instead. A cold repo-wide `nmr ci` in this package's own monorepo spends 26 lines on a run that prints roughly 250.
|
|
12
|
+
|
|
13
|
+
**A fan-out that selects no scope fails.** A `-F` pattern matches a package's manifest `name` rather than its directory name, so a plausible directory name selects nothing, and pnpm exits 0 over an empty selection as it does over a passing one. nmr asks pnpm what the pattern selects and refuses the invocation where the answer is nothing, naming the pattern and, where the pattern carries a name, the names it could have named. A pattern of another form states the rule its own shape answers to: a directory (`./dir`, `/dir`, `{dir}`), an exclusion (`!`), or a changed-since selector (`[<ref>]`), none of which a workspace name repairs. A pattern pnpm rejects rather than resolves, such as a bad git ref in a `[since]` selector, is left to pnpm to report. `-R` is refused on the same ground where the workspace declares no package at all, which is its only empty selection.
|
|
14
|
+
|
|
15
|
+
A verdict is one write of at most 512 bytes, the smallest `PIPE_BUF` POSIX permits, so packages running concurrently under `pnpm --recursive` cannot interleave within a line. A line longer than that is cut and marked with `…`; nothing nmr reports today comes near it.
|
|
16
|
+
|
|
17
|
+
## Reporting for a machine
|
|
18
|
+
|
|
19
|
+
`--json` renders each verdict as a JSON object instead of a line of prose — one object per line, newline-delimited, with no enclosing array and no separators between them. A consumer parses each line as it arrives, and a run cut off partway still leaves every line it already emitted parseable, which an enclosing array would not.
|
|
20
|
+
|
|
21
|
+
```console
|
|
22
|
+
$ nmr --json check
|
|
23
|
+
{"command":"typecheck","scope":"nmr-core","outcome":"passed","durationMs":255}
|
|
24
|
+
{"command":"fmt:check","scope":"nmr-core","outcome":"recalled","ageMs":240000,"savedMs":3888,"replay":[{"command":"fmt:check","excerpt":"Checking formatting... All matched files use Prettier code style!","scope":"nmr-core"}]}
|
|
25
|
+
{"command":"check","scope":"nmr-core","outcome":"passed","durationMs":31204}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Every object carries `command`, `scope`, and `outcome`, and then what that outcome carries and no other does:
|
|
29
|
+
|
|
30
|
+
| `outcome` | Also carries |
|
|
31
|
+
| ---------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
32
|
+
| `passed` | `durationMs` |
|
|
33
|
+
| `failed` | `durationMs`, `exitCode` |
|
|
34
|
+
| `recalled` | `ageMs`, `savedMs`, and `replay` where the skip has excerpts to [replay](check-cache.md#replaying-a-skipped-runs-output) |
|
|
35
|
+
| `no-op` | `reason`, either `empty-override` or `noop-override` |
|
|
36
|
+
|
|
37
|
+
Both renderings are produced from one record, so neither reports what the other does not. Where the prose line makes a presentation decision the record does not — a saving too small to be worth a clause, say — the object carries the fact and leaves the decision to whoever reads it.
|
|
38
|
+
|
|
39
|
+
**An object is one write of at most 512 bytes**, the [same ceiling](#what-nmr-reports) a prose line is held to, so packages running concurrently under `pnpm --recursive` cannot interleave within one. Cuts land inside the record's text rather than across its structure, so the line still parses, and a record that overruns is fitted by rungs — each shedding what a reader can better spare than the one below it:
|
|
40
|
+
|
|
41
|
+
1. **The excerpts are shortened toward one another**, so an assembly's constituents come down together rather than the first ones being emptied to leave the last whole. No excerpt is cut below `…`, which keeps a cut one distinguishable from a run that recorded nothing.
|
|
42
|
+
2. **The excerpts go**, leaving the `scope` and `command` that name each constituent. An entry carrying no `excerpt` key is one whose text did not fit.
|
|
43
|
+
3. **The trailing constituents go**, as the prose line drops its own tail, and the `replay` array itself once none fit.
|
|
44
|
+
4. **The `scope` and `command` are cut**, each marked. This is the last resort and the reason for it is the ceiling itself: a line that overruns can be split across a pipe and corrupt the records of every scope sharing it, where a marked cut costs only its own.
|
|
45
|
+
|
|
46
|
+
A package-scoped `check` carries four constituents and fits uncut. A four-package root `check` assembles twelve — `typecheck` and `test` each fan out to every package, and a nested composite's list is spliced in flat — which is more than the ceiling can name: it reaches rung 3, keeping eight and dropping the trailing four. Rung 4 needs a scope and command running to hundreds of bytes between them.
|
|
47
|
+
|
|
48
|
+
**The mode implies quiet.** The commands' own output is withheld, so stdout carries the objects alone — no prose verdict, and no override notice. `NMR_COMMAND_VERBOSITY=full` does not opt back out. A failure still surrenders the failing command's output, on stderr, as under any quiet run. Both modes leave a command on the same channels, so a machine-readable run and a quiet one share a [retention key](check-cache.md#replaying-a-skipped-runs-output): each replays what the other recorded.
|
|
49
|
+
|
|
50
|
+
The format is nmr's own reporting and not a mode the commands it runs are told about, so it is out of the [pass key](check-cache.md#what-the-key-is-made-of) and the retention key alike. Selecting it neither invalidates a recorded pass nor costs a replay.
|
|
51
|
+
|
|
52
|
+
`--log`, `--help`, and `--version` are unaffected: none of them reports a verdict, and what `--log` prints is a tool's own transcript, which has no rendering as a record.
|
|
53
|
+
|
|
54
|
+
## Reserved environment variables
|
|
55
|
+
|
|
56
|
+
`NMR_COMMAND_VERBOSITY` is nmr's own, and carries how loudly a run reports the output of the commands it runs. Its values are `full` and `quiet`; any other non-empty value is reported and exits 1, rather than falling back to a mode nobody chose, and an empty value reads as unset. Each process in a chain suppresses the output of the command it runs rather than of everything below it, so a failure still surrenders the failing command's output. It governs those commands' output alone: nmr's [verdicts](#what-nmr-reports) print in every verbosity, and the override notice is the one message a quiet run withholds.
|
|
57
|
+
|
|
58
|
+
`-q` sets it for the run and outranks an inherited value. Both values are spelled out so either direction is expressible: export `NMR_COMMAND_VERBOSITY=quiet` for a quiet shell, and set `full` on one invocation to opt back out.
|
|
59
|
+
|
|
60
|
+
`NMR_REPORT_FORMAT` is nmr's own as well, and carries how nmr renders its own verdicts. Its values are `text` and `json`; any other non-empty value is reported and exits 1, and an empty value reads as unset. `--json` sets it for the run and outranks an inherited value, and both values are spelled out so either direction is expressible: export `NMR_REPORT_FORMAT=json` for a shell whose consumer parses, and set `text` on one invocation to opt back out. Two sources, then, where a verbosity has four: no repo-level default and no harness detection, since detection would hand every agent JSON in place of the prose the [shipped guidance](../README.md#agent-guidance) teaches. See [reporting for a machine](#reporting-for-a-machine) for what the objects carry.
|
|
61
|
+
|
|
62
|
+
### Where a verbosity comes from
|
|
63
|
+
|
|
64
|
+
Four sources, in precedence order. Each is read once, at the top of a chain: the resolved value travels down as `NMR_COMMAND_VERBOSITY`, so a nested process reads a decision already made rather than making its own.
|
|
65
|
+
|
|
66
|
+
| Source | Set it when |
|
|
67
|
+
| ----------------------------------------------------- | ---------------------------------------------------------- |
|
|
68
|
+
| `-q` | One invocation should be quiet. |
|
|
69
|
+
| `NMR_COMMAND_VERBOSITY` | A shell, or a harness that launches one, should be quiet. |
|
|
70
|
+
| `output.commandVerbosity` in the monorepo-root config | A repo should be quiet for everyone working in it. |
|
|
71
|
+
| An agent harness nmr recognizes in the environment | Nothing above applies and an agent is running the command. |
|
|
72
|
+
|
|
73
|
+
Falling off the bottom leaves the run `full`, which is what an unrecognized harness gets: detection adds quiet where it fires and changes nothing where it does not.
|
|
74
|
+
|
|
75
|
+
Detection reads a set of environment-variable names and fires on any one of them holding a non-empty value. The shipped set is `CLAUDECODE`, `ROVODEV_CLI`, and `ROVO_CLI`. **Treat that set as perishable.** The names belong to ecosystems nmr does not control and that rename, and a rename fails silently here, since the symptom is a return to loud output rather than an error. `output.extraAgentEnvVars` is how a repo adds a name — a harness nmr has never heard of, or one whose marker was renamed after the installed version shipped — without waiting for a release. A repo that wants no detection at all configures `output.commandVerbosity: 'full'`, which outranks it.
|
|
76
|
+
|
|
77
|
+
## Configuring
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
export default defineConfig({
|
|
81
|
+
output: {
|
|
82
|
+
// The verbosity a run takes when neither `-q` nor the environment named one.
|
|
83
|
+
commandVerbosity: 'quiet',
|
|
84
|
+
// Markers of a harness the shipped set does not name, added to it rather than replacing it.
|
|
85
|
+
extraAgentEnvVars: ['MY_HARNESS'],
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`output` is a monorepo-root key, and both of its fields feed the [verbosity ladder](#where-a-verbosity-comes-from).
|