@williamthorsen/nmr 0.23.0 → 0.24.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.
@@ -1,11 +1,34 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
1
9
  import { existsSync } from 'node:fs';
2
10
  import path from 'node:path';
3
- import { createJiti } from 'jiti';
4
- import { isObject } from "./helpers/type-guards.js";
11
+ import { pathToFileURL } from 'node:url';
12
+ import { isObject, isStringRecord } from "./helpers/type-guards.js";
5
13
  const CONFIG_FILENAME = 'nmr.config.ts';
6
14
  const CONFIG_DIR = '.config';
7
- export function defineConfig(config) {
8
- return config;
15
+ const CONFIG_TIERS = {
16
+ root: {
17
+ label: 'a monorepo-root config',
18
+ honoredKeys: ['checkCache', 'devBin', 'rootScripts', 'workspaceScripts'],
19
+ elsewhere: "the package's own config",
20
+ },
21
+ workspace: {
22
+ label: 'a package config',
23
+ honoredKeys: ['build'],
24
+ elsewhere: 'the monorepo-root config',
25
+ },
26
+ };
27
+ const RECOGNIZED_KEYS = [...CONFIG_TIERS.root.honoredKeys, ...CONFIG_TIERS.workspace.honoredKeys];
28
+ const RECOGNIZED_BUILD_KEYS = ['extraIgnorePatterns'];
29
+ const RECOGNIZED_CHECK_CACHE_KEYS = ['enabled', 'excludeCommands', 'extraCommands'];
30
+ export function resolveConfigPath(baseDir) {
31
+ return path.join(baseDir, CONFIG_DIR, CONFIG_FILENAME);
9
32
  }
10
33
  function isScriptRecord(value) {
11
34
  if (!isObject(value))
@@ -27,14 +50,52 @@ function validateScriptField(value, fieldName, configPath) {
27
50
  }
28
51
  return value[fieldName];
29
52
  }
30
- function isStringRecord(value) {
31
- if (!isObject(value))
32
- return false;
33
- for (const v of Object.values(value)) {
34
- if (typeof v !== 'string')
35
- return false;
53
+ function isStringArray(value) {
54
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
55
+ }
56
+ function validateBuildField(value, configPath) {
57
+ const build = value.build;
58
+ if (build === undefined) {
59
+ return undefined;
36
60
  }
37
- return true;
61
+ if (!isObject(build)) {
62
+ throw new Error(`Invalid nmr config at ${configPath}: \`build\` must be an object`);
63
+ }
64
+ assertRecognizedKeys(build, RECOGNIZED_BUILD_KEYS, configPath, 'build.');
65
+ const extraIgnorePatterns = build.extraIgnorePatterns;
66
+ if (extraIgnorePatterns !== undefined && !isStringArray(extraIgnorePatterns)) {
67
+ throw new Error(`Invalid nmr config at ${configPath}: \`build.extraIgnorePatterns\` must be a string[]`);
68
+ }
69
+ return extraIgnorePatterns === undefined ? {} : { extraIgnorePatterns };
70
+ }
71
+ function validateCheckCacheField(value, configPath) {
72
+ const checkCache = value.checkCache;
73
+ if (checkCache === undefined) {
74
+ return undefined;
75
+ }
76
+ if (!isObject(checkCache)) {
77
+ throw new Error(`Invalid nmr config at ${configPath}: \`checkCache\` must be an object`);
78
+ }
79
+ assertRecognizedKeys(checkCache, RECOGNIZED_CHECK_CACHE_KEYS, configPath, 'checkCache.');
80
+ const config = {};
81
+ const enabled = checkCache.enabled;
82
+ if (enabled !== undefined) {
83
+ if (typeof enabled !== 'boolean') {
84
+ throw new Error(`Invalid nmr config at ${configPath}: \`checkCache.enabled\` must be a boolean`);
85
+ }
86
+ config.enabled = enabled;
87
+ }
88
+ for (const field of ['excludeCommands', 'extraCommands']) {
89
+ const commands = checkCache[field];
90
+ if (commands === undefined) {
91
+ continue;
92
+ }
93
+ if (!isStringArray(commands)) {
94
+ throw new Error(`Invalid nmr config at ${configPath}: \`checkCache.${field}\` must be a string[]`);
95
+ }
96
+ config[field] = commands;
97
+ }
98
+ return config;
38
99
  }
39
100
  function validateStringRecordField(value, fieldName, configPath) {
40
101
  if (!(fieldName in value) || value[fieldName] === undefined) {
@@ -49,7 +110,14 @@ function validateConfig(value, configPath) {
49
110
  if (!isObject(value)) {
50
111
  throw new Error(`Invalid nmr config at ${configPath}: expected an object, got ${typeof value}`);
51
112
  }
113
+ assertRecognizedKeys(value, RECOGNIZED_KEYS, configPath);
52
114
  const config = {};
115
+ const build = validateBuildField(value, configPath);
116
+ if (build)
117
+ config.build = build;
118
+ const checkCache = validateCheckCacheField(value, configPath);
119
+ if (checkCache)
120
+ config.checkCache = checkCache;
53
121
  const devBin = validateStringRecordField(value, 'devBin', configPath);
54
122
  if (devBin)
55
123
  config.devBin = devBin;
@@ -61,12 +129,44 @@ function validateConfig(value, configPath) {
61
129
  config.rootScripts = rootScripts;
62
130
  return config;
63
131
  }
64
- export async function loadConfig(monorepoRoot) {
65
- const configPath = path.join(monorepoRoot, CONFIG_DIR, CONFIG_FILENAME);
132
+ export async function loadConfig(baseDir) {
133
+ const configPath = resolveConfigPath(baseDir);
66
134
  if (!existsSync(configPath)) {
67
135
  return {};
68
136
  }
69
- const jiti = createJiti(path.join(monorepoRoot, 'package.json'));
70
- const loaded = await jiti.import(configPath, { default: true });
137
+ const imported = await import(__rewriteRelativeImportExtension(pathToFileURL(configPath).href));
138
+ const loaded = isObject(imported) ? imported.default : undefined;
71
139
  return validateConfig(loaded, configPath);
72
140
  }
141
+ export async function loadRootConfig(monorepoRoot) {
142
+ const config = await loadConfig(monorepoRoot);
143
+ assertTierKeys(config, CONFIG_TIERS.root, resolveConfigPath(monorepoRoot));
144
+ return config;
145
+ }
146
+ export async function loadWorkspaceConfig(packageDir) {
147
+ const config = await loadConfig(packageDir);
148
+ assertTierKeys(config, CONFIG_TIERS.workspace, resolveConfigPath(packageDir));
149
+ return config;
150
+ }
151
+ function assertRecognizedKeys(value, recognizedKeys, configPath, prefix = '') {
152
+ const unrecognized = Object.keys(value).filter((key) => !recognizedKeys.includes(key));
153
+ if (unrecognized.length === 0) {
154
+ return;
155
+ }
156
+ throw new Error(`Invalid nmr config at ${configPath}: unrecognized ${unrecognized.length === 1 ? 'key' : 'keys'} ` +
157
+ `${formatKeyList(unrecognized, prefix)}. Recognized: ${formatKeyList(recognizedKeys, prefix)}.`);
158
+ }
159
+ function assertTierKeys(config, tier, configPath) {
160
+ const unsupported = Object.keys(config).filter((key) => !tier.honoredKeys.includes(key));
161
+ if (unsupported.length === 0) {
162
+ return;
163
+ }
164
+ throw new Error(`Invalid nmr config at ${configPath}: ${tier.label} honors ${tier.honoredKeys.toSorted().join(', ')} alone, ` +
165
+ `not ${unsupported.toSorted().join(', ')}. Move those keys to ${tier.elsewhere}.`);
166
+ }
167
+ function formatKeyList(keys, prefix) {
168
+ return keys
169
+ .toSorted()
170
+ .map((key) => `\`${prefix}${key}\``)
171
+ .join(', ');
172
+ }
@@ -1,4 +1,4 @@
1
- import type { NmrConfig } from './config.js';
1
+ import type { NmrConfig } from './types.js';
2
2
  export interface ResolvedContext {
3
3
  monorepoRoot: string;
4
4
  isRoot: boolean;
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { loadConfig } from "./config.js";
2
+ import { loadRootConfig } from "./config.js";
3
3
  import { findMonorepoRoot, getWorkspacePackageDirs } from "./workspace.js";
4
4
  export function findContainingPackageDir(dir, workspacePackageDirs) {
5
5
  const resolved = path.resolve(dir);
@@ -17,7 +17,7 @@ export function findContainingPackageDir(dir, workspacePackageDirs) {
17
17
  export async function resolveContext(cwd) {
18
18
  const resolvedCwd = path.resolve(cwd ?? process.cwd());
19
19
  const monorepoRoot = findMonorepoRoot(resolvedCwd);
20
- const config = await loadConfig(monorepoRoot);
20
+ const config = await loadRootConfig(monorepoRoot);
21
21
  const workspaceDirs = getWorkspacePackageDirs(monorepoRoot);
22
22
  const packageDir = findContainingPackageDir(resolvedCwd, workspaceDirs);
23
23
  return {
@@ -1,3 +1,4 @@
1
+ const GATE_PROJECTS = '--project unit --project tool';
1
2
  export const workspaceScripts = {
2
3
  build: ['compile'],
3
4
  check: ['typecheck', 'fmt:check', 'lint:check', 'test'],
@@ -11,11 +12,12 @@ export const workspaceScripts = {
11
12
  lint: 'eslint --fix .',
12
13
  'lint:check': 'eslint .',
13
14
  'lint:strict': 'strict-lint',
14
- test: "pnpm exec vitest --project '!integration'",
15
+ test: `pnpm exec vitest ${GATE_PROJECTS}`,
15
16
  'test:all': 'pnpm exec vitest',
16
- 'test:coverage': "pnpm exec vitest --project '!integration' --coverage",
17
- 'test:integration': 'pnpm exec vitest --project integration',
18
- 'test:watch': "pnpm exec vitest --project '!integration' --watch",
17
+ 'test:coverage': `pnpm exec vitest ${GATE_PROJECTS} --coverage`,
18
+ 'test:tool': 'pnpm exec vitest --project tool',
19
+ 'test:unit': 'pnpm exec vitest --project unit',
20
+ 'test:watch': `pnpm exec vitest ${GATE_PROJECTS} --watch`,
19
21
  typecheck: 'tsgo --noEmit',
20
22
  upgrade: 'nmr-taze --include-locked',
21
23
  'view-coverage': 'open coverage/index.html',
@@ -28,7 +30,7 @@ export const rootScripts = {
28
30
  check: ['typecheck', 'fmt:check', 'lint:check', 'test'],
29
31
  'check:agent-files': 'nmr-sync-agent-files --check',
30
32
  'check:strict': ['typecheck', 'fmt:check', 'lint:strict', 'test:coverage', 'check:agent-files'],
31
- ci: ['build', 'check:strict', 'audit'],
33
+ ci: ['build', 'check:strict'],
32
34
  clean: 'nmr-clean',
33
35
  fix: ['lint', 'fmt'],
34
36
  'fix:check': ['fmt:check', 'lint:check'],
@@ -37,22 +39,25 @@ export const rootScripts = {
37
39
  lint: 'nmr root:lint && pnpm --recursive exec nmr lint',
38
40
  'lint:check': 'nmr root:lint:check && pnpm --recursive exec nmr lint:check',
39
41
  'lint:strict': 'nmr root:lint:strict && pnpm --recursive exec nmr lint:strict',
42
+ prepush: ['ci', 'audit'],
40
43
  'report-overrides': 'nmr-report-overrides',
41
44
  'root:check': ['root:typecheck', 'fmt:check', 'root:lint:check', 'root:test'],
42
45
  'root:lint': "eslint --fix --ignore-pattern 'packages/**' .",
43
46
  'root:lint:check': "eslint --ignore-pattern 'packages/**' .",
44
47
  'root:lint:strict': "strict-lint --ignore-pattern 'packages/**' .",
45
- 'root:test': "vitest --config ./vitest.root.config.ts --project '!integration'",
48
+ 'root:test': `vitest --config ./vitest.root.config.ts ${GATE_PROJECTS}`,
46
49
  'root:test:all': 'vitest --config ./vitest.root.config.ts',
47
- 'root:test:integration': 'vitest --config ./vitest.root.config.ts --project integration',
50
+ 'root:test:tool': 'vitest --config ./vitest.root.config.ts --project tool',
51
+ 'root:test:unit': 'vitest --config ./vitest.root.config.ts --project unit',
48
52
  'root:typecheck': 'tsgo --noEmit',
49
53
  'root:upgrade': 'nmr-taze --include-locked',
50
54
  'sync-agent-files': 'nmr-sync-agent-files',
51
55
  test: 'nmr root:test && pnpm --recursive exec nmr test',
52
56
  'test:all': 'nmr root:test:all && pnpm --recursive exec nmr test:all',
53
57
  'test:coverage': 'nmr root:test && pnpm --recursive exec nmr test:coverage',
54
- 'test:integration': 'nmr root:test:integration && pnpm --recursive exec nmr test:integration',
55
- 'test:watch': "vitest --project '!integration' --watch",
58
+ 'test:tool': 'nmr root:test:tool && pnpm --recursive exec nmr test:tool',
59
+ 'test:unit': 'nmr root:test:unit && pnpm --recursive exec nmr test:unit',
60
+ 'test:watch': `vitest ${GATE_PROJECTS} --watch`,
56
61
  typecheck: 'nmr root:typecheck && pnpm --recursive exec nmr typecheck',
57
62
  upgrade: 'nmr-report-overrides && nmr-taze --include-locked --recursive',
58
63
  };
@@ -0,0 +1,3 @@
1
+ import type { NmrConfig } from './types.js';
2
+ export type { BuildConfig, CheckCacheConfig, NmrConfig } from './types.js';
3
+ export declare function defineConfig(config: NmrConfig): NmrConfig;
@@ -0,0 +1,3 @@
1
+ export function defineConfig(config) {
2
+ return config;
3
+ }
@@ -1,2 +1,2 @@
1
- import type { NmrConfig } from './config.js';
1
+ import type { NmrConfig } from './types.js';
2
2
  export declare function generateHelp(config: NmrConfig, packageDir: string | undefined, useRoot: boolean): string;
package/dist/esm/help.js CHANGED
@@ -9,6 +9,7 @@ export function generateHelp(config, packageDir, useRoot) {
9
9
  ' -R, --recursive Run command in all packages',
10
10
  ' -w, --workspace-root Use root scripts, running at the monorepo root',
11
11
  ' -q, --quiet Suppress output on success; show full output on failure',
12
+ ' --no-cache Run even if this tree already passed; record the result',
12
13
  ' -?, --help Show this help',
13
14
  ' -V, --version Show version number',
14
15
  '',
@@ -1 +1,2 @@
1
1
  export declare function isObject(value: unknown): value is Record<string, unknown>;
2
+ export declare function isStringRecord(value: unknown): value is Record<string, string>;
@@ -1,3 +1,6 @@
1
1
  export function isObject(value) {
2
2
  return typeof value === 'object' && value !== null && !Array.isArray(value);
3
3
  }
4
+ export function isStringRecord(value) {
5
+ return isObject(value) && Object.values(value).every((entry) => typeof entry === 'string');
6
+ }
@@ -1,5 +1,5 @@
1
- import type { NmrConfig } from './config.js';
2
1
  import type { ScriptRegistry, ScriptValue } from './resolve-scripts.js';
2
+ import type { NmrConfig } from './types.js';
3
3
  export declare function applyDevBin(command: string, devBin: Record<string, string> | undefined, monorepoRoot: string): string;
4
4
  export interface ResolvedScript {
5
5
  command: string;
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { readPackageVersion, reportError } from '@williamthorsen/nmr-core';
3
+ import { computeCacheKey, CURRENT_RUNTIME, encodeTreeSnapshot, findStaleBuildOutput, formatMisplacedNoCacheWarning, formatSkipLine, NO_CACHE_ENV_VAR, readBuildOutputState, readCheckCacheEntry, resolveCacheableCommands, resolveTreeSnapshot, TREE_SNAPSHOT_ENV_VAR, writeCheckCacheEntry, writeDebugNote, } from "./check-cache.js";
3
4
  import { resolveContext } from "./context.js";
4
5
  import { generateHelp } from "./help.js";
5
6
  import { isHookName } from "./helpers/hook-name.js";
@@ -27,16 +28,26 @@ export async function runCli(options) {
27
28
  }
28
29
  const { command } = parsed;
29
30
  const passthrough = parsed.passthrough.length > 0 ? ' ' + parsed.passthrough.map(shellQuote).join(' ') : '';
30
- const runOptions = { quiet: parsed.quiet, stdout, stderr, env };
31
+ const snapshot = openGate({
32
+ command,
33
+ config: context.config,
34
+ env,
35
+ monorepoRoot: context.monorepoRoot,
36
+ passthrough: parsed.passthrough,
37
+ stderr,
38
+ });
39
+ const noCache = parsed.noCache || env[NO_CACHE_ENV_VAR] === '1';
40
+ const childEnv = buildChildEnv(env, snapshot, noCache);
41
+ const runOptions = { quiet: parsed.quiet, stdout, stderr, env: childEnv };
31
42
  if (parsed.filter) {
32
43
  const delegateCmd = `pnpm --filter ${shellQuote(parsed.filter)} exec nmr ${command}${passthrough}`;
33
44
  const exitCode = runCommand(delegateCmd, context.monorepoRoot, runOptions);
34
45
  return { exitCode };
35
46
  }
36
47
  if (parsed.recursive) {
37
- const childEnv = { ...env, NMR_RUN_IF_PRESENT: '1' };
48
+ const delegateEnv = { ...childEnv, NMR_RUN_IF_PRESENT: '1' };
38
49
  const delegateCmd = `pnpm --recursive exec nmr ${command}${passthrough}`;
39
- const exitCode = runCommand(delegateCmd, context.monorepoRoot, { ...runOptions, env: childEnv });
50
+ const exitCode = runCommand(delegateCmd, context.monorepoRoot, { ...runOptions, env: delegateEnv });
40
51
  return { exitCode };
41
52
  }
42
53
  const registry = useRoot ? buildRootRegistry(context.config) : buildWorkspaceRegistry(context.config);
@@ -52,20 +63,122 @@ export async function runCli(options) {
52
63
  if (skipExitCode !== undefined) {
53
64
  return { exitCode: skipExitCode };
54
65
  }
55
- if (resolved.source === 'package' && !parsed.quiet && registry[command] !== undefined) {
56
- stdout.write(`📦 ${path.basename(anchorDir)}: Using override script: ${resolved.command}\n`);
57
- }
58
66
  const substitutedCommand = applyDevBin(resolved.command, context.config.devBin, context.monorepoRoot);
59
67
  const mainCommand = substitutedCommand + passthrough;
60
68
  const isHookInvocation = isHookName(command);
61
69
  const fullCommand = isHookInvocation
62
70
  ? mainCommand
63
71
  : wrapWithHooks(command, mainCommand, registry, anchorDir, parsed.workspaceRoot);
64
- const exitCode = runCommand(fullCommand, anchorDir, runOptions);
72
+ const key = resolveCacheKey({
73
+ anchorDir,
74
+ command,
75
+ commandString: fullCommand,
76
+ env,
77
+ monorepoRoot: context.monorepoRoot,
78
+ snapshot,
79
+ stderr,
80
+ substitution: substitutedCommand === resolved.command ? undefined : substitutedCommand,
81
+ });
82
+ const exitCode = await runGated({
83
+ anchorDir,
84
+ command,
85
+ commandString: fullCommand,
86
+ config: context.config,
87
+ env,
88
+ key,
89
+ monorepoRoot: context.monorepoRoot,
90
+ noCache,
91
+ overrideNotice: formatOverrideNotice(resolved, registry, command, anchorDir, parsed.quiet),
92
+ quiet: parsed.quiet,
93
+ runOptions,
94
+ snapshot,
95
+ stderr,
96
+ stdout,
97
+ });
65
98
  return { exitCode };
66
99
  }
100
+ function buildChildEnv(env, snapshot, noCache) {
101
+ return {
102
+ ...env,
103
+ ...(snapshot !== undefined && { [TREE_SNAPSHOT_ENV_VAR]: encodeTreeSnapshot(snapshot) }),
104
+ ...(noCache && { [NO_CACHE_ENV_VAR]: '1' }),
105
+ };
106
+ }
107
+ function formatOverrideNotice(resolved, registry, command, anchorDir, quiet) {
108
+ if (quiet || resolved.source !== 'package' || registry[command] === undefined) {
109
+ return undefined;
110
+ }
111
+ return `📦 ${path.basename(anchorDir)}: Using override script: ${resolved.command}\n`;
112
+ }
113
+ function handleSkipMessage(resolvedCommand, anchorDir, quiet, stdout) {
114
+ if (resolvedCommand === '') {
115
+ if (!quiet) {
116
+ stdout.write(`⛔ ${path.basename(anchorDir)}: Override script is defined but empty. Skipping.\n`);
117
+ }
118
+ return 0;
119
+ }
120
+ if (resolvedCommand === ':') {
121
+ if (!quiet) {
122
+ stdout.write(`⛔ ${path.basename(anchorDir)}: Override script is a no-op. Skipping.\n`);
123
+ }
124
+ return 0;
125
+ }
126
+ return undefined;
127
+ }
128
+ function hasRunnableHook(hookName, registry, anchorDir, workspaceRoot) {
129
+ const resolved = resolveScript(hookName, registry, anchorDir, workspaceRoot);
130
+ if (!resolved)
131
+ return false;
132
+ return resolved.command !== '' && resolved.command !== ':';
133
+ }
134
+ async function lookUpRecordedPass(options) {
135
+ const { anchorDir, command, env, key, monorepoRoot, stderr } = options;
136
+ const entry = await readCheckCacheEntry({ anchorDir, command, monorepoRoot });
137
+ if (entry === undefined) {
138
+ writeDebugNote(`running ${command}: no pass recorded for this scope`, env, stderr);
139
+ return undefined;
140
+ }
141
+ if (entry.key !== key) {
142
+ writeDebugNote(`running ${command}: the tree or its inputs changed since the last pass`, env, stderr);
143
+ return undefined;
144
+ }
145
+ const output = await readBuildOutputState(monorepoRoot, options.config);
146
+ const [missing] = output.missing;
147
+ if (missing !== undefined) {
148
+ writeDebugNote(`running ${command}: ${missing} has no build output`, env, stderr);
149
+ return undefined;
150
+ }
151
+ const stale = findStaleBuildOutput(entry.buildDigests, output.digests);
152
+ if (stale !== undefined) {
153
+ writeDebugNote(`running ${command}: ${stale}'s build output came from a different tree`, env, stderr);
154
+ return undefined;
155
+ }
156
+ return formatSkipLine(command, entry, Date.now());
157
+ }
158
+ function openGate(options) {
159
+ const { command, config, env, monorepoRoot, passthrough, stderr } = options;
160
+ const covered = !isHookName(command) &&
161
+ config.checkCache?.enabled !== false &&
162
+ resolveCacheableCommands(config.checkCache).has(command);
163
+ if (!covered) {
164
+ return undefined;
165
+ }
166
+ if (passthrough.includes('--no-cache')) {
167
+ stderr.write(`${formatMisplacedNoCacheWarning(command)}\n`);
168
+ }
169
+ if (passthrough.length > 0) {
170
+ return undefined;
171
+ }
172
+ const snapshot = resolveTreeSnapshot({ monorepoRoot, env });
173
+ if (!snapshot.ok) {
174
+ writeDebugNote(`gate disabled: ${snapshot.reason}`, env, stderr);
175
+ return undefined;
176
+ }
177
+ return snapshot.snapshot;
178
+ }
67
179
  function parseArgs(args) {
68
180
  const parsed = {
181
+ noCache: false,
69
182
  quiet: false,
70
183
  recursive: false,
71
184
  workspaceRoot: false,
@@ -113,26 +226,124 @@ function parseArgs(args) {
113
226
  i++;
114
227
  continue;
115
228
  }
229
+ if (arg === '--no-cache') {
230
+ parsed.noCache = true;
231
+ i++;
232
+ continue;
233
+ }
116
234
  parsed.command = arg;
117
235
  parsed.passthrough = args.slice(i + 1);
118
236
  break;
119
237
  }
120
238
  return { ok: true, parsed };
121
239
  }
122
- function handleSkipMessage(resolvedCommand, anchorDir, quiet, stdout) {
123
- if (resolvedCommand === '') {
124
- if (!quiet) {
125
- stdout.write(`⛔ ${path.basename(anchorDir)}: Override script is defined but empty. Skipping.\n`);
126
- }
127
- return 0;
240
+ async function recordPass(options) {
241
+ const { anchorDir, command, env, monorepoRoot, snapshot, stderr } = options;
242
+ const current = resolveTreeSnapshot({ monorepoRoot, env: {} });
243
+ if (!current.ok) {
244
+ writeDebugNote(`not recording ${command}: ${current.reason}`, env, stderr);
245
+ return;
128
246
  }
129
- if (resolvedCommand === ':') {
130
- if (!quiet) {
131
- stdout.write(`⛔ ${path.basename(anchorDir)}: Override script is a no-op. Skipping.\n`);
247
+ if (current.snapshot.hash !== snapshot.hash) {
248
+ writeDebugNote(`not recording ${command}: the tree changed while it ran`, env, stderr);
249
+ return;
250
+ }
251
+ const output = await readBuildOutputState(monorepoRoot, options.config);
252
+ const [missing] = output.missing;
253
+ if (missing !== undefined) {
254
+ writeDebugNote(`not recording ${command}: ${missing} has no build output`, env, stderr);
255
+ return;
256
+ }
257
+ try {
258
+ await writeCheckCacheEntry({
259
+ anchorDir,
260
+ command,
261
+ monorepoRoot,
262
+ entry: {
263
+ key: options.key,
264
+ treeHash: snapshot.hash,
265
+ headSha: snapshot.headSha,
266
+ commandString: options.commandString,
267
+ nmrVersion: VERSION,
268
+ nodeVersion: CURRENT_RUNTIME.nodeVersion,
269
+ durationMs: options.durationMs,
270
+ recordedAt: new Date().toISOString(),
271
+ buildDigests: output.digests,
272
+ },
273
+ });
274
+ }
275
+ catch (error) {
276
+ const message = error instanceof Error ? error.message : String(error);
277
+ writeDebugNote(`could not record ${command}: ${message}`, env, stderr);
278
+ }
279
+ }
280
+ function resolveCacheKey(options) {
281
+ const { env, snapshot, stderr, substitution } = options;
282
+ if (snapshot === undefined) {
283
+ return undefined;
284
+ }
285
+ if (substitution !== undefined) {
286
+ writeDebugNote(`gate disabled: devBin substituted \`${substitution}\``, env, stderr);
287
+ return undefined;
288
+ }
289
+ const result = computeCacheKey({
290
+ anchorDir: options.anchorDir,
291
+ command: options.command,
292
+ commandString: options.commandString,
293
+ env,
294
+ monorepoRoot: options.monorepoRoot,
295
+ nmrVersion: VERSION,
296
+ snapshot,
297
+ });
298
+ if (!result.ok) {
299
+ writeDebugNote(`gate disabled: ${result.reason}`, env, stderr);
300
+ return undefined;
301
+ }
302
+ return result.key;
303
+ }
304
+ async function runGated(options) {
305
+ const { anchorDir, command, commandString, env, key, monorepoRoot, snapshot, stderr, stdout } = options;
306
+ const gated = key !== undefined && snapshot !== undefined;
307
+ if (gated && !options.noCache) {
308
+ const skipLine = await lookUpRecordedPass({
309
+ anchorDir,
310
+ command,
311
+ config: options.config,
312
+ env,
313
+ key,
314
+ monorepoRoot,
315
+ stderr,
316
+ });
317
+ if (skipLine !== undefined) {
318
+ if (!options.quiet) {
319
+ stdout.write(`${skipLine}\n`);
320
+ }
321
+ return 0;
132
322
  }
133
- return 0;
134
323
  }
135
- return undefined;
324
+ if (options.overrideNotice !== undefined) {
325
+ stdout.write(options.overrideNotice);
326
+ }
327
+ const startedAt = Date.now();
328
+ const exitCode = runCommand(commandString, anchorDir, options.runOptions);
329
+ if (exitCode === 0 && gated) {
330
+ await recordPass({
331
+ anchorDir,
332
+ command,
333
+ commandString,
334
+ config: options.config,
335
+ durationMs: Date.now() - startedAt,
336
+ env,
337
+ key,
338
+ monorepoRoot,
339
+ snapshot,
340
+ stderr,
341
+ });
342
+ }
343
+ return exitCode;
344
+ }
345
+ function shellQuote(arg) {
346
+ return "'" + arg.replace(/'/g, String.raw `'\''`) + "'";
136
347
  }
137
348
  function wrapWithHooks(command, mainCommand, registry, anchorDir, workspaceRoot) {
138
349
  const segments = [];
@@ -146,12 +357,3 @@ function wrapWithHooks(command, mainCommand, registry, anchorDir, workspaceRoot)
146
357
  }
147
358
  return segments.join(' && ');
148
359
  }
149
- function hasRunnableHook(hookName, registry, anchorDir, workspaceRoot) {
150
- const resolved = resolveScript(hookName, registry, anchorDir, workspaceRoot);
151
- if (!resolved)
152
- return false;
153
- return resolved.command !== '' && resolved.command !== ':';
154
- }
155
- function shellQuote(arg) {
156
- return "'" + arg.replace(/'/g, String.raw `'\''`) + "'";
157
- }
@@ -0,0 +1,15 @@
1
+ export interface BuildConfig {
2
+ extraIgnorePatterns?: string[];
3
+ }
4
+ export interface CheckCacheConfig {
5
+ enabled?: boolean;
6
+ excludeCommands?: string[];
7
+ extraCommands?: string[];
8
+ }
9
+ export interface NmrConfig {
10
+ build?: BuildConfig;
11
+ checkCache?: CheckCacheConfig;
12
+ devBin?: Record<string, string>;
13
+ workspaceScripts?: Record<string, string | string[]>;
14
+ rootScripts?: Record<string, string | string[]>;
15
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -7,9 +7,14 @@ export type RootOverrides = Omit<ViteUserConfig, 'test'> & {
7
7
  export interface VitestConfigOptions {
8
8
  root?: RootOverrides;
9
9
  project?: ProjectConfig;
10
+ tiers?: Partial<Record<TierName, ProjectConfig>>;
10
11
  }
11
12
  export interface RootVitestConfigOptions extends VitestConfigOptions {
12
13
  monorepoRoot: string;
13
14
  }
14
- export declare function defineVitestConfig(options?: VitestConfigOptions): ViteUserConfig;
15
- export declare function defineRootVitestConfig(options: RootVitestConfigOptions | undefined): ViteUserConfig;
15
+ type RootConfigLayers = [...(VitestConfigOptions | undefined)[], RootVitestConfigOptions];
16
+ declare const TIER_NAMES: readonly ["unit", "tool", "localhost", "remote"];
17
+ export type TierName = (typeof TIER_NAMES)[number];
18
+ export declare function defineVitestConfig(...layers: (VitestConfigOptions | undefined)[]): ViteUserConfig;
19
+ export declare function defineRootVitestConfig(...layers: RootConfigLayers): ViteUserConfig;
20
+ export {};