hermex 2.0.0-beta.1 → 2.0.0-beta.10

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,11 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { Command } from "commander";
3
- import ora from "ora";
2
+ import { Command, Option } from "commander";
4
3
  import chalk from "chalk";
5
4
  import Table from "cli-table3";
6
5
  import fs, { existsSync, readFileSync } from "node:fs";
7
6
  import { fileURLToPath, pathToFileURL } from "node:url";
8
- import path, { join, resolve } from "node:path";
7
+ import path, { dirname, join, resolve } from "node:path";
9
8
  import { z } from "zod";
10
9
  import { parseSync } from "@swc/core";
11
10
  import micromatch from "micromatch";
@@ -15,6 +14,10 @@ import path$1 from "path";
15
14
  import semver from "semver";
16
15
  import { load } from "js-yaml";
17
16
  import lockfile from "@yarnpkg/lockfile";
17
+ import { randomUUID } from "node:crypto";
18
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
19
+ import { homedir } from "node:os";
20
+ import ora from "ora";
18
21
  //#region src/utils/format-utils.ts
19
22
  /**
20
23
  * Format a number with thousand separators
@@ -24,6 +27,31 @@ import lockfile from "@yarnpkg/lockfile";
24
27
  function formatCount(num) {
25
28
  return num.toLocaleString();
26
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
+ }
27
55
  //#endregion
28
56
  //#region src/utils/print-summary.ts
29
57
  function printHeader$4() {
@@ -168,26 +196,88 @@ function printPatternsChart(patterns) {
168
196
  })), { maxWidth: 50 });
169
197
  }
170
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
171
257
  //#region src/utils/print-packages.ts
172
258
  function printHeader() {
173
259
  console.log(chalk.blueBright.bold("\nšŸ“¦ Packages\n"));
174
260
  }
175
261
  function formatPackageName(pkg, banned) {
176
262
  let prefix = "";
177
- if (pkg.releaseAge?.deprecated) prefix += chalk.red("[DEPRECATED] ");
178
- if (banned) prefix += banned.severity === "error" ? chalk.red("[BANNED] ") : chalk.yellow("[RESTRICTED] ");
179
- 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] ");
180
266
  return prefix + pkg.packageName;
181
267
  }
182
268
  function formatUpgradeCell(releaseAge) {
183
269
  if (!releaseAge) return "";
184
- const { worstLevel, upgrades, severity } = releaseAge;
185
- 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
+ }
186
275
  const top = upgrades[0];
187
- if (!top) return chalk.green("āœ“");
276
+ if (!top) return severityIcon("success");
188
277
  const suffix = severity === "warn" ? chalk.gray(" [not enforced]") : "";
189
- if (worstLevel === "mandatory_upgrade") return (severity === "warn" ? chalk.yellow : chalk.red)(`⚠ ${top.semverBump} ${top.version} (${top.releasedDaysAgo}d)`) + suffix;
190
- 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}`;
191
281
  }
192
282
  function getBannedViolation(pkg, violations) {
193
283
  return violations.find((v) => v.packageName === pkg.packageName);
@@ -221,7 +311,7 @@ function printPackagesTable(packages, violations) {
221
311
  }
222
312
  });
223
313
  packages.forEach((pkg) => {
224
- const versionCell = pkg.hasVersionConflict ? chalk.yellow(`⚠ ${pkg.allVersions.join(", ")} (multiple — bundle impact)`) : pkg.version || "N/A";
314
+ const versionCell = pkg.hasVersionConflict ? chalk.yellow(`⚠ ${pkg.allVersions.join(", ")} (${pkg.allVersions.length} versions — bundle impact)`) : pkg.version || "N/A";
225
315
  const row = [
226
316
  formatPackageName(pkg, getBannedViolation(pkg, violations)),
227
317
  versionCell,
@@ -262,13 +352,6 @@ function renderBar(percentage) {
262
352
  const empty = BAR_WIDTH - filled;
263
353
  return chalk.cyan("ā–ˆ".repeat(filled)) + chalk.gray("ā–‘".repeat(empty));
264
354
  }
265
- function formatComponents(components, max = 3) {
266
- if (components.length === 0) return "";
267
- const shown = components.slice(0, max);
268
- const rest = components.length - max;
269
- const list = shown.join(", ");
270
- return rest > 0 ? `${list} (+${rest} more)` : list;
271
- }
272
355
  function printVersusResult(result) {
273
356
  console.log(chalk.bold(` ${result.name}`));
274
357
  console.log(chalk.gray(` ${"─".repeat(50)}`));
@@ -278,7 +361,7 @@ function printVersusResult(result) {
278
361
  const bar = renderBar(entry.percentage);
279
362
  const pct = chalk.bold(`${entry.percentage.toFixed(1)}%`);
280
363
  const usage = chalk.gray(`(${entry.count} usages)`);
281
- const components = entry.components.length > 0 ? chalk.gray(` ${formatComponents(entry.components)}`) : "";
364
+ const components = entry.components.length > 0 ? chalk.gray(` ${formatTruncatedList(entry.components, "component")}`) : "";
282
365
  console.log(` ${name} ${bar} ${pct} ${usage}${components}`);
283
366
  }
284
367
  if (result.totalCount === 0) console.log(chalk.gray(" No usage detected for any package in this group."));
@@ -295,34 +378,37 @@ function formatRuleType(type) {
295
378
  switch (type) {
296
379
  case "detect_files": return "detect_files";
297
380
  case "require_files": return "require_files";
298
- case "forbid_packages": return "forbid_packages";
299
381
  case "require_packages": return "require_packages";
300
382
  case "require_scripts": return "require_scripts";
301
- case "require_package_fields": return "pkg_fields";
383
+ case "require_package_fields": return "package_fields";
384
+ case "forbid_package_fields": return "package_fields";
302
385
  case "engine_version": return "engine_version";
386
+ case "codeowners": return "codeowners";
303
387
  }
304
388
  }
305
- function ruleIcon(violation) {
306
- if (violation.severity === "error") return chalk.red("āœ—");
307
- if (violation.severity === "info") return chalk.blue("ℹ");
308
- return chalk.yellow("⚠");
309
- }
310
389
  function describeViolation(v) {
311
390
  const patterns = v.patterns.join(", ");
312
391
  const suffix = v.message ? chalk.gray(` — ${v.message}`) : "";
313
- 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) => {
314
393
  const parts = f.replace(/\\/g, "/").split("/");
315
394
  return parts[parts.length - 1];
316
- }).join(", ")})${suffix}`;
395
+ }), "file")})${suffix}`;
317
396
  if (v.type === "require_files") return `${patterns} not found${suffix}`;
318
397
  if (v.type === "require_packages") return `${patterns} not installed${suffix}`;
319
- if (v.type === "forbid_packages") return `${patterns} is forbidden${suffix}`;
320
398
  if (v.type === "require_scripts") return `script ${patterns} missing in package.json${suffix}`;
321
- if (v.type === "require_package_fields") return `field ${patterns} missing in package.json${suffix}`;
399
+ if (v.type === "require_package_fields") {
400
+ if (v.fieldPath && v.actualValue !== void 0) return `field ${v.fieldPath} is ${chalk.yellow(v.actualValue)}, does not match required value${suffix}`;
401
+ return `field ${patterns} missing in package.json${suffix}`;
402
+ }
403
+ if (v.type === "forbid_package_fields") return `field ${v.fieldPath ?? patterns} is forbidden in package.json${suffix}`;
322
404
  if (v.type === "engine_version") {
323
405
  if (!v.installedRange) return `engines.node not specified (required ${v.requiredRange})${suffix}`;
324
406
  return `engines.node is ${chalk.yellow(v.installedRange)}, required ${chalk.cyan(v.requiredRange)}${suffix}`;
325
407
  }
408
+ if (v.type === "codeowners") {
409
+ if (v.matchedFiles.length === 0) return `CODEOWNERS not found (looked in ${patterns})${suffix}`;
410
+ return `${v.matchedFiles.length} scanned file(s) have no owner: ${formatTruncatedList(v.matchedFiles, "file")}${suffix}`;
411
+ }
326
412
  return `${patterns} not present${suffix}`;
327
413
  }
328
414
  function printRules(aggregated) {
@@ -330,25 +416,23 @@ function printRules(aggregated) {
330
416
  const hasRuleViolations = ruleViolations.length > 0;
331
417
  const hasBannedViolations = bannedPackageViolations.length > 0;
332
418
  if (!hasRuleViolations && !hasBannedViolations) {
333
- console.log(chalk.greenBright.bold("\nāœ“ Compliance\n"));
419
+ console.log(chalk.greenBright.bold(`\n${severityIcon("success")} Compliance\n`));
334
420
  console.log(chalk.gray(" All compliance checks passed"));
335
421
  return;
336
422
  }
337
423
  console.log(chalk.blueBright.bold("\nšŸ” Compliance\n"));
338
- if (hasRuleViolations) for (const v of ruleViolations) {
339
- const icon = ruleIcon(v);
340
- const type = chalk.gray(formatRuleType(v.type).padEnd(14));
341
- const severityTag = v.severity === "error" ? chalk.red("[ERROR]") : v.severity === "info" ? chalk.blue("[INFO]") : chalk.yellow("[WARN]");
342
- console.log(` ${icon} ${type} ${describeViolation(v)} ${severityTag}`);
343
- }
344
- if (hasBannedViolations) {
345
- if (hasRuleViolations) console.log();
346
- for (const v of bannedPackageViolations) {
347
- const icon = v.severity === "error" ? chalk.red("āœ—") : chalk.yellow("⚠");
348
- const tag = v.severity === "error" ? chalk.red("[BANNED]") : chalk.yellow("[RESTRICTED]");
349
- const msg = v.message ? chalk.gray(` — ${v.message}`) : "";
350
- console.log(` ${icon} ${tag} ${v.packageName}${msg}`);
351
- }
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
+ }));
352
436
  }
353
437
  const errorCount = [...ruleViolations.filter((v) => v.severity === "error"), ...bannedPackageViolations.filter((v) => v.severity === "error")].length;
354
438
  const warnCount = [...ruleViolations.filter((v) => v.severity === "warn"), ...bannedPackageViolations.filter((v) => v.severity === "warn")].length;
@@ -417,11 +501,19 @@ const RuleConfigSchema = z.object({
417
501
  message: z.string().optional()
418
502
  });
419
503
  const RuleConfigOrArraySchema = z.union([RuleConfigSchema, z.array(RuleConfigSchema)]);
504
+ const PackageFieldRuleSchema = RuleConfigSchema.extend({
505
+ /** Optional micromatch patterns the field's stringified value must match */
506
+ values: z.array(z.string()).optional() });
507
+ const PackageFieldRuleOrArraySchema = z.union([PackageFieldRuleSchema, z.array(PackageFieldRuleSchema)]);
420
508
  const EngineVersionRuleSchema = z.object({
421
509
  severity: RuleSeveritySchema,
422
510
  range: z.string(),
423
511
  message: z.string().optional()
424
512
  });
