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 +514 -132
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +381 -0
- package/dist/index.mjs +13 -0
- package/package.json +14 -2
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 +=
|
|
178
|
-
if (banned) prefix += banned.severity === "error" ?
|
|
179
|
-
else if (pkg.internal) prefix +=
|
|
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)
|
|
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
|
|
276
|
+
if (!top) return severityIcon("success");
|
|
188
277
|
const suffix = severity === "warn" ? chalk.gray(" [not enforced]") : "";
|
|
189
|
-
|
|
190
|
-
return
|
|
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(", ")} (
|
|
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(` ${
|
|
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 "
|
|
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
|
-
})
|
|
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")
|
|
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("
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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:
|
|
451
|
-
|
|
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
|
|
1295
|
-
|
|
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
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
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)
|
|
1460
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1506
|
-
|
|
1741
|
+
stream.write(chalk.yellow(` ${file}\n`));
|
|
1742
|
+
stream.write(chalk.gray(` ${message}\n`));
|
|
1507
1743
|
}
|
|
1508
|
-
|
|
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
|
-
|
|
1655
|
-
if (
|
|
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
|
|
1765
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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("
|
|
2315
|
+
console.log(chalk.greenBright.bold(`\n${severityIcon("success")} COMPLIANT\n`));
|
|
1925
2316
|
return;
|
|
1926
2317
|
}
|
|
1927
|
-
console.log(chalk.redBright.bold(`\n
|
|
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
|
|
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
|
}
|