@williamthorsen/nmr 0.48.0 → 0.50.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 (111) hide show
  1. package/.meta/changelog.json +218 -0
  2. package/.readyup/kits/default.js +1527 -1460
  3. package/.readyup/manifest.json +96 -11
  4. package/CHANGELOG.md +65 -0
  5. package/README.md +19 -8
  6. package/agents/guidance/rulebooks/nmr.md +2 -2
  7. package/dist/esm/check-cache-build-output.d.ts +24 -0
  8. package/dist/esm/check-cache-build-output.js +49 -0
  9. package/dist/esm/check-cache-delegation.d.ts +27 -0
  10. package/dist/esm/check-cache-delegation.js +92 -0
  11. package/dist/esm/check-cache-entries.d.ts +109 -0
  12. package/dist/esm/check-cache-entries.js +87 -0
  13. package/dist/esm/check-cache-gate.d.ts +64 -0
  14. package/dist/esm/check-cache-gate.js +226 -0
  15. package/dist/esm/check-cache-key.d.ts +47 -0
  16. package/dist/esm/check-cache-key.js +77 -0
  17. package/dist/esm/check-cache-record.d.ts +69 -0
  18. package/dist/esm/check-cache-record.js +167 -0
  19. package/dist/esm/check-cache.d.ts +8 -176
  20. package/dist/esm/check-cache.js +9 -214
  21. package/dist/esm/child-env.d.ts +57 -0
  22. package/dist/esm/child-env.js +59 -0
  23. package/dist/esm/child-process.d.ts +55 -0
  24. package/dist/esm/child-process.js +112 -0
  25. package/dist/esm/commands/build.d.ts +0 -16
  26. package/dist/esm/commands/build.js +9 -214
  27. package/dist/esm/commands/clean.js +3 -2
  28. package/dist/esm/commands/compiler.d.ts +18 -0
  29. package/dist/esm/commands/compiler.js +49 -0
  30. package/dist/esm/commands/fmt.d.ts +0 -39
  31. package/dist/esm/commands/fmt.js +4 -81
  32. package/dist/esm/commands/format-targets.d.ts +39 -0
  33. package/dist/esm/commands/format-targets.js +83 -0
  34. package/dist/esm/commands/rewrite-specifiers.d.ts +17 -0
  35. package/dist/esm/commands/rewrite-specifiers.js +122 -0
  36. package/dist/esm/commands/tsconfig-chain.d.ts +8 -0
  37. package/dist/esm/commands/tsconfig-chain.js +63 -0
  38. package/dist/esm/config-check-cache.d.ts +17 -0
  39. package/dist/esm/config-check-cache.js +63 -0
  40. package/dist/esm/config-validation.d.ts +20 -0
  41. package/dist/esm/config-validation.js +236 -0
  42. package/dist/esm/config.js +4 -286
  43. package/dist/esm/crossing-diagnostics.d.ts +39 -0
  44. package/dist/esm/crossing-diagnostics.js +132 -0
  45. package/dist/esm/default-scripts.js +1 -1
  46. package/dist/esm/delegate-host.d.ts +43 -0
  47. package/dist/esm/delegate-host.js +191 -0
  48. package/dist/esm/delegation-target.d.ts +52 -0
  49. package/dist/esm/delegation-target.js +71 -0
  50. package/dist/esm/fan-out.d.ts +57 -0
  51. package/dist/esm/fan-out.js +146 -0
  52. package/dist/esm/glyphs.d.ts +1 -1
  53. package/dist/esm/help.js +10 -3
  54. package/dist/esm/helpers/deriveExcerpt.d.ts +9 -0
  55. package/dist/esm/helpers/deriveExcerpt.js +1 -1
  56. package/dist/esm/helpers/renderQuotedList.d.ts +2 -0
  57. package/dist/esm/helpers/renderQuotedList.js +9 -0
  58. package/dist/esm/hooks.d.ts +17 -0
  59. package/dist/esm/hooks.js +23 -0
  60. package/dist/esm/invocation-args.d.ts +40 -0
  61. package/dist/esm/invocation-args.js +178 -0
  62. package/dist/esm/invocation.d.ts +67 -0
  63. package/dist/esm/invocation.js +3 -0
  64. package/dist/esm/machine-lock.d.ts +2 -0
  65. package/dist/esm/machine-lock.js +1 -1
  66. package/dist/esm/output-channel.d.ts +23 -0
  67. package/dist/esm/output-channel.js +14 -0
  68. package/dist/esm/pass-manifest.d.ts +53 -0
  69. package/dist/esm/pass-manifest.js +89 -0
  70. package/dist/esm/presentation.d.ts +47 -0
  71. package/dist/esm/presentation.js +32 -0
  72. package/dist/esm/recording.d.ts +1 -1
  73. package/dist/esm/recording.js +2 -2
  74. package/dist/esm/remote-host-session.d.ts +62 -0
  75. package/dist/esm/remote-host-session.js +277 -0
  76. package/dist/esm/remote-probe-host.d.ts +22 -0
  77. package/dist/esm/remote-probe-host.js +50 -0
  78. package/dist/esm/remote-probe.d.ts +63 -0
  79. package/dist/esm/remote-probe.js +88 -0
  80. package/dist/esm/remote-run.d.ts +45 -65
  81. package/dist/esm/remote-run.js +128 -162
  82. package/dist/esm/remote-session.d.ts +40 -0
  83. package/dist/esm/remote-session.js +151 -0
  84. package/dist/esm/remote-snapshot.d.ts +21 -0
  85. package/dist/esm/remote-snapshot.js +80 -0
  86. package/dist/esm/replay-assembly.d.ts +2 -2
  87. package/dist/esm/replay-assembly.js +1 -1
  88. package/dist/esm/run-chain.d.ts +21 -0
  89. package/dist/esm/run-chain.js +108 -0
  90. package/dist/esm/runCli.d.ts +1 -9
  91. package/dist/esm/runCli.js +39 -1002
  92. package/dist/esm/runner.d.ts +1 -54
  93. package/dist/esm/runner.js +2 -125
  94. package/dist/esm/shell-syntax.d.ts +30 -0
  95. package/dist/esm/shell-syntax.js +120 -0
  96. package/dist/esm/steps.d.ts +0 -2
  97. package/dist/esm/steps.js +3 -119
  98. package/dist/esm/types.d.ts +5 -0
  99. package/dist/esm/user-config.d.ts +34 -0
  100. package/dist/esm/user-config.js +93 -0
  101. package/dist/esm/verbosity.d.ts +6 -0
  102. package/dist/esm/verbosity.js +7 -7
  103. package/dist/esm/verdict.d.ts +1 -1
  104. package/dist/esm/verdict.js +1 -1
  105. package/docs/check-cache.md +28 -12
  106. package/docs/delegate.md +103 -17
  107. package/docs/reporting.md +6 -1
  108. package/docs/scripts.md +2 -1
  109. package/package.json +4 -4
  110. /package/dist/esm/{prettier-default-config.d.ts → prettier-default.config.d.ts} +0 -0
  111. /package/dist/esm/{prettier-default-config.js → prettier-default.config.js} +0 -0
