hermex 2.0.0-beta.8 → 2.0.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.
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";
@@ -27,6 +27,31 @@ import ora from "ora";
27
27
  function formatCount(num) {
28
28
  return num.toLocaleString();
29
29
  }
30
+ /**
31
+ * Format how far an upgrade candidate is past its age threshold
32
+ * @returns Formatted string (e.g., "40 days overdue", "1 day overdue")
33
+ */
34
+ function formatDaysOverdue(releasedDaysAgo, thresholdDays) {
35
+ const overdue = releasedDaysAgo - thresholdDays;
36
+ return `${overdue} day${overdue === 1 ? "" : "s"} overdue`;
37
+ }
38
+ /**
39
+ * Format how long until an upgrade candidate breaches its age threshold
40
+ * @returns Formatted string (e.g., "12 days remaining", "1 day remaining")
41
+ */
42
+ function formatDaysRemaining(daysRemaining) {
43
+ return `${daysRemaining} day${daysRemaining === 1 ? "" : "s"} remaining`;
44
+ }
45
+ /**
46
+ * Join a list of items, showing only the first `limit` and summarizing the rest
47
+ * @returns Formatted string (e.g., "a, b and 3 other files", "a, b and 1 other file")
48
+ */
49
+ function formatTruncatedList(items, noun, limit = 2) {
50
+ const shown = items.slice(0, limit).join(", ");
51
+ const rest = items.length - limit;
52
+ if (rest <= 0) return shown;
53
+ return `${shown} and ${rest} other ${noun}${rest === 1 ? "" : "s"}`;
54
+ }
30
55
  //#endregion
31
56
  //#region src/utils/print-summary.ts
32
57
  function printHeader$4() {
@@ -171,26 +196,88 @@ function printPatternsChart(patterns) {
171
196
  })), { maxWidth: 50 });
172
197
  }
173
198
  //#endregion
199
+ //#region src/utils/severity-format.ts
200
+ const ICONS = {
201
+ error: "šŸ”“",
202
+ warn: "🟔",
203
+ info: "šŸ”µ",
204
+ success: "🟢"
205
+ };
206
+ const COLORS = {
207
+ error: chalk.red,
208
+ warn: chalk.yellow,
209
+ info: chalk.blue,
210
+ success: chalk.green
211
+ };
212
+ /** Colored-circle glyph for a severity — carries meaning without relying on ANSI color. */
213
+ function severityIcon(severity) {
214
+ return ICONS[severity];
215
+ }
216
+ /** chalk color function for a severity, for text that needs coloring. */
217
+ function severityColor(severity) {
218
+ return COLORS[severity];
219
+ }
220
+ function formatViolationLine(opts) {
221
+ return ` ${opts.icon} ${opts.label.padEnd(14)} ${opts.description}`;
222
+ }
223
+ /**
224
+ * Resolves an explicit color-on/off override from CLI flags or the NO_COLOR
225
+ * convention (https://no-color.org). Returns `undefined` when there's no
226
+ * explicit signal, meaning chalk's own auto-detection should be left alone
227
+ * (including its GitHub-Actions-aware detection of non-TTY streams).
228
+ */
229
+ function resolveColorLevel(opts) {
230
+ if (opts.colorFlag === false) return 0;
231
+ if (opts.colorFlag === true) return 1;
232
+ if (opts.noColorEnv !== void 0) return 0;
233
+ }
234
+ const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g;
235
+ function stripAnsiWrites(stream) {
236
+ const originalWrite = stream.write.bind(stream);
237
+ stream.write = ((chunk, ...rest) => {
238
+ return originalWrite(typeof chunk === "string" ? chunk.replace(ANSI_ESCAPE_PATTERN, "") : chunk, ...rest);
239
+ });
240
+ }
241
+ /**
242
+ * Beyond setting chalk's own level, this strips ANSI codes at the stream
243
+ * boundary when color is explicitly disabled. hermex also prints through
244
+ * cli-table3 and ora (whose spinner symbols come from log-symbols/yoctocolors)
245
+ * — neither shares hermex's chalk instance or honors chalk.level, so mutating
246
+ * chalk alone can't make NO_COLOR/--no-color hold for their output too.
247
+ */
248
+ function applyColorLevel(level) {
249
+ if (level === void 0) return;
250
+ chalk.level = level;
251
+ if (level === 0) {
252
+ stripAnsiWrites(process.stdout);
253
+ stripAnsiWrites(process.stderr);
254
+ }
255
+ }
256
+ //#endregion
174
257
  //#region src/utils/print-packages.ts