513
+ const CodeownersRuleSchema = z.object({
514
+ severity: RuleSeveritySchema,
515
+ message: z.string().optional()
516
+ });
425
517
  const ThresholdSchema = z.union([z.number(), z.literal(false)]);
426
518
  const HermexConfigSchema = z.object({
427
519
  includes: z.array(z.string()).default(["**/*.{tsx,jsx,ts,js}"]),
@@ -447,15 +539,18 @@ const HermexConfigSchema = z.object({
447
539
  forbid_packages: RuleConfigOrArraySchema.default([]),
448
540
  require_packages: RuleConfigOrArraySchema.default([]),
449
541
  require_scripts: RuleConfigOrArraySchema.default([]),
450
- require_package_fields: RuleConfigOrArraySchema.default([]),
451
- engine_version: z.union([EngineVersionRuleSchema, z.array(EngineVersionRuleSchema)]).optional()
542
+ require_package_fields: PackageFieldRuleOrArraySchema.default([]),
543
+ forbid_package_fields: PackageFieldRuleOrArraySchema.default([]),
544
+ engine_version: z.union([EngineVersionRuleSchema, z.array(EngineVersionRuleSchema)]).optional(),
545
+ codeowners: CodeownersRuleSchema.optional()
452
546
  }).default(() => ({
453
547
  detect_files: [],
454
548
  require_files: [],
455
549
  forbid_packages: [],
456
550
  require_packages: [],
457
551
  require_scripts: [],
458
- require_package_fields: []
552
+ require_package_fields: [],
553
+ forbid_package_fields: []
459
554
  })),
460
555
  output: z.object({
461
556
  summary: z.union([z.literal("log"), z.literal(false)]).default("log"),
@@ -489,7 +584,9 @@ const HermexConfigSchema = z.object({
489
584
  minor: 45,
490
585
  major: 60
491
586
  })),
492
- enforceOn: z.array(z.string()).default([])
587
+ enforceOn: z.array(z.string()).default([]),
588
+ cacheTtlMs: z.number().int().positive().optional(),
589
+ cacheDisabled: z.boolean().default(false)
493
590
  }).default(() => ({
494
591
  enabled: false,
495
592
  registry: "https://registry.npmjs.org",
@@ -498,7 +595,8 @@ const HermexConfigSchema = z.object({
498
595
  minor: 45,
499
596
  major: 60
500
597
  },
501
- enforceOn: []
598
+ enforceOn: [],
599
+ cacheDisabled: false
502
600
  }))
503
601
  });