@@ -0,0 +1,167 @@
1
+ import path from 'node:path';
2
+ import { describeError } from '@williamthorsen/toolbelt.errors';
3
+ import { isCacheableCommand, resolveTreeSnapshot, writeDebugNote } from "./check-cache.js";
4
+ import { findStaleBuildOutput, readBuildOutputState } from "./check-cache-build-output.js";
5
+ import { readCheckCacheEntry, recordTranscript, writeCheckCacheEntry, } from "./check-cache-entries.js";
6
+ import { CURRENT_RUNTIME } from "./check-cache-key.js";
7
+ import { deriveExcerpt } from "./helpers/deriveExcerpt.js";
8
+ import { composeTranscript } from "./helpers/transcript.js";
9
+ import { RUN_IF_PRESENT_ENV_VAR, VERSION } from "./invocation.js";
10
+ import { writePassManifestEntry } from "./pass-manifest.js";
11
+ import { renderRecording, renderRefusal, resolveRecording } from "./recording.js";
12
+ import { assembleReplay } from "./replay-assembly.js";
13
+ export async function recordHostPass(options) {
14
+ const { env, host, key, ref, retention, snapshot, stderr } = options;
15
+ const recorded = await readCheckCacheEntry(ref);
16
+ if (recorded !== undefined && recorded.host === undefined && recorded.key === key) {
17
+ const output = await readBuildOutputState(options.monorepoRoot, options.config);
18
+ if (output.missingPackages.length === 0 &&
19
+ findStaleBuildOutput(recorded.buildDigests, output.digests) === undefined) {
20
+ writeDebugNote(`not recording ${ref.command} on ${host}: a pass earned here covers this tree`, env, stderr);
21
+ return;
22
+ }
23
+ }
24
+ await writePass({
25
+ entry: {
26
+ key,
27
+ treeHash: snapshot.treeId,
28
+ headSha: snapshot.headSha,
29
+ commandString: options.commandString,
30
+ nmrVersion: VERSION,
31
+ nodeVersion: CURRENT_RUNTIME.nodeVersion,
32
+ durationMs: options.durationMs,
33
+ recordedAt: new Date().toISOString(),
34
+ buildDigests: {},
35
+ host,
36
+ ...(retention !== undefined && { retention }),
37
+ },
38
+ env,
39
+ ref,
40
+ retainedOutput: options.retainedOutput,
41
+ stderr,
42
+ });
43
+ }
44
+ export async function recordPass(options) {
45
+ const { anchorDir, command, env, monorepoRoot, snapshot, stderr } = options;
46
+ const currentRead = resolveTreeSnapshot({ monorepoRoot, env: {} });
47
+ if (!currentRead.ok) {
48
+ writeDebugNote(`not recording ${command}: ${currentRead.reason}`, env, stderr);
49
+ return;
50
+ }
51
+ if (currentRead.snapshot.treeId !== snapshot.treeId) {
52
+ writeDebugNote(`not recording ${command}: the tree changed while it ran`, env, stderr);
53
+ return;
54
+ }
55
+ const ref = { anchorDir, command, monorepoRoot };
56
+ const output = await readBuildOutputState(monorepoRoot, options.config);
57
+ const [missingPackage] = output.missingPackages;
58
+ if (missingPackage !== undefined) {
59
+ writeDebugNote(`not recording ${command}: ${missingPackage} does not have build output`, env, stderr);
60
+ return;
61
+ }
62
+ const changedPackage = findStaleBuildOutput(options.buildOutputBefore.digests, output.digests);
63
+ if (changedPackage !== undefined) {
64
+ writeDebugNote(`not recording ${command}: ${changedPackage}'s build output changed while it ran`, env, stderr);
65
+ return;
66
+ }
67
+ const retention = await composeRetention({
68
+ anchorDir,
69
+ command,
70
+ key: options.retentionKey,
71
+ monorepoRoot,
72
+ retainedOutput: options.retainedOutput,
73
+ runId: options.runId,
74
+ steps: options.ownSteps,
75
+ treeHash: snapshot.treeId,
76
+ });
77
+ const isWritten = await writePass({
78
+ entry: {
79
+ key: options.key,
80
+ treeHash: snapshot.treeId,
81
+ headSha: snapshot.headSha,
82
+ commandString: options.commandString,
83
+ nmrVersion: VERSION,
84
+ nodeVersion: CURRENT_RUNTIME.nodeVersion,
85
+ durationMs: options.durationMs,
86
+ recordedAt: new Date().toISOString(),
87
+ buildDigests: output.digests,
88
+ ...(retention !== undefined && { retention }),
89
+ },
90
+ env,
91
+ ref,
92
+ retainedOutput: options.retainedOutput,
93
+ stderr,
94
+ });
95
+ if (isWritten) {
96
+ await writePassManifestEntry({
97
+ anchorDir,
98
+ command,
99
+ commandString: options.commandString,
100
+ durationMs: options.durationMs,
101
+ env,
102
+ monorepoRoot,
103
+ replay: retention?.replay,
104
+ stderr,
105
+ treeHash: snapshot.treeId,
106
+ });
107
+ }
108
+ }
109
+ export async function reportRecording(plan) {
110
+ const { anchorDir, command, context, env, scope, stderr, stdout, styles } = plan.invocation;
111
+ const { config, monorepoRoot } = context;
112
+ const lookup = await resolveRecording({
113
+ anchorDir,
114
+ command,
115
+ currentIdentity: {
116
+ commandString: plan.commandString,
117
+ nmrVersion: VERSION,
118
+ nodeVersion: CURRENT_RUNTIME.nodeVersion,
119
+ treeHash: plan.snapshot?.treeId,
120
+ },
121
+ isCacheable: isCacheableCommand(config.checkCache, command),
122
+ key: plan.key,
123
+ monorepoRoot,
124
+ });
125
+ if (!lookup.ok) {
126
+ stderr.write(`${renderRefusal({ command, refusal: lookup.refusal, scope, style: styles.stderr })}\n`);
127
+ return { exitCode: env[RUN_IF_PRESENT_ENV_VAR] === '1' ? 0 : 1 };
128
+ }
129
+ stdout.write(renderRecording({ command, recording: lookup.recording, scope, style: styles.stdout }));
130
+ return { exitCode: 0 };
131
+ }
132
+ async function composeRetention(options) {
133
+ const { anchorDir, command, key, retainedOutput, runId } = options;
134
+ if (retainedOutput !== undefined) {
135
+ const excerpt = deriveExcerpt(retainedOutput.stdout.toString('utf8')) ?? deriveExcerpt(retainedOutput.stderr.toString('utf8'));
136
+ if (excerpt === undefined) {
137
+ return undefined;
138
+ }
139
+ return { key, replay: [{ command, excerpt, scope: path.basename(anchorDir) }], runId };
140
+ }
141
+ const replay = await assembleReplay({
142
+ anchorDir,
143
+ monorepoRoot: options.monorepoRoot,
144
+ runId,
145
+ steps: options.steps,
146
+ treeHash: options.treeHash,
147
+ });
148
+ return replay.length === 0 ? undefined : { key, replay, runId };
149
+ }
150
+ async function writePass(options) {
151
+ const { entry, env, ref, stderr } = options;
152
+ const transcript = options.retainedOutput === undefined ? undefined : composeTranscript(options.retainedOutput);
153
+ try {
154
+ await recordTranscript(ref, transcript);
155
+ await writeCheckCacheEntry({ ...ref, entry });
156
+ return true;
157
+ }
158
+ catch (error) {
159
+ try {
160
+ await recordTranscript(ref, undefined);
161
+ }
162
+ catch {
163
+ }
164
+ writeDebugNote(`could not record ${ref.command}: ${describeError(error)}`, env, stderr);
165
+ return false;
166
+ }
167
+ }
@@ -1,75 +1,11 @@
1
1
  import type { Writable } from 'node:stream';
