@williamthorsen/nmr 0.29.0 → 0.31.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 (66) hide show
  1. package/.readyup/kits/default.js +33 -22
  2. package/.readyup/manifest.json +2 -2
  3. package/README.md +327 -78
  4. package/agents/guidance/rulebooks/nmr.md +4 -2
  5. package/bin/nmr-report-catalog.js +11 -0
  6. package/dist/esm/UserError.d.ts +3 -0
  7. package/dist/esm/UserError.js +6 -0
  8. package/dist/esm/check-cache.d.ts +36 -1
  9. package/dist/esm/check-cache.js +93 -24
  10. package/dist/esm/cli-build.js +2 -1
  11. package/dist/esm/cli-clean.js +2 -1
  12. package/dist/esm/cli-ensure-prepublish-hooks.js +2 -1
  13. package/dist/esm/cli-report-catalog.d.ts +1 -0
  14. package/dist/esm/cli-report-catalog.js +11 -0
  15. package/dist/esm/cli-report-overrides.js +2 -1
  16. package/dist/esm/cli-taze.js +2 -1
  17. package/dist/esm/cli.js +2 -1
  18. package/dist/esm/commands/clean.js +9 -5
  19. package/dist/esm/commands/report-catalog.d.ts +1 -0
  20. package/dist/esm/commands/report-catalog.js +34 -0
  21. package/dist/esm/commands/report-overrides.js +22 -7
  22. package/dist/esm/commands/taze.js +2 -1
  23. package/dist/esm/config.d.ts +1 -0
  24. package/dist/esm/config.js +81 -16
  25. package/dist/esm/default-scripts.d.ts +5 -1
  26. package/dist/esm/default-scripts.js +19 -16
  27. package/dist/esm/defineConfig.d.ts +1 -1
  28. package/dist/esm/help.js +3 -1
  29. package/dist/esm/helpers/clampToBytes.d.ts +2 -0
  30. package/dist/esm/helpers/clampToBytes.js +20 -0
  31. package/dist/esm/helpers/createBoundedBuffer.js +1 -4
  32. package/dist/esm/helpers/deriveExcerpt.d.ts +1 -0
  33. package/dist/esm/helpers/deriveExcerpt.js +30 -0
  34. package/dist/esm/helpers/duration.js +16 -9
  35. package/dist/esm/helpers/package-json.d.ts +11 -4
  36. package/dist/esm/helpers/package-json.js +51 -22
  37. package/dist/esm/helpers/readStringValues.d.ts +1 -0
  38. package/dist/esm/helpers/readStringValues.js +8 -0
  39. package/dist/esm/helpers/transcript.d.ts +5 -0
  40. package/dist/esm/helpers/transcript.js +59 -0
  41. package/dist/esm/recording.d.ts +65 -0
  42. package/dist/esm/recording.js +96 -0
  43. package/dist/esm/replay-assembly.d.ts +9 -0
  44. package/dist/esm/replay-assembly.js +26 -0
  45. package/dist/esm/report-format.d.ts +18 -0
  46. package/dist/esm/report-format.js +25 -0
  47. package/dist/esm/reportCliFailure.d.ts +2 -0
  48. package/dist/esm/reportCliFailure.js +11 -0
  49. package/dist/esm/resolve-scripts.d.ts +1 -1
  50. package/dist/esm/resolver.d.ts +13 -3
  51. package/dist/esm/resolver.js +34 -19
  52. package/dist/esm/runCli.d.ts +1 -0
  53. package/dist/esm/runCli.js +372 -70
  54. package/dist/esm/runner.d.ts +20 -1
  55. package/dist/esm/runner.js +72 -12
  56. package/dist/esm/scripts.d.ts +1 -1
  57. package/dist/esm/steps.d.ts +18 -0
  58. package/dist/esm/steps.js +144 -0
  59. package/dist/esm/types.d.ts +11 -2
  60. package/dist/esm/verbosity.d.ts +22 -0
  61. package/dist/esm/verbosity.js +37 -0
  62. package/dist/esm/verdict.d.ts +29 -0
  63. package/dist/esm/verdict.js +150 -0
  64. package/dist/esm/workspace.d.ts +2 -0
  65. package/dist/esm/workspace.js +20 -3
  66. package/package.json +12 -5
