hermex 2.0.0-beta.9 → 2.0.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.
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { Command } from "commander";
2
+ import { Command, Option } from "commander";
3
3
  import chalk from "chalk";
4
4
  import Table from "cli-table3";
5
5
  import fs, { existsSync, readFileSync } from "node:fs";
@@ -13,6 +13,7 @@ import fs$1 from "fs";
13
13
  import path$1 from "path";
14
14
  import semver from "semver";
15
15
  import { load } from "js-yaml";
16
+ import { parse, removeSuffix } from "@pnpm/dependency-path";
16
17
  import lockfile from "@yarnpkg/lockfile";
17
18
  import { randomUUID } from "node:crypto";
18
19
  import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
@@ -27,6 +28,31 @@ import ora from "ora";
27
28
  function formatCount(num) {
28
29
  return num.toLocaleString();
29
30
  }
31
+ /**
32
+ * Format how far an upgrade candidate is past its age threshold
33
+ * @returns Formatted string (e.g., "40 days overdue", "1 day overdue")
34
+ */
35
+ function formatDaysOverdue(releasedDaysAgo, thresholdDays) {
36
+ const overdue = releasedDaysAgo - thresholdDays;
37
+ return `${overdue} day${overdue === 1 ? "" : "s"} overdue`;
38
+ }
39
+ /**
40
+ * Format how long until an upgrade candidate breaches its age threshold
41
+ * @returns Formatted string (e.g., "12 days remaining", "1 day remaining")
42
+ */
43
+ function formatDaysRemaining(daysRemaining) {
44
+ return `${daysRemaining} day${daysRemaining === 1 ? "" : "s"} remaining`;
45
+ }
46
+ /**
47
+ * Join a list of items, showing only the first `limit` and summarizing the rest
48
+ * @returns Formatted string (e.g., "a, b and 3 other files", "a, b and 1 other file")
49
+ */
50
+ function formatTruncatedList(items, noun, limit = 2) {
51
+ const shown = items.slice(0, limit).join(", ");
52
+ const rest = items.length - limit;
53
+ if (rest <= 0) return shown;
54
+ return `${shown} and ${rest} other ${noun}${rest === 1 ? "" : "s"}`;
55
+ }
30
56
  //#endregion
31
57
  //#region src/utils/print-summary.ts
32
58
  function printHeader$4() {
@@ -171,26 +197,88 @@ function printPatternsChart(patterns) {
171
197
  })), { maxWidth: 50 });
172
198
  }
173
199
  //#endregion
200
+ //#region src/utils/severity-format.ts
201
+ const ICONS = {
202
+ error: "šŸ”“",
203
+ warn: "🟔",
204
+ info: "šŸ”µ",
205
+ success: "🟢"
206
+ };
207
+ const COLORS = {
208
+ error: chalk.red,
209
+ warn: chalk.yellow,
210
+ info: chalk.blue,
211
+ success: chalk.green
212
+ };
213
+ /** Colored-circle glyph for a severity — carries meaning without relying on ANSI color. */
214
+ function severityIcon(severity) {
215
+ return ICONS[severity];
216
+ }
217
+ /** chalk color function for a severity, for text that needs coloring. */
218
+ function severityColor(severity) {
219
+ return COLORS[severity];
220
+ }
221
+ function formatViolationLine(opts) {
222
+ return ` ${opts.icon} ${opts.label.padEnd(14)} ${opts.description}`;
223
+ }
224
+ /**
225
+ * Resolves an explicit color-on/off override from CLI flags or the NO_COLOR
226
+ * convention (https://no-color.org). Returns `undefined` when there's no
227
+ * explicit signal, meaning chalk's own auto-detection should be left alone
228
+ * (including its GitHub-Actions-aware detection of non-TTY streams).
229
+ */
230
+ function resolveColorLevel(opts) {
231
+ if (opts.colorFlag === false) return 0;
232
+ if (opts.colorFlag === true) return 1;
233
+ if (opts.noColorEnv !== void 0) return 0;
234
+ }
235
+ const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g;
236
+ function stripAnsiWrites(stream) {
237
+ const originalWrite = stream.write.bind(stream);
238
+ stream.write = ((chunk, ...rest) => {
239
+ return originalWrite(typeof chunk === "string" ? chunk.replace(ANSI_ESCAPE_PATTERN, "") : chunk, ...rest);
240
+ });
241
+ }
242
+ /**
243
+ * Beyond setting chalk's own level, this strips ANSI codes at the stream
244
+ * boundary when color is explicitly disabled. hermex also prints through
245
+ * cli-table3 and ora (whose spinner symbols come from log-symbols/yoctocolors)
246
+ * — neither shares hermex's chalk instance or honors chalk.level, so mutating
247
+ * chalk alone can't make NO_COLOR/--no-color hold for their output too.
248
+ */
249
+ function applyColorLevel(level) {
250
+ if (level === void 0) return;
251
+ chalk.level = level;
252
+ if (level === 0) {
253
+ stripAnsiWrites(process.stdout);
254
+ stripAnsiWrites(process.stderr);
255
+ }
256
+ }
257
+ //#endregion
174
258
  //#region src/utils/print-packages.ts
