@williamthorsen/nmr 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.readyup/kits/default.js +56 -16
  2. package/.readyup/manifest.json +34 -3
  3. package/README.md +265 -64
  4. package/agents/guidance/rulebooks/nmr.md +5 -2
  5. package/bin/nmr-report-catalog.js +11 -0
  6. package/dist/esm/UserError.d.ts +3 -0
  7. package/dist/esm/UserError.js +6 -0
  8. package/dist/esm/check-cache.d.ts +36 -1
  9. package/dist/esm/check-cache.js +91 -23
  10. package/dist/esm/cli-build.js +2 -1
  11. package/dist/esm/cli-clean.js +2 -1
  12. package/dist/esm/cli-ensure-prepublish-hooks.js +4 -24
  13. package/dist/esm/cli-report-catalog.d.ts +1 -0
  14. package/dist/esm/cli-report-catalog.js +11 -0
  15. package/dist/esm/cli-report-overrides.js +2 -1
  16. package/dist/esm/cli-taze.js +2 -1
  17. package/dist/esm/cli.js +2 -1
  18. package/dist/esm/commands/build-output.d.ts +1 -0
  19. package/dist/esm/commands/build-output.js +25 -2
  20. package/dist/esm/commands/build.d.ts +5 -1
  21. package/dist/esm/commands/build.js +22 -8
  22. package/dist/esm/commands/clean.js +12 -0
  23. package/dist/esm/commands/ensure-prepublish-hooks.d.ts +1 -0
  24. package/dist/esm/commands/ensure-prepublish-hooks.js +43 -0
  25. package/dist/esm/commands/report-catalog.d.ts +1 -0
  26. package/dist/esm/commands/report-catalog.js +40 -0
  27. package/dist/esm/commands/report-overrides.js +30 -7
  28. package/dist/esm/commands/taze.js +2 -1
  29. package/dist/esm/config.d.ts +1 -0
  30. package/dist/esm/config.js +66 -18
  31. package/dist/esm/default-scripts.d.ts +5 -1
  32. package/dist/esm/default-scripts.js +13 -10
  33. package/dist/esm/defineConfig.d.ts +1 -1
  34. package/dist/esm/help.js +3 -1
  35. package/dist/esm/helpers/clampToBytes.d.ts +2 -0
  36. package/dist/esm/helpers/clampToBytes.js +20 -0
  37. package/dist/esm/helpers/createBoundedBuffer.js +1 -4
  38. package/dist/esm/helpers/deriveExcerpt.d.ts +1 -0
  39. package/dist/esm/helpers/deriveExcerpt.js +30 -0
  40. package/dist/esm/helpers/duration.js +16 -9
  41. package/dist/esm/helpers/package-json.d.ts +11 -4
  42. package/dist/esm/helpers/package-json.js +51 -22
  43. package/dist/esm/helpers/readStringValues.d.ts +1 -0
  44. package/dist/esm/helpers/readStringValues.js +8 -0
  45. package/dist/esm/helpers/reportClosing.d.ts +1 -0
  46. package/dist/esm/helpers/reportClosing.js +3 -0
  47. package/dist/esm/helpers/transcript.d.ts +5 -0
  48. package/dist/esm/helpers/transcript.js +59 -0
  49. package/dist/esm/recording.d.ts +65 -0
  50. package/dist/esm/recording.js +96 -0
  51. package/dist/esm/replay-assembly.d.ts +9 -0
  52. package/dist/esm/replay-assembly.js +26 -0
  53. package/dist/esm/report-format.d.ts +18 -0
  54. package/dist/esm/report-format.js +25 -0
  55. package/dist/esm/reportCliFailure.d.ts +2 -0
  56. package/dist/esm/reportCliFailure.js +11 -0
  57. package/dist/esm/resolve-scripts.d.ts +1 -1
  58. package/dist/esm/resolver.d.ts +9 -1
  59. package/dist/esm/resolver.js +28 -18
  60. package/dist/esm/runCli.js +339 -74
  61. package/dist/esm/runner.d.ts +9 -3
  62. package/dist/esm/runner.js +25 -4
  63. package/dist/esm/scripts.d.ts +1 -1
  64. package/dist/esm/steps.d.ts +9 -2
  65. package/dist/esm/steps.js +117 -4
  66. package/dist/esm/types.d.ts +11 -2
  67. package/dist/esm/verbosity.d.ts +15 -4
  68. package/dist/esm/verbosity.js +32 -12
  69. package/dist/esm/verdict.d.ts +29 -0
  70. package/dist/esm/verdict.js +150 -0
  71. package/dist/esm/workspace.d.ts +2 -0
  72. package/dist/esm/workspace.js +20 -3
  73. package/package.json +14 -5
@@ -1,4 +1,5 @@
1
1
  import type { Writable } from 'node:stream';
2
+ import type { OutputChannels } from './runner.js';
2
3
  import type { CheckCacheConfig, NmrConfig } from './types.js';
