@williamthorsen/nmr 0.38.0 → 0.40.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 (112) hide show
  1. package/.meta/changelog.json +212 -4
  2. package/.readyup/kits/default.js +124 -104
  3. package/.readyup/manifest.json +12 -7
  4. package/CHANGELOG.md +108 -0
  5. package/README.md +41 -47
  6. package/agents/README.md +8 -8
  7. package/agents/guidance/rulebooks/nmr.md +8 -8
  8. package/bin/ensure-prepublish-hooks.js +1 -1
  9. package/bin/nmr-clean.js +1 -1
  10. package/bin/nmr-compile.js +1 -1
  11. package/bin/nmr-fmt.js +1 -1
  12. package/bin/nmr-report-catalog.js +1 -1
  13. package/bin/nmr-report-overrides.js +1 -1
  14. package/bin/nmr-taze.js +1 -1
  15. package/bin/nmr.js +1 -1
  16. package/dist/esm/UserError.d.ts +1 -2
  17. package/dist/esm/check-cache.d.ts +61 -63
  18. package/dist/esm/check-cache.js +24 -24
  19. package/dist/esm/cli-ensure-prepublish-hooks.js +4 -4
  20. package/dist/esm/commands/build-output.d.ts +37 -38
  21. package/dist/esm/commands/build-output.js +9 -9
  22. package/dist/esm/commands/build.d.ts +13 -14
  23. package/dist/esm/commands/build.js +67 -65
  24. package/dist/esm/commands/clean.d.ts +6 -6
  25. package/dist/esm/commands/clean.js +11 -11
  26. package/dist/esm/commands/ensure-prepublish-hooks.d.ts +7 -7
  27. package/dist/esm/commands/ensure-prepublish-hooks.js +41 -41
  28. package/dist/esm/commands/fmt.d.ts +8 -8
  29. package/dist/esm/commands/fmt.js +33 -33
  30. package/dist/esm/commands/report-catalog.d.ts +5 -7
  31. package/dist/esm/commands/report-catalog.js +10 -8
  32. package/dist/esm/commands/report-overrides.d.ts +0 -2
  33. package/dist/esm/commands/report-overrides.js +8 -8
  34. package/dist/esm/commands/taze.d.ts +9 -8
  35. package/dist/esm/config.d.ts +5 -5
  36. package/dist/esm/config.js +27 -27
  37. package/dist/esm/context.d.ts +1 -2
  38. package/dist/esm/context.js +8 -8
  39. package/dist/esm/default-scripts.d.ts +5 -6
  40. package/dist/esm/defineConfig.d.ts +1 -1
  41. package/dist/esm/git-ignored-paths.d.ts +9 -0
  42. package/dist/esm/git-ignored-paths.js +10 -0
  43. package/dist/esm/glyphs.d.ts +3 -3
  44. package/dist/esm/help.d.ts +4 -4
  45. package/dist/esm/help.js +21 -21
  46. package/dist/esm/helpers/clampToBytes.d.ts +4 -5
  47. package/dist/esm/helpers/clampToBytes.js +3 -3
  48. package/dist/esm/helpers/config-path.d.ts +0 -2
  49. package/dist/esm/helpers/createBoundedBuffer.d.ts +3 -3
  50. package/dist/esm/helpers/createBoundedBuffer.js +2 -2
  51. package/dist/esm/helpers/deriveExcerpt.d.ts +4 -5
  52. package/dist/esm/helpers/duration.d.ts +10 -11
  53. package/dist/esm/helpers/duration.js +7 -7
  54. package/dist/esm/helpers/filter-selection.d.ts +19 -19
  55. package/dist/esm/helpers/filter-selection.js +12 -12
  56. package/dist/esm/helpers/findClosestName.d.ts +1 -1
  57. package/dist/esm/helpers/findClosestName.js +15 -15
  58. package/dist/esm/helpers/hook-name.d.ts +2 -5
  59. package/dist/esm/helpers/package-json.d.ts +11 -10
  60. package/dist/esm/helpers/package-json.js +26 -25
  61. package/dist/esm/helpers/readStringValues.d.ts +2 -3
  62. package/dist/esm/helpers/reportClosing.d.ts +8 -9
  63. package/dist/esm/helpers/transcript.d.ts +15 -15
  64. package/dist/esm/helpers/transcript.js +14 -14
  65. package/dist/esm/helpers/type-guards.d.ts +1 -0
  66. package/dist/esm/output-style.d.ts +12 -13
  67. package/dist/esm/prettier.d.ts +2 -2
  68. package/dist/esm/prettier.js +5 -5
  69. package/dist/esm/recording.d.ts +25 -20
  70. package/dist/esm/recording.js +28 -21
  71. package/dist/esm/replay-assembly.d.ts +10 -10
  72. package/dist/esm/report-format.d.ts +15 -20
  73. package/dist/esm/report-format.js +7 -7
  74. package/dist/esm/reportCliFailure.d.ts +2 -3
  75. package/dist/esm/resolve-scripts.d.ts +4 -5
  76. package/dist/esm/resolveBinStyles.d.ts +4 -5
  77. package/dist/esm/resolveBinStyles.js +3 -3
  78. package/dist/esm/resolver.d.ts +16 -20
  79. package/dist/esm/resolver.js +15 -15
  80. package/dist/esm/runCli.d.ts +6 -6
  81. package/dist/esm/runCli.js +261 -195
  82. package/dist/esm/runner.d.ts +20 -20
  83. package/dist/esm/runner.js +18 -18
  84. package/dist/esm/steps.d.ts +51 -38
  85. package/dist/esm/steps.js +32 -27
  86. package/dist/esm/taze.d.ts +1 -1
  87. package/dist/esm/tests.d.ts +23 -18
  88. package/dist/esm/tests.js +21 -9
  89. package/dist/esm/tiers.d.ts +28 -24
  90. package/dist/esm/tiers.js +13 -9
  91. package/dist/esm/types.d.ts +5 -5
  92. package/dist/esm/verbosity.d.ts +23 -21
  93. package/dist/esm/verbosity.js +7 -7
  94. package/dist/esm/verdict.d.ts +39 -32
  95. package/dist/esm/verdict.js +63 -50
  96. package/dist/esm/vitest-source-resolution.d.ts +11 -11
  97. package/dist/esm/vitest-source-resolution.js +49 -47
  98. package/dist/esm/vitest.d.ts +27 -26
  99. package/dist/esm/vitest.js +90 -33
  100. package/dist/esm/workspace.d.ts +6 -22
  101. package/dist/esm/workspace.js +9 -67
  102. package/docs/check-cache.md +57 -57
  103. package/docs/prettier.md +10 -10
  104. package/docs/reporting.md +48 -44
  105. package/docs/scripts.md +29 -29
  106. package/docs/upgrades.md +18 -18
  107. package/docs/utilities.md +13 -13
  108. package/docs/vitest.md +68 -66
  109. package/docs/workspace.md +27 -4
  110. package/package.json +6 -6
  111. package/dist/esm/helpers/workspace-patterns.d.ts +0 -8
  112. package/dist/esm/helpers/workspace-patterns.js +0 -29
