@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,87 @@
1
+ import path from 'node:path';
2
+ import { readCacheEntry, readJsonCacheEntry, removeCacheDir, removeCacheEntry, resolveCacheEntryPath, writeCacheEntry, } from '@williamthorsen/nmr-core';
3
+ import { describeError } from '@williamthorsen/toolbelt.errors';
4
+ import { writeDebugNote } from "./check-cache.js";
5
+ import { isObject, isStringRecord } from "./helpers/type-guards.js";
6
+ const CACHE_TOOL = 'nmr-check';
7
+ const UNSAFE_SLUG_CHARACTERS = /[^\w.-]+/g;
8
+ export async function certifyRetention(options) {
9
+ const { entry, runId } = options;
10
+ if (entry.retention === undefined || entry.retention.runId === runId) {
11
+ return;
12
+ }
13
+ try {
14
+ await writeCheckCacheEntry({
15
+ anchorDir: options.anchorDir,
16
+ command: options.command,
17
+ monorepoRoot: options.monorepoRoot,
18
+ entry: { ...entry, retention: { ...entry.retention, runId } },
19
+ });
20
+ }
21
+ catch (error) {
22
+ writeDebugNote(`could not certify ${options.command}: ${describeError(error)}`, options.env, options.stderr);
23
+ }
24
+ }
25
+ export async function readCheckCacheEntry(options) {
26
+ const parsedEntry = await readJsonCacheEntry(resolveEntryPath(options), isParsedCheckCacheEntry);
27
+ if (parsedEntry === undefined) {
28
+ return undefined;
29
+ }
30
+ const { retention, ...pass } = parsedEntry;
31
+ return isRetention(retention) ? { ...pass, retention } : pass;
32
+ }
33
+ export async function readTranscript(ref) {
34
+ return readCacheEntry(resolveEntryPath(ref, '.log'));
35
+ }
36
+ export async function recordTranscript(ref, transcript) {
37
+ const entryPath = resolveEntryPath(ref, '.log');
38
+ if (transcript === undefined) {
39
+ await removeCacheEntry(entryPath);
40
+ return;
41
+ }
42
+ await writeCacheEntry(entryPath, transcript);
43
+ }
44
+ export async function removeCheckCache(scopeDir) {
45
+ await removeCacheDir({ tool: CACHE_TOOL, scopeDir });
46
+ }
47
+ export function isReplayLine(value) {
48
+ return (isObject(value) &&
49
+ typeof value['command'] === 'string' &&
50
+ typeof value['excerpt'] === 'string' &&
51
+ typeof value['scope'] === 'string');
52
+ }
53
+ export async function writeCheckCacheEntry(options) {
54
+ await writeCacheEntry(resolveEntryPath(options), JSON.stringify(options.entry, undefined, 2));
55
+ }
56
+ function isParsedCheckCacheEntry(value) {
57
+ if (!isObject(value)) {
58
+ return false;
59
+ }
60
+ const stringFields = ['key', 'treeHash', 'headSha', 'commandString', 'nmrVersion', 'nodeVersion', 'recordedAt'];
61
+ if (stringFields.some((field) => typeof value[field] !== 'string')) {
62
+ return false;
63
+ }
64
+ return ((value['host'] === undefined || typeof value['host'] === 'string') &&
65
+ typeof value['durationMs'] === 'number' &&
66
+ Number.isFinite(value['durationMs']) &&
67
+ !Number.isNaN(Date.parse(String(value['recordedAt']))) &&
68
+ isStringRecord(value['buildDigests']));
69
+ }
70
+ function isRetention(value) {
71
+ return (isObject(value) &&
72
+ typeof value['key'] === 'string' &&
73
+ typeof value['runId'] === 'string' &&
74
+ Array.isArray(value['replay']) &&
75
+ value['replay'].every((line) => isReplayLine(line)));
76
+ }
77
+ function resolveEntryPath(options, extension = '.json') {
78
+ const anchorDir = path.resolve(options.anchorDir);
79
+ const ref = {
80
+ tool: CACHE_TOOL,
81
+ scopeDir: options.monorepoRoot,
82
+ slug: `${path.basename(anchorDir)}-${options.command.replaceAll(UNSAFE_SLUG_CHARACTERS, '-')}`,
83
+ extension,
84
+ discriminators: [anchorDir, options.command],
85
+ };
86
+ return resolveCacheEntryPath(ref);
87
+ }
@@ -0,0 +1,64 @@
1
+ import type { Writable } from 'node:stream';
2
+ import type { OutputStyle } from '@williamthorsen/nmr-core';
3
+ import { type TreeSnapshot } from './check-cache.js';
4
+ import { type RunPlan } from './invocation.js';
5
+ import type { NmrConfig } from './types.js';
6
+ import type { VerdictOutcome } from './verdict.js';
7
+ /**
8
+ * Decides whether the check-result cache covers this invocation, and takes the tree snapshot on which it would
9
+ * gate.
10
+ * Returns `undefined` when the cache does not apply, which always means the command runs.
11
+ *
12
+ * Decided before anything is resolved or spawned, so that a delegating invocation hands the snapshot to its
13
+ * children rather than leaving each of them to hash the tree again. A hook leaf is out of scope because it is
14
+ * not a command that anyone asks for: it runs as part of the chain that the gate already covers. Arguments are
15
+ * out of scope because they change what a command does in ways that the gate cannot read.
16
+ */
17
+ export declare function openGate(options: {
18
+ command: string;
19
+ config: NmrConfig;
20
+ env: NodeJS.ProcessEnv;
21
+ monorepoRoot: string;
22
+ passthrough: string[];
23
+ stderr: Writable;
24
+ style: OutputStyle;
25
+ }): TreeSnapshot | undefined;
26
+ /**
27
+ * Computes the key under which this invocation would be recorded, or `undefined` when the cache does not apply.
28
+ * The cache does not apply to a `devBin` substitution: the substitute is built from a location that the tree id
29
+ * does not describe, so a pass by it is not a pass by the command that the key names.
30
+ */
31
+ export declare function resolveCacheKey(options: {
32
+ anchorDir: string;
33
+ command: string;
34
+ commandString: string;
35
+ env: NodeJS.ProcessEnv;
36
+ monorepoRoot: string;
37
+ snapshot: TreeSnapshot | undefined;
38
+ stderr: Writable;
39
+ substitution: string | undefined;
40
+ }): string | undefined;
41
+ /**
42
+ * Runs the resolved steps behind the check-result cache: skips them when a recorded pass covers this
43
+ * invocation, and records a pass when one is earned. Returns the exit code and the outcome that a verdict reports,
44
+ * either way, with the host that earned the outcome when it was not this machine. The outcome is absent when the
45
+ * invocation already reported why it did not run.
46
+ *
47
+ * The cache is consulted before the plan's target is: a miss runs on the host to which the invocation is delegated,
48
+ * outside this machine's lock, and runs here only when there is not a host to run on.
49
+ *
50
+ * The override notice waits until the command is going to run, because naming the script that stands in for a
51
+ * built-in says nothing useful about an invocation that skipped it.
52
+ *
53
+ * Two step lists: `steps` is the chain that runs, the hooks wrapped around the command included, and
54
+ * `ownSteps` is the command's alone, which is what a composite's assembly reads. A hook contributes nothing to
55
+ * what a skip replays, as it contributes nothing to a leaf's excerpt.
56
+ */
57
+ export declare function runGated(plan: RunPlan): Promise<GatedResult>;
58
+ /** How a gated invocation ended, and the host that ran it when it was not this machine. */
59
+ interface GatedResult {
60
+ exitCode: number;
61
+ host?: string;
62
+ outcome?: VerdictOutcome;
63
+ }
64
+ export {};
@@ -0,0 +1,226 @@
1
+ import { formatMisplacedNoCacheWarning, isCacheableCommand, resolveTreeSnapshot, writeDebugNote, } from "./check-cache.js";
2
+ import { findStaleBuildOutput, readBuildOutputState } from "./check-cache-build-output.js";
3
+ import { recordDelegatedPass } from "./check-cache-delegation.js";
4
+ import { certifyRetention, readCheckCacheEntry } from "./check-cache-entries.js";
5
+ import { computeCacheKey, computeRetentionKey } from "./check-cache-key.js";
6
+ import { recordPass } from "./check-cache-record.js";
7
+ import { addLockEnv, takeMachineLock } from "./child-env.js";
8
+ import { runPlanOnHost } from "./delegate-host.js";
9
+ import { VERSION } from "./invocation.js";
10
+ import { resolveChannel } from "./output-channel.js";
11
+ import { writePassManifestEntry } from "./pass-manifest.js";
12
+ import { runSteps } from "./runner.js";
13
+ export function openGate(options) {
14
+ const { command, config, env, monorepoRoot, passthrough, stderr, style } = options;
15
+ if (!isCacheableCommand(config.checkCache, command)) {
16
+ return undefined;
17
+ }
18
+ if (passthrough.includes('--no-cache')) {
19
+ stderr.write(`${formatMisplacedNoCacheWarning(command, style)}\n`);
20
+ }
21
+ if (passthrough.length > 0) {
22
+ writeDebugNote(`gate disabled: ${command} was passed arguments`, env, stderr);
23
+ return undefined;
24
+ }
25
+ const snapshot = resolveTreeSnapshot({ monorepoRoot, env });
26
+ if (!snapshot.ok) {
27
+ writeDebugNote(`gate disabled: ${snapshot.reason}`, env, stderr);
28
+ return undefined;
29
+ }
30
+ return snapshot.snapshot;
31
+ }
32
+ export function resolveCacheKey(options) {
33
+ const { env, snapshot, stderr, substitution } = options;
34
+ if (snapshot === undefined) {
35
+ return undefined;
36
+ }
37
+ if (substitution !== undefined) {
38
+ writeDebugNote(`gate disabled: devBin substituted \`${substitution}\``, env, stderr);
39
+ return undefined;
40
+ }
41
+ const result = computeCacheKey({
42
+ anchorDir: options.anchorDir,
43
+ command: options.command,
44
+ commandString: options.commandString,
45
+ env,
46
+ monorepoRoot: options.monorepoRoot,
47
+ nmrVersion: VERSION,
48
+ snapshot,
49
+ });
50
+ if (!result.ok) {
51
+ writeDebugNote(`gate disabled: ${result.reason}`, env, stderr);
52
+ return undefined;
53
+ }
54
+ return result.key;
55
+ }
56
+ export async function runGated(plan) {
57
+ const { commandString, key } = plan;
58
+ const { anchorDir, command, context, env, stderr, stdout } = plan.invocation;
59
+ const { config, monorepoRoot } = context;
60
+ let gate = await readGate(plan);
61
+ if (gate !== undefined && !plan.shouldBypassCache) {
62
+ const recalled = await recallPass(plan, gate);
63
+ if (recalled !== undefined) {
64
+ return recalled;
65
+ }
66
+ }
67
+ const hostResult = await runOnHost(plan, gate);
68
+ if (hostResult !== undefined) {
69
+ return hostResult;
70
+ }
71
+ const lock = await takeMachineLock(plan.invocation);
72
+ try {
73
+ if (lock?.waited === true && key !== undefined) {
74
+ gate = await readGate(plan);
75
+ if (gate !== undefined && !plan.shouldBypassCache) {
76
+ const recalled = await recallPass(plan, gate);
77
+ if (recalled !== undefined) {
78
+ return recalled;
79
+ }
80
+ }
81
+ }
82
+ if (plan.overrideNotice !== undefined) {
83
+ stdout.write(plan.overrideNotice);
84
+ }
85
+ const runOptions = { ...plan.runOptions, env: addLockEnv(plan.runOptions.env ?? env, lock) };
86
+ const startedAtMs = Date.now();
87
+ const { exitCode, retainedOutput } = await runSteps(plan.steps, anchorDir, runOptions);
88
+ const durationMs = Date.now() - startedAtMs;
89
+ if (exitCode === 0 && gate !== undefined) {
90
+ await recordPass({
91
+ anchorDir,
92
+ buildOutputBefore: gate.buildOutputBefore,
93
+ command,
94
+ commandString,
95
+ config,
96
+ durationMs,
97
+ env,
98
+ key: gate.key,
99
+ monorepoRoot,
100
+ ownSteps: plan.ownSteps,
101
+ retainedOutput,
102
+ retentionKey: gate.retentionKey,
103
+ runId: plan.runId,
104
+ snapshot: gate.snapshot,
105
+ stderr,
106
+ });
107
+ }
108
+ return {
109
+ exitCode,
110
+ outcome: exitCode === 0 ? { outcome: 'passed', durationMs } : { outcome: 'failed', durationMs, exitCode },
111
+ };
112
+ }
113
+ finally {
114
+ await lock?.release();
115
+ }
116
+ }
117
+ function composeRecall(entry, retentionKey) {
118
+ return {
119
+ ageMs: Math.max(0, Date.now() - Date.parse(entry.recordedAt)),
120
+ entry,
121
+ savedMs: entry.durationMs,
122
+ ...(entry.retention?.key === retentionKey && { replay: entry.retention.replay }),
123
+ };
124
+ }
125
+ async function lookUpRecordedPass(options) {
126
+ const { anchorDir, buildOutput, command, env, key, monorepoRoot, stderr, targetHosts } = options;
127
+ const entry = await readCheckCacheEntry({ anchorDir, command, monorepoRoot });
128
+ if (entry === undefined) {
129
+ writeDebugNote(`running ${command}: no pass recorded for this scope`, env, stderr);
130
+ return undefined;
131
+ }
132
+ if (entry.key !== key) {
133
+ writeDebugNote(`running ${command}: the tree or its inputs changed since the last pass`, env, stderr);
134
+ return undefined;
135
+ }
136
+ if (entry.host !== undefined) {
137
+ if (targetHosts?.includes(entry.host) !== true) {
138
+ writeDebugNote(`running ${command}: the last pass was earned on ${entry.host}`, env, stderr);
139
+ return undefined;
140
+ }
141
+ return composeRecall(entry, options.retentionKey);
142
+ }
143
+ const [missingPackage] = buildOutput.missingPackages;
144
+ if (missingPackage !== undefined) {
145
+ writeDebugNote(`running ${command}: ${missingPackage} does not have build output`, env, stderr);
146
+ return undefined;
147
+ }
148
+ const stalePackage = findStaleBuildOutput(entry.buildDigests, buildOutput.digests);
149
+ if (stalePackage !== undefined) {
150
+ writeDebugNote(`running ${command}: ${stalePackage}'s build output came from a different tree`, env, stderr);
151
+ return undefined;
152
+ }
153
+ return composeRecall(entry, options.retentionKey);
154
+ }
155
+ async function readGate(plan) {
156
+ const { key, snapshot } = plan;
157
+ const { context, env, stderr, stdout } = plan.invocation;
158
+ if (key === undefined || snapshot === undefined) {
159
+ return undefined;
160
+ }
161
+ const quiet = plan.runOptions.quiet === true;
162
+ const channels = { stderr: resolveChannel(stderr, quiet), stdout: resolveChannel(stdout, quiet) };
163
+ return {
164
+ buildOutputBefore: await readBuildOutputState(context.monorepoRoot, context.config),
165
+ channels,
166
+ key,
167
+ retentionKey: computeRetentionKey({ channels, env, passKey: key }),
168
+ snapshot,
169
+ };
170
+ }
171
+ async function recallPass(plan, gate) {
172
+ const { anchorDir, command, context, env, stderr } = plan.invocation;
173
+ const { monorepoRoot } = context;
174
+ const recalledPass = await lookUpRecordedPass({
175
+ anchorDir,
176
+ buildOutput: gate.buildOutputBefore,
177
+ command,
178
+ env,
179
+ key: gate.key,
180
+ monorepoRoot,
181
+ retentionKey: gate.retentionKey,
182
+ stderr,
183
+ targetHosts: plan.target.kind === 'remote' ? plan.target.hosts : undefined,
184
+ });
185
+ if (recalledPass === undefined) {
186
+ return undefined;
187
+ }
188
+ const { entry, ...recall } = recalledPass;
189
+ if (recall.replay !== undefined) {
190
+ await certifyRetention({ anchorDir, command, entry, env, monorepoRoot, runId: plan.runId, stderr });
191
+ }
192
+ if (entry.host === undefined) {
193
+ await writePassManifestEntry({
194
+ anchorDir,
195
+ command,
196
+ commandString: plan.commandString,
197
+ durationMs: entry.durationMs,
198
+ env,
199
+ monorepoRoot,
200
+ replay: recall.replay,
201
+ stderr,
202
+ treeHash: gate.snapshot.treeId,
203
+ });
204
+ }
205
+ return {
206
+ exitCode: 0,
207
+ ...(entry.host !== undefined && { host: entry.host }),
208
+ outcome: { outcome: 'recalled', ...recall },
209
+ };
210
+ }
211
+ async function runOnHost(plan, gate) {
212
+ if (plan.target.kind !== 'remote') {
213
+ return undefined;
214
+ }
215
+ const hostRun = await runPlanOnHost(plan, plan.target);
216
+ if (hostRun.kind === 'here') {
217
+ return undefined;
218
+ }
219
+ if (hostRun.kind === 'refused') {
220
+ return { exitCode: hostRun.exitCode };
221
+ }
222
+ if (hostRun.exitCode === 0 && gate !== undefined) {
223
+ await recordDelegatedPass(plan, gate, hostRun);
224
+ }
225
+ return { exitCode: hostRun.exitCode, host: hostRun.host, outcome: hostRun.outcome };
226
+ }
@@ -0,0 +1,47 @@
1
+ import type { TreeSnapshot } from './check-cache.js';
2
+ import type { OutputChannels } from './output-channel.js';
3
+ /** The parts of the running interpreter that can change what a check concludes. */
4
+ export interface RuntimeIdentity {
5
+ arch: string;
6
+ nodeVersion: string;
7
+ platform: string;
8
+ }
9
+ /** The interpreter on which this process is running, folded into every key and recorded alongside every pass. */
10
+ export declare const CURRENT_RUNTIME: RuntimeIdentity;
11
+ /**
12
+ * Folds everything that can change what a command concludes into one key: the tree's content, the command
13
+ * string that would run, the scope in which it would run, nmr's own version, the interpreter, what is installed,
14
+ * and the environment variables that a check can read. A hit is this key matching a recorded one, so an ingredient left
15
+ * out here is an ingredient that could change while the cache still claims a pass.
16
+ *
17
+ * Reports a reason instead of a key when the install fingerprint cannot be read, which disables the gate.
18
+ */
19
+ export declare function computeCacheKey(options: {
20
+ anchorDir: string;
21
+ command: string;
22
+ commandString: string;
23
+ env: NodeJS.ProcessEnv;
24
+ monorepoRoot: string;
25
+ nmrVersion: string;
26
+ runtime?: RuntimeIdentity;
27
+ snapshot: TreeSnapshot;
28
+ }): {
29
+ ok: true;
30
+ key: string;
31
+ } | {
32
+ ok: false;
33
+ reason: string;
34
+ };
35
+ /**
36
+ * Folds what changes a transcript without changing a conclusion onto the pass key: the channel on which each of
37
+ * the command's output streams ran, and the environment variables through which a tool presents itself.
38
+ *
39
+ * The channel kind keeps a run at a terminal from replaying a piped recording. It is the channel's kind and not
40
+ * whether a terminal is attached: under quiet mode the child writes to pipes at a terminal, and its transcript
41
+ * is reproducible.
42
+ */
43
+ export declare function computeRetentionKey(options: {
44
+ channels: OutputChannels;
45
+ env: NodeJS.ProcessEnv;
46
+ passKey: string;
47
+ }): string;
@@ -0,0 +1,77 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+ export const CURRENT_RUNTIME = {
6
+ arch: process.arch,
7
+ nodeVersion: process.version,
8
+ platform: process.platform,
9
+ };
10
+ const INSTALL_FINGERPRINT_FILES = [
11
+ path.join('node_modules', '.modules.yaml'),
12
+ path.join('node_modules', '.pnpm', 'lock.yaml'),
13
+ ];
14
+ const KEY_FORMAT = 'nmr-check-cache-v2';
15
+ const KEYED_ENV_VARS = ['LANG', 'LC_ALL', 'NODE_OPTIONS', 'TZ'];
16
+ const RETENTION_KEY_FORMAT = 'nmr-retention-v1';
17
+ const RETENTION_KEYED_ENV_VARS = ['CI', 'COLUMNS', 'FORCE_COLOR', 'NO_COLOR', 'TERM'];
18
+ export function computeCacheKey(options) {
19
+ const fingerprint = resolveInstallFingerprint(options.monorepoRoot);
20
+ if (!fingerprint.ok) {
21
+ return fingerprint;
22
+ }
23
+ const runtime = options.runtime ?? CURRENT_RUNTIME;
24
+ const scope = path.relative(options.monorepoRoot, options.anchorDir);
25
+ const parts = [
26
+ KEY_FORMAT,
27
+ options.snapshot.treeId,
28
+ scope === '' ? '.' : scope,
29
+ options.command,
30
+ options.commandString,
31
+ options.nmrVersion,
32
+ runtime.nodeVersion,
33
+ runtime.platform,
34
+ runtime.arch,
35
+ fingerprint.fingerprint,
36
+ ];
37
+ return { ok: true, key: digestParts([...parts, ...composeEnvParts(KEYED_ENV_VARS, options.env)]) };
38
+ }
39
+ export function computeRetentionKey(options) {
40
+ const parts = [
41
+ RETENTION_KEY_FORMAT,
42
+ options.passKey,
43
+ describeChannel(options.channels.stdout),
44
+ describeChannel(options.channels.stderr),
45
+ ];
46
+ return digestParts([...parts, ...composeEnvParts(RETENTION_KEYED_ENV_VARS, options.env)]);
47
+ }
48
+ function composeEnvParts(names, env) {
49
+ return names.flatMap((name) => {
50
+ const value = env[name];
51
+ return [name, value === undefined ? 'unset' : 'set', value ?? ''];
52
+ });
53
+ }
54
+ function describeChannel(channel) {
55
+ return channel === 'pipe' ? 'pipe' : 'descriptor';
56
+ }
57
+ function digestParts(parts) {
58
+ const hash = createHash('sha256');
59
+ for (const part of parts) {
60
+ hash.update(part);
61
+ hash.update('\0');
62
+ }
63
+ return hash.digest('hex');
64
+ }
65
+ function resolveInstallFingerprint(monorepoRoot) {
66
+ const hash = createHash('sha256');
67
+ for (const relativePath of INSTALL_FINGERPRINT_FILES) {
68
+ try {
69
+ hash.update(readFileSync(path.join(monorepoRoot, relativePath)));
70
+ }
71
+ catch {
72
+ return { ok: false, reason: `no install fingerprint: ${relativePath} is unreadable` };
73
+ }
74
+ hash.update('\0');
75
+ }
76
+ return { ok: true, fingerprint: hash.digest('hex') };
77
+ }
@@ -0,0 +1,69 @@
1
+ import type { Writable } from 'node:stream';
2
+ import { type TreeSnapshot } from './check-cache.js';
3
+ import { type BuildOutputState } from './check-cache-build-output.js';
4
+ import { type EntryRef, type Retention } from './check-cache-entries.js';
5
+ import { type RunCliResult, type RunPlan } from './invocation.js';
6
+ import type { RetainedOutput } from './runner.js';
7
+ import type { Step } from './steps.js';
8
+ import type { NmrConfig } from './types.js';
9
+ /**
10
+ * Records a pass that `host` earned, unless a pass earned here under the same key would still be recalled, because
11
+ * that pass satisfies every invocation that the host pass would. One whose build output is missing or came from
12
+ * another tree no longer satisfies any invocation, and the host pass replaces it.
13
+ *
14
+ * A host pass is recorded without build digests, because this machine's build output says nothing about the host's.
15
+ * The caller has already held the host's tree to the tree on which the key was computed.
16
+ */
17
+ export declare function recordHostPass(options: {
18
+ commandString: string;
19
+ config: NmrConfig;
20
+ durationMs: number;
21
+ env: NodeJS.ProcessEnv;
22
+ host: string;
23
+ key: string;
24
+ monorepoRoot: string;
25
+ ref: EntryRef;
26
+ retainedOutput: RetainedOutput | undefined;
27
+ retention: Retention | undefined;
28
+ snapshot: TreeSnapshot;
29
+ stderr: Writable;
30
+ }): Promise<void>;
31
+ /**
32
+ * Records a pass, unless what the check was asked about moved while it ran: a rewritten file describes a tree
33
+ * that no longer exists, and build output that changed leaves it unclear over which output the pass was earned.
34
+ * Recording either would certify content against which nothing ran. The recorded key is the one that the
35
+ * snapshot produced, so every entry from one invocation refers to one tree.
36
+ *
37
+ * A cache that cannot be written is not worth failing a green run over: `recordPass` reports that failure in the
38
+ * debug note.
39
+ *
40
+ * The retention that a skip replays is composed here for the same reason and after the same tests: a pass that is
41
+ * not recorded must not leave behind an excerpt that claims it was. A composite's assembly is built here rather
42
+ * than when it skips, to ensure that every excerpt in it was certified during the run whose pass contains it.
43
+ */
44
+ export declare function recordPass(options: {
45
+ anchorDir: string;
46
+ buildOutputBefore: BuildOutputState;
47
+ command: string;
48
+ commandString: string;
49
+ config: NmrConfig;
50
+ durationMs: number;
51
+ env: NodeJS.ProcessEnv;
52
+ key: string;
53
+ monorepoRoot: string;
54
+ retainedOutput: RetainedOutput | undefined;
55
+ ownSteps: readonly Step[];
56
+ retentionKey: string;
57
+ runId: string;
58
+ snapshot: TreeSnapshot;
59
+ stderr: Writable;
60
+ }): Promise<void>;
61
+ /**
62
+ * Prints what the current scope has recorded for one command, and reports what it exits with.
63
+ *
64
+ * A refusal ends the invocation non-zero, which is what tells a caller that nothing on stdout is the run that it
65
+ * asked for. Under a delegate it does not: a fan-out asks every selected scope, and a scope that never ran the
66
+ * command is a gap in a survey rather than a failure of one, so bailing there would hide every scope that has
67
+ * something to show.
68
+ */
69
+ export declare function reportRecording(plan: RunPlan): Promise<RunCliResult>;