@williamthorsen/nmr 0.30.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 (64) hide show
  1. package/.readyup/kits/default.js +27 -16
  2. package/.readyup/manifest.json +2 -2
  3. package/README.md +299 -62
  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 +91 -23
  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/report-catalog.d.ts +1 -0
  19. package/dist/esm/commands/report-catalog.js +34 -0
  20. package/dist/esm/commands/report-overrides.js +22 -7
  21. package/dist/esm/commands/taze.js +2 -1
  22. package/dist/esm/config.d.ts +1 -0
  23. package/dist/esm/config.js +66 -18
  24. package/dist/esm/default-scripts.d.ts +5 -1
  25. package/dist/esm/default-scripts.js +13 -10
  26. package/dist/esm/defineConfig.d.ts +1 -1
  27. package/dist/esm/help.js +3 -1
  28. package/dist/esm/helpers/clampToBytes.d.ts +2 -0
  29. package/dist/esm/helpers/clampToBytes.js +20 -0
  30. package/dist/esm/helpers/createBoundedBuffer.js +1 -4
  31. package/dist/esm/helpers/deriveExcerpt.d.ts +1 -0
  32. package/dist/esm/helpers/deriveExcerpt.js +30 -0
  33. package/dist/esm/helpers/duration.js +16 -9
  34. package/dist/esm/helpers/package-json.d.ts +11 -4
  35. package/dist/esm/helpers/package-json.js +51 -22
  36. package/dist/esm/helpers/readStringValues.d.ts +1 -0
  37. package/dist/esm/helpers/readStringValues.js +8 -0
  38. package/dist/esm/helpers/transcript.d.ts +5 -0
  39. package/dist/esm/helpers/transcript.js +59 -0
  40. package/dist/esm/recording.d.ts +65 -0
  41. package/dist/esm/recording.js +96 -0
  42. package/dist/esm/replay-assembly.d.ts +9 -0
  43. package/dist/esm/replay-assembly.js +26 -0
  44. package/dist/esm/report-format.d.ts +18 -0
  45. package/dist/esm/report-format.js +25 -0
  46. package/dist/esm/reportCliFailure.d.ts +2 -0
  47. package/dist/esm/reportCliFailure.js +11 -0
  48. package/dist/esm/resolve-scripts.d.ts +1 -1
  49. package/dist/esm/resolver.d.ts +9 -1
  50. package/dist/esm/resolver.js +26 -17
  51. package/dist/esm/runCli.js +339 -74
  52. package/dist/esm/runner.d.ts +9 -3
  53. package/dist/esm/runner.js +25 -4
  54. package/dist/esm/scripts.d.ts +1 -1
  55. package/dist/esm/steps.d.ts +9 -2
  56. package/dist/esm/steps.js +117 -4
  57. package/dist/esm/types.d.ts +11 -2
  58. package/dist/esm/verbosity.d.ts +15 -4
  59. package/dist/esm/verbosity.js +32 -12
  60. package/dist/esm/verdict.d.ts +29 -0
  61. package/dist/esm/verdict.js +150 -0
  62. package/dist/esm/workspace.d.ts +2 -0
  63. package/dist/esm/workspace.js +20 -3
  64. package/package.json +12 -5
@@ -1,4 +1,8 @@
1
- export type ScriptValue = string | string[];
1
+ export interface StepSpec {
2
+ run: string;
3
+ declinesArgs?: boolean;
4
+ }
5
+ export type ScriptValue = string | ReadonlyArray<string | StepSpec>;
2
6
  export type ScriptRegistry = Record<string, ScriptValue>;
3
7
  export declare const workspaceScripts: ScriptRegistry;
4
8
  export declare const rootScripts: ScriptRegistry;
@@ -1,8 +1,10 @@
1
1
  const GATE_PROJECTS = '--project unit --project tool';