175
258
  function printHeader() {
176
259
  console.log(chalk.blueBright.bold("\nšŸ“¦ Packages\n"));
177
260
  }
178
261
  function formatPackageName(pkg, banned) {
179
262
  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] ");
263
+ if (pkg.releaseAge?.deprecated) prefix += severityColor("error")("[DEPRECATED] ");
264
+ if (banned) prefix += banned.severity === "error" ? severityColor("error")("[BANNED] ") : severityColor("warn")("[RESTRICTED] ");
265
+ else if (pkg.internal) prefix += severityColor("warn")("[int] ");
183
266
  return prefix + pkg.packageName;
184
267
  }
185
268
  function formatUpgradeCell(releaseAge) {
186
269
  if (!releaseAge) return "";
187
- const { worstLevel, upgrades, severity } = releaseAge;
188
- if (!worstLevel) return chalk.green("āœ“");
270
+ const { worstLevel, upgrades, severity, pendingUpgrade } = releaseAge;
271
+ if (!worstLevel) {
272
+ if (pendingUpgrade) return `${severityIcon("info")} ${pendingUpgrade.semverBump} ${pendingUpgrade.version} (${formatDaysRemaining(pendingUpgrade.daysRemaining)})`;
273
+ return severityIcon("success");
274
+ }
189
275
  const top = upgrades[0];
190
- if (!top) return chalk.green("āœ“");
276
+ if (!top) return severityIcon("success");
191
277
  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;
278
+ const overdue = formatDaysOverdue(top.releasedDaysAgo, top.thresholdDays);
279
+ if (worstLevel === "mandatory_upgrade") return `${severityIcon(severity === "warn" ? "warn" : "error")} ${top.semverBump} ${top.version} (${overdue})${suffix}`;
280
+ return `${severityIcon("warn")} ${top.semverBump} ${top.version} (${overdue})${suffix}`;
194
281
  }