175
259
  function printHeader() {
176
260
  console.log(chalk.blueBright.bold("\nšŸ“¦ Packages\n"));
177
261
  }
178
262
  function formatPackageName(pkg, banned) {
179
263
  let prefix = "";
180
- if (pkg.releaseAge?.deprecated) prefix += chalk.red("[DEPRECATED] ");
181
- if (banned) prefix += banned.severity === "error" ? chalk.red("[BANNED] ") : chalk.yellow("[RESTRICTED] ");
182
- else if (pkg.internal) prefix += chalk.yellow("[int] ");
264
+ if (pkg.releaseAge?.deprecated) prefix += severityColor("error")("[DEPRECATED] ");
265
+ if (banned) prefix += banned.severity === "error" ? severityColor("error")("[BANNED] ") : severityColor("warn")("[RESTRICTED] ");
266
+ else if (pkg.internal) prefix += severityColor("warn")("[int] ");
183
267
  return prefix + pkg.packageName;
184
268
  }
185
269
  function formatUpgradeCell(releaseAge) {
186
270
  if (!releaseAge) return "";
187
- const { worstLevel, upgrades, severity } = releaseAge;
188
- if (!worstLevel) return chalk.green("āœ“");
271
+ const { worstLevel, upgrades, severity, pendingUpgrade } = releaseAge;
272
+ if (!worstLevel) {
273
+ if (pendingUpgrade) return `${severityIcon("info")} ${pendingUpgrade.semverBump} ${pendingUpgrade.version} (${formatDaysRemaining(pendingUpgrade.daysRemaining)})`;
274
+ return severityIcon("success");
275
+ }
189
276
  const top = upgrades[0];
190
- if (!top) return chalk.green("āœ“");
277
+ if (!top) return severityIcon("success");
191
278
  const suffix = severity === "warn" ? chalk.gray(" [not enforced]") : "";
192
- if (worstLevel === "mandatory_upgrade") return (severity === "warn" ? chalk.yellow : chalk.red)(`⚠ ${top.semverBump} ${top.version} (${top.releasedDaysAgo}d)`) + suffix;
193
- return chalk.yellow(`↑ ${top.semverBump} ${top.version} (${top.releasedDaysAgo}d)`) + suffix;
279
+ const overdue = formatDaysOverdue(top.breachReleasedDaysAgo, top.thresholdDays);
280
+ if (worstLevel === "mandatory_upgrade") return `${severityIcon(severity === "warn" ? "warn" : "error")} ${top.semverBump} ${top.version} (${overdue})${suffix}`;
281
+ return `${severityIcon("warn")} ${top.semverBump} ${top.version} (${overdue})${suffix}`;
194
282
  }