2
+ const TYPECHECK_STEP = { run: 'typecheck', declinesArgs: true };
3
+ const ROOT_TYPECHECK_STEP = { run: 'root:typecheck', declinesArgs: true };
2
4
  export const workspaceScripts = {
3
5
  build: ['compile'],
4
- check: ['typecheck', 'fmt:check', 'lint:check', 'test'],
5
- 'check:strict': ['typecheck', 'fmt:check', 'lint:strict', 'test:coverage'],
6
+ check: [TYPECHECK_STEP, 'fmt:check', 'lint:check', 'test'],
7
+ 'check:strict': [TYPECHECK_STEP, 'fmt:check', 'lint:strict', 'test:coverage'],
6
8
  clean: 'nmr-clean',
7
9
  compile: 'nmr-compile',
8
10
  fix: ['lint', 'fmt'],
@@ -12,6 +14,7 @@ export const workspaceScripts = {
12
14
  lint: 'eslint --fix .',
13
15
  'lint:check': 'eslint .',
14
16
  'lint:strict': 'strict-lint',
17
+ 'report-catalog': 'nmr-report-catalog',
15
18
  test: `pnpm exec vitest ${GATE_PROJECTS}`,
16
19
  'test:all': 'pnpm exec vitest',
17
20
  'test:coverage': `pnpm exec vitest ${GATE_PROJECTS} --coverage`,
@@ -19,7 +22,7 @@ export const workspaceScripts = {
19
22
  'test:unit': 'pnpm exec vitest --project unit',
20
23
  'test:watch': `pnpm exec vitest ${GATE_PROJECTS} --watch`,
21
24
  typecheck: 'tsgo --noEmit',
22
- upgrade: 'nmr-taze --include-locked',
25
+ upgrade: 'nmr-report-catalog && nmr-taze --include-locked',
23
26
  'view-coverage': 'open coverage/index.html',
24
27
  };
25
28
  export const rootScripts = {
@@ -27,9 +30,9 @@ export const rootScripts = {
27
30
  'audit:dev': 'pnpm exec v11y --dev',
28
31
  'audit:prod': 'pnpm exec v11y --prod',
29
32
  build: ['-R build'],
30
- check: ['typecheck', 'fmt:check', 'lint:check', 'test'],
31
- 'check:strict': ['typecheck', 'fmt:check', 'lint:strict', 'test:coverage'],
32
- ci: ['build', 'check:strict'],
33
+ check: [TYPECHECK_STEP, 'fmt:check', 'lint:check', 'test'],
34
+ 'check:strict': [TYPECHECK_STEP, 'fmt:check', 'lint:strict', 'test:coverage'],
35
+ ci: [{ run: 'build', declinesArgs: true }, 'check:strict'],
33
36
  clean: 'nmr-clean',
34
37
  fix: ['lint', 'fmt'],
35
38
  'fix:check': ['fmt:check', 'lint:check'],
@@ -38,9 +41,9 @@ export const rootScripts = {
38
41
  lint: 'eslint --fix .',
39
42
  'lint:check': 'eslint .',
40
43
  'lint:strict': 'strict-lint',
41
- prepush: ['audit', 'ci'],
44
+ prepush: [{ run: 'audit', declinesArgs: true }, 'ci'],
42
45
  'report-overrides': 'nmr-report-overrides',
43
- 'root:check': ['root:typecheck', 'fmt:check', 'root:lint:check', 'root:test'],
46
+ 'root:check': [ROOT_TYPECHECK_STEP, 'fmt:check', 'root:lint:check', 'root:test'],
44
47
  'root:lint': "eslint --fix --ignore-pattern 'packages/**' .",
45
48
  'root:lint:check': "eslint --ignore-pattern 'packages/**' .",
46
49
  'root:lint:strict': "strict-lint --ignore-pattern 'packages/**' .",
@@ -49,13 +52,13 @@ export const rootScripts = {
49
52
  'root:test:tool': 'vitest --config ./vitest.root.config.ts --project tool',
50
53
  'root:test:unit': 'vitest --config ./vitest.root.config.ts --project unit',
51
54
  'root:typecheck': 'tsgo --noEmit',
52
- 'root:upgrade': 'nmr-taze --include-locked',
55
+ 'root:upgrade': 'nmr-report-overrides && nmr-taze --include-locked',
53
56
  test: ['root:test', '-R test'],
54
57
  'test:all': ['root:test:all', '-R test:all'],
55
58
  'test:coverage': ['root:test', '-R test:coverage'],
56
59
  'test:tool': ['root:test:tool', '-R test:tool'],
57
60
  'test:unit': ['root:test:unit', '-R test:unit'],
58
61
  'test:watch': `vitest ${GATE_PROJECTS} --watch`,
59
- typecheck: ['root:typecheck', '-R typecheck'],
62
+ typecheck: [ROOT_TYPECHECK_STEP, { run: '-R typecheck', declinesArgs: true }],
60
63
  upgrade: 'nmr-report-overrides && nmr-taze --include-locked --recursive',
61
64
  };
@@ -1,3 +1,3 @@
1
1
  import type { NmrConfig } from './types.js';
2
- export type { BuildConfig, CheckCacheConfig, NmrConfig } from './types.js';
2
+ export type { BuildConfig, CheckCacheConfig, CommandVerbosity, NmrConfig, OutputConfig, ScriptValue, StepSpec, } from './types.js';
3
3
  export declare function defineConfig(config: NmrConfig): NmrConfig;
package/dist/esm/help.js CHANGED
@@ -8,7 +8,9 @@ export function generateHelp(config, packageDir, useRoot) {
8
8
  ' -F, --filter <pattern> Run command in matching packages',
9
9
  ' -R, --recursive Run command in all packages',
10
10
  ' -w, --workspace-root Use root scripts, running at the monorepo root',
11
- ' -q, --quiet Suppress output on success; show full output on failure',
11
+ " -q, --quiet Suppress command output, keeping nmr's verdicts",
12
+ ' --json Report one JSON object per command, withholding command output',
13
+ ' --log Print the recorded run instead of running (up to 256 KiB)',
12
14
  ' --no-cache Run even if this tree already passed; record the result',
13
15
  ' -?, --help Show this help',
14
16
  ' -V, --version Show version number',
@@ -0,0 +1,2 @@
1
+ export declare const TRUNCATION_MARK = "\u2026";
2
+ export declare function clampToBytes(value: string, budgetBytes: number): string;
@@ -0,0 +1,20 @@
1
+ export const TRUNCATION_MARK = '…';
2
+ export function clampToBytes(value, budgetBytes) {
3
+ if (Buffer.byteLength(value) <= budgetBytes) {
4
+ return value;
5
+ }
6
+ const budget = budgetBytes - Buffer.byteLength(TRUNCATION_MARK);
7
+ if (budget < 0) {
8
+ return '';
9
+ }
10
+ let kept = '';
11
+ let bytes = 0;
12
+ for (const character of value) {
13
+ const size = Buffer.byteLength(character);
14
+ if (bytes + size > budget)
15
+ break;
16
+ kept += character;
17
+ bytes += size;
18
+ }
19
+ return `${kept}${TRUNCATION_MARK}`;
20
+ }
@@ -1,3 +1,4 @@
1
+ import { formatElisionMarker } from "./transcript.js";
1
2
  const DEFAULT_HEAD_LIMIT_BYTES = 1_048_576;
2
3
  const DEFAULT_TAIL_LIMIT_BYTES = 1_048_576;
3
4
  export function createBoundedBuffer(options = {}) {
@@ -54,7 +55,3 @@ export function createBoundedBuffer(options = {}) {
54
55
  }
55
56
  return { append, toBuffer };
56
57
  }
57
- function formatElisionMarker(byteCount) {
58
- const unit = byteCount === 1 ? 'byte' : 'bytes';
59
- return `\n… nmr elided ${byteCount.toLocaleString('en-US')} ${unit} …\n`;
60
- }
@@ -0,0 +1 @@
1
+ export declare function deriveExcerpt(transcript: string): string | undefined;
@@ -0,0 +1,30 @@
1
+ import { clampToBytes } from "./clampToBytes.js";
2
+ import { cleanTranscript } from "./transcript.js";
3
+ const MAX_BLOCK_LINES = 8;
4
+ const MAX_EXCERPT_BYTES = 2_048;
5
+ const NON_RULE_CHARACTER = /[^-=_~*+.|:#]/u;
6
+ export function deriveExcerpt(transcript) {
7
+ const lines = cleanTranscript(transcript).split('\n');
8
+ let end = lines.length;
9
+ while (end > 0 && isBlank(lines[end - 1])) {
10
+ end--;
11
+ }
12
+ let start = end;
13
+ while (start > 0 && !isBlank(lines[start - 1])) {
14
+ start--;
15
+ }
16
+ const excerpt = lines
17
+ .slice(Math.max(start, end - MAX_BLOCK_LINES), end)
18
+ .filter((line) => !isRuleOnly(line))
19
+ .join(' ')
20
+ .replaceAll(/\s+/gu, ' ')
21
+ .trim();
22
+ return excerpt === '' ? undefined : clampToBytes(excerpt, MAX_EXCERPT_BYTES);
23
+ }
24
+ function isBlank(line) {
25
+ return line === undefined || line.trim() === '';
26
+ }
27
+ function isRuleOnly(line) {
28
+ const content = line.replaceAll(/\s/gu, '');
29
+ return content !== '' && !NON_RULE_CHARACTER.test(content);
30
+ }
@@ -1,21 +1,28 @@
1
1
  export function formatDuration(milliseconds) {
2
- const seconds = Math.round(milliseconds / MILLISECONDS_PER_SECOND);
3
- if (seconds < SECONDS_PER_MINUTE) {
4
- return `${seconds}s`;
2
+ const total = Math.max(0, milliseconds);
3
+ if (total < MILLISECONDS_PER_MINUTE) {
4
+ return `${truncateToTenth(total)}s`;
5
5
  }
6
- const minutes = Math.round(seconds / SECONDS_PER_MINUTE);
7
- if (minutes < MINUTES_PER_HOUR) {
8
- return `${minutes}m`;
6
+ const totalMinutes = Math.floor(total / MILLISECONDS_PER_MINUTE);
7
+ if (totalMinutes < MINUTES_PER_HOUR) {
8
+ const seconds = Math.floor(total / MILLISECONDS_PER_SECOND) % SECONDS_PER_MINUTE;
9
+ return joinUnits(`${totalMinutes}m`, seconds, 's');
9
10
  }
10
- return `${Math.round(minutes / MINUTES_PER_HOUR)}h`;
11
+ return joinUnits(`${Math.floor(totalMinutes / MINUTES_PER_HOUR)}h`, totalMinutes % MINUTES_PER_HOUR, 'm');
11
12
  }
12
13
  export function formatSaving(milliseconds) {
13
14
  if (!Number.isFinite(milliseconds) || milliseconds < MILLISECONDS_PER_SECOND) {
14
15
  return undefined;
15
16
  }
16
- return `${SPEED_BOOST_ICON} saved ~${formatDuration(milliseconds)}`;
17
+ return `saved ~${formatDuration(milliseconds)}`;
17
18
  }
19
+ const MILLISECONDS_PER_MINUTE = 60_000;
18
20
  const MILLISECONDS_PER_SECOND = 1_000;
19
21
  const MINUTES_PER_HOUR = 60;
20
22
  const SECONDS_PER_MINUTE = 60;
21
- const SPEED_BOOST_ICON = '🚀';
23
+ function joinUnits(major, minor, minorUnit) {
24
+ return minor === 0 ? major : `${major} ${minor}${minorUnit}`;
25
+ }
26
+ function truncateToTenth(milliseconds) {
27
+ return Math.floor(milliseconds / 100) / 10;
28
+ }
@@ -1,12 +1,19 @@
1
- export interface PackageJson {
1
+ export declare const DEPENDENCY_FIELDS: readonly ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
2
+ export type DependencyField = (typeof DEPENDENCY_FIELDS)[number];
3
+ export type PackageJson = {
2
4
  name?: string;
3
5
  private?: boolean;
4
6
  version?: string;
5
7
  packageManager?: string;
6
8
  scripts?: Record<string, string>;
7
9
  pnpm?: {
8
- overrides?: Record<string, string>;
10
+ overrides?: Record<string, unknown>;
9
11
  };
10
- }
12
+ } & {
13
+ [K in DependencyField]?: Record<string, string>;
14
+ };
11
15
  export declare function readPackageJson(dir: string): PackageJson;
12
- export declare function getPnpmOverrides(pkg: PackageJson): Record<string, string> | undefined;
16
+ export declare function getPnpmOverrides(pkg: PackageJson): Record<string, unknown> | undefined;
17
+ export declare function parsePackageJson(raw: string, file: string): unknown;
18
+ export declare function readScriptRecord(dir: string, scripts: Record<string, unknown>): Record<string, string>;
19
+ export declare function resolvePackageJsonPath(dir: string): string;
@@ -1,10 +1,22 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { describeError } from '@williamthorsen/toolbelt.errors';
4
+ import { CONFIG_RELATIVE_PATH } from "../config.js";
5
+ import { UserError } from "../UserError.js";
6
+ import { isMonorepoRoot } from "../workspace.js";
7
+ import { readStringValues } from "./readStringValues.js";
3
8
  import { isObject } from "./type-guards.js";
9
+ export const DEPENDENCY_FIELDS = [
10
+ 'dependencies',
11
+ 'devDependencies',
12
+ 'optionalDependencies',
13
+ 'peerDependencies',
14
+ ];
4
15
  export function readPackageJson(dir) {
5
- const parsed = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
16
+ const file = resolvePackageJsonPath(dir);
17
+ const parsed = parsePackageJson(readFileSync(file, 'utf8'), file);
6
18
  if (!isObject(parsed)) {
7
- throw new TypeError(`Invalid package.json in ${dir}: expected an object`);
19
+ throw new UserError(`Invalid package.json in ${dir}: expected an object`);
8
20
  }
9
21
  const pkg = {};
10
22
  if (typeof parsed['name'] === 'string')
@@ -16,24 +28,18 @@ export function readPackageJson(dir) {
16
28
  if (typeof parsed['packageManager'] === 'string')
17
29
  pkg.packageManager = parsed['packageManager'];
18
30
  if (isObject(parsed['scripts'])) {
19
- const scripts = {};
20
- const scriptEntries = Object.entries(parsed['scripts']);
21
- for (const [key, val] of scriptEntries) {
22
- if (typeof val === 'string')
23
- scripts[key] = val;
24
- }
25
- pkg.scripts = scripts;
31
+ pkg.scripts = readScriptRecord(dir, parsed['scripts']);
26
32
  }
27
33
  if (isObject(parsed['pnpm'])) {
28
34
  const pnpm = parsed['pnpm'];
29
35
  if (isObject(pnpm['overrides'])) {
30
- const overrides = {};
31
- const overrideEntries = Object.entries(pnpm['overrides']);
32
- for (const [key, val] of overrideEntries) {
33
- if (typeof val === 'string')
34
- overrides[key] = val;
35
- }
36
- pkg.pnpm = { overrides };
36
+ pkg.pnpm = { overrides: pnpm['overrides'] };
37
+ }
38
+ }
39
+ for (const field of DEPENDENCY_FIELDS) {
40
+ const declared = parsed[field];
41
+ if (isObject(declared)) {
42
+ pkg[field] = readStringValues(declared);
37
43
  }
38
44
  }
39
45
  return pkg;
@@ -42,11 +48,34 @@ export function getPnpmOverrides(pkg) {
42
48
  if (!isObject(pkg.pnpm))
43
49
  return undefined;
44
50
  const overrides = pkg.pnpm.overrides;
45
- if (!isObject(overrides))
46
- return undefined;
47
- for (const value of Object.values(overrides)) {
48
- if (typeof value !== 'string')
49
- return undefined;
51
+ return isObject(overrides) ? overrides : undefined;
52
+ }
53
+ export function parsePackageJson(raw, file) {
54
+ try {
55
+ return JSON.parse(raw);
56
+ }
57
+ catch (error) {
58
+ throw new UserError(`Invalid package.json at ${file}: ${describeError(error)}`);
59
+ }
60
+ }
61
+ export function readScriptRecord(dir, scripts) {
62
+ const result = {};
63
+ for (const [key, value] of Object.entries(scripts)) {
64
+ if (typeof value !== 'string') {
65
+ throw new UserError(formatMalformedScript(dir, key, value));
66
+ }
67
+ result[key] = value;
68
+ }
69
+ return result;
70
+ }
71
+ export function resolvePackageJsonPath(dir) {
72
+ return path.join(dir, 'package.json');
73
+ }
74
+ function formatMalformedScript(dir, key, value) {
75
+ const rejection = `Invalid package.json at ${resolvePackageJsonPath(dir)}: \`scripts.${key}\` must be a string.`;
76
+ if (!Array.isArray(value)) {
77
+ return rejection;
50
78
  }
51
- return overrides;
79
+ const field = isMonorepoRoot(dir) ? 'rootScripts' : 'workspaceScripts';
80
+ return `${rejection} A step list belongs in \`${CONFIG_RELATIVE_PATH}\` under \`${field}\`.`;
52
81
  }
@@ -0,0 +1 @@
1
+ export declare function readStringValues(record: Record<string, unknown>): Record<string, string>;
@@ -0,0 +1,8 @@
1
+ export function readStringValues(record) {
2
+ const result = {};
3
+ for (const [key, value] of Object.entries(record)) {
4
+ if (typeof value === 'string')
5
+ result[key] = value;
6
+ }
7
+ return result;
8
+ }
@@ -0,0 +1,5 @@
1
+ import type { RetainedOutput } from '../runner.js';
2
+ export declare const TRANSCRIPT_LIMIT_BYTES = 262144;
3
+ export declare function cleanTranscript(raw: string): string;
4
+ export declare function composeTranscript(retained: RetainedOutput): string | undefined;
5
+ export declare function formatElisionMarker(byteCount: number): string;
@@ -0,0 +1,59 @@
1
+ export const TRANSCRIPT_LIMIT_BYTES = 262_144;
2
+ const ANSI_PATTERN = /\u{1B}(?:\[[\u{30}-\u{3F}]*[\u{20}-\u{2F}]*[\u{40}-\u{7E}]|\][^\u{7}\u{1B}]*(?:\u{7}|\u{1B}\\))/gu;
3
+ const END_LIMIT_BYTES = TRANSCRIPT_LIMIT_BYTES / 2;
4
+ const STREAM_MARKER = '\n… nmr: stderr …\n';
5
+ export function cleanTranscript(raw) {
6
+ return raw
7
+ .replaceAll(ANSI_PATTERN, '')
8
+ .split('\n')
9
+ .map((line) => renderCarriageReturns(line))
10
+ .join('\n');
11
+ }
12
+ export function composeTranscript(retained) {
13
+ const streams = [retained.stdout, retained.stderr]
14
+ .map((stream) => cleanTranscript(stream.toString('utf8')))
15
+ .filter((stream) => stream.trim() !== '');
16
+ if (streams.length === 0) {
17
+ return undefined;
18
+ }
19
+ return clampTranscript(streams.join(STREAM_MARKER));
20
+ }
21
+ export function formatElisionMarker(byteCount) {
22
+ const unit = byteCount === 1 ? 'byte' : 'bytes';
23
+ return `\n… nmr elided ${byteCount.toLocaleString('en-US')} ${unit} …\n`;
24
+ }
25
+ function alignEnd(buffer, end) {
26
+ let aligned = Math.min(buffer.length, end);
27
+ while (aligned > 0 && isContinuationByte(buffer[aligned])) {
28
+ aligned--;
29
+ }
30
+ return aligned;
31
+ }
32
+ function alignStart(buffer, start) {
33
+ let aligned = Math.max(0, start);
34
+ while (aligned < buffer.length && isContinuationByte(buffer[aligned])) {
35
+ aligned++;
36
+ }
37
+ return aligned;
38
+ }
39
+ function clampTranscript(transcript) {
40
+ const buffer = Buffer.from(transcript, 'utf8');
41
+ if (buffer.length <= TRANSCRIPT_LIMIT_BYTES) {
42
+ return transcript;
43
+ }
44
+ const headEnd = alignEnd(buffer, END_LIMIT_BYTES);
45
+ const tailStart = alignStart(buffer, buffer.length - END_LIMIT_BYTES);
46
+ const head = buffer.subarray(0, headEnd).toString('utf8');
47
+ const tail = buffer.subarray(tailStart).toString('utf8');
48
+ return `${head}${formatElisionMarker(tailStart - headEnd)}${tail}`;
49
+ }
50
+ function isContinuationByte(byte) {
51
+ return byte !== undefined && (byte & 0b1100_0000) === 0b1000_0000;
52
+ }
53
+ function renderCarriageReturns(line) {
54
+ if (!line.includes('\r')) {
55
+ return line;
56
+ }
57
+ const segments = line.split('\r').filter((segment) => segment.trim() !== '');
58
+ return segments.at(-1) ?? '';
59
+ }
@@ -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
+ }