195
282
  function getBannedViolation(pkg, violations) {
196
283
  return violations.find((v) => v.packageName === pkg.packageName);
@@ -265,13 +352,6 @@ function renderBar(percentage) {
265
352
  const empty = BAR_WIDTH - filled;
266
353
  return chalk.cyan("ā–ˆ".repeat(filled)) + chalk.gray("ā–‘".repeat(empty));
267
354
  }
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
355
  function printVersusResult(result) {
276
356
  console.log(chalk.bold(` ${result.name}`));
277
357
  console.log(chalk.gray(` ${"─".repeat(50)}`));
@@ -281,7 +361,7 @@ function printVersusResult(result) {
281
361
  const bar = renderBar(entry.percentage);
282
362
  const pct = chalk.bold(`${entry.percentage.toFixed(1)}%`);
283
363
  const usage = chalk.gray(`(${entry.count} usages)`);
284
- const components = entry.components.length > 0 ? chalk.gray(` ${formatComponents(entry.components)}`) : "";
364
+ const components = entry.components.length > 0 ? chalk.gray(` ${formatTruncatedList(entry.components, "component")}`) : "";
285
365
  console.log(` ${name} ${bar} ${pct} ${usage}${components}`);
286
366
  }
287
367
  if (result.totalCount === 0) console.log(chalk.gray(" No usage detected for any package in this group."));
@@ -298,30 +378,23 @@ function formatRuleType(type) {
298
378
  switch (type) {
299
379
  case "detect_files": return "detect_files";
300
380
  case "require_files": return "require_files";
301
- case "forbid_packages": return "forbid_packages";
302
381
  case "require_packages": return "require_packages";
303
382
  case "require_scripts": return "require_scripts";
304
- case "require_package_fields": return "pkg_fields";
305
- case "forbid_package_fields": return "pkg_fields";
383
+ case "require_package_fields": return "package_fields";
384
+ case "forbid_package_fields": return "package_fields";
306
385
  case "engine_version": return "engine_version";
307
386
  case "codeowners": return "codeowners";
308
387
  }
309
388
  }
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
389
  function describeViolation(v) {
316
390
  const patterns = v.patterns.join(", ");
317
391
  const suffix = v.message ? chalk.gray(` — ${v.message}`) : "";
318
- if (v.type === "detect_files") return `${patterns} detected (${v.matchedFiles.map((f) => {
392
+ if (v.type === "detect_files") return `${patterns} detected (${formatTruncatedList(v.matchedFiles.map((f) => {
319
393
  const parts = f.replace(/\\/g, "/").split("/");
320
394
  return parts[parts.length - 1];
321
- }).join(", ")})${suffix}`;
395
+ }), "file")})${suffix}`;
322
396
  if (v.type === "require_files") return `${patterns} not found${suffix}`;
323
397
  if (v.type === "require_packages") return `${patterns} not installed${suffix}`;
324
- if (v.type === "forbid_packages") return `${patterns} is forbidden${suffix}`;
325
398
  if (v.type === "require_scripts") return `script ${patterns} missing in package.json${suffix}`;
326
399
  if (v.type === "require_package_fields") {
327
400
  if (v.fieldPath && v.actualValue !== void 0) return `field ${v.fieldPath} is ${chalk.yellow(v.actualValue)}, does not match required value${suffix}`;
@@ -334,9 +407,7 @@ function describeViolation(v) {
334
407
  }
335
408
  if (v.type === "codeowners") {
336
409
  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}`;
410
+ return `${v.matchedFiles.length} scanned file(s) have no owner: ${formatTruncatedList(v.matchedFiles, "file")}${suffix}`;
340
411
  }
341
412
  return `${patterns} not present${suffix}`;
342
413
  }
@@ -345,25 +416,23 @@ function printRules(aggregated) {
345
416
  const hasRuleViolations = ruleViolations.length > 0;
346
417
  const hasBannedViolations = bannedPackageViolations.length > 0;
347
418
  if (!hasRuleViolations && !hasBannedViolations) {
348
- console.log(chalk.greenBright.bold("\nāœ“ Compliance\n"));
419
+ console.log(chalk.greenBright.bold(`\n${severityIcon("success")} Compliance\n`));
349
420
  console.log(chalk.gray(" All compliance checks passed"));
350
421
  return;
351
422
  }
352
423
  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
- }
424
+ if (hasRuleViolations) for (const v of ruleViolations) console.log(formatViolationLine({
425
+ icon: severityIcon(v.severity),
426
+ label: formatRuleType(v.type),
427
+ description: describeViolation(v)
428
+ }));
429
+ if (hasBannedViolations) for (const v of bannedPackageViolations) {
430
+ const msg = v.message ? chalk.gray(` — ${v.message}`) : "";
431
+ console.log(formatViolationLine({
432
+ icon: severityIcon(v.severity),
433
+ label: "forbid_packages",
434
+ description: `${v.packageName} is forbidden${msg}`
435
+ }));
367
436
  }
368
437
  const errorCount = [...ruleViolations.filter((v) => v.severity === "error"), ...bannedPackageViolations.filter((v) => v.severity === "error")].length;
369
438
  const warnCount = [...ruleViolations.filter((v) => v.severity === "warn"), ...bannedPackageViolations.filter((v) => v.severity === "warn")].length;
@@ -2003,6 +2072,9 @@ function classifyBump(installed, candidate) {
2003
2072
  if (diff === "major" || diff === "premajor") return "major";
2004
2073
  return null;
2005
2074
  }
2075
+ function pickNewest(versions) {
2076
+ return versions.reduce((a, b) => a.daysAgo < b.daysAgo ? a : b);
2077
+ }
2006
2078
  function upgradeLevel(daysAgo, bump, thresholds) {
2007
2079
  const threshold = thresholds[bump];
2008
2080
  if (threshold === false || threshold === void 0) return null;
@@ -2037,12 +2109,13 @@ function computeReleaseAge(installedVersion, timeMap, deprecated, thresholds, di
2037
2109
  for (const [bump, versions] of byBump.entries()) {
2038
2110
  const level = upgradeLevel(Math.max(...versions.map((v) => v.daysAgo)), bump, thresholds);
2039
2111
  if (!level) continue;
2040
- const newest = versions.reduce((a, b) => a.daysAgo < b.daysAgo ? a : b);
2112
+ const newest = pickNewest(versions);
2041
2113
  upgrades.push({
2042
2114
  version: newest.version,
2043
2115
  releasedDaysAgo: newest.daysAgo,
2044
2116
  semverBump: bump,
2045
- level
2117
+ level,
2118
+ thresholdDays: thresholds[bump]
2046
2119
  });
2047
2120
  }
2048
2121
  const finalUpgrades = upgrades.sort((a, b) => b.releasedDaysAgo - a.releasedDaysAgo);
@@ -2050,10 +2123,29 @@ function computeReleaseAge(installedVersion, timeMap, deprecated, thresholds, di
2050
2123
  const latestEntry = latestVersion ? timeMap[latestVersion] : void 0;
2051
2124
  const latestReleasedDaysAgo = latestEntry ? daysSince(latestEntry) : void 0;
2052
2125
  for (const upgrade of finalUpgrades) if (latestVersion && upgrade.version === latestVersion) upgrade.isLatest = true;
2126
+ const worstLevel = finalUpgrades.some((u) => u.level === "mandatory_upgrade") ? "mandatory_upgrade" : finalUpgrades.length > 0 ? "needs_upgrade" : null;
2127
+ let pendingUpgrade;
2128
+ if (worstLevel === null) for (const [bump, versions] of byBump.entries()) {
2129
+ const threshold = thresholds[bump];
2130
+ if (threshold === false || threshold === void 0) continue;
2131
+ const daysRemaining = threshold - Math.max(...versions.map((v) => v.daysAgo));
2132
+ if (daysRemaining <= 0) continue;
2133
+ if (!pendingUpgrade || daysRemaining < pendingUpgrade.daysRemaining) {
2134
+ const newest = pickNewest(versions);
2135
+ pendingUpgrade = {
2136
+ version: newest.version,
2137
+ semverBump: bump,
2138
+ releasedDaysAgo: newest.daysAgo,
2139
+ thresholdDays: threshold,
2140
+ daysRemaining
2141
+ };
2142
+ }
2143
+ }
2053
2144
  return {
2054
2145
  installedVersion,
2055
2146
  upgrades: finalUpgrades,
2056
- worstLevel: finalUpgrades.some((u) => u.level === "mandatory_upgrade") ? "mandatory_upgrade" : finalUpgrades.length > 0 ? "needs_upgrade" : null,
2147
+ worstLevel,
2148
+ pendingUpgrade,
2057
2149
  deprecated,
2058
2150
  latestVersion,
2059
2151
  latestReleasedDaysAgo,
@@ -2162,8 +2254,12 @@ async function runPipeline(config, spinner, isJson) {
2162
2254
  * Shared command preamble: routes human-readable chrome (version line,
2163
2255
  * spinner) to stderr when the command emits JSON on stdout.
2164
2256
  */
2165
- function createCommandContext(config) {
2166
- const isJson = config.output.format === "json";
2257
+ function createCommandContext(config, options = {}) {
2258
+ applyColorLevel(resolveColorLevel({
2259
+ colorFlag: options.color === false ? false : void 0,
2260
+ noColorEnv: process.env["NO_COLOR"]
2261
+ }));
2262
+ const isJson = (options.format ?? config.output.format) === "json";
2167
2263
  const stream = isJson ? process.stderr : process.stdout;
2168
2264
  stream.write(chalk.gray(`hermex v${getVersion()}\n`));
2169
2265
  return {
@@ -2177,12 +2273,15 @@ function createCommandContext(config) {
2177
2273
  //#endregion
2178
2274
  //#region src/commands/scan.ts
2179
2275
  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));
2276
+ 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) => {
2277
+ await executeScan(await loadConfig(process.cwd(), options.config), {
2278
+ format: options.format,
2279
+ color: options.color
2280
+ });
2182
2281
  });
2183
2282
  }
2184
- async function executeScan(config) {
2185
- const { isJson, spinner } = createCommandContext(config);
2283
+ async function executeScan(config, contextOptions = {}) {
2284
+ const { isJson, spinner } = createCommandContext(config, contextOptions);
2186
2285
  try {
2187
2286
  const aggregated = await runPipeline(config, spinner, isJson);
2188
2287
  if (!aggregated) return;
@@ -2205,21 +2304,19 @@ function printScanResults(aggregated, config) {
2205
2304
  }
2206
2305
  //#endregion
2207
2306
  //#region src/utils/print-compliance.ts
2307
+ /**
2308
+ * Prints only the bottom-line verdict — the Compliance and Packages sections
2309
+ * printed above this already itemize every violation (rule and release-age
2310
+ * alike), so repeating them here would just restate the same šŸ”“ rows.
2311
+ */
2208
2312
  function printComplianceVerdict(result) {
2209
2313
  const mandatoryCount = result.errorRuleViolations.length + result.errorBannedPackageViolations.length + result.mandatoryReleaseAgeViolations.length;
2210
2314
  if (result.compliant) {
2211
- console.log(chalk.greenBright.bold("\nāœ“ COMPLIANT\n"));
2315
+ console.log(chalk.greenBright.bold(`\n${severityIcon("success")} COMPLIANT\n`));
2212
2316
  return;
2213
2317
  }
2214
- console.log(chalk.redBright.bold(`\nāœ— NOT COMPLIANT`));
2318
+ console.log(chalk.redBright.bold(`\n${severityIcon("error")} NOT COMPLIANT`));
2215
2319
  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
2320
  console.log();
2224
2321
  }
2225
2322
  //#endregion
@@ -2245,12 +2342,15 @@ function computeCompliance(aggregated) {
2245
2342
  //#endregion
2246
2343
  //#region src/commands/comply.ts
2247
2344
  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));
2345
+ 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) => {
2346
+ await executeComply(await loadConfig(process.cwd(), options.config), {
2347
+ format: options.format,
2348
+ color: options.color
2349
+ });
2250
2350
  });
2251
2351
  }
2252
- async function executeComply(config) {
2253
- const { isJson, spinner } = createCommandContext(config);
2352
+ async function executeComply(config, contextOptions = {}) {
2353
+ const { isJson, spinner } = createCommandContext(config, contextOptions);
2254
2354
  try {
2255
2355
  const aggregated = await runPipeline(config, spinner, isJson);
2256
2356
  if (!aggregated) {
@@ -2262,6 +2362,7 @@ async function executeComply(config) {
2262
2362
  else {
2263
2363
  printRules(aggregated);
2264
2364
  if (config.releaseAge.enabled) printPackages(aggregated, "table");
2365
+ if (config.output.versus) printVersus(aggregated);
2265
2366
  printComplianceVerdict(compliance);
2266
2367
  }
2267
2368
  process.exitCode = compliance.compliant ? 0 : 1;