@williamthorsen/nmr 0.47.0 → 0.49.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 (117) hide show
  1. package/.meta/changelog.json +206 -0
  2. package/.readyup/kits/default.js +1534 -1466
  3. package/.readyup/manifest.json +142 -12
  4. package/CHANGELOG.md +55 -0
  5. package/README.md +44 -6
  6. package/agents/guidance/rulebooks/nmr.md +2 -1
  7. package/dist/esm/bin-path.js +4 -3
  8. package/dist/esm/check-cache-build-output.d.ts +24 -0
  9. package/dist/esm/check-cache-build-output.js +49 -0
  10. package/dist/esm/check-cache-delegation.d.ts +27 -0
  11. package/dist/esm/check-cache-delegation.js +92 -0
  12. package/dist/esm/check-cache-entries.d.ts +109 -0
  13. package/dist/esm/check-cache-entries.js +87 -0
  14. package/dist/esm/check-cache-gate.d.ts +64 -0
  15. package/dist/esm/check-cache-gate.js +226 -0
  16. package/dist/esm/check-cache-key.d.ts +47 -0
  17. package/dist/esm/check-cache-key.js +77 -0
  18. package/dist/esm/check-cache-record.d.ts +69 -0
  19. package/dist/esm/check-cache-record.js +167 -0
  20. package/dist/esm/check-cache.d.ts +8 -176
  21. package/dist/esm/check-cache.js +9 -214
  22. package/dist/esm/child-env.d.ts +57 -0
  23. package/dist/esm/child-env.js +59 -0
  24. package/dist/esm/child-process.d.ts +55 -0
  25. package/dist/esm/child-process.js +112 -0
  26. package/dist/esm/commands/build.d.ts +0 -16
  27. package/dist/esm/commands/build.js +9 -214
  28. package/dist/esm/commands/clean.js +3 -2
  29. package/dist/esm/commands/compiler.d.ts +18 -0
  30. package/dist/esm/commands/compiler.js +49 -0
  31. package/dist/esm/commands/fmt.d.ts +0 -39
  32. package/dist/esm/commands/fmt.js +4 -81
  33. package/dist/esm/commands/format-targets.d.ts +39 -0
  34. package/dist/esm/commands/format-targets.js +83 -0
  35. package/dist/esm/commands/rewrite-specifiers.d.ts +17 -0
  36. package/dist/esm/commands/rewrite-specifiers.js +122 -0
  37. package/dist/esm/commands/tsconfig-chain.d.ts +8 -0
  38. package/dist/esm/commands/tsconfig-chain.js +63 -0
  39. package/dist/esm/config-check-cache.d.ts +17 -0
  40. package/dist/esm/config-check-cache.js +63 -0
  41. package/dist/esm/config-validation.d.ts +20 -0
  42. package/dist/esm/config-validation.js +236 -0
  43. package/dist/esm/config.js +4 -286
  44. package/dist/esm/crossing-diagnostics.d.ts +39 -0
  45. package/dist/esm/crossing-diagnostics.js +132 -0
  46. package/dist/esm/default-scripts.js +1 -1
  47. package/dist/esm/delegate-host.d.ts +43 -0
  48. package/dist/esm/delegate-host.js +178 -0
  49. package/dist/esm/delegation-target.d.ts +52 -0
  50. package/dist/esm/delegation-target.js +71 -0
  51. package/dist/esm/fan-out.d.ts +57 -0
  52. package/dist/esm/fan-out.js +146 -0
  53. package/dist/esm/glyphs.d.ts +1 -1
  54. package/dist/esm/help.js +10 -0
  55. package/dist/esm/helpers/deriveExcerpt.d.ts +9 -0
  56. package/dist/esm/helpers/deriveExcerpt.js +1 -1
  57. package/dist/esm/helpers/findClosestName.js +4 -3
  58. package/dist/esm/helpers/quoteForShell.d.ts +5 -0
  59. package/dist/esm/helpers/quoteForShell.js +3 -0
  60. package/dist/esm/helpers/renderQuotedList.d.ts +2 -0
  61. package/dist/esm/helpers/renderQuotedList.js +9 -0
  62. package/dist/esm/hooks.d.ts +17 -0
  63. package/dist/esm/hooks.js +23 -0
  64. package/dist/esm/invocation-args.d.ts +40 -0
  65. package/dist/esm/invocation-args.js +178 -0
  66. package/dist/esm/invocation.d.ts +67 -0
  67. package/dist/esm/invocation.js +3 -0
  68. package/dist/esm/machine-lock.d.ts +2 -0
  69. package/dist/esm/machine-lock.js +1 -1
  70. package/dist/esm/output-channel.d.ts +23 -0
  71. package/dist/esm/output-channel.js +14 -0
  72. package/dist/esm/pass-manifest.d.ts +53 -0
  73. package/dist/esm/pass-manifest.js +89 -0
  74. package/dist/esm/presentation.d.ts +47 -0
  75. package/dist/esm/presentation.js +32 -0
  76. package/dist/esm/recording.d.ts +1 -1
  77. package/dist/esm/recording.js +2 -2
  78. package/dist/esm/remote-host-session.d.ts +62 -0
  79. package/dist/esm/remote-host-session.js +277 -0
  80. package/dist/esm/remote-probe-host.d.ts +22 -0
  81. package/dist/esm/remote-probe-host.js +50 -0
  82. package/dist/esm/remote-probe.d.ts +31 -0
  83. package/dist/esm/remote-probe.js +78 -0
  84. package/dist/esm/remote-run.d.ts +91 -0
  85. package/dist/esm/remote-run.js +189 -0
  86. package/dist/esm/remote-session.d.ts +40 -0
  87. package/dist/esm/remote-session.js +151 -0
  88. package/dist/esm/remote-snapshot.d.ts +21 -0
  89. package/dist/esm/remote-snapshot.js +80 -0
  90. package/dist/esm/replay-assembly.d.ts +2 -2
  91. package/dist/esm/replay-assembly.js +1 -1
  92. package/dist/esm/run-chain.d.ts +21 -0
  93. package/dist/esm/run-chain.js +108 -0
  94. package/dist/esm/runCli.d.ts +1 -9
  95. package/dist/esm/runCli.js +41 -895
  96. package/dist/esm/runner.d.ts +1 -54
  97. package/dist/esm/runner.js +2 -125
  98. package/dist/esm/shell-syntax.d.ts +30 -0
  99. package/dist/esm/shell-syntax.js +120 -0
  100. package/dist/esm/steps.d.ts +0 -2
  101. package/dist/esm/steps.js +3 -119
  102. package/dist/esm/types.d.ts +5 -0
  103. package/dist/esm/user-config.d.ts +34 -0
  104. package/dist/esm/user-config.js +93 -0
  105. package/dist/esm/verbosity.d.ts +6 -0
  106. package/dist/esm/verbosity.js +7 -7
  107. package/dist/esm/verdict.d.ts +3 -1
  108. package/dist/esm/verdict.js +4 -3
  109. package/dist/esm/vitest-source-resolution.js +2 -3
  110. package/docs/check-cache.md +30 -14
  111. package/docs/delegate.md +141 -0
  112. package/docs/machine-lock.md +2 -1
  113. package/docs/reporting.md +6 -1
  114. package/docs/scripts.md +2 -1
  115. package/package.json +5 -5
  116. /package/dist/esm/{prettier-default-config.d.ts → prettier-default.config.d.ts} +0 -0
  117. /package/dist/esm/{prettier-default-config.js → prettier-default.config.js} +0 -0