package/dist/esm/tests.js CHANGED
@@ -1,25 +1,37 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { findMisplacedTestFiles, findUntieredTestFiles, TIER_NAMES } from "./tiers.js";
3
3
  import { findMonorepoRoot } from "./workspace.js";
4
- const MISPLACED_REMEDY = 'Every test file must sit under a `__tests__` directory, the only place the shared Vitest config collects from. Move each file below into one, or name its directory in `exclude`.';
4
+ const MISPLACED_REMEDY = 'Every test file must be under a `__tests__` directory, the only place from which the shared Vitest config collects. Move each file below into one, or name its directory in `excludedBasenames`.';
5
5
  const UNTIERED_REMEDY = `Every collected test file must name its isolation tier in the segment before \`.test.\`, one of: ${TIER_NAMES.join(', ')}. Rename each file below to <subject>[.<aspect>].<tier>.test.ts.`;
6
+ const RETIRED_OPTION_KEYS = new Map([['exclude', 'excludedBasenames']]);
6
7
  export function checkTestFileConventions(options = {}) {
8
+ assertNoRetiredOptions(options);
7
9
  describe('test file conventions', () => {
8
- const { misplaced, rootDir, untiered } = reportTestFileConventions(options);
10
+ const { misplacedFiles, rootDir, untieredFiles } = reportTestFileConventions(options);
9
11
  it('every collected test file names its tier', () => {
10
- expect(untiered, `${UNTIERED_REMEDY} Swept from ${rootDir}.`).toStrictEqual([]);
12
+ expect(untieredFiles, `${UNTIERED_REMEDY} Swept from ${rootDir}.`).toStrictEqual([]);
11
13
  });
12
- it('every test file sits under a __tests__ directory', () => {
13
- expect(misplaced, `${MISPLACED_REMEDY} Swept from ${rootDir}.`).toStrictEqual([]);
14
+ it('every test file is under a __tests__ directory', () => {
15
+ expect(misplacedFiles, `${MISPLACED_REMEDY} Swept from ${rootDir}.`).toStrictEqual([]);
14
16
  });
15
17
  });
16
18
  }
17
- export function reportTestFileConventions({ exclude, rootDir, } = {}) {
19
+ export function reportTestFileConventions(options = {}) {
20
+ assertNoRetiredOptions(options);
21
+ const { excludedBasenames, rootDir } = options;
18
22
  const sweptRoot = rootDir ?? findMonorepoRoot();
19
- const scanOptions = { ...(exclude !== undefined && { exclude }) };
23
+ const scanOptions = { ...(excludedBasenames !== undefined && { excludedBasenames }) };
20
24
  return {
21
- misplaced: findMisplacedTestFiles(sweptRoot, scanOptions),
25
+ misplacedFiles: findMisplacedTestFiles(sweptRoot, scanOptions),
22
26
  rootDir: sweptRoot,
23
- untiered: findUntieredTestFiles(sweptRoot, scanOptions),
27
+ untieredFiles: findUntieredTestFiles(sweptRoot, scanOptions),
24
28
  };
25
29
  }
30
+ function assertNoRetiredOptions(options) {
31
+ for (const key of Object.keys(options)) {
32
+ const replacement = RETIRED_OPTION_KEYS.get(key);
33
+ if (replacement !== undefined) {
34
+ throw new TypeError(`Invalid test-file-conventions options: \`${key}\` was renamed to \`${replacement}\`.`);
35
+ }
36
+ }
37
+ }
@@ -1,30 +1,30 @@
1
- /** Collection patterns for the residual project, which claims every test file the named tiers leave. */
1
+ /** Collection patterns for the residual project, which claims every test file that the named tiers leave. */
2
2
  export declare const ALL_TEST_PATTERNS: string[];
3
3
  /** Builds the collection patterns for one tier. */
4
4
  export declare function buildTierPatterns(tier: string): string[];
5
5
  /**
6
- * Walks `rootDir` for every `*.test.ts` or `*.test.tsx` file sitting outside a `__tests__` directory, returning
7
- * paths relative to it, POSIX-separated and sorted.
6
+ * Walks `rootDir` for every `*.test.ts` or `*.test.tsx` file outside a `__tests__` directory, returning paths
7
+ * relative to it, POSIX-separated and sorted.
8
8
  *
9
- * No project collects such a file, so it runs nowhere and reports nothing. Only a sweep of the tree tells it apart
10
- * from a file that runs and passes.
9
+ * The shared config's projects do not collect such a file, so it runs nowhere and reports nothing. Only a sweep of
10
+ * the tree tells it apart from a file that runs and passes.
11
11
  */
12
12
  export declare function findMisplacedTestFiles(rootDir: string, options?: TestFileScanOptions): string[];
13
13
  /**
14
- * Walks `rootDir` for every test file the shared config's projects collect, returning paths relative to it,
14
+ * Walks `rootDir` for every test file that the shared config's projects collect, returning paths relative to it,
15
15
  * POSIX-separated and sorted.
16
16
  *
17
- * A walk, because `node:fs` `globSync` does not descend into a dot-directory and honours no option that would make
18
- * it. Vitest's globber does, so the same pattern string means different things to the two: a check built on the glob
19
- * would report clean over every test file under a dot-directory.
17
+ * A walk, because `node:fs` `globSync` does not descend into a dot-directory and does not honour any option that
18
+ * would make it. Vitest's globber does, so the same pattern string means different things to the two: A check built
19
+ * on the glob would report clean over every test file under a dot-directory.
20
20
  */
21
21
  export declare function findTestFiles(rootDir: string, options?: TestFileScanOptions): string[];
22
22
  /**
23
- * Walks `rootDir` for every collected test file whose name selects no tier, returning paths relative to it,
23
+ * Walks `rootDir` for every collected test file whose name does not select a tier, returning paths relative to it,
24
24
  * POSIX-separated and sorted.
25
25
  *
26
- * `unit` is the residual project, so such a file runs under it and reports success: no test run tells it apart from
27
- * a conformant file.
26
+ * Because `unit` is the residual project, such a file runs under it and reports success: A test run cannot tell it
27
+ * apart from a conformant file.
28
28
  */
29
29
  export declare function findUntieredTestFiles(rootDir: string, options?: TestFileScanOptions): string[];
30
30
  /**
@@ -35,27 +35,31 @@ export declare function findUntieredTestFiles(rootDir: string, options?: TestFil
35
35
  */
36
36
  export declare function hasTierInfix(filePath: string): boolean;
37
37
  /**
38
- * Directory names that hold no test worth collecting: dependencies, build output, and generated reports.
38
+ * Names of directories that do not contain any test worth collecting: dependencies, build output, and generated
39
+ * reports.
39
40
  *
40
- * The one point where the collection glob and the walk must agree about scope. Over-reporting is a failure a
41
- * consumer cannot fix; under-reporting is the silence a conformance check exists to end.
41
+ * These and the paths that git ignores are the scope on which the collection glob and the walk must agree.
42
+ * Over-reporting is a failure that a consumer cannot fix; under-reporting hides the violations that a conformance
43
+ * check exists to report.
42
44
  */
43
45
  export declare const TEST_COLLECTION_EXCLUDE: string[];
44
- /** Options every sweep over a repo's test files takes. */
46
+ /** Options that every sweep over a repo's test files takes. */
45
47
  export interface TestFileScanOptions {
46
48
  /**
47
- * Directory basenames pruned at any depth, additive to `TEST_COLLECTION_EXCLUDE`.
49
+ * Directory basenames pruned at any depth, additive to `TEST_COLLECTION_EXCLUDE` and to the paths that git
50
+ * ignores.
48
51
  *
49
- * Basenames rather than globs, so the array a repo passes here is the array it passes to the shared Vitest
50
- * config's `testCollectionExclude`. A directory pruned from the sweep but still collected by Vitest is the
51
- * silence these sweeps exist to end.
52
+ * Basenames rather than globs, so that a repo can pass the same array here and to the shared Vitest config's
53
+ * `testCollectionExclude`. Vitest still runs the files in a directory pruned from the sweep alone, and the sweep
54
+ * does not report them.
52
55
  */
53
- exclude?: readonly string[];
56
+ excludedBasenames?: readonly string[];
54
57
  }
55
58
  /**
56
- * The isolation ladder, ordered by the furthest thing a test reaches. A tier names what a test reaches, never how it
57
- * invokes it: a test driving a compiler through its JavaScript API is `tool`, exactly as one spawning `tsc` would be.
58
- * Each named tier's name is also its filename infix, so `parse.tool.test.ts` lands in `tool`.
59
+ * The isolation ladder, ordered by the furthest thing that a test reaches. A tier names what a test reaches, never
60
+ * how it invokes it: A test driving a compiler through its JavaScript API is `tool`, exactly as one spawning `tsc`
61
+ * would be. Each named tier's name is also its filename infix, so `parse.tool.test.ts` is collected by the `tool`
62
+ * project.
59
63
  */
60
64
  export declare const TIER_NAMES: readonly ["unit", "tool", "localhost", "remote"];
61
65
  /** One of the four isolation tiers. */
package/dist/esm/tiers.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { listGitIgnoredPaths } from "./git-ignored-paths.js";
3
4
  const TEST_DIR = '__tests__';
4
5
  const TEST_EXTENSIONS = '{ts,tsx}';
5
6
  const TEST_GLOB_PREFIX = `**/${TEST_DIR}/**`;
@@ -23,26 +24,29 @@ export function hasTierInfix(filePath) {
23
24
  }
24
25
  export const TEST_COLLECTION_EXCLUDE = ['.git', 'coverage', 'dist', 'node_modules'];
25
26
  export const TIER_NAMES = ['unit', 'tool', 'localhost', 'remote'];
26
- function collectTestFiles(dir, relativeDir, inTestDir, context) {
27
+ function collectTestFiles(dir, relativeDir, isInTestDir, context) {
27
28
  const entries = readdirSync(dir, { withFileTypes: true });
28
29
  for (const entry of entries) {
29
30
  const relativePath = relativeDir === '' ? entry.name : `${relativeDir}/${entry.name}`;
30
31
  if (entry.isDirectory()) {
31
- if (context.pruned.has(entry.name))
32
+ if (context.pruned.has(entry.name) || context.ignoredPaths.has(`${relativePath}/`))
32
33
  continue;
33
- collectTestFiles(path.join(dir, entry.name), relativePath, inTestDir || entry.name === TEST_DIR, context);
34
+ collectTestFiles(path.join(dir, entry.name), relativePath, isInTestDir || entry.name === TEST_DIR, context);
34
35
  }
35
- else if (inTestDir !== context.misplaced && TEST_FILE_PATTERN.test(entry.name)) {
36
- context.found.push(relativePath);
36
+ else if (isInTestDir !== context.misplaced &&
37
+ TEST_FILE_PATTERN.test(entry.name) &&
38
+ !context.ignoredPaths.has(relativePath)) {
39
+ context.foundPaths.push(relativePath);
37
40
  }
38
41
  }
39
42
  }
40
- function walkTestFiles(rootDir, { exclude = [] }, misplaced) {
43
+ function walkTestFiles(rootDir, { excludedBasenames = [] }, misplaced) {
41
44
  const context = {
42
- found: [],
45
+ foundPaths: [],
46
+ ignoredPaths: new Set(listGitIgnoredPaths(rootDir)),
43
47
  misplaced,
44
- pruned: new Set([...TEST_COLLECTION_EXCLUDE, ...exclude]),
48
+ pruned: new Set([...TEST_COLLECTION_EXCLUDE, ...excludedBasenames]),
45
49
  };
46
50
  collectTestFiles(rootDir, '', false, context);
47
- return context.found.toSorted();
51
+ return context.foundPaths.toSorted();
48
52
  }
@@ -6,7 +6,7 @@ export type { CommandVerbosity } from './verbosity.js';
6
6
  export interface BuildConfig {
7
7
  /**
8
8
  * Patterns added to the build's default ignore set. Extends rather than replaces, so that declaring one pattern
9
- * cannot silently drop the defaults and start shipping a package's own tests.
9
+ * cannot silently drop the defaults and start publishing a package's own tests.
10
10
  * The programmatic `buildPackage` option of the same name behaves identically; its bare `ignorePatterns` replaces.
11
11
  */
12
12
  extraIgnorePatterns?: string[];
@@ -23,7 +23,7 @@ export interface CheckCacheConfig {
23
23
  /**
24
24
  * Command names added to the cacheable set. Extends rather than replaces, so that declaring one command
25
25
  * cannot silently drop the defaults. A name listed here promises exit-status-only semantics through its whole
26
- * chain, hooks included, and has to resolve to a command: both lists are read by name alone, so a misspelt
26
+ * chain, hooks included, and has to resolve to a command: Both lists are read by name alone, so a misspelt
27
27
  * entry would otherwise be inert.
28
28
  */
29
29
  extraCommands?: string[];
@@ -31,13 +31,13 @@ export interface CheckCacheConfig {
31
31
  /** Output settings honored at the monorepo root. */
32
32
  export interface OutputConfig {
33
33
  /**
34
- * The verbosity a run takes when neither `-q` nor the environment named one. Both of those outrank it, and it
35
- * outranks agent-harness detection, so `full` here is also how a repo declines to be detected.
34
+ * The verbosity that a run takes when neither `-q` nor the environment named one. Both of those outrank it.
35
+ * Because it outranks agent-harness detection, `full` here is also how a repo declines to be detected.
36
36
  */
37
37
  commandVerbosity?: CommandVerbosity;
38
38
  /**
39
39
  * Environment-variable names added to the set whose presence marks an agent harness. Extends rather than
40
- * replaces, so that declaring one name cannot silently drop the shipped list, and a repo absorbs a harness's
40
+ * replaces, so that declaring one name cannot silently drop the built-in list, and a repo handles a harness's
41
41
  * rename without waiting for an nmr release.
42
42
  */
43
43
  extraAgentEnvVars?: string[];
@@ -1,55 +1,57 @@
1
1
  import type { OutputConfig } from './types.js';
2
2
  /**
3
- * Environment variables a known agent harness sets, whose presence selects quiet when no level above them chose.
4
- * The identifiers belong to ecosystems nmr does not control and that rename, so `output.extraAgentEnvVars` extends
5
- * this list rather than a repo waiting for a release. `ROVO_CLI` anticipates a rename of `ROVODEV_CLI` that Rovo
6
- * has signalled but not made; a variable nobody sets never fires.
3
+ * Environment variables that a known agent harness sets, whose presence selects quiet when every level above them
4
+ * left the verbosity unset. `output.extraAgentEnvVars` extends the list. `ROVO_CLI` anticipates a rename of
5
+ * `ROVODEV_CLI` that Rovo has signalled but not made.
7
6
  */
8
7
  export declare const AGENT_ENV_VARS: readonly ["CLAUDECODE", "ROVODEV_CLI", "ROVO_CLI"];
9
8
  /**
10
9
  * The points on the loudness ladder. Both are spelled out rather than leaving `full` implicit in the variable's
11
- * absence, so a repo-level default is overridable from the environment in either direction.
10
+ * absence, so that a repo-level default is overridable from the environment in either direction.
12
11
  */
13
12
  export declare const COMMAND_VERBOSITIES: readonly ["full", "quiet"];
14
13
  /**
15
- * Carries the resolved verbosity down the spawned chain, so `-q` reaches every process rather than the first.
16
- * Deliberately not a keyed variable: it changes what a run prints and never what a command concludes, so folding
17
- * it into the cache key would stop a quiet run from hitting a pass a loud one recorded.
14
+ * Passes the resolved verbosity down the chain of spawned processes, so that `-q` applies to every process rather
15
+ * than only the first. The pass key does not include it: It changes what a run prints and never what a command
16
+ * concludes, so a quiet run can reuse a pass that a full run recorded.
18
17
  */
19
18
  export declare const COMMAND_VERBOSITY_ENV_VAR = "NMR_COMMAND_VERBOSITY";
20
- /** How loudly nmr reports the output of the commands it runs, which is never its own verdicts. */
19
+ /** How loudly nmr reports the output of the commands that it runs, which is never its own verdicts. */
21
20
  export type CommandVerbosity = (typeof COMMAND_VERBOSITIES)[number];
22
21
  /**
23
- * Renders the rejection of a value off the loudness ladder, naming where it was written. Every source that accepts
24
- * a verbosity renders through this, so no two of them can come to name different ladders.
22
+ * Renders the rejection of a value off the loudness ladder, naming where it was written. Because every source that
23
+ * accepts a verbosity renders through this, the sources cannot come to name different ladders.
25
24
  */
26
25
  export declare function formatVerbosityRejection(source: string, value: string): string;
27
26
  /** Narrows a raw value to a point on the loudness ladder. */
28
27
  export declare function isCommandVerbosity(value: string): value is CommandVerbosity;
29
28
  /**
30
- * Reads the verbosity the environment names. An unrecognized value resolves to nothing at all, since falling back
31
- * would pick a mode nobody chose and hide a misspelling for the life of the shell.
29
+ * Reads the verbosity that the environment names. An unrecognized value is returned as an error, since a fallback
30
+ * would pick a mode that nobody chose and hide a misspelling for the life of the shell.
32
31
  *
33
- * An unset or empty variable names no verbosity rather than resolving to `full`, which is what leaves the config
34
- * and detection levels reachable: a floor written in here would outrank both of them.
32
+ * For an unset or empty variable, this returns a result without a verbosity rather than resolving to `full`, which
33
+ * is what leaves the config and detection levels reachable: A floor written in here would outrank both of them.
35
34
  */
36
35
  export declare function readVerbosityEnv(env: NodeJS.ProcessEnv): VerbosityRead;
37
36
  export interface ResolveVerbosityOptions {
38
- /** Read for the detection level, and for that alone: the variable this module owns arrives as `envVerbosity`. */
37
+ /**
38
+ * Read for the detection level, and for that alone: The caller passes the variable that this module owns as
39
+ * `envVerbosity`.
40
+ */
39
41
  env: NodeJS.ProcessEnv;
40
42
  envVerbosity: CommandVerbosity | undefined;
43
+ hasQuietFlag: boolean;
41
44
  output: OutputConfig | undefined;
42
- quietFlag: boolean;
43
45
  }
44
46
  /**
45
- * Resolves the verbosity this process runs at: the flag, then the environment, then the repo's config, then
47
+ * Resolves the verbosity at which this process runs: the flag, then the environment, then the repo's config, then
46
48
  * detection of a known agent harness, then `full`.
47
49
  *
48
- * Total by construction. The environment was validated as it was read and the config as it was loaded, so no
49
- * rejection is left for this to report and the whole ladder reads in one place.
50
+ * Total by construction. The environment was validated as it was read and the config as it was loaded, so this does
51
+ * not have any rejection left to report, and the whole ladder can be read in one place.
50
52
  */
51
53
  export declare function resolveVerbosity(options: ResolveVerbosityOptions): CommandVerbosity;
52
- /** The verbosity the environment names, if any, or the message naming why its value could not be read. */
54
+ /** The verbosity that the environment names, if any, or the message naming why its value could not be read. */
53
55
  export type VerbosityRead = {
54
56
  ok: true;
55
57
  verbosity?: CommandVerbosity;
@@ -9,18 +9,18 @@ export function isCommandVerbosity(value) {
9
9
  return names.includes(value);
10
10
  }
11
11
  export function readVerbosityEnv(env) {
12
- const raw = env[COMMAND_VERBOSITY_ENV_VAR];
13
- if (raw === undefined || raw === '') {
12
+ const rawValue = env[COMMAND_VERBOSITY_ENV_VAR];
13
+ if (rawValue === undefined || rawValue === '') {
14
14
  return { ok: true };
15
15
  }
16
- if (!isCommandVerbosity(raw)) {
17
- return { ok: false, error: formatVerbosityRejection(COMMAND_VERBOSITY_ENV_VAR, raw) };
16
+ if (!isCommandVerbosity(rawValue)) {
17
+ return { ok: false, error: formatVerbosityRejection(COMMAND_VERBOSITY_ENV_VAR, rawValue) };
18
18
  }
19
- return { ok: true, verbosity: raw };
19
+ return { ok: true, verbosity: rawValue };
20
20
  }
21
21
  export function resolveVerbosity(options) {
22
- const { env, envVerbosity, output, quietFlag } = options;
23
- if (quietFlag)
22
+ const { env, envVerbosity, hasQuietFlag, output } = options;
23
+ if (hasQuietFlag)
24
24
  return 'quiet';
25
25
  if (envVerbosity !== undefined)
26
26
  return envVerbosity;
@@ -3,33 +3,41 @@ import { type OutputStyle } from '@williamthorsen/nmr-core';
3
3
  import type { ReplayLine } from './check-cache.js';
4
4
  import type { ReportFormat } from './report-format.js';
5
5
  /**
6
- * The ceiling a rendered verdict, its newline included, is held to.
6
+ * The ceiling to which a rendered verdict, its newline included, is held.
7
7
  *
8
- * POSIX guarantees a write at or below `PIPE_BUF` reaches a pipe whole, and 512 is the smallest bound the
9
- * standard permits, so one write of a line this size cannot be interleaved by a concurrent scope under
10
- * `pnpm --recursive`. No Node API reports the platform's own bound, and a per-platform table would move the
11
- * truncation point from one machine to the next. The ceiling governs pipes: a terminal offers no such
12
- * guarantee at any size.
8
+ * POSIX guarantees that a write at or below `PIPE_BUF` arrives in a pipe whole, and 512 is the smallest bound that
9
+ * the standard permits, so one write of a line this size cannot be interleaved by a concurrent scope under
10
+ * `pnpm --recursive`. Node does not have an API that reports the platform's own bound, and a per-platform table
11
+ * would move the truncation point from one machine to the next. The ceiling applies to pipes: A terminal does not
12
+ * offer that guarantee at any size.
13
13
  */
14
- export declare const VERDICT_LINE_LIMIT = 512;
14
+ export declare const VERDICT_LINE_LIMIT_BYTES = 512;
15
15
  /**
16
- * What a command nmr ran came to, holding the facts a reporting line is rendered from rather than the line
17
- * itself, so a machine-readable rendering spends the same record a human-readable one does.
16
+ * Why a command ran nothing, which the `no-op` outcome includes and `--json` serializes.
18
17
  *
19
- * A recalled pass carries its saving as a duration and not as a decision about whether to mention it: whether
20
- * one is worth naming belongs to rendering, and a consumer bypassing the renderer inherits neither the
21
- * threshold nor an obligation to restate it.
18
+ * `empty-workspace` and `empty-override` both leave a chain without any step in it, so the reason tells a command
19
+ * emptied by the workspace apart from one emptied by an override.
20
+ */
21
+ export type NoOpReason = 'empty-override' | 'empty-workspace' | 'noop-override';
22
+ /**
23
+ * The result of a command that nmr ran. It contains the facts from which a reporting line is rendered rather than
24
+ * the line itself, which lets a machine-readable rendering use the same record as a human-readable one.
25
+ *
26
+ * A recalled pass records its saving as a duration and not as a decision about whether to mention it: Whether
27
+ * one is worth naming belongs to rendering, and a consumer bypassing the renderer inherits neither the threshold
28
+ * nor an obligation to restate it.
22
29
  */
23
30
  export type Verdict = {
24
31
  command: string;
25
32
  scope: string;
26
33
  } & VerdictOutcome;
27
34
  /**
28
- * How a command ended, together with the facts that ending carries and no other does, and the trailing detail
29
- * a line reserves room for.
35
+ * How a command ended, together with the facts specific to that ending, and the trailing detail for which a line
36
+ * reserves room.
30
37
  *
31
- * A recalled pass carries the excerpts it replays rather than a composed detail string, so the marker naming
32
- * them a recording, and the ceiling they are held to, stay with the module that owns the line's grammar.
38
+ * A recalled pass contains the excerpts that it replays rather than a composed detail string, so that the marker
39
+ * naming them a recording, and the ceiling to which they are held, stay with the module that owns the line's
40
+ * grammar.
33
41
  */
34
42
  export type VerdictOutcome = {
35
43
  detail?: string;
@@ -47,32 +55,31 @@ export type VerdictOutcome = {
47
55
  replay?: ReplayLine[];
48
56
  } | {
49
57
  outcome: 'no-op';
50
- reason: 'empty-override' | 'noop-override';
58
+ reason: NoOpReason;
51
59
  });
52
60
  /**
53
- * Renders a verdict as the line nmr reports it on, without the newline that terminates it.
61
+ * Renders a verdict as the line on which nmr reports it, without the newline that terminates it.
54
62
  *
55
- * The line ends without terminal punctuation and reserves its tail for `detail`, so a later change appends to
56
- * the grammar rather than rewriting it. A detail carrying nothing but line breaks takes its whole clause with
57
- * it, rather than leaving a separator pointing at nothing.
63
+ * The line ends without terminal punctuation and reserves its tail for `detail`, so that a later change appends to
64
+ * the grammar rather than rewriting it. When a detail contains nothing but line breaks, the line omits the detail's
65
+ * whole clause rather than leaving a separator pointing at nothing.
58
66
  *
59
- * The marker takes no padding column: every plain marker a verdict reaches for is four characters, so the
60
- * lines align by construction. `formatStatusLine` pads to a column that `BLOCK` widens, and no verdict is one.
67
+ * The marker does not take a padding column. Because every plain marker that a verdict uses is four characters,
68
+ * the lines align by construction. `formatStatusLine` pads to a column that `BLOCK` widens, and a verdict is never
69
+ * one.
61
70
  */
62
71
  export declare function renderVerdict(verdict: Verdict, style: OutputStyle): string;
63
72
  /**
64
- * Renders a verdict as the JSON object a machine consumer reads, without the newline that terminates it.
73
+ * Renders a verdict as the JSON object that a machine consumer reads, without the newline that terminates it.
65
74
  *
66
- * Held to the ceiling the prose line is held to, so one write still reaches a pipe whole where concurrent
67
- * scopes share a descriptor. Cuts land inside the record's text rather than across its structure, which is
68
- * what leaves the line parseable, and a record is fitted by rungs, each shedding what a reader can better
69
- * spare than the rung below it. What the ceiling costs is [documented](../docs/reporting.md#reporting-for-a-machine)
70
- * in the same order.
75
+ * Held to the same ceiling as the prose line, so that one write still arrives in a pipe whole when concurrent
76
+ * scopes share a descriptor. It cuts inside the record's text rather than across its structure, which leaves the
77
+ * line parseable, and fits a record by rungs, each shedding what a reader can better spare than the rung below it.
78
+ * Each rung is [documented](../docs/reporting.md#reporting-for-a-machine) in the same order.
71
79
  */
72
80
  export declare function serializeVerdict(verdict: Verdict): string;
73
81
  /**
74
- * Writes a verdict to a stream as a single write, which is what holds a line together when concurrent scopes
75
- * share one descriptor. Both renderings spend the one record, so neither can come to report what the other
76
- * does not.
82
+ * Writes a verdict to a stream as a single write, which keeps a line intact when concurrent scopes share one
83
+ * descriptor. Both renderings use the one record, so neither can come to report what the other does not.
77
84
  */
78
85
  export declare function writeVerdict(verdict: Verdict, stream: Writable, format: ReportFormat, style: OutputStyle): void;
@@ -2,7 +2,7 @@ import { STATUS_GLYPHS } from '@williamthorsen/nmr-core';
2
2
  import { NMR_GLYPHS } from "./glyphs.js";
3
3
  import { clampToBytes, TRUNCATION_MARK } from "./helpers/clampToBytes.js";
4
4
  import { formatDuration, formatSaving } from "./helpers/duration.js";
5
- export const VERDICT_LINE_LIMIT = 512;
5
+ export const VERDICT_LINE_LIMIT_BYTES = 512;
6
6
  export function renderVerdict(verdict, style) {
7
7
  const { marker, phrase } = describeOutcome(verdict, style);
8
8
  const detail = flattenDetail(verdict.detail ?? renderReplay(verdict) ?? '');
@@ -10,15 +10,15 @@ export function renderVerdict(verdict, style) {
10
10
  return clampToBytes(`${marker} ${verdict.scope}: ${verdict.command}: ${phrase}${detailClause}`, LINE_BUDGET_BYTES);
11
11
  }
12
12
  export function serializeVerdict(verdict) {
13
- const rendered = JSON.stringify(verdict);
14
- return isWithinBudget(rendered) ? rendered : renderWithinBudget(verdict);
13
+ const renderedLine = JSON.stringify(verdict);
14
+ return isWithinBudget(renderedLine) ? renderedLine : renderWithinBudget(verdict);
15
15
  }
16
16
  export function writeVerdict(verdict, stream, format, style) {
17
17
  const line = format === 'json' ? serializeVerdict(verdict) : renderVerdict(verdict, style);
18
18
  stream.write(`${line}\n`);
19
19
  }
20
20
  const NEWLINE_BYTES = 1;
21
- const LINE_BUDGET_BYTES = VERDICT_LINE_LIMIT - NEWLINE_BYTES;
21
+ const LINE_BUDGET_BYTES = VERDICT_LINE_LIMIT_BYTES - NEWLINE_BYTES;
22
22
  const MIN_CUT_BYTES = Buffer.byteLength(TRUNCATION_MARK);
23
23
  const REPLAY_MARKER = 'replayed:';
24
24
  function describeOutcome(verdict, style) {
@@ -40,13 +40,24 @@ function describeOutcome(verdict, style) {
40
40
  };
41
41
  }
42
42
  case 'no-op':
43
- return {
44
- marker: NMR_GLYPHS[style].noop.text,
45
- phrase: `skipped, the override is ${verdict.reason === 'empty-override' ? 'empty' : 'a no-op'}`,
46
- };
43
+ return { marker: NMR_GLYPHS[style].noop.text, phrase: `skipped, ${describeNoOpReason(verdict.reason)}` };
44
+ default: {
45
+ const unhandledVerdict = verdict;
46
+ throw new Error(`Unhandled verdict outcome: ${JSON.stringify(unhandledVerdict)}`);
47
+ }
48
+ }
49
+ }
50
+ function describeNoOpReason(reason) {
51
+ switch (reason) {
52
+ case 'empty-override':
53
+ return 'the override is empty';
54
+ case 'empty-workspace':
55
+ return 'the workspace does not declare any package';
56
+ case 'noop-override':
57
+ return 'the override is a no-op';
47
58
  default: {
48
- const unhandled = verdict;
49
- throw new Error(`Unhandled verdict outcome: ${JSON.stringify(unhandled)}`);
59
+ const unhandledReason = reason;
60
+ throw new Error(`Unhandled no-op reason: ${String(unhandledReason)}`);
50
61
  }
51
62
  }
52
63
  }
@@ -64,14 +75,16 @@ function renderReplay(verdict) {
64
75
  function flattenDetail(detail) {
65
76
  return detail.replaceAll(/[\r\n]+/gu, ' ').trim();
66
77
  }
67
- function findCutTarget(sizes, longest, overrunBytes) {
68
- const below = sizes.filter((size) => size < longest && size > MIN_CUT_BYTES);
69
- const share = Math.ceil(overrunBytes / sizes.filter((size) => size === longest).length);
70
- const target = below.length === 0 ? longest - share : Math.max(...below, longest - overrunBytes);
78
+ function findCutTarget(sizesBytes, longestSizeBytes, overrunBytes) {
79
+ const smallerSizesBytes = sizesBytes.filter((size) => size < longestSizeBytes && size > MIN_CUT_BYTES);
80
+ const share = Math.ceil(overrunBytes / sizesBytes.filter((size) => size === longestSizeBytes).length);
81
+ const target = smallerSizesBytes.length === 0
82
+ ? longestSizeBytes - share
83
+ : Math.max(...smallerSizesBytes, longestSizeBytes - overrunBytes);
71
84
  return Math.max(MIN_CUT_BYTES, target);
72
85
  }
73
- function isWithinBudget(rendered) {
74
- return Buffer.byteLength(rendered) <= LINE_BUDGET_BYTES;
86
+ function isWithinBudget(renderedLine) {
87
+ return Buffer.byteLength(renderedLine) <= LINE_BUDGET_BYTES;
75
88
  }
76
89
  function readAttribution(verdict) {
77
90
  if (verdict.outcome !== 'recalled' || verdict.replay === undefined) {
@@ -84,64 +97,64 @@ function readCuttableText(verdict) {
84
97
  return [verdict.detail ?? '', ...excerpts];
85
98
  }
86
99
  function renderClamped(record) {
87
- const clamped = { ...record };
88
- let rendered = JSON.stringify(clamped);
89
- while (!isWithinBudget(rendered)) {
100
+ const clampedRecord = { ...record };
101
+ let renderedLine = JSON.stringify(clampedRecord);
102
+ while (!isWithinBudget(renderedLine)) {
90
103
  const fields = ['command', 'scope'];
91
- const sizes = fields.map((field) => Buffer.byteLength(typeof clamped[field] === 'string' ? clamped[field] : ''));
92
- const longest = Math.max(...sizes);
93
- if (longest <= MIN_CUT_BYTES) {
94
- return rendered;
104
+ const sizesBytes = fields.map((field) => Buffer.byteLength(typeof clampedRecord[field] === 'string' ? clampedRecord[field] : ''));
105
+ const longestSizeBytes = Math.max(...sizesBytes);
106
+ if (longestSizeBytes <= MIN_CUT_BYTES) {
107
+ return renderedLine;
95
108
  }
96
- const target = findCutTarget(sizes, longest, Buffer.byteLength(rendered) - LINE_BUDGET_BYTES);
97
- const field = fields[sizes.indexOf(longest)] ?? 'command';
98
- clamped[field] = clampToBytes(typeof clamped[field] === 'string' ? clamped[field] : '', target);
99
- rendered = JSON.stringify(clamped);
109
+ const target = findCutTarget(sizesBytes, longestSizeBytes, Buffer.byteLength(renderedLine) - LINE_BUDGET_BYTES);
110
+ const field = fields[sizesBytes.indexOf(longestSizeBytes)] ?? 'command';
111
+ clampedRecord[field] = clampToBytes(typeof clampedRecord[field] === 'string' ? clampedRecord[field] : '', target);
112
+ renderedLine = JSON.stringify(clampedRecord);
100
113
  }
101
- return rendered;
114
+ return renderedLine;
102
115
  }
103
116
  function renderWithinBudget(verdict) {
104
- const shortened = shortenCuttableText(verdict);
105
- const shortenedLine = JSON.stringify(shortened);
117
+ const shortenedVerdict = shortenCuttableText(verdict);
118
+ const shortenedLine = JSON.stringify(shortenedVerdict);
106
119
  if (isWithinBudget(shortenedLine)) {
107
120
  return shortenedLine;
108
121
  }
109
- const record = { ...shortened };
122
+ const record = { ...shortenedVerdict };
110
123
  delete record['detail'];
111
- const attribution = readAttribution(shortened);
112
- for (let kept = attribution.length; kept > 0; kept--) {
113
- record['replay'] = attribution.slice(0, kept);
114
- const rendered = JSON.stringify(record);
115
- if (isWithinBudget(rendered)) {
116
- return rendered;
124
+ const attribution = readAttribution(shortenedVerdict);
125
+ for (let keptCount = attribution.length; keptCount > 0; keptCount--) {
126
+ record['replay'] = attribution.slice(0, keptCount);
127
+ const renderedLine = JSON.stringify(record);
128
+ if (isWithinBudget(renderedLine)) {
129
+ return renderedLine;
117
130
  }
118
131
  }
119
132
  delete record['replay'];
120
- const bare = JSON.stringify(record);
121
- return isWithinBudget(bare) ? bare : renderClamped(record);
133
+ const bareLine = JSON.stringify(record);
134
+ return isWithinBudget(bareLine) ? bareLine : renderClamped(record);
122
135
  }
123
136
  function shortenCuttableText(verdict) {
124
137
  let candidate = verdict;
125
- let rendered = JSON.stringify(candidate);
126
- while (!isWithinBudget(rendered)) {
127
- const shortened = shortenLongestText(candidate, Buffer.byteLength(rendered) - LINE_BUDGET_BYTES);
128
- if (shortened === undefined) {
138
+ let renderedLine = JSON.stringify(candidate);
139
+ while (!isWithinBudget(renderedLine)) {
140
+ const shortenedVerdict = shortenLongestText(candidate, Buffer.byteLength(renderedLine) - LINE_BUDGET_BYTES);
141
+ if (shortenedVerdict === undefined) {
129
142
  return candidate;
130
143
  }
131
- candidate = shortened;
132
- rendered = JSON.stringify(candidate);
144
+ candidate = shortenedVerdict;
145
+ renderedLine = JSON.stringify(candidate);
133
146
  }
134
147
  return candidate;
135
148
  }
136
149
  function shortenLongestText(verdict, overrunBytes) {
137
150
  const texts = readCuttableText(verdict);
138
- const sizes = texts.map((text) => Buffer.byteLength(text));
139
- const longest = Math.max(...sizes);
140
- if (longest <= MIN_CUT_BYTES) {
151
+ const sizesBytes = texts.map((text) => Buffer.byteLength(text));
152
+ const longestSizeBytes = Math.max(...sizesBytes);
153
+ if (longestSizeBytes <= MIN_CUT_BYTES) {
141
154
  return undefined;
142
155
  }
143
- const index = sizes.indexOf(longest);
144
- const target = findCutTarget(sizes, longest, overrunBytes);
156
+ const index = sizesBytes.indexOf(longestSizeBytes);
157
+ const target = findCutTarget(sizesBytes, longestSizeBytes, overrunBytes);
145
158
  return writeCuttableText(verdict, index, clampToBytes(texts[index] ?? '', target));
146
159
  }
147
160
  function writeCuttableText(verdict, index, value) {