@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
package/agents/README.md
CHANGED
|
@@ -11,7 +11,7 @@ A line earns a place in it only if both hold:
|
|
|
11
11
|
1. **It is absent from nmr's own output.** Bare `nmr` already prints the flags, every command, and the shell command each resolves to; failing checks already name their own fix. Repeating any of that costs launch tokens to say something the agent will be told anyway, at the moment it matters.
|
|
12
12
|
2. **The obvious action goes wrong without it.** Guidance that prevents a silent mistake earns its place; guidance that prevents a mistake the next error message would diagnose does not.
|
|
13
13
|
|
|
14
|
-
Everything else belongs in `packages/nmr/
|
|
14
|
+
Everything else belongs in the topic document under `packages/nmr/docs/` that covers it, reachable on demand at `node_modules/@williamthorsen/nmr/docs/`, with a pointer from the cheatsheet naming the topic and its document so an agent knows where to look. When adding a feature, document it there and extend that pointer rather than the cheatsheet.
|
|
15
15
|
|
|
16
16
|
## Constraints on rulebook bodies
|
|
17
17
|
|
|
@@ -13,5 +13,5 @@ delivery: ambient
|
|
|
13
13
|
- A check that already passed on the current working tree skips, printing `⏭️ <scope>: <command>: passed …` and, where the recalled run left one, `— replayed: <excerpt>` of what that run concluded. Any edit, addition, or deletion re-runs it, so read the replayed excerpt rather than paying for the answer again: never pass `--no-cache` to get a "real" run, which costs the command's full runtime to reach the same answer. Its one use is a file rather than an exit status -- a skipped command writes none, so a fresh `coverage/` needs `nmr --no-cache test:coverage`. A composite replays its constituents' excerpts, each attributed as `<scope>: <command>: <excerpt>` and cut at the line's ceiling where there are too many to fit. A skip carrying no excerpt is one whose run wrote to a terminal, or one whose constituents recorded none.
|
|
14
14
|
- `nmr --log <command>` prints the whole run behind that excerpt -- up to 256 KiB, led by when it was recorded, how long it ran, and the command string that produced it -- so output you need in full is a recording to read rather than a run to pay for again. It runs nothing, and it is held to the tree rather than to the terminal: a recording a piped run made prints for a reader at one. A composite prints its constituents' excerpts, so the transcript of one of them is `nmr -F <pkg> --log <command>` or that constituent's own name. Nothing to show is said on one line and exits non-zero, naming which it is.
|
|
15
15
|
- Every command nmr runs reports one line -- `✅ <scope>: <command>: passed in 12.4s`, `❌ … failed in 1.2s (exit 1)`, `⏭️ … passed 4m ago on this tree`, or `⛔ … skipped, the override is empty` -- and a composite reports alongside every command it expands into. The lines print in every verbosity, so `-q` is the low-noise way to run rather than a silent one: it withholds the output of the commands nmr runs and keeps nmr's own verdicts, which is how a pass, a cached skip, and a `""`/`":"` override stay distinguishable. Expect a run to be quiet already without the flag: nmr recognizes a known agent harness in the environment and takes quiet from that, as it does from `NMR_COMMAND_VERBOSITY` or the repo's `output.commandVerbosity`. A command whose output you need in full is one to run with `NMR_COMMAND_VERBOSITY=full`, which outranks both.
|
|
16
|
-
- Every test file lives under a `__tests__` directory and names its isolation tier last: `<subject>[.<aspect>].<tier>.test.ts`, where any earlier segment is free-form documentation. A tier is the furthest thing the test reaches -- `tool` (a program the environment supplies), `localhost` (a service on this machine), `remote` (a machine that isn't this one), or `unit`, the residual claiming everything else. A file outside `__tests__`, or one whose tier segment is missing or misspelt, fails the test run in a repo that declares nmr's exported conventions check, which reports both. In a repo that has not, neither fails anything -- the first is collected by nothing and the second runs under `unit` -- and `rdy run --packages` reports the
|
|
17
|
-
- The rest is in `node_modules/@williamthorsen/nmr/
|
|
16
|
+
- Every test file lives under a `__tests__` directory and names its isolation tier last: `<subject>[.<aspect>].<tier>.test.ts`, where any earlier segment is free-form documentation. A tier is the furthest thing the test reaches -- `tool` (a program the environment supplies), `localhost` (a service on this machine), `remote` (a machine that isn't this one), or `unit`, the residual claiming everything else. A file outside `__tests__`, or one whose tier segment is missing or misspelt, fails the test run in a repo that declares nmr's exported conventions check, which reports both. In a repo that has not, neither fails anything -- the first is collected by nothing and the second runs under `unit` -- and `rdy run --packages` reports both and warns that the check is missing.
|
|
17
|
+
- The rest is in `node_modules/@williamthorsen/nmr/docs/`: `scripts.md` for pre/post hooks (every `nmr X` auto-wraps as `nmr X:pre && nmr X && nmr X:post`) and script overrides and their `""`/`":"` skip values; `upgrades.md` for `nmr upgrade` ceilings (report-only until `nmr upgrade --write`); `utilities.md` for the `nmr-compile` build (there is no repo-local build script); `vitest.md` for the test tiers' timeout budgets, the `tiers` seam that raises one, and which script reaches which tier; and `check-cache.md` for the check-result cache's key, the retention key a replayed excerpt is additionally held to, the retention ceiling `--log` prints within, its `checkCache` config, and every condition that disables it.
|
|
@@ -5,7 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import { readCacheEntry, writeCacheEntry } from '@williamthorsen/nmr-core';
|
|
6
6
|
import { glob } from 'glob';
|
|
7
7
|
import * as ts from 'typescript';
|
|
8
|
-
import { resolveConfigPath } from "../config.js";
|
|
8
|
+
import { resolveConfigPath } from "../helpers/config-path.js";
|
|
9
9
|
import { DEFAULT_ENTRY_GLOBS, DEFAULT_IGNORE_PATTERNS, DEFAULT_OUTDIR, hasExpectedBuildOutput, resolveBuildCachePath, resolveScratchDirs, resolveToolchainFingerprint, } from "./build-output.js";
|
|
10
10
|
const PACKAGE_ICON = '📦';
|
|
11
11
|
const SKIPPED_ICON = '⏭️';
|
package/dist/esm/config.d.ts
CHANGED
|
@@ -1,12 +1,4 @@
|
|
|
1
1
|
import type { NmrConfig } from './types.js';
|
|
2
|
-
/** The config's path relative to the directory holding it, for a message composed without one in hand. */
|
|
3
|
-
export declare const CONFIG_RELATIVE_PATH: string;
|
|
4
|
-
/**
|
|
5
|
-
* Resolves the config-file path for a directory, whether that is the monorepo root or a package.
|
|
6
|
-
* Callers that only need to know whether a config exists -- the build, which folds it into its cache digest -- go
|
|
7
|
-
* through this rather than spelling the path again, so that the two cannot drift into hashing a file nothing reads.
|
|
8
|
-
*/
|
|
9
|
-
export declare function resolveConfigPath(baseDir: string): string;
|
|
10
2
|
/**
|
|
11
3
|
* Loads the nmr configuration from `.config/nmr.config.ts` under `baseDir`, which is the monorepo root for
|
|
12
4
|
* the repo-wide config and a package directory for the workspace tier. Returns an empty config if the file
|
package/dist/esm/config.js
CHANGED
|
@@ -7,15 +7,16 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
9
|
import { existsSync } from 'node:fs';
|
|
10
|
-
import path from 'node:path';
|
|
11
10
|
import { pathToFileURL } from 'node:url';
|
|
11
|
+
import { resolveConfigPath } from "./helpers/config-path.js";
|
|
12
|
+
import { findClosestName } from "./helpers/findClosestName.js";
|
|
13
|
+
import { isHookName } from "./helpers/hook-name.js";
|
|
12
14
|
import { isObject, isStringRecord } from "./helpers/type-guards.js";
|
|
15
|
+
import { buildRootRegistry, buildWorkspaceRegistry, readPackageJsonScripts } from "./resolver.js";
|
|
13
16
|
import { findUnexpressibleToken } from "./steps.js";
|
|
14
17
|
import { UserError } from "./UserError.js";
|
|
15
18
|
import { formatVerbosityRejection, isCommandVerbosity } from "./verbosity.js";
|
|
16
|
-
|
|
17
|
-
const CONFIG_DIR = '.config';
|
|
18
|
-
export const CONFIG_RELATIVE_PATH = path.join(CONFIG_DIR, CONFIG_FILENAME);
|
|
19
|
+
import { getWorkspacePackageDirs, isMonorepoRoot } from "./workspace.js";
|
|
19
20
|
const CONFIG_TIERS = {
|
|
20
21
|
root: {
|
|
21
22
|
label: 'a monorepo-root config',
|
|
@@ -33,9 +34,6 @@ const RECOGNIZED_BUILD_KEYS = ['extraIgnorePatterns'];
|
|
|
33
34
|
const RECOGNIZED_CHECK_CACHE_KEYS = ['enabled', 'excludeCommands', 'extraCommands'];
|
|
34
35
|
const RECOGNIZED_OUTPUT_KEYS = ['commandVerbosity', 'extraAgentEnvVars'];
|
|
35
36
|
const RECOGNIZED_STEP_KEYS = ['declinesArgs', 'run'];
|
|
36
|
-
export function resolveConfigPath(baseDir) {
|
|
37
|
-
return path.join(baseDir, CONFIG_DIR, CONFIG_FILENAME);
|
|
38
|
-
}
|
|
39
37
|
function isScriptRecord(value) {
|
|
40
38
|
if (!isObject(value))
|
|
41
39
|
return false;
|
|
@@ -132,6 +130,64 @@ function validateCheckCacheField(value, configPath) {
|
|
|
132
130
|
}
|
|
133
131
|
return config;
|
|
134
132
|
}
|
|
133
|
+
function assertResolvableCheckCacheCommands(config, configPath, baseDir) {
|
|
134
|
+
const { checkCache } = config;
|
|
135
|
+
if (checkCache === undefined || !isMonorepoRoot(baseDir)) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const fields = ['excludeCommands', 'extraCommands'];
|
|
139
|
+
const entries = fields.flatMap((field) => (checkCache[field] ?? []).map((command) => ({ command, field })));
|
|
140
|
+
const hook = entries.find(({ command }) => isHookName(command));
|
|
141
|
+
if (hook !== undefined) {
|
|
142
|
+
throw new UserError(`Invalid nmr config at ${configPath}: \`checkCache.${hook.field}\` names the hook \`${hook.command}\`, ` +
|
|
143
|
+
'which is never gated on its own: it runs as part of the chain of the command it wraps.');
|
|
144
|
+
}
|
|
145
|
+
const registered = new Set([
|
|
146
|
+
...Object.keys(buildRootRegistry(config)),
|
|
147
|
+
...Object.keys(buildWorkspaceRegistry(config)),
|
|
148
|
+
]);
|
|
149
|
+
const missing = entries.filter(({ command }) => !registered.has(command));
|
|
150
|
+
if (missing.length === 0) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const declared = readDeclaredScriptNames(baseDir);
|
|
154
|
+
const unresolvable = missing.find(({ command }) => !declared.has(command));
|
|
155
|
+
if (unresolvable === undefined) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const closest = findClosestName(unresolvable.command, [...registered, ...declared]);
|
|
159
|
+
throw new UserError(`Invalid nmr config at ${configPath}: \`checkCache.${unresolvable.field}\` names no command: ` +
|
|
160
|
+
`\`${unresolvable.command}\`.${closest === undefined ? '' : ` Did you mean \`${closest}\`?`}`);
|
|
161
|
+
}
|
|
162
|
+
function readDeclaredScriptNames(monorepoRoot) {
|
|
163
|
+
let packageDirs;
|
|
164
|
+
try {
|
|
165
|
+
packageDirs = getWorkspacePackageDirs(monorepoRoot);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
packageDirs = [];
|
|
169
|
+
}
|
|
170
|
+
const names = new Set();
|
|
171
|
+
const dirs = [monorepoRoot, ...packageDirs];
|
|
172
|
+
for (const dir of dirs) {
|
|
173
|
+
const scripts = readPackageScripts(dir);
|
|
174
|
+
for (const name of Object.keys(scripts)) {
|
|
175
|
+
names.add(name);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return names;
|
|
179
|
+
}
|
|
180
|
+
function readPackageScripts(packageDir) {
|
|
181
|
+
try {
|
|
182
|
+
return readPackageJsonScripts(packageDir) ?? {};
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (error instanceof UserError) {
|
|
186
|
+
return {};
|
|
187
|
+
}
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
135
191
|
function validateOutputField(value, configPath) {
|
|
136
192
|
const output = value['output'];
|
|
137
193
|
if (output === undefined) {
|
|
@@ -168,7 +224,7 @@ function validateStringRecordField(value, fieldName, configPath) {
|
|
|
168
224
|
}
|
|
169
225
|
return value[fieldName];
|
|
170
226
|
}
|
|
171
|
-
function validateConfig(value, configPath) {
|
|
227
|
+
function validateConfig(value, configPath, baseDir) {
|
|
172
228
|
if (!isObject(value)) {
|
|
173
229
|
throw new UserError(`Invalid nmr config at ${configPath}: expected an object, got ${typeof value}`);
|
|
174
230
|
}
|
|
@@ -192,6 +248,7 @@ function validateConfig(value, configPath) {
|
|
|
192
248
|
const rootScripts = validateScriptField(value, 'rootScripts', configPath);
|
|
193
249
|
if (rootScripts)
|
|
194
250
|
config.rootScripts = rootScripts;
|
|
251
|
+
assertResolvableCheckCacheCommands(config, configPath, baseDir);
|
|
195
252
|
return config;
|
|
196
253
|
}
|
|
197
254
|
export async function loadConfig(baseDir) {
|
|
@@ -201,7 +258,7 @@ export async function loadConfig(baseDir) {
|
|
|
201
258
|
}
|
|
202
259
|
const imported = await import(__rewriteRelativeImportExtension(pathToFileURL(configPath).href));
|
|
203
260
|
const loaded = isObject(imported) ? imported['default'] : undefined;
|
|
204
|
-
return validateConfig(loaded, configPath);
|
|
261
|
+
return validateConfig(loaded, configPath, baseDir);
|
|
205
262
|
}
|
|
206
263
|
export async function loadRootConfig(monorepoRoot) {
|
|
207
264
|
const config = await loadConfig(monorepoRoot);
|
package/dist/esm/context.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export interface ResolvedContext {
|
|
|
3
3
|
monorepoRoot: string;
|
|
4
4
|
isRoot: boolean;
|
|
5
5
|
packageDir?: string;
|
|
6
|
+
/** The workspace's package directories, resolved once here rather than swept for again by each reader. */
|
|
7
|
+
workspacePackageDirs: string[];
|
|
6
8
|
config: NmrConfig;
|
|
7
9
|
}
|
|
8
10
|
/**
|
package/dist/esm/context.js
CHANGED
package/dist/esm/help.js
CHANGED
|
@@ -5,7 +5,7 @@ export function generateHelp(config, packageDir, useRoot) {
|
|
|
5
5
|
'Usage: nmr [flags] <command> [args...]',
|
|
6
6
|
'',
|
|
7
7
|
'Flags:',
|
|
8
|
-
' -F, --filter <pattern> Run command in
|
|
8
|
+
' -F, --filter <pattern> Run command in packages whose manifest name matches',
|
|
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",
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The config's path relative to the directory holding it, for a message composed without one in hand. */
|
|
2
|
+
export declare const CONFIG_RELATIVE_PATH: string;
|
|
3
|
+
/**
|
|
4
|
+
* Resolves the config-file path for a directory, whether that is the monorepo root or a package.
|
|
5
|
+
* Callers that only need to know whether a config exists -- the build, which folds it into its cache digest -- go
|
|
6
|
+
* through this rather than spelling the path again, so that the two cannot drift into hashing a file nothing reads.
|
|
7
|
+
*/
|
|
8
|
+
export declare function resolveConfigPath(baseDir: string): string;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
const CONFIG_DIR = '.config';
|
|
3
|
+
const CONFIG_FILENAME = 'nmr.config.ts';
|
|
4
|
+
export const CONFIG_RELATIVE_PATH = path.join(CONFIG_DIR, CONFIG_FILENAME);
|
|
5
|
+
export function resolveConfigPath(baseDir) {
|
|
6
|
+
return path.join(baseDir, CONFIG_DIR, CONFIG_FILENAME);
|
|
7
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asks pnpm what a `-F` pattern selects, so an invocation that would select nothing is caught before it
|
|
3
|
+
* delegates, and one selecting several packages is told from one selecting a single package.
|
|
4
|
+
*
|
|
5
|
+
* pnpm's own selector answers rather than a matcher of nmr's: `-F` means the whole selector language, names
|
|
6
|
+
* and globs alongside `./path`, `{dir}`, `pkg...`, `!negation`, and `[since]`, and a matcher here would leave
|
|
7
|
+
* every form but the simplest silently unchecked.
|
|
8
|
+
*
|
|
9
|
+
* Runs from the monorepo root, where the delegate runs, so a path pattern resolves against the same directory
|
|
10
|
+
* in the probe as in the run it stands in for.
|
|
11
|
+
*
|
|
12
|
+
* The listing counts the root project wherever the filter leaves it standing, while the `exec` a delegation
|
|
13
|
+
* composes runs there only where the pattern selects the root positively: `-F '!./packages/*'` lists the root
|
|
14
|
+
* and executes nowhere. A listing of the root alone is therefore the one reading the listing cannot settle,
|
|
15
|
+
* and it is put to `exec` itself. Every other listing settles on its own, and counts the packages beside the
|
|
16
|
+
* root: two of them run in two scopes, while one beside the root may run in that package alone.
|
|
17
|
+
*/
|
|
18
|
+
export declare function readFilterSelection(pattern: string, monorepoRoot: string): FilterSelection;
|
|
19
|
+
/**
|
|
20
|
+
* Reduces a listing to what it proves about the selection.
|
|
21
|
+
*
|
|
22
|
+
* Every outcome the probe cannot read resolves to `unresolved`, which claims nothing about the selection, and the
|
|
23
|
+
* invocation delegates as it would have. A pattern pnpm rejects rather than resolves, such as a bad git ref in
|
|
24
|
+
* a changed-since selector, is pnpm's to report, and reporting it here as a match failure would name a
|
|
25
|
+
* different fault than the one the user has.
|
|
26
|
+
*/
|
|
27
|
+
export declare function interpretSelectionProbe(probe: SelectionProbe, monorepoRoot: string): ProbeReading;
|
|
28
|
+
/**
|
|
29
|
+
* Reduces a marked delegate run to what it proves: a scope that wrote the marker is a scope the run reached. The
|
|
30
|
+
* run is made only for a listing of the root alone, so that scope is the only one.
|
|
31
|
+
*/
|
|
32
|
+
export declare function interpretDelegateProbe(probe: SelectionProbe): FilterSelection;
|
|
33
|
+
/** What a `-F` pattern selected, as far as pnpm could be asked. `multiple` is two or more packages beside the root. */
|
|
34
|
+
export type FilterSelection = 'empty' | 'multiple' | 'single' | 'unresolved';
|
|
35
|
+
/** What a listing proves, `root-only` being the reading that only the delegate can settle. */
|
|
36
|
+
export type ProbeReading = FilterSelection | 'root-only';
|
|
37
|
+
/** The part of a probe's completion a selection is read from. */
|
|
38
|
+
export interface SelectionProbe {
|
|
39
|
+
error: Error | undefined;
|
|
40
|
+
status: number | null;
|
|
41
|
+
stdout: string;
|
|
42
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { isObject } from "./type-guards.js";
|
|
6
|
+
const SELECTION_MARKER = 'nmr-selected-scope';
|
|
7
|
+
export function readFilterSelection(pattern, monorepoRoot) {
|
|
8
|
+
const listing = spawnSync('pnpm', ['ls', '--filter', pattern, '--depth', '-1', '--json'], {
|
|
9
|
+
cwd: monorepoRoot,
|
|
10
|
+
encoding: 'utf8',
|
|
11
|
+
});
|
|
12
|
+
const reading = interpretSelectionProbe({ error: listing.error, status: listing.status, stdout: listing.stdout }, monorepoRoot);
|
|
13
|
+
return reading === 'root-only' ? readDelegateSelection(pattern, monorepoRoot) : reading;
|
|
14
|
+
}
|
|
15
|
+
export function interpretSelectionProbe(probe, monorepoRoot) {
|
|
16
|
+
if (probe.error !== undefined || probe.status !== 0) {
|
|
17
|
+
return 'unresolved';
|
|
18
|
+
}
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(probe.stdout);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return 'unresolved';
|
|
25
|
+
}
|
|
26
|
+
if (!Array.isArray(parsed)) {
|
|
27
|
+
return 'unresolved';
|
|
28
|
+
}
|
|
29
|
+
if (parsed.length === 0) {
|
|
30
|
+
return 'empty';
|
|
31
|
+
}
|
|
32
|
+
const packageCount = parsed.filter((entry) => !isRootEntry(entry, monorepoRoot)).length;
|
|
33
|
+
if (packageCount === 0) {
|
|
34
|
+
return 'root-only';
|
|
35
|
+
}
|
|
36
|
+
return packageCount === 1 ? 'single' : 'multiple';
|
|
37
|
+
}
|
|
38
|
+
export function interpretDelegateProbe(probe) {
|
|
39
|
+
if (probe.error !== undefined || probe.status !== 0) {
|
|
40
|
+
return 'unresolved';
|
|
41
|
+
}
|
|
42
|
+
return probe.stdout.includes(SELECTION_MARKER) ? 'single' : 'empty';
|
|
43
|
+
}
|
|
44
|
+
function isRootEntry(entry, monorepoRoot) {
|
|
45
|
+
if (!isObject(entry) || typeof entry['path'] !== 'string') {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return readRealPath(entry['path']) === readRealPath(monorepoRoot);
|
|
49
|
+
}
|
|
50
|
+
function readRealPath(target) {
|
|
51
|
+
try {
|
|
52
|
+
return realpathSync(target);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return path.resolve(target);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function readDelegateSelection(pattern, monorepoRoot) {
|
|
59
|
+
const probe = spawnSync('pnpm', ['--filter', pattern, 'exec', process.execPath, '--eval', `process.stdout.write('${SELECTION_MARKER}')`], { cwd: monorepoRoot, encoding: 'utf8' });
|
|
60
|
+
return interpretDelegateProbe({ error: probe.error, status: probe.status, stdout: probe.stdout });
|
|
61
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds the candidate closest to a name, or `undefined` where none is near enough to be worth naming.
|
|
3
|
+
*
|
|
4
|
+
* The ceiling scales with the name's length, so a short name admits one slip and a long one a few. A fixed
|
|
5
|
+
* ceiling would either miss a typo in `root:test:coverage` or offer `fix` for `ci`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function findClosestName(name: string, candidates: Iterable<string>): string | undefined;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function findClosestName(name, candidates) {
|
|
2
|
+
const ceiling = Math.max(1, Math.floor(name.length / 4));
|
|
3
|
+
let closest;
|
|
4
|
+
let shortest = Infinity;
|
|
5
|
+
for (const candidate of candidates) {
|
|
6
|
+
const distance = measureDistance(name, candidate);
|
|
7
|
+
if (distance < shortest) {
|
|
8
|
+
shortest = distance;
|
|
9
|
+
closest = candidate;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return shortest <= ceiling ? closest : undefined;
|
|
13
|
+
}
|
|
14
|
+
function measureDistance(first, second) {
|
|
15
|
+
if (first.length === 0 || second.length === 0) {
|
|
16
|
+
return Math.max(first.length, second.length);
|
|
17
|
+
}
|
|
18
|
+
let previous = Array.from({ length: second.length + 1 }, (_unused, column) => column);
|
|
19
|
+
let current = Array.from({ length: second.length + 1 }, () => 0);
|
|
20
|
+
for (let row = 1; row <= first.length; row += 1) {
|
|
21
|
+
current[0] = row;
|
|
22
|
+
for (let column = 1; column <= second.length; column += 1) {
|
|
23
|
+
const substitution = readCell(previous, column - 1) + (first[row - 1] === second[column - 1] ? 0 : 1);
|
|
24
|
+
current[column] = Math.min(readCell(current, column - 1) + 1, readCell(previous, column) + 1, substitution);
|
|
25
|
+
}
|
|
26
|
+
const completed = current;
|
|
27
|
+
current = previous;
|
|
28
|
+
previous = completed;
|
|
29
|
+
}
|
|
30
|
+
return readCell(previous, second.length);
|
|
31
|
+
}
|
|
32
|
+
function readCell(row, index) {
|
|
33
|
+
const value = row[index];
|
|
34
|
+
if (value === undefined) {
|
|
35
|
+
throw new RangeError(`No cell at index ${index} in a row of ${row.length}`);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { describeError } from '@williamthorsen/toolbelt.errors';
|
|
4
|
-
import { CONFIG_RELATIVE_PATH } from "../config.js";
|
|
5
4
|
import { UserError } from "../UserError.js";
|
|
6
5
|
import { isMonorepoRoot } from "../workspace.js";
|
|
6
|
+
import { CONFIG_RELATIVE_PATH } from "./config-path.js";
|
|
7
7
|
import { readStringValues } from "./readStringValues.js";
|
|
8
8
|
import { isObject } from "./type-guards.js";
|
|
9
9
|
export const DEPENDENCY_FIELDS = [
|
package/dist/esm/runCli.js
CHANGED
|
@@ -2,10 +2,12 @@ import path from 'node:path';
|
|
|
2
2
|
import { readPackageVersion, reportError } from '@williamthorsen/nmr-core';
|
|
3
3
|
import { describeError } from '@williamthorsen/toolbelt.errors';
|
|
4
4
|
import { certifyRetention, computeCacheKey, computeRetentionKey, CURRENT_RUNTIME, encodeTreeSnapshot, findStaleBuildOutput, formatMisplacedNoCacheWarning, isCacheableCommand, NO_CACHE_ENV_VAR, readBuildOutputState, readCheckCacheEntry, recordTranscript, resolveRunId, resolveTreeSnapshot, RUN_ID_ENV_VAR, TREE_SNAPSHOT_ENV_VAR, writeCheckCacheEntry, writeDebugNote, } from "./check-cache.js";
|
|
5
|
-
import { resolveConfigPath } from "./config.js";
|
|
6
5
|
import { resolveContext } from "./context.js";
|
|
7
6
|
import { generateHelp } from "./help.js";
|
|
7
|
+
import { resolveConfigPath } from "./helpers/config-path.js";
|
|
8
8
|
import { deriveExcerpt } from "./helpers/deriveExcerpt.js";
|
|
9
|
+
import { readFilterSelection } from "./helpers/filter-selection.js";
|
|
10
|
+
import { findClosestName } from "./helpers/findClosestName.js";
|
|
9
11
|
import { isHookName } from "./helpers/hook-name.js";
|
|
10
12
|
import { resolvePackageJsonPath } from "./helpers/package-json.js";
|
|
11
13
|
import { composeTranscript } from "./helpers/transcript.js";
|
|
@@ -18,8 +20,10 @@ import { composeNmrStep, findNmrCrossing, renderChain } from "./steps.js";
|
|
|
18
20
|
import { UserError } from "./UserError.js";
|
|
19
21
|
import { COMMAND_VERBOSITY_ENV_VAR, readVerbosityEnv, resolveVerbosity, } from "./verbosity.js";
|
|
20
22
|
import { writeVerdict } from "./verdict.js";
|
|
23
|
+
import { readWorkspacePackageNames } from "./workspace.js";
|
|
21
24
|
const VERSION = readPackageVersion(import.meta.url);
|
|
22
25
|
const CROSSING_CONSEQUENCE = "so nmr handles the nested run's output as a tool's.";
|
|
26
|
+
const NAME_CEILING = 10;
|
|
23
27
|
const NAMED_ESCAPES = new Map([
|
|
24
28
|
['\n', String.raw `\n`],
|
|
25
29
|
['\r', String.raw `\r`],
|
|
@@ -88,7 +92,7 @@ export async function runCli(options) {
|
|
|
88
92
|
};
|
|
89
93
|
const delegation = composeDelegation({ childEnv, command, parsed });
|
|
90
94
|
if (delegation !== undefined) {
|
|
91
|
-
return
|
|
95
|
+
return runDelegation({ context, delegation, parsed, runOptions, stderr });
|
|
92
96
|
}
|
|
93
97
|
const registry = useRoot ? buildRootRegistry(context.config) : buildWorkspaceRegistry(context.config);
|
|
94
98
|
assertNoSelfReference({
|
|
@@ -346,6 +350,58 @@ function findArgError(parsed) {
|
|
|
346
350
|
}
|
|
347
351
|
return undefined;
|
|
348
352
|
}
|
|
353
|
+
function findEmptySelectionRefusal(options) {
|
|
354
|
+
const { context, parsed, selection } = options;
|
|
355
|
+
if (parsed.filter !== undefined) {
|
|
356
|
+
if (selection !== 'empty') {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
return formatEmptyFilterError(parsed.filter, readWorkspacePackageNames(context.workspacePackageDirs));
|
|
360
|
+
}
|
|
361
|
+
return context.workspacePackageDirs.length === 0 ? formatEmptyWorkspaceError() : undefined;
|
|
362
|
+
}
|
|
363
|
+
function formatEmptyFilterError(pattern, names) {
|
|
364
|
+
const rejection = `-F/--filter matched no workspace: \`${pattern}\`.`;
|
|
365
|
+
if (pattern.startsWith('!')) {
|
|
366
|
+
return `${rejection} A pattern beginning with \`!\` excludes what it matches, and this one leaves no package standing.`;
|
|
367
|
+
}
|
|
368
|
+
if (pattern.includes('[')) {
|
|
369
|
+
return `${rejection} A pattern carrying \`[<ref>]\` selects the packages changed since a git ref, and none has changed.`;
|
|
370
|
+
}
|
|
371
|
+
if (pattern.startsWith('.') || pattern.startsWith('/') || pattern.startsWith('{')) {
|
|
372
|
+
return (`${rejection} ` +
|
|
373
|
+
'A pattern written `./dir`, `/dir`, or `{dir}` selects the packages under a directory, ' +
|
|
374
|
+
'and no package sits under this one.');
|
|
375
|
+
}
|
|
376
|
+
return (`${rejection} A pattern matches a package's manifest \`name\`, ` +
|
|
377
|
+
`not its directory name.${suggestWorkspaceNames(pattern, names)}`);
|
|
378
|
+
}
|
|
379
|
+
function formatEmptyWorkspaceError() {
|
|
380
|
+
return ('-R/--recursive matched no workspace: pnpm-workspace.yaml declares no package directory, ' +
|
|
381
|
+
'so there is no scope to fan out to.');
|
|
382
|
+
}
|
|
383
|
+
function suggestWorkspaceNames(pattern, names) {
|
|
384
|
+
if (names.length === 0) {
|
|
385
|
+
return '';
|
|
386
|
+
}
|
|
387
|
+
const containing = names.filter((name) => name.toLowerCase().includes(pattern.toLowerCase()));
|
|
388
|
+
if (containing.length > 0) {
|
|
389
|
+
return ` Did you mean ${renderNames(containing)}?`;
|
|
390
|
+
}
|
|
391
|
+
const closest = findClosestName(pattern, names);
|
|
392
|
+
if (closest !== undefined) {
|
|
393
|
+
return ` Did you mean \`${closest}\`?`;
|
|
394
|
+
}
|
|
395
|
+
return ` The workspace declares ${renderNames(names)}.`;
|
|
396
|
+
}
|
|
397
|
+
function renderNames(names) {
|
|
398
|
+
const shown = names
|
|
399
|
+
.slice(0, NAME_CEILING)
|
|
400
|
+
.map((name) => `\`${name}\``)
|
|
401
|
+
.join(', ');
|
|
402
|
+
const remainder = names.length - NAME_CEILING;
|
|
403
|
+
return remainder > 0 ? `${shown}, and ${remainder} more` : shown;
|
|
404
|
+
}
|
|
349
405
|
function findNoOpReason(resolvedCommand) {
|
|
350
406
|
if (resolvedCommand === '') {
|
|
351
407
|
return 'empty-override';
|
|
@@ -446,7 +502,7 @@ function parseArgs(args) {
|
|
|
446
502
|
if (arg === '-F' || arg === '--filter') {
|
|
447
503
|
i++;
|
|
448
504
|
const filterValue = args[i];
|
|
449
|
-
if (filterValue
|
|
505
|
+
if (!filterValue) {
|
|
450
506
|
return { ok: false, error: '-F/--filter requires a pattern argument' };
|
|
451
507
|
}
|
|
452
508
|
parsed.filter = filterValue;
|
|
@@ -642,6 +698,18 @@ function resolveCacheKey(options) {
|
|
|
642
698
|
}
|
|
643
699
|
return result.key;
|
|
644
700
|
}
|
|
701
|
+
async function runDelegation(options) {
|
|
702
|
+
const { context, delegation, parsed, runOptions, stderr } = options;
|
|
703
|
+
const selection = parsed.filter === undefined ? undefined : readFilterSelection(parsed.filter, context.monorepoRoot);
|
|
704
|
+
const refusal = findEmptySelectionRefusal({ context, parsed, selection });
|
|
705
|
+
if (refusal !== undefined) {
|
|
706
|
+
reportError(refusal, stderr);
|
|
707
|
+
return { exitCode: 1 };
|
|
708
|
+
}
|
|
709
|
+
const shouldWithholdInput = parsed.filter === undefined || selection === 'multiple';
|
|
710
|
+
const step = shouldWithholdInput ? { ...delegation.step, shouldWithholdInput } : delegation.step;
|
|
711
|
+
return runSteps([step], context.monorepoRoot, { ...runOptions, env: delegation.env });
|
|
712
|
+
}
|
|
645
713
|
async function runGated(options) {
|
|
646
714
|
const { anchorDir, command, commandString, env, key, monorepoRoot, snapshot, stderr, stdout } = options;
|
|
647
715
|
const quiet = options.runOptions.quiet === true;
|
package/dist/esm/runner.js
CHANGED
|
@@ -39,16 +39,16 @@ export async function runSteps(steps, cwd, options) {
|
|
|
39
39
|
return { exitCode: 0, ...composeRetainedOutput(parts, isCaptureWhole) };
|
|
40
40
|
}
|
|
41
41
|
export async function runCommand(command, cwd, options) {
|
|
42
|
-
return runSpawned({ args: [], file: command, useShell: true }, cwd, options);
|
|
42
|
+
return runSpawned({ args: [], file: command, input: 'inherit', useShell: true }, cwd, options);
|
|
43
43
|
}
|
|
44
|
-
async function runSpawned({ args, file, useShell }, cwd, options) {
|
|
44
|
+
async function runSpawned({ args, file, input, useShell }, cwd, options) {
|
|
45
45
|
const quiet = options.quiet === true;
|
|
46
46
|
const stdout = options.stdout ?? process.stdout;
|
|
47
47
|
const stderr = options.stderr ?? process.stderr;
|
|
48
48
|
const env = options.env ?? process.env;
|
|
49
49
|
const child = spawn(file, args, {
|
|
50
50
|
shell: useShell,
|
|
51
|
-
stdio: [
|
|
51
|
+
stdio: [input, options.channels.stdout, options.channels.stderr],
|
|
52
52
|
cwd,
|
|
53
53
|
env,
|
|
54
54
|
});
|
|
@@ -78,13 +78,14 @@ async function runSpawned({ args, file, useShell }, cwd, options) {
|
|
|
78
78
|
function runStep(step, cwd, options) {
|
|
79
79
|
const { quiet, stderr, stdout } = options;
|
|
80
80
|
if (step.kind === 'opaque') {
|
|
81
|
-
return runSpawned({ args: [], file: step.command, useShell: true }, cwd, {
|
|
81
|
+
return runSpawned({ args: [], file: step.command, input: 'inherit', useShell: true }, cwd, {
|
|
82
82
|
...options,
|
|
83
83
|
channels: { stderr: resolveChannel(stderr, quiet), stdout: resolveChannel(stdout, quiet) },
|
|
84
84
|
});
|
|
85
85
|
}
|
|
86
86
|
const [file, ...args] = step.argv;
|
|
87
|
-
|
|
87
|
+
const input = step.shouldWithholdInput === true ? 'ignore' : 'inherit';
|
|
88
|
+
return runSpawned({ args, file, input, useShell: false }, cwd, {
|
|
88
89
|
...options,
|
|
89
90
|
quiet: false,
|
|
90
91
|
channels: { stderr: resolveInheritedChannel(stderr), stdout: resolveInheritedChannel(stdout) },
|
package/dist/esm/steps.d.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* `declinesArgs` travels from the composite element that composed the step to the one reader that acts on it,
|
|
8
8
|
* the binding of the invocation's trailing arguments. Every stage between the two -- the devBin substitution,
|
|
9
9
|
* the chain rendering, the replay assembly, the runner -- passes it through and asks nothing of it.
|
|
10
|
+
*
|
|
11
|
+
* `shouldWithholdInput` is read by the runner alone, which gives the step's child the null device as stdin rather
|
|
12
|
+
* than nmr's own. Like `declinesArgs`, it is set only where it holds and leaves the rendered chain unchanged.
|
|
10
13
|
*/
|
|
11
14
|
export type Step = {
|
|
12
15
|
kind: 'opaque';
|
|
@@ -15,6 +18,7 @@ export type Step = {
|
|
|
15
18
|
kind: 'structural';
|
|
16
19
|
argv: readonly [string, ...(readonly string[])];
|
|
17
20
|
declinesArgs?: boolean;
|
|
21
|
+
shouldWithholdInput?: boolean;
|
|
18
22
|
};
|
|
19
23
|
/** What a structural step asks of the nmr process it spawns. */
|
|
20
24
|
export interface NmrStepTarget {
|
package/dist/esm/types.d.ts
CHANGED
|
@@ -17,13 +17,14 @@ export interface CheckCacheConfig {
|
|
|
17
17
|
enabled?: boolean;
|
|
18
18
|
/**
|
|
19
19
|
* Command names removed from the cacheable set, applied after `extraCommands`. This is how a repo retires a
|
|
20
|
-
* name whose chain turned out to do more than report an exit status.
|
|
20
|
+
* name whose chain turned out to do more than report an exit status. Every name has to resolve to a command.
|
|
21
21
|
*/
|
|
22
22
|
excludeCommands?: string[];
|
|
23
23
|
/**
|
|
24
24
|
* Command names added to the cacheable set. Extends rather than replaces, so that declaring one command
|
|
25
25
|
* cannot silently drop the defaults. A name listed here promises exit-status-only semantics through its whole
|
|
26
|
-
* chain, hooks included
|
|
26
|
+
* chain, hooks included, and has to resolve to a command: both lists are read by name alone, so a misspelt
|
|
27
|
+
* entry would otherwise be inert.
|
|
27
28
|
*/
|
|
28
29
|
extraCommands?: string[];
|
|
29
30
|
}
|
package/dist/esm/verdict.d.ts
CHANGED
|
@@ -62,7 +62,7 @@ export declare function renderVerdict(verdict: Verdict): string;
|
|
|
62
62
|
* Held to the ceiling the prose line is held to, so one write still reaches a pipe whole where concurrent
|
|
63
63
|
* scopes share a descriptor. Cuts land inside the record's text rather than across its structure, which is
|
|
64
64
|
* what leaves the line parseable, and a record is fitted by rungs, each shedding what a reader can better
|
|
65
|
-
* spare than the rung below it. What the ceiling costs is [documented](../
|
|
65
|
+
* spare than the rung below it. What the ceiling costs is [documented](../docs/reporting.md#reporting-for-a-machine)
|
|
66
66
|
* in the same order.
|
|
67
67
|
*/
|
|
68
68
|
export declare function serializeVerdict(verdict: Verdict): string;
|
|
@@ -2,3 +2,9 @@ import os from 'node:os';
|
|
|
2
2
|
process.env['GIT_CONFIG_GLOBAL'] = os.devNull;
|
|
3
3
|
process.env['GIT_CONFIG_SYSTEM'] = os.devNull;
|
|
4
4
|
process.env['GIT_CONFIG_NOSYSTEM'] = '1';
|
|
5
|
+
process.env['GIT_ATTR_NOSYSTEM'] = '1';
|
|
6
|
+
process.env['GIT_CONFIG_COUNT'] = '2';
|
|
7
|
+
process.env['GIT_CONFIG_KEY_0'] = 'core.excludesFile';
|
|
8
|
+
process.env['GIT_CONFIG_VALUE_0'] = os.devNull;
|
|
9
|
+
process.env['GIT_CONFIG_KEY_1'] = 'core.attributesFile';
|
|
10
|
+
process.env['GIT_CONFIG_VALUE_1'] = os.devNull;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
/**
|
|
3
|
+
* Builds the plugin that resolves a package's `source` export condition, for a package whose files sit outside
|
|
4
|
+
* every `node_modules`.
|
|
5
|
+
*
|
|
6
|
+
* The condition cannot be emitted as a `resolve.conditions` entry instead: Vitest turns the server environment's
|
|
7
|
+
* conditions into `--conditions` flags on the worker process, where Node applies them to every package it
|
|
8
|
+
* resolves natively. Node refuses to strip types from a file under `node_modules`, so a dependency declaring a
|
|
9
|
+
* TypeScript `source` entry fails to load as soon as the condition reaches that far.
|
|
10
|
+
*
|
|
11
|
+
* Resolving here instead keeps the condition inside Vite, which transforms what it resolves. A package under
|
|
12
|
+
* `node_modules` resolves through its remaining conditions, whatever its `exports` map declares.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createSourceResolutionPlugin(): Plugin;
|
|
15
|
+
/** What a resolution needs beyond the specifier and its importer: the resolving environment, and the caches. */
|
|
16
|
+
export interface SourceResolutionContext {
|
|
17
|
+
/** The Vite environment resolving the specifier, which decides the environment-specific condition. */
|
|
18
|
+
environmentName: string;
|
|
19
|
+
/** Parsed manifests, keyed by package directory. Absent from a caller that resolves once. */
|
|
20
|
+
manifests?: Map<string, unknown>;
|
|
21
|
+
/** Package directories, keyed by specifier and importer directory. Absent from a caller that resolves once. */
|
|
22
|
+
packageDirs?: Map<string, string | undefined>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Resolves a bare specifier to the file that its package's `source` condition names, or nothing where the condition
|
|
26
|
+
* does not reach it.
|
|
27
|
+
*
|
|
28
|
+
* Answers a bare specifier naming a package whose real directory sits outside every `node_modules`. Every other
|
|
29
|
+
* specifier returns nothing -- such as a relative or absolute path, a virtual module, a builtin, or a package under
|
|
30
|
+
* `node_modules` -- so Vite resolves the specifier through its own conditions. Throws where the condition reaches no
|
|
31
|
+
* file, because falling through to the build output is the staleness that resolving from source exists to prevent,
|
|
32
|
+
* and nothing in a run reports it.
|
|
33
|
+
*
|
|
34
|
+
* A query or hash suffix is split off before the `exports` lookup and re-appended to the resolved path, as Vite
|
|
35
|
+
* does, so `pkg/icon.svg?raw` matches the `./icon.svg` entry and still reaches the plugin that reads the suffix.
|
|
36
|
+
*/
|
|
37
|
+
export declare function resolveSourceTarget(specifier: string, importer: string | undefined, context: SourceResolutionContext): string | undefined;
|