2
2
  import { type OutputStyle } from '@williamthorsen/nmr-core';
3
- import type { OutputChannels } from './runner.js';
4
- import type { CheckCacheConfig, NmrConfig } from './types.js';
5
- /** A recorded pass: what ran, on which tree, and how long it took. */
6
- export interface CheckCacheEntry {
7
- /** The full cache key under which the pass was recorded; a hit is this key matching the key computed now. */
8
- key: string;
9
- treeHash: string;
10
- headSha: string;
11
- commandString: string;
12
- nmrVersion: string;
13
- nodeVersion: string;
14
- durationMs: number;
15
- /** ISO-8601 instant the pass completed. */
16
- recordedAt: string;
17
- /**
18
- * The build digest that each covered package's output had when the pass was recorded. Build output is
19
- * git-ignored, so the tree hash cannot describe it; comparing these separates output built from this tree from
20
- * output that another tree left behind.
21
- */
22
- buildDigests: Record<string, string>;
23
- /** What a skip replays in place of the run that it recalls, absent on a pass that retained nothing. */
24
- retention?: Retention;
25
- }
26
- /** What locates one command's recorded pass at one scope, and the transcript beside it. */
27
- export interface EntryRef {
28
- anchorDir: string;
29
- command: string;
30
- monorepoRoot: string;
31
- }
32
- /** One command's excerpt, attributed to the scope and the command that produced it. */
33
- export interface ReplayLine {
34
- command: string;
35
- excerpt: string;
36
- scope: string;
37
- }
38
- /**
39
- * What a recalled pass replays, the key certifying the excerpts describe this environment's output, and the
40
- * run that last vouched for them.
41
- *
42
- * A list rather than one excerpt: a composite's entry contains its constituents' lines, and a nested one's are
43
- * spliced into its parent's, after which the attribution cannot be re-derived from the entry that contains them.
44
- *
45
- * The witness admits a constituent's line into the assembly that its parent records: a run writes it when it
46
- * records an excerpt, and restamps it when it recalls one and replays it.
47
- */
48
- export interface Retention {
49
- key: string;
50
- replay: ReplayLine[];
51
- runId: string;
52
- }
53
- /** What nmr's own build has left on disk across the workspace. */
54
- export interface BuildOutputState {
55
- /** Covered packages whose output is absent. */
56
- missingPackages: string[];
57
- /** The digest of the inputs from which each covered package's output was built, keyed by package name. */
58
- digests: Record<string, string>;
59
- }
60
- /** The parts of the running interpreter that can change what a check concludes. */
61
- export interface RuntimeIdentity {
62
- arch: string;
63
- nodeVersion: string;
64
- platform: string;
65
- }
3
+ import type { CheckCacheConfig } from './types.js';
66
4
  /** One working tree, observed once per top-level invocation and shared with every process below it. */
