@williamthorsen/nmr 0.38.0 → 0.39.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 +84 -0
- package/.readyup/kits/default.js +84 -79
- package/.readyup/manifest.json +5 -5
- package/CHANGELOG.md +70 -0
- package/README.md +37 -11
- package/dist/esm/check-cache.d.ts +2 -2
- package/dist/esm/check-cache.js +24 -24
- package/dist/esm/cli-ensure-prepublish-hooks.js +4 -4
- package/dist/esm/commands/build-output.d.ts +2 -2
- package/dist/esm/commands/build-output.js +9 -9
- package/dist/esm/commands/build.js +65 -63
- package/dist/esm/commands/clean.js +11 -11
- package/dist/esm/commands/ensure-prepublish-hooks.d.ts +2 -2
- package/dist/esm/commands/ensure-prepublish-hooks.js +41 -41
- package/dist/esm/commands/fmt.js +33 -33
- package/dist/esm/commands/report-catalog.js +9 -7
- package/dist/esm/commands/report-overrides.js +7 -7
- package/dist/esm/config.js +25 -25
- package/dist/esm/context.js +8 -8
- package/dist/esm/help.d.ts +2 -2
- package/dist/esm/help.js +21 -21
- package/dist/esm/helpers/clampToBytes.js +3 -3
- package/dist/esm/helpers/createBoundedBuffer.d.ts +1 -1
- package/dist/esm/helpers/createBoundedBuffer.js +2 -2
- package/dist/esm/helpers/duration.d.ts +2 -2
- package/dist/esm/helpers/duration.js +7 -7
- package/dist/esm/helpers/filter-selection.js +12 -12
- package/dist/esm/helpers/findClosestName.js +15 -15
- package/dist/esm/helpers/package-json.d.ts +2 -2
- package/dist/esm/helpers/package-json.js +26 -25
- package/dist/esm/helpers/transcript.d.ts +2 -2
- package/dist/esm/helpers/transcript.js +14 -14
- package/dist/esm/prettier.js +5 -5
- package/dist/esm/recording.d.ts +5 -5
- package/dist/esm/recording.js +21 -14
- package/dist/esm/report-format.d.ts +1 -1
- package/dist/esm/report-format.js +7 -7
- package/dist/esm/resolveBinStyles.js +3 -3
- package/dist/esm/resolver.d.ts +2 -2
- package/dist/esm/resolver.js +15 -15
- package/dist/esm/runCli.js +252 -186
- package/dist/esm/runner.d.ts +1 -1
- package/dist/esm/runner.js +18 -18
- package/dist/esm/steps.d.ts +13 -1
- package/dist/esm/steps.js +32 -27
- package/dist/esm/tests.d.ts +7 -7
- package/dist/esm/tests.js +20 -8
- package/dist/esm/tiers.d.ts +1 -1
- package/dist/esm/tiers.js +8 -8
- package/dist/esm/verbosity.d.ts +1 -1
- package/dist/esm/verbosity.js +7 -7
- package/dist/esm/verdict.d.ts +9 -2
- package/dist/esm/verdict.js +63 -50
- package/dist/esm/vitest-source-resolution.js +48 -46
- package/dist/esm/vitest.d.ts +2 -2
- package/dist/esm/vitest.js +74 -25
- package/dist/esm/workspace.d.ts +2 -18
- package/dist/esm/workspace.js +9 -67
- package/docs/reporting.md +6 -2
- package/docs/vitest.md +6 -6
- package/docs/workspace.md +24 -1
- package/package.json +3 -4
- package/dist/esm/helpers/workspace-patterns.d.ts +0 -8
- package/dist/esm/helpers/workspace-patterns.js +0 -29
package/README.md
CHANGED
|
@@ -5,24 +5,50 @@
|
|
|
5
5
|
Context-aware script runner for pnpm monorepos. Ships an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
|
|
6
6
|
|
|
7
7
|
<!-- section:release-notes -->
|
|
8
|
-
## Release notes — v0.
|
|
8
|
+
## Release notes — v0.39.0 (2026-09-22)
|
|
9
9
|
|
|
10
10
|
### 🎉 Features
|
|
11
11
|
|
|
12
|
-
-
|
|
12
|
+
- Report why a workspace selects no package, and no-op nmr's own -R steps (#841)
|
|
13
13
|
|
|
14
|
-
-
|
|
15
|
-
- Adds
|
|
16
|
-
- Replaces
|
|
14
|
+
- Drops a `-R` step that nmr composed into a script where the workspace holds no package, so `typecheck` and the five `test` scripts run there instead of failing the run.
|
|
15
|
+
- Adds a no-op verdict for a command that the drop leaves with no step at all.
|
|
16
|
+
- Replaces the single refusal for a workspace that selects no package with one message per cause.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Migration: In a repo that declares a workspace package pattern and deliberately holds no package, delete the `.config/nmr.config.ts` entries added to work around the `-R` refusal, the `build: ':'` override included.
|
|
19
19
|
|
|
20
|
-
- 🚨 **Breaking:**
|
|
20
|
+
- 🚨 **Breaking:** Prefix the booleans and reject unrecognized Vitest option keys (#849)
|
|
21
21
|
|
|
22
|
-
-
|
|
23
|
-
-
|
|
22
|
+
- Renames two boolean options of `defineVitestConfig` and `defineRootVitestConfig`, the factories that build a repo's Vitest config: `isolateGit` becomes `shouldIsolateGit`, and `resolveFromSource` becomes `shouldResolveFromSource`.
|
|
23
|
+
- Makes both factories reject an option key outside the recognized set, naming the offending keys and the recognized ones, rather than ignoring a misspelled or retired key and letting its option fall back to the default with nothing in the run reporting it.
|
|
24
|
+
- Adds a retired-key check that runs ahead of the unrecognized-key check, reporting the new name for a config that still sets `isolateGit` or `resolveFromSource`.
|
|
24
25
|
|
|
25
|
-
Migration:
|
|
26
|
+
Migration: In each options object passed to `defineVitestConfig` or `defineRootVitestConfig`, rename `isolateGit` to `shouldIsolateGit` and `resolveFromSource` to `shouldResolveFromSource`, and correct any other key that the factory now rejects. `defineRootVitestConfig` recognizes `monorepoRoot` on the last layer alone, so a config that sets it on an earlier layer now throws where the old code ignored it.
|
|
27
|
+
|
|
28
|
+
- 🚨 **Breaking:** End nmr's source value names with the kind of the value (#850)
|
|
29
|
+
|
|
30
|
+
- Renames two fields of `TestFileConventionsReport`, `misplaced` and `untiered`, to `misplacedFiles` and `untieredFiles`, and renames `TestFileConventionsOptions`'s `exclude` to `excludedBasenames`, in the `@williamthorsen/nmr/tests` entry point.
|
|
31
|
+
- Adds a guard to `checkTestFileConventions` and `reportTestFileConventions` that throws a `TypeError` naming `excludedBasenames` when a call still sets `exclude`.
|
|
32
|
+
|
|
33
|
+
Migration: Read `report.misplacedFiles` and `report.untieredFiles` in place of `report.misplaced` and `report.untiered`, and pass `excludedBasenames` in place of `exclude`. A call that still sets `exclude` throws rather than running with no exclusions, so the failure surfaces where Vitest loads the call site.
|
|
34
|
+
|
|
35
|
+
- 🚨 **Breaking:** Hoist the pnpm-workspace resolver into nmr-core as a total API (#864)
|
|
36
|
+
|
|
37
|
+
- Adds a `./workspace` subpath export to `@williamthorsen/nmr-core`, whose `resolveWorkspace` returns a pnpm workspace's package directories, or the cause of an empty result, as a value rather than as a thrown error.
|
|
38
|
+
- Reports a `pnpm-workspace.yaml` that the YAML parser rejects under a cause of its own, `unreadable-manifest`, so `nmr -R build` names the syntax error and its remedy in place of failing with the parser's own error.
|
|
39
|
+
- Removes `diagnoseEmptyWorkspace` and `EmptyWorkspaceDiagnosis` from `@williamthorsen/nmr/workspace`, since `resolveWorkspace` reports the same cause in one call.
|
|
40
|
+
|
|
41
|
+
Migration: Replace each `diagnoseEmptyWorkspace` call with `resolveWorkspace`, which reports the cause on the empty result it already returns, and rename the `no-manifest` cause to `no-package`. `EmptyWorkspaceCause` also gains `unreadable-manifest`, so an exhaustive switch over it stops compiling until it handles the new member; every other export of `@williamthorsen/nmr/workspace` keeps its signature and its `UserError` throws.
|
|
42
|
+
|
|
43
|
+
- 🚨 **Breaking:** Adopt the shared workspace resolver in release-kit, honoring exclusions (#865)
|
|
44
|
+
|
|
45
|
+
- 🚨 **Breaking:** Narrows `no-pattern` in `@williamthorsen/nmr-core`'s `EmptyWorkspaceCause`, moving an absent or empty `packages` list onto `no-packages-list` and a value that is not a list of strings onto `unreadable-packages`.
|
|
46
|
+
- Stops `release-kit` from tagging, publishing, and label-syncing a package that a `!` entry in `pnpm-workspace.yaml` excludes, which its previous `glob`-based resolution could not remove from the match.
|
|
47
|
+
- Replaces the release of the root package alone with a failure naming the condition that emptied the workspace, when `pnpm-workspace.yaml` does not hold valid YAML or declares a workspace that does not resolve to a package.
|
|
48
|
+
- Replaces the `no-pattern` remedy that `nmr` printed for a `pnpm-workspace.yaml` declaring no `packages` list, and for one declaring a `packages` value that is not a list of strings.
|
|
49
|
+
- Raises `engines.node` to `>=24.16.0` in `@williamthorsen/nmr-core` and `@williamthorsen/release-kit`, because Node 24.0 through 24.15 ignores the `followSymlinks` option that the workspace matcher passes to `globSync` and drops a symlinked package directory from the resolution without reporting it.
|
|
50
|
+
|
|
51
|
+
Migration: Add a `no-packages-list` arm and an `unreadable-packages` arm to any `switch` that covers `EmptyWorkspaceCause` exhaustively. `no-pattern` now names only a list that the resolver read in full and whose entries all failed to become a positive pattern, so move the handling of an absent `packages` key, an empty list, and a value that is not a list of strings out of the `no-pattern` arm into the two new ones. Route `no-packages-list` to single-package behavior rather than to a failure path: pnpm resolves such a manifest to the root package alone.
|
|
26
52
|
<!-- /section:release-notes -->
|
|
27
53
|
|
|
28
54
|
## Installation
|
|
@@ -163,7 +189,7 @@ Every command nmr runs reports one line naming the scope it ran at, the command,
|
|
|
163
189
|
|
|
164
190
|
**A pipe and a CI log get words rather than emoji.** The same four lines read `PASS`, `FAIL`, `SKIP`, and `NOOP` there, which `grep FAIL` finds. `--output-style` and `NMR_OUTPUT_STYLE` each force either style.
|
|
165
191
|
|
|
166
|
-
**Verdicts print in every verbosity.** `-q` withholds the output of the commands nmr runs, never nmr's own words: a passing quiet run reports its verdicts and nothing else. A command
|
|
192
|
+
**Verdicts print in every verbosity.** `-q` withholds the output of the commands nmr runs, never nmr's own words: a passing quiet run reports its verdicts and nothing else. A command that ran nothing reports the skip rather than exiting 0 in silence, which is what separates it from a command that passed: an override resolving to `""` or `":"`, and a fan-out step that a package-free workspace left nothing to do. An `NMR_RUN_IF_PRESENT` miss reports nothing, having no command to report on.
|
|
167
193
|
|
|
168
194
|
[What nmr reports](docs/reporting.md) covers how verdicts nest, the `--json` format, the output style, and where the verbosity of a run comes from.
|
|
169
195
|
|
|
@@ -53,7 +53,7 @@ export interface Retention {
|
|
|
53
53
|
/** What nmr's own build has left on disk across the workspace. */
|
|
54
54
|
export interface BuildOutputState {
|
|
55
55
|
/** Covered packages whose output is absent. */
|
|
56
|
-
|
|
56
|
+
missingPackages: string[];
|
|
57
57
|
/** The digest of the inputs each covered package's output was built from, keyed by package name. */
|
|
58
58
|
digests: Record<string, string>;
|
|
59
59
|
}
|
|
@@ -154,7 +154,7 @@ export declare function encodeTreeSnapshot(snapshot: TreeSnapshot): string;
|
|
|
154
154
|
* package agrees. A package that has appeared or disappeared between them counts as a disagreement, because
|
|
155
155
|
* the output the earlier observation describes is not the output the later one found.
|
|
156
156
|
*/
|
|
157
|
-
export declare function findStaleBuildOutput(
|
|
157
|
+
export declare function findStaleBuildOutput(earlierDigests: Record<string, string>, laterDigests: Record<string, string>): string | undefined;
|
|
158
158
|
/**
|
|
159
159
|
* Renders the warning for a `--no-cache` that landed after the command name, where it is an argument to the
|
|
160
160
|
* command rather than a flag to nmr. Passing it on unchanged is the honest thing to do with an argument, so
|
package/dist/esm/check-cache.js
CHANGED
|
@@ -103,9 +103,9 @@ export function computeRetentionKey(options) {
|
|
|
103
103
|
export function encodeTreeSnapshot(snapshot) {
|
|
104
104
|
return `${snapshot.hash} ${snapshot.headSha}`;
|
|
105
105
|
}
|
|
106
|
-
export function findStaleBuildOutput(
|
|
107
|
-
const names = [...new Set([...Object.keys(
|
|
108
|
-
return names.find((name) =>
|
|
106
|
+
export function findStaleBuildOutput(earlierDigests, laterDigests) {
|
|
107
|
+
const names = [...new Set([...Object.keys(earlierDigests), ...Object.keys(laterDigests)])].toSorted();
|
|
108
|
+
return names.find((name) => earlierDigests[name] !== laterDigests[name]);
|
|
109
109
|
}
|
|
110
110
|
export function formatMisplacedNoCacheWarning(command, style) {
|
|
111
111
|
return (`${STATUS_GLYPHS[style].warning.text} --no-cache after the command name is passed to \`${command}\`, ` +
|
|
@@ -115,7 +115,7 @@ export function isCacheableCommand(checkCache, command) {
|
|
|
115
115
|
return !isHookName(command) && checkCache?.enabled !== false && resolveCacheableCommands(checkCache).has(command);
|
|
116
116
|
}
|
|
117
117
|
export async function readBuildOutputState(monorepoRoot, config) {
|
|
118
|
-
const state = {
|
|
118
|
+
const state = { missingPackages: [], digests: {} };
|
|
119
119
|
let packageDirs;
|
|
120
120
|
try {
|
|
121
121
|
packageDirs = getWorkspacePackageDirs(monorepoRoot);
|
|
@@ -138,17 +138,17 @@ export async function readBuildOutputState(monorepoRoot, config) {
|
|
|
138
138
|
state.digests[name] = (await readBuildDigest(packageDir)) ?? '';
|
|
139
139
|
}
|
|
140
140
|
else {
|
|
141
|
-
state.
|
|
141
|
+
state.missingPackages.push(name);
|
|
142
142
|
}
|
|
143
143
|
}
|
|
144
144
|
return state;
|
|
145
145
|
}
|
|
146
146
|
export async function readCheckCacheEntry(options) {
|
|
147
|
-
const
|
|
148
|
-
if (
|
|
147
|
+
const parsedEntry = await readJsonCacheEntry(resolveEntryPath(options), isParsedCheckCacheEntry);
|
|
148
|
+
if (parsedEntry === undefined) {
|
|
149
149
|
return undefined;
|
|
150
150
|
}
|
|
151
|
-
const { retention, ...pass } =
|
|
151
|
+
const { retention, ...pass } = parsedEntry;
|
|
152
152
|
return isRetention(retention) ? { ...pass, retention } : pass;
|
|
153
153
|
}
|
|
154
154
|
export async function readTranscript(ref) {
|
|
@@ -168,23 +168,23 @@ export async function removeCheckCache(scopeDir) {
|
|
|
168
168
|
export function resolveCacheableCommands(checkCache) {
|
|
169
169
|
const commands = new Set([...DEFAULT_CACHEABLE_COMMANDS, ...(checkCache?.extraCommands ?? [])]);
|
|
170
170
|
const excludedCommands = checkCache?.excludeCommands ?? [];
|
|
171
|
-
for (const
|
|
172
|
-
commands.delete(
|
|
171
|
+
for (const excludedCommand of excludedCommands) {
|
|
172
|
+
commands.delete(excludedCommand);
|
|
173
173
|
}
|
|
174
174
|
return commands;
|
|
175
175
|
}
|
|
176
176
|
export function resolveRunId(env) {
|
|
177
|
-
const
|
|
178
|
-
return
|
|
177
|
+
const inheritedRunId = env[RUN_ID_ENV_VAR];
|
|
178
|
+
return inheritedRunId === undefined || inheritedRunId === '' ? randomUUID() : inheritedRunId;
|
|
179
179
|
}
|
|
180
180
|
export function resolveTreeSnapshot(options) {
|
|
181
|
-
const
|
|
182
|
-
if (
|
|
183
|
-
return { ok: true, snapshot:
|
|
181
|
+
const inheritedSnapshot = decodeTreeSnapshot(options.env[TREE_SNAPSHOT_ENV_VAR]);
|
|
182
|
+
if (inheritedSnapshot !== undefined && readHeadSha(options.monorepoRoot) === inheritedSnapshot.headSha) {
|
|
183
|
+
return { ok: true, snapshot: inheritedSnapshot };
|
|
184
184
|
}
|
|
185
|
-
const
|
|
186
|
-
if (!
|
|
187
|
-
return
|
|
185
|
+
const hashedTree = hashWorkingTree(options.monorepoRoot);
|
|
186
|
+
if (!hashedTree.ok) {
|
|
187
|
+
return hashedTree;
|
|
188
188
|
}
|
|
189
189
|
let physicalRoot;
|
|
190
190
|
try {
|
|
@@ -193,10 +193,10 @@ export function resolveTreeSnapshot(options) {
|
|
|
193
193
|
catch {
|
|
194
194
|
return { ok: false, reason: `could not resolve ${options.monorepoRoot}` };
|
|
195
195
|
}
|
|
196
|
-
if (
|
|
197
|
-
return { ok: false, reason: `the monorepo root is not the git toplevel (${
|
|
196
|
+
if (hashedTree.toplevel !== physicalRoot) {
|
|
197
|
+
return { ok: false, reason: `the monorepo root is not the git toplevel (${hashedTree.toplevel})` };
|
|
198
198
|
}
|
|
199
|
-
return { ok: true, snapshot: { hash:
|
|
199
|
+
return { ok: true, snapshot: { hash: hashedTree.hash, headSha: hashedTree.headSha } };
|
|
200
200
|
}
|
|
201
201
|
export async function writeCheckCacheEntry(options) {
|
|
202
202
|
await writeCacheEntry(resolveEntryPath(options), JSON.stringify(options.entry, undefined, 2));
|
|
@@ -212,11 +212,11 @@ function composeEnvParts(names, env) {
|
|
|
212
212
|
return [name, value === undefined ? 'unset' : 'set', value ?? ''];
|
|
213
213
|
});
|
|
214
214
|
}
|
|
215
|
-
function decodeTreeSnapshot(
|
|
216
|
-
if (
|
|
215
|
+
function decodeTreeSnapshot(encodedSnapshot) {
|
|
216
|
+
if (encodedSnapshot === undefined) {
|
|
217
217
|
return undefined;
|
|
218
218
|
}
|
|
219
|
-
const [hash, headSha] =
|
|
219
|
+
const [hash, headSha] = encodedSnapshot.split(' ', 2);
|
|
220
220
|
if (hash === undefined || headSha === undefined || hash === '' || headSha === '') {
|
|
221
221
|
return undefined;
|
|
222
222
|
}
|
|
@@ -4,15 +4,15 @@ import { DEFAULT_HOOK, ensurePrepublishHooks, reportPrepublishHooks } from "./co
|
|
|
4
4
|
import { resolveBinStyles } from "./resolveBinStyles.js";
|
|
5
5
|
import { findMonorepoRoot } from "./workspace.js";
|
|
6
6
|
const flagSchema = {
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
shouldFix: { long: '--fix', type: 'boolean' },
|
|
8
|
+
isDryRun: { long: '--dry-run', type: 'boolean' },
|
|
9
9
|
command: { long: '--command', type: 'string' },
|
|
10
10
|
};
|
|
11
|
-
const {
|
|
11
|
+
const { shouldFix, isDryRun, command } = parseArgsOrExit(process.argv.slice(2), flagSchema).flags;
|
|
12
12
|
try {
|
|
13
13
|
const { stdout } = resolveBinStyles();
|
|
14
14
|
const monorepoRoot = findMonorepoRoot();
|
|
15
|
-
const options = command ? {
|
|
15
|
+
const options = command ? { shouldFix, isDryRun, command } : { shouldFix, isDryRun };
|
|
16
16
|
const result = ensurePrepublishHooks(monorepoRoot, options);
|
|
17
17
|
reportPrepublishHooks(result, command ?? DEFAULT_HOOK, stdout);
|
|
18
18
|
if (result.hasFailures) {
|
|
@@ -8,8 +8,8 @@ export interface BuildOptions {
|
|
|
8
8
|
}
|
|
9
9
|
/** The pair of directories a build publishes through, both siblings of the emit directory. */
|
|
10
10
|
export interface ScratchDirs {
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
previousDir: string;
|
|
12
|
+
stagingDir: string;
|
|
13
13
|
}
|
|
14
14
|
export declare const DEFAULT_ENTRY_GLOBS: string[];
|
|
15
15
|
/**
|
|
@@ -17,8 +17,8 @@ export async function hasBuildOutput(packageDir, options = {}) {
|
|
|
17
17
|
return hasExpectedBuildOutput(packageDir, outdir, entryPoints);
|
|
18
18
|
}
|
|
19
19
|
export function hasExpectedBuildOutput(packageDir, outdir, entryPoints) {
|
|
20
|
-
const
|
|
21
|
-
if (!
|
|
20
|
+
const doesEmitOutput = entryPoints.some((entry) => !entry.endsWith('.d.ts'));
|
|
21
|
+
if (!doesEmitOutput) {
|
|
22
22
|
return true;
|
|
23
23
|
}
|
|
24
24
|
const outputDir = path.resolve(packageDir, outdir);
|
|
@@ -40,8 +40,8 @@ export function resolveScratchDirs(emitDir) {
|
|
|
40
40
|
const parent = path.dirname(emitDir);
|
|
41
41
|
const name = path.basename(emitDir);
|
|
42
42
|
return {
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
previousDir: path.join(parent, `.${name}.previous`),
|
|
44
|
+
stagingDir: path.join(parent, `.${name}.staging`),
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
export async function resolveToolchainFingerprint(packageDir, selfDir = SELF_DIR) {
|
|
@@ -54,14 +54,14 @@ export async function resolveToolchainFingerprint(packageDir, selfDir = SELF_DIR
|
|
|
54
54
|
return readPackageJson(selfDir).version ?? '';
|
|
55
55
|
}
|
|
56
56
|
function isSameDir(left, right) {
|
|
57
|
-
return
|
|
57
|
+
return resolveRealPath(left) === resolveRealPath(right);
|
|
58
58
|
}
|
|
59
|
-
function
|
|
60
|
-
const
|
|
59
|
+
function resolveRealPath(dir) {
|
|
60
|
+
const absoluteDir = path.resolve(dir);
|
|
61
61
|
try {
|
|
62
|
-
return realpathSync(
|
|
62
|
+
return realpathSync(absoluteDir);
|
|
63
63
|
}
|
|
64
64
|
catch {
|
|
65
|
-
return
|
|
65
|
+
return absoluteDir;
|
|
66
66
|
}
|
|
67
67
|
}
|
|
@@ -60,18 +60,18 @@ export function resolveTsconfigChain(packageDir, configFileName = 'tsconfig.json
|
|
|
60
60
|
const resolvedChain = [];
|
|
61
61
|
const seen = new Set();
|
|
62
62
|
function walk(configPath) {
|
|
63
|
-
const
|
|
64
|
-
if (seen.has(
|
|
63
|
+
const normalizedPath = path.resolve(configPath);
|
|
64
|
+
if (seen.has(normalizedPath)) {
|
|
65
65
|
return;
|
|
66
66
|
}
|
|
67
|
-
seen.add(
|
|
68
|
-
resolvedChain.push(
|
|
69
|
-
const configFile = ts.readConfigFile(
|
|
67
|
+
seen.add(normalizedPath);
|
|
68
|
+
resolvedChain.push(normalizedPath);
|
|
69
|
+
const configFile = ts.readConfigFile(normalizedPath, (fileName) => ts.sys.readFile(fileName));
|
|
70
70
|
if (configFile.error) {
|
|
71
|
-
throw new Error(`nmr-compile: failed to read ${
|
|
71
|
+
throw new Error(`nmr-compile: failed to read ${normalizedPath}.\n${formatDiagnostics([configFile.error])}`);
|
|
72
72
|
}
|
|
73
73
|
for (const entry of normalizeExtendsField(configFile.config)) {
|
|
74
|
-
walk(resolveExtendsTarget(entry,
|
|
74
|
+
walk(resolveExtendsTarget(entry, normalizedPath));
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
walk(path.resolve(packageDir, configFileName));
|
|
@@ -84,26 +84,26 @@ async function emitPackage(packageDir, entryPoints, outdir) {
|
|
|
84
84
|
const emitDir = resolveEmitDir(packageDir, outdir);
|
|
85
85
|
const scratchDirs = resolveScratchDirs(emitDir);
|
|
86
86
|
await removeScratchDirs(scratchDirs);
|
|
87
|
-
const
|
|
87
|
+
const emittedFiles = new Map();
|
|
88
88
|
function collect(fileName, text, writeByteOrderMark) {
|
|
89
|
-
|
|
89
|
+
emittedFiles.set(fileName, { text, writeByteOrderMark });
|
|
90
90
|
}
|
|
91
91
|
const host = createCachingCompilerHost(compilerOptions);
|
|
92
92
|
const scriptProgram = ts.createProgram(rootNames, { ...compilerOptions, declaration: false }, host);
|
|
93
93
|
assertEmitSucceeded(scriptProgram.emit(undefined, collect));
|
|
94
94
|
const declarationProgram = ts.createProgram(rootNames, { ...compilerOptions, emitDeclarationOnly: true, removeComments: false }, host, scriptProgram);
|
|
95
95
|
assertEmitSucceeded(declarationProgram.emit(undefined, collect));
|
|
96
|
-
const
|
|
97
|
-
for (const [fileName, file] of
|
|
98
|
-
|
|
96
|
+
const stagedFiles = new Map();
|
|
97
|
+
for (const [fileName, file] of emittedFiles) {
|
|
98
|
+
stagedFiles.set(fileName, { ...file, text: rewriteSpecifiers(fileName, file.text, compilerOptions, sourceRoot) });
|
|
99
99
|
}
|
|
100
|
-
if (
|
|
100
|
+
if (stagedFiles.size === 0) {
|
|
101
101
|
await rm(emitDir, { force: true, recursive: true });
|
|
102
102
|
return 0;
|
|
103
103
|
}
|
|
104
|
-
writeStagedOutput(
|
|
104
|
+
writeStagedOutput(stagedFiles, emitDir, scratchDirs.stagingDir);
|
|
105
105
|
await swapIntoPlace(emitDir, scratchDirs);
|
|
106
|
-
return
|
|
106
|
+
return stagedFiles.size;
|
|
107
107
|
}
|
|
108
108
|
function assertEmitSucceeded(result) {
|
|
109
109
|
if (result.emitSkipped) {
|
|
@@ -112,14 +112,14 @@ function assertEmitSucceeded(result) {
|
|
|
112
112
|
}
|
|
113
113
|
function createCachingCompilerHost(compilerOptions) {
|
|
114
114
|
const host = ts.createCompilerHost(compilerOptions);
|
|
115
|
-
const
|
|
115
|
+
const parsedFiles = new Map();
|
|
116
116
|
const readSourceFile = host.getSourceFile.bind(host);
|
|
117
117
|
host.getSourceFile = (fileName, languageVersionOrOptions, onError, shouldCreateNewSourceFile) => {
|
|
118
|
-
if (
|
|
119
|
-
return
|
|
118
|
+
if (parsedFiles.has(fileName)) {
|
|
119
|
+
return parsedFiles.get(fileName);
|
|
120
120
|
}
|
|
121
121
|
const sourceFile = readSourceFile(fileName, languageVersionOrOptions, onError, shouldCreateNewSourceFile);
|
|
122
|
-
|
|
122
|
+
parsedFiles.set(fileName, sourceFile);
|
|
123
123
|
return sourceFile;
|
|
124
124
|
};
|
|
125
125
|
return host;
|
|
@@ -132,29 +132,31 @@ async function discardScratchDirs(scratchDirs) {
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
async function removeScratchDirs(scratchDirs) {
|
|
135
|
-
await rm(scratchDirs.
|
|
136
|
-
await rm(scratchDirs.
|
|
135
|
+
await rm(scratchDirs.previousDir, { force: true, recursive: true });
|
|
136
|
+
await rm(scratchDirs.stagingDir, { force: true, recursive: true });
|
|
137
137
|
}
|
|
138
138
|
function resolveEmitDir(packageDir, outdir) {
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
139
|
+
const resolvedDir = path.resolve(packageDir, outdir);
|
|
140
|
+
const relativePathFromPackage = path.relative(packageDir, resolvedDir);
|
|
141
|
+
if (relativePathFromPackage === '' ||
|
|
142
|
+
relativePathFromPackage.startsWith('..') ||
|
|
143
|
+
path.isAbsolute(relativePathFromPackage)) {
|
|
142
144
|
throw new Error(`nmr-compile: refusing to build into '${outdir}', which does not resolve inside the package. ` +
|
|
143
145
|
'The build replaces its output directory on each emit, so the directory must sit below the package root.');
|
|
144
146
|
}
|
|
145
|
-
return
|
|
147
|
+
return resolvedDir;
|
|
146
148
|
}
|
|
147
149
|
async function swapIntoPlace(emitDir, scratchDirs) {
|
|
148
150
|
const hadPreviousOutput = existsSync(emitDir);
|
|
149
151
|
if (hadPreviousOutput) {
|
|
150
|
-
await rename(emitDir, scratchDirs.
|
|
152
|
+
await rename(emitDir, scratchDirs.previousDir);
|
|
151
153
|
}
|
|
152
154
|
try {
|
|
153
|
-
await rename(scratchDirs.
|
|
155
|
+
await rename(scratchDirs.stagingDir, emitDir);
|
|
154
156
|
}
|
|
155
157
|
catch (error) {
|
|
156
158
|
if (hadPreviousOutput && !existsSync(emitDir)) {
|
|
157
|
-
await rename(scratchDirs.
|
|
159
|
+
await rename(scratchDirs.previousDir, emitDir);
|
|
158
160
|
}
|
|
159
161
|
throw error;
|
|
160
162
|
}
|
|
@@ -166,13 +168,13 @@ function synthesizeCompilerOptions(packageDir, outdir) {
|
|
|
166
168
|
if (configFile.error) {
|
|
167
169
|
throw new Error(`nmr-compile: failed to read ${configPath}.\n${formatDiagnostics([configFile.error])}`);
|
|
168
170
|
}
|
|
169
|
-
const
|
|
170
|
-
if (
|
|
171
|
-
throw new Error(`nmr-compile: failed to parse ${configPath}.\n${formatDiagnostics(
|
|
171
|
+
const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, packageDir);
|
|
172
|
+
if (parsedConfig.errors.length > 0) {
|
|
173
|
+
throw new Error(`nmr-compile: failed to parse ${configPath}.\n${formatDiagnostics(parsedConfig.errors)}`);
|
|
172
174
|
}
|
|
173
175
|
const resolvedOutDir = path.resolve(packageDir, outdir);
|
|
174
176
|
return {
|
|
175
|
-
...
|
|
177
|
+
...parsedConfig.options,
|
|
176
178
|
noEmit: false,
|
|
177
179
|
emitDeclarationOnly: false,
|
|
178
180
|
declaration: true,
|
|
@@ -185,8 +187,8 @@ function synthesizeCompilerOptions(packageDir, outdir) {
|
|
|
185
187
|
noEmitOnError: false,
|
|
186
188
|
};
|
|
187
189
|
}
|
|
188
|
-
function writeStagedOutput(
|
|
189
|
-
for (const [fileName, file] of
|
|
190
|
+
function writeStagedOutput(stagedFiles, emitDir, stagingDir) {
|
|
191
|
+
for (const [fileName, file] of stagedFiles) {
|
|
190
192
|
if (!isWithin(emitDir, fileName)) {
|
|
191
193
|
throw new Error(`nmr-compile: the compiler emitted ${fileName}, which is outside the output directory ${emitDir}. ` +
|
|
192
194
|
"Verify the resolved tsconfig's 'outDir' and 'declarationDir'.");
|
|
@@ -198,57 +200,57 @@ function rewriteSpecifiers(outputFile, text, compilerOptions, sourceRoot) {
|
|
|
198
200
|
if (!isRewritableOutput(outputFile)) {
|
|
199
201
|
return text;
|
|
200
202
|
}
|
|
201
|
-
const sourceFile = ts.createSourceFile(outputFile, text, ts.ScriptTarget.Latest, false,
|
|
203
|
+
const sourceFile = ts.createSourceFile(outputFile, text, ts.ScriptTarget.Latest, false, resolveScriptKind(outputFile));
|
|
202
204
|
const aliasPrefixes = collectAliasPrefixes(compilerOptions);
|
|
203
205
|
const sourceContainingFile = mapOutputToSource(outputFile, compilerOptions, sourceRoot);
|
|
204
206
|
const edits = [];
|
|
205
|
-
|
|
207
|
+
visitModuleSpecifiers(sourceFile, (literal) => {
|
|
206
208
|
const replacement = resolveSpecifierReplacement(literal.text, sourceContainingFile, compilerOptions, sourceRoot, aliasPrefixes);
|
|
207
209
|
if (replacement === undefined) {
|
|
208
210
|
return;
|
|
209
211
|
}
|
|
210
|
-
const
|
|
211
|
-
const quote = text[
|
|
212
|
-
edits.push({
|
|
212
|
+
const startOffset = literal.getStart(sourceFile);
|
|
213
|
+
const quote = text[startOffset] ?? '"';
|
|
214
|
+
edits.push({ startOffset, endOffset: literal.getEnd(), text: `${quote}${replacement}${quote}` });
|
|
213
215
|
});
|
|
214
216
|
if (edits.length === 0) {
|
|
215
217
|
return text;
|
|
216
218
|
}
|
|
217
|
-
const orderedEdits = [...edits].sort((a, b) => b.
|
|
219
|
+
const orderedEdits = [...edits].sort((a, b) => b.startOffset - a.startOffset);
|
|
218
220
|
let updatedText = text;
|
|
219
221
|
for (const edit of orderedEdits) {
|
|
220
|
-
updatedText = updatedText.slice(0, edit.
|
|
222
|
+
updatedText = updatedText.slice(0, edit.startOffset) + edit.text + updatedText.slice(edit.endOffset);
|
|
221
223
|
}
|
|
222
224
|
return updatedText;
|
|
223
225
|
}
|
|
224
226
|
function resolveSpecifierReplacement(specifier, sourceContainingFile, compilerOptions, sourceRoot, aliasPrefixes) {
|
|
225
227
|
if (isRelativeSpecifier(specifier)) {
|
|
226
|
-
const
|
|
227
|
-
return
|
|
228
|
+
const rewrittenSpecifier = swapTypeScriptExtension(specifier);
|
|
229
|
+
return rewrittenSpecifier === specifier ? undefined : rewrittenSpecifier;
|
|
228
230
|
}
|
|
229
231
|
if (aliasPrefixes.every((prefix) => !(specifier === prefix || specifier.startsWith(prefix)))) {
|
|
230
232
|
return undefined;
|
|
231
233
|
}
|
|
232
|
-
const
|
|
233
|
-
if (!
|
|
234
|
+
const resolvedModule = ts.resolveModuleName(specifier, sourceContainingFile, compilerOptions, ts.sys).resolvedModule;
|
|
235
|
+
if (!resolvedModule) {
|
|
234
236
|
throw new Error(`nmr-compile: could not resolve aliased import '${specifier}' from ${sourceContainingFile}. ` +
|
|
235
237
|
`Verify the tsconfig 'paths' mapping and that the target file exists.`);
|
|
236
238
|
}
|
|
237
|
-
if (!isWithin(sourceRoot,
|
|
239
|
+
if (!isWithin(sourceRoot, resolvedModule.resolvedFileName)) {
|
|
238
240
|
const { paths: _paths, baseUrl: _baseUrl, rootDirs: _rootDirs, ...nodeResolutionOptions } = compilerOptions;
|
|
239
|
-
const
|
|
240
|
-
if (
|
|
241
|
+
const bareResolvedModule = ts.resolveModuleName(specifier, sourceContainingFile, nodeResolutionOptions, ts.sys).resolvedModule;
|
|
242
|
+
if (bareResolvedModule) {
|
|
241
243
|
return undefined;
|
|
242
244
|
}
|
|
243
245
|
throw new Error(`nmr-compile: aliased import '${specifier}' from ${sourceContainingFile} resolves to ` +
|
|
244
|
-
`${
|
|
246
|
+
`${resolvedModule.resolvedFileName}, outside the package source root ${sourceRoot}, and does not resolve ` +
|
|
245
247
|
`the way Node will at runtime, which ignores tsconfig 'paths', 'baseUrl', and 'rootDirs'. The ` +
|
|
246
248
|
`emitted specifier would fail at runtime; re-anchor the alias inside the package.`);
|
|
247
249
|
}
|
|
248
|
-
const
|
|
249
|
-
return swapTypeScriptExtension(
|
|
250
|
+
const relativeSpecifier = buildRelativeSpecifier(path.dirname(sourceContainingFile), resolvedModule.resolvedFileName);
|
|
251
|
+
return swapTypeScriptExtension(relativeSpecifier);
|
|
250
252
|
}
|
|
251
|
-
function
|
|
253
|
+
function visitModuleSpecifiers(sourceFile, visit) {
|
|
252
254
|
function walk(node) {
|
|
253
255
|
const specifier = getModuleSpecifier(node);
|
|
254
256
|
if (specifier !== undefined) {
|
|
@@ -274,12 +276,12 @@ function getModuleSpecifier(node) {
|
|
|
274
276
|
}
|
|
275
277
|
return undefined;
|
|
276
278
|
}
|
|
277
|
-
async function detectBuildChanges(packageDir, files, emitConfig, toolchain, cachePath,
|
|
279
|
+
async function detectBuildChanges(packageDir, files, emitConfig, toolchain, cachePath, isOutputPresent, style) {
|
|
278
280
|
const packageName = path.basename(packageDir);
|
|
279
281
|
const previousHash = await readCacheEntry(cachePath);
|
|
280
282
|
const currentHash = await computeBuildHash(packageDir, files, emitConfig, toolchain);
|
|
281
283
|
if (previousHash === currentHash) {
|
|
282
|
-
if (
|
|
284
|
+
if (isOutputPresent) {
|
|
283
285
|
console.info(`${STATUS_GLYPHS[style].skipped.text} ${packageName}: No changes detected. Skipping build.`);
|
|
284
286
|
return { changed: false, currentHash };
|
|
285
287
|
}
|
|
@@ -346,12 +348,12 @@ function resolveExtendsTarget(extendsEntry, fromConfigPath) {
|
|
|
346
348
|
}
|
|
347
349
|
throw new Error(`nmr-compile: ${fromConfigPath} extends '${extendsEntry}', which does not exist.`);
|
|
348
350
|
}
|
|
349
|
-
const
|
|
351
|
+
const resolvedPath = resolvePackageSpecifier(extendsEntry, fromConfigPath) ??
|
|
350
352
|
resolvePackageSpecifier(`${extendsEntry}/tsconfig.json`, fromConfigPath);
|
|
351
|
-
if (
|
|
353
|
+
if (resolvedPath === undefined) {
|
|
352
354
|
throw new Error(`nmr-compile: ${fromConfigPath} extends '${extendsEntry}', which does not resolve to a file.`);
|
|
353
355
|
}
|
|
354
|
-
return
|
|
356
|
+
return resolvedPath;
|
|
355
357
|
}
|
|
356
358
|
function resolvePackageSpecifier(specifier, fromConfigPath) {
|
|
357
359
|
const { resolvedModule } = ts.resolveModuleName(specifier, fromConfigPath, { moduleResolution: ts.ModuleResolutionKind.NodeNext, resolveJsonModule: true }, ts.sys);
|
|
@@ -361,8 +363,8 @@ function isRelativeSpecifier(specifier) {
|
|
|
361
363
|
return specifier.startsWith('./') || specifier.startsWith('../');
|
|
362
364
|
}
|
|
363
365
|
function isWithin(parent, child) {
|
|
364
|
-
const
|
|
365
|
-
return
|
|
366
|
+
const relativePath = path.relative(parent, child);
|
|
367
|
+
return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
|
|
366
368
|
}
|
|
367
369
|
function mapOutputToSource(outputFile, compilerOptions, sourceRoot) {
|
|
368
370
|
const outDir = compilerOptions.outDir ?? path.dirname(outputFile);
|
|
@@ -370,11 +372,11 @@ function mapOutputToSource(outputFile, compilerOptions, sourceRoot) {
|
|
|
370
372
|
const withoutExtension = relativeFromOut.replace(/\.d\.ts$|\.js$/, '');
|
|
371
373
|
return path.join(sourceRoot, `${withoutExtension}.ts`);
|
|
372
374
|
}
|
|
373
|
-
function
|
|
374
|
-
const
|
|
375
|
-
return
|
|
375
|
+
function buildRelativeSpecifier(fromDir, targetFile) {
|
|
376
|
+
const relativePath = path.relative(fromDir, targetFile).split(path.sep).join('/');
|
|
377
|
+
return relativePath.startsWith('.') ? relativePath : `./${relativePath}`;
|
|
376
378
|
}
|
|
377
|
-
function
|
|
379
|
+
function resolveScriptKind(file) {
|
|
378
380
|
return file.endsWith('.d.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS;
|
|
379
381
|
}
|
|
380
382
|
function swapTypeScriptExtension(specifier) {
|
|
@@ -42,26 +42,26 @@ async function clearCheckCache(scopeDir, style) {
|
|
|
42
42
|
await removeCheckCache(scopeDir);
|
|
43
43
|
console.info(formatGlyphLine(NMR_GLYPHS, style, 'clean', 'Removed all recorded check results.'));
|
|
44
44
|
}
|
|
45
|
-
function describeSweep(
|
|
46
|
-
const packages =
|
|
47
|
-
const skipClause =
|
|
45
|
+
function describeSweep(cleanedCount, skippedCount) {
|
|
46
|
+
const packages = cleanedCount === 1 ? '1 package' : `${cleanedCount} packages`;
|
|
47
|
+
const skipClause = skippedCount === 0 ? '' : `, skipping ${skippedCount} with an empty clean override`;
|
|
48
48
|
return `Cleaned ${packages}${skipClause}.`;
|
|
49
49
|
}
|
|
50
50
|
async function sweepWorkspace(monorepoRoot, workspacePackageDirs, style) {
|
|
51
51
|
const config = await loadRootConfig(monorepoRoot);
|
|
52
52
|
const registry = buildWorkspaceRegistry(config);
|
|
53
|
-
let
|
|
54
|
-
let
|
|
53
|
+
let cleanedCount = 0;
|
|
54
|
+
let skippedCount = 0;
|
|
55
55
|
for (const packageDir of workspacePackageDirs) {
|
|
56
|
-
const
|
|
57
|
-
const resolvedCommand =
|
|
56
|
+
const resolvedScript = resolveScript(CLEAN_COMMAND, registry, packageDir, false);
|
|
57
|
+
const resolvedCommand = resolvedScript === undefined ? undefined : renderChain(resolvedScript.steps);
|
|
58
58
|
if (resolvedCommand === undefined || resolvedCommand === '') {
|
|
59
|
-
|
|
59
|
+
skippedCount++;
|
|
60
60
|
continue;
|
|
61
61
|
}
|
|
62
62
|
if (resolvedCommand === BUILT_IN_CLEAN) {
|
|
63
63
|
await cleanPackage(packageDir, style);
|
|
64
|
-
|
|
64
|
+
cleanedCount++;
|
|
65
65
|
continue;
|
|
66
66
|
}
|
|
67
67
|
const command = applyDevBin(resolvedCommand, config.devBin, monorepoRoot);
|
|
@@ -71,7 +71,7 @@ async function sweepWorkspace(monorepoRoot, workspacePackageDirs, style) {
|
|
|
71
71
|
if (exitCode !== 0) {
|
|
72
72
|
throw new Error(`nmr-clean: \`${command}\` failed in ${path.basename(packageDir)} with exit code ${exitCode}.`);
|
|
73
73
|
}
|
|
74
|
-
|
|
74
|
+
cleanedCount++;
|
|
75
75
|
}
|
|
76
|
-
reportClosing(formatGlyphLine(NMR_GLYPHS, style, 'clean', describeSweep(
|
|
76
|
+
reportClosing(formatGlyphLine(NMR_GLYPHS, style, 'clean', describeSweep(cleanedCount, skippedCount)));
|
|
77
77
|
}
|
|
@@ -16,8 +16,8 @@ export declare const DEFAULT_HOOK = "npm run build";
|
|
|
16
16
|
* have a `prepublishOnly` script.
|
|
17
17
|
*/
|
|
18
18
|
export declare function ensurePrepublishHooks(monorepoRoot: string, options: {
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
shouldFix: boolean;
|
|
20
|
+
isDryRun: boolean;
|
|
21
21
|
command?: string;
|
|
22
22
|
}): EnsurePrepublishHooksResult;
|
|
23
23
|
/**
|