hermex 2.0.2 → 2.1.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
@@ -2,7 +2,7 @@
2
2
  import { Command, Option } from "commander";
3
3
  import chalk from "chalk";
4
4
  import Table from "cli-table3";
5
- import fs, { existsSync, readFileSync } from "node:fs";
5
+ import fs, { existsSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
7
7
  import path, { dirname, join, resolve } from "node:path";
8
8
  import { z } from "zod";
@@ -158,7 +158,7 @@ function printComponentsChart(components) {
158
158
  //#endregion
159
159
  //#region src/utils/print-patterns.ts
160
160
  function printHeader$1() {
161
- console.log(chalk.blue.bold("\nšŸ” Code Patterns\n"));
161
+ console.log(chalk.blue.bold("\n🧩 Code Patterns\n"));
162
162
  }
163
163
  function printPatterns(aggregated, mode) {
164
164
  const patterns = aggregated.patternCounts.filter((p) => p.count > 0);
@@ -233,10 +233,14 @@ function resolveColorLevel(opts) {
233
233
  if (opts.noColorEnv !== void 0) return 0;
234
234
  }
235
235
  const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g;
236
+ /** Removes ANSI color escapes — for output destinations (files, PR comments) that can't render them. */
237
+ function stripAnsi(text) {
238
+ return text.replace(ANSI_ESCAPE_PATTERN, "");
239
+ }
236
240
  function stripAnsiWrites(stream) {
237
241
  const originalWrite = stream.write.bind(stream);
238
242
  stream.write = ((chunk, ...rest) => {
239
- return originalWrite(typeof chunk === "string" ? chunk.replace(ANSI_ESCAPE_PATTERN, "") : chunk, ...rest);
243
+ return originalWrite(typeof chunk === "string" ? stripAnsi(chunk) : chunk, ...rest);
240
244
  });
241
245
  }
242
246
  /**
@@ -266,6 +270,10 @@ function formatPackageName(pkg, banned) {
266
270
  else if (pkg.internal) prefix += severityColor("warn")("[int] ");
267
271
  return prefix + pkg.packageName;
268
272
  }
273
+ function describeUpgradeTarget(top) {
274
+ const overdue = top.releasedDaysAgo > top.thresholdDays ? "no compliant release available" : formatDaysOverdue(top.breachReleasedDaysAgo, top.thresholdDays);
275
+ return `${top.semverBump} ${top.version} (${overdue})`;
276
+ }
269
277
  function formatUpgradeCell(releaseAge) {
270
278
  if (!releaseAge) return "";
271
279
  const { worstLevel, upgrades, severity, pendingUpgrade } = releaseAge;
@@ -276,9 +284,9 @@ function formatUpgradeCell(releaseAge) {
276
284
  const top = upgrades[0];
277
285
  if (!top) return severityIcon("success");
278
286
  const suffix = severity === "warn" ? chalk.gray(" [not enforced]") : "";
279
- const overdue = top.releasedDaysAgo > top.thresholdDays ? "no compliant release available" : 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}`;
287
+ const description = describeUpgradeTarget(top);
288
+ if (worstLevel === "major_overdue") return `${severityIcon(severity === "warn" ? "warn" : "error")} ${description}${suffix}`;
289
+ return `${severityIcon("warn")} ${description}${suffix}`;
282
290
  }
283
291
  function getBannedViolation(pkg, violations) {
284
292
  return violations.find((v) => v.packageName === pkg.packageName);
@@ -296,13 +304,7 @@ function printPackagesTable(packages, violations) {
296
304
  return;
297
305
  }
298
306
  const hasReleaseAge = packages.some((p) => p.releaseAge !== void 0);
299
- const head = [
300
- "Package",
301
- "Version",
302
- "Components",
303
- "Usage",
304
- "Percentage"
305
- ];
307
+ const head = ["Package", "Version"];
306
308
  if (hasReleaseAge) head.push("Upgrades");
307
309
  const table = new Table({
308
310
  head,
@@ -313,20 +315,12 @@ function printPackagesTable(packages, violations) {
313
315
  });
314
316
  packages.forEach((pkg) => {
315
317
  const versionCell = pkg.hasVersionConflict ? chalk.yellow(`⚠ ${pkg.allVersions.join(", ")} (${pkg.allVersions.length} versions — bundle impact)`) : pkg.version || "N/A";
316
- const row = [
317
- formatPackageName(pkg, getBannedViolation(pkg, violations)),
318
- versionCell,
319
- formatCount(pkg.componentCount),
320
- formatCount(pkg.usageCount),
321
- `${pkg.percentage.toFixed(1)}%`
322
- ];
318
+ const row = [formatPackageName(pkg, getBannedViolation(pkg, violations)), versionCell];
323
319
  if (hasReleaseAge) row.push(formatUpgradeCell(pkg.releaseAge));
324
320
  table.push(row);
325
321
  });
326
322
  console.log(table.toString());
327
- const totalComponents = packages.reduce((sum, p) => sum + p.componentCount, 0);
328
- const totalExternalUsage = packages.reduce((sum, p) => sum + p.usageCount, 0);
329
- console.log(chalk.gray(`\nTotal: ${formatCount(packages.length)} packages | ${formatCount(totalComponents)} unique components | ${formatCount(totalExternalUsage)} total usages`));
323
+ console.log(chalk.gray(`\nTotal: ${formatCount(packages.length)} packages`));
330
324
  }
331
325
  function printPackagesChart(packages, violations) {
332
326
  printHeader();
@@ -362,15 +356,14 @@ function printVersusResult(result) {
362
356
  const bar = renderBar(entry.percentage);
363
357
  const pct = chalk.bold(`${entry.percentage.toFixed(1)}%`);
364
358
  const usage = chalk.gray(`(${entry.count} usages)`);
365
- const components = entry.components.length > 0 ? chalk.gray(` ${formatTruncatedList(entry.components, "component")}`) : "";
366
- console.log(` ${name} ${bar} ${pct} ${usage}${components}`);
359
+ console.log(` ${name} ${bar} ${pct} ${usage}`);
367
360
  }
368
361
  if (result.totalCount === 0) console.log(chalk.gray(" No usage detected for any package in this group."));
369
362
  console.log();
370
363
  }
371
364
  function printVersus(aggregated) {
372
365
  if (aggregated.versusResults.length === 0) return;
373
- console.log(chalk.magentaBright.bold("\nāš–ļø Versus\n"));
366
+ console.log(chalk.magentaBright.bold("\nāš–ļø Versus\n"));
374
367
  for (const result of aggregated.versusResults) printVersusResult(result);
375
368
  }
376
369
  //#endregion
@@ -417,11 +410,11 @@ function printRules(aggregated) {
417
410
  const hasRuleViolations = ruleViolations.length > 0;
418
411
  const hasBannedViolations = bannedPackageViolations.length > 0;
419
412
  if (!hasRuleViolations && !hasBannedViolations) {
420
- console.log(chalk.greenBright.bold(`\n${severityIcon("success")} Compliance\n`));
421
- console.log(chalk.gray(" All compliance checks passed"));
413
+ console.log(chalk.greenBright.bold(`\n${severityIcon("success")} Rules\n`));
414
+ console.log(chalk.gray(" All rule checks passed"));
422
415
  return;
423
416
  }
424
- console.log(chalk.blueBright.bold("\nšŸ” Compliance\n"));
417
+ console.log(chalk.blueBright.bold("\nšŸ” Rules\n"));
425
418
  if (hasRuleViolations) for (const v of ruleViolations) console.log(formatViolationLine({
426
419
  icon: severityIcon(v.severity),
427
420
  label: formatRuleType(v.type),
@@ -2099,7 +2092,7 @@ function pickNewest(versions) {
2099
2092
  function upgradeLevel(daysAgo, bump, thresholds) {
2100
2093
  const threshold = thresholds[bump];
2101
2094
  if (threshold === false || threshold === void 0) return null;
2102
- if (daysAgo > threshold) return bump === "major" ? "mandatory_upgrade" : "needs_upgrade";
2095
+ if (daysAgo > threshold) return bump === "major" ? "major_overdue" : "minor_overdue";
2103
2096
  return null;
2104
2097
  }
2105
2098
  function computeReleaseAge(installedVersion, timeMap, deprecated, thresholds, distTags, severity) {
@@ -2145,14 +2138,12 @@ function computeReleaseAge(installedVersion, timeMap, deprecated, thresholds, di
2145
2138
  const latestVersion = distTags?.["latest"];
2146
2139
  const latestEntry = latestVersion ? timeMap[latestVersion] : void 0;
2147
2140
  const latestReleasedDaysAgo = latestEntry ? daysSince(latestEntry) : void 0;
2148
- const hadInWindowCandidate = minCompliantVersion !== void 0;
2149
- if (!hadInWindowCandidate && latestVersion && latestReleasedDaysAgo !== void 0 && !semver.prerelease(latestVersion) && semver.gt(latestVersion, installedVersion)) {
2141
+ if (!(minCompliantVersion !== void 0) && latestVersion && latestReleasedDaysAgo !== void 0 && !semver.prerelease(latestVersion) && semver.gt(latestVersion, installedVersion)) {
2150
2142
  minCompliantVersion = latestVersion;
2151
2143
  minCompliantReleasedDaysAgo = latestReleasedDaysAgo;
2152
2144
  }
2153
- const compliantByLatestFallback = !hadInWindowCandidate && minCompliantVersion !== void 0;
2154
2145
  for (const upgrade of finalUpgrades) if (latestVersion && upgrade.version === latestVersion) upgrade.isLatest = true;
2155
- const worstLevel = compliantByLatestFallback ? null : finalUpgrades.some((u) => u.level === "mandatory_upgrade") ? "mandatory_upgrade" : finalUpgrades.length > 0 ? "needs_upgrade" : null;
2146
+ const worstLevel = finalUpgrades.some((u) => u.level === "major_overdue") ? "major_overdue" : finalUpgrades.length > 0 ? "minor_overdue" : null;
2156
2147
  let pendingUpgrade;
2157
2148
  if (worstLevel === null) for (const [bump, versions] of byBump.entries()) {
2158
2149
  const threshold = thresholds[bump];
@@ -2239,20 +2230,20 @@ function isDeclarationFile(filePath) {
2239
2230
  */
2240
2231
  async function runPipeline(config, spinner, isJson) {
2241
2232
  const lockfileResult = findAndParseLockfile(process.cwd());
2242
- spinner.succeed(chalk.blue(`šŸ“¦ Found ${lockfileResult.lockfileType} lockfile (supports: ${lockfileResult.supportedVersions.join(", ")}) - ${Object.keys(lockfileResult.versions).length} packages`));
2243
- spinner.start("Finding files...");
2233
+ spinner.succeed(chalk.blue(`Found ${lockfileResult.lockfileType} lockfile (supports: ${lockfileResult.supportedVersions.join(", ")}) - ${Object.keys(lockfileResult.versions).length} packages`));
2234
+ if (spinner.isEnabled) spinner.start("Finding files...");
2244
2235
  const files = (await findFiles(config.includes, config.excludes)).filter((f) => !isDeclarationFile(f));
2245
2236
  if (files.length === 0) {
2246
2237
  spinner.fail(chalk.red(`No files found matching includes: ${config.includes.join(", ")}`));
2247
2238
  return null;
2248
2239
  }
2249
- spinner.succeed(chalk.green(` Found ${files.length} files`));
2250
- spinner.start("Analyzing files...");
2240
+ spinner.succeed(chalk.green(`Found ${files.length} files`));
2241
+ if (spinner.isEnabled) spinner.start("Analyzing files...");
2251
2242
  const reports = [];
2252
2243
  const parseErrors = [];
2253
2244
  for (let i = 0; i < files.length; i++) {
2254
2245
  const file = files[i];
2255
- spinner.text = `Analyzing files... (${i + 1}/${files.length})`;
2246
+ if (spinner.isEnabled) spinner.text = `Analyzing files... (${i + 1}/${files.length})`;
2256
2247
  try {
2257
2248
  const report = parseFile(file);
2258
2249
  if (report) reports.push(report);
@@ -2270,10 +2261,10 @@ async function runPipeline(config, spinner, isJson) {
2270
2261
  const evaluatorViolations = evaluateRules(process.cwd(), config.rules, config.excludes, files);
2271
2262
  aggregated.ruleViolations = [...aggregated.ruleViolations, ...evaluatorViolations];
2272
2263
  if (config.releaseAge.enabled) {
2273
- spinner.start("Fetching release age from registry...");
2264
+ if (spinner.isEnabled) spinner.start("Fetching release age from registry...");
2274
2265
  const { enriched, skipped } = await enrichWithReleaseAge(aggregated.packageDistribution, config.releaseAge);
2275
2266
  aggregated.packageDistribution = enriched;
2276
- spinner.succeed(chalk.blue(`šŸ“… Release age fetched${skipped > 0 ? chalk.gray(` (${skipped} packages skipped — registry unreachable or not found)`) : ""}`));
2267
+ spinner.succeed(chalk.blue(`Release age fetched${skipped > 0 ? chalk.gray(` (${skipped} packages skipped — registry unreachable or not found)`) : ""}`));
2277
2268
  }
2278
2269
  return aggregated;
2279
2270
  }
@@ -2334,12 +2325,12 @@ function printScanResults(aggregated, config) {
2334
2325
  //#endregion
2335
2326
  //#region src/utils/print-compliance.ts
2336
2327
  /**
2337
- * Prints only the bottom-line verdict — the Compliance and Packages sections
2328
+ * Prints only the bottom-line verdict — the Rules and Packages sections
2338
2329
  * printed above this already itemize every violation (rule and release-age
2339
2330
  * alike), so repeating them here would just restate the same šŸ”“ rows.
2340
2331
  */
2341
2332
  function printComplianceVerdict(result) {
2342
- const mandatoryCount = result.errorRuleViolations.length + result.errorBannedPackageViolations.length + result.mandatoryReleaseAgeViolations.length;
2333
+ const mandatoryCount = result.errorRuleViolations.length + result.errorBannedPackageViolations.length + result.releaseAgeViolations.length;
2343
2334
  if (result.compliant) {
2344
2335
  console.log(chalk.greenBright.bold(`\n${severityIcon("success")} COMPLIANT\n`));
2345
2336
  return;
@@ -2351,34 +2342,85 @@ function printComplianceVerdict(result) {
2351
2342
  //#endregion
2352
2343
  //#region src/utils/compliance.ts
2353
2344
  /**
2354
- * A package is a mandatory compliance failure only when its releaseAge
2355
- * severity is 'error' (i.e. it's in scope per `releaseAge.enforceOn`) AND
2356
- * its worstLevel is 'mandatory_upgrade' — 'needs_upgrade' (patch/minor) is
2357
- * advisory even for enforced packages, matching the existing "mandatory"
2358
- * vocabulary already used by upgradeLevel().
2345
+ * A package is a compliance failure when its releaseAge severity is 'error'
2346
+ * (i.e. it's in scope per `releaseAge.enforceOn`) AND it has any breached
2347
+ * threshold at all (worstLevel is non-null) — both 'minor_overdue' and
2348
+ * 'major_overdue' fail comply for an enforced package; only severity
2349
+ * decides mandatory vs advisory, not which tier breached (#28).
2359
2350
  */
2360
2351
  function computeCompliance(aggregated) {
2361
2352
  const errorRuleViolations = aggregated.ruleViolations.filter((v) => v.severity === "error");
2362
2353
  const errorBannedPackageViolations = aggregated.bannedPackageViolations.filter((v) => v.severity === "error");
2363
- const mandatoryReleaseAgeViolations = aggregated.packageDistribution.filter((p) => p.releaseAge?.severity === "error" && p.releaseAge?.worstLevel === "mandatory_upgrade");
2354
+ const releaseAgeViolations = aggregated.packageDistribution.filter((p) => p.releaseAge?.severity === "error" && p.releaseAge?.worstLevel !== null);
2364
2355
  return {
2365
- compliant: errorRuleViolations.length === 0 && errorBannedPackageViolations.length === 0 && mandatoryReleaseAgeViolations.length === 0,
2356
+ compliant: errorRuleViolations.length === 0 && errorBannedPackageViolations.length === 0 && releaseAgeViolations.length === 0,
2366
2357
  errorRuleViolations,
2367
2358
  errorBannedPackageViolations,
2368
- mandatoryReleaseAgeViolations
2359
+ releaseAgeViolations
2369
2360
  };
2370
2361
  }
2371
2362
  //#endregion
2363
+ //#region src/utils/write-summary-file.ts
2364
+ function buildRulesSection(aggregated) {
2365
+ const ruleViolations = aggregated.ruleViolations.filter((v) => v.severity !== "info");
2366
+ const bannedPackageViolations = aggregated.bannedPackageViolations.filter((v) => v.severity !== "info");
2367
+ if (ruleViolations.length === 0 && bannedPackageViolations.length === 0) return "### Rules\n\nAll rule checks passed\n";
2368
+ const lines = ["### Rules", ""];
2369
+ for (const v of ruleViolations) lines.push(`- ${severityIcon(v.severity)} ${formatRuleType(v.type)} — ${describeViolation(v)}`);
2370
+ for (const v of bannedPackageViolations) {
2371
+ const msg = v.message ? ` — ${v.message}` : "";
2372
+ lines.push(`- ${severityIcon(v.severity)} forbid_packages — ${v.packageName} is forbidden${msg}`);
2373
+ }
2374
+ const allViolations = [...ruleViolations, ...bannedPackageViolations];
2375
+ const errorCount = allViolations.filter((v) => v.severity === "error").length;
2376
+ const warnCount = allViolations.filter((v) => v.severity === "warn").length;
2377
+ const parts = [];
2378
+ if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
2379
+ if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? "s" : ""}`);
2380
+ lines.push("", parts.join(", "));
2381
+ return lines.join("\n") + "\n";
2382
+ }
2383
+ function buildPackagesSection(compliance) {
2384
+ if (compliance.releaseAgeViolations.length === 0) return "";
2385
+ const lines = ["### Packages", ""];
2386
+ for (const pkg of compliance.releaseAgeViolations) {
2387
+ const top = pkg.releaseAge?.upgrades[0];
2388
+ const reasons = [];
2389
+ if (top) reasons.push(describeUpgradeTarget(top));
2390
+ if (pkg.releaseAge?.deprecated) reasons.push("deprecated");
2391
+ lines.push(`- ${severityIcon("error")} ${pkg.packageName} — ${reasons.join(", ")}`);
2392
+ }
2393
+ return lines.join("\n") + "\n";
2394
+ }
2395
+ function buildVerdictSection(compliance) {
2396
+ if (compliance.compliant) return `### ${severityIcon("success")} COMPLIANT\n`;
2397
+ const mandatoryCount = compliance.errorRuleViolations.length + compliance.errorBannedPackageViolations.length + compliance.releaseAgeViolations.length;
2398
+ return `### ${severityIcon("error")} NOT COMPLIANT\n\n${mandatoryCount} mandatory violation${mandatoryCount > 1 ? "s" : ""} found\n`;
2399
+ }
2400
+ /**
2401
+ * Writes a concise, ANSI-free markdown summary (rules, mandatory package
2402
+ * violations, verdict) for CI surfaces that can't render the full human
2403
+ * report — a sticky PR comment or job summary (#31). Omits Versus and
2404
+ * progress chrome by construction; never touches ora or the Versus renderer.
2405
+ */
2406
+ function writeSummaryFile(path, aggregated, compliance) {
2407
+ writeFileSync(path, stripAnsi([
2408
+ buildRulesSection(aggregated),
2409
+ buildPackagesSection(compliance),
2410
+ buildVerdictSection(compliance)
2411
+ ].filter((section) => section.length > 0).join("\n")));
2412
+ }
2413
+ //#endregion
2372
2414
  //#region src/commands/comply.ts
2373
2415
  function registerComplyCommand(program) {
2374
- 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) => {
2416
+ 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)").option("--summary-file <path>", "Write a concise, ANSI-free markdown summary (rules, flagged packages, verdict) to this path, for a CI job summary or PR comment").action(async (options) => {
2375
2417
  await executeComply(await loadConfig(process.cwd(), options.config), {
2376
2418
  format: options.format,
2377
2419
  color: options.color
2378
- });
2420
+ }, options.summaryFile);
2379
2421
  });
2380
2422
  }
2381
- async function executeComply(config, contextOptions = {}) {
2423
+ async function executeComply(config, contextOptions = {}, summaryFile) {
2382
2424
  const { isJson, spinner } = createCommandContext(config, contextOptions);
2383
2425
  try {
2384
2426
  const aggregated = await runPipeline(config, spinner, isJson);
@@ -2394,6 +2436,7 @@ async function executeComply(config, contextOptions = {}) {
2394
2436
  if (config.output.versus) printVersus(aggregated);
2395
2437
  printComplianceVerdict(compliance);
2396
2438
  }
2439
+ if (summaryFile) writeSummaryFile(summaryFile, aggregated, compliance);
2397
2440
  process.exitCode = compliance.compliant ? 0 : 1;
2398
2441
  } catch (error) {
2399
2442
  const message = error instanceof Error ? error.message : String(error);