67
5
  export interface TreeSnapshot {
68
- hash: string;
6
+ treeId: string;
69
7
  headSha: string;
70
8
  }
71
- /** The interpreter on which this process is running, folded into every key and recorded alongside every pass. */
72
- export declare const CURRENT_RUNTIME: RuntimeIdentity;
73
9
  /** Set to `1` to hear why the gate did not skip, or why it is disabled. */
74
10
  export declare const DEBUG_ENV_VAR = "NMR_DEBUG";
75
11
  /**
@@ -85,76 +21,17 @@ export declare const DEBUG_ENV_VAR = "NMR_DEBUG";
85
21
  export declare const DEFAULT_CACHEABLE_COMMANDS: string[];
86
22
  /** Set to `1` for the standing equivalent of `--no-cache`: skip the lookup, still record on success. */
87
23
  export declare const NO_CACHE_ENV_VAR = "NMR_NO_CACHE";
24
+ /**
25
+ * Names the directory into which every nmr process of a delegated run reports the passes that it records or recalls.
26
+ * Only the host session sets it, so that the delegating machine can learn a composite's constituent passes.
27
+ */
28
+ export declare const PASS_MANIFEST_DIR_ENV_VAR = "NMR_PASS_MANIFEST_DIR";
88
29
  /** Passes one run's identity down the spawned chain, so that an entry can name the run that vouched for it. */
