hermex 2.0.0-beta.5 → 2.0.0-beta.7

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,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import ora from "ora";
4
3
  import chalk from "chalk";
5
4
  import Table from "cli-table3";
6
5
  import fs, { existsSync, readFileSync } from "node:fs";
@@ -18,6 +17,7 @@ import lockfile from "@yarnpkg/lockfile";
18
17
  import { randomUUID } from "node:crypto";
19
18
  import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
20
19
  import { homedir } from "node:os";
20
+ import ora from "ora";
21
21
  //#region src/utils/format-utils.ts
22
22
  /**
23
23
  * Format a number with thousand separators
@@ -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
@@ -1075,8 +1098,9 @@ function visitChildren(node, state, context) {
1075
1098
  /**
1076
1099
  * Generates a comprehensive usage report from parser state
1077
1100
  */
1078
- function generateReport(state) {
1101
+ function generateReport(state, filePath) {
1079
1102
  return {
1103
+ filePath,
1080
1104
  summary: {
1081
1105
  totalImports: state.usagePatterns.defaultImports.size + state.usagePatterns.namedImports.size + state.usagePatterns.namespaceImports.size,
1082
1106
  totalComponents: state.componentNames.size,
@@ -1157,7 +1181,7 @@ function swcOptionsForFile(filePath) {
1157
1181
  function parseCode(code, filePath = "file.tsx") {
1158
1182
  const state = createState();
1159
1183
  visitNode(parseSync(code, swcOptionsForFile(filePath)), state);
1160
- return generateReport(state);
1184
+ return generateReport(state, filePath);
1161
1185
  }
1162
1186
  function parseFile(filePath) {
1163
1187
  return parseCode(fs.readFileSync(filePath, "utf8"), filePath);
@@ -1296,18 +1320,63 @@ function evaluateScriptRules(repoPath, rulesConfig) {
1296
1320
  }
1297
1321
  //#endregion
1298
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
+ }
1299
1343
  function evaluatePackageFieldRules(repoPath, rulesConfig) {
1300
- const rules = toArray(rulesConfig.require_package_fields);
1301
- 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 [];
1302
1347
  const pkg = readPackageJson(repoPath);
1303
- const fieldKeys = pkg ? Object.keys(pkg) : [];
1304
- return rules.filter((rule) => !rule.patterns.some((p) => fieldKeys.includes(p))).map((rule) => ({
1305
- type: "require_package_fields",
1306
- severity: rule.severity,
1307
- patterns: rule.patterns,
1308
- message: rule.message,
1309
- matchedFiles: []
1310
- }));
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;
1311
1380
  }
1312
1381
  //#endregion
1313
1382
  //#region src/rules/engine-version.ts
@@ -1338,13 +1407,102 @@ function evaluateEngineVersion(repoPath, rulesConfig) {
1338
1407
  });
1339
1408
  }
1340
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
1341
1498
  //#region src/rules/evaluator.ts
1342
- function evaluateRules(repoPath, rulesConfig, excludes) {
1499
+ function evaluateRules(repoPath, rulesConfig, excludes, scannedFiles = []) {
1343
1500
  return [
1344
1501
  ...evaluateFileRules(repoPath, rulesConfig, excludes),
1345
1502
  ...evaluateScriptRules(repoPath, rulesConfig),
1346
1503
  ...evaluatePackageFieldRules(repoPath, rulesConfig),
1347
- ...evaluateEngineVersion(repoPath, rulesConfig)
1504
+ ...evaluateEngineVersion(repoPath, rulesConfig),
1505
+ ...evaluateCodeowners(repoPath, rulesConfig, scannedFiles)
1348
1506
  ];
1349
1507
  }
1350
1508
  //#endregion
@@ -1462,14 +1620,16 @@ function aggregateReports(reports, versions = {}, config, multiVersions = {}) {
1462
1620
  for (const jsx of report.patterns.usage.jsx) {
1463
1621
  const key = jsx.component;
1464
1622
  const existing = componentUsageMap.get(key);
1465
- if (existing) existing.count++;
1466
- else {
1623
+ if (existing) {
1624
+ existing.count++;
1625
+ existing.files.add(report.filePath);
1626
+ } else {
1467
1627
  const source = findComponentSource(jsx.component, report, availablePackages);
1468
1628
  componentUsageMap.set(key, {
1469
1629
  name: jsx.component,
1470
1630
  source,
1471
1631
  count: 1,
1472
- files: /* @__PURE__ */ new Set()
1632
+ files: /* @__PURE__ */ new Set([report.filePath])
1473
1633
  });
1474
1634
  }
1475
1635
  }
@@ -1981,7 +2141,7 @@ async function runPipeline(config, spinner) {
1981
2141
  spinner.succeed(chalk.green(`Analysis complete! Analyzed ${reports.length}/${files.length} files`));
1982
2142
  printErrors(parseErrors);
1983
2143
  const aggregated = aggregateReports(reports, lockfileResult.versions, config, lockfileResult.multiVersions);
1984
- const evaluatorViolations = evaluateRules(process.cwd(), config.rules, config.excludes);
2144
+ const evaluatorViolations = evaluateRules(process.cwd(), config.rules, config.excludes, files);
1985
2145
  aggregated.ruleViolations = [...aggregated.ruleViolations, ...evaluatorViolations];
1986
2146
  if (config.releaseAge.enabled) {
1987
2147
  spinner.start("Fetching release age from registry...");
@@ -1992,6 +2152,24 @@ async function runPipeline(config, spinner) {
1992
2152
  return aggregated;
1993
2153
  }
1994
2154
  //#endregion
2155
+ //#region src/commands/command-context.ts
2156
+ /**
2157
+ * Shared command preamble: routes human-readable chrome (version line,
2158
+ * spinner) to stderr when the command emits JSON on stdout.
2159
+ */
2160
+ function createCommandContext(config) {
2161
+ const isJson = config.output.format === "json";
2162
+ const stream = isJson ? process.stderr : process.stdout;
2163
+ stream.write(chalk.gray(`hermex v${getVersion()}\n`));
2164
+ return {
2165
+ isJson,
2166
+ spinner: ora({
2167
+ text: "Parsing lockfile...",
2168
+ stream
2169
+ }).start()
2170
+ };
2171
+ }
2172
+ //#endregion
1995
2173
  //#region src/commands/scan.ts
1996
2174
  function registerScanCommand(program) {
1997
2175
  program.command("scan").description("Scan and analyze local files").option("--config <path>", "Path to hermex config file (overrides CWD discovery)").action(async (options) => {
@@ -1999,12 +2177,7 @@ function registerScanCommand(program) {
1999
2177
  });
2000
2178
  }
2001
2179
  async function executeScan(config) {
2002
- const isJson = config.output.format === "json";
2003
- (isJson ? process.stderr : process.stdout).write(chalk.gray(`hermex v${getVersion()}\n`));
2004
- const spinner = ora({
2005
- text: "Parsing lockfile...",
2006
- stream: isJson ? process.stderr : process.stdout
2007
- }).start();
2180
+ const { isJson, spinner } = createCommandContext(config);
2008
2181
  try {
2009
2182
  const aggregated = await runPipeline(config, spinner);
2010
2183
  if (!aggregated) return;
@@ -2013,8 +2186,7 @@ async function executeScan(config) {
2013
2186
  } catch (error) {
2014
2187
  const message = error instanceof Error ? error.message : String(error);
2015
2188
  spinner.fail(chalk.red("Analysis failed: " + message));
2016
- console.error(error);
2017
- process.exit(1);
2189
+ process.exitCode = 1;
2018
2190
  }
2019
2191
  }
2020
2192
  function printScanResults(aggregated, config) {
@@ -2073,12 +2245,7 @@ function registerComplyCommand(program) {
2073
2245
  });
2074
2246
  }
2075
2247
  async function executeComply(config) {
2076
- const isJson = config.output.format === "json";
2077
- (isJson ? process.stderr : process.stdout).write(chalk.gray(`hermex v${getVersion()}\n`));
2078
- const spinner = ora({
2079
- text: "Parsing lockfile...",
2080
- stream: isJson ? process.stderr : process.stdout
2081
- }).start();
2248
+ const { isJson, spinner } = createCommandContext(config);
2082
2249
  try {
2083
2250
  const aggregated = await runPipeline(config, spinner);
2084
2251
  if (!aggregated) {
@@ -2096,7 +2263,6 @@ async function executeComply(config) {
2096
2263
  } catch (error) {
2097
2264
  const message = error instanceof Error ? error.message : String(error);
2098
2265
  spinner.fail(chalk.red("Compliance check failed: " + message));
2099
- console.error(error);
2100
2266
  process.exitCode = 2;
2101
2267
  }
2102
2268
  }