v11y-check 0.17.0 โ†’ 0.18.1

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,4 +1,26 @@
1
1
  [
2
+ {
3
+ "version": "0.18.0",
4
+ "date": "2026-09-18",
5
+ "sections": [
6
+ {
7
+ "title": "๐ŸŽ‰ Features",
8
+ "audience": "all",
9
+ "items": [
10
+ {
11
+ "description": "Adopt output styles, shared glyphs, and column-aware wrapping (#836)",
12
+ "body": "- Makes `v11y-check` print `check` and `init` output without emoji, and with a word such as `PASS` or `STALE` in place of each row marker, to whichever of stdout and stderr is not a terminal, or to both when `CI` is set.\n- Adds the `V11Y_CHECK_OUTPUT_STYLE` environment variable, which accepts `auto`, `plain`, or `rich`, overrides that detection, and makes any other value a usage error that exits with code 1.\n- Changes the emoji row markers of rich output: โŒ replaces ๐Ÿšจ for an unallowed row, โœ… replaces โš ๏ธ for an allowed row, โฉ replaces โ„น๏ธ for a below-threshold row, ๐Ÿงน replaces ๐Ÿ—‘๏ธ for a stale row, and the `allowed` and `ignored` suffixes of a compact row lose their emoji.",
13
+ "hash": "757a884be126469f0a9ac1ff85c599f2f4895ec7"
14
+ },
15
+ {
16
+ "description": "Give nmr plain output in pipes and CI logs (#838)",
17
+ "body": "- Stops `nmr` from printing emoji to a pipe or a CI log: When the output stream is not a terminal or `CI` is set, a verdict reads `PASS`, `FAIL`, `SKIP`, or `NOOP` and a command icon is omitted, so that a log can be searched for a verdict.\n- Adds `--output-style <auto|plain|rich>` to `nmr` and the `NMR_OUTPUT_STYLE` variable, each forcing either style; the flag outranks the variable, a value that names no style exits 1 with a usage error, and neither enters a check-cache key: A rich run and a plain run share a recorded pass.\n- Replaces several rich glyphs, taking the status markers from the set that release-kit and v11y-check print so that one status has one glyph across the three tools: a recalled pass shows โฉ in place of โญ๏ธ, a skipped empty override shows โšช in place of โ›”, a warning and the `--log` refusal show ๐ŸŸ  in place of โš ๏ธ and ๐Ÿ“ญ, the `--log` header shows ๐Ÿ’พ in place of ๐Ÿ“ผ, and `nmr-ensure-prepublish-hooks` reports โœ… and โŒ in place of โœ“ and โœ—.",
18
+ "hash": "693b3a7f57def3d299ced240c19bda90f6253bdc"
19
+ }
20
+ ]
21
+ }
22
+ ]
23
+ },
2
24
  {
3
25
  "version": "0.17.0",
4
26
  "date": "2026-09-15",
@@ -1,6 +1,6 @@
1
1
  /** @noformat -- @generated. Do not edit. Compiled by rdy. */
2
2
  /* eslint-disable */
3
- export const __readyupVersion = "0.36.0";
3
+ export const __readyupVersion = "0.38.0";
4
4
 
5
5
 
6
6
  // .readyup/kits/default.ts
@@ -63,7 +63,7 @@ var default_default = defineRdyKit({
63
63
  ]
64
64
  });
65
65
  function getMinVersion() {
66
- const picked = { "version": "0.17.0" };
66
+ const picked = { "version": "0.18.1" };
67
67
  if (typeof picked["version"] !== "string") {
68
68
  throw new TypeError("v11y-check/package.json: 'version' must be a string");
69
69
  }
@@ -13,7 +13,7 @@
13
13
  "path": "kits/default.ts"
14
14
  },
15
15
  {
16
- "hash": "7316a587",
16
+ "hash": "609eb92c",
17
17
  "kind": "inline",
18
18
  "path": "../package.json",
19
19
  "paths": [
@@ -23,10 +23,10 @@
23
23
  ],
24
24
  "name": "default",
25
25
  "path": "kits/default.js",
26
- "readyupVersion": "0.36.0",
26
+ "readyupVersion": "0.38.0",
27
27
  "source": "kits/default.ts",
28
28
  "sourceHash": "5600fe16",
29
- "targetHash": "37393c2b"
29
+ "targetHash": "b2273259"
30
30
  }
31
31
  ]