89
30
  export declare const RUN_ID_ENV_VAR = "NMR_RUN_ID";
90
31
  /** Passes the top-level tree snapshot down the spawned chain, to make one invocation hash the tree only once. */
91
32
  export declare const TREE_SNAPSHOT_ENV_VAR = "NMR_TREE_SNAPSHOT";
92
- /**
93
- * Restamps a recalled entry's retention with the run that is replaying it, so that an excerpt certified by this
94
- * run can join the assembly recorded by a composite above it.
95
- *
96
- * A recall certifies as surely as a recording does. Because the pass key matched, the excerpt describes this
97
- * tree; because the caller calls this only when the retention key matched too, the excerpt also describes this
98
- * presentation environment. Everything else the entry records stands -- the instant and the duration belong to
99
- * the run that earned the pass, and a recall must not make it read as later or longer than it was.
100
- *
101
- * A cache that cannot be written is not worth failing a green run over: `certifyRetention` reports that failure
102
- * in the debug note.
103
- */
104
- export declare function certifyRetention(options: {
105
- anchorDir: string;
106
- command: string;
107
- entry: CheckCacheEntry;
108
- env: NodeJS.ProcessEnv;
109
- monorepoRoot: string;
110
- runId: string;
111
- stderr: Writable;
112
- }): Promise<void>;
113
- /**
114
- * Folds everything that can change what a command concludes into one key: the tree's content, the command
115
- * string that would run, the scope in which it would run, nmr's own version, the interpreter, what is installed,
116
- * and the environment variables that a check can read. A hit is this key matching a recorded one, so an ingredient left
117
- * out here is an ingredient that could change while the cache still claims a pass.
118
- *
119
- * Reports a reason instead of a key when the install fingerprint cannot be read, which disables the gate.
120
- */
121
- export declare function computeCacheKey(options: {
122
- anchorDir: string;
123
- command: string;
124
- commandString: string;
125
- env: NodeJS.ProcessEnv;
126
- monorepoRoot: string;
127
- nmrVersion: string;
128
- runtime?: RuntimeIdentity;
129
- snapshot: TreeSnapshot;
130
- }): {
131
- ok: true;
132
- key: string;
133
- } | {
134
- ok: false;
135
- reason: string;
136
- };
137
- /**
138
- * Folds what changes a transcript without changing a conclusion onto the pass key: the channel on which each of
139
- * the command's output streams ran, and the environment variables through which a tool presents itself.
140
- *
141
- * The channel kind keeps a run at a terminal from replaying a piped recording. It is the channel's kind and not
142
- * whether a terminal is attached: under quiet mode the child writes to pipes at a terminal, and its transcript
143
- * is reproducible.
144
- */
145
- export declare function computeRetentionKey(options: {
146
- channels: OutputChannels;
147
- env: NodeJS.ProcessEnv;
148
- passKey: string;
149
- }): string;
150
33
  /** Renders a snapshot for the environment of every process below this one. */
151
34
  export declare function encodeTreeSnapshot(snapshot: TreeSnapshot): string;
152
- /**
153
- * Names a covered package whose output differs between two observations of it, or `undefined` when every
154
- * package agrees. A package that has appeared or disappeared between them counts as a disagreement, because
155
- * the output that the earlier observation describes is not the output that the later one found.
156
- */
157
- export declare function findStaleBuildOutput(earlierDigests: Record<string, string>, laterDigests: Record<string, string>): string | undefined;
158
35
  /**
159
36
  * Renders the warning for a `--no-cache` that appears after the command name, which makes it an argument to
160
37
  * the command rather than a flag to nmr. nmr passes the argument on unchanged, so the warning is the only sign
@@ -169,44 +46,6 @@ export declare function formatMisplacedNoCacheWarning(command: string, style: Ou
169
46
  * excluding it here keeps the gate and every reader of its entries in agreement.
170
47
  */