504
602
  //#endregion
@@ -864,10 +962,10 @@ function analyzeConditionalExpression(node, state) {
864
962
  */
865
963
  function analyzeArrayExpression(node, state) {
866
964
  if (node.elements?.some((elem) => {
867
- if (elem?.type === "Identifier") return state.componentNames.has(elem.value);
965
+ if (elem?.expression?.type === "Identifier") return state.componentNames.has(elem.expression.value);
868
966
  return false;
869
967
  })) state.usagePatterns.arrayMappings.add({
870
- components: node.elements?.map((elem) => elem?.value).filter(Boolean),
968
+ components: node.elements?.map((elem) => elem?.expression?.value).filter(Boolean),
871
969
  line: node.span?.start || 0
872
970
  });
873
971
  }
@@ -893,11 +991,11 @@ function analyzeObjectExpression(node, state) {
893
991
  * Analyzes React.lazy() imports
894
992
  */
895
993
  function analyzeLazyImport(node, state) {
896
- const arg = node.arguments?.[0];
994
+ const arg = node.arguments?.[0]?.expression;
897
995
  if (arg?.type === "ArrowFunctionExpression" && arg.body?.type === "CallExpression") {
898
996
  const importCall = arg.body;
899
997
  if (importCall.callee?.type === "Import") {
900
- const source = importCall.arguments?.[0]?.value;
998
+ const source = importCall.arguments?.[0]?.expression?.value;
901
999
  if (source) state.usagePatterns.lazyImports.add({
902
1000
  source,
903
1001
  line: node.span?.start || 0
@@ -909,7 +1007,7 @@ function analyzeLazyImport(node, state) {
909
1007
  * Analyzes dynamic import() calls
910
1008
  */
911
1009
  function analyzeDynamicImport(node, state) {
912
- const source = node.arguments?.[0]?.value;
1010
+ const source = node.arguments?.[0]?.expression?.value;
913
1011
  if (source) state.usagePatterns.dynamicImports.add({
914
1012
  source,
915
1013
  line: node.span?.start || 0
@@ -923,7 +1021,7 @@ function analyzeDynamicImport(node, state) {
923
1021
  function analyzeHOCUsage(node, state) {
924
1022
  state.usagePatterns.hocUsage.add({
925
1023
  function: node.callee?.value || "[unknown]",
926
- component: node.arguments?.[0]?.value || "[unknown]",
1024
+ component: node.arguments?.[0]?.expression?.value || "[unknown]",
927
1025
  line: node.span?.start || 0
928
1026
  });
929
1027
  }
@@ -931,7 +1029,7 @@ function analyzeHOCUsage(node, state) {
931
1029
  * Analyzes React.memo() usage
932
1030
  */
933
1031
  function analyzeMemoUsage(node, state) {
934
- const component = node.arguments?.[0];
1032
+ const component = node.arguments?.[0]?.expression;
935
1033
  if (component?.type === "Identifier" && state.componentNames.has(component.value)) state.usagePatterns.memoizedComponents.add({
936
1034
  component: component.value,
937
1035
  line: node.span?.start || 0
@@ -962,7 +1060,7 @@ function analyzeMemberExpression(node, state) {
962
1060
  * Checks if a node represents HOC pattern
963
1061
  */
964
1062
  function isHOCPattern(node, state) {
965
- return node.callee?.type === "Identifier" && node.arguments?.some((arg) => arg.type === "Identifier" && state.componentNames.has(arg.value));
1063
+ return node.callee?.type === "Identifier" && node.arguments?.some((arg) => arg.expression?.type === "Identifier" && state.componentNames.has(arg.expression.value));
966
1064
  }
967
1065
  //#endregion
968
1066
  //#region src/swc-parser/core/visitor.ts
@@ -1069,8 +1167,9 @@ function visitChildren(node, state, context) {
1069
1167
  /**
1070
1168
  * Generates a comprehensive usage report from parser state
1071
1169
  */
1072
- function generateReport(state) {
1170
+ function generateReport(state, filePath) {
1073
1171
  return {
1172
+ filePath,
1074
1173
  summary: {
1075
1174
  totalImports: state.usagePatterns.defaultImports.size + state.usagePatterns.namedImports.size + state.usagePatterns.namespaceImports.size,
1076
1175
  totalComponents: state.componentNames.size,
@@ -1151,7 +1250,7 @@ function swcOptionsForFile(filePath) {
1151
1250
  function parseCode(code, filePath = "file.tsx") {
1152
1251
  const state = createState();
1153
1252
  visitNode(parseSync(code, swcOptionsForFile(filePath)), state);
1154
- return generateReport(state);
1253
+ return generateReport(state, filePath);
1155
1254
  }
1156
1255
  function parseFile(filePath) {
1157
1256
  return parseCode(fs.readFileSync(filePath, "utf8"), filePath);
@@ -1290,18 +1389,63 @@ function evaluateScriptRules(repoPath, rulesConfig) {
1290
1389
  }
1291
1390
  //#endregion
1292
1391
  //#region src/rules/package-field-rules.ts
1392
+ /** Resolves a dot-path like "engines.node" against the manifest object. */
1393
+ function getFieldAtPath(pkg, path) {
1394
+ let current = pkg;
1395
+ for (const key of path.split(".")) {
1396
+ if (current === null || typeof current !== "object" || !(key in current)) return {
1397
+ exists: false,
1398
+ value: void 0
1399
+ };
1400
+ current = current[key];
1401
+ }
1402
+ return {
1403
+ exists: true,
1404
+ value: current
1405
+ };
1406
+ }
1407
+ /** Primitives compare by string form; objects/arrays never match a value pattern. */
1408
+ function valueMatches(value, valuePatterns) {
1409
+ if (value === null || typeof value === "object") return false;
1410
+ return micromatch.isMatch(String(value), valuePatterns);
1411
+ }
1293
1412
  function evaluatePackageFieldRules(repoPath, rulesConfig) {
1294
- const rules = toArray(rulesConfig.require_package_fields);
1295
- if (rules.length === 0) return [];
1413
+ const requireRules = toArray(rulesConfig.require_package_fields);
1414
+ const forbidRules = toArray(rulesConfig.forbid_package_fields);
1415
+ if (requireRules.length === 0 && forbidRules.length === 0) return [];
1296
1416
  const pkg = readPackageJson(repoPath);
1297
- const fieldKeys = pkg ? Object.keys(pkg) : [];
1298
- return rules.filter((rule) => !rule.patterns.some((p) => fieldKeys.includes(p))).map((rule) => ({
1299
- type: "require_package_fields",
1300
- severity: rule.severity,
1301
- patterns: rule.patterns,
1302
- message: rule.message,
1303
- matchedFiles: []
1304
- }));
1417
+ const violations = [];
1418
+ for (const rule of requireRules) {
1419
+ const lookups = rule.patterns.map((p) => ({
1420
+ path: p,
1421
+ ...getFieldAtPath(pkg, p)
1422
+ }));
1423
+ if (!lookups.some((l) => l.exists && (!rule.values || valueMatches(l.value, rule.values)))) {
1424
+ const mismatch = rule.values ? lookups.find((l) => l.exists) : void 0;
1425
+ violations.push({
1426
+ type: "require_package_fields",
1427
+ severity: rule.severity,
1428
+ patterns: rule.patterns,
1429
+ message: rule.message,
1430
+ matchedFiles: [],
1431
+ fieldPath: mismatch?.path,
1432
+ actualValue: mismatch && typeof mismatch.value !== "object" ? String(mismatch.value) : void 0
1433
+ });
1434
+ }
1435
+ }
1436
+ for (const rule of forbidRules) for (const pattern of rule.patterns) {
1437
+ const lookup = getFieldAtPath(pkg, pattern);
1438
+ if (lookup.exists && (!rule.values || valueMatches(lookup.value, rule.values))) violations.push({
1439
+ type: "forbid_package_fields",
1440
+ severity: rule.severity,
1441
+ patterns: rule.patterns,
1442
+ message: rule.message,
1443
+ matchedFiles: [],
1444
+ fieldPath: pattern,
1445
+ actualValue: lookup.value !== null && typeof lookup.value !== "object" ? String(lookup.value) : void 0
1446
+ });
1447
+ }
1448
+ return violations;
1305
1449
  }
1306
1450
  //#endregion
1307
1451
  //#region src/rules/engine-version.ts
@@ -1332,13 +1476,102 @@ function evaluateEngineVersion(repoPath, rulesConfig) {
1332
1476
  });
1333
1477
  }
1334
1478
  //#endregion
1479
+ //#region src/rules/codeowners.ts
1480
+ const CODEOWNERS_LOCATIONS = [
1481
+ ".github/CODEOWNERS",
1482
+ "CODEOWNERS",
1483
+ "docs/CODEOWNERS"
1484
+ ];
1485
+ /** First existing location, GitHub search order. Null if none exist. */
1486
+ function findCodeownersFile(repoPath) {
1487
+ for (const location of CODEOWNERS_LOCATIONS) {
1488
+ const full = path$1.join(repoPath, location);
1489
+ if (fs$1.existsSync(full)) return full;
1490
+ }
1491
+ return null;
1492
+ }
1493
+ /** Parses content into entries; skips blank lines and # comments. */
1494
+ function parseCodeowners(content) {
1495
+ const entries = [];
1496
+ for (const rawLine of content.split(/\r?\n/)) {
1497
+ const line = rawLine.trim();
1498
+ if (!line || line.startsWith("#")) continue;
1499
+ const tokens = line.split(/\s+/);
1500
+ const pattern = tokens[0];
1501
+ const owners = tokens.slice(1);
1502
+ entries.push({
1503
+ pattern,
1504
+ globs: codeownersPatternToGlobs(pattern),
1505
+ owners
1506
+ });
1507
+ }
1508
+ return entries;
1509
+ }
1510
+ /**
1511
+ * Translates a CODEOWNERS (gitignore-style) pattern into micromatch glob(s).
1512
+ *
1513
+ * Rules:
1514
+ * - `*` alone matches everything.
1515
+ * - A leading `/`, or a `/` anywhere in the middle of the pattern, anchors
1516
+ * the match to the repo root (the leading `/` is stripped once anchored).
1517
+ * - A pattern with no anchoring gets a `**\/` prefix so it matches at any depth.
1518
+ * - A trailing `/` restricts the match to a directory, so `/**` is appended.
1519
+ * - A bare name (no glob characters, no slash at all) also matches as a
1520
+ * directory anywhere, so the `/**` directory variant is added alongside
1521
+ * the plain match.
1522
+ */
1523
+ function codeownersPatternToGlobs(pattern) {
1524
+ if (pattern === "*") return ["**"];
1525
+ let p = pattern;
1526
+ const hadLeadingSlash = p.startsWith("/");
1527
+ if (hadLeadingSlash) p = p.slice(1);
1528
+ const hadTrailingSlash = p.endsWith("/") && p.length > 1;
1529
+ if (hadTrailingSlash) p = p.slice(0, -1);
1530
+ const hasInternalSlash = p.includes("/");
1531
+ const anchored = hadLeadingSlash || hasInternalSlash;
1532
+ const hasGlobChars = /[*?[\]{}!()]/.test(p);
1533
+ const isBareName = !anchored && !hasGlobChars && !hadTrailingSlash;
1534
+ const base = anchored ? p : `**/${p}`;
1535
+ const globs = [hadTrailingSlash ? `${base}/**` : base];
1536
+ if (isBareName) globs.push(`${base}/**`);
1537
+ return globs;
1538
+ }
1539
+ /** Last matching entry wins; owned iff that entry has >= 1 owner. */
1540
+ function fileIsOwned(file, entries) {
1541
+ for (let i = entries.length - 1; i >= 0; i--) if (micromatch.isMatch(file, entries[i].globs, { dot: true })) return entries[i].owners.length > 0;
1542
+ return false;
1543
+ }
1544
+ function evaluateCodeowners(repoPath, rulesConfig, scannedFiles) {
1545
+ const rule = rulesConfig.codeowners;
1546
+ if (!rule) return [];
1547
+ const filePath = findCodeownersFile(repoPath);
1548
+ if (!filePath) return [{
1549
+ type: "codeowners",
1550
+ severity: rule.severity,
1551
+ patterns: CODEOWNERS_LOCATIONS,
1552
+ message: rule.message,
1553
+ matchedFiles: []
1554
+ }];
1555
+ const entries = parseCodeowners(fs$1.readFileSync(filePath, "utf-8"));
1556
+ const unowned = scannedFiles.map((f) => (path$1.isAbsolute(f) ? path$1.relative(repoPath, f) : f).replace(/\\/g, "/")).filter((f) => !fileIsOwned(f, entries));
1557
+ if (unowned.length === 0) return [];
1558
+ return [{
1559
+ type: "codeowners",
1560
+ severity: rule.severity,
1561
+ patterns: [path$1.basename(filePath)],
1562
+ message: rule.message,
1563
+ matchedFiles: unowned
1564
+ }];
1565
+ }
1566
+ //#endregion
1335
1567
  //#region src/rules/evaluator.ts
1336
- function evaluateRules(repoPath, rulesConfig, excludes) {
1568
+ function evaluateRules(repoPath, rulesConfig, excludes, scannedFiles = []) {
1337
1569
  return [
1338
1570
  ...evaluateFileRules(repoPath, rulesConfig, excludes),
1339
1571
  ...evaluateScriptRules(repoPath, rulesConfig),
1340
1572
  ...evaluatePackageFieldRules(repoPath, rulesConfig),
1341
- ...evaluateEngineVersion(repoPath, rulesConfig)
1573
+ ...evaluateEngineVersion(repoPath, rulesConfig),
1574
+ ...evaluateCodeowners(repoPath, rulesConfig, scannedFiles)
1342
1575
  ];
1343
1576
  }
1344
1577
  //#endregion
@@ -1456,14 +1689,16 @@ function aggregateReports(reports, versions = {}, config, multiVersions = {}) {
1456
1689
  for (const jsx of report.patterns.usage.jsx) {
1457
1690
  const key = jsx.component;
1458
1691
  const existing = componentUsageMap.get(key);
1459
- if (existing) existing.count++;
1460
- else {
1692
+ if (existing) {
1693
+ existing.count++;
1694
+ existing.files.add(report.filePath);
1695
+ } else {
1461
1696
  const source = findComponentSource(jsx.component, report, availablePackages);
1462
1697
  componentUsageMap.set(key, {
1463
1698
  name: jsx.component,
1464
1699
  source,
1465
1700
  count: 1,
1466
- files: /* @__PURE__ */ new Set()
1701
+ files: /* @__PURE__ */ new Set([report.filePath])
1467
1702
  });
1468
1703
  }
1469
1704
  }
@@ -1498,14 +1733,15 @@ function aggregateReports(reports, versions = {}, config, multiVersions = {}) {
1498
1733
  }
1499
1734
  //#endregion
1500
1735
  //#region src/utils/print-errors.ts
1501
- function printErrors(errors) {
1736
+ function printErrors(errors, isJson) {
1502
1737
  if (errors.length === 0) return;
1503
- console.log(chalk.yellow(`\n⚠ ${errors.length} file(s) failed to parse:`));
1738
+ const stream = isJson ? process.stderr : process.stdout;
1739
+ stream.write(chalk.yellow(`\n⚠ ${errors.length} file(s) failed to parse:\n`));
1504
1740
  for (const { file, message } of errors) {
1505
- console.log(chalk.yellow(` ${file}`));
1506
- console.log(chalk.gray(` ${message}`));
1741
+ stream.write(chalk.yellow(` ${file}\n`));
1742
+ stream.write(chalk.gray(` ${message}\n`));
1507
1743
  }
1508
- console.log("");
1744
+ stream.write("\n");
1509
1745
  }
1510
1746
  //#endregion
1511
1747
  //#region src/utils/file-utils.ts
@@ -1587,6 +1823,20 @@ var NpmLockfileAdapter = class {
1587
1823
  };
1588
1824
  //#endregion
1589
1825
  //#region src/lock-parser/patterns/pnpm.ts
1826
+ function parsePackageKey(rawKey) {
1827
+ const withoutPeerSuffix = (rawKey.startsWith("/") ? rawKey.slice(1) : rawKey).replace(/\(.*\)$/, "");
1828
+ const atMatch = withoutPeerSuffix.match(/^(.+)@(\d+\.\d+\.\d+[^/]*)$/);
1829
+ if (atMatch) return {
1830
+ name: atMatch[1],
1831
+ version: atMatch[2]
1832
+ };
1833
+ const slashMatch = withoutPeerSuffix.match(/^(.+?)\/(\d+\.\d+\.\d+.*)$/);
1834
+ if (slashMatch) return {
1835
+ name: slashMatch[1],
1836
+ version: slashMatch[2]
1837
+ };
1838
+ return null;
1839
+ }
1590
1840
  var PnpmLockfileAdapter = class {
1591
1841
  name = "pnpm";
1592
1842
  supportedVersions = [
@@ -1631,9 +1881,34 @@ var PnpmLockfileAdapter = class {
1631
1881
  return {};
1632
1882
  }
1633
1883
  }
1884
+ parseMultiVersion(lockFilePath) {
1885
+ try {
1886
+ const lockData = load(fs$1.readFileSync(lockFilePath, "utf8"));
1887
+ const versionSets = {};
1888
+ if (lockData.packages) Object.keys(lockData.packages).forEach((key) => {
1889
+ const parsed = parsePackageKey(key);
1890
+ if (!parsed) return;
1891
+ if (!versionSets[parsed.name]) versionSets[parsed.name] = /* @__PURE__ */ new Set();
1892
+ versionSets[parsed.name].add(parsed.version);
1893
+ });
1894
+ const result = {};
1895
+ for (const [pkg, versions] of Object.entries(versionSets)) result[pkg] = Array.from(versions).sort();
1896
+ return result;
1897
+ } catch {
1898
+ return {};
1899
+ }
1900
+ }
1634
1901
  };
1635
1902
  //#endregion
1636
1903
  //#region src/lock-parser/patterns/yarn.ts
1904
+ function extractPackageName(key) {
1905
+ if (key.startsWith("@")) {
1906
+ const match = key.match(/^(@[^@]+\/[^@]+)@/);
1907
+ return match ? match[1] : key;
1908
+ }
1909
+ const match = key.match(/^([^@]+)@/);
1910
+ return match ? match[1] : key;
1911
+ }
1637
1912
  var YarnLockfileAdapter = class {
1638
1913
  name = "yarn";
1639
1914
  supportedVersions = ["v1", "v2+"];
@@ -1651,15 +1926,8 @@ var YarnLockfileAdapter = class {
1651
1926
  }
1652
1927
  const versions = {};
1653
1928
  Object.entries(parsed.object).forEach(([key, value]) => {
1654
- let pkgName = key;
1655
- if (key.startsWith("@")) {
1656
- const match = key.match(/^(@[^@]+\/[^@]+)@/);
1657
- if (match) pkgName = match[1];
1658
- } else {
1659
- const match = key.match(/^([^@]+)@/);
1660
- if (match) pkgName = match[1];
1661
- }
1662
- if (value.version && (!versions[pkgName] || value.version)) versions[pkgName] = value.version;
1929
+ const pkgName = extractPackageName(key);
1930
+ if (value.version && !versions[pkgName]) versions[pkgName] = value.version;
1663
1931
  });
1664
1932
  return versions;
1665
1933
  } catch (error) {
@@ -1668,6 +1936,25 @@ var YarnLockfileAdapter = class {
1668
1936
  return {};
1669
1937
  }
1670
1938
  }
1939
+ parseMultiVersion(lockFilePath) {
1940
+ try {
1941
+ const content = fs$1.readFileSync(lockFilePath, "utf8");
1942
+ const parsed = lockfile.parse(content);
1943
+ if (parsed.type !== "success") return {};
1944
+ const versionSets = {};
1945
+ Object.entries(parsed.object).forEach(([key, value]) => {
1946
+ if (!value.version) return;
1947
+ const pkgName = extractPackageName(key);
1948
+ if (!versionSets[pkgName]) versionSets[pkgName] = /* @__PURE__ */ new Set();
1949
+ versionSets[pkgName].add(value.version);
1950
+ });
1951
+ const result = {};
1952
+ for (const [pkg, vers] of Object.entries(versionSets)) result[pkg] = Array.from(vers).sort();
1953
+ return result;
1954
+ } catch {
1955
+ return {};
1956
+ }
1957
+ }
1671
1958
  };
1672
1959
  //#endregion
1673
1960
  //#region src/lock-parser/index.ts
@@ -1724,6 +2011,53 @@ async function fetchPackageInfo(name, registryUrl, authToken) {
1724
2011
  }
1725
2012
  }
1726
2013
  //#endregion
2014
+ //#region src/npm-registry/cache.ts
2015
+ const DEFAULT_TTL_MS = 3600 * 1e3;
2016
+ const DEFAULT_CACHE_DIR = join(homedir(), ".hermex", "cache", "npm");
2017
+ function cachePathFor(registryUrl, packageName, options) {
2018
+ return join(options?.cacheDir ?? DEFAULT_CACHE_DIR, new URL(registryUrl).host.replace(/:/g, "_"), `${encodeURIComponent(packageName)}.json`);
2019
+ }
2020
+ async function readCache(registryUrl, packageName, options) {
2021
+ const ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS;
2022
+ try {
2023
+ const raw = await readFile(cachePathFor(registryUrl, packageName, options), "utf8");
2024
+ const entry = JSON.parse(raw);
2025
+ if (entry.registryUrl !== registryUrl || entry.packageName !== packageName) return null;
2026
+ if (Date.now() - entry.cachedAt >= ttlMs) return null;
2027
+ return entry.data;
2028
+ } catch {
2029
+ return null;
2030
+ }
2031
+ }
2032
+ async function writeCache(registryUrl, packageName, data, options) {
2033
+ const finalPath = cachePathFor(registryUrl, packageName, options);
2034
+ const tmpPath = `${finalPath}.tmp-${randomUUID()}`;
2035
+ try {
2036
+ await mkdir(dirname(finalPath), { recursive: true });
2037
+ await writeFile(tmpPath, JSON.stringify({
2038
+ cachedAt: Date.now(),
2039
+ registryUrl,
2040
+ packageName,
2041
+ data
2042
+ }), "utf8");
2043
+ await rename(tmpPath, finalPath);
2044
+ } catch {
2045
+ try {
2046
+ await unlink(tmpPath);
2047
+ } catch {}
2048
+ }
2049
+ }
2050
+ async function getPackageInfo(packageName, registryUrl, authToken, options) {
2051
+ const cacheEnabled = !authToken && !options?.disabled;
2052
+ if (cacheEnabled) {
2053
+ const cached = await readCache(registryUrl, packageName, options);
2054
+ if (cached) return cached;
2055
+ }
2056
+ const info = await fetchPackageInfo(packageName, registryUrl, authToken);
2057
+ if (info && cacheEnabled) await writeCache(registryUrl, packageName, info, options);
2058
+ return info;
2059
+ }
2060
+ //#endregion
1727
2061
  //#region src/npm-registry/enricher.ts
1728
2062
  const CONCURRENCY = 8;
1729
2063
  function daysSince(dateStr) {
@@ -1738,6 +2072,9 @@ function classifyBump(installed, candidate) {
1738
2072
  if (diff === "major" || diff === "premajor") return "major";
1739
2073
  return null;
1740
2074
  }
2075
+ function pickNewest(versions) {
2076
+ return versions.reduce((a, b) => a.daysAgo < b.daysAgo ? a : b);
2077
+ }
1741
2078
  function upgradeLevel(daysAgo, bump, thresholds) {
1742
2079
  const threshold = thresholds[bump];
1743
2080
  if (threshold === false || threshold === void 0) return null;
@@ -1751,6 +2088,7 @@ function computeReleaseAge(installedVersion, timeMap, deprecated, thresholds, di
1751
2088
  for (const [version, dateStr] of Object.entries(timeMap)) {
1752
2089
  if (version === "created" || version === "modified") continue;
1753
2090
  if (!semver.valid(version)) continue;
2091
+ if (semver.prerelease(version)) continue;
1754
2092
  if (semver.lte(version, installedVersion)) continue;
1755
2093
  const bump = classifyBump(installedVersion, version);
1756
2094
  if (!bump) continue;
@@ -1761,8 +2099,8 @@ function computeReleaseAge(installedVersion, timeMap, deprecated, thresholds, di
1761
2099
  daysAgo
1762
2100
  });
1763
2101
  byBump.set(bump, list);
1764
- const threshold = thresholds.patch;
1765
- if (bump === "patch" && threshold !== false && threshold !== void 0 && daysAgo <= threshold && (minCompliantReleasedDaysAgo === void 0 || daysAgo > minCompliantReleasedDaysAgo)) {
2102
+ const threshold = thresholds[bump];
2103
+ if (threshold !== false && threshold !== void 0 && daysAgo <= threshold && (minCompliantReleasedDaysAgo === void 0 || daysAgo > minCompliantReleasedDaysAgo)) {
1766
2104
  minCompliantVersion = version;
1767
2105
  minCompliantReleasedDaysAgo = daysAgo;
1768
2106
  }
@@ -1771,12 +2109,13 @@ function computeReleaseAge(installedVersion, timeMap, deprecated, thresholds, di
1771
2109
  for (const [bump, versions] of byBump.entries()) {
1772
2110
  const level = upgradeLevel(Math.max(...versions.map((v) => v.daysAgo)), bump, thresholds);
1773
2111
  if (!level) continue;
1774
- const newest = versions.reduce((a, b) => a.daysAgo < b.daysAgo ? a : b);
2112
+ const newest = pickNewest(versions);
1775
2113
  upgrades.push({
1776
2114
  version: newest.version,
1777
2115
  releasedDaysAgo: newest.daysAgo,
1778
2116
  semverBump: bump,
1779
- level
2117
+ level,
2118
+ thresholdDays: thresholds[bump]
1780
2119
  });
1781
2120
  }
1782
2121
  const finalUpgrades = upgrades.sort((a, b) => b.releasedDaysAgo - a.releasedDaysAgo);
@@ -1784,10 +2123,29 @@ function computeReleaseAge(installedVersion, timeMap, deprecated, thresholds, di
1784
2123
  const latestEntry = latestVersion ? timeMap[latestVersion] : void 0;
1785
2124
  const latestReleasedDaysAgo = latestEntry ? daysSince(latestEntry) : void 0;
1786
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
+ }
1787
2144
  return {
1788
2145
  installedVersion,
1789
2146
  upgrades: finalUpgrades,
1790
- worstLevel: finalUpgrades.some((u) => u.level === "mandatory_upgrade") ? "mandatory_upgrade" : finalUpgrades.length > 0 ? "needs_upgrade" : null,
2147
+ worstLevel,
2148
+ pendingUpgrade,
1791
2149
  deprecated,
1792
2150
  latestVersion,
1793
2151
  latestReleasedDaysAgo,
@@ -1802,10 +2160,15 @@ async function enrichWithReleaseAge(packages, config) {
1802
2160
  const targets = packages.filter((p) => !p.internal && p.version);
1803
2161
  const enriched = [...packages];
1804
2162
  let skipped = 0;
2163
+ const envTtl = Number(process.env["HERMEX_REGISTRY_CACHE_TTL_MS"]);
2164
+ const cacheOptions = {
2165
+ ttlMs: Number.isFinite(envTtl) && envTtl > 0 ? envTtl : config.cacheTtlMs,
2166
+ disabled: process.env["HERMEX_REGISTRY_CACHE_DISABLED"] === "1" || config.cacheDisabled === true
2167
+ };
1805
2168
  for (let i = 0; i < targets.length; i += CONCURRENCY) {
1806
2169
  const batch = targets.slice(i, i + CONCURRENCY);
1807
2170
  const results = await Promise.all(batch.map(async (pkg) => {
1808
- const info = await fetchPackageInfo(pkg.packageName, registryUrl, authToken);
2171
+ const info = await getPackageInfo(pkg.packageName, registryUrl, authToken, cacheOptions);
1809
2172
  if (!info || !info.time) {
1810
2173
  skipped++;
1811
2174
  return {
@@ -1836,16 +2199,20 @@ async function enrichWithReleaseAge(packages, config) {
1836
2199
  }
1837
2200
  //#endregion
1838
2201
  //#region src/commands/pipeline.ts
2202
+ const DECLARATION_FILE_RE = /\.d\.(ts|mts|cts)$/;
2203
+ function isDeclarationFile(filePath) {
2204
+ return DECLARATION_FILE_RE.test(filePath);
2205
+ }
1839
2206
  /**
1840
2207
  * Runs the shared parse → aggregate → rules → release-age pipeline used by
1841
2208
  * both `scan` and `comply`. Returns `null` if no files matched (the spinner
1842
2209
  * has already reported the failure); throws on unexpected errors.
1843
2210
  */
1844
- async function runPipeline(config, spinner) {
2211
+ async function runPipeline(config, spinner, isJson) {
1845
2212
  const lockfileResult = findAndParseLockfile(process.cwd());
1846
2213
  spinner.succeed(chalk.blue(`šŸ“¦ Found ${lockfileResult.lockfileType} lockfile (supports: ${lockfileResult.supportedVersions.join(", ")}) - ${Object.keys(lockfileResult.versions).length} packages`));
1847
2214
  spinner.start("Finding files...");
1848
- const files = await findFiles(config.includes, config.excludes);
2215
+ const files = (await findFiles(config.includes, config.excludes)).filter((f) => !isDeclarationFile(f));
1849
2216
  if (files.length === 0) {
1850
2217
  spinner.fail(chalk.red(`No files found matching includes: ${config.includes.join(", ")}`));
1851
2218
  return null;
@@ -1869,9 +2236,9 @@ async function runPipeline(config, spinner) {
1869
2236
  }
1870
2237
  }
1871
2238
  spinner.succeed(chalk.green(`Analysis complete! Analyzed ${reports.length}/${files.length} files`));
1872
- printErrors(parseErrors);
2239
+ printErrors(parseErrors, isJson);
1873
2240
  const aggregated = aggregateReports(reports, lockfileResult.versions, config, lockfileResult.multiVersions);
1874
- const evaluatorViolations = evaluateRules(process.cwd(), config.rules, config.excludes);
2241
+ const evaluatorViolations = evaluateRules(process.cwd(), config.rules, config.excludes, files);
1875
2242
  aggregated.ruleViolations = [...aggregated.ruleViolations, ...evaluatorViolations];
1876
2243
  if (config.releaseAge.enabled) {
1877
2244
  spinner.start("Fetching release age from registry...");
@@ -1882,29 +2249,48 @@ async function runPipeline(config, spinner) {
1882
2249
  return aggregated;
1883
2250
  }
1884
2251
  //#endregion
2252
+ //#region src/commands/command-context.ts
2253
+ /**
2254
+ * Shared command preamble: routes human-readable chrome (version line,
2255
+ * spinner) to stderr when the command emits JSON on stdout.
2256
+ */
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";
2263
+ const stream = isJson ? process.stderr : process.stdout;
2264
+ stream.write(chalk.gray(`hermex v${getVersion()}\n`));
2265
+ return {
2266
+ isJson,
2267
+ spinner: ora({
2268
+ text: "Parsing lockfile...",
2269
+ stream
2270
+ }).start()
2271
+ };
2272
+ }
2273
+ //#endregion
1885
2274
  //#region src/commands/scan.ts
1886
2275
  function registerScanCommand(program) {
1887
- program.command("scan").description("Scan and analyze local files").option("--config <path>", "Path to hermex config file (overrides CWD discovery)").action(async (options) => {
1888
- 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
+ });
1889
2281
  });
1890
2282
  }
1891
- async function executeScan(config) {
1892
- const isJson = config.output.format === "json";
1893
- (isJson ? process.stderr : process.stdout).write(chalk.gray(`hermex v${getVersion()}\n`));
1894
- const spinner = ora({
1895
- text: "Parsing lockfile...",
1896
- stream: isJson ? process.stderr : process.stdout
1897
- }).start();
2283
+ async function executeScan(config, contextOptions = {}) {
2284
+ const { isJson, spinner } = createCommandContext(config, contextOptions);
1898
2285
  try {
1899
- const aggregated = await runPipeline(config, spinner);
2286
+ const aggregated = await runPipeline(config, spinner, isJson);
1900
2287
  if (!aggregated) return;
1901
2288
  if (isJson) printJson(aggregated);
1902
2289
  else printScanResults(aggregated, config);
1903
2290
  } catch (error) {
1904
2291
  const message = error instanceof Error ? error.message : String(error);
1905
2292
  spinner.fail(chalk.red("Analysis failed: " + message));
1906
- console.error(error);
1907
- process.exit(1);
2293
+ process.exitCode = 1;
1908
2294
  }
1909
2295
  }
1910
2296
  function printScanResults(aggregated, config) {
@@ -1918,21 +2304,19 @@ function printScanResults(aggregated, config) {
1918
2304
  }
1919
2305
  //#endregion
1920
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
+ */
1921
2312
  function printComplianceVerdict(result) {
1922
2313
  const mandatoryCount = result.errorRuleViolations.length + result.errorBannedPackageViolations.length + result.mandatoryReleaseAgeViolations.length;
1923
2314
  if (result.compliant) {
1924
- console.log(chalk.greenBright.bold("\nāœ“ COMPLIANT\n"));
2315
+ console.log(chalk.greenBright.bold(`\n${severityIcon("success")} COMPLIANT\n`));
1925
2316
  return;
1926
2317
  }
1927
- console.log(chalk.redBright.bold(`\nāœ— NOT COMPLIANT`));
2318
+ console.log(chalk.redBright.bold(`\n${severityIcon("error")} NOT COMPLIANT`));
1928
2319
  console.log(chalk.red(` ${mandatoryCount} mandatory violation${mandatoryCount > 1 ? "s" : ""} found`));
1929
- if (result.mandatoryReleaseAgeViolations.length > 0) {
1930
- console.log();
1931
- for (const pkg of result.mandatoryReleaseAgeViolations) {
1932
- const top = pkg.releaseAge?.upgrades[0];
1933
- console.log(chalk.red(` āœ— releaseAge ${pkg.packageName} is ${top?.semverBump} version behind (${top?.releasedDaysAgo}d) — mandatory upgrade [ERROR]`));
1934
- }
1935
- }
1936
2320
  console.log();
1937
2321
  }
1938
2322
  //#endregion
@@ -1958,19 +2342,17 @@ function computeCompliance(aggregated) {
1958
2342
  //#endregion
1959
2343
  //#region src/commands/comply.ts
1960
2344
  function registerComplyCommand(program) {
1961
- 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) => {
1962
- 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
+ });
1963
2350
  });
1964
2351
  }
1965
- async function executeComply(config) {
1966
- const isJson = config.output.format === "json";
1967
- (isJson ? process.stderr : process.stdout).write(chalk.gray(`hermex v${getVersion()}\n`));
1968
- const spinner = ora({
1969
- text: "Parsing lockfile...",
1970
- stream: isJson ? process.stderr : process.stdout
1971
- }).start();
2352
+ async function executeComply(config, contextOptions = {}) {
2353
+ const { isJson, spinner } = createCommandContext(config, contextOptions);
1972
2354
  try {
1973
- const aggregated = await runPipeline(config, spinner);
2355
+ const aggregated = await runPipeline(config, spinner, isJson);
1974
2356
  if (!aggregated) {
1975
2357
  process.exitCode = 2;
1976
2358
  return;
@@ -1980,13 +2362,13 @@ async function executeComply(config) {
1980
2362
  else {
1981
2363
  printRules(aggregated);
1982
2364
  if (config.releaseAge.enabled) printPackages(aggregated, "table");
2365
+ if (config.output.versus) printVersus(aggregated);
1983
2366
  printComplianceVerdict(compliance);
1984
2367
  }
1985
2368
  process.exitCode = compliance.compliant ? 0 : 1;
1986
2369
  } catch (error) {
1987
2370
  const message = error instanceof Error ? error.message : String(error);
1988
2371
  spinner.fail(chalk.red("Compliance check failed: " + message));
1989
- console.error(error);
1990
2372
  process.exitCode = 2;
1991
2373
  }
1992
2374
  }