32
32
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.18.0 โ€” 2026-09-18
6
+
7
+ ### ๐ŸŽ‰ Features
8
+
9
+ - Adopt output styles, shared glyphs, and column-aware wrapping (#836)
10
+
11
+ - Makes `v11y-check` print `check` and `init` output without emoji, and with a word such as `PASS` or `STALE` in place of each row marker, to whichever of stdout and stderr is not a terminal, or to both when `CI` is set.
12
+ - Adds the `V11Y_CHECK_OUTPUT_STYLE` environment variable, which accepts `auto`, `plain`, or `rich`, overrides that detection, and makes any other value a usage error that exits with code 1.
13
+ - Changes the emoji row markers of rich output: โŒ replaces ๐Ÿšจ for an unallowed row, โœ… replaces โš ๏ธ for an allowed row, โฉ replaces โ„น๏ธ for a below-threshold row, ๐Ÿงน replaces ๐Ÿ—‘๏ธ for a stale row, and the `allowed` and `ignored` suffixes of a compact row lose their emoji.
14
+
15
+ - Give nmr plain output in pipes and CI logs (#838)
16
+
17
+ - Stops `nmr` from printing emoji to a pipe or a CI log: When the output stream is not a terminal or `CI` is set, a verdict reads `PASS`, `FAIL`, `SKIP`, or `NOOP` and a command icon is omitted, so that a log can be searched for a verdict.
18
+ - Adds `--output-style <auto|plain|rich>` to `nmr` and the `NMR_OUTPUT_STYLE` variable, each forcing either style; the flag outranks the variable, a value that names no style exits 1 with a usage error, and neither enters a check-cache key: A rich run and a plain run share a recorded pass.
19
+ - Replaces several rich glyphs, taking the status markers from the set that release-kit and v11y-check print so that one status has one glyph across the three tools: a recalled pass shows โฉ in place of โญ๏ธ, a skipped empty override shows โšช in place of โ›”, a warning and the `--log` refusal show ๐ŸŸ  in place of โš ๏ธ and ๐Ÿ“ญ, the `--log` header shows ๐Ÿ’พ in place of ๐Ÿ“ผ, and `nmr-ensure-prepublish-hooks` reports โœ… and โŒ in place of โœ“ and โœ—.
20
+
5
21
  ## 0.17.0 โ€” 2026-09-15
6
22
 
7
23
  ### ๐ŸŽ‰ Features
package/README.md CHANGED
@@ -1,19 +1,10 @@
1
+ <!-- readme-type: cli -->
2
+
1
3
  # v11y-check
2
4
 
3
5
  Wraps [audit-ci](https://github.com/IBM/audit-ci) with a richer config model, typed JSON source of truth, and a sync workflow that automates allowlist management.
4
6
 
5
- <!-- section:release-notes -->
6
- ## Release notes โ€” v0.17.0 (2026-09-15)
7
-
8
- ### ๐ŸŽ‰ Features
9
-
10
- - Fix scaffold error handling and cover every scaffolded workflow (#806)
11
-
12
- - Adds a `create-github-release.yaml matches template` check to release-kit's ReadyUp kit, which warns when `.github/workflows/create-github-release.yaml` differs from the template for the detected repo type and skips in a repo that has not installed that workflow.
13
- - Stops `v11y init --force` from replacing an existing `.config/v11y-check.config.json` with the defaults, which discarded the repo's thresholds and the allowlist maintained by `v11y sync`.
14
-
15
- Migration: To reset `.config/v11y-check.config.json` to its defaults, delete the file and run `v11y init`, because `v11y init --force` now overwrites only `.github/workflows/audit.yaml`.
16
- <!-- /section:release-notes -->
7
+ <!-- section:release-notes --><!-- /section:release-notes -->
17
8
 
18
9
  ## Installation
19
10
 
@@ -76,6 +67,12 @@ The source-of-truth config lives at `.config/v11y-check.config.json`:
76
67
  - **`severityThreshold`** (optional) โ€” Fail on advisories at or above this severity. Valid values: `'low'`, `'moderate'`, `'high'`, `'critical'`. When omitted, audit-ci uses its own defaults.
77
68
  - **`allowlist`** โ€” Typed advisory entries with `id`, `path`, `url`, and optional `reason` and `addedAt`. `addedAt` is an ISO 8601 UTC datetime (e.g., `2026-04-15T09:30:00.000Z`) populated automatically by `v11y sync` on new entries; existing entries retain whatever value they had. Older `YYYY-MM-DD` values are still accepted.
78
69
 
70
+ ### Output style
71
+
72
+ The `V11Y_CHECK_OUTPUT_STYLE` environment variable sets the style for every command, `init` included: `rich` marks each finding with an emoji, such as โŒ or โœ…, and `plain` prints a word in its place, such as `FAIL` or `PASS`, which a reader of a log can search for. `auto`, the default, defers to detection. Any other value is a usage error that exits with code 1.
73
+
74
+ Detection decides stdout and stderr separately. A stream is plain when `CI` is set to anything other than an empty string or `false`, when the stream is not a terminal, or when `TERM` is `linux`; otherwise it is rich. `--json` output contains no emoji in either style.
75
+
79
76
  ## CLI reference
80
77
 
81
78
  ```
@@ -98,6 +95,11 @@ Other options:
98
95
  --verbose, -v Show detailed per-vulnerability output
99
96
  --help, -h Show this help message
100
97
  --version, -V Show version number
98
+
99
+ Environment:
100
+ V11Y_CHECK_OUTPUT_STYLE
101
+ Output style: auto (default), plain, or rich. auto prints plain,
102
+ without emoji, when CI is set or the stream is not a terminal.
101
103
  ```
102
104
 
103
105
  ### Init options
@@ -1,7 +1,9 @@
1
+ import process from 'node:process';
1
2
  import { parseArgs, readPackageVersion, reportError } from '@williamthorsen/nmr-core';
2
3
  import { describeError } from '@williamthorsen/toolbelt.errors';
3
4
  import { auditCommand, checkCommand, syncCommand } from "../cli.js";
4
5
  import { initCommand } from "../init/initCommand.js";
6
+ import { OUTPUT_STYLE_ENV_VAR, resolveStyles } from "../resolveStyles.js";
5
7
  const VERSION = readPackageVersion(import.meta.url);
6
8
  const SUBCOMMANDS = ['check', 'init', 'sync'];
7
9
  const MIN_PREFIX_LENGTH = 3;
@@ -26,6 +28,11 @@ Other options:
26
28
  --verbose, -v Show detailed per-vulnerability output
27
29
  --help, -h Show this help message
28
30
  --version, -V Show version number
31
+
32
+ Environment:
33
+ ${OUTPUT_STYLE_ENV_VAR}
34
+ Output style: auto (default), plain, or rich. auto prints plain,
35
+ without emoji, when CI is set or the stream is not a terminal.
29
36
  `);
30
37
  }
31
38
  function showInitHelp() {
@@ -56,7 +63,7 @@ Other options:
56
63
  --help, -h Show this help message
57
64
  `);
58
65
  }
59
- function parseSharedFlags(flags) {
66
+ function parseSharedFlags(flags, styles) {
60
67
  const flagSchema = {
61
68
  config: { long: '--config', type: 'string' },
62
69
  dev: { long: '--dev', type: 'boolean' },
@@ -77,6 +84,7 @@ function parseSharedFlags(flags) {
77
84
  configPath: parsed.config,
78
85
  json: parsed.json,
79
86
  scopes,
87
+ styles,
80
88
  verbose: parsed.verbose,
81
89
  };
82
90
  }
@@ -101,14 +109,22 @@ export async function routeCommand(args) {
101
109
  console.info(VERSION);
102
110
  return 0;
103
111
  }
112
+ const styles = resolveStyles({
113
+ env: process.env,
114
+ stderrIsTty: process.stderr.isTTY,
115
+ stdoutIsTty: process.stdout.isTTY,
116
+ });
117
+ if (styles === undefined) {
118
+ return 1;
119
+ }
104
120
  if (command === 'check') {
105
- return handleSubcommand(args.slice(1), checkCommand);
121
+ return handleSubcommand(args.slice(1), styles, checkCommand);
106
122
  }
107
123
  if (command === 'init') {
108
- return handleInit(args.slice(1));
124
+ return handleInit(args.slice(1), styles);
109
125
  }
110
126
  if (command === 'sync') {
111
- return handleSubcommand(args.slice(1), syncCommand, showSyncHelp);
127
+ return handleSubcommand(args.slice(1), styles, syncCommand, showSyncHelp);
112
128
  }
113
129
  if (command !== undefined && !command.startsWith('-')) {
114
130
  const typoMatch = findTypoMatch(command);
@@ -121,18 +137,18 @@ export async function routeCommand(args) {
121
137
  }
122
138
  if (args.includes('--raw')) {
123
139
  const filteredArgs = args.filter((a) => a !== '--raw');
124
- return handleSubcommand(filteredArgs, auditCommand);
140
+ return handleSubcommand(filteredArgs, styles, auditCommand);
125
141
  }
126
- return handleSubcommand(args, checkCommand);
142
+ return handleSubcommand(args, styles, checkCommand);
127
143
  }
128
- async function handleSubcommand(flags, handler, helpFn = showHelp) {
144
+ async function handleSubcommand(flags, styles, handler, helpFn = showHelp) {
129
145
  if (flags.some((f) => f === '--help' || f === '-h')) {
130
146
  helpFn();
131
147
  return 0;
132
148
  }
133
149
  let options;
134
150
  try {
135
- options = parseSharedFlags(flags);
151
+ options = parseSharedFlags(flags, styles);
136
152
  }
137
153
  catch (error) {
138
154
  reportError(describeError(error));
@@ -146,7 +162,7 @@ async function handleSubcommand(flags, handler, helpFn = showHelp) {
146
162
  return 1;
147
163
  }
148
164
  }
149
- function handleInit(flags) {
165
+ function handleInit(flags, styles) {
150
166
  if (flags.some((f) => f === '--help' || f === '-h')) {
151
167
  showInitHelp();
152
168
  return 0;
@@ -163,5 +179,5 @@ function handleInit(flags) {
163
179
  reportError(describeError(error));
164
180
  return 1;
165
181
  }
166
- return initCommand({ dryRun: parsed.flags.dryRun, force: parsed.flags.force });
182
+ return initCommand({ dryRun: parsed.flags.dryRun, force: parsed.flags.force, styles });
167
183
  }
package/dist/esm/cli.js CHANGED
@@ -129,13 +129,13 @@ export async function checkCommand(options) {
129
129
  checkResult[scope] = scopeResult;
130
130
  }
131
131
  if (options.verbose && !options.json) {
132
- process.stdout.write(formatCheckVerboseText(checkResult, scopes, undefined, thresholds));
132
+ process.stdout.write(formatCheckVerboseText(checkResult, scopes, options.styles.stdout, undefined, thresholds));
133
133
  }
134
134
  else if (options.json) {
135
135
  process.stdout.write(formatCheckJson(checkResult, scopes));
136
136
  }
137
137
  else {
138
- process.stdout.write(formatCheckText(checkResult, scopes, undefined, thresholds));
138
+ process.stdout.write(formatCheckText(checkResult, scopes, options.styles.stdout, undefined, thresholds));
139
139
  }
140
140
  const hasUnallowed = scopes.some((s) => checkResult[s].unallowed.length > 0);
141
141
  return hasUnallowed ? 1 : 0;
@@ -1,3 +1,4 @@
1
+ import { type OutputStyle } from '@williamthorsen/nmr-core';
1
2
  import type { AuditResult, AuditScope, SeverityThreshold } from './types.js';
2
3
  /** Classification of an allowlist entry relative to current audit findings. */
3
4
  export interface AllowedVuln {
@@ -32,21 +33,21 @@ export interface CheckResult {
32
33
  dev: ScopeCheckResult;
33
34
  prod: ScopeCheckResult;
34
35
  }
35
- /** Map a severity string to a colored circle emoji. */
36
- export declare function severityIndicator(severity: string | undefined): string;
36
+ /** Maps a severity string to its indicator in a style: a colored circle when rich, nothing when plain. */
37
+ export declare function severityIndicator(severity: string | undefined, style: OutputStyle): string;
37
38
  /** Resolve the best display ID for a vulnerability: GHSA ID if available, otherwise the numeric ID. */
38
39
  export declare function displayId(vuln: {
39
40
  ghsaId?: string | undefined;
40
41
  id: string;
41
42
  }): string;
42
43
  /** Build the severity suffix for a finding line, e.g. ` ๐Ÿ”ด critical`. */
43
- export declare function formatSeveritySuffix(severity: string | undefined): string;
44
+ export declare function formatSeveritySuffix(severity: string | undefined, style: OutputStyle): string;
44
45
  /**
45
46
  * Format check results as human-readable text output.
46
47
  *
47
48
  * Produces an intro banner, scoped findings with severity labels and GHSA IDs,
48
49
  * and an action hints footer.
49
50
  */
50
- export declare function formatCheckText(result: CheckResult, scopes: AuditScope[], now?: Date, thresholds?: Partial<Record<AuditScope, SeverityThreshold>>): string;
51
+ export declare function formatCheckText(result: CheckResult, scopes: AuditScope[], style: OutputStyle, now?: Date, thresholds?: Partial<Record<AuditScope, SeverityThreshold>>): string;
51
52
  /** Format check results as a JSON string with a top-level `summary` block derived from the result. */
52
53
  export declare function formatCheckJson(result: CheckResult, scopes: AuditScope[]): string;
@@ -1,69 +1,76 @@
1
+ import { formatGlyphLine } from '@williamthorsen/nmr-core';
1
2
  import { formatActionHints } from "./format-actions.js";
2
3
  import { deriveSummary } from "./format-summary.js";
3
4
  import { formatRelativeTime } from "./format-time.js";
4
- const SEVERITY_INDICATORS = {
5
- critical: '\u{1F534}',
6
- high: '\u{1F534}',
7
- info: '\u{1F7E1}',
8
- low: '\u{1F7E1}',
9
- moderate: '\u{1F7E0}',
5
+ import { formatMarkedLine, V11Y_GLYPHS } from "./glyphs.js";
6
+ const SEVERITY_GLYPH_NAMES = {
7
+ critical: 'severityHigh',
8
+ high: 'severityHigh',
9
+ info: 'severityLow',
10
+ low: 'severityLow',
11
+ moderate: 'severityModerate',
10
12
  };
11
- export function severityIndicator(severity) {
13
+ export function severityIndicator(severity, style) {
12
14
  if (severity === undefined)
13
15
  return '';
14
- return SEVERITY_INDICATORS[severity] ?? '';
16
+ const name = SEVERITY_GLYPH_NAMES[severity];
17
+ return name === undefined ? '' : V11Y_GLYPHS[style][name].text;
15
18
  }
16
19
  export function displayId(vuln) {
17
20
  return vuln.ghsaId ?? vuln.id;
18
21
  }
19
- const SCOPE_LABELS = {
20
- dev: ' \u{1F527} dev:',
21
- prod: ' \u{1F4E6} prod:',
22
+ const SCOPE_GLYPH_NAMES = {
23
+ dev: 'scopeDev',
24
+ prod: 'scopeProd',
22
25
  };
23
26
  const SCOPE_NAMES = {
24
27
  dev: 'dev',
25
28
  prod: 'prod',
26
29
  };
27
- function formatThresholdAnnotation(threshold) {
30
+ function formatThresholdAnnotation(threshold, style) {
28
31
  if (threshold === undefined || threshold === 'low')
29
32
  return '';
30
- const indicator = severityIndicator(threshold);
33
+ const indicator = severityIndicator(threshold, style);
31
34
  const indicatorPart = indicator.length > 0 ? `${indicator} ` : '';
32
35
  return `(threshold: ${indicatorPart}${threshold})`;
33
36
  }
34
- function formatIntroBanner(scopes, thresholds) {
37
+ function formatIntroBanner(scopes, style, thresholds) {
35
38
  const first = scopes[0];
36
39
  if (scopes.length === 1 && first !== undefined) {
37
- const annotation = formatThresholdAnnotation(thresholds?.[first]);
38
- return `\u{1F52C} Auditing ${SCOPE_NAMES[first]} dependencies${annotation && ` ${annotation}`} ...`;
40
+ const annotation = formatThresholdAnnotation(thresholds?.[first], style);
41
+ const message = `Auditing ${SCOPE_NAMES[first]} dependencies${annotation && ` ${annotation}`} ...`;
42
+ return formatGlyphLine(V11Y_GLYPHS, style, 'audit', message);
39
43
  }
40
- return '\u{1F52C} Auditing dependencies ...';
44
+ return formatGlyphLine(V11Y_GLYPHS, style, 'audit', 'Auditing dependencies ...');
41
45
  }
42
- export function formatSeveritySuffix(severity) {
46
+ export function formatSeveritySuffix(severity, style) {
43
47
  if (severity === undefined || severity === '')
44
48
  return '';
45
- const emoji = severityIndicator(severity);
49
+ const emoji = severityIndicator(severity, style);
46
50
  const emojiPart = emoji.length > 0 ? `${emoji} ` : '';
47
51
  return ` ${emojiPart}${severity}`;
48
52
  }
49
53
  function formatAllowedSuffix(addedAt, now) {
50
54
  const relative = formatRelativeTime(addedAt, now);
51
55
  if (relative.length === 0)
52
- return ` \u{2022} \u{2705} allowed (${addedAt})`;
53
- return ` \u{2022} \u{2705} allowed since ${relative} (${addedAt})`;
56
+ return ` \u{2022} allowed (${addedAt})`;
57
+ return ` \u{2022} allowed since ${relative} (${addedAt})`;
54
58
  }
55
- function formatUnallowedLine(vuln) {
56
- return ` \u{2022} \u{1F6A8} ${displayId(vuln)}: ${vuln.path}${formatSeveritySuffix(vuln.severity)}`;
59
+ function formatUnallowedLine(vuln, style) {
60
+ const message = `${displayId(vuln)}: ${vuln.path}${formatSeveritySuffix(vuln.severity, style)}`;
61
+ return ` \u{2022} ${formatMarkedLine(style, 'failed', message)}`;
57
62
  }
58
- function formatAllowedLine(vuln, now) {
63
+ function formatAllowedLine(vuln, now, style) {
59
64
  const suffix = vuln.addedAt !== undefined ? formatAllowedSuffix(vuln.addedAt, now) : '';
60
- return ` \u{2022} \u{26A0}\u{FE0F} ${displayId(vuln)}: ${vuln.path}${formatSeveritySuffix(vuln.severity)}${suffix}`;
65
+ const message = `${displayId(vuln)}: ${vuln.path}${formatSeveritySuffix(vuln.severity, style)}${suffix}`;
66
+ return ` \u{2022} ${formatMarkedLine(style, 'passed', message)}`;
61
67
  }
62
- function formatStaleLine(entry) {
63
- return ` \u{2022} \u{1F5D1}\u{FE0F} ${entry.id} \u{2022} not needed`;
68
+ function formatStaleLine(entry, style) {
69
+ return ` \u{2022} ${formatMarkedLine(style, 'stale', `${entry.id} \u{2022} not needed`)}`;
64
70
  }
65
- function formatBelowThresholdLine(vuln) {
66
- return ` \u{2022} \u{2139}\u{FE0F} ${displayId(vuln)}: ${vuln.path}${formatSeveritySuffix(vuln.severity)} \u{2022} \u{1F6AB} ignored`;
71
+ function formatBelowThresholdLine(vuln, style) {
72
+ const message = `${displayId(vuln)}: ${vuln.path}${formatSeveritySuffix(vuln.severity, style)} \u{2022} ignored`;
73
+ return ` \u{2022} ${formatMarkedLine(style, 'skipped', message)}`;
67
74
  }
68
75
  function hasFindings(result) {
69
76
  return (result.unallowed.length > 0 ||
@@ -71,22 +78,22 @@ function hasFindings(result) {
71
78
  result.stale.length > 0 ||
72
79
  result.belowThreshold.length > 0);
73
80
  }
74
- function formatScopeFindings(result, now) {
75
- const lines = Array.from(result.unallowed, (vuln) => formatUnallowedLine(vuln));
81
+ function formatScopeFindings(result, now, style) {
82
+ const lines = Array.from(result.unallowed, (vuln) => formatUnallowedLine(vuln, style));
76
83
  for (const vuln of result.allowed) {
77
- lines.push(formatAllowedLine(vuln, now));
84
+ lines.push(formatAllowedLine(vuln, now, style));
78
85
  }
79
86
  for (const entry of result.stale) {
80
- lines.push(formatStaleLine(entry));
87
+ lines.push(formatStaleLine(entry, style));
81
88
  }
82
89
  for (const vuln of result.belowThreshold) {
83
- lines.push(formatBelowThresholdLine(vuln));
90
+ lines.push(formatBelowThresholdLine(vuln, style));
84
91
  }
85
92
  return lines;
86
93
  }
87
- export function formatCheckText(result, scopes, now, thresholds) {
94
+ export function formatCheckText(result, scopes, style, now, thresholds) {
88
95
  const effectiveNow = now ?? new Date();
89
- const lines = [formatIntroBanner(scopes, thresholds)];
96
+ const lines = [formatIntroBanner(scopes, style, thresholds)];
90
97
  const anyFindings = scopes.some((scope) => hasFindings(result[scope]));
91
98
  if (!anyFindings) {
92
99
  lines.push('No known vulnerabilities found.');
@@ -96,7 +103,7 @@ export function formatCheckText(result, scopes, now, thresholds) {
96
103
  if (singleScope !== undefined) {
97
104
  const scope = singleScope;
98
105
  const scopeResult = result[scope];
99
- lines.push(...formatScopeFindings(scopeResult, effectiveNow));
106
+ lines.push(...formatScopeFindings(scopeResult, effectiveNow, style));
100
107
  const actions = formatActionHints(result, scopes);
101
108
  if (actions.length > 0) {
102
109
  lines.push('', ...actions.split('\n').filter((l) => l.length > 0));
@@ -105,11 +112,11 @@ export function formatCheckText(result, scopes, now, thresholds) {
105
112
  }
106
113
  for (const scope of scopes) {
107
114
  const scopeResult = result[scope];
108
- const annotation = formatThresholdAnnotation(thresholds?.[scope]);
109
- const header = `${SCOPE_LABELS[scope]}${annotation && ` ${annotation}`}`;
110
- lines.push(header);
115
+ const annotation = formatThresholdAnnotation(thresholds?.[scope], style);
116
+ const label = formatGlyphLine(V11Y_GLYPHS, style, SCOPE_GLYPH_NAMES[scope], `${SCOPE_NAMES[scope]}:`);
117
+ lines.push(` ${label}${annotation && ` ${annotation}`}`);
111
118
  if (hasFindings(scopeResult)) {
112
- lines.push(...formatScopeFindings(scopeResult, effectiveNow));
119
+ lines.push(...formatScopeFindings(scopeResult, effectiveNow, style));
113
120
  }
114
121
  else {
115
122
  lines.push(' No known vulnerabilities found.');
@@ -1,4 +1,5 @@
1
+ import { type OutputStyle } from '@williamthorsen/nmr-core';
1
2
  import { type CheckResult } from './format-check.js';
2
3
  import type { AuditScope, SeverityThreshold } from './types.js';
3
4
  /** Format the verbose per-vulnerability check output as text. */
4
- export declare function formatCheckVerboseText(result: CheckResult, scopes: AuditScope[], now?: Date, thresholds?: Partial<Record<AuditScope, SeverityThreshold>>): string;
5
+ export declare function formatCheckVerboseText(result: CheckResult, scopes: AuditScope[], style: OutputStyle, now?: Date, thresholds?: Partial<Record<AuditScope, SeverityThreshold>>): string;
@@ -1,35 +1,35 @@
1
+ import { formatGlyphLine, wrapToWidth } from '@williamthorsen/nmr-core';
1
2
  import { formatActionHints } from "./format-actions.js";
2
3
  import { displayId, formatSeveritySuffix, severityIndicator, } from "./format-check.js";
3
4
  import { formatRelativeTime } from "./format-time.js";
4
- const STATUS_UNALLOWED = '\u{1F6A8}';
5
- const STATUS_ALLOWED = '\u{26A0}\u{FE0F}';
6
- const STATUS_BELOW_THRESHOLD = '\u{2139}\u{FE0F}';
7
- const STATUS_STALE = '\u{1F5D1}\u{FE0F}';
8
- const SCOPE_HEADERS = {
9
- prod: '-- \u{1F4E6} prod --',
10
- dev: '-- \u{1F527} dev --',
5
+ import { formatMarkedLine, measureRowMarkerColumn, V11Y_GLYPHS } from "./glyphs.js";
6
+ const SCOPE_GLYPH_NAMES = {
7
+ dev: 'scopeDev',
8
+ prod: 'scopeProd',
11
9
  };
12
- const DETAIL_INDENT = ' '.repeat(5);
10
+ const MARKER_INDENT = ' ';
13
11
  const WRAP_COLUMNS = 72;
14
- export function formatCheckVerboseText(result, scopes, now, thresholds) {
12
+ export function formatCheckVerboseText(result, scopes, style, now, thresholds) {
15
13
  const effectiveNow = now ?? new Date();
16
- const sections = Array.from(scopes, (scope) => formatScopeVerbose(scope, result[scope], effectiveNow, thresholds?.[scope]));
14
+ const sections = Array.from(scopes, (scope) => formatScopeVerbose(scope, result[scope], effectiveNow, style, thresholds?.[scope]));
17
15
  const actions = formatActionHints(result, scopes);
18
16
  const body = sections.join('\n\n') + '\n';
19
17
  if (actions.length === 0)
20
18
  return body;
21
19
  return body + '\n' + actions + '\n';
22
20
  }
23
- function formatThresholdSuffix(threshold) {
21
+ function formatThresholdSuffix(threshold, style) {
24
22
  if (threshold === undefined || threshold === 'low')
25
23
  return '';
26
- const indicator = severityIndicator(threshold);
24
+ const indicator = severityIndicator(threshold, style);
27
25
  const indicatorPart = indicator.length > 0 ? `${indicator} ` : '';
28
26
  return ` (threshold: ${indicatorPart}${threshold})`;
29
27
  }
30
- function formatScopeVerbose(scope, result, now, threshold) {
31
- const thresholdSuffix = formatThresholdSuffix(threshold);
32
- const lines = [`${SCOPE_HEADERS[scope]}${thresholdSuffix}`];
28
+ function formatScopeVerbose(scope, result, now, style, threshold) {
29
+ const thresholdSuffix = formatThresholdSuffix(threshold, style);
30
+ const lines = [
31
+ `-- ${formatGlyphLine(V11Y_GLYPHS, style, SCOPE_GLYPH_NAMES[scope], scope)} --${thresholdSuffix}`,
32
+ ];
33
33
  const hasFindings = result.unallowed.length > 0 ||
34
34
  result.allowed.length > 0 ||
35
35
  result.stale.length > 0 ||
@@ -38,65 +38,72 @@ function formatScopeVerbose(scope, result, now, threshold) {
38
38
  lines.push(' (none)');
39
39
  return lines.join('\n');
40
40
  }
41
- const blocks = Array.from(result.unallowed, (vuln) => formatUnallowedBlock(vuln));
41
+ const blocks = Array.from(result.unallowed, (vuln) => formatUnallowedBlock(vuln, style));
42
42
  for (const vuln of result.allowed) {
43
- blocks.push(formatAllowedBlock(vuln, now));
43
+ blocks.push(formatAllowedBlock(vuln, now, style));
44
44
  }
45
45
  for (const entry of result.stale) {
46
- blocks.push(formatStaleLine(entry));
46
+ blocks.push(formatStaleLine(entry, style));
47
47
  }
48
48
  for (const vuln of result.belowThreshold) {
49
- blocks.push(formatBelowThresholdBlock(vuln));
49
+ blocks.push(formatBelowThresholdBlock(vuln, style));
50
50
  }
51
51
  return lines.concat(blocks.join('\n\n')).join('\n');
52
52
  }
53
- function formatUnallowedBlock(vuln) {
54
- const headerLine = ` ${STATUS_UNALLOWED} ${displayId(vuln)}${formatSeveritySuffix(vuln.severity)}`;
55
- const detail = formatAdvisoryDetail(vuln);
53
+ function formatUnallowedBlock(vuln, style) {
54
+ const message = `${displayId(vuln)}${formatSeveritySuffix(vuln.severity, style)}`;
55
+ const headerLine = `${MARKER_INDENT}${formatMarkedLine(style, 'failed', message)}`;
56
+ const detail = formatAdvisoryDetail(vuln, style);
56
57
  return [headerLine, ...detail].join('\n');
57
58
  }
58
- function formatAllowedBlock(vuln, now) {
59
+ function formatAllowedBlock(vuln, now, style) {
59
60
  const allowedSuffix = vuln.addedAt !== undefined ? formatAllowedSuffix(vuln.addedAt, now) : '';
60
- const headerLine = ` ${STATUS_ALLOWED} ${displayId(vuln)}${formatSeveritySuffix(vuln.severity)}${allowedSuffix}`;
61
+ const message = `${displayId(vuln)}${formatSeveritySuffix(vuln.severity, style)}${allowedSuffix}`;
62
+ const headerLine = `${MARKER_INDENT}${formatMarkedLine(style, 'passed', message)}`;
61
63
  const hasTitle = vuln.title !== undefined;
62
- const detail = formatAdvisoryDetail(vuln);
64
+ const detail = formatAdvisoryDetail(vuln, style);
63
65
  if (vuln.reason !== undefined) {
64
66
  const reasonLineIndex = findReasonInsertionIndex(hasTitle);
65
- detail.splice(reasonLineIndex, 0, `${DETAIL_INDENT}reason: ${vuln.reason}`);
67
+ detail.splice(reasonLineIndex, 0, `${buildDetailIndent(style)}reason: ${vuln.reason}`);
66
68
  }
67
69
  return [headerLine, ...detail].join('\n');
68
70
  }
69
- function formatStaleLine(entry) {
70
- return ` ${STATUS_STALE} ${entry.id} not needed`;
71
+ function formatStaleLine(entry, style) {
72
+ return `${MARKER_INDENT}${formatMarkedLine(style, 'stale', `${entry.id} not needed`)}`;
71
73
  }
72
- function formatBelowThresholdBlock(vuln) {
73
- const headerLine = ` ${STATUS_BELOW_THRESHOLD} ${displayId(vuln)}${formatSeveritySuffix(vuln.severity)} ignored (below threshold)`;
74
- const detail = formatAdvisoryDetail(vuln);
74
+ function formatBelowThresholdBlock(vuln, style) {
75
+ const message = `${displayId(vuln)}${formatSeveritySuffix(vuln.severity, style)} ignored (below threshold)`;
76
+ const headerLine = `${MARKER_INDENT}${formatMarkedLine(style, 'skipped', message)}`;
77
+ const detail = formatAdvisoryDetail(vuln, style);
75
78
  return [headerLine, ...detail].join('\n');
76
79
  }
77
- function formatAdvisoryDetail(vuln) {
80
+ function formatAdvisoryDetail(vuln, style) {
81
+ const detailIndent = buildDetailIndent(style);
78
82
  const lines = [];
79
83
  if (vuln.title !== undefined) {
80
- lines.push(`${DETAIL_INDENT}${vuln.title}`);
84
+ lines.push(`${detailIndent}${vuln.title}`);
81
85
  }
82
- lines.push(...formatPathsLines(vuln.paths), `${DETAIL_INDENT}link: ${vuln.url}`);
86
+ lines.push(...formatPathsLines(vuln.paths, detailIndent), `${detailIndent}link: ${vuln.url}`);
83
87
  if (vuln.description !== undefined) {
84
- lines.push('', ...formatDescriptionLines(vuln.description));
88
+ lines.push('', ...formatDescriptionLines(vuln.description, detailIndent));
85
89
  }
86
90
  return lines;
87
91
  }
92
+ function buildDetailIndent(style) {
93
+ return ' '.repeat(MARKER_INDENT.length + measureRowMarkerColumn(style) + 1);
94
+ }
88
95
  function findReasonInsertionIndex(hasTitle) {
89
96
  return hasTitle ? 1 : 0;
90
97
  }
91
- function formatPathsLines(paths) {
98
+ function formatPathsLines(paths, detailIndent) {
92
99
  if (paths.length === 0)
93
100
  return [];
94
101
  if (paths.length === 1) {
95
- return [`${DETAIL_INDENT}path: ${paths[0]}`];
102
+ return [`${detailIndent}path: ${paths[0]}`];
96
103
  }
97
- const lines = [`${DETAIL_INDENT}paths:`];
104
+ const lines = [`${detailIndent}paths:`];
98
105
  for (const pathValue of paths) {
99
- lines.push(`${DETAIL_INDENT} - ${pathValue}`);
106
+ lines.push(`${detailIndent} - ${pathValue}`);
100
107
  }
101
108
  return lines;
102
109
  }
@@ -106,41 +113,17 @@ function formatAllowedSuffix(addedAt, now) {
106
113
  return ` allowed (${addedAt})`;
107
114
  return ` allowed ${relative} (${addedAt})`;
108
115
  }
109
- function formatDescriptionLines(description) {
116
+ function formatDescriptionLines(description, detailIndent) {
110
117
  const paragraphs = description.split(/\n\s*\n/);
111
118
  const lines = [];
112
119
  let needsSeparator = false;
113
120
  for (const paragraph of paragraphs) {
114
121
  if (needsSeparator)
115
122
  lines.push('');
116
- const wrapped = wrapParagraph(paragraph.trim(), WRAP_COLUMNS);
117
- for (const wrappedLine of wrapped) {
118
- lines.push(`${DETAIL_INDENT}${wrappedLine}`);
119
- }
123
+ const wrapped = wrapToWidth(paragraph, { indent: detailIndent.length, width: WRAP_COLUMNS + detailIndent.length });
124
+ if (wrapped !== '')
125
+ lines.push(...wrapped.split('\n'));
120
126
  needsSeparator = true;
121
127
  }
122
128
  return lines;
123
129
  }
124
- function wrapParagraph(paragraph, columns) {
125
- if (paragraph.length === 0)
126
- return [];
127
- const words = paragraph.split(/\s+/);
128
- const lines = [];
129
- let current = '';
130
- for (const word of words) {
131
- if (current === '') {
132
- current = word;
133
- continue;
134
- }
135
- if (current.length + 1 + word.length > columns) {
136
- lines.push(current);
137
- current = word;
138
- }
139
- else {
140
- current += ` ${word}`;
141
- }
142
- }
143
- if (current !== '')
144
- lines.push(current);
145
- return lines;
146
- }
@@ -0,0 +1,13 @@
1
+ import { type OutputStyle, type StatusGlyphName } from '@williamthorsen/nmr-core';
2
+ /**
3
+ * Glyphs that name no status; a status marker comes from nmr-core's `STATUS_GLYPHS`. A plain variant is empty
4
+ * where a word always follows the glyph.
5
+ */
6
+ export declare const V11Y_GLYPHS: Readonly<Record<"plain" | "rich", Readonly<Record<"audit" | "scopeDev" | "scopeProd" | "severityHigh" | "severityLow" | "severityModerate" | "stale", import("@williamthorsen/toolbelt.terminal/candidate").Glyph>>>>;
7
+ export type V11yGlyphName = keyof (typeof V11Y_GLYPHS)['rich'];
8
+ /** A glyph that opens a finding's row: a status, or the stale marker. */
9
+ export type RowMarkerName = StatusGlyphName | 'stale';
10
+ /** Renders a row marker and a message, padding the marker so that messages align across markers. */
11
+ export declare function formatMarkedLine(style: OutputStyle, name: RowMarkerName, message: string): string;
12
+ /** Reports the cells taken by the widest row marker in a style. */
13
+ export declare function measureRowMarkerColumn(style: OutputStyle): number;
@@ -0,0 +1,18 @@
1
+ import { defineGlyphSet, measureGlyphColumn, STATUS_GLYPHS, } from '@williamthorsen/nmr-core';
2
+ export const V11Y_GLYPHS = defineGlyphSet({
3
+ audit: { plain: '', rich: '\u{1F52C}' },
4
+ scopeDev: { plain: '', rich: '\u{1F527}' },
5
+ scopeProd: { plain: '', rich: '\u{1F4E6}' },
6
+ severityHigh: { plain: '', rich: '\u{1F534}' },
7
+ severityLow: { plain: '', rich: '\u{1F7E1}' },
8
+ severityModerate: { plain: '', rich: '\u{1F7E0}' },
9
+ stale: { plain: 'STALE', rich: '\u{1F9F9}' },
10
+ });
11
+ export function formatMarkedLine(style, name, message) {
12
+ const glyph = name === 'stale' ? V11Y_GLYPHS[style].stale : STATUS_GLYPHS[style][name];
13
+ const padding = ' '.repeat(measureRowMarkerColumn(style) - glyph.width);
14
+ return `${glyph.text}${padding} ${message}`;
15
+ }
16
+ export function measureRowMarkerColumn(style) {
17
+ return Math.max(measureGlyphColumn(STATUS_GLYPHS[style]), V11Y_GLYPHS[style].stale.width);
18
+ }
@@ -1,6 +1,8 @@
1
+ import { type StreamStyles } from '@williamthorsen/nmr-core';
1
2
  interface InitOptions {
2
3
  dryRun: boolean;
3
4
  force: boolean;
5
+ styles: StreamStyles;
4
6
  }
5
7
  /**
6
8
  * Run the `v11y init` command.
@@ -8,5 +10,5 @@ interface InitOptions {
8
10
  * Scaffolds the starter config file and the GitHub Actions workflow, then prints next steps.
9
11
  * Returns the process exit code (0 for success, 1 if any write failed).
10
12
  */
11
- export declare function initCommand({ dryRun, force }: InitOptions): number;
13
+ export declare function initCommand({ dryRun, force, styles }: InitOptions): number;
12
14
  export {};
@@ -1,7 +1,7 @@
1
1
  import { printError, printStep, reportWriteResult } from '@williamthorsen/nmr-core';
2
2
  import { describeError } from '@williamthorsen/toolbelt.errors';
3
3
  import { scaffoldFiles } from "./scaffold.js";
4
- export function initCommand({ dryRun, force }) {
4
+ export function initCommand({ dryRun, force, styles }) {
5
5
  if (dryRun) {
6
6
  console.info('[dry-run mode]');
7
7
  }
@@ -11,11 +11,11 @@ export function initCommand({ dryRun, force }) {
11
11
  results = scaffoldFiles({ dryRun, force });
12
12
  }
13
13
  catch (error) {
14
- printError(`Failed to scaffold files: ${describeError(error)}`);
14
+ printError(`Failed to scaffold files: ${describeError(error)}`, styles.stderr);
15
15
  return 1;
16
16
  }
17
17
  for (const result of results) {
18
- reportWriteResult(result, dryRun);
18
+ reportWriteResult(result, dryRun, styles);
19
19
  }
20
20
  if (results.some((r) => r.outcome === 'failed')) {
21
21
  return 1;
@@ -0,0 +1,10 @@
1
+ import { type StreamStyles } from '@williamthorsen/nmr-core';
2
+ /** The environment variable holding the caller's standing style preference: `auto`, `plain`, or `rich`. */
3
+ export declare const OUTPUT_STYLE_ENV_VAR = "V11Y_CHECK_OUTPUT_STYLE";
4
+ export interface ResolveStylesOptions {
5
+ readonly env: Readonly<Record<string, string | undefined>>;
6
+ readonly stderrIsTty: boolean;
7
+ readonly stdoutIsTty: boolean;
8
+ }
9
+ /** Resolves the style of each output stream, reporting a usage error and returning `undefined` when the variable names no setting. */
10
+ export declare function resolveStyles(options: ResolveStylesOptions): StreamStyles | undefined;
@@ -0,0 +1,10 @@
1
+ import { describeInvalidOutputStyle, reportError, resolveStreamStyles, } from '@williamthorsen/nmr-core';
2
+ export const OUTPUT_STYLE_ENV_VAR = 'V11Y_CHECK_OUTPUT_STYLE';
3
+ export function resolveStyles(options) {
4
+ const { invalid, styles } = resolveStreamStyles({ ...options, envVar: OUTPUT_STYLE_ENV_VAR });
5
+ if (invalid !== undefined) {
6
+ reportError(describeInvalidOutputStyle(invalid));
7
+ return undefined;
8
+ }
9
+ return styles;
10
+ }
@@ -1,3 +1,4 @@
1
+ import type { StreamStyles } from '@williamthorsen/nmr-core';
1
2
  import { z } from 'zod';
2
3
  /** The dependency scope to audit: development or production. */
3
4
  export type AuditScope = 'dev' | 'prod';
@@ -107,6 +108,7 @@ export interface CommandOptions {
107
108
  configPath?: string | undefined;
108
109
  json: boolean;
109
110
  scopes: AuditScope[];
111
+ styles: StreamStyles;
110
112
  verbose: boolean;
111
113
  }
112
114
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "v11y-check",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
4
4
  "private": false,
5
5
  "description": "Wrap audit-ci with a richer config model, typed JSON source of truth, and sync workflow",
6
6
  "keywords": [
@@ -45,16 +45,16 @@
45
45
  "templates"
46
46
  ],
47
47
  "dependencies": {
48
- "@williamthorsen/nmr-core": "0.11.1",
49
- "@williamthorsen/toolbelt.errors": "0.7.0",
48
+ "@williamthorsen/nmr-core": "0.13.0",
49
+ "@williamthorsen/toolbelt.errors": "0.8.1",
50
50
  "audit-ci": "7.1.0",
51
- "zod": "4.5.4"
51
+ "zod": "4.6.5"
52
52
  },
53
53
  "devDependencies": {
54
- "@williamthorsen/toolbelt.filesystem": "0.8.3",
55
- "@williamthorsen/toolbelt.testing": "0.5.1",
56
- "@williamthorsen/toolbelt.vitest": "0.9.0",
57
- "readyup": "0.36.0",
54
+ "@williamthorsen/toolbelt.filesystem": "0.9.1",
55
+ "@williamthorsen/toolbelt.testing": "0.6.1",
56
+ "@williamthorsen/toolbelt.vitest": "0.10.1",
57
+ "readyup": "0.38.0",
58
58
  "vitest": "4.1.11"
59
59
  },
60
60
  "engines": {