171
48
  export declare function isCacheableCommand(checkCache: CheckCacheConfig | undefined, command: string): boolean;
172
- /**
173
- * Reads the state of the build output that nmr's own build covers. Build output is git-ignored, so the tree hash
174
- * says nothing about it: a `ci` whose `build` constituent is cached would otherwise skip on a tree whose `dist`
175
- * had been deleted, or whose `dist` was compiled from a different tree, and hand back a green exit over a
176
- * repository that cannot run.
177
- *
178
- * Because a package whose `build` or `compile` is overridden emits to a location that this function does not
179
- * know about, it is left out rather than made a permanent miss.
180
- */
181
- export declare function readBuildOutputState(monorepoRoot: string, config: NmrConfig): Promise<BuildOutputState>;
182
- /**
183
- * Reads the entry recorded for one command at one scope, or `undefined` when there is none to trust.
184
- *
185
- * Retention is vouched for separately from the pass that contains it: an excerpt of a shape that this function
186
- * cannot read is dropped, leaving a pass that skips cleanly and reports its verdict alone.
187
- */
188
- export declare function readCheckCacheEntry(options: {
189
- anchorDir: string;
190
- command: string;
191
- monorepoRoot: string;
192
- }): Promise<CheckCacheEntry | undefined>;
193
- /**
194
- * Reads the whole output that one recorded pass retained, or `undefined` when it retained none.
195
- *
196
- * Held to nothing on its own: the entry beside it says which tree the bytes describe, and a caller
197
- * that has not matched the entry's key is reading a transcript of some other tree.
198
- */
199
- export declare function readTranscript(ref: EntryRef): Promise<string | undefined>;
200
- /**
201
- * Makes the transcript beside one entry be exactly what this pass retained, removing what an earlier pass
202
- * left when this one retained nothing.
203
- *
204
- * A composite retains nothing of its own, so without the removal a leaf's transcript would remain beside an
205
- * entry that never produced it, and `--log` would date another run's bytes by this one's instant.
206
- */
207
- export declare function recordTranscript(ref: EntryRef, transcript: string | undefined): Promise<void>;
208
- /** Removes every recorded pass for a monorepo, or for a standalone package outside one. */
209
- export declare function removeCheckCache(scopeDir: string): Promise<void>;
210
49
  /**
211
50
  * Merges a repo's `checkCache` configuration into the default set: `extraCommands` adds to the defaults, and
212
51
  * `excludeCommands` removes a command whose chain does more than report an exit status.
@@ -217,7 +56,7 @@ export declare function resolveCacheableCommands(checkCache: CheckCacheConfig |
217
56
  * down, and otherwise a fresh one, because the run starts at this invocation.
218
57
  *
219
58
  * Unbounded, whereas the tree snapshot is bounded by a HEAD comparison: a process that outlives its run hands a
220
- * stale identity to the invocations that it later makes, and the tree hash keeps that harmless, because every
59
+ * stale identity to the invocations that it later makes, and the tree id keeps that harmless, because every
221
60
  * constituent entry is held to it before its excerpt joins an assembly.
222
61
  */
223
62
  export declare function resolveRunId(env: NodeJS.ProcessEnv): string;