@@ -0,0 +1,109 @@
1
+ import type { Writable } from 'node:stream';
2
+ /** A recorded pass: what ran, on which tree, and how long it took. */
3
+ export interface CheckCacheEntry {
4
+ /** The full cache key under which the pass was recorded; a hit is this key matching the key computed now. */
5
+ key: string;
6
+ treeHash: string;
7
+ headSha: string;
8
+ commandString: string;
9
+ nmrVersion: string;
10
+ nodeVersion: string;
11
+ durationMs: number;
12
+ /** ISO-8601 instant the pass completed. */
13
+ recordedAt: string;
14
+ /**
15
+ * The build digest that each covered package's output had when the pass was recorded. Build output is
16
+ * git-ignored, so the tree id cannot describe it; comparing these separates output built from this tree from
17
+ * output that another tree left behind.
18
+ */
19
+ buildDigests: Record<string, string>;
20
+ /** The host that earned a delegated pass, absent on a pass earned on this machine. */
21
+ host?: string;
22
+ /** What a skip replays in place of the run that it recalls, absent on a pass that retained nothing. */
23
+ retention?: Retention;
24
+ }
25
+ /** What locates one command's recorded pass at one scope, and the transcript beside it. */
26
+ export interface EntryRef {
27
+ anchorDir: string;
28
+ command: string;
29
+ monorepoRoot: string;
30
+ }
31
+ /** One command's excerpt, attributed to the scope and the command that produced it. */
32
+ export interface ReplayLine {
33
+ command: string;
34
+ excerpt: string;
35
+ scope: string;
36
+ }
37
+ /**
38
+ * What a recalled pass replays, the key certifying the excerpts describe this environment's output, and the
39
+ * run that last vouched for them.
40
+ *
41
+ * A list rather than one excerpt: a composite's entry contains its constituents' lines, and a nested one's are
42
+ * spliced into its parent's, after which the attribution cannot be re-derived from the entry that contains them.
43
+ *
44
+ * The witness admits a constituent's line into the assembly that its parent records: a run writes it when it
45
+ * records an excerpt, and restamps it when it recalls one and replays it.
46
+ */
47
+ export interface Retention {
48
+ key: string;
49
+ replay: ReplayLine[];
50
+ runId: string;
51
+ }
52
+ /**
53
+ * Restamps a recalled entry's retention with the run that is replaying it, so that an excerpt certified by this
54
+ * run can join the assembly recorded by a composite above it.
55
+ *
56
+ * A recall certifies as surely as a recording does. Because the pass key matched, the excerpt describes this
57
+ * tree; because the caller calls this only when the retention key matched too, the excerpt also describes this
58
+ * presentation environment. Everything else the entry records stands -- the instant and the duration belong to
59
+ * the run that earned the pass, and a recall must not make it read as later or longer than it was.
60
+ *
61
+ * A cache that cannot be written is not worth failing a green run over: `certifyRetention` reports that failure
62
+ * in the debug note.
63
+ */
64
+ export declare function certifyRetention(options: {
65
+ anchorDir: string;
66
+ command: string;
67
+ entry: CheckCacheEntry;
68
+ env: NodeJS.ProcessEnv;
69
+ monorepoRoot: string;
70
+ runId: string;
71
+ stderr: Writable;
72
+ }): Promise<void>;
73
+ /**
74
+ * Reads the entry recorded for one command at one scope, or `undefined` when there is none to trust.
75
+ *
76
+ * Retention is vouched for separately from the pass that contains it: an excerpt of a shape that this function
77
+ * cannot read is dropped, leaving a pass that skips cleanly and reports its verdict alone.
78
+ */
79
+ export declare function readCheckCacheEntry(options: {
80
+ anchorDir: string;
81
+ command: string;
82
+ monorepoRoot: string;
83
+ }): Promise<CheckCacheEntry | undefined>;
84
+ /**
85
+ * Reads the whole output that one recorded pass retained, or `undefined` when it retained none.
86
+ *
87
+ * Held to nothing on its own: the entry beside it says which tree the bytes describe, and a caller
88
+ * that has not matched the entry's key is reading a transcript of some other tree.
89
+ */
90
+ export declare function readTranscript(ref: EntryRef): Promise<string | undefined>;
91
+ /**
92
+ * Makes the transcript beside one entry be exactly what this pass retained, removing what an earlier pass
93
+ * left when this one retained nothing.
94
+ *
95
+ * A composite retains nothing of its own, so without the removal a leaf's transcript would remain beside an
96
+ * entry that never produced it, and `--log` would date another run's bytes by this one's instant.
97
+ */
98
+ export declare function recordTranscript(ref: EntryRef, transcript: string | undefined): Promise<void>;
99
+ /** Removes every recorded pass for a monorepo, or for a standalone package outside one. */
100
+ export declare function removeCheckCache(scopeDir: string): Promise<void>;
101
+ /** Narrows a recorded replay line, whose three fields a rendered line uses in full. */
102
+ export declare function isReplayLine(value: unknown): value is ReplayLine;
103
+ /** Records a pass, replacing whatever this command last recorded at this scope. */
104
+ export declare function writeCheckCacheEntry(options: {
105
+ anchorDir: string;
106
+ command: string;
107
+ entry: CheckCacheEntry;
108
+ monorepoRoot: string;
109
+ }): Promise<void>;
@@ -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>;