@@ -0,0 +1,65 @@
1
+ import type { CheckCacheEntry } from './check-cache.js';
2
+ export interface RunIdentity {
3
+ commandString: string;
4
+ nmrVersion: string;
5
+ nodeVersion: string;
6
+ treeHash: string | undefined;
7
+ }
8
+ export interface Recording {
9
+ entry: CheckCacheEntry;
10
+ transcript?: string;
11
+ }
12
+ export type KeyDifference = {
13
+ ingredient: 'tree';
14
+ } | {
15
+ ingredient: 'command-string';
16
+ } | {
17
+ ingredient: 'nmr-version';
18
+ current: string;
19
+ recorded: string;
20
+ } | {
21
+ ingredient: 'node-version';
22
+ current: string;
23
+ recorded: string;
24
+ } | {
25
+ ingredient: 'other';
26
+ };
27
+ export type RecordingLookup = {
28
+ ok: true;
29
+ recording: Recording;
30
+ } | {
31
+ ok: false;
32
+ refusal: RecordingRefusal;
33
+ };
34
+ export type RecordingRefusal = {
35
+ kind: 'uncacheable';
36
+ } | {
37
+ kind: 'gate-aside';
38
+ } | {
39
+ kind: 'unrecorded';
40
+ } | {
41
+ kind: 'mismatched';
42
+ ageMs: number;
43
+ difference: KeyDifference;
44
+ } | {
45
+ kind: 'no-output';
46
+ ageMs: number;
47
+ };
48
+ export declare function renderRecording(options: {
49
+ command: string;
50
+ recording: Recording;
51
+ scope: string;
52
+ }): string;
53
+ export declare function renderRefusal(options: {
54
+ command: string;
55
+ refusal: RecordingRefusal;
56
+ scope: string;
57
+ }): string;
58
+ export declare function resolveRecording(options: {
59
+ anchorDir: string;
60
+ command: string;
61
+ current: RunIdentity;
62
+ isCacheable: boolean;
63
+ key: string | undefined;
64
+ monorepoRoot: string;
65
+ }): Promise<RecordingLookup>;
@@ -0,0 +1,96 @@
1
+ import { readCheckCacheEntry, readTranscript } from "./check-cache.js";
2
+ import { formatDuration } from "./helpers/duration.js";
3
+ export function renderRecording(options) {
4
+ const { entry } = options.recording;
5
+ const age = formatDuration(Math.max(0, Date.now() - Date.parse(entry.recordedAt)));
6
+ const header = `📼 ${options.scope}: ${options.command} — recorded ${entry.recordedAt} (${age} ago), ` +
7
+ `ran in ${formatDuration(entry.durationMs)}\n$ ${entry.commandString}\n\n`;
8
+ return `${header}${appendNewline(renderBody(options.recording))}`;
9
+ }
10
+ export function renderRefusal(options) {
11
+ return `📭 ${options.scope}: ${options.command}: no recording; ${describeRefusal(options.command, options.refusal)}`;
12
+ }
13
+ export async function resolveRecording(options) {
14
+ const { anchorDir, command, monorepoRoot } = options;
15
+ if (!options.isCacheable) {
16
+ return { ok: false, refusal: { kind: 'uncacheable' } };
17
+ }
18
+ if (options.key === undefined) {
19
+ return { ok: false, refusal: { kind: 'gate-aside' } };
20
+ }
21
+ const entry = await readCheckCacheEntry({ anchorDir, command, monorepoRoot });
22
+ if (entry === undefined) {
23
+ return { ok: false, refusal: { kind: 'unrecorded' } };
24
+ }
25
+ const ageMs = Math.max(0, Date.now() - Date.parse(entry.recordedAt));
26
+ if (entry.key !== options.key) {
27
+ return { ok: false, refusal: { kind: 'mismatched', ageMs, difference: findKeyDifference(entry, options.current) } };
28
+ }
29
+ const transcript = await readTranscript({ anchorDir, command, monorepoRoot });
30
+ if (transcript === undefined && (entry.retention?.replay.length ?? 0) === 0) {
31
+ return { ok: false, refusal: { kind: 'no-output', ageMs } };
32
+ }
33
+ return { ok: true, recording: { entry, ...(transcript !== undefined && { transcript }) } };
34
+ }
35
+ function appendNewline(body) {
36
+ return body.endsWith('\n') ? body : `${body}\n`;
37
+ }
38
+ function describeDifference(difference) {
39
+ switch (difference.ingredient) {
40
+ case 'tree':
41
+ return 'on a tree this is not';
42
+ case 'command-string':
43
+ return 'over a command chain this is not';
44
+ case 'nmr-version':
45
+ return `under nmr ${difference.recorded}, not ${difference.current}`;
46
+ case 'node-version':
47
+ return `under Node ${difference.recorded}, not ${difference.current}`;
48
+ case 'other':
49
+ return 'under an install or environment this run does not share';
50
+ default: {
51
+ const unhandled = difference;
52
+ throw new Error(`Unhandled key difference: ${JSON.stringify(unhandled)}`);
53
+ }
54
+ }
55
+ }
56
+ function describeRefusal(command, refusal) {
57
+ switch (refusal.kind) {
58
+ case 'uncacheable':
59
+ return `\`${command}\` is outside the check-result cache, so no run of it is recorded`;
60
+ case 'gate-aside':
61
+ return 'the check-result cache is standing aside here (NMR_DEBUG=1 reports why)';
62
+ case 'unrecorded':
63
+ return 'nothing has recorded a pass for this scope';
64
+ case 'mismatched':
65
+ return `the last pass was ${formatDuration(refusal.ageMs)} ago, ${describeDifference(refusal.difference)}`;
66
+ case 'no-output':
67
+ return `the pass ${formatDuration(refusal.ageMs)} ago retained none, as a run printing nothing or writing to a terminal does`;
68
+ default: {
69
+ const unhandled = refusal;
70
+ throw new Error(`Unhandled refusal: ${JSON.stringify(unhandled)}`);
71
+ }
72
+ }
73
+ }
74
+ function findKeyDifference(entry, current) {
75
+ if (current.treeHash !== undefined && entry.treeHash !== current.treeHash) {
76
+ return { ingredient: 'tree' };
77
+ }
78
+ if (entry.commandString !== current.commandString) {
79
+ return { ingredient: 'command-string' };
80
+ }
81
+ if (entry.nmrVersion !== current.nmrVersion) {
82
+ return { ingredient: 'nmr-version', current: current.nmrVersion, recorded: entry.nmrVersion };
83
+ }
84
+ if (entry.nodeVersion !== current.nodeVersion) {
85
+ return { ingredient: 'node-version', current: current.nodeVersion, recorded: entry.nodeVersion };
86
+ }
87
+ return { ingredient: 'other' };
88
+ }
89
+ function renderBody(recording) {
90
+ if (recording.transcript !== undefined) {
91
+ return recording.transcript;
92
+ }
93
+ return (recording.entry.retention?.replay ?? [])
94
+ .map((line) => `${line.scope}: ${line.command}: ${line.excerpt}`)
95
+ .join('\n');
96
+ }
@@ -0,0 +1,9 @@
1
+ import type { ReplayLine } from './check-cache.js';
2
+ import type { Step } from './steps.js';
3
+ export declare function assembleReplay(options: {
4
+ anchorDir: string;
5
+ monorepoRoot: string;
6
+ runId: string;
7
+ steps: readonly Step[];
8
+ treeHash: string;
9
+ }): Promise<ReplayLine[]>;
@@ -0,0 +1,26 @@
1
+ import { readCheckCacheEntry } from "./check-cache.js";
2
+ import { readNmrStep } from "./steps.js";
3
+ import { getWorkspacePackageDirs } from "./workspace.js";
4
+ export async function assembleReplay(options) {
5
+ const { anchorDir, monorepoRoot, runId, steps, treeHash } = options;
6
+ const candidates = steps.flatMap((step) => {
7
+ const target = readNmrStep(step);
8
+ if (target === undefined) {
9
+ return [];
10
+ }
11
+ const scopeDirs = target.isDelegate
12
+ ? resolveDelegateScopes(monorepoRoot)
13
+ : [target.isWorkspaceRoot ? monorepoRoot : anchorDir];
14
+ return scopeDirs.map((scopeDir) => ({ anchorDir: scopeDir, command: target.command, monorepoRoot }));
15
+ });
16
+ const entries = await Promise.all(candidates.map((candidate) => readCheckCacheEntry(candidate)));
17
+ return entries.flatMap((entry) => entry?.treeHash === treeHash && entry.retention?.runId === runId ? entry.retention.replay : []);
18
+ }
19
+ function resolveDelegateScopes(monorepoRoot) {
20
+ try {
21
+ return getWorkspacePackageDirs(monorepoRoot);
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ }
@@ -0,0 +1,18 @@
1
+ export declare function formatReportFormatRejection(source: string, value: string): string;
2
+ export declare function isReportFormat(value: string): value is ReportFormat;
3
+ export declare function readReportFormatEnv(env: NodeJS.ProcessEnv): ReportFormatRead;
4
+ export declare const REPORT_FORMAT_ENV_VAR = "NMR_REPORT_FORMAT";
5
+ export declare const REPORT_FORMATS: readonly ["text", "json"];
6
+ export type ReportFormat = (typeof REPORT_FORMATS)[number];
7
+ export type ReportFormatRead = {
8
+ ok: true;
9
+ format?: ReportFormat;
10
+ } | {
11
+ ok: false;
12
+ error: string;
13
+ };
14
+ export declare function resolveReportFormat(options: ResolveReportFormatOptions): ReportFormat;
15
+ export interface ResolveReportFormatOptions {
16
+ envFormat: ReportFormat | undefined;
17
+ jsonFlag: boolean;
18
+ }
@@ -0,0 +1,25 @@
1
+ export function formatReportFormatRejection(source, value) {
2
+ return `${source} is \`${value}\`, which is not one of: ${REPORT_FORMATS.join(', ')}`;
3
+ }
4
+ export function isReportFormat(value) {
5
+ const names = REPORT_FORMATS;
6
+ return names.includes(value);
7
+ }
8
+ export function readReportFormatEnv(env) {
9
+ const raw = env[REPORT_FORMAT_ENV_VAR];
10
+ if (raw === undefined || raw === '') {
11
+ return { ok: true };
12
+ }
13
+ if (!isReportFormat(raw)) {
14
+ return { ok: false, error: formatReportFormatRejection(REPORT_FORMAT_ENV_VAR, raw) };
15
+ }
16
+ return { ok: true, format: raw };
17
+ }
18
+ export const REPORT_FORMAT_ENV_VAR = 'NMR_REPORT_FORMAT';
19
+ export const REPORT_FORMATS = ['text', 'json'];
20
+ export function resolveReportFormat(options) {
21
+ const { envFormat, jsonFlag } = options;
22
+ if (jsonFlag)
23
+ return 'json';
24
+ return envFormat ?? 'text';
25
+ }
@@ -0,0 +1,2 @@
1
+ import type { Writable } from 'node:stream';
2
+ export declare function reportCliFailure(error: unknown, stderr: Writable): void;
@@ -0,0 +1,11 @@
1
+ import { reportError } from '@williamthorsen/nmr-core';
2
+ import { describeError } from '@williamthorsen/toolbelt.errors';
3
+ import { UserError } from "./UserError.js";
4
+ export function reportCliFailure(error, stderr) {
5
+ if (error instanceof UserError) {
6
+ reportError(error.message, stderr);
7
+ return;
8
+ }
9
+ const detail = error instanceof Error && error.stack !== undefined ? error.stack : describeError(error);
10
+ stderr.write(`${detail}\n`);
11
+ }
@@ -1,4 +1,4 @@
1
1
  import type { ScriptRegistry } from './default-scripts.js';
2
- export type { ScriptRegistry, ScriptValue } from './default-scripts.js';
2
+ export type { ScriptRegistry, ScriptValue, StepSpec } from './default-scripts.js';
3
3
  export declare function getDefaultWorkspaceScripts(): ScriptRegistry;
4
4
  export declare function getDefaultRootScripts(): ScriptRegistry;
@@ -1,11 +1,21 @@
1
1
  import type { ScriptRegistry, ScriptValue } from './resolve-scripts.js';
2
+ import type { Step } from './steps.js';
2
3
  import type { NmrConfig } from './types.js';
3
4
  export declare function applyDevBin(command: string, devBin: Record<string, string> | undefined, monorepoRoot: string): string;
5
+ export declare function applyDevBinToSteps(steps: readonly Step[], devBin: Record<string, string> | undefined, monorepoRoot: string): readonly Step[];
6
+ export type ScriptOrigin = {
7
+ tier: 'registry';
8
+ key: string;
9
+ } | {
10
+ tier: 'package';
11
+ file: string;
12
+ key: string;
13
+ };
4
14
  export interface ResolvedScript {
5
- command: string;
6
- source: 'default' | 'package';
15
+ origin: ScriptOrigin;
16
+ steps: readonly Step[];
7
17
  }
8
- export declare function expandScript(script: ScriptValue, workspaceRoot: boolean): string;
18
+ export declare function expandScript(script: ScriptValue, workspaceRoot: boolean): readonly Step[];
9
19
  export declare function describeScript(script: ScriptValue): string;
10
20
  export declare function readPackageJsonScripts(packageDir: string): Record<string, string> | undefined;
11
21
  export declare function buildWorkspaceRegistry(config: NmrConfig): ScriptRegistry;
@@ -1,7 +1,9 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { parsePackageJson, readScriptRecord, resolvePackageJsonPath } from "./helpers/package-json.js";
3
4
  import { isObject } from "./helpers/type-guards.js";
4
5
  import { getDefaultRootScripts, getDefaultWorkspaceScripts } from "./resolve-scripts.js";
6
+ import { composeNmrStep } from "./steps.js";
5
7
  export function applyDevBin(command, devBin, monorepoRoot) {
6
8
  if (!devBin) {
7
9
  return command;
@@ -16,6 +18,12 @@ export function applyDevBin(command, devBin, monorepoRoot) {
16
18
  const resolvedReplacement = resolveReplacementPaths(replacement, monorepoRoot);
17
19
  return resolvedReplacement + rest;
18
20
  }
21
+ export function applyDevBinToSteps(steps, devBin, monorepoRoot) {
22
+ if (!devBin) {
23
+ return steps;
24
+ }
25
+ return steps.map((step) => step.kind === 'opaque' ? { kind: 'opaque', command: applyDevBin(step.command, devBin, monorepoRoot) } : step);
26
+ }
19
27
  function resolveReplacementPaths(replacement, monorepoRoot) {
20
28
  const tokens = replacement.split(' ');
21
29
  return tokens
@@ -32,29 +40,26 @@ function resolveReplacementPaths(replacement, monorepoRoot) {
32
40
  }
33
41
  export function expandScript(script, workspaceRoot) {
34
42
  if (typeof script === 'string') {
35
- return script;
43
+ return [{ kind: 'opaque', command: script }];
36
44
  }
37
- const flag = workspaceRoot ? '-w ' : '';
38
- return script.map((s) => `nmr ${flag}${s}`).join(' && ');
45
+ return script.map((element) => typeof element === 'string'
46
+ ? composeNmrStep(element, workspaceRoot)
47
+ : composeNmrStep(element.run, workspaceRoot, element.declinesArgs));
39
48
  }
40
49
  export function describeScript(script) {
41
- return typeof script === 'string' ? script : `[${script.join(', ')}]`;
50
+ return typeof script === 'string' ? script : `[${script.map(describeElement).join(', ')}]`;
51
+ }
52
+ function describeElement(element) {
53
+ if (typeof element === 'string') {
54
+ return element;
55
+ }
56
+ return element.declinesArgs === true ? `${element.run} (no args)` : element.run;
42
57
  }
43
58
  export function readPackageJsonScripts(packageDir) {
59
+ const file = resolvePackageJsonPath(packageDir);
60
+ let raw;
44
61
  try {
45
- const raw = readFileSync(path.join(packageDir, 'package.json'), 'utf8');
46
- const parsed = JSON.parse(raw);
47
- if (!isObject(parsed))
48
- return undefined;
49
- const scripts = parsed['scripts'];
50
- if (!isObject(scripts))
51
- return undefined;
52
- const result = {};
53
- for (const [key, val] of Object.entries(scripts)) {
54
- if (typeof val === 'string')
55
- result[key] = val;
56
- }
57
- return result;
62
+ raw = readFileSync(file, 'utf8');
58
63
  }
59
64
  catch (error) {
60
65
  if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
@@ -62,6 +67,13 @@ export function readPackageJsonScripts(packageDir) {
62
67
  }
63
68
  throw error;
64
69
  }
70
+ const parsed = parsePackageJson(raw, file);
71
+ if (!isObject(parsed))
72
+ return undefined;
73
+ const scripts = parsed['scripts'];
74
+ if (!isObject(scripts))
75
+ return undefined;
76
+ return readScriptRecord(packageDir, scripts);
65
77
  }
66
78
  export function buildWorkspaceRegistry(config) {
67
79
  return {
@@ -85,7 +97,10 @@ export function resolveScript(commandName, registry, packageDir, workspaceRoot)
85
97
  if (pkgScripts && Object.hasOwn(pkgScripts, commandName)) {
86
98
  const override = pkgScripts[commandName];
87
99
  if (override !== undefined && !isSelfReferential(override, commandName)) {
88
- return { command: override, source: 'package' };
100
+ return {
101
+ origin: { tier: 'package', file: resolvePackageJsonPath(packageDir), key: commandName },
102
+ steps: [{ kind: 'opaque', command: override }],
103
+ };
89
104
  }
90
105
  }
91
106
  }
@@ -96,5 +111,5 @@ export function resolveScript(commandName, registry, packageDir, workspaceRoot)
96
111
  if (registryEntry === undefined) {
97
112
  return undefined;
98
113
  }
99
- return { command: expandScript(registryEntry, workspaceRoot), source: 'default' };
114
+ return { origin: { tier: 'registry', key: commandName }, steps: expandScript(registryEntry, workspaceRoot) };
100
115
  }
@@ -1,4 +1,5 @@
1
1
  import type { Writable } from 'node:stream';
2
+ export declare const RUN_IF_PRESENT_ENV_VAR = "NMR_RUN_IF_PRESENT";
2
3
  export interface RunCliOptions {
3
4
  args: string[];
4
5
  cwd: string;