195
283
  function getBannedViolation(pkg, violations) {
196
284
  return violations.find((v) => v.packageName === pkg.packageName);
@@ -265,13 +353,6 @@ function renderBar(percentage) {
265
353
  const empty = BAR_WIDTH - filled;
266
354
  return chalk.cyan("ā–ˆ".repeat(filled)) + chalk.gray("ā–‘".repeat(empty));
267
355
  }
268
- function formatComponents(components, max = 3) {
269
- if (components.length === 0) return "";
270
- const shown = components.slice(0, max);
271
- const rest = components.length - max;
272
- const list = shown.join(", ");
273
- return rest > 0 ? `${list} (+${rest} more)` : list;
274
- }
275
356
  function printVersusResult(result) {
276
357
  console.log(chalk.bold(` ${result.name}`));
277
358
  console.log(chalk.gray(` ${"─".repeat(50)}`));
@@ -281,7 +362,7 @@ function printVersusResult(result) {
281
362
  const bar = renderBar(entry.percentage);
282
363
  const pct = chalk.bold(`${entry.percentage.toFixed(1)}%`);
283
364
  const usage = chalk.gray(`(${entry.count} usages)`);
284
- const components = entry.components.length > 0 ? chalk.gray(` ${formatComponents(entry.components)}`) : "";
365
+ const components = entry.components.length > 0 ? chalk.gray(` ${formatTruncatedList(entry.components, "component")}`) : "";
285
366
  console.log(` ${name} ${bar} ${pct} ${usage}${components}`);
286
367
  }
287
368
  if (result.totalCount === 0) console.log(chalk.gray(" No usage detected for any package in this group."));
@@ -298,30 +379,23 @@ function formatRuleType(type) {
298
379
  switch (type) {
299
380
  case "detect_files": return "detect_files";
300
381
  case "require_files": return "require_files";
301
- case "forbid_packages": return "forbid_packages";
302
382
  case "require_packages": return "require_packages";
303
383
  case "require_scripts": return "require_scripts";
304
- case "require_package_fields": return "pkg_fields";
305
- case "forbid_package_fields": return "pkg_fields";
384
+ case "require_package_fields": return "package_fields";
385
+ case "forbid_package_fields": return "package_fields";
306
386
  case "engine_version": return "engine_version";
307
387
  case "codeowners": return "codeowners";
308
388
  }
309
389
  }
310
- function ruleIcon(violation) {
311
- if (violation.severity === "error") return chalk.red("āœ—");
312
- if (violation.severity === "info") return chalk.blue("ℹ");
313
- return chalk.yellow("⚠");
314
- }
315
390
  function describeViolation(v) {
316
391
  const patterns = v.patterns.join(", ");
317
392
  const suffix = v.message ? chalk.gray(` — ${v.message}`) : "";
318
- if (v.type === "detect_files") return `${patterns} detected (${v.matchedFiles.map((f) => {
393
+ if (v.type === "detect_files") return `${patterns} detected (${formatTruncatedList(v.matchedFiles.map((f) => {
319
394
  const parts = f.replace(/\\/g, "/").split("/");
320
395
  return parts[parts.length - 1];
321
- }).join(", ")})${suffix}`;
396
+ }), "file")})${suffix}`;
322
397
  if (v.type === "require_files") return `${patterns} not found${suffix}`;
323
398
  if (v.type === "require_packages") return `${patterns} not installed${suffix}`;
324
- if (v.type === "forbid_packages") return `${patterns} is forbidden${suffix}`;
325
399
  if (v.type === "require_scripts") return `script ${patterns} missing in package.json${suffix}`;
326
400
  if (v.type === "require_package_fields") {
327
401
  if (v.fieldPath && v.actualValue !== void 0) return `field ${v.fieldPath} is ${chalk.yellow(v.actualValue)}, does not match required value${suffix}`;
@@ -334,9 +408,7 @@ function describeViolation(v) {
334
408
  }
335
409
  if (v.type === "codeowners") {
336
410
  if (v.matchedFiles.length === 0) return `CODEOWNERS not found (looked in ${patterns})${suffix}`;
337
- const shown = v.matchedFiles.slice(0, 3).join(", ");
338
- const more = v.matchedFiles.length > 3 ? ` and ${v.matchedFiles.length - 3} more` : "";
339
- return `${v.matchedFiles.length} scanned file(s) have no owner: ${shown}${more}${suffix}`;
411
+ return `${v.matchedFiles.length} scanned file(s) have no owner: ${formatTruncatedList(v.matchedFiles, "file")}${suffix}`;
340
412
  }
341
413
  return `${patterns} not present${suffix}`;
342
414
  }
@@ -345,25 +417,23 @@ function printRules(aggregated) {
345
417
  const hasRuleViolations = ruleViolations.length > 0;
346
418
  const hasBannedViolations = bannedPackageViolations.length > 0;
347
419
  if (!hasRuleViolations && !hasBannedViolations) {
348
- console.log(chalk.greenBright.bold("\nāœ“ Compliance\n"));
420
+ console.log(chalk.greenBright.bold(`\n${severityIcon("success")} Compliance\n`));
349
421
  console.log(chalk.gray(" All compliance checks passed"));
350
422
  return;
351
423
  }
352
424
  console.log(chalk.blueBright.bold("\nšŸ” Compliance\n"));
353
- if (hasRuleViolations) for (const v of ruleViolations) {
354
- const icon = ruleIcon(v);
355
- const type = chalk.gray(formatRuleType(v.type).padEnd(14));
356
- const severityTag = v.severity === "error" ? chalk.red("[ERROR]") : v.severity === "info" ? chalk.blue("[INFO]") : chalk.yellow("[WARN]");
357
- console.log(` ${icon} ${type} ${describeViolation(v)} ${severityTag}`);
358
- }
359
- if (hasBannedViolations) {
360
- if (hasRuleViolations) console.log();
361
- for (const v of bannedPackageViolations) {
362
- const icon = v.severity === "error" ? chalk.red("āœ—") : chalk.yellow("⚠");
363
- const tag = v.severity === "error" ? chalk.red("[BANNED]") : chalk.yellow("[RESTRICTED]");
364
- const msg = v.message ? chalk.gray(` — ${v.message}`) : "";
365
- console.log(` ${icon} ${tag} ${v.packageName}${msg}`);
366
- }
425
+ if (hasRuleViolations) for (const v of ruleViolations) console.log(formatViolationLine({
426
+ icon: severityIcon(v.severity),
427
+ label: formatRuleType(v.type),
428
+ description: describeViolation(v)
429
+ }));
430
+ if (hasBannedViolations) for (const v of bannedPackageViolations) {
431
+ const msg = v.message ? chalk.gray(` — ${v.message}`) : "";
432
+ console.log(formatViolationLine({
433
+ icon: severityIcon(v.severity),
434
+ label: "forbid_packages",
435
+ description: `${v.packageName} is forbidden${msg}`
436
+ }));
367
437
  }
368
438
  const errorCount = [...ruleViolations.filter((v) => v.severity === "error"), ...bannedPackageViolations.filter((v) => v.severity === "error")].length;
369
439
  const warnCount = [...ruleViolations.filter((v) => v.severity === "warn"), ...bannedPackageViolations.filter((v) => v.severity === "warn")].length;
@@ -1755,13 +1825,13 @@ var NpmLockfileAdapter = class {
1755
1825
  //#endregion
1756
1826
  //#region src/lock-parser/patterns/pnpm.ts
1757
1827
  function parsePackageKey(rawKey) {
1758
- const withoutPeerSuffix = (rawKey.startsWith("/") ? rawKey.slice(1) : rawKey).replace(/\(.*\)$/, "");
1759
- const atMatch = withoutPeerSuffix.match(/^(.+)@(\d+\.\d+\.\d+[^/]*)$/);
1760
- if (atMatch) return {
1761
- name: atMatch[1],
1762
- version: atMatch[2]
1828
+ const key = rawKey.startsWith("/") ? rawKey.slice(1) : rawKey;
1829
+ const parsed = parse(key);
1830
+ if (parsed.name && parsed.version) return {
1831
+ name: parsed.name,
1832
+ version: parsed.version
1763
1833
  };
1764
- const slashMatch = withoutPeerSuffix.match(/^(.+?)\/(\d+\.\d+\.\d+.*)$/);
1834
+ const slashMatch = key.match(/^(.+?)\/(\d+\.\d+\.\d+.*)$/);
1765
1835
  if (slashMatch) return {
1766
1836
  name: slashMatch[1],
1767
1837
  version: slashMatch[2]
@@ -1787,10 +1857,10 @@ var PnpmLockfileAdapter = class {
1787
1857
  const rootImporter = lockData.importers["."];
1788
1858
  if (rootImporter) {
1789
1859
  if (rootImporter.dependencies) {
1790
- for (const [name, data] of Object.entries(rootImporter.dependencies)) if (typeof data === "object" && data !== null && "version" in data) versions[name] = data.version;
1860
+ for (const [name, data] of Object.entries(rootImporter.dependencies)) if (typeof data === "object" && data !== null && "version" in data) versions[name] = removeSuffix(data.version);
1791
1861
  }
1792
1862
  if (rootImporter.devDependencies) {
1793
- for (const [name, data] of Object.entries(rootImporter.devDependencies)) if (typeof data === "object" && data !== null && "version" in data) versions[name] = data.version;
1863
+ for (const [name, data] of Object.entries(rootImporter.devDependencies)) if (typeof data === "object" && data !== null && "version" in data) versions[name] = removeSuffix(data.version);
1794
1864
  }
1795
1865
  }
1796
1866
  }
@@ -1798,12 +1868,12 @@ var PnpmLockfileAdapter = class {
1798
1868
  const match = key.match(/\/(.+?)\/(\d+\.\d+\.\d+.*?)(?:_|$)/);
1799
1869
  if (match) {
1800
1870
  const [, pkgName, version] = match;
1801
- versions[pkgName] = version;
1871
+ versions[pkgName] = removeSuffix(version);
1802
1872
  }
1803
1873
  });
1804
1874
  if (lockData.dependencies && Object.keys(versions).length === 0) Object.entries(lockData.dependencies).forEach(([name, versionSpec]) => {
1805
- if (typeof versionSpec === "string" && !versionSpec.startsWith("link:")) versions[name] = versionSpec;
1806
- else if (typeof versionSpec === "object" && versionSpec.version) versions[name] = versionSpec.version;
1875
+ if (typeof versionSpec === "string" && !versionSpec.startsWith("link:")) versions[name] = removeSuffix(versionSpec);
1876
+ else if (typeof versionSpec === "object" && versionSpec.version) versions[name] = removeSuffix(versionSpec.version);
1807
1877
  });
1808
1878
  return versions;
1809
1879
  } catch (error) {
@@ -2003,6 +2073,9 @@ function classifyBump(installed, candidate) {
2003
2073
  if (diff === "major" || diff === "premajor") return "major";
2004
2074
  return null;
2005
2075
  }
2076
+ function pickNewest(versions) {
2077
+ return versions.reduce((a, b) => a.daysAgo < b.daysAgo ? a : b);
2078
+ }
2006
2079
  function upgradeLevel(daysAgo, bump, thresholds) {
2007
2080
  const threshold = thresholds[bump];
2008
2081
  if (threshold === false || threshold === void 0) return null;
@@ -2035,14 +2108,17 @@ function computeReleaseAge(installedVersion, timeMap, deprecated, thresholds, di
2035
2108
  }
2036
2109
  const upgrades = [];
2037
2110
  for (const [bump, versions] of byBump.entries()) {
2038
- const level = upgradeLevel(Math.max(...versions.map((v) => v.daysAgo)), bump, thresholds);
2111
+ const oldestDaysAgo = Math.max(...versions.map((v) => v.daysAgo));
2112
+ const level = upgradeLevel(oldestDaysAgo, bump, thresholds);
2039
2113
  if (!level) continue;
2040
- const newest = versions.reduce((a, b) => a.daysAgo < b.daysAgo ? a : b);
2114
+ const newest = pickNewest(versions);
2041
2115
  upgrades.push({
2042
2116
  version: newest.version,
2043
2117
  releasedDaysAgo: newest.daysAgo,
2118
+ breachReleasedDaysAgo: oldestDaysAgo,
2044
2119
  semverBump: bump,
2045
- level
2120
+ level,
2121
+ thresholdDays: thresholds[bump]
2046
2122
  });
2047
2123
  }
2048
2124
  const finalUpgrades = upgrades.sort((a, b) => b.releasedDaysAgo - a.releasedDaysAgo);
@@ -2050,10 +2126,29 @@ function computeReleaseAge(installedVersion, timeMap, deprecated, thresholds, di
2050
2126
  const latestEntry = latestVersion ? timeMap[latestVersion] : void 0;
2051
2127
  const latestReleasedDaysAgo = latestEntry ? daysSince(latestEntry) : void 0;
2052
2128
  for (const upgrade of finalUpgrades) if (latestVersion && upgrade.version === latestVersion) upgrade.isLatest = true;
2129
+ const worstLevel = finalUpgrades.some((u) => u.level === "mandatory_upgrade") ? "mandatory_upgrade" : finalUpgrades.length > 0 ? "needs_upgrade" : null;
2130
+ let pendingUpgrade;
2131
+ if (worstLevel === null) for (const [bump, versions] of byBump.entries()) {
2132
+ const threshold = thresholds[bump];
2133
+ if (threshold === false || threshold === void 0) continue;
2134
+ const daysRemaining = threshold - Math.max(...versions.map((v) => v.daysAgo));
2135
+ if (daysRemaining <= 0) continue;
2136
+ if (!pendingUpgrade || daysRemaining < pendingUpgrade.daysRemaining) {
2137
+ const newest = pickNewest(versions);
2138
+ pendingUpgrade = {
2139
+ version: newest.version,
2140
+ semverBump: bump,
2141
+ releasedDaysAgo: newest.daysAgo,
2142
+ thresholdDays: threshold,
2143
+ daysRemaining
2144
+ };
2145
+ }
2146
+ }
2053
2147
  return {
2054
2148
  installedVersion,
2055
2149
  upgrades: finalUpgrades,
2056
- worstLevel: finalUpgrades.some((u) => u.level === "mandatory_upgrade") ? "mandatory_upgrade" : finalUpgrades.length > 0 ? "needs_upgrade" : null,
2150
+ worstLevel,
2151
+ pendingUpgrade,
2057
2152
  deprecated,
2058
2153
  latestVersion,
2059
2154
  latestReleasedDaysAgo,
@@ -2162,8 +2257,12 @@ async function runPipeline(config, spinner, isJson) {
2162
2257
  * Shared command preamble: routes human-readable chrome (version line,
2163
2258
  * spinner) to stderr when the command emits JSON on stdout.
2164
2259
  */
2165
- function createCommandContext(config) {
2166
- const isJson = config.output.format === "json";
2260
+ function createCommandContext(config, options = {}) {
2261
+ applyColorLevel(resolveColorLevel({
2262
+ colorFlag: options.color === false ? false : void 0,
2263
+ noColorEnv: process.env["NO_COLOR"]
2264
+ }));
2265
+ const isJson = (options.format ?? config.output.format) === "json";
2167
2266
  const stream = isJson ? process.stderr : process.stdout;
2168
2267
  stream.write(chalk.gray(`hermex v${getVersion()}\n`));
2169
2268
  return {
@@ -2177,12 +2276,15 @@ function createCommandContext(config) {
2177
2276
  //#endregion
2178
2277
  //#region src/commands/scan.ts
2179
2278
  function registerScanCommand(program) {
2180
- program.command("scan").description("Scan and analyze local files").option("--config <path>", "Path to hermex config file (overrides CWD discovery)").action(async (options) => {
2181
- await executeScan(await loadConfig(process.cwd(), options.config));
2279
+ program.command("scan").description("Scan and analyze local files").option("--config <path>", "Path to hermex config file (overrides CWD discovery)").addOption(new Option("--format <format>", "Output format, overrides output.format in the config file").choices(["human", "json"])).option("--no-color", "Disable colored output (see also NO_COLOR env var)").action(async (options) => {
2280
+ await executeScan(await loadConfig(process.cwd(), options.config), {
2281
+ format: options.format,
2282
+ color: options.color
2283
+ });
2182
2284
  });
2183
2285
  }
2184
- async function executeScan(config) {
2185
- const { isJson, spinner } = createCommandContext(config);
2286
+ async function executeScan(config, contextOptions = {}) {
2287
+ const { isJson, spinner } = createCommandContext(config, contextOptions);
2186
2288
  try {
2187
2289
  const aggregated = await runPipeline(config, spinner, isJson);
2188
2290
  if (!aggregated) return;
@@ -2205,21 +2307,19 @@ function printScanResults(aggregated, config) {
2205
2307
  }
2206
2308
  //#endregion
2207
2309
  //#region src/utils/print-compliance.ts
2310
+ /**
2311
+ * Prints only the bottom-line verdict — the Compliance and Packages sections
2312
+ * printed above this already itemize every violation (rule and release-age
2313
+ * alike), so repeating them here would just restate the same šŸ”“ rows.
2314
+ */
2208
2315
  function printComplianceVerdict(result) {
2209
2316
  const mandatoryCount = result.errorRuleViolations.length + result.errorBannedPackageViolations.length + result.mandatoryReleaseAgeViolations.length;
2210
2317
  if (result.compliant) {
2211
- console.log(chalk.greenBright.bold("\nāœ“ COMPLIANT\n"));
2318
+ console.log(chalk.greenBright.bold(`\n${severityIcon("success")} COMPLIANT\n`));
2212
2319
  return;
2213
2320
  }
2214
- console.log(chalk.redBright.bold(`\nāœ— NOT COMPLIANT`));
2321
+ console.log(chalk.redBright.bold(`\n${severityIcon("error")} NOT COMPLIANT`));
2215
2322
  console.log(chalk.red(` ${mandatoryCount} mandatory violation${mandatoryCount > 1 ? "s" : ""} found`));
2216
- if (result.mandatoryReleaseAgeViolations.length > 0) {
2217
- console.log();
2218
- for (const pkg of result.mandatoryReleaseAgeViolations) {
2219
- const top = pkg.releaseAge?.upgrades[0];
2220
- console.log(chalk.red(` āœ— releaseAge ${pkg.packageName} is ${top?.semverBump} version behind (${top?.releasedDaysAgo}d) — mandatory upgrade [ERROR]`));
2221
- }
2222
- }
2223
2323
  console.log();
2224
2324
  }
2225
2325
  //#endregion
@@ -2245,12 +2345,15 @@ function computeCompliance(aggregated) {
2245
2345
  //#endregion
2246
2346
  //#region src/commands/comply.ts
2247
2347
  function registerComplyCommand(program) {
2248
- program.command("comply").description("Check compliance with hermex.config.ts rules and release-age policy (exits non-zero if not compliant)").option("--config <path>", "Path to hermex config file (overrides CWD discovery)").action(async (options) => {
2249
- await executeComply(await loadConfig(process.cwd(), options.config));
2348
+ program.command("comply").description("Check compliance with hermex.config.ts rules and release-age policy (exits non-zero if not compliant)").option("--config <path>", "Path to hermex config file (overrides CWD discovery)").addOption(new Option("--format <format>", "Output format, overrides output.format in the config file").choices(["human", "json"])).option("--no-color", "Disable colored output (see also NO_COLOR env var)").action(async (options) => {
2349
+ await executeComply(await loadConfig(process.cwd(), options.config), {
2350
+ format: options.format,
2351
+ color: options.color
2352
+ });
2250
2353
  });
2251
2354
  }
2252
- async function executeComply(config) {
2253
- const { isJson, spinner } = createCommandContext(config);
2355
+ async function executeComply(config, contextOptions = {}) {
2356
+ const { isJson, spinner } = createCommandContext(config, contextOptions);
2254
2357
  try {
2255
2358
  const aggregated = await runPipeline(config, spinner, isJson);
2256
2359
  if (!aggregated) {
@@ -2262,6 +2365,7 @@ async function executeComply(config) {
2262
2365
  else {
2263
2366
  printRules(aggregated);
2264
2367
  if (config.releaseAge.enabled) printPackages(aggregated, "table");
2368
+ if (config.output.versus) printVersus(aggregated);
2265
2369
  printComplianceVerdict(compliance);
2266
2370
  }
2267
2371
  process.exitCode = compliance.compliant ? 0 : 1;