hermex 2.0.0-beta.6 → 2.0.0-beta.8

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
@@ -302,7 +302,9 @@ function formatRuleType(type) {
302
302
  case "require_packages": return "require_packages";
303
303
  case "require_scripts": return "require_scripts";
304
304
  case "require_package_fields": return "pkg_fields";
305
+ case "forbid_package_fields": return "pkg_fields";
305
306
  case "engine_version": return "engine_version";
307
+ case "codeowners": return "codeowners";
306
308
  }
307
309
  }
308
310
  function ruleIcon(violation) {
@@ -321,11 +323,21 @@ function describeViolation(v) {
321
323
  if (v.type === "require_packages") return `${patterns} not installed${suffix}`;
322
324
  if (v.type === "forbid_packages") return `${patterns} is forbidden${suffix}`;
323
325
  if (v.type === "require_scripts") return `script ${patterns} missing in package.json${suffix}`;
324
- if (v.type === "require_package_fields") return `field ${patterns} missing in package.json${suffix}`;
326
+ if (v.type === "require_package_fields") {
327
+ if (v.fieldPath && v.actualValue !== void 0) return `field ${v.fieldPath} is ${chalk.yellow(v.actualValue)}, does not match required value${suffix}`;
328
+ return `field ${patterns} missing in package.json${suffix}`;
329
+ }
330
+ if (v.type === "forbid_package_fields") return `field ${v.fieldPath ?? patterns} is forbidden in package.json${suffix}`;
325
331
  if (v.type === "engine_version") {
326
332
  if (!v.installedRange) return `engines.node not specified (required ${v.requiredRange})${suffix}`;
327
333
  return `engines.node is ${chalk.yellow(v.installedRange)}, required ${chalk.cyan(v.requiredRange)}${suffix}`;
328
334
  }
335
+ if (v.type === "codeowners") {
336
+ 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}`;
340
+ }
329
341
  return `${patterns} not present${suffix}`;
330
342
  }
331
343
  function printRules(aggregated) {
@@ -420,11 +432,19 @@ const RuleConfigSchema = z.object({
420
432
  message: z.string().optional()
421
433
  });
422
434
  const RuleConfigOrArraySchema = z.union([RuleConfigSchema, z.array(RuleConfigSchema)]);
435
+ const PackageFieldRuleSchema = RuleConfigSchema.extend({
436
+ /** Optional micromatch patterns the field's stringified value must match */
437
+ values: z.array(z.string()).optional() });
438
+ const PackageFieldRuleOrArraySchema = z.union([PackageFieldRuleSchema, z.array(PackageFieldRuleSchema)]);
423
439
  const EngineVersionRuleSchema = z.object({
424
440
  severity: RuleSeveritySchema,
425
441
  range: z.string(),
426
442
  message: z.string().optional()
427
443
  });
444
+ const CodeownersRuleSchema = z.object({
445
+ severity: RuleSeveritySchema,
446
+ message: z.string().optional()
447
+ });
428
448
  const ThresholdSchema = z.union([z.number(), z.literal(false)]);
429
449
  const HermexConfigSchema = z.object({
430
450
  includes: z.array(z.string()).default(["**/*.{tsx,jsx,ts,js}"]),
@@ -450,15 +470,18 @@ const HermexConfigSchema = z.object({
450
470
  forbid_packages: RuleConfigOrArraySchema.default([]),
451
471
  require_packages: RuleConfigOrArraySchema.default([]),
452
472
  require_scripts: RuleConfigOrArraySchema.default([]),
453
- require_package_fields: RuleConfigOrArraySchema.default([]),
454
- engine_version: z.union([EngineVersionRuleSchema, z.array(EngineVersionRuleSchema)]).optional()
473
+ require_package_fields: PackageFieldRuleOrArraySchema.default([]),
474
+ forbid_package_fields: PackageFieldRuleOrArraySchema.default([]),
475
+ engine_version: z.union([EngineVersionRuleSchema, z.array(EngineVersionRuleSchema)]).optional(),
476
+ codeowners: CodeownersRuleSchema.optional()
455
477
  }).default(() => ({
456
478
  detect_files: [],
457
479
  require_files: [],
458
480
  forbid_packages: [],
459
481
  require_packages: [],
460
482
  require_scripts: [],
461
- require_package_fields: []
483
+ require_package_fields: [],
484
+ forbid_package_fields: []
462
485
  })),
463
486
  output: z.object({
464
487
  summary: z.union([z.literal("log"), z.literal(false)]).default("log"),
@@ -870,10 +893,10 @@ function analyzeConditionalExpression(node, state) {
870
893
  */
871
894
  function analyzeArrayExpression(node, state) {
872
895
  if (node.elements?.some((elem) => {
873
- if (elem?.type === "Identifier") return state.componentNames.has(elem.value);
896
+ if (elem?.expression?.type === "Identifier") return state.componentNames.has(elem.expression.value);
874
897
  return false;
875
898
  })) state.usagePatterns.arrayMappings.add({
876
- components: node.elements?.map((elem) => elem?.value).filter(Boolean),
899
+ components: node.elements?.map((elem) => elem?.expression?.value).filter(Boolean),
877
900
  line: node.span?.start || 0
878
901
  });
879
902
  }
@@ -899,11 +922,11 @@ function analyzeObjectExpression(node, state) {
899
922
  * Analyzes React.lazy() imports
900
923
  */
901
924
  function analyzeLazyImport(node, state) {
902
- const arg = node.arguments?.[0];
925
+ const arg = node.arguments?.[0]?.expression;
903
926
  if (arg?.type === "ArrowFunctionExpression" && arg.body?.type === "CallExpression") {
904
927
  const importCall = arg.body;
905
928
  if (importCall.callee?.type === "Import") {
906
- const source = importCall.arguments?.[0]?.value;
929
+ const source = importCall.arguments?.[0]?.expression?.value;
907
930
  if (source) state.usagePatterns.lazyImports.add({
908
931
  source,
909
932
  line: node.span?.start || 0
@@ -915,7 +938,7 @@ function analyzeLazyImport(node, state) {
915
938
  * Analyzes dynamic import() calls
916
939
  */
917
940
  function analyzeDynamicImport(node, state) {
918
- const source = node.arguments?.[0]?.value;
941
+ const source = node.arguments?.[0]?.expression?.value;
919
942
  if (source) state.usagePatterns.dynamicImports.add({
920
943
  source,
921
944
  line: node.span?.start || 0
@@ -929,7 +952,7 @@ function analyzeDynamicImport(node, state) {
929
952
  function analyzeHOCUsage(node, state) {
930
953
  state.usagePatterns.hocUsage.add({
931
954
  function: node.callee?.value || "[unknown]",
932
- component: node.arguments?.[0]?.value || "[unknown]",
955
+ component: node.arguments?.[0]?.expression?.value || "[unknown]",
933
956
  line: node.span?.start || 0
934
957
  });
935
958
  }
@@ -937,7 +960,7 @@ function analyzeHOCUsage(node, state) {
937
960
  * Analyzes React.memo() usage
938
961
  */
939
962
  function analyzeMemoUsage(node, state) {
940
- const component = node.arguments?.[0];
963
+ const component = node.arguments?.[0]?.expression;
941
964
  if (component?.type === "Identifier" && state.componentNames.has(component.value)) state.usagePatterns.memoizedComponents.add({
942
965
  component: component.value,
943
966
  line: node.span?.start || 0
@@ -968,7 +991,7 @@ function analyzeMemberExpression(node, state) {
968
991
  * Checks if a node represents HOC pattern
969
992
  */
970
993
  function isHOCPattern(node, state) {
971
- return node.callee?.type === "Identifier" && node.arguments?.some((arg) => arg.type === "Identifier" && state.componentNames.has(arg.value));
994
+ return node.callee?.type === "Identifier" && node.arguments?.some((arg) => arg.expression?.type === "Identifier" && state.componentNames.has(arg.expression.value));
972
995
  }
973
996
  //#endregion
974
997
  //#region src/swc-parser/core/visitor.ts
@@ -1297,18 +1320,63 @@ function evaluateScriptRules(repoPath, rulesConfig) {
1297
1320
  }
1298
1321
  //#endregion
1299
1322
  //#region src/rules/package-field-rules.ts
1323
+ /** Resolves a dot-path like "engines.node" against the manifest object. */
1324
+ function getFieldAtPath(pkg, path) {
1325
+ let current = pkg;
1326
+ for (const key of path.split(".")) {
1327
+ if (current === null || typeof current !== "object" || !(key in current)) return {
1328
+ exists: false,
1329
+ value: void 0
1330
+ };
1331
+ current = current[key];
1332
+ }
1333
+ return {
1334
+ exists: true,
1335
+ value: current
1336
+ };
1337
+ }
1338
+ /** Primitives compare by string form; objects/arrays never match a value pattern. */
1339
+ function valueMatches(value, valuePatterns) {
1340
+ if (value === null || typeof value === "object") return false;
1341
+ return micromatch.isMatch(String(value), valuePatterns);
1342
+ }
1300
1343
  function evaluatePackageFieldRules(repoPath, rulesConfig) {
1301
- const rules = toArray(rulesConfig.require_package_fields);
1302
- if (rules.length === 0) return [];
1344
+ const requireRules = toArray(rulesConfig.require_package_fields);
1345
+ const forbidRules = toArray(rulesConfig.forbid_package_fields);
1346
+ if (requireRules.length === 0 && forbidRules.length === 0) return [];
1303
1347
  const pkg = readPackageJson(repoPath);
1304
- const fieldKeys = pkg ? Object.keys(pkg) : [];
1305
- return rules.filter((rule) => !rule.patterns.some((p) => fieldKeys.includes(p))).map((rule) => ({
1306
- type: "require_package_fields",
1307
- severity: rule.severity,
1308
- patterns: rule.patterns,
1309
- message: rule.message,
1310
- matchedFiles: []
1311
- }));
1348
+ const violations = [];
1349
+ for (const rule of requireRules) {
1350
+ const lookups = rule.patterns.map((p) => ({
1351
+ path: p,
1352
+ ...getFieldAtPath(pkg, p)
1353
+ }));
1354
+ if (!lookups.some((l) => l.exists && (!rule.values || valueMatches(l.value, rule.values)))) {
1355
+ const mismatch = rule.values ? lookups.find((l) => l.exists) : void 0;
1356
+ violations.push({
1357
+ type: "require_package_fields",
1358
+ severity: rule.severity,
1359
+ patterns: rule.patterns,
1360
+ message: rule.message,
1361
+ matchedFiles: [],
1362
+ fieldPath: mismatch?.path,
1363
+ actualValue: mismatch && typeof mismatch.value !== "object" ? String(mismatch.value) : void 0
1364
+ });
1365
+ }
1366
+ }
1367
+ for (const rule of forbidRules) for (const pattern of rule.patterns) {
1368
+ const lookup = getFieldAtPath(pkg, pattern);
1369
+ if (lookup.exists && (!rule.values || valueMatches(lookup.value, rule.values))) violations.push({
1370
+ type: "forbid_package_fields",
1371
+ severity: rule.severity,
1372
+ patterns: rule.patterns,
1373
+ message: rule.message,
1374
+ matchedFiles: [],
1375
+ fieldPath: pattern,
1376
+ actualValue: lookup.value !== null && typeof lookup.value !== "object" ? String(lookup.value) : void 0
1377
+ });
1378
+ }
1379
+ return violations;
1312
1380
  }
1313
1381
  //#endregion
1314
1382
  //#region src/rules/engine-version.ts
@@ -1339,13 +1407,102 @@ function evaluateEngineVersion(repoPath, rulesConfig) {
1339
1407
  });
1340
1408
  }
1341
1409
  //#endregion
1410
+ //#region src/rules/codeowners.ts
1411
+ const CODEOWNERS_LOCATIONS = [
1412
+ ".github/CODEOWNERS",
1413
+ "CODEOWNERS",
1414
+ "docs/CODEOWNERS"
1415
+ ];
1416
+ /** First existing location, GitHub search order. Null if none exist. */
1417
+ function findCodeownersFile(repoPath) {
1418
+ for (const location of CODEOWNERS_LOCATIONS) {
1419
+ const full = path$1.join(repoPath, location);
1420
+ if (fs$1.existsSync(full)) return full;
1421
+ }
1422
+ return null;
1423
+ }
1424
+ /** Parses content into entries; skips blank lines and # comments. */
1425
+ function parseCodeowners(content) {
1426
+ const entries = [];
1427
+ for (const rawLine of content.split(/\r?\n/)) {
1428
+ const line = rawLine.trim();
1429
+ if (!line || line.startsWith("#")) continue;
1430
+ const tokens = line.split(/\s+/);
1431
+ const pattern = tokens[0];
1432
+ const owners = tokens.slice(1);
1433
+ entries.push({
1434
+ pattern,
1435
+ globs: codeownersPatternToGlobs(pattern),
1436
+ owners
1437
+ });
1438
+ }
1439
+ return entries;
1440
+ }
1441
+ /**
1442
+ * Translates a CODEOWNERS (gitignore-style) pattern into micromatch glob(s).
1443
+ *
1444
+ * Rules:
1445
+ * - `*` alone matches everything.
1446
+ * - A leading `/`, or a `/` anywhere in the middle of the pattern, anchors
1447
+ * the match to the repo root (the leading `/` is stripped once anchored).
1448
+ * - A pattern with no anchoring gets a `**\/` prefix so it matches at any depth.
1449
+ * - A trailing `/` restricts the match to a directory, so `/**` is appended.
1450
+ * - A bare name (no glob characters, no slash at all) also matches as a
1451
+ * directory anywhere, so the `/**` directory variant is added alongside
1452
+ * the plain match.
1453
+ */
1454
+ function codeownersPatternToGlobs(pattern) {
1455
+ if (pattern === "*") return ["**"];
1456
+ let p = pattern;
1457
+ const hadLeadingSlash = p.startsWith("/");
1458
+ if (hadLeadingSlash) p = p.slice(1);
1459
+ const hadTrailingSlash = p.endsWith("/") && p.length > 1;
1460
+ if (hadTrailingSlash) p = p.slice(0, -1);
1461
+ const hasInternalSlash = p.includes("/");
1462
+ const anchored = hadLeadingSlash || hasInternalSlash;
1463
+ const hasGlobChars = /[*?[\]{}!()]/.test(p);
1464
+ const isBareName = !anchored && !hasGlobChars && !hadTrailingSlash;
1465
+ const base = anchored ? p : `**/${p}`;
1466
+ const globs = [hadTrailingSlash ? `${base}/**` : base];
1467
+ if (isBareName) globs.push(`${base}/**`);
1468
+ return globs;
1469
+ }
1470
+ /** Last matching entry wins; owned iff that entry has >= 1 owner. */
1471
+ function fileIsOwned(file, entries) {
1472
+ for (let i = entries.length - 1; i >= 0; i--) if (micromatch.isMatch(file, entries[i].globs, { dot: true })) return entries[i].owners.length > 0;
1473
+ return false;
1474
+ }
1475
+ function evaluateCodeowners(repoPath, rulesConfig, scannedFiles) {
1476
+ const rule = rulesConfig.codeowners;
1477
+ if (!rule) return [];
1478
+ const filePath = findCodeownersFile(repoPath);
1479
+ if (!filePath) return [{
1480
+ type: "codeowners",
1481
+ severity: rule.severity,
1482
+ patterns: CODEOWNERS_LOCATIONS,
1483
+ message: rule.message,
1484
+ matchedFiles: []
1485
+ }];
1486
+ const entries = parseCodeowners(fs$1.readFileSync(filePath, "utf-8"));
1487
+ const unowned = scannedFiles.map((f) => (path$1.isAbsolute(f) ? path$1.relative(repoPath, f) : f).replace(/\\/g, "/")).filter((f) => !fileIsOwned(f, entries));
1488
+ if (unowned.length === 0) return [];
1489
+ return [{
1490
+ type: "codeowners",
1491
+ severity: rule.severity,
1492
+ patterns: [path$1.basename(filePath)],
1493
+ message: rule.message,
1494
+ matchedFiles: unowned
1495
+ }];
1496
+ }
1497
+ //#endregion
1342
1498
  //#region src/rules/evaluator.ts
1343
- function evaluateRules(repoPath, rulesConfig, excludes) {
1499
+ function evaluateRules(repoPath, rulesConfig, excludes, scannedFiles = []) {
1344
1500
  return [
1345
1501
  ...evaluateFileRules(repoPath, rulesConfig, excludes),
1346
1502
  ...evaluateScriptRules(repoPath, rulesConfig),
1347
1503
  ...evaluatePackageFieldRules(repoPath, rulesConfig),
1348
- ...evaluateEngineVersion(repoPath, rulesConfig)
1504
+ ...evaluateEngineVersion(repoPath, rulesConfig),
1505
+ ...evaluateCodeowners(repoPath, rulesConfig, scannedFiles)
1349
1506
  ];
1350
1507
  }
1351
1508
  //#endregion
@@ -1507,14 +1664,15 @@ function aggregateReports(reports, versions = {}, config, multiVersions = {}) {
1507
1664
  }
1508
1665
  //#endregion
1509
1666
  //#region src/utils/print-errors.ts
1510
- function printErrors(errors) {
1667
+ function printErrors(errors, isJson) {
1511
1668
  if (errors.length === 0) return;
1512
- console.log(chalk.yellow(`\n⚠ ${errors.length} file(s) failed to parse:`));
1669
+ const stream = isJson ? process.stderr : process.stdout;
1670
+ stream.write(chalk.yellow(`\n⚠ ${errors.length} file(s) failed to parse:\n`));
1513
1671
  for (const { file, message } of errors) {
1514
- console.log(chalk.yellow(` ${file}`));
1515
- console.log(chalk.gray(` ${message}`));
1672
+ stream.write(chalk.yellow(` ${file}\n`));
1673
+ stream.write(chalk.gray(` ${message}\n`));
1516
1674
  }
1517
- console.log("");
1675
+ stream.write("\n");
1518
1676
  }
1519
1677
  //#endregion
1520
1678
  //#region src/utils/file-utils.ts
@@ -1869,8 +2027,8 @@ function computeReleaseAge(installedVersion, timeMap, deprecated, thresholds, di
1869
2027
  daysAgo
1870
2028
  });
1871
2029
  byBump.set(bump, list);
1872
- const threshold = thresholds.patch;
1873
- if (bump === "patch" && threshold !== false && threshold !== void 0 && daysAgo <= threshold && (minCompliantReleasedDaysAgo === void 0 || daysAgo > minCompliantReleasedDaysAgo)) {
2030
+ const threshold = thresholds[bump];
2031
+ if (threshold !== false && threshold !== void 0 && daysAgo <= threshold && (minCompliantReleasedDaysAgo === void 0 || daysAgo > minCompliantReleasedDaysAgo)) {
1874
2032
  minCompliantVersion = version;
1875
2033
  minCompliantReleasedDaysAgo = daysAgo;
1876
2034
  }
@@ -1949,16 +2107,20 @@ async function enrichWithReleaseAge(packages, config) {
1949
2107
  }
1950
2108
  //#endregion
1951
2109
  //#region src/commands/pipeline.ts
2110
+ const DECLARATION_FILE_RE = /\.d\.(ts|mts|cts)$/;
2111
+ function isDeclarationFile(filePath) {
2112
+ return DECLARATION_FILE_RE.test(filePath);
2113
+ }
1952
2114
  /**
1953
2115
  * Runs the shared parse → aggregate → rules → release-age pipeline used by
1954
2116
  * both `scan` and `comply`. Returns `null` if no files matched (the spinner
1955
2117
  * has already reported the failure); throws on unexpected errors.
1956
2118
  */
1957
- async function runPipeline(config, spinner) {
2119
+ async function runPipeline(config, spinner, isJson) {
1958
2120
  const lockfileResult = findAndParseLockfile(process.cwd());
1959
2121
  spinner.succeed(chalk.blue(`📦 Found ${lockfileResult.lockfileType} lockfile (supports: ${lockfileResult.supportedVersions.join(", ")}) - ${Object.keys(lockfileResult.versions).length} packages`));
1960
2122
  spinner.start("Finding files...");
1961
- const files = await findFiles(config.includes, config.excludes);
2123
+ const files = (await findFiles(config.includes, config.excludes)).filter((f) => !isDeclarationFile(f));
1962
2124
  if (files.length === 0) {
1963
2125
  spinner.fail(chalk.red(`No files found matching includes: ${config.includes.join(", ")}`));
1964
2126
  return null;
@@ -1982,9 +2144,9 @@ async function runPipeline(config, spinner) {
1982
2144
  }
1983
2145
  }
1984
2146
  spinner.succeed(chalk.green(`Analysis complete! Analyzed ${reports.length}/${files.length} files`));
1985
- printErrors(parseErrors);
2147
+ printErrors(parseErrors, isJson);
1986
2148
  const aggregated = aggregateReports(reports, lockfileResult.versions, config, lockfileResult.multiVersions);
1987
- const evaluatorViolations = evaluateRules(process.cwd(), config.rules, config.excludes);
2149
+ const evaluatorViolations = evaluateRules(process.cwd(), config.rules, config.excludes, files);
1988
2150
  aggregated.ruleViolations = [...aggregated.ruleViolations, ...evaluatorViolations];
1989
2151
  if (config.releaseAge.enabled) {
1990
2152
  spinner.start("Fetching release age from registry...");
@@ -2022,7 +2184,7 @@ function registerScanCommand(program) {
2022
2184
  async function executeScan(config) {
2023
2185
  const { isJson, spinner } = createCommandContext(config);
2024
2186
  try {
2025
- const aggregated = await runPipeline(config, spinner);
2187
+ const aggregated = await runPipeline(config, spinner, isJson);
2026
2188
  if (!aggregated) return;
2027
2189
  if (isJson) printJson(aggregated);
2028
2190
  else printScanResults(aggregated, config);
@@ -2090,7 +2252,7 @@ function registerComplyCommand(program) {
2090
2252
  async function executeComply(config) {
2091
2253
  const { isJson, spinner } = createCommandContext(config);
2092
2254
  try {
2093
- const aggregated = await runPipeline(config, spinner);
2255
+ const aggregated = await runPipeline(config, spinner, isJson);
2094
2256
  if (!aggregated) {
2095
2257
  process.exitCode = 2;
2096
2258
  return;