@@ -240,13 +79,6 @@ export declare function resolveTreeSnapshot(options: {
240
79
  ok: false;
241
80
  reason: string;
242
81
  };
243
- /** Records a pass, replacing whatever this command last recorded at this scope. */
244
- export declare function writeCheckCacheEntry(options: {
245
- anchorDir: string;
246
- command: string;
247
- entry: CheckCacheEntry;
248
- monorepoRoot: string;
249
- }): Promise<void>;
250
82
  /**
251
83
  * Writes a note explaining a gate decision, but only when `NMR_DEBUG=1`. The gate is silent by default: a
252
84
  * reason to run is not news, and a line per invocation explaining why nothing was skipped would bury the
@@ -1,22 +1,7 @@
1
- import { createHash, randomUUID } from 'node:crypto';
2
- import { readFileSync, realpathSync } from 'node:fs';
3
- import path from 'node:path';
4
- import process from 'node:process';
5
- import { hashWorkingTree, readCacheEntry, readHeadSha, readJsonCacheEntry, removeCacheDir, removeCacheEntry, resolveCacheEntryPath, STATUS_GLYPHS, writeCacheEntry, } from '@williamthorsen/nmr-core';
6
- import { describeError } from '@williamthorsen/toolbelt.errors';
7
- import { hasBuildOutput, readBuildDigest } from "./commands/build-output.js";
8
- import { loadWorkspaceConfig } from "./config.js";
1
+ import { randomUUID } from 'node:crypto';
2
+ import { realpathSync } from 'node:fs';
3
+ import { hashWorkingTree, readHeadSha, STATUS_GLYPHS } from '@williamthorsen/nmr-core';
9
4
  import { isHookName } from "./helpers/hook-name.js";
10
- import { isObject, isStringRecord } from "./helpers/type-guards.js";
11
- import { getDefaultWorkspaceScripts } from "./resolve-scripts.js";
12
- import { buildWorkspaceRegistry, resolveScript } from "./resolver.js";
13
- import { renderChain } from "./steps.js";
14
- import { getWorkspacePackageDirs } from "./workspace.js";
15
- export const CURRENT_RUNTIME = {
16
- arch: process.arch,
17
- nodeVersion: process.version,
18
- platform: process.platform,
19
- };
20
5
  export const DEBUG_ENV_VAR = 'NMR_DEBUG';
21
6
  export const DEFAULT_CACHEABLE_COMMANDS = [
22
7
  'check',
@@ -40,72 +25,11 @@ export const DEFAULT_CACHEABLE_COMMANDS = [
40
25
  'typecheck',
41
26
  ];
42
27
  export const NO_CACHE_ENV_VAR = 'NMR_NO_CACHE';
28
+ export const PASS_MANIFEST_DIR_ENV_VAR = 'NMR_PASS_MANIFEST_DIR';
43
29
  export const RUN_ID_ENV_VAR = 'NMR_RUN_ID';
44
30
  export const TREE_SNAPSHOT_ENV_VAR = 'NMR_TREE_SNAPSHOT';
45
- const BUILT_IN_COMPILE = 'nmr-compile';
46
- const CACHE_TOOL = 'nmr-check';
47
- const INSTALL_FINGERPRINT_FILES = [
48
- path.join('node_modules', '.modules.yaml'),
49
- path.join('node_modules', '.pnpm', 'lock.yaml'),
50
- ];
51
- const KEY_FORMAT = 'nmr-check-cache-v1';
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'];
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
- }
73
- export function computeCacheKey(options) {
74
- const fingerprint = resolveInstallFingerprint(options.monorepoRoot);
75
- if (!fingerprint.ok) {
76
- return fingerprint;
77
- }
78
- const runtime = options.runtime ?? CURRENT_RUNTIME;
79
- const scope = path.relative(options.monorepoRoot, options.anchorDir);
80
- const parts = [
81
- KEY_FORMAT,
82
- options.snapshot.hash,
83
- scope === '' ? '.' : scope,
84
- options.command,
85
- options.commandString,
86
- options.nmrVersion,
87
- runtime.nodeVersion,
88
- runtime.platform,
89
- runtime.arch,
90
- fingerprint.fingerprint,
91
- ];
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)]);
102
- }
103
31
  export function encodeTreeSnapshot(snapshot) {
104
- return `${snapshot.hash} ${snapshot.headSha}`;
105
- }
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]);
32
+ return `${snapshot.treeId} ${snapshot.headSha}`;
109
33
  }
110
34
  export function formatMisplacedNoCacheWarning(command, style) {
111
35
  return (`${STATUS_GLYPHS[style].warning.text} --no-cache after the command name is passed to \`${command}\`, ` +
@@ -114,57 +38,6 @@ export function formatMisplacedNoCacheWarning(command, style) {
114
38
  export function isCacheableCommand(checkCache, command) {
115
39
  return !isHookName(command) && checkCache?.enabled !== false && resolveCacheableCommands(checkCache).has(command);
116
40
  }
117
- export async function readBuildOutputState(monorepoRoot, config) {
118
- const state = { missingPackages: [], digests: {} };
119
- let packageDirs;
120
- try {
121
- packageDirs = getWorkspacePackageDirs(monorepoRoot);
122
- }
123
- catch {
124
- return state;
125
- }
126
- const registry = buildWorkspaceRegistry(config);
127
- if (JSON.stringify(registry['build']) !== JSON.stringify(getDefaultWorkspaceScripts()['build'])) {
128
- return state;
129
- }
130
- for (const packageDir of packageDirs) {
131
- if (!isProbeSubject(packageDir, registry)) {
132
- continue;
133
- }
134
- const { build } = await loadWorkspaceConfig(packageDir);
135
- const options = build?.extraIgnorePatterns === undefined ? {} : { extraIgnorePatterns: build.extraIgnorePatterns };
136
- const name = path.relative(monorepoRoot, packageDir);
137
- if (await hasBuildOutput(packageDir, options)) {
138
- state.digests[name] = (await readBuildDigest(packageDir)) ?? '';
139
- }
140
- else {
141
- state.missingPackages.push(name);
142
- }
143
- }
144
- return state;
145
- }
146
- export async function readCheckCacheEntry(options) {
147
- const parsedEntry = await readJsonCacheEntry(resolveEntryPath(options), isParsedCheckCacheEntry);
148
- if (parsedEntry === undefined) {
149
- return undefined;
150
- }
151
- const { retention, ...pass } = parsedEntry;
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);
164
- }
165
- export async function removeCheckCache(scopeDir) {
166
- await removeCacheDir({ tool: CACHE_TOOL, scopeDir });
167
- }
168
41
  export function resolveCacheableCommands(checkCache) {
169
42
  const commands = new Set([...DEFAULT_CACHEABLE_COMMANDS, ...(checkCache?.extraCommands ?? [])]);
170
43
  const excludedCommands = checkCache?.excludeCommands ?? [];
@@ -196,98 +69,20 @@ export function resolveTreeSnapshot(options) {
196
69
  if (hashedTree.toplevel !== physicalRoot) {
197
70
  return { ok: false, reason: `the monorepo root is not the git toplevel (${hashedTree.toplevel})` };
198
71
  }
199
- return { ok: true, snapshot: { hash: hashedTree.hash, headSha: hashedTree.headSha } };
200
- }
201
- export async function writeCheckCacheEntry(options) {
202
- await writeCacheEntry(resolveEntryPath(options), JSON.stringify(options.entry, undefined, 2));
72
+ return { ok: true, snapshot: { treeId: hashedTree.treeId, headSha: hashedTree.headSha } };
203
73
  }
204
74
  export function writeDebugNote(message, env, stderr) {
205
75
  if (env[DEBUG_ENV_VAR] === '1') {
206
76
  stderr.write(`nmr check-cache: ${message}\n`);
207
77
  }
208
78
  }
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
- }
215
79
  function decodeTreeSnapshot(encodedSnapshot) {
216
80
  if (encodedSnapshot === undefined) {
217
81
  return undefined;
218
82
  }
219
- const [hash, headSha] = encodedSnapshot.split(' ', 2);
220
- if (hash === undefined || headSha === undefined || hash === '' || headSha === '') {
83
+ const [treeId, headSha] = encodedSnapshot.split(' ', 2);
84
+ if (treeId === undefined || headSha === undefined || treeId === '' || headSha === '') {
221
85
  return undefined;
222
86
  }
223
- return { hash, headSha };
224
- }
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) {
237
- if (!isObject(value)) {
238
- return false;
239
- }
240
- const stringFields = ['key', 'treeHash', 'headSha', 'commandString', 'nmrVersion', 'nodeVersion', 'recordedAt'];
241
- if (stringFields.some((field) => typeof value[field] !== 'string')) {
242
- return false;
243
- }
244
- return (typeof value['durationMs'] === 'number' &&
245
- Number.isFinite(value['durationMs']) &&
246
- !Number.isNaN(Date.parse(String(value['recordedAt']))) &&
247
- isStringRecord(value['buildDigests']));
248
- }
249
- function isProbeSubject(packageDir, registry) {
250
- const build = resolveScript('build', registry, packageDir, false);
251
- if (build === undefined || build.origin.tier === 'package') {
252
- return false;
253
- }
254
- const compile = resolveScript('compile', registry, packageDir, false);
255
- return compile !== undefined && renderChain(compile.steps) === BUILT_IN_COMPILE;
256
- }
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') {
271
- const anchorDir = path.resolve(options.anchorDir);
272
- const ref = {
273
- tool: CACHE_TOOL,
274
- scopeDir: options.monorepoRoot,
275
- slug: `${path.basename(anchorDir)}-${options.command.replaceAll(UNSAFE_SLUG_CHARACTERS, '-')}`,
276
- extension,
277
- discriminators: [anchorDir, options.command],
278
- };
279
- return resolveCacheEntryPath(ref);
280
- }
281
- function resolveInstallFingerprint(monorepoRoot) {
282
- const hash = createHash('sha256');
283
- for (const relativePath of INSTALL_FINGERPRINT_FILES) {
284
- try {
285
- hash.update(readFileSync(path.join(monorepoRoot, relativePath)));
286
- }
287
- catch {
288
- return { ok: false, reason: `no install fingerprint: ${relativePath} is unreadable` };
289
- }
290
- hash.update('\0');
291
- }
292
- return { ok: true, fingerprint: hash.digest('hex') };
87
+ return { treeId, headSha };
293
88
  }