3
4
  export interface CheckCacheEntry {
4
5
  key: string;
@@ -10,6 +11,22 @@ export interface CheckCacheEntry {
10
11
  durationMs: number;
11
12
  recordedAt: string;
12
13
  buildDigests: Record<string, string>;
14
+ retention?: Retention;
15
+ }
16
+ export interface EntryRef {
17
+ anchorDir: string;
18
+ command: string;
19
+ monorepoRoot: string;
20
+ }
21
+ export interface ReplayLine {
22
+ command: string;
23
+ excerpt: string;
24
+ scope: string;
25
+ }
26
+ export interface Retention {
27
+ key: string;
28
+ replay: ReplayLine[];
29
+ runId: string;
13
30
  }
14
31
  export interface BuildOutputState {
15
32
  missing: string[];
@@ -28,7 +45,17 @@ export declare const CURRENT_RUNTIME: RuntimeIdentity;
28
45
  export declare const DEBUG_ENV_VAR = "NMR_DEBUG";
29
46
  export declare const DEFAULT_CACHEABLE_COMMANDS: string[];
30
47
  export declare const NO_CACHE_ENV_VAR = "NMR_NO_CACHE";
48
+ export declare const RUN_ID_ENV_VAR = "NMR_RUN_ID";
31
49
  export declare const TREE_SNAPSHOT_ENV_VAR = "NMR_TREE_SNAPSHOT";
50
+ export declare function certifyRetention(options: {
51
+ anchorDir: string;
52
+ command: string;
53
+ entry: CheckCacheEntry;
54
+ env: NodeJS.ProcessEnv;
55
+ monorepoRoot: string;
56
+ runId: string;
57
+ stderr: Writable;
58
+ }): Promise<void>;
32
59
  export declare function computeCacheKey(options: {
33
60
  anchorDir: string;
34
61
  command: string;
@@ -45,18 +72,26 @@ export declare function computeCacheKey(options: {
45
72
  ok: false;
46
73
  reason: string;
47
74
  };
75
+ export declare function computeRetentionKey(options: {
76
+ channels: OutputChannels;
77
+ env: NodeJS.ProcessEnv;
78
+ passKey: string;
79
+ }): string;
48
80
  export declare function encodeTreeSnapshot(snapshot: TreeSnapshot): string;
49
81
  export declare function findStaleBuildOutput(earlier: Record<string, string>, later: Record<string, string>): string | undefined;
50
82
  export declare function formatMisplacedNoCacheWarning(command: string): string;
51
- export declare function formatSkipLine(command: string, entry: CheckCacheEntry, now: number): string;
83
+ export declare function isCacheableCommand(checkCache: CheckCacheConfig | undefined, command: string): boolean;
52
84
  export declare function readBuildOutputState(monorepoRoot: string, config: NmrConfig): Promise<BuildOutputState>;
53
85
  export declare function readCheckCacheEntry(options: {
54
86
  anchorDir: string;
55
87
  command: string;
56
88
  monorepoRoot: string;
57
89
  }): Promise<CheckCacheEntry | undefined>;
90
+ export declare function readTranscript(ref: EntryRef): Promise<string | undefined>;
91
+ export declare function recordTranscript(ref: EntryRef, transcript: string | undefined): Promise<void>;
58
92
  export declare function removeCheckCache(scopeDir: string): Promise<void>;
59
93
  export declare function resolveCacheableCommands(checkCache: CheckCacheConfig | undefined): Set<string>;
94
+ export declare function resolveRunId(env: NodeJS.ProcessEnv): string;
60
95
  export declare function resolveTreeSnapshot(options: {
61
96
  monorepoRoot: string;
62
97
  env: NodeJS.ProcessEnv;
@@ -1,11 +1,12 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { readFileSync, realpathSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import process from 'node:process';
5
- import { hashWorkingTree, readHeadSha, readJsonCacheEntry, removeCacheDir, resolveCacheEntryPath, writeCacheEntry, } from '@williamthorsen/nmr-core';
5
+ import { hashWorkingTree, readCacheEntry, readHeadSha, readJsonCacheEntry, removeCacheDir, removeCacheEntry, resolveCacheEntryPath, writeCacheEntry, } from '@williamthorsen/nmr-core';
6
+ import { describeError } from '@williamthorsen/toolbelt.errors';
6
7
  import { hasBuildOutput, readBuildDigest } from "./commands/build-output.js";
7
8
  import { loadWorkspaceConfig } from "./config.js";
8
- import { formatDuration, formatSaving } from "./helpers/duration.js";
9
+ import { isHookName } from "./helpers/hook-name.js";
9
10
  import { isObject, isStringRecord } from "./helpers/type-guards.js";
10
11
  import { getDefaultWorkspaceScripts } from "./resolve-scripts.js";
11
12
  import { buildWorkspaceRegistry, resolveScript } from "./resolver.js";
@@ -39,6 +40,7 @@ export const DEFAULT_CACHEABLE_COMMANDS = [
39
40
  'typecheck',
40
41
  ];
41
42
  export const NO_CACHE_ENV_VAR = 'NMR_NO_CACHE';
43
+ export const RUN_ID_ENV_VAR = 'NMR_RUN_ID';
42
44
  export const TREE_SNAPSHOT_ENV_VAR = 'NMR_TREE_SNAPSHOT';
43
45
  const BUILT_IN_COMPILE = 'nmr-compile';
44
46
  const CACHE_TOOL = 'nmr-check';
@@ -48,7 +50,26 @@ const INSTALL_FINGERPRINT_FILES = [
48
50
  ];
49
51
  const KEY_FORMAT = 'nmr-check-cache-v1';
50
52
  const KEYED_ENV_VARS = ['LANG', 'LC_ALL', 'NODE_OPTIONS', 'TZ'];
53
+ const RETENTION_KEY_FORMAT = 'nmr-retention-v1';
54
+ const RETENTION_KEYED_ENV_VARS = ['CI', 'COLUMNS', 'FORCE_COLOR', 'NO_COLOR', 'TERM'];
51
55
  const UNSAFE_SLUG_CHARACTERS = /[^\w.-]+/g;
56
+ export async function certifyRetention(options) {
57
+ const { entry, runId } = options;
58
+ if (entry.retention === undefined || entry.retention.runId === runId) {
59
+ return;
60
+ }
61
+ try {
62
+ await writeCheckCacheEntry({
63
+ anchorDir: options.anchorDir,
64
+ command: options.command,
65
+ monorepoRoot: options.monorepoRoot,
66
+ entry: { ...entry, retention: { ...entry.retention, runId } },
67
+ });
68
+ }
69
+ catch (error) {
70
+ writeDebugNote(`could not certify ${options.command}: ${describeError(error)}`, options.env, options.stderr);
71
+ }
72
+ }
52
73
  export function computeCacheKey(options) {
53
74
  const fingerprint = resolveInstallFingerprint(options.monorepoRoot);
54
75
  if (!fingerprint.ok) {
@@ -68,16 +89,16 @@ export function computeCacheKey(options) {
68
89
  runtime.arch,
69
90
  fingerprint.fingerprint,
70
91
  ];
71
- for (const name of KEYED_ENV_VARS) {
72
- const value = options.env[name];
73
- parts.push(name, value === undefined ? 'unset' : 'set', value ?? '');
74
- }
75
- const hash = createHash('sha256');
76
- for (const part of parts) {
77
- hash.update(part);
78
- hash.update('\0');
79
- }
80
- return { ok: true, key: hash.digest('hex') };
92
+ return { ok: true, key: digestParts([...parts, ...composeEnvParts(KEYED_ENV_VARS, options.env)]) };
93
+ }
94
+ export function computeRetentionKey(options) {
95
+ const parts = [
96
+ RETENTION_KEY_FORMAT,
97
+ options.passKey,
98
+ describeChannel(options.channels.stdout),
99
+ describeChannel(options.channels.stderr),
100
+ ];
101
+ return digestParts([...parts, ...composeEnvParts(RETENTION_KEYED_ENV_VARS, options.env)]);
81
102
  }
82
103
  export function encodeTreeSnapshot(snapshot) {
83
104
  return `${snapshot.hash} ${snapshot.headSha}`;
@@ -90,11 +111,8 @@ export function formatMisplacedNoCacheWarning(command) {
90
111
  return (`⚠️ --no-cache after the command name is passed to \`${command}\`, not read by nmr. ` +
91
112
  `Did you mean \`nmr --no-cache ${command}\`?`);
92
113
  }
93
- export function formatSkipLine(command, entry, now) {
94
- const age = formatDuration(Math.max(0, now - Date.parse(entry.recordedAt)));
95
- const saving = formatSaving(entry.durationMs);
96
- const savingClause = saving === undefined ? '' : ` (${saving})`;
97
- return `⏭️ ${command}: passed ${age} ago on this tree${savingClause}.`;
114
+ export function isCacheableCommand(checkCache, command) {
115
+ return !isHookName(command) && checkCache?.enabled !== false && resolveCacheableCommands(checkCache).has(command);
98
116
  }
99
117
  export async function readBuildOutputState(monorepoRoot, config) {
100
118
  const state = { missing: [], digests: {} };
@@ -126,7 +144,23 @@ export async function readBuildOutputState(monorepoRoot, config) {
126
144
  return state;
127
145
  }
128
146
  export async function readCheckCacheEntry(options) {
129
- return readJsonCacheEntry(resolveEntryPath(options), isCheckCacheEntry);
147
+ const parsed = await readJsonCacheEntry(resolveEntryPath(options), isParsedCheckCacheEntry);
148
+ if (parsed === undefined) {
149
+ return undefined;
150
+ }
151
+ const { retention, ...pass } = parsed;
152
+ return isRetention(retention) ? { ...pass, retention } : pass;
153
+ }
154
+ export async function readTranscript(ref) {
155
+ return readCacheEntry(resolveEntryPath(ref, '.log'));
156
+ }
157
+ export async function recordTranscript(ref, transcript) {
158
+ const entryPath = resolveEntryPath(ref, '.log');
159
+ if (transcript === undefined) {
160
+ await removeCacheEntry(entryPath);
161
+ return;
162
+ }
163
+ await writeCacheEntry(entryPath, transcript);
130
164
  }
131
165
  export async function removeCheckCache(scopeDir) {
132
166
  await removeCacheDir({ tool: CACHE_TOOL, scopeDir });
@@ -139,6 +173,10 @@ export function resolveCacheableCommands(checkCache) {
139
173
  }
140
174
  return commands;
141
175
  }
176
+ export function resolveRunId(env) {
177
+ const inherited = env[RUN_ID_ENV_VAR];
178
+ return inherited === undefined || inherited === '' ? randomUUID() : inherited;
179
+ }
142
180
  export function resolveTreeSnapshot(options) {
143
181
  const inherited = decodeTreeSnapshot(options.env[TREE_SNAPSHOT_ENV_VAR]);
144
182
  if (inherited !== undefined && readHeadSha(options.monorepoRoot) === inherited.headSha) {
@@ -168,6 +206,12 @@ export function writeDebugNote(message, env, stderr) {
168
206
  stderr.write(`nmr check-cache: ${message}\n`);
169
207
  }
170
208
  }
209
+ function composeEnvParts(names, env) {
210
+ return names.flatMap((name) => {
211
+ const value = env[name];
212
+ return [name, value === undefined ? 'unset' : 'set', value ?? ''];
213
+ });
214
+ }
171
215
  function decodeTreeSnapshot(encoded) {
172
216
  if (encoded === undefined) {
173
217
  return undefined;
@@ -178,7 +222,18 @@ function decodeTreeSnapshot(encoded) {
178
222
  }
179
223
  return { hash, headSha };
180
224
  }
181
- function isCheckCacheEntry(value) {
225
+ function describeChannel(channel) {
226
+ return channel === 'pipe' ? 'pipe' : 'descriptor';
227
+ }
228
+ function digestParts(parts) {
229
+ const hash = createHash('sha256');
230
+ for (const part of parts) {
231
+ hash.update(part);
232
+ hash.update('\0');
233
+ }
234
+ return hash.digest('hex');
235
+ }
236
+ function isParsedCheckCacheEntry(value) {
182
237
  if (!isObject(value)) {
183
238
  return false;
184
239
  }
@@ -193,19 +248,32 @@ function isCheckCacheEntry(value) {
193
248
  }
194
249
  function isProbeSubject(packageDir, registry) {
195
250
  const build = resolveScript('build', registry, packageDir, false);
196
- if (build === undefined || build.source === 'package') {
251
+ if (build === undefined || build.origin.tier === 'package') {
197
252
  return false;
198
253
  }
199
254
  const compile = resolveScript('compile', registry, packageDir, false);
200
255
  return compile !== undefined && renderChain(compile.steps) === BUILT_IN_COMPILE;
201
256
  }
202
- function resolveEntryPath(options) {
257
+ function isReplayLine(value) {
258
+ return (isObject(value) &&
259
+ typeof value['command'] === 'string' &&
260
+ typeof value['excerpt'] === 'string' &&
261
+ typeof value['scope'] === 'string');
262
+ }
263
+ function isRetention(value) {
264
+ return (isObject(value) &&
265
+ typeof value['key'] === 'string' &&
266
+ typeof value['runId'] === 'string' &&
267
+ Array.isArray(value['replay']) &&
268
+ value['replay'].every((line) => isReplayLine(line)));
269
+ }
270
+ function resolveEntryPath(options, extension = '.json') {
203
271
  const anchorDir = path.resolve(options.anchorDir);
204
272
  const ref = {
205
273
  tool: CACHE_TOOL,
206
274
  scopeDir: options.monorepoRoot,
207
275
  slug: `${path.basename(anchorDir)}-${options.command.replaceAll(UNSAFE_SLUG_CHARACTERS, '-')}`,
208
- extension: '.json',
276
+ extension,
209
277
  discriminators: [anchorDir, options.command],
210
278
  };
211
279
  return resolveCacheEntryPath(ref);
@@ -1,4 +1,5 @@
1
1
  import { reportError } from '@williamthorsen/nmr-core';
2
+ import { describeError } from '@williamthorsen/toolbelt.errors';
2
3
  import { buildPackage } from "./commands/build.js";
3
4
  import { loadWorkspaceConfig } from "./config.js";
4
5
  try {
@@ -9,6 +10,6 @@ try {
9
10
  });
10
11
  }
11
12
  catch (error) {
12
- reportError(error instanceof Error ? error.message : String(error));
13
+ reportError(describeError(error));
13
14
  process.exitCode = 1;
14
15
  }
@@ -1,9 +1,10 @@
1
1
  import { reportError } from '@williamthorsen/nmr-core';
2
+ import { describeError } from '@williamthorsen/toolbelt.errors';
2
3
  import { runClean } from "./commands/clean.js";
3
4
  try {
4
5
  await runClean(process.cwd());
5
6
  }
6
7
  catch (error) {
7
- reportError(error instanceof Error ? error.message : String(error));
8
+ reportError(describeError(error));
8
9
  process.exitCode = 1;
9
10
  }
@@ -1,5 +1,6 @@
1
1
  import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
2
- import { DEFAULT_HOOK, ensurePrepublishHooks } from "./commands/ensure-prepublish-hooks.js";
2
+ import { describeError } from '@williamthorsen/toolbelt.errors';
3
+ import { DEFAULT_HOOK, ensurePrepublishHooks, reportPrepublishHooks } from "./commands/ensure-prepublish-hooks.js";
3
4
  import { findMonorepoRoot } from "./workspace.js";
4
5
  const flagSchema = {
5
6
  fix: { long: '--fix', type: 'boolean' },
@@ -9,35 +10,14 @@ const flagSchema = {
9
10
  const { fix, dryRun, command } = parseArgsOrExit(process.argv.slice(2), flagSchema).flags;
10
11
  try {
11
12
  const monorepoRoot = findMonorepoRoot();
12
- const hookCommand = command ?? DEFAULT_HOOK;
13
13
  const options = command ? { fix, dryRun, command } : { fix, dryRun };
14
14
  const result = ensurePrepublishHooks(monorepoRoot, options);
15
- for (const pkg of result.packages) {
16
- if (pkg.isPrivate) {
17
- continue;
18
- }
19
- switch (pkg.action) {
20
- case 'ok':
21
- console.info(`✓ ${pkg.packageName}: prepublishOnly = "${pkg.prepublishOnly}"`);
22
- break;
23
- case 'missing':
24
- console.warn(`✗ ${pkg.packageName}: missing prepublishOnly`);
25
- break;
26
- case 'fixed':
27
- console.info(`✓ ${pkg.packageName}: added prepublishOnly = "${hookCommand}"`);
28
- break;
29
- case 'would-fix':
30
- console.info(`~ ${pkg.packageName}: would add prepublishOnly = "${hookCommand}"`);
31
- break;
32
- }
33
- }
15
+ reportPrepublishHooks(result, command ?? DEFAULT_HOOK);
34
16
  if (result.hasFailures) {
35
- const missing = result.packages.filter((p) => p.action === 'missing').length;
36
- process.stderr.write(`\n${missing} package(s) missing prepublishOnly. Use --fix to add it.\n`);
37
17
  process.exitCode = 1;
38
18
  }
39
19
  }
40
20
  catch (error) {
41
- reportError(error instanceof Error ? error.message : String(error));
21
+ reportError(describeError(error));
42
22
  process.exitCode = 1;
43
23
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import process from 'node:process';
2
+ import { reportError } from '@williamthorsen/nmr-core';
3
+ import { describeError } from '@williamthorsen/toolbelt.errors';
4
+ import { reportCatalog } from "./commands/report-catalog.js";
5
+ try {
6
+ reportCatalog(process.cwd());
7
+ }
8
+ catch (error) {
9
+ reportError(describeError(error));
10
+ process.exitCode = 1;
11
+ }
@@ -1,4 +1,5 @@
1
1
  import { reportError } from '@williamthorsen/nmr-core';
2
+ import { describeError } from '@williamthorsen/toolbelt.errors';
2
3
  import { reportOverrides } from "./commands/report-overrides.js";
3
4
  import { findMonorepoRoot } from "./workspace.js";
4
5
  try {
@@ -6,6 +7,6 @@ try {
6
7
  reportOverrides(monorepoRoot);
7
8
  }
8
9
  catch (error) {
9
- reportError(error instanceof Error ? error.message : String(error));
10
+ reportError(describeError(error));
10
11
  process.exitCode = 1;
11
12
  }
@@ -1,10 +1,11 @@
1
1
  import process from 'node:process';
2
2
  import { reportError } from '@williamthorsen/nmr-core';
3
+ import { describeError } from '@williamthorsen/toolbelt.errors';
3
4
  import { runTaze } from "./commands/taze.js";
4
5
  try {
5
6
  process.exitCode = runTaze(process.argv.slice(2));
6
7
  }
7
8
  catch (error) {
8
- reportError(error instanceof Error ? error.message : String(error));
9
+ reportError(describeError(error));
9
10
  process.exitCode = 1;
10
11
  }
package/dist/esm/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import process from 'node:process';
2
+ import { reportCliFailure } from "./reportCliFailure.js";
2
3
  import { runCli } from "./runCli.js";
3
4
  try {
4
5
  const { exitCode } = await runCli({
@@ -11,6 +12,6 @@ try {
11
12
  process.exitCode = exitCode;
12
13
  }
13
14
  catch (error) {
14
- process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
15
+ reportCliFailure(error, process.stderr);
15
16
  process.exitCode = 1;
16
17
  }
@@ -16,3 +16,4 @@ export declare function hasExpectedBuildOutput(packageDir: string, outdir: strin
16
16
  export declare function readBuildDigest(packageDir: string): Promise<string | undefined>;
17
17
  export declare function resolveBuildCachePath(packageDir: string): string;
18
18
  export declare function resolveScratchDirs(emitDir: string): ScratchDirs;
19
+ export declare function resolveToolchainFingerprint(packageDir: string, selfDir?: string): Promise<string>;
@@ -1,11 +1,13 @@
1
- import { existsSync, readdirSync } from 'node:fs';
1
+ import { existsSync, readdirSync, realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { readCacheEntry, resolveCacheEntryPath } from '@williamthorsen/nmr-core';
3
+ import { findPackageRoot, readCacheEntry, resolveCacheEntryPath } from '@williamthorsen/nmr-core';
4
4
  import { glob } from 'glob';
5
+ import { readPackageJson } from "../helpers/package-json.js";
5
6
  export const DEFAULT_ENTRY_GLOBS = ['src/**/*.ts'];
6
7
  export const DEFAULT_IGNORE_PATTERNS = ['**/__fixtures__/**', '**/__mocks__/**', '**/__tests__/**', '**/test-utils/**'];
7
8
  export const DEFAULT_OUTDIR = 'dist/esm/';
8
9
  const BUILD_CACHE_TOOL = 'nmr-compile';
10
+ const SELF_DIR = findPackageRoot(import.meta.url);
9
11
  export async function hasBuildOutput(packageDir, options = {}) {
10
12
  const outdir = options.outdir ?? DEFAULT_OUTDIR;
11
13
  const entryPoints = await glob(options.entryGlobs ?? DEFAULT_ENTRY_GLOBS, {
@@ -42,3 +44,24 @@ export function resolveScratchDirs(emitDir) {
42
44
  staging: path.join(parent, `.${name}.staging`),
43
45
  };
44
46
  }
47
+ export async function resolveToolchainFingerprint(packageDir, selfDir = SELF_DIR) {
48
+ if (!isSameDir(packageDir, selfDir)) {
49
+ const digest = await readBuildDigest(selfDir);
50
+ if (digest !== undefined) {
51
+ return digest;
52
+ }
53
+ }
54
+ return readPackageJson(selfDir).version ?? '';
55
+ }
56
+ function isSameDir(left, right) {
57
+ return toRealPath(left) === toRealPath(right);
58
+ }
59
+ function toRealPath(dir) {
60
+ const absolute = path.resolve(dir);
61
+ try {
62
+ return realpathSync(absolute);
63
+ }
64
+ catch {
65
+ return absolute;
66
+ }
67
+ }
@@ -1,4 +1,8 @@
1
1
  import type { BuildOptions } from './build-output.js';
2
+ export interface BuildToolchain {
3
+ compilerVersion: string;
4
+ fingerprint: string;
5
+ }
2
6
  export declare function buildPackage(packageDir: string, options?: BuildOptions): Promise<void>;
3
- export declare function computeBuildHash(packageDir: string, files: string[], emitConfig: object, compilerVersion: string): Promise<string>;
7
+ export declare function computeBuildHash(packageDir: string, files: string[], emitConfig: object, toolchain: BuildToolchain): Promise<string>;
4
8
  export declare function resolveTsconfigChain(packageDir: string, configFileName?: string): string[];
@@ -6,7 +6,7 @@ import { readCacheEntry, writeCacheEntry } from '@williamthorsen/nmr-core';
6
6
  import { glob } from 'glob';
7
7
  import * as ts from 'typescript';
8
8
  import { resolveConfigPath } from "../config.js";
9
- import { DEFAULT_ENTRY_GLOBS, DEFAULT_IGNORE_PATTERNS, DEFAULT_OUTDIR, hasExpectedBuildOutput, resolveBuildCachePath, resolveScratchDirs, } from "./build-output.js";
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 = '⏭️';
12
12
  const SOURCE_ROOT = 'src';
@@ -30,15 +30,20 @@ export async function buildPackage(packageDir, options = {}) {
30
30
  ...(existsSync(configPath) ? [path.relative(packageDir, configPath)] : []),
31
31
  ...resolveTsconfigChain(packageDir),
32
32
  ];
33
- const { changed, currentHash } = await detectBuildChanges(packageDir, [...entryPoints, ...dependencies], emitConfig, ts.version, cachePath, hasExpectedBuildOutput(packageDir, outdir, entryPoints));
33
+ const toolchain = {
34
+ compilerVersion: ts.version,
35
+ fingerprint: await resolveToolchainFingerprint(packageDir),
36
+ };
37
+ const { changed, currentHash } = await detectBuildChanges(packageDir, [...entryPoints, ...dependencies], emitConfig, toolchain, cachePath, hasExpectedBuildOutput(packageDir, outdir, entryPoints));
34
38
  if (!changed) {
35
39
  await discardScratchDirs(resolveScratchDirs(emitDir));
36
40
  return;
37
41
  }
38
- await emitPackage(packageDir, entryPoints, outdir);
42
+ const emittedFileCount = await emitPackage(packageDir, entryPoints, outdir);
39
43
  await writeCacheEntry(cachePath, currentHash);
44
+ console.info(`${PACKAGE_ICON} ${path.basename(packageDir)}: ${describeEmit(emittedFileCount, outdir)}`);
40
45
  }
41
- export async function computeBuildHash(packageDir, files, emitConfig, compilerVersion) {
46
+ export async function computeBuildHash(packageDir, files, emitConfig, toolchain) {
42
47
  const hash = createHash('sha256');
43
48
  for (const file of files.toSorted()) {
44
49
  hash.update(file);
@@ -47,7 +52,9 @@ export async function computeBuildHash(packageDir, files, emitConfig, compilerVe
47
52
  }
48
53
  hash.update(JSON.stringify(emitConfig));
49
54
  hash.update('\0');
50
- hash.update(compilerVersion);
55
+ hash.update(toolchain.compilerVersion);
56
+ hash.update('\0');
57
+ hash.update(toolchain.fingerprint);
51
58
  return hash.digest('hex');
52
59
  }
53
60
  export function resolveTsconfigChain(packageDir, configFileName = 'tsconfig.json') {
@@ -92,10 +99,11 @@ async function emitPackage(packageDir, entryPoints, outdir) {
92
99
  }
93
100
  if (staged.size === 0) {
94
101
  await rm(emitDir, { force: true, recursive: true });
95
- return;
102
+ return 0;
96
103
  }
97
104
  writeStagedOutput(staged, emitDir, scratchDirs.staging);
98
105
  await swapIntoPlace(emitDir, scratchDirs);
106
+ return staged.size;
99
107
  }
100
108
  async function discardScratchDirs(scratchDirs) {
101
109
  try {
@@ -247,10 +255,10 @@ function getModuleSpecifier(node) {
247
255
  }
248
256
  return undefined;
249
257
  }
250
- async function detectBuildChanges(packageDir, files, emitConfig, compilerVersion, cachePath, outputPresent) {
258
+ async function detectBuildChanges(packageDir, files, emitConfig, toolchain, cachePath, outputPresent) {
251
259
  const packageName = path.basename(packageDir);
252
260
  const previousHash = await readCacheEntry(cachePath);
253
- const currentHash = await computeBuildHash(packageDir, files, emitConfig, compilerVersion);
261
+ const currentHash = await computeBuildHash(packageDir, files, emitConfig, toolchain);
254
262
  if (previousHash === currentHash) {
255
263
  if (outputPresent) {
256
264
  console.info(`${SKIPPED_ICON} ${packageName}: No changes detected. Skipping build.`);
@@ -278,6 +286,12 @@ function collectAliasPrefixes(compilerOptions) {
278
286
  }
279
287
  return Object.keys(compilerOptions.paths).map((pattern) => pattern.replace(/\*$/, ''));
280
288
  }
289
+ function describeEmit(fileCount, outdir) {
290
+ if (fileCount === 0) {
291
+ return 'Emitted no output.';
292
+ }
293
+ return `Compiled ${fileCount} files to ${outdir.replace(/\/+$/u, '')}.`;
294
+ }
281
295
  function formatDiagnostics(diagnostics) {
282
296
  return ts.formatDiagnosticsWithColorAndContext(diagnostics, {
283
297
  getCurrentDirectory: () => process.cwd(),
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { removeCheckCache } from "../check-cache.js";
4
4
  import { loadRootConfig } from "../config.js";
5
5
  import { findContainingPackageDir } from "../context.js";
6
+ import { reportClosing } from "../helpers/reportClosing.js";
6
7
  import { applyDevBin, buildWorkspaceRegistry, resolveScript } from "../resolver.js";
7
8
  import { resolveChannel, runCommand } from "../runner.js";
8
9
  import { renderChain } from "../steps.js";
@@ -40,17 +41,26 @@ async function clearCheckCache(scopeDir) {
40
41
  await removeCheckCache(scopeDir);
41
42
  console.info(`${CLEAN_ICON} Removed all recorded check results.`);
42
43
  }
44
+ function describeSweep(cleaned, skipped) {
45
+ const packages = cleaned === 1 ? '1 package' : `${cleaned} packages`;
46
+ const skipClause = skipped === 0 ? '' : `, skipping ${skipped} with an empty clean override`;
47
+ return `Cleaned ${packages}${skipClause}.`;
48
+ }
43
49
  async function sweepWorkspace(monorepoRoot, workspacePackageDirs) {
44
50
  const config = await loadRootConfig(monorepoRoot);
45
51
  const registry = buildWorkspaceRegistry(config);
52
+ let cleaned = 0;
53
+ let skipped = 0;
46
54
  for (const packageDir of workspacePackageDirs) {
47
55
  const resolved = resolveScript(CLEAN_COMMAND, registry, packageDir, false);
48
56
  const resolvedCommand = resolved === undefined ? undefined : renderChain(resolved.steps);
49
57
  if (resolvedCommand === undefined || resolvedCommand === '') {
58
+ skipped++;
50
59
  continue;
51
60
  }
52
61
  if (resolvedCommand === BUILT_IN_CLEAN) {
53
62
  await cleanPackage(packageDir);
63
+ cleaned++;
54
64
  continue;
55
65
  }
56
66
  const command = applyDevBin(resolvedCommand, config.devBin, monorepoRoot);
@@ -60,5 +70,7 @@ async function sweepWorkspace(monorepoRoot, workspacePackageDirs) {
60
70
  if (exitCode !== 0) {
61
71
  throw new Error(`nmr-clean: \`${command}\` failed in ${path.basename(packageDir)} with exit code ${exitCode}.`);
62
72
  }
73
+ cleaned++;
63
74
  }
75
+ reportClosing(`${CLEAN_ICON} ${describeSweep(cleaned, skipped)}`);
64
76
  }
@@ -15,3 +15,4 @@ export declare function ensurePrepublishHooks(monorepoRoot: string, options: {
15
15
  dryRun: boolean;
16
16
  command?: string;
17
17
  }): EnsurePrepublishHooksResult;
18
+ export declare function reportPrepublishHooks(result: EnsurePrepublishHooksResult, hookCommand: string): void;
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { readPackageJson } from "../helpers/package-json.js";
4
+ import { reportClosing } from "../helpers/reportClosing.js";
4
5
  import { isObject } from "../helpers/type-guards.js";
5
6
  import { getWorkspacePackageDirs } from "../workspace.js";
6
7
  export const DEFAULT_HOOK = 'npm run build';
@@ -59,6 +60,17 @@ export function ensurePrepublishHooks(monorepoRoot, options) {
59
60
  const hasFailures = packages.some((p) => p.action === 'missing');
60
61
  return { packages, hasFailures };
61
62
  }
63
+ export function reportPrepublishHooks(result, hookCommand) {
64
+ const publishable = result.packages.filter((pkg) => !pkg.isPrivate);
65
+ if (publishable.length === 0) {
66
+ console.info('No publishable packages found.');
67
+ return;
68
+ }
69
+ for (const pkg of publishable) {
70
+ console.info(renderHookStatus(pkg, hookCommand));
71
+ }
72
+ reportClosing(describeHookRun(publishable));
73
+ }
62
74
  function addPrepublishOnly(packageDir, command) {
63
75
  const filePath = path.join(packageDir, 'package.json');
64
76
  const raw = readFileSync(filePath, 'utf8');
@@ -71,3 +83,34 @@ function addPrepublishOnly(packageDir, command) {
71
83
  parsed['scripts'] = scripts;
72
84
  writeFileSync(filePath, JSON.stringify(parsed, null, 2) + '\n', 'utf8');
73
85
  }
86
+ function countAction(publishable, action) {
87
+ return publishable.filter((pkg) => pkg.action === action).length;
88
+ }
89
+ function describeHookRun(publishable) {
90
+ const packages = publishable.length === 1 ? '1 publishable package' : `${publishable.length} publishable packages`;
91
+ const missing = countAction(publishable, 'missing');
92
+ if (missing > 0) {
93
+ return `${missing} of ${packages} ${missing === 1 ? 'is' : 'are'} missing prepublishOnly. Run with --fix to add it.`;
94
+ }
95
+ const fixed = countAction(publishable, 'fixed');
96
+ if (fixed > 0) {
97
+ return `Added prepublishOnly to ${fixed} of ${packages}.`;
98
+ }
99
+ const wouldFix = countAction(publishable, 'would-fix');
100
+ if (wouldFix > 0) {
101
+ return `Would add prepublishOnly to ${wouldFix} of ${packages}.`;
102
+ }
103
+ return `${packages} ${publishable.length === 1 ? 'has' : 'have'} prepublishOnly.`;
104
+ }
105
+ function renderHookStatus(pkg, hookCommand) {
106
+ switch (pkg.action) {
107
+ case 'ok':
108
+ return `✓ ${pkg.packageName}: prepublishOnly = "${pkg.prepublishOnly}"`;
109
+ case 'missing':
110
+ return `✗ ${pkg.packageName}: missing prepublishOnly`;
111
+ case 'fixed':
112
+ return `✓ ${pkg.packageName}: added prepublishOnly = "${hookCommand}"`;
113
+ case 'would-fix':
114
+ return `~ ${pkg.packageName}: would add prepublishOnly = "${hookCommand}"`;
115
+ }
116
+ }
@@ -0,0 +1 @@
1
+ export declare function reportCatalog(cwd: string): void;