hermex 1.3.0-beta.4 ā 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/README.md +7 -0
- package/dist/cli.mjs +1909 -1423
- 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,1410 +1,1747 @@
|
|
|
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
|
-
import
|
|
4
|
+
import Table from "cli-table3";
|
|
6
5
|
import fs, { existsSync, readFileSync } from "node:fs";
|
|
7
|
-
import
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
import path, { dirname, join, resolve } from "node:path";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { parseSync } from "@swc/core";
|
|
8
10
|
import micromatch from "micromatch";
|
|
9
11
|
import { glob, globSync } from "glob";
|
|
10
12
|
import fs$1 from "fs";
|
|
11
13
|
import path$1 from "path";
|
|
12
14
|
import semver from "semver";
|
|
13
|
-
import Table from "cli-table3";
|
|
14
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
15
15
|
import { load } from "js-yaml";
|
|
16
16
|
import lockfile from "@yarnpkg/lockfile";
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
conditionalUsage: /* @__PURE__ */ new Set(),
|
|
30
|
-
arrayMappings: /* @__PURE__ */ new Set(),
|
|
31
|
-
objectMappings: /* @__PURE__ */ new Set(),
|
|
32
|
-
hocUsage: /* @__PURE__ */ new Set(),
|
|
33
|
-
forwardedRefs: /* @__PURE__ */ new Set(),
|
|
34
|
-
memoizedComponents: /* @__PURE__ */ new Set(),
|
|
35
|
-
portalUsage: /* @__PURE__ */ new Set(),
|
|
36
|
-
jsxUsage: /* @__PURE__ */ new Map(),
|
|
37
|
-
destructuredUsage: /* @__PURE__ */ new Set(),
|
|
38
|
-
propsAnalysis: /* @__PURE__ */ new Map()
|
|
39
|
-
},
|
|
40
|
-
componentNames: /* @__PURE__ */ new Set(),
|
|
41
|
-
allIdentifiers: /* @__PURE__ */ new Set()
|
|
42
|
-
};
|
|
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";
|
|
21
|
+
//#region src/utils/format-utils.ts
|
|
22
|
+
/**
|
|
23
|
+
* Format a number with thousand separators
|
|
24
|
+
* @param num - Number to format
|
|
25
|
+
* @returns Formatted string (e.g., 1,234,567)
|
|
26
|
+
*/
|
|
27
|
+
function formatCount(num) {
|
|
28
|
+
return num.toLocaleString();
|
|
43
29
|
}
|
|
44
|
-
//#endregion
|
|
45
|
-
//#region src/swc-parser/patterns/imports.ts
|
|
46
30
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* - Named imports
|
|
50
|
-
* - Namespace imports
|
|
51
|
-
* - Aliased imports
|
|
31
|
+
* Format how far an upgrade candidate is past its age threshold
|
|
32
|
+
* @returns Formatted string (e.g., "40 days overdue", "1 day overdue")
|
|
52
33
|
*/
|
|
53
|
-
function
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
case "ImportDefaultSpecifier":
|
|
57
|
-
analyzeDefaultImport(spec, source, node, state);
|
|
58
|
-
break;
|
|
59
|
-
case "ImportNamespaceSpecifier":
|
|
60
|
-
analyzeNamespaceImport(spec, source, node, state);
|
|
61
|
-
break;
|
|
62
|
-
case "ImportSpecifier":
|
|
63
|
-
analyzeNamedImport(spec, source, node, state);
|
|
64
|
-
break;
|
|
65
|
-
}
|
|
34
|
+
function formatDaysOverdue(releasedDaysAgo, thresholdDays) {
|
|
35
|
+
const overdue = releasedDaysAgo - thresholdDays;
|
|
36
|
+
return `${overdue} day${overdue === 1 ? "" : "s"} overdue`;
|
|
66
37
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/utils/print-summary.ts
|
|
57
|
+
function printHeader$4() {
|
|
58
|
+
console.log(chalk.green.bold("\nš Summary\n"));
|
|
59
|
+
}
|
|
60
|
+
function printSummary(aggregated) {
|
|
61
|
+
printHeader$4();
|
|
62
|
+
const table = new Table({
|
|
63
|
+
head: ["Metric", "Count"],
|
|
64
|
+
style: {
|
|
65
|
+
head: ["cyan"],
|
|
66
|
+
border: ["gray"]
|
|
67
|
+
}
|
|
73
68
|
});
|
|
74
|
-
|
|
69
|
+
const externalComponents = aggregated.topComponents.filter((comp) => comp.source !== "unknown" && comp.source !== "local").length;
|
|
70
|
+
const totalExternalUsage = aggregated.packageDistribution.reduce((sum, pkg) => sum + pkg.usageCount, 0);
|
|
71
|
+
table.push(["Files Analyzed", formatCount(aggregated.filesAnalyzed)], ["External Packages", formatCount(aggregated.packageDistribution.length)], ["External Components", formatCount(externalComponents)], ["Total Usages", formatCount(totalExternalUsage)]);
|
|
72
|
+
console.log(table.toString());
|
|
75
73
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/utils/print-details.ts
|
|
76
|
+
function printHeader$3() {
|
|
77
|
+
console.log(chalk.cyan.bold("\nš Details\n"));
|
|
78
|
+
}
|
|
79
|
+
function printDetails(aggregated) {
|
|
80
|
+
printHeader$3();
|
|
81
|
+
console.log(chalk.cyan(` Total usage patterns: ${formatCount(aggregated.totalUsagePatterns)}`));
|
|
82
|
+
for (const pattern of aggregated.patternCounts) if (pattern.count > 0) console.log(chalk.cyan(` ${pattern.displayName}: ${formatCount(pattern.count)}`));
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region src/utils/chart-renderer.ts
|
|
86
|
+
function renderBarChart(data, options = {}) {
|
|
87
|
+
const { maxWidth = 50, showValues = true, barChar = "ā", emptyChar = "ā" } = options;
|
|
88
|
+
if (data.length === 0) {
|
|
89
|
+
console.log(chalk.gray(" No data to display"));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const maxValue = Math.max(...data.map((d) => d.value));
|
|
93
|
+
if (maxValue === 0) {
|
|
94
|
+
console.log(chalk.gray(" All values are zero"));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const maxLabelLength = Math.max(...data.map((d) => d.label.length));
|
|
98
|
+
for (const item of data) {
|
|
99
|
+
const percentage = item.value / maxValue;
|
|
100
|
+
const barLength = Math.round(percentage * maxWidth);
|
|
101
|
+
const emptyLength = maxWidth - barLength;
|
|
102
|
+
const paddedLabel = item.label.padEnd(maxLabelLength, " ");
|
|
103
|
+
const bar = chalk.green(barChar.repeat(barLength)) + chalk.gray(emptyChar.repeat(emptyLength));
|
|
104
|
+
const valueStr = showValues ? ` ${formatCount(item.value)}` : "";
|
|
105
|
+
console.log(`${paddedLabel} ${bar}${valueStr}\n`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/utils/print-components.ts
|
|
110
|
+
function printHeader$2() {
|
|
111
|
+
console.log(chalk.magenta.bold("\nāļø Components\n"));
|
|
112
|
+
}
|
|
113
|
+
function printComponents(aggregated, mode) {
|
|
114
|
+
const components = aggregated.topComponents;
|
|
115
|
+
if (mode === "table") printComponentsTable(components);
|
|
116
|
+
else if (mode === "chart") printComponentsChart(components);
|
|
117
|
+
}
|
|
118
|
+
function printComponentsTable(components) {
|
|
119
|
+
printHeader$2();
|
|
120
|
+
const externalComponents = components.filter((comp) => comp.source !== "unknown" && comp.source !== "local");
|
|
121
|
+
if (externalComponents.length === 0) {
|
|
122
|
+
console.log(chalk.gray(" No external components found"));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const table = new Table({
|
|
126
|
+
head: [
|
|
127
|
+
"Component",
|
|
128
|
+
"Package",
|
|
129
|
+
"Count"
|
|
130
|
+
],
|
|
131
|
+
style: {
|
|
132
|
+
head: ["cyan"],
|
|
133
|
+
border: ["gray"]
|
|
134
|
+
}
|
|
82
135
|
});
|
|
83
|
-
|
|
136
|
+
externalComponents.forEach((comp) => {
|
|
137
|
+
table.push([
|
|
138
|
+
comp.name,
|
|
139
|
+
comp.source,
|
|
140
|
+
comp.count.toString()
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
console.log(table.toString());
|
|
84
144
|
}
|
|
85
|
-
function
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
145
|
+
function printComponentsChart(components) {
|
|
146
|
+
printHeader$2();
|
|
147
|
+
const externalComponents = components.filter((comp) => comp.source !== "unknown" && comp.source !== "local");
|
|
148
|
+
if (externalComponents.length === 0) {
|
|
149
|
+
console.log(chalk.gray(" No external components found"));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
renderBarChart(externalComponents.map((comp) => ({
|
|
153
|
+
label: comp.name,
|
|
154
|
+
value: comp.count
|
|
155
|
+
})), { maxWidth: 50 });
|
|
156
|
+
}
|
|
157
|
+
//#endregion
|
|
158
|
+
//#region src/utils/print-patterns.ts
|
|
159
|
+
function printHeader$1() {
|
|
160
|
+
console.log(chalk.blue.bold("\nš Code Patterns\n"));
|
|
161
|
+
}
|
|
162
|
+
function printPatterns(aggregated, mode) {
|
|
163
|
+
const patterns = aggregated.patternCounts.filter((p) => p.count > 0);
|
|
164
|
+
if (mode === "table") printPatternsTable(patterns);
|
|
165
|
+
else if (mode === "chart") printPatternsChart(patterns);
|
|
166
|
+
}
|
|
167
|
+
function printPatternsTable(patterns) {
|
|
168
|
+
printHeader$1();
|
|
169
|
+
if (patterns.length === 0) {
|
|
170
|
+
console.log(chalk.gray(" No patterns found"));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const table = new Table({
|
|
174
|
+
head: ["Pattern", "Count"],
|
|
175
|
+
style: {
|
|
176
|
+
head: ["cyan"],
|
|
177
|
+
border: ["gray"]
|
|
178
|
+
}
|
|
92
179
|
});
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
local: localName,
|
|
96
|
-
source,
|
|
97
|
-
line: node.span?.start || 0
|
|
180
|
+
patterns.forEach((pattern) => {
|
|
181
|
+
table.push([pattern.displayName, pattern.count.toString()]);
|
|
98
182
|
});
|
|
99
|
-
|
|
183
|
+
console.log(table.toString());
|
|
184
|
+
const totalPatterns = patterns.reduce((sum, p) => sum + p.count, 0);
|
|
185
|
+
console.log(chalk.gray(`\nTotal: ${totalPatterns} patterns detected`));
|
|
186
|
+
}
|
|
187
|
+
function printPatternsChart(patterns) {
|
|
188
|
+
printHeader$1();
|
|
189
|
+
if (patterns.length === 0) {
|
|
190
|
+
console.log(chalk.gray(" No patterns found"));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
renderBarChart(patterns.map((pattern) => ({
|
|
194
|
+
label: pattern.displayName,
|
|
195
|
+
value: pattern.count
|
|
196
|
+
})), { maxWidth: 50 });
|
|
100
197
|
}
|
|
101
198
|
//#endregion
|
|
102
|
-
//#region src/
|
|
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
|
+
}
|
|
103
223
|
/**
|
|
104
|
-
*
|
|
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).
|
|
105
228
|
*/
|
|
106
|
-
function
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
+
});
|
|
113
240
|
}
|
|
114
241
|
/**
|
|
115
|
-
*
|
|
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.
|
|
116
247
|
*/
|
|
117
|
-
function
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
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);
|
|
121
254
|
}
|
|
122
|
-
return false;
|
|
123
255
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (!attributes) return [];
|
|
129
|
-
return attributes.map((attr) => {
|
|
130
|
-
if (attr.type === "JSXAttribute") return {
|
|
131
|
-
name: attr.name?.value || attr.name?.name?.value,
|
|
132
|
-
value: extractJSXAttributeValue(attr.value)
|
|
133
|
-
};
|
|
134
|
-
if (attr.type === "SpreadElement") return {
|
|
135
|
-
name: "...",
|
|
136
|
-
value: "[spread]",
|
|
137
|
-
isSpread: true
|
|
138
|
-
};
|
|
139
|
-
return null;
|
|
140
|
-
}).filter(Boolean);
|
|
256
|
+
//#endregion
|
|
257
|
+
//#region src/utils/print-packages.ts
|
|
258
|
+
function printHeader() {
|
|
259
|
+
console.log(chalk.blueBright.bold("\nš¦ Packages\n"));
|
|
141
260
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
case "StringLiteral": return value.value;
|
|
149
|
-
case "JSXExpressionContainer": return extractExpressionValue(value.expression);
|
|
150
|
-
default: return "[complex]";
|
|
151
|
-
}
|
|
261
|
+
function formatPackageName(pkg, banned) {
|
|
262
|
+
let 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] ");
|
|
266
|
+
return prefix + pkg.packageName;
|
|
152
267
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
case "StringLiteral":
|
|
160
|
-
case "NumericLiteral":
|
|
161
|
-
case "BooleanLiteral": return expr.value;
|
|
162
|
-
case "Identifier": return `{${expr.value}}`;
|
|
163
|
-
case "ArrowFunctionExpression":
|
|
164
|
-
case "FunctionExpression": return "[function]";
|
|
165
|
-
case "ObjectExpression": return "[object]";
|
|
166
|
-
case "ArrayExpression": return "[array]";
|
|
167
|
-
default: return "[expression]";
|
|
268
|
+
function formatUpgradeCell(releaseAge) {
|
|
269
|
+
if (!releaseAge) return "";
|
|
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");
|
|
168
274
|
}
|
|
275
|
+
const top = upgrades[0];
|
|
276
|
+
if (!top) return severityIcon("success");
|
|
277
|
+
const suffix = severity === "warn" ? chalk.gray(" [not enforced]") : "";
|
|
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}`;
|
|
169
281
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
*/
|
|
173
|
-
function getUsageContext(parent) {
|
|
174
|
-
if (!parent) return "direct";
|
|
175
|
-
switch (parent.type) {
|
|
176
|
-
case "ConditionalExpression": return "conditional";
|
|
177
|
-
case "ArrayExpression": return "array";
|
|
178
|
-
case "ObjectExpression": return "object";
|
|
179
|
-
case "CallExpression": return "hoc";
|
|
180
|
-
case "VariableDeclarator": return "variable";
|
|
181
|
-
default: return "jsx";
|
|
182
|
-
}
|
|
282
|
+
function getBannedViolation(pkg, violations) {
|
|
283
|
+
return violations.find((v) => v.packageName === pkg.packageName);
|
|
183
284
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
function analyzePropsInDetail(attributes, componentName, state) {
|
|
190
|
-
const analysis = {
|
|
191
|
-
namedProps: [],
|
|
192
|
-
hasSpread: false,
|
|
193
|
-
hasComplexProps: false,
|
|
194
|
-
hasEventHandlers: false,
|
|
195
|
-
propDetails: []
|
|
196
|
-
};
|
|
197
|
-
if (!attributes) return analysis;
|
|
198
|
-
for (const attr of attributes) if (attr.type === "JSXAttribute") {
|
|
199
|
-
const propName = attr.name?.value || attr.name?.name?.value;
|
|
200
|
-
if (propName) {
|
|
201
|
-
analysis.namedProps.push(propName);
|
|
202
|
-
const propDetail = {
|
|
203
|
-
name: propName,
|
|
204
|
-
type: getPropType(attr.value),
|
|
205
|
-
isEventHandler: propName.startsWith("on"),
|
|
206
|
-
isComplex: isComplexProp(attr.value)
|
|
207
|
-
};
|
|
208
|
-
if (propDetail.isEventHandler) analysis.hasEventHandlers = true;
|
|
209
|
-
if (propDetail.isComplex) analysis.hasComplexProps = true;
|
|
210
|
-
analysis.propDetails.push(propDetail);
|
|
211
|
-
}
|
|
212
|
-
} else if (attr.type === "SpreadElement") {
|
|
213
|
-
analysis.hasSpread = true;
|
|
214
|
-
analysis.propDetails.push({
|
|
215
|
-
name: "...",
|
|
216
|
-
type: "spread",
|
|
217
|
-
isSpread: true,
|
|
218
|
-
isComplex: true,
|
|
219
|
-
isEventHandler: false,
|
|
220
|
-
warning: "Spread props cannot be statically analyzed"
|
|
221
|
-
});
|
|
222
|
-
analysis.hasComplexProps = true;
|
|
223
|
-
}
|
|
224
|
-
state.usagePatterns.propsAnalysis.set(componentName, analysis);
|
|
225
|
-
return analysis;
|
|
285
|
+
function printPackages(aggregated, mode) {
|
|
286
|
+
const packages = aggregated.packageDistribution;
|
|
287
|
+
const violations = aggregated.bannedPackageViolations;
|
|
288
|
+
if (mode === "table") printPackagesTable(packages, violations);
|
|
289
|
+
else if (mode === "chart") printPackagesChart(packages, violations);
|
|
226
290
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
switch (value.type) {
|
|
233
|
-
case "StringLiteral": return "string";
|
|
234
|
-
case "JSXExpressionContainer": {
|
|
235
|
-
const expr = value.expression;
|
|
236
|
-
if (!expr) return "unknown";
|
|
237
|
-
switch (expr.type) {
|
|
238
|
-
case "NumericLiteral": return "number";
|
|
239
|
-
case "BooleanLiteral": return "boolean";
|
|
240
|
-
case "StringLiteral": return "string";
|
|
241
|
-
case "ArrowFunctionExpression":
|
|
242
|
-
case "FunctionExpression": return "function";
|
|
243
|
-
case "ObjectExpression": return "object";
|
|
244
|
-
case "ArrayExpression": return "array";
|
|
245
|
-
case "Identifier": return "variable";
|
|
246
|
-
default: return "expression";
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
default: return "unknown";
|
|
291
|
+
function printPackagesTable(packages, violations) {
|
|
292
|
+
printHeader();
|
|
293
|
+
if (packages.length === 0) {
|
|
294
|
+
console.log(chalk.gray(" No packages found"));
|
|
295
|
+
return;
|
|
250
296
|
}
|
|
297
|
+
const hasReleaseAge = packages.some((p) => p.releaseAge !== void 0);
|
|
298
|
+
const head = [
|
|
299
|
+
"Package",
|
|
300
|
+
"Version",
|
|
301
|
+
"Components",
|
|
302
|
+
"Usage",
|
|
303
|
+
"Percentage"
|
|
304
|
+
];
|
|
305
|
+
if (hasReleaseAge) head.push("Upgrades");
|
|
306
|
+
const table = new Table({
|
|
307
|
+
head,
|
|
308
|
+
style: {
|
|
309
|
+
head: ["cyan"],
|
|
310
|
+
border: ["gray"]
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
packages.forEach((pkg) => {
|
|
314
|
+
const versionCell = pkg.hasVersionConflict ? chalk.yellow(`ā ${pkg.allVersions.join(", ")} (${pkg.allVersions.length} versions ā bundle impact)`) : pkg.version || "N/A";
|
|
315
|
+
const row = [
|
|
316
|
+
formatPackageName(pkg, getBannedViolation(pkg, violations)),
|
|
317
|
+
versionCell,
|
|
318
|
+
formatCount(pkg.componentCount),
|
|
319
|
+
formatCount(pkg.usageCount),
|
|
320
|
+
`${pkg.percentage.toFixed(1)}%`
|
|
321
|
+
];
|
|
322
|
+
if (hasReleaseAge) row.push(formatUpgradeCell(pkg.releaseAge));
|
|
323
|
+
table.push(row);
|
|
324
|
+
});
|
|
325
|
+
console.log(table.toString());
|
|
326
|
+
const totalComponents = packages.reduce((sum, p) => sum + p.componentCount, 0);
|
|
327
|
+
const totalExternalUsage = packages.reduce((sum, p) => sum + p.usageCount, 0);
|
|
328
|
+
console.log(chalk.gray(`\nTotal: ${formatCount(packages.length)} packages | ${formatCount(totalComponents)} unique components | ${formatCount(totalExternalUsage)} total usages`));
|
|
251
329
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (value.type === "JSXExpressionContainer") {
|
|
258
|
-
const expr = value.expression;
|
|
259
|
-
if (!expr) return false;
|
|
260
|
-
return expr.type === "ObjectExpression" || expr.type === "ArrayExpression" || expr.type === "CallExpression" || expr.type === "ConditionalExpression";
|
|
330
|
+
function printPackagesChart(packages, violations) {
|
|
331
|
+
printHeader();
|
|
332
|
+
if (packages.length === 0) {
|
|
333
|
+
console.log(chalk.gray(" No packages found"));
|
|
334
|
+
return;
|
|
261
335
|
}
|
|
262
|
-
|
|
336
|
+
const maxBarWidth = 40;
|
|
337
|
+
const maxPercentage = Math.max(...packages.map((p) => p.percentage));
|
|
338
|
+
const maxLabelLength = Math.max(...packages.map((p) => p.packageName.length + (p.internal ? 6 : 0)));
|
|
339
|
+
packages.forEach((pkg) => {
|
|
340
|
+
const barLength = Math.round(pkg.percentage / maxPercentage * maxBarWidth);
|
|
341
|
+
const emptyLength = maxBarWidth - barLength;
|
|
342
|
+
const label = formatPackageName(pkg, getBannedViolation(pkg, violations)).padEnd(maxLabelLength, " ");
|
|
343
|
+
const bar = chalk.green("ā".repeat(barLength)) + chalk.gray("ā".repeat(emptyLength));
|
|
344
|
+
console.log(`${label} ${bar} ${chalk.bold(pkg.percentage.toFixed(1) + "%")} (${pkg.usageCount})`);
|
|
345
|
+
});
|
|
263
346
|
}
|
|
264
347
|
//#endregion
|
|
265
|
-
//#region src/
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
348
|
+
//#region src/utils/print-versus.ts
|
|
349
|
+
const BAR_WIDTH = 30;
|
|
350
|
+
function renderBar(percentage) {
|
|
351
|
+
const filled = Math.round(percentage / 100 * BAR_WIDTH);
|
|
352
|
+
const empty = BAR_WIDTH - filled;
|
|
353
|
+
return chalk.cyan("ā".repeat(filled)) + chalk.gray("ā".repeat(empty));
|
|
271
354
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if (!state.usagePatterns.jsxUsage.has(elementName)) state.usagePatterns.jsxUsage.set(elementName, usage);
|
|
355
|
+
function printVersusResult(result) {
|
|
356
|
+
console.log(chalk.bold(` ${result.name}`));
|
|
357
|
+
console.log(chalk.gray(` ${"ā".repeat(50)}`));
|
|
358
|
+
const maxNameLen = Math.max(...result.entries.map((e) => e.packageName.length));
|
|
359
|
+
for (const entry of result.entries) {
|
|
360
|
+
const name = entry.packageName.padEnd(maxNameLen);
|
|
361
|
+
const bar = renderBar(entry.percentage);
|
|
362
|
+
const pct = chalk.bold(`${entry.percentage.toFixed(1)}%`);
|
|
363
|
+
const usage = chalk.gray(`(${entry.count} usages)`);
|
|
364
|
+
const components = entry.components.length > 0 ? chalk.gray(` ${formatTruncatedList(entry.components, "component")}`) : "";
|
|
365
|
+
console.log(` ${name} ${bar} ${pct} ${usage}${components}`);
|
|
366
|
+
}
|
|
367
|
+
if (result.totalCount === 0) console.log(chalk.gray(" No usage detected for any package in this group."));
|
|
368
|
+
console.log();
|
|
287
369
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
*/
|
|
293
|
-
function isKnownComponent(name, state) {
|
|
294
|
-
return state.componentNames.has(name) || state.allIdentifiers.has(name);
|
|
370
|
+
function printVersus(aggregated) {
|
|
371
|
+
if (aggregated.versusResults.length === 0) return;
|
|
372
|
+
console.log(chalk.magentaBright.bold("\nāļø Versus\n"));
|
|
373
|
+
for (const result of aggregated.versusResults) printVersusResult(result);
|
|
295
374
|
}
|
|
296
375
|
//#endregion
|
|
297
|
-
//#region src/
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (assignment && isKnownComponent(assignment, state)) {
|
|
309
|
-
state.usagePatterns.variableAssignments.set(varName, {
|
|
310
|
-
assignment,
|
|
311
|
-
line: node.span?.start || 0
|
|
312
|
-
});
|
|
313
|
-
state.componentNames.add(varName);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
if (decl.id?.type === "ObjectPattern") analyzeDestructuringPattern(decl.id, decl.init, state);
|
|
376
|
+
//#region src/utils/print-rules.ts
|
|
377
|
+
function formatRuleType(type) {
|
|
378
|
+
switch (type) {
|
|
379
|
+
case "detect_files": return "detect_files";
|
|
380
|
+
case "require_files": return "require_files";
|
|
381
|
+
case "require_packages": return "require_packages";
|
|
382
|
+
case "require_scripts": return "require_scripts";
|
|
383
|
+
case "require_package_fields": return "package_fields";
|
|
384
|
+
case "forbid_package_fields": return "package_fields";
|
|
385
|
+
case "engine_version": return "engine_version";
|
|
386
|
+
case "codeowners": return "codeowners";
|
|
318
387
|
}
|
|
319
388
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
state.componentNames.add(propName);
|
|
334
|
-
}
|
|
389
|
+
function describeViolation(v) {
|
|
390
|
+
const patterns = v.patterns.join(", ");
|
|
391
|
+
const suffix = v.message ? chalk.gray(` ā ${v.message}`) : "";
|
|
392
|
+
if (v.type === "detect_files") return `${patterns} detected (${formatTruncatedList(v.matchedFiles.map((f) => {
|
|
393
|
+
const parts = f.replace(/\\/g, "/").split("/");
|
|
394
|
+
return parts[parts.length - 1];
|
|
395
|
+
}), "file")})${suffix}`;
|
|
396
|
+
if (v.type === "require_files") return `${patterns} not found${suffix}`;
|
|
397
|
+
if (v.type === "require_packages") return `${patterns} not installed${suffix}`;
|
|
398
|
+
if (v.type === "require_scripts") return `script ${patterns} missing in package.json${suffix}`;
|
|
399
|
+
if (v.type === "require_package_fields") {
|
|
400
|
+
if (v.fieldPath && v.actualValue !== void 0) return `field ${v.fieldPath} is ${chalk.yellow(v.actualValue)}, does not match required value${suffix}`;
|
|
401
|
+
return `field ${patterns} missing in package.json${suffix}`;
|
|
335
402
|
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
function extractAssignmentInfo(node) {
|
|
341
|
-
switch (node.type) {
|
|
342
|
-
case "Identifier": return node.value;
|
|
343
|
-
case "MemberExpression": return `${extractAssignmentInfo(node.object)}.${node.property.value}`;
|
|
344
|
-
case "ConditionalExpression": return `${extractAssignmentInfo(node.consequent)} | ${extractAssignmentInfo(node.alternate)}`;
|
|
345
|
-
default: return null;
|
|
403
|
+
if (v.type === "forbid_package_fields") return `field ${v.fieldPath ?? patterns} is forbidden in package.json${suffix}`;
|
|
404
|
+
if (v.type === "engine_version") {
|
|
405
|
+
if (!v.installedRange) return `engines.node not specified (required ${v.requiredRange})${suffix}`;
|
|
406
|
+
return `engines.node is ${chalk.yellow(v.installedRange)}, required ${chalk.cyan(v.requiredRange)}${suffix}`;
|
|
346
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
|
+
}
|
|
412
|
+
return `${patterns} not present${suffix}`;
|
|
347
413
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
* Analyzes object expressions with component mappings
|
|
378
|
-
*/
|
|
379
|
-
function analyzeObjectExpression(node, state) {
|
|
380
|
-
const componentProps = node.properties?.filter((prop) => {
|
|
381
|
-
if (prop.type === "KeyValueProperty" && prop.value?.type === "Identifier") return state.componentNames.has(prop.value.value);
|
|
382
|
-
return false;
|
|
383
|
-
});
|
|
384
|
-
if (componentProps?.length > 0) state.usagePatterns.objectMappings.add({
|
|
385
|
-
mappings: componentProps.map((prop) => ({
|
|
386
|
-
key: prop.key?.value || "[computed]",
|
|
387
|
-
component: prop.value?.value
|
|
388
|
-
})),
|
|
389
|
-
line: node.span?.start || 0
|
|
390
|
-
});
|
|
414
|
+
function printRules(aggregated) {
|
|
415
|
+
const { ruleViolations, bannedPackageViolations } = aggregated;
|
|
416
|
+
const hasRuleViolations = ruleViolations.length > 0;
|
|
417
|
+
const hasBannedViolations = bannedPackageViolations.length > 0;
|
|
418
|
+
if (!hasRuleViolations && !hasBannedViolations) {
|
|
419
|
+
console.log(chalk.greenBright.bold(`\n${severityIcon("success")} Compliance\n`));
|
|
420
|
+
console.log(chalk.gray(" All compliance checks passed"));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
console.log(chalk.blueBright.bold("\nš Compliance\n"));
|
|
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
|
+
}));
|
|
436
|
+
}
|
|
437
|
+
const errorCount = [...ruleViolations.filter((v) => v.severity === "error"), ...bannedPackageViolations.filter((v) => v.severity === "error")].length;
|
|
438
|
+
const warnCount = [...ruleViolations.filter((v) => v.severity === "warn"), ...bannedPackageViolations.filter((v) => v.severity === "warn")].length;
|
|
439
|
+
const parts = [];
|
|
440
|
+
if (errorCount > 0) parts.push(chalk.red(`${errorCount} error${errorCount > 1 ? "s" : ""}`));
|
|
441
|
+
if (warnCount > 0) parts.push(chalk.yellow(`${warnCount} warning${warnCount > 1 ? "s" : ""}`));
|
|
442
|
+
console.log(chalk.gray(`\n ${parts.join(", ")}`));
|
|
391
443
|
}
|
|
392
444
|
//#endregion
|
|
393
|
-
//#region src/
|
|
445
|
+
//#region src/utils/version.ts
|
|
446
|
+
let cachedVersion;
|
|
394
447
|
/**
|
|
395
|
-
*
|
|
448
|
+
* Walks up from `dir` to find the nearest package.json.
|
|
449
|
+
* Needed because this module's own path differs between the tsdown-bundled
|
|
450
|
+
* single-file `dist/cli.mjs` and the unbundled source used by tests.
|
|
396
451
|
*/
|
|
397
|
-
function
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const
|
|
401
|
-
if (
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
line: node.span?.start || 0
|
|
406
|
-
});
|
|
407
|
-
}
|
|
452
|
+
function findPackageJson(dir) {
|
|
453
|
+
let current = dir;
|
|
454
|
+
while (true) {
|
|
455
|
+
const candidate = path.join(current, "package.json");
|
|
456
|
+
if (existsSync(candidate)) return candidate;
|
|
457
|
+
const parent = path.dirname(current);
|
|
458
|
+
if (parent === current) throw new Error(`Could not locate package.json above ${dir}`);
|
|
459
|
+
current = parent;
|
|
408
460
|
}
|
|
409
461
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
if (source) state.usagePatterns.dynamicImports.add({
|
|
416
|
-
source,
|
|
417
|
-
line: node.span?.start || 0
|
|
418
|
-
});
|
|
462
|
+
function getVersion() {
|
|
463
|
+
if (cachedVersion) return cachedVersion;
|
|
464
|
+
const pkgPath = findPackageJson(path.dirname(fileURLToPath(import.meta.url)));
|
|
465
|
+
cachedVersion = JSON.parse(readFileSync(pkgPath, "utf8")).version;
|
|
466
|
+
return cachedVersion;
|
|
419
467
|
}
|
|
420
468
|
//#endregion
|
|
421
|
-
//#region src/
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/**
|
|
443
|
-
* Analyzes React.forwardRef() usage
|
|
444
|
-
*/
|
|
445
|
-
function analyzeForwardRefUsage(node, state) {
|
|
446
|
-
state.usagePatterns.forwardedRefs.add({ line: node.span?.start || 0 });
|
|
447
|
-
}
|
|
448
|
-
/**
|
|
449
|
-
* Analyzes ReactDOM.createPortal() usage
|
|
450
|
-
*/
|
|
451
|
-
function analyzePortalUsage(node, state) {
|
|
452
|
-
state.usagePatterns.portalUsage.add({ line: node.span?.start || 0 });
|
|
453
|
-
}
|
|
454
|
-
/**
|
|
455
|
-
* Analyzes member expression access (e.g., Foundation.Button)
|
|
456
|
-
*/
|
|
457
|
-
function analyzeMemberExpression(node, state) {
|
|
458
|
-
if (node.object?.type === "Identifier" && state.allIdentifiers.has(node.object.value)) {
|
|
459
|
-
const propertyName = node.property?.value;
|
|
460
|
-
if (propertyName) state.componentNames.add(propertyName);
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
/**
|
|
464
|
-
* Checks if a node represents HOC pattern
|
|
465
|
-
*/
|
|
466
|
-
function isHOCPattern(node, state) {
|
|
467
|
-
return node.callee?.type === "Identifier" && node.arguments?.some((arg) => arg.type === "Identifier" && state.componentNames.has(arg.value));
|
|
469
|
+
//#region src/utils/print-json.ts
|
|
470
|
+
function printJson(aggregated) {
|
|
471
|
+
const result = {
|
|
472
|
+
version: getVersion(),
|
|
473
|
+
summary: {
|
|
474
|
+
filesAnalyzed: aggregated.filesAnalyzed,
|
|
475
|
+
totalImports: aggregated.totalImports,
|
|
476
|
+
totalComponents: aggregated.totalComponents,
|
|
477
|
+
totalUsagePatterns: aggregated.totalUsagePatterns
|
|
478
|
+
},
|
|
479
|
+
packages: aggregated.packageDistribution,
|
|
480
|
+
components: aggregated.topComponents.map((c) => ({
|
|
481
|
+
...c,
|
|
482
|
+
files: [...c.files]
|
|
483
|
+
})),
|
|
484
|
+
patterns: aggregated.patternCounts,
|
|
485
|
+
versus: aggregated.versusResults,
|
|
486
|
+
ruleViolations: aggregated.ruleViolations,
|
|
487
|
+
bannedPackageViolations: aggregated.bannedPackageViolations
|
|
488
|
+
};
|
|
489
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
468
490
|
}
|
|
469
491
|
//#endregion
|
|
470
|
-
//#region src/
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
492
|
+
//#region src/config/schema.ts
|
|
493
|
+
const RuleSeveritySchema = z.enum([
|
|
494
|
+
"error",
|
|
495
|
+
"warn",
|
|
496
|
+
"info"
|
|
497
|
+
]);
|
|
498
|
+
const RuleConfigSchema = z.object({
|
|
499
|
+
severity: RuleSeveritySchema,
|
|
500
|
+
patterns: z.array(z.string()),
|
|
501
|
+
message: z.string().optional()
|
|
502
|
+
});
|
|
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)]);
|
|
508
|
+
const EngineVersionRuleSchema = z.object({
|
|
509
|
+
severity: RuleSeveritySchema,
|
|
510
|
+
range: z.string(),
|
|
511
|
+
message: z.string().optional()
|
|
512
|
+
});
|
|
513
|
+
const CodeownersRuleSchema = z.object({
|
|
514
|
+
severity: RuleSeveritySchema,
|
|
515
|
+
message: z.string().optional()
|
|
516
|
+
});
|
|
517
|
+
const ThresholdSchema = z.union([z.number(), z.literal(false)]);
|
|
518
|
+
const HermexConfigSchema = z.object({
|
|
519
|
+
includes: z.array(z.string()).default(["**/*.{tsx,jsx,ts,js}"]),
|
|
520
|
+
excludes: z.array(z.string()).default([
|
|
521
|
+
"**/node_modules/**",
|
|
522
|
+
"**/dist/**",
|
|
523
|
+
"**/build/**"
|
|
524
|
+
]),
|
|
525
|
+
packages: z.object({
|
|
526
|
+
internal: z.array(z.string()).default([]),
|
|
527
|
+
ignore: z.array(z.string()).default([])
|
|
528
|
+
}).default(() => ({
|
|
529
|
+
internal: [],
|
|
530
|
+
ignore: []
|
|
531
|
+
})),
|
|
532
|
+
versus: z.array(z.object({
|
|
533
|
+
name: z.string(),
|
|
534
|
+
packages: z.array(z.string()).min(2)
|
|
535
|
+
})).default([]),
|
|
536
|
+
rules: z.object({
|
|
537
|
+
detect_files: RuleConfigOrArraySchema.default([]),
|
|
538
|
+
require_files: RuleConfigOrArraySchema.default([]),
|
|
539
|
+
forbid_packages: RuleConfigOrArraySchema.default([]),
|
|
540
|
+
require_packages: RuleConfigOrArraySchema.default([]),
|
|
541
|
+
require_scripts: RuleConfigOrArraySchema.default([]),
|
|
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()
|
|
546
|
+
}).default(() => ({
|
|
547
|
+
detect_files: [],
|
|
548
|
+
require_files: [],
|
|
549
|
+
forbid_packages: [],
|
|
550
|
+
require_packages: [],
|
|
551
|
+
require_scripts: [],
|
|
552
|
+
require_package_fields: [],
|
|
553
|
+
forbid_package_fields: []
|
|
554
|
+
})),
|
|
555
|
+
output: z.object({
|
|
556
|
+
summary: z.union([z.literal("log"), z.literal(false)]).default("log"),
|
|
557
|
+
components: z.union([z.enum(["table", "chart"]), z.literal(false)]).default("table"),
|
|
558
|
+
packages: z.union([z.enum(["table", "chart"]), z.literal(false)]).default("table"),
|
|
559
|
+
patterns: z.union([z.enum(["table", "chart"]), z.literal(false)]).default("table"),
|
|
560
|
+
details: z.boolean().default(false),
|
|
561
|
+
versus: z.boolean().default(true),
|
|
562
|
+
rules: z.boolean().default(true),
|
|
563
|
+
format: z.enum(["human", "json"]).default("human")
|
|
564
|
+
}).default(() => ({
|
|
565
|
+
summary: "log",
|
|
566
|
+
components: "table",
|
|
567
|
+
packages: "table",
|
|
568
|
+
patterns: "table",
|
|
569
|
+
details: false,
|
|
570
|
+
versus: true,
|
|
571
|
+
rules: true,
|
|
572
|
+
format: "human"
|
|
573
|
+
})),
|
|
574
|
+
releaseAge: z.object({
|
|
575
|
+
enabled: z.boolean().default(false),
|
|
576
|
+
registry: z.string().default("https://registry.npmjs.org"),
|
|
577
|
+
authToken: z.string().optional(),
|
|
578
|
+
thresholds: z.object({
|
|
579
|
+
patch: ThresholdSchema.default(30),
|
|
580
|
+
minor: ThresholdSchema.default(45),
|
|
581
|
+
major: ThresholdSchema.default(60)
|
|
582
|
+
}).default(() => ({
|
|
583
|
+
patch: 30,
|
|
584
|
+
minor: 45,
|
|
585
|
+
major: 60
|
|
586
|
+
})),
|
|
587
|
+
enforceOn: z.array(z.string()).default([]),
|
|
588
|
+
cacheTtlMs: z.number().int().positive().optional(),
|
|
589
|
+
cacheDisabled: z.boolean().default(false)
|
|
590
|
+
}).default(() => ({
|
|
591
|
+
enabled: false,
|
|
592
|
+
registry: "https://registry.npmjs.org",
|
|
593
|
+
thresholds: {
|
|
594
|
+
patch: 30,
|
|
595
|
+
minor: 45,
|
|
596
|
+
major: 60
|
|
597
|
+
},
|
|
598
|
+
enforceOn: [],
|
|
599
|
+
cacheDisabled: false
|
|
600
|
+
}))
|
|
601
|
+
});
|
|
602
|
+
//#endregion
|
|
603
|
+
//#region src/config/loader.ts
|
|
604
|
+
async function loadConfig(cwd, explicitPath) {
|
|
605
|
+
const configPath = explicitPath ? resolve(explicitPath) : join(cwd, "hermex.config.ts");
|
|
606
|
+
if (explicitPath && !existsSync(configPath)) throw new Error(`Config file not found: ${configPath}`);
|
|
607
|
+
if (existsSync(configPath)) {
|
|
608
|
+
const mod = await import(pathToFileURL(configPath).href);
|
|
609
|
+
return HermexConfigSchema.parse(mod.default ?? mod);
|
|
610
|
+
}
|
|
611
|
+
return HermexConfigSchema.parse({});
|
|
612
|
+
}
|
|
613
|
+
//#endregion
|
|
614
|
+
//#region src/swc-parser/core/state.ts
|
|
615
|
+
function createState() {
|
|
616
|
+
return {
|
|
617
|
+
usagePatterns: {
|
|
618
|
+
namedImports: /* @__PURE__ */ new Set(),
|
|
619
|
+
namespaceImports: /* @__PURE__ */ new Set(),
|
|
620
|
+
defaultImports: /* @__PURE__ */ new Set(),
|
|
621
|
+
aliasedImports: /* @__PURE__ */ new Map(),
|
|
622
|
+
variableAssignments: /* @__PURE__ */ new Map(),
|
|
623
|
+
lazyImports: /* @__PURE__ */ new Set(),
|
|
624
|
+
dynamicImports: /* @__PURE__ */ new Set(),
|
|
625
|
+
conditionalUsage: /* @__PURE__ */ new Set(),
|
|
626
|
+
arrayMappings: /* @__PURE__ */ new Set(),
|
|
627
|
+
objectMappings: /* @__PURE__ */ new Set(),
|
|
628
|
+
hocUsage: /* @__PURE__ */ new Set(),
|
|
629
|
+
forwardedRefs: /* @__PURE__ */ new Set(),
|
|
630
|
+
memoizedComponents: /* @__PURE__ */ new Set(),
|
|
631
|
+
portalUsage: /* @__PURE__ */ new Set(),
|
|
632
|
+
jsxUsage: /* @__PURE__ */ new Map(),
|
|
633
|
+
destructuredUsage: /* @__PURE__ */ new Set(),
|
|
634
|
+
propsAnalysis: /* @__PURE__ */ new Map()
|
|
635
|
+
},
|
|
636
|
+
componentNames: /* @__PURE__ */ new Set(),
|
|
637
|
+
allIdentifiers: /* @__PURE__ */ new Set()
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
//#endregion
|
|
641
|
+
//#region src/swc-parser/patterns/imports.ts
|
|
642
|
+
/**
|
|
643
|
+
* Analyzes import declarations and tracks all types:
|
|
644
|
+
* - Default imports
|
|
645
|
+
* - Named imports
|
|
646
|
+
* - Namespace imports
|
|
647
|
+
* - Aliased imports
|
|
648
|
+
*/
|
|
649
|
+
function analyzeImportDeclaration(node, state) {
|
|
650
|
+
const source = node.source.value;
|
|
651
|
+
for (const spec of node.specifiers) switch (spec.type) {
|
|
652
|
+
case "ImportDefaultSpecifier":
|
|
653
|
+
analyzeDefaultImport(spec, source, node, state);
|
|
519
654
|
break;
|
|
520
|
-
case "
|
|
521
|
-
|
|
522
|
-
case "ExpressionStatement":
|
|
523
|
-
case "ReturnStatement":
|
|
524
|
-
case "VariableDeclarator":
|
|
525
|
-
case "ArrowFunctionExpression":
|
|
526
|
-
case "FunctionExpression":
|
|
527
|
-
visitChildren(node, state, {
|
|
528
|
-
...context,
|
|
529
|
-
parent: node
|
|
530
|
-
});
|
|
655
|
+
case "ImportNamespaceSpecifier":
|
|
656
|
+
analyzeNamespaceImport(spec, source, node, state);
|
|
531
657
|
break;
|
|
532
|
-
|
|
533
|
-
|
|
658
|
+
case "ImportSpecifier":
|
|
659
|
+
analyzeNamedImport(spec, source, node, state);
|
|
534
660
|
break;
|
|
535
661
|
}
|
|
536
662
|
}
|
|
663
|
+
function analyzeDefaultImport(spec, source, node, state) {
|
|
664
|
+
const name = spec.local.value;
|
|
665
|
+
state.usagePatterns.defaultImports.add({
|
|
666
|
+
name,
|
|
667
|
+
source,
|
|
668
|
+
line: node.span?.start || 0
|
|
669
|
+
});
|
|
670
|
+
state.componentNames.add(name);
|
|
671
|
+
}
|
|
672
|
+
function analyzeNamespaceImport(spec, source, node, state) {
|
|
673
|
+
const name = spec.local.value;
|
|
674
|
+
state.usagePatterns.namespaceImports.add({
|
|
675
|
+
name,
|
|
676
|
+
source,
|
|
677
|
+
line: node.span?.start || 0
|
|
678
|
+
});
|
|
679
|
+
state.allIdentifiers.add(name);
|
|
680
|
+
}
|
|
681
|
+
function analyzeNamedImport(spec, source, node, state) {
|
|
682
|
+
const importedName = spec.imported ? spec.imported.value : spec.local.value;
|
|
683
|
+
const localName = spec.local.value;
|
|
684
|
+
state.usagePatterns.namedImports.add({
|
|
685
|
+
name: importedName,
|
|
686
|
+
source,
|
|
687
|
+
line: node.span?.start || 0
|
|
688
|
+
});
|
|
689
|
+
if (importedName !== localName) state.usagePatterns.aliasedImports.set(localName, {
|
|
690
|
+
imported: importedName,
|
|
691
|
+
local: localName,
|
|
692
|
+
source,
|
|
693
|
+
line: node.span?.start || 0
|
|
694
|
+
});
|
|
695
|
+
state.componentNames.add(localName);
|
|
696
|
+
}
|
|
697
|
+
//#endregion
|
|
698
|
+
//#region src/swc-parser/utils/jsx-helpers.ts
|
|
537
699
|
/**
|
|
538
|
-
*
|
|
700
|
+
* Extracts the name from a JSX element (handles identifiers and member expressions)
|
|
539
701
|
*/
|
|
540
|
-
function
|
|
541
|
-
if (
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
else if (node.callee?.property?.value === "forwardRef") analyzeForwardRefUsage(node, state);
|
|
702
|
+
function getJSXElementName(nameNode) {
|
|
703
|
+
if (!nameNode) return "";
|
|
704
|
+
switch (nameNode.type) {
|
|
705
|
+
case "Identifier": return nameNode.value;
|
|
706
|
+
case "JSXMemberExpression": return `${getJSXElementName(nameNode.object)}.${nameNode.property.value}`;
|
|
707
|
+
default: return "";
|
|
547
708
|
}
|
|
548
|
-
if (node.callee?.property?.value === "createPortal" || node.callee?.value === "createPortal") analyzePortalUsage(node, state);
|
|
549
|
-
visitChildren(node, state, context);
|
|
550
709
|
}
|
|
551
710
|
/**
|
|
552
|
-
*
|
|
711
|
+
* Checks if a JSX member expression is a known component
|
|
553
712
|
*/
|
|
554
|
-
function
|
|
555
|
-
if (
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
if (Array.isArray(value)) {
|
|
559
|
-
for (const item of value) if (item && typeof item === "object") visitNode(item, state, {
|
|
560
|
-
...context,
|
|
561
|
-
parent: node
|
|
562
|
-
});
|
|
563
|
-
} else if (value && typeof value === "object" && value.type) visitNode(value, state, {
|
|
564
|
-
...context,
|
|
565
|
-
parent: node
|
|
566
|
-
});
|
|
713
|
+
function isMemberExpressionComponent(nameNode, state) {
|
|
714
|
+
if (nameNode?.type === "JSXMemberExpression") {
|
|
715
|
+
const objectName = getJSXElementName(nameNode.object);
|
|
716
|
+
return state.allIdentifiers.has(objectName);
|
|
567
717
|
}
|
|
718
|
+
return false;
|
|
568
719
|
}
|
|
569
|
-
//#endregion
|
|
570
|
-
//#region src/swc-parser/core/report.ts
|
|
571
720
|
/**
|
|
572
|
-
*
|
|
721
|
+
* Extracts props from JSX attributes
|
|
573
722
|
*/
|
|
574
|
-
function
|
|
575
|
-
return
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
usage: {
|
|
589
|
-
jsx: Array.from(state.usagePatterns.jsxUsage.values()),
|
|
590
|
-
variables: Array.from(state.usagePatterns.variableAssignments.entries()).map(([key, value]) => ({
|
|
591
|
-
variable: key,
|
|
592
|
-
assignment: value.assignment
|
|
593
|
-
})),
|
|
594
|
-
destructuring: Array.from(state.usagePatterns.destructuredUsage),
|
|
595
|
-
conditional: Array.from(state.usagePatterns.conditionalUsage),
|
|
596
|
-
arrays: Array.from(state.usagePatterns.arrayMappings),
|
|
597
|
-
objects: Array.from(state.usagePatterns.objectMappings)
|
|
598
|
-
},
|
|
599
|
-
advanced: {
|
|
600
|
-
lazy: Array.from(state.usagePatterns.lazyImports),
|
|
601
|
-
dynamic: Array.from(state.usagePatterns.dynamicImports),
|
|
602
|
-
hoc: Array.from(state.usagePatterns.hocUsage),
|
|
603
|
-
memo: Array.from(state.usagePatterns.memoizedComponents),
|
|
604
|
-
forwardRef: Array.from(state.usagePatterns.forwardedRefs),
|
|
605
|
-
portal: Array.from(state.usagePatterns.portalUsage)
|
|
606
|
-
},
|
|
607
|
-
props: Array.from(state.usagePatterns.propsAnalysis.entries()).map(([component, analysis]) => ({
|
|
608
|
-
component,
|
|
609
|
-
analysis
|
|
610
|
-
}))
|
|
611
|
-
},
|
|
612
|
-
components: Array.from(state.componentNames).sort()
|
|
613
|
-
};
|
|
723
|
+
function extractJSXProps(attributes) {
|
|
724
|
+
if (!attributes) return [];
|
|
725
|
+
return attributes.map((attr) => {
|
|
726
|
+
if (attr.type === "JSXAttribute") return {
|
|
727
|
+
name: attr.name?.value || attr.name?.name?.value,
|
|
728
|
+
value: extractJSXAttributeValue(attr.value)
|
|
729
|
+
};
|
|
730
|
+
if (attr.type === "SpreadElement") return {
|
|
731
|
+
name: "...",
|
|
732
|
+
value: "[spread]",
|
|
733
|
+
isSpread: true
|
|
734
|
+
};
|
|
735
|
+
return null;
|
|
736
|
+
}).filter(Boolean);
|
|
614
737
|
}
|
|
615
738
|
/**
|
|
616
|
-
*
|
|
739
|
+
* Extracts value from JSX attribute
|
|
617
740
|
*/
|
|
618
|
-
function
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
return
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
//#region src/swc-parser/index.ts
|
|
626
|
-
function swcOptionsForFile(filePath) {
|
|
627
|
-
const ext = path.extname(filePath).toLowerCase();
|
|
628
|
-
if (ext === ".ts") return {
|
|
629
|
-
syntax: "typescript",
|
|
630
|
-
tsx: false,
|
|
631
|
-
decorators: true,
|
|
632
|
-
dynamicImport: true
|
|
633
|
-
};
|
|
634
|
-
if (ext === ".tsx") return {
|
|
635
|
-
syntax: "typescript",
|
|
636
|
-
tsx: true,
|
|
637
|
-
decorators: true,
|
|
638
|
-
dynamicImport: true
|
|
639
|
-
};
|
|
640
|
-
if (ext === ".jsx") return {
|
|
641
|
-
syntax: "ecmascript",
|
|
642
|
-
jsx: true,
|
|
643
|
-
decorators: true,
|
|
644
|
-
importAssertions: true
|
|
645
|
-
};
|
|
646
|
-
return {
|
|
647
|
-
syntax: "ecmascript",
|
|
648
|
-
jsx: true,
|
|
649
|
-
decorators: true,
|
|
650
|
-
importAssertions: true
|
|
651
|
-
};
|
|
741
|
+
function extractJSXAttributeValue(value) {
|
|
742
|
+
if (!value) return true;
|
|
743
|
+
switch (value.type) {
|
|
744
|
+
case "StringLiteral": return value.value;
|
|
745
|
+
case "JSXExpressionContainer": return extractExpressionValue(value.expression);
|
|
746
|
+
default: return "[complex]";
|
|
747
|
+
}
|
|
652
748
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
749
|
+
/**
|
|
750
|
+
* Extracts a readable value from an expression
|
|
751
|
+
*/
|
|
752
|
+
function extractExpressionValue(expr) {
|
|
753
|
+
if (!expr) return "[unknown]";
|
|
754
|
+
switch (expr.type) {
|
|
755
|
+
case "StringLiteral":
|
|
756
|
+
case "NumericLiteral":
|
|
757
|
+
case "BooleanLiteral": return expr.value;
|
|
758
|
+
case "Identifier": return `{${expr.value}}`;
|
|
759
|
+
case "ArrowFunctionExpression":
|
|
760
|
+
case "FunctionExpression": return "[function]";
|
|
761
|
+
case "ObjectExpression": return "[object]";
|
|
762
|
+
case "ArrayExpression": return "[array]";
|
|
763
|
+
default: return "[expression]";
|
|
764
|
+
}
|
|
657
765
|
}
|
|
658
|
-
|
|
659
|
-
|
|
766
|
+
/**
|
|
767
|
+
* Determines the context where a component is being used
|
|
768
|
+
*/
|
|
769
|
+
function getUsageContext(parent) {
|
|
770
|
+
if (!parent) return "direct";
|
|
771
|
+
switch (parent.type) {
|
|
772
|
+
case "ConditionalExpression": return "conditional";
|
|
773
|
+
case "ArrayExpression": return "array";
|
|
774
|
+
case "ObjectExpression": return "object";
|
|
775
|
+
case "CallExpression": return "hoc";
|
|
776
|
+
case "VariableDeclarator": return "variable";
|
|
777
|
+
default: return "jsx";
|
|
778
|
+
}
|
|
660
779
|
}
|
|
661
780
|
//#endregion
|
|
662
|
-
//#region src/
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
781
|
+
//#region src/swc-parser/patterns/props.ts
|
|
782
|
+
/**
|
|
783
|
+
* Analyzes props in detail for a component
|
|
784
|
+
*/
|
|
785
|
+
function analyzePropsInDetail(attributes, componentName, state) {
|
|
786
|
+
const analysis = {
|
|
787
|
+
namedProps: [],
|
|
788
|
+
hasSpread: false,
|
|
789
|
+
hasComplexProps: false,
|
|
790
|
+
hasEventHandlers: false,
|
|
791
|
+
propDetails: []
|
|
792
|
+
};
|
|
793
|
+
if (!attributes) return analysis;
|
|
794
|
+
for (const attr of attributes) if (attr.type === "JSXAttribute") {
|
|
795
|
+
const propName = attr.name?.value || attr.name?.name?.value;
|
|
796
|
+
if (propName) {
|
|
797
|
+
analysis.namedProps.push(propName);
|
|
798
|
+
const propDetail = {
|
|
799
|
+
name: propName,
|
|
800
|
+
type: getPropType(attr.value),
|
|
801
|
+
isEventHandler: propName.startsWith("on"),
|
|
802
|
+
isComplex: isComplexProp(attr.value)
|
|
803
|
+
};
|
|
804
|
+
if (propDetail.isEventHandler) analysis.hasEventHandlers = true;
|
|
805
|
+
if (propDetail.isComplex) analysis.hasComplexProps = true;
|
|
806
|
+
analysis.propDetails.push(propDetail);
|
|
807
|
+
}
|
|
808
|
+
} else if (attr.type === "SpreadElement") {
|
|
809
|
+
analysis.hasSpread = true;
|
|
810
|
+
analysis.propDetails.push({
|
|
811
|
+
name: "...",
|
|
812
|
+
type: "spread",
|
|
813
|
+
isSpread: true,
|
|
814
|
+
isComplex: true,
|
|
815
|
+
isEventHandler: false,
|
|
816
|
+
warning: "Spread props cannot be statically analyzed"
|
|
817
|
+
});
|
|
818
|
+
analysis.hasComplexProps = true;
|
|
669
819
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
function findComponentSource(componentName, report, availablePackages) {
|
|
673
|
-
const namedImport = report.patterns.imports.named.find((imp) => imp.name === componentName);
|
|
674
|
-
if (namedImport) return resolvePackageFromImportPath(namedImport.source, availablePackages);
|
|
675
|
-
const defaultImport = report.patterns.imports.default.find((imp) => imp.name === componentName);
|
|
676
|
-
if (defaultImport) return resolvePackageFromImportPath(defaultImport.source, availablePackages);
|
|
677
|
-
const aliasedImport = report.patterns.imports.aliased.find((imp) => imp.local === componentName);
|
|
678
|
-
if (aliasedImport) return resolvePackageFromImportPath(aliasedImport.source, availablePackages);
|
|
679
|
-
return "unknown";
|
|
820
|
+
state.usagePatterns.propsAnalysis.set(componentName, analysis);
|
|
821
|
+
return analysis;
|
|
680
822
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
if (
|
|
823
|
+
/**
|
|
824
|
+
* Determines the type of a prop value
|
|
825
|
+
*/
|
|
826
|
+
function getPropType(value) {
|
|
827
|
+
if (!value) return "boolean";
|
|
828
|
+
switch (value.type) {
|
|
829
|
+
case "StringLiteral": return "string";
|
|
830
|
+
case "JSXExpressionContainer": {
|
|
831
|
+
const expr = value.expression;
|
|
832
|
+
if (!expr) return "unknown";
|
|
833
|
+
switch (expr.type) {
|
|
834
|
+
case "NumericLiteral": return "number";
|
|
835
|
+
case "BooleanLiteral": return "boolean";
|
|
836
|
+
case "StringLiteral": return "string";
|
|
837
|
+
case "ArrowFunctionExpression":
|
|
838
|
+
case "FunctionExpression": return "function";
|
|
839
|
+
case "ObjectExpression": return "object";
|
|
840
|
+
case "ArrayExpression": return "array";
|
|
841
|
+
case "Identifier": return "variable";
|
|
842
|
+
default: return "expression";
|
|
843
|
+
}
|
|
691
844
|
}
|
|
845
|
+
default: return "unknown";
|
|
692
846
|
}
|
|
693
|
-
return null;
|
|
694
847
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
existing.componentCount++;
|
|
705
|
-
existing.usageCount += component.count;
|
|
706
|
-
existing.components.push(component.name);
|
|
707
|
-
} else {
|
|
708
|
-
const isInternal = internalPatterns.length > 0 ? micromatch.isMatch(component.source, internalPatterns) : false;
|
|
709
|
-
const allVersions = multiVersions[component.source] ?? [];
|
|
710
|
-
const hasVersionConflict = allVersions.length > 1;
|
|
711
|
-
packageMap.set(component.source, {
|
|
712
|
-
packageName: component.source,
|
|
713
|
-
version: getPackageVersion(component.source, versions),
|
|
714
|
-
componentCount: 1,
|
|
715
|
-
usageCount: component.count,
|
|
716
|
-
percentage: 0,
|
|
717
|
-
components: [component.name],
|
|
718
|
-
internal: isInternal,
|
|
719
|
-
hasVersionConflict,
|
|
720
|
-
allVersions
|
|
721
|
-
});
|
|
722
|
-
}
|
|
848
|
+
/**
|
|
849
|
+
* Checks if a prop value is complex (object, array, call, conditional)
|
|
850
|
+
*/
|
|
851
|
+
function isComplexProp(value) {
|
|
852
|
+
if (!value) return false;
|
|
853
|
+
if (value.type === "JSXExpressionContainer") {
|
|
854
|
+
const expr = value.expression;
|
|
855
|
+
if (!expr) return false;
|
|
856
|
+
return expr.type === "ObjectExpression" || expr.type === "ArrayExpression" || expr.type === "CallExpression" || expr.type === "ConditionalExpression";
|
|
723
857
|
}
|
|
724
|
-
|
|
725
|
-
const totalExternalUsage = distribution.reduce((sum, pkg) => sum + pkg.usageCount, 0);
|
|
726
|
-
for (const pkg of distribution) pkg.percentage = totalExternalUsage > 0 ? pkg.usageCount / totalExternalUsage * 100 : 0;
|
|
727
|
-
return distribution.sort((a, b) => b.usageCount - a.usageCount);
|
|
858
|
+
return false;
|
|
728
859
|
}
|
|
729
860
|
//#endregion
|
|
730
|
-
//#region src/
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
const matches = [];
|
|
737
|
-
for (const pattern of patterns) {
|
|
738
|
-
const found = globSync(pattern, {
|
|
739
|
-
cwd: repoPath,
|
|
740
|
-
nodir: true,
|
|
741
|
-
ignore
|
|
742
|
-
});
|
|
743
|
-
matches.push(...found.map((f) => f.replace(/\\/g, "/")));
|
|
744
|
-
}
|
|
745
|
-
return [...new Set(matches)];
|
|
861
|
+
//#region src/swc-parser/patterns/jsx.ts
|
|
862
|
+
/**
|
|
863
|
+
* Analyzes JSX element usage
|
|
864
|
+
*/
|
|
865
|
+
function analyzeJSXElement(node, state) {
|
|
866
|
+
if (node.opening) analyzeJSXOpeningElement(node.opening, state, node);
|
|
746
867
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
868
|
+
/**
|
|
869
|
+
* Analyzes JSX opening element and tracks component usage
|
|
870
|
+
*/
|
|
871
|
+
function analyzeJSXOpeningElement(node, state, parent) {
|
|
872
|
+
const elementName = getJSXElementName(node.name);
|
|
873
|
+
if (!state.componentNames.has(elementName) && !isMemberExpressionComponent(node.name, state)) return;
|
|
874
|
+
const propsAnalysis = analyzePropsInDetail(node.attributes, elementName, state);
|
|
875
|
+
const usage = {
|
|
876
|
+
component: elementName,
|
|
877
|
+
props: extractJSXProps(node.attributes).map((p) => p.name),
|
|
878
|
+
propsAnalysis,
|
|
879
|
+
line: node.span?.start || 0,
|
|
880
|
+
context: getUsageContext(parent)
|
|
881
|
+
};
|
|
882
|
+
if (!state.usagePatterns.jsxUsage.has(elementName)) state.usagePatterns.jsxUsage.set(elementName, usage);
|
|
754
883
|
}
|
|
755
884
|
//#endregion
|
|
756
|
-
//#region src/
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
type: "forbid_files",
|
|
763
|
-
severity: rule.severity,
|
|
764
|
-
patterns: rule.patterns,
|
|
765
|
-
message: rule.message,
|
|
766
|
-
matchedFiles: matches
|
|
767
|
-
});
|
|
768
|
-
}
|
|
769
|
-
for (const rule of toArray(rulesConfig.require_files)) if (findMatches(rule.patterns, repoPath, excludes).length === 0) violations.push({
|
|
770
|
-
type: "require_files",
|
|
771
|
-
severity: rule.severity,
|
|
772
|
-
patterns: rule.patterns,
|
|
773
|
-
message: rule.message,
|
|
774
|
-
matchedFiles: []
|
|
775
|
-
});
|
|
776
|
-
for (const rule of toArray(rulesConfig.allow_files)) if (findMatches(rule.patterns, repoPath, excludes).length === 0) violations.push({
|
|
777
|
-
type: "allow_files",
|
|
778
|
-
severity: rule.severity,
|
|
779
|
-
patterns: rule.patterns,
|
|
780
|
-
message: rule.message,
|
|
781
|
-
matchedFiles: []
|
|
782
|
-
});
|
|
783
|
-
return violations;
|
|
885
|
+
//#region src/swc-parser/utils/matchers.ts
|
|
886
|
+
/**
|
|
887
|
+
* Checks if a name is a known component from imports
|
|
888
|
+
*/
|
|
889
|
+
function isKnownComponent(name, state) {
|
|
890
|
+
return state.componentNames.has(name) || state.allIdentifiers.has(name);
|
|
784
891
|
}
|
|
785
892
|
//#endregion
|
|
786
|
-
//#region src/
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
type
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
893
|
+
//#region src/swc-parser/patterns/variables.ts
|
|
894
|
+
/**
|
|
895
|
+
* Analyzes variable declarations for component assignments
|
|
896
|
+
*/
|
|
897
|
+
function analyzeVariableDeclaration(node, state) {
|
|
898
|
+
if (!node.declarations) return;
|
|
899
|
+
for (const decl of node.declarations) {
|
|
900
|
+
if (decl.id?.type === "Identifier") {
|
|
901
|
+
const varName = decl.id.value;
|
|
902
|
+
if (decl.init) {
|
|
903
|
+
const assignment = extractAssignmentInfo(decl.init);
|
|
904
|
+
if (assignment && isKnownComponent(assignment, state)) {
|
|
905
|
+
state.usagePatterns.variableAssignments.set(varName, {
|
|
906
|
+
assignment,
|
|
907
|
+
line: node.span?.start || 0
|
|
908
|
+
});
|
|
909
|
+
state.componentNames.add(varName);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
if (decl.id?.type === "ObjectPattern") analyzeDestructuringPattern(decl.id, decl.init, state);
|
|
914
|
+
}
|
|
799
915
|
}
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
if (
|
|
805
|
-
const
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
916
|
+
/**
|
|
917
|
+
* Analyzes destructuring patterns
|
|
918
|
+
*/
|
|
919
|
+
function analyzeDestructuringPattern(pattern, init, state) {
|
|
920
|
+
if (!pattern.properties) return;
|
|
921
|
+
for (const prop of pattern.properties) if (prop.type === "AssignmentPatternProperty" && prop.key?.type === "Identifier") {
|
|
922
|
+
const propName = prop.key.value;
|
|
923
|
+
if (init?.type === "Identifier" && state.allIdentifiers.has(init.value)) {
|
|
924
|
+
state.usagePatterns.destructuredUsage.add({
|
|
925
|
+
property: propName,
|
|
926
|
+
source: init.value,
|
|
927
|
+
line: pattern.span?.start || 0
|
|
928
|
+
});
|
|
929
|
+
state.componentNames.add(propName);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Extracts assignment information from various node types
|
|
935
|
+
*/
|
|
936
|
+
function extractAssignmentInfo(node) {
|
|
937
|
+
switch (node.type) {
|
|
938
|
+
case "Identifier": return node.value;
|
|
939
|
+
case "MemberExpression": return `${extractAssignmentInfo(node.object)}.${node.property.value}`;
|
|
940
|
+
case "ConditionalExpression": return `${extractAssignmentInfo(node.consequent)} | ${extractAssignmentInfo(node.alternate)}`;
|
|
941
|
+
default: return null;
|
|
942
|
+
}
|
|
814
943
|
}
|
|
815
944
|
//#endregion
|
|
816
|
-
//#region src/
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
matchedFiles: [],
|
|
828
|
-
requiredRange: rule.range
|
|
829
|
-
}];
|
|
830
|
-
const minVer = semver.minVersion(nodeRange);
|
|
831
|
-
if (!minVer || !semver.satisfies(minVer, rule.range)) return [{
|
|
832
|
-
type: "engine_version",
|
|
833
|
-
severity: rule.severity,
|
|
834
|
-
patterns: [],
|
|
835
|
-
message: rule.message,
|
|
836
|
-
matchedFiles: [],
|
|
837
|
-
installedRange: nodeRange,
|
|
838
|
-
requiredRange: rule.range
|
|
839
|
-
}];
|
|
840
|
-
return [];
|
|
945
|
+
//#region src/swc-parser/patterns/conditionals.ts
|
|
946
|
+
/**
|
|
947
|
+
* Analyzes conditional expressions (ternary operators) with components
|
|
948
|
+
*/
|
|
949
|
+
function analyzeConditionalExpression(node, state) {
|
|
950
|
+
const consequent = node.consequent?.type === "Identifier" ? node.consequent.value : null;
|
|
951
|
+
const alternate = node.alternate?.type === "Identifier" ? node.alternate.value : null;
|
|
952
|
+
if (consequent && state.componentNames.has(consequent) || alternate && state.componentNames.has(alternate)) state.usagePatterns.conditionalUsage.add({
|
|
953
|
+
consequent: consequent || "",
|
|
954
|
+
alternate: alternate || "",
|
|
955
|
+
line: node.span?.start || 0
|
|
841
956
|
});
|
|
842
957
|
}
|
|
843
958
|
//#endregion
|
|
844
|
-
//#region src/
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
959
|
+
//#region src/swc-parser/patterns/collections.ts
|
|
960
|
+
/**
|
|
961
|
+
* Analyzes array expressions containing components
|
|
962
|
+
*/
|
|
963
|
+
function analyzeArrayExpression(node, state) {
|
|
964
|
+
if (node.elements?.some((elem) => {
|
|
965
|
+
if (elem?.expression?.type === "Identifier") return state.componentNames.has(elem.expression.value);
|
|
966
|
+
return false;
|
|
967
|
+
})) state.usagePatterns.arrayMappings.add({
|
|
968
|
+
components: node.elements?.map((elem) => elem?.expression?.value).filter(Boolean),
|
|
969
|
+
line: node.span?.start || 0
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Analyzes object expressions with component mappings
|
|
974
|
+
*/
|
|
975
|
+
function analyzeObjectExpression(node, state) {
|
|
976
|
+
const componentProps = node.properties?.filter((prop) => {
|
|
977
|
+
if (prop.type === "KeyValueProperty" && prop.value?.type === "Identifier") return state.componentNames.has(prop.value.value);
|
|
978
|
+
return false;
|
|
979
|
+
});
|
|
980
|
+
if (componentProps?.length > 0) state.usagePatterns.objectMappings.add({
|
|
981
|
+
mappings: componentProps.map((prop) => ({
|
|
982
|
+
key: prop.key?.value || "[computed]",
|
|
983
|
+
component: prop.value?.value
|
|
984
|
+
})),
|
|
985
|
+
line: node.span?.start || 0
|
|
986
|
+
});
|
|
852
987
|
}
|
|
853
988
|
//#endregion
|
|
854
|
-
//#region src/
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
989
|
+
//#region src/swc-parser/patterns/lazy-dynamic.ts
|
|
990
|
+
/**
|
|
991
|
+
* Analyzes React.lazy() imports
|
|
992
|
+
*/
|
|
993
|
+
function analyzeLazyImport(node, state) {
|
|
994
|
+
const arg = node.arguments?.[0]?.expression;
|
|
995
|
+
if (arg?.type === "ArrowFunctionExpression" && arg.body?.type === "CallExpression") {
|
|
996
|
+
const importCall = arg.body;
|
|
997
|
+
if (importCall.callee?.type === "Import") {
|
|
998
|
+
const source = importCall.arguments?.[0]?.expression?.value;
|
|
999
|
+
if (source) state.usagePatterns.lazyImports.add({
|
|
1000
|
+
source,
|
|
1001
|
+
line: node.span?.start || 0
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
866
1004
|
}
|
|
867
|
-
return violations;
|
|
868
1005
|
}
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
const
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
patterns: rule.patterns,
|
|
878
|
-
message: rule.message,
|
|
879
|
-
matchedFiles: []
|
|
1006
|
+
/**
|
|
1007
|
+
* Analyzes dynamic import() calls
|
|
1008
|
+
*/
|
|
1009
|
+
function analyzeDynamicImport(node, state) {
|
|
1010
|
+
const source = node.arguments?.[0]?.expression?.value;
|
|
1011
|
+
if (source) state.usagePatterns.dynamicImports.add({
|
|
1012
|
+
source,
|
|
1013
|
+
line: node.span?.start || 0
|
|
880
1014
|
});
|
|
881
|
-
return violations;
|
|
882
1015
|
}
|
|
883
1016
|
//#endregion
|
|
884
|
-
//#region src/
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
increment(patternMap, "usage.arrays", report.patterns.usage.arrays.length);
|
|
895
|
-
increment(patternMap, "usage.objects", report.patterns.usage.objects.length);
|
|
896
|
-
increment(patternMap, "advanced.lazy", report.patterns.advanced.lazy.length);
|
|
897
|
-
increment(patternMap, "advanced.dynamic", report.patterns.advanced.dynamic.length);
|
|
898
|
-
increment(patternMap, "advanced.hoc", report.patterns.advanced.hoc.length);
|
|
899
|
-
increment(patternMap, "advanced.memo", report.patterns.advanced.memo.length);
|
|
900
|
-
increment(patternMap, "advanced.forwardRef", report.patterns.advanced.forwardRef.length);
|
|
901
|
-
increment(patternMap, "advanced.portal", report.patterns.advanced.portal.length);
|
|
1017
|
+
//#region src/swc-parser/patterns/advanced.ts
|
|
1018
|
+
/**
|
|
1019
|
+
* Analyzes Higher-Order Component (HOC) usage
|
|
1020
|
+
*/
|
|
1021
|
+
function analyzeHOCUsage(node, state) {
|
|
1022
|
+
state.usagePatterns.hocUsage.add({
|
|
1023
|
+
function: node.callee?.value || "[unknown]",
|
|
1024
|
+
component: node.arguments?.[0]?.expression?.value || "[unknown]",
|
|
1025
|
+
line: node.span?.start || 0
|
|
1026
|
+
});
|
|
902
1027
|
}
|
|
903
|
-
|
|
904
|
-
|
|
1028
|
+
/**
|
|
1029
|
+
* Analyzes React.memo() usage
|
|
1030
|
+
*/
|
|
1031
|
+
function analyzeMemoUsage(node, state) {
|
|
1032
|
+
const component = node.arguments?.[0]?.expression;
|
|
1033
|
+
if (component?.type === "Identifier" && state.componentNames.has(component.value)) state.usagePatterns.memoizedComponents.add({
|
|
1034
|
+
component: component.value,
|
|
1035
|
+
line: node.span?.start || 0
|
|
1036
|
+
});
|
|
905
1037
|
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
"imports.aliased": "Aliased Imports",
|
|
912
|
-
"usage.jsx": "JSX Usage",
|
|
913
|
-
"usage.variables": "Variable Assignments",
|
|
914
|
-
"usage.destructuring": "Destructuring",
|
|
915
|
-
"usage.conditional": "Conditional Usage",
|
|
916
|
-
"usage.arrays": "Array Mappings",
|
|
917
|
-
"usage.objects": "Object Mappings",
|
|
918
|
-
"advanced.lazy": "Lazy Loading",
|
|
919
|
-
"advanced.dynamic": "Dynamic Imports",
|
|
920
|
-
"advanced.hoc": "Higher-Order Components",
|
|
921
|
-
"advanced.memo": "Memoized Components",
|
|
922
|
-
"advanced.forwardRef": "Forward Refs",
|
|
923
|
-
"advanced.portal": "Portal Usage"
|
|
924
|
-
}[patternType] || patternType;
|
|
1038
|
+
/**
|
|
1039
|
+
* Analyzes React.forwardRef() usage
|
|
1040
|
+
*/
|
|
1041
|
+
function analyzeForwardRefUsage(node, state) {
|
|
1042
|
+
state.usagePatterns.forwardedRefs.add({ line: node.span?.start || 0 });
|
|
925
1043
|
}
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1044
|
+
/**
|
|
1045
|
+
* Analyzes ReactDOM.createPortal() usage
|
|
1046
|
+
*/
|
|
1047
|
+
function analyzePortalUsage(node, state) {
|
|
1048
|
+
state.usagePatterns.portalUsage.add({ line: node.span?.start || 0 });
|
|
930
1049
|
}
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
entries.sort((a, b) => b.count - a.count);
|
|
946
|
-
return {
|
|
947
|
-
name: vc.name,
|
|
948
|
-
packages: vc.packages,
|
|
949
|
-
entries,
|
|
950
|
-
totalCount
|
|
951
|
-
};
|
|
952
|
-
});
|
|
1050
|
+
/**
|
|
1051
|
+
* Analyzes member expression access (e.g., Foundation.Button)
|
|
1052
|
+
*/
|
|
1053
|
+
function analyzeMemberExpression(node, state) {
|
|
1054
|
+
if (node.object?.type === "Identifier" && state.allIdentifiers.has(node.object.value)) {
|
|
1055
|
+
const propertyName = node.property?.value;
|
|
1056
|
+
if (propertyName) state.componentNames.add(propertyName);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Checks if a node represents HOC pattern
|
|
1061
|
+
*/
|
|
1062
|
+
function isHOCPattern(node, state) {
|
|
1063
|
+
return node.callee?.type === "Identifier" && node.arguments?.some((arg) => arg.expression?.type === "Identifier" && state.componentNames.has(arg.expression.value));
|
|
953
1064
|
}
|
|
954
1065
|
//#endregion
|
|
955
|
-
//#region src/
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
if (existing) existing.count++;
|
|
969
|
-
else {
|
|
970
|
-
const source = findComponentSource(jsx.component, report, availablePackages);
|
|
971
|
-
componentUsageMap.set(key, {
|
|
972
|
-
name: jsx.component,
|
|
973
|
-
source,
|
|
974
|
-
count: 1,
|
|
975
|
-
files: /* @__PURE__ */ new Set()
|
|
1066
|
+
//#region src/swc-parser/core/visitor.ts
|
|
1067
|
+
/**
|
|
1068
|
+
* Main AST visitor that routes nodes to appropriate pattern analyzers
|
|
1069
|
+
*/
|
|
1070
|
+
function visitNode(node, state, context = {}) {
|
|
1071
|
+
if (!node) return;
|
|
1072
|
+
switch (node.type) {
|
|
1073
|
+
case "Module":
|
|
1074
|
+
if (node.body) {
|
|
1075
|
+
for (const item of node.body) if (item.type === "ImportDeclaration") visitNode(item, state, context);
|
|
1076
|
+
for (const item of node.body) if (item.type !== "ImportDeclaration") visitNode(item, state, {
|
|
1077
|
+
...context,
|
|
1078
|
+
parent: node
|
|
976
1079
|
});
|
|
977
1080
|
}
|
|
978
|
-
|
|
979
|
-
|
|
1081
|
+
break;
|
|
1082
|
+
case "ImportDeclaration":
|
|
1083
|
+
analyzeImportDeclaration(node, state);
|
|
1084
|
+
break;
|
|
1085
|
+
case "CallExpression":
|
|
1086
|
+
analyzeCallExpression(node, state, context);
|
|
1087
|
+
break;
|
|
1088
|
+
case "VariableDeclaration":
|
|
1089
|
+
analyzeVariableDeclaration(node, state);
|
|
1090
|
+
visitChildren(node, state, context);
|
|
1091
|
+
break;
|
|
1092
|
+
case "JSXElement":
|
|
1093
|
+
case "JSXFragment":
|
|
1094
|
+
analyzeJSXElement(node, state);
|
|
1095
|
+
visitChildren(node, state, context);
|
|
1096
|
+
break;
|
|
1097
|
+
case "JSXOpeningElement":
|
|
1098
|
+
analyzeJSXOpeningElement(node, state, context.parent);
|
|
1099
|
+
break;
|
|
1100
|
+
case "ArrayExpression":
|
|
1101
|
+
analyzeArrayExpression(node, state);
|
|
1102
|
+
visitChildren(node, state, context);
|
|
1103
|
+
break;
|
|
1104
|
+
case "ObjectExpression":
|
|
1105
|
+
analyzeObjectExpression(node, state);
|
|
1106
|
+
visitChildren(node, state, context);
|
|
1107
|
+
break;
|
|
1108
|
+
case "MemberExpression":
|
|
1109
|
+
analyzeMemberExpression(node, state);
|
|
1110
|
+
visitChildren(node, state, context);
|
|
1111
|
+
break;
|
|
1112
|
+
case "ConditionalExpression":
|
|
1113
|
+
analyzeConditionalExpression(node, state);
|
|
1114
|
+
visitChildren(node, state, context);
|
|
1115
|
+
break;
|
|
1116
|
+
case "FunctionDeclaration":
|
|
1117
|
+
case "ClassDeclaration":
|
|
1118
|
+
case "ExpressionStatement":
|
|
1119
|
+
case "ReturnStatement":
|
|
1120
|
+
case "VariableDeclarator":
|
|
1121
|
+
case "ArrowFunctionExpression":
|
|
1122
|
+
case "FunctionExpression":
|
|
1123
|
+
visitChildren(node, state, {
|
|
1124
|
+
...context,
|
|
1125
|
+
parent: node
|
|
1126
|
+
});
|
|
1127
|
+
break;
|
|
1128
|
+
default:
|
|
1129
|
+
visitChildren(node, state, context);
|
|
1130
|
+
break;
|
|
980
1131
|
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Analyzes call expressions and routes to specific analyzers
|
|
1135
|
+
*/
|
|
1136
|
+
function analyzeCallExpression(node, state, context) {
|
|
1137
|
+
if (node.callee?.value === "lazy" || node.callee?.object?.value === "React" && node.callee?.property?.value === "lazy") analyzeLazyImport(node, state);
|
|
1138
|
+
if (node.callee?.type === "Import") analyzeDynamicImport(node, state);
|
|
1139
|
+
if (isHOCPattern(node, state)) analyzeHOCUsage(node, state);
|
|
1140
|
+
if (node.callee?.object?.value === "React") {
|
|
1141
|
+
if (node.callee?.property?.value === "memo") analyzeMemoUsage(node, state);
|
|
1142
|
+
else if (node.callee?.property?.value === "forwardRef") analyzeForwardRefUsage(node, state);
|
|
1143
|
+
}
|
|
1144
|
+
if (node.callee?.property?.value === "createPortal" || node.callee?.value === "createPortal") analyzePortalUsage(node, state);
|
|
1145
|
+
visitChildren(node, state, context);
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Visits all children of a node
|
|
1149
|
+
*/
|
|
1150
|
+
function visitChildren(node, state, context) {
|
|
1151
|
+
if (!node) return;
|
|
1152
|
+
for (const key in node) {
|
|
1153
|
+
const value = node[key];
|
|
1154
|
+
if (Array.isArray(value)) {
|
|
1155
|
+
for (const item of value) if (item && typeof item === "object") visitNode(item, state, {
|
|
1156
|
+
...context,
|
|
1157
|
+
parent: node
|
|
1158
|
+
});
|
|
1159
|
+
} else if (value && typeof value === "object" && value.type) visitNode(value, state, {
|
|
1160
|
+
...context,
|
|
1161
|
+
parent: node
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
//#endregion
|
|
1166
|
+
//#region src/swc-parser/core/report.ts
|
|
1167
|
+
/**
|
|
1168
|
+
* Generates a comprehensive usage report from parser state
|
|
1169
|
+
*/
|
|
1170
|
+
function generateReport(state, filePath) {
|
|
992
1171
|
return {
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1172
|
+
filePath,
|
|
1173
|
+
summary: {
|
|
1174
|
+
totalImports: state.usagePatterns.defaultImports.size + state.usagePatterns.namedImports.size + state.usagePatterns.namespaceImports.size,
|
|
1175
|
+
totalComponents: state.componentNames.size,
|
|
1176
|
+
totalUsagePatterns: calculateTotalPatterns(state)
|
|
1177
|
+
},
|
|
1178
|
+
patterns: {
|
|
1179
|
+
imports: {
|
|
1180
|
+
default: Array.from(state.usagePatterns.defaultImports),
|
|
1181
|
+
named: Array.from(state.usagePatterns.namedImports),
|
|
1182
|
+
namespace: Array.from(state.usagePatterns.namespaceImports),
|
|
1183
|
+
aliased: Array.from(state.usagePatterns.aliasedImports.values())
|
|
1184
|
+
},
|
|
1185
|
+
usage: {
|
|
1186
|
+
jsx: Array.from(state.usagePatterns.jsxUsage.values()),
|
|
1187
|
+
variables: Array.from(state.usagePatterns.variableAssignments.entries()).map(([key, value]) => ({
|
|
1188
|
+
variable: key,
|
|
1189
|
+
assignment: value.assignment
|
|
1190
|
+
})),
|
|
1191
|
+
destructuring: Array.from(state.usagePatterns.destructuredUsage),
|
|
1192
|
+
conditional: Array.from(state.usagePatterns.conditionalUsage),
|
|
1193
|
+
arrays: Array.from(state.usagePatterns.arrayMappings),
|
|
1194
|
+
objects: Array.from(state.usagePatterns.objectMappings)
|
|
1195
|
+
},
|
|
1196
|
+
advanced: {
|
|
1197
|
+
lazy: Array.from(state.usagePatterns.lazyImports),
|
|
1198
|
+
dynamic: Array.from(state.usagePatterns.dynamicImports),
|
|
1199
|
+
hoc: Array.from(state.usagePatterns.hocUsage),
|
|
1200
|
+
memo: Array.from(state.usagePatterns.memoizedComponents),
|
|
1201
|
+
forwardRef: Array.from(state.usagePatterns.forwardedRefs),
|
|
1202
|
+
portal: Array.from(state.usagePatterns.portalUsage)
|
|
1203
|
+
},
|
|
1204
|
+
props: Array.from(state.usagePatterns.propsAnalysis.entries()).map(([component, analysis]) => ({
|
|
1205
|
+
component,
|
|
1206
|
+
analysis
|
|
1207
|
+
}))
|
|
1208
|
+
},
|
|
1209
|
+
components: Array.from(state.componentNames).sort()
|
|
1006
1210
|
};
|
|
1007
1211
|
}
|
|
1008
|
-
//#endregion
|
|
1009
|
-
//#region src/utils/format-utils.ts
|
|
1010
1212
|
/**
|
|
1011
|
-
*
|
|
1012
|
-
* @param num - Number to format
|
|
1013
|
-
* @returns Formatted string (e.g., 1,234,567)
|
|
1213
|
+
* Calculates total number of usage patterns found
|
|
1014
1214
|
*/
|
|
1015
|
-
function
|
|
1016
|
-
return
|
|
1215
|
+
function calculateTotalPatterns(state) {
|
|
1216
|
+
return Object.values(state.usagePatterns).reduce((sum, collection) => {
|
|
1217
|
+
if (collection instanceof Set || collection instanceof Map) return sum + collection.size;
|
|
1218
|
+
return sum;
|
|
1219
|
+
}, 0);
|
|
1017
1220
|
}
|
|
1018
1221
|
//#endregion
|
|
1019
|
-
//#region src/
|
|
1020
|
-
function
|
|
1021
|
-
|
|
1222
|
+
//#region src/swc-parser/index.ts
|
|
1223
|
+
function swcOptionsForFile(filePath) {
|
|
1224
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
1225
|
+
if (ext === ".ts") return {
|
|
1226
|
+
syntax: "typescript",
|
|
1227
|
+
tsx: false,
|
|
1228
|
+
decorators: true,
|
|
1229
|
+
dynamicImport: true
|
|
1230
|
+
};
|
|
1231
|
+
if (ext === ".tsx") return {
|
|
1232
|
+
syntax: "typescript",
|
|
1233
|
+
tsx: true,
|
|
1234
|
+
decorators: true,
|
|
1235
|
+
dynamicImport: true
|
|
1236
|
+
};
|
|
1237
|
+
if (ext === ".jsx") return {
|
|
1238
|
+
syntax: "ecmascript",
|
|
1239
|
+
jsx: true,
|
|
1240
|
+
decorators: true,
|
|
1241
|
+
importAssertions: true
|
|
1242
|
+
};
|
|
1243
|
+
return {
|
|
1244
|
+
syntax: "ecmascript",
|
|
1245
|
+
jsx: true,
|
|
1246
|
+
decorators: true,
|
|
1247
|
+
importAssertions: true
|
|
1248
|
+
};
|
|
1022
1249
|
}
|
|
1023
|
-
function
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1250
|
+
function parseCode(code, filePath = "file.tsx") {
|
|
1251
|
+
const state = createState();
|
|
1252
|
+
visitNode(parseSync(code, swcOptionsForFile(filePath)), state);
|
|
1253
|
+
return generateReport(state, filePath);
|
|
1254
|
+
}
|
|
1255
|
+
function parseFile(filePath) {
|
|
1256
|
+
return parseCode(fs.readFileSync(filePath, "utf8"), filePath);
|
|
1257
|
+
}
|
|
1258
|
+
//#endregion
|
|
1259
|
+
//#region src/utils/package-distribution.ts
|
|
1260
|
+
function resolvePackageFromImportPath(importPath, availablePackages) {
|
|
1261
|
+
if (importPath.startsWith(".") || importPath.startsWith("/")) return "local";
|
|
1262
|
+
const sortedPackages = [...availablePackages].sort((a, b) => b.length - a.length);
|
|
1263
|
+
for (const pkg of sortedPackages) {
|
|
1264
|
+
if (importPath === pkg) return pkg;
|
|
1265
|
+
if (importPath.startsWith(`${pkg}/`)) return pkg;
|
|
1266
|
+
}
|
|
1267
|
+
return "unknown";
|
|
1268
|
+
}
|
|
1269
|
+
function findComponentSource(componentName, report, availablePackages) {
|
|
1270
|
+
const namedImport = report.patterns.imports.named.find((imp) => imp.name === componentName);
|
|
1271
|
+
if (namedImport) return resolvePackageFromImportPath(namedImport.source, availablePackages);
|
|
1272
|
+
const defaultImport = report.patterns.imports.default.find((imp) => imp.name === componentName);
|
|
1273
|
+
if (defaultImport) return resolvePackageFromImportPath(defaultImport.source, availablePackages);
|
|
1274
|
+
const aliasedImport = report.patterns.imports.aliased.find((imp) => imp.local === componentName);
|
|
1275
|
+
if (aliasedImport) return resolvePackageFromImportPath(aliasedImport.source, availablePackages);
|
|
1276
|
+
return "unknown";
|
|
1277
|
+
}
|
|
1278
|
+
function getPackageVersion(packageName, versions) {
|
|
1279
|
+
if (versions[packageName]) return versions[packageName];
|
|
1280
|
+
if (packageName.includes("/")) {
|
|
1281
|
+
const parts = packageName.split("/");
|
|
1282
|
+
if (packageName.startsWith("@") && parts.length > 2) {
|
|
1283
|
+
const basePackage = `${parts[0]}/${parts[1]}`;
|
|
1284
|
+
if (versions[basePackage]) return versions[basePackage];
|
|
1285
|
+
}
|
|
1286
|
+
if (!packageName.startsWith("@") && parts.length > 1) {
|
|
1287
|
+
if (versions[parts[0]]) return versions[parts[0]];
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
return null;
|
|
1291
|
+
}
|
|
1292
|
+
function calculatePackageDistribution(componentUsageMap, versions, config, multiVersions = {}) {
|
|
1293
|
+
const ignorePatterns = config?.packages.ignore ?? [];
|
|
1294
|
+
const internalPatterns = config?.packages.internal ?? [];
|
|
1295
|
+
const packageMap = /* @__PURE__ */ new Map();
|
|
1296
|
+
for (const component of componentUsageMap.values()) {
|
|
1297
|
+
if (component.source === "unknown" || component.source === "local") continue;
|
|
1298
|
+
if (ignorePatterns.length > 0 && micromatch.isMatch(component.source, ignorePatterns)) continue;
|
|
1299
|
+
const existing = packageMap.get(component.source);
|
|
1300
|
+
if (existing) {
|
|
1301
|
+
existing.componentCount++;
|
|
1302
|
+
existing.usageCount += component.count;
|
|
1303
|
+
existing.components.push(component.name);
|
|
1304
|
+
} else {
|
|
1305
|
+
const isInternal = internalPatterns.length > 0 ? micromatch.isMatch(component.source, internalPatterns) : false;
|
|
1306
|
+
const allVersions = multiVersions[component.source] ?? [];
|
|
1307
|
+
const hasVersionConflict = allVersions.length > 1;
|
|
1308
|
+
packageMap.set(component.source, {
|
|
1309
|
+
packageName: component.source,
|
|
1310
|
+
version: getPackageVersion(component.source, versions),
|
|
1311
|
+
componentCount: 1,
|
|
1312
|
+
usageCount: component.count,
|
|
1313
|
+
percentage: 0,
|
|
1314
|
+
components: [component.name],
|
|
1315
|
+
internal: isInternal,
|
|
1316
|
+
hasVersionConflict,
|
|
1317
|
+
allVersions
|
|
1318
|
+
});
|
|
1030
1319
|
}
|
|
1031
|
-
}
|
|
1032
|
-
const
|
|
1033
|
-
const totalExternalUsage =
|
|
1034
|
-
|
|
1035
|
-
|
|
1320
|
+
}
|
|
1321
|
+
const distribution = Array.from(packageMap.values());
|
|
1322
|
+
const totalExternalUsage = distribution.reduce((sum, pkg) => sum + pkg.usageCount, 0);
|
|
1323
|
+
for (const pkg of distribution) pkg.percentage = totalExternalUsage > 0 ? pkg.usageCount / totalExternalUsage * 100 : 0;
|
|
1324
|
+
return distribution.sort((a, b) => b.usageCount - a.usageCount);
|
|
1036
1325
|
}
|
|
1037
1326
|
//#endregion
|
|
1038
|
-
//#region src/
|
|
1039
|
-
function
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
function printDetails(aggregated) {
|
|
1043
|
-
printHeader$3();
|
|
1044
|
-
console.log(chalk.cyan(` Total usage patterns: ${formatCount(aggregated.totalUsagePatterns)}`));
|
|
1045
|
-
for (const pattern of aggregated.patternCounts) if (pattern.count > 0) console.log(chalk.cyan(` ${pattern.displayName}: ${formatCount(pattern.count)}`));
|
|
1327
|
+
//#region src/rules/shared.ts
|
|
1328
|
+
function toArray(val) {
|
|
1329
|
+
if (!val) return [];
|
|
1330
|
+
return Array.isArray(val) ? val : [val];
|
|
1046
1331
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
if (maxValue === 0) {
|
|
1057
|
-
console.log(chalk.gray(" All values are zero"));
|
|
1058
|
-
return;
|
|
1332
|
+
function findMatches(patterns, repoPath, ignore) {
|
|
1333
|
+
const matches = [];
|
|
1334
|
+
for (const pattern of patterns) {
|
|
1335
|
+
const found = globSync(pattern, {
|
|
1336
|
+
cwd: repoPath,
|
|
1337
|
+
nodir: true,
|
|
1338
|
+
ignore
|
|
1339
|
+
});
|
|
1340
|
+
matches.push(...found.map((f) => f.replace(/\\/g, "/")));
|
|
1059
1341
|
}
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
const
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
console.log(`${paddedLabel} ${bar}${valueStr}\n`);
|
|
1342
|
+
return [...new Set(matches)];
|
|
1343
|
+
}
|
|
1344
|
+
function readPackageJson(repoPath) {
|
|
1345
|
+
try {
|
|
1346
|
+
const content = fs$1.readFileSync(path$1.join(repoPath, "package.json"), "utf-8");
|
|
1347
|
+
return JSON.parse(content);
|
|
1348
|
+
} catch {
|
|
1349
|
+
return null;
|
|
1069
1350
|
}
|
|
1070
1351
|
}
|
|
1071
1352
|
//#endregion
|
|
1072
|
-
//#region src/
|
|
1073
|
-
function
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
if (externalComponents.length === 0) {
|
|
1085
|
-
console.log(chalk.gray(" No external components found"));
|
|
1086
|
-
return;
|
|
1353
|
+
//#region src/rules/file-rules.ts
|
|
1354
|
+
function evaluateFileRules(repoPath, rulesConfig, excludes) {
|
|
1355
|
+
const violations = [];
|
|
1356
|
+
for (const rule of toArray(rulesConfig.detect_files)) {
|
|
1357
|
+
const matches = findMatches(rule.patterns, repoPath, excludes);
|
|
1358
|
+
if (matches.length > 0) violations.push({
|
|
1359
|
+
type: "detect_files",
|
|
1360
|
+
severity: rule.severity,
|
|
1361
|
+
patterns: rule.patterns,
|
|
1362
|
+
message: rule.message,
|
|
1363
|
+
matchedFiles: matches
|
|
1364
|
+
});
|
|
1087
1365
|
}
|
|
1088
|
-
const
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
]
|
|
1094
|
-
style: {
|
|
1095
|
-
head: ["cyan"],
|
|
1096
|
-
border: ["gray"]
|
|
1097
|
-
}
|
|
1098
|
-
});
|
|
1099
|
-
externalComponents.forEach((comp) => {
|
|
1100
|
-
table.push([
|
|
1101
|
-
comp.name,
|
|
1102
|
-
comp.source,
|
|
1103
|
-
comp.count.toString()
|
|
1104
|
-
]);
|
|
1366
|
+
for (const rule of toArray(rulesConfig.require_files)) if (findMatches(rule.patterns, repoPath, excludes).length === 0) violations.push({
|
|
1367
|
+
type: "require_files",
|
|
1368
|
+
severity: rule.severity,
|
|
1369
|
+
patterns: rule.patterns,
|
|
1370
|
+
message: rule.message,
|
|
1371
|
+
matchedFiles: []
|
|
1105
1372
|
});
|
|
1106
|
-
|
|
1373
|
+
return violations;
|
|
1107
1374
|
}
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1375
|
+
//#endregion
|
|
1376
|
+
//#region src/rules/script-rules.ts
|
|
1377
|
+
function evaluateScriptRules(repoPath, rulesConfig) {
|
|
1378
|
+
const rules = toArray(rulesConfig.require_scripts);
|
|
1379
|
+
if (rules.length === 0) return [];
|
|
1380
|
+
const pkg = readPackageJson(repoPath);
|
|
1381
|
+
const scriptKeys = Object.keys(pkg?.scripts ?? {});
|
|
1382
|
+
return rules.filter((rule) => !rule.patterns.some((p) => scriptKeys.some((k) => micromatch.isMatch(k, p)))).map((rule) => ({
|
|
1383
|
+
type: "require_scripts",
|
|
1384
|
+
severity: rule.severity,
|
|
1385
|
+
patterns: rule.patterns,
|
|
1386
|
+
message: rule.message,
|
|
1387
|
+
matchedFiles: []
|
|
1388
|
+
}));
|
|
1119
1389
|
}
|
|
1120
1390
|
//#endregion
|
|
1121
|
-
//#region src/
|
|
1122
|
-
|
|
1123
|
-
|
|
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
|
+
};
|
|
1124
1406
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
if (
|
|
1128
|
-
|
|
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);
|
|
1129
1411
|
}
|
|
1130
|
-
function
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
const
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1412
|
+
function evaluatePackageFieldRules(repoPath, rulesConfig) {
|
|
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 [];
|
|
1416
|
+
const pkg = readPackageJson(repoPath);
|
|
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
|
+
});
|
|
1141
1434
|
}
|
|
1142
|
-
});
|
|
1143
|
-
patterns.forEach((pattern) => {
|
|
1144
|
-
table.push([pattern.displayName, pattern.count.toString()]);
|
|
1145
|
-
});
|
|
1146
|
-
console.log(table.toString());
|
|
1147
|
-
const totalPatterns = patterns.reduce((sum, p) => sum + p.count, 0);
|
|
1148
|
-
console.log(chalk.gray(`\nTotal: ${totalPatterns} patterns detected`));
|
|
1149
|
-
}
|
|
1150
|
-
function printPatternsChart(patterns) {
|
|
1151
|
-
printHeader$1();
|
|
1152
|
-
if (patterns.length === 0) {
|
|
1153
|
-
console.log(chalk.gray(" No patterns found"));
|
|
1154
|
-
return;
|
|
1155
1435
|
}
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
value
|
|
1159
|
-
|
|
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;
|
|
1160
1449
|
}
|
|
1161
1450
|
//#endregion
|
|
1162
|
-
//#region src/
|
|
1163
|
-
function
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
const violations = aggregated.bannedPackageViolations;
|
|
1188
|
-
if (mode === "table") printPackagesTable(packages, violations);
|
|
1189
|
-
else if (mode === "chart") printPackagesChart(packages, violations);
|
|
1190
|
-
}
|
|
1191
|
-
function printPackagesTable(packages, violations) {
|
|
1192
|
-
printHeader();
|
|
1193
|
-
if (packages.length === 0) {
|
|
1194
|
-
console.log(chalk.gray(" No packages found"));
|
|
1195
|
-
return;
|
|
1196
|
-
}
|
|
1197
|
-
const hasReleaseAge = packages.some((p) => p.releaseAge !== void 0);
|
|
1198
|
-
const head = [
|
|
1199
|
-
"Package",
|
|
1200
|
-
"Version",
|
|
1201
|
-
"Components",
|
|
1202
|
-
"Usage",
|
|
1203
|
-
"Percentage"
|
|
1204
|
-
];
|
|
1205
|
-
if (hasReleaseAge) head.push("Upgrades");
|
|
1206
|
-
const table = new Table({
|
|
1207
|
-
head,
|
|
1208
|
-
style: {
|
|
1209
|
-
head: ["cyan"],
|
|
1210
|
-
border: ["gray"]
|
|
1211
|
-
}
|
|
1212
|
-
});
|
|
1213
|
-
packages.forEach((pkg) => {
|
|
1214
|
-
const versionCell = pkg.hasVersionConflict ? chalk.yellow(`ā ${pkg.allVersions.join(", ")} (multiple ā bundle impact)`) : pkg.version || "N/A";
|
|
1215
|
-
const row = [
|
|
1216
|
-
formatPackageName(pkg, getBannedViolation(pkg, violations)),
|
|
1217
|
-
versionCell,
|
|
1218
|
-
formatCount(pkg.componentCount),
|
|
1219
|
-
formatCount(pkg.usageCount),
|
|
1220
|
-
`${pkg.percentage.toFixed(1)}%`
|
|
1221
|
-
];
|
|
1222
|
-
if (hasReleaseAge) row.push(formatUpgradeCell(pkg.releaseAge));
|
|
1223
|
-
table.push(row);
|
|
1451
|
+
//#region src/rules/engine-version.ts
|
|
1452
|
+
function evaluateEngineVersion(repoPath, rulesConfig) {
|
|
1453
|
+
const rules = toArray(rulesConfig.engine_version);
|
|
1454
|
+
if (rules.length === 0) return [];
|
|
1455
|
+
const nodeRange = (readPackageJson(repoPath)?.engines)?.node;
|
|
1456
|
+
return rules.flatMap((rule) => {
|
|
1457
|
+
if (!nodeRange) return [{
|
|
1458
|
+
type: "engine_version",
|
|
1459
|
+
severity: rule.severity,
|
|
1460
|
+
patterns: [],
|
|
1461
|
+
message: rule.message ?? "engines.node not specified in package.json",
|
|
1462
|
+
matchedFiles: [],
|
|
1463
|
+
requiredRange: rule.range
|
|
1464
|
+
}];
|
|
1465
|
+
const minVer = semver.minVersion(nodeRange);
|
|
1466
|
+
if (!minVer || !semver.satisfies(minVer, rule.range)) return [{
|
|
1467
|
+
type: "engine_version",
|
|
1468
|
+
severity: rule.severity,
|
|
1469
|
+
patterns: [],
|
|
1470
|
+
message: rule.message,
|
|
1471
|
+
matchedFiles: [],
|
|
1472
|
+
installedRange: nodeRange,
|
|
1473
|
+
requiredRange: rule.range
|
|
1474
|
+
}];
|
|
1475
|
+
return [];
|
|
1224
1476
|
});
|
|
1225
|
-
console.log(table.toString());
|
|
1226
|
-
const totalComponents = packages.reduce((sum, p) => sum + p.componentCount, 0);
|
|
1227
|
-
const totalExternalUsage = packages.reduce((sum, p) => sum + p.usageCount, 0);
|
|
1228
|
-
console.log(chalk.gray(`\nTotal: ${formatCount(packages.length)} packages | ${formatCount(totalComponents)} unique components | ${formatCount(totalExternalUsage)} total usages`));
|
|
1229
1477
|
}
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
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;
|
|
1235
1490
|
}
|
|
1236
|
-
|
|
1237
|
-
const maxPercentage = Math.max(...packages.map((p) => p.percentage));
|
|
1238
|
-
const maxLabelLength = Math.max(...packages.map((p) => p.packageName.length + (p.internal ? 6 : 0)));
|
|
1239
|
-
packages.forEach((pkg) => {
|
|
1240
|
-
const barLength = Math.round(pkg.percentage / maxPercentage * maxBarWidth);
|
|
1241
|
-
const emptyLength = maxBarWidth - barLength;
|
|
1242
|
-
const label = formatPackageName(pkg, getBannedViolation(pkg, violations)).padEnd(maxLabelLength, " ");
|
|
1243
|
-
const bar = chalk.green("ā".repeat(barLength)) + chalk.gray("ā".repeat(emptyLength));
|
|
1244
|
-
console.log(`${label} ${bar} ${chalk.bold(pkg.percentage.toFixed(1) + "%")} (${pkg.usageCount})`);
|
|
1245
|
-
});
|
|
1491
|
+
return null;
|
|
1246
1492
|
}
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
const
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
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;
|
|
1254
1509
|
}
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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;
|
|
1261
1543
|
}
|
|
1262
|
-
function
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
const
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
+
}];
|
|
1276
1565
|
}
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1566
|
+
//#endregion
|
|
1567
|
+
//#region src/rules/evaluator.ts
|
|
1568
|
+
function evaluateRules(repoPath, rulesConfig, excludes, scannedFiles = []) {
|
|
1569
|
+
return [
|
|
1570
|
+
...evaluateFileRules(repoPath, rulesConfig, excludes),
|
|
1571
|
+
...evaluateScriptRules(repoPath, rulesConfig),
|
|
1572
|
+
...evaluatePackageFieldRules(repoPath, rulesConfig),
|
|
1573
|
+
...evaluateEngineVersion(repoPath, rulesConfig),
|
|
1574
|
+
...evaluateCodeowners(repoPath, rulesConfig, scannedFiles)
|
|
1575
|
+
];
|
|
1281
1576
|
}
|
|
1282
1577
|
//#endregion
|
|
1283
|
-
//#region src/utils/
|
|
1284
|
-
function
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1578
|
+
//#region src/utils/package-rules.ts
|
|
1579
|
+
function detectBannedPackages(distribution, config) {
|
|
1580
|
+
const forbidRules = toArray(config?.rules.forbid_packages);
|
|
1581
|
+
if (forbidRules.length === 0) return [];
|
|
1582
|
+
const violations = [];
|
|
1583
|
+
for (const pkg of distribution) for (const rule of forbidRules) if (micromatch.isMatch(pkg.packageName, rule.patterns)) {
|
|
1584
|
+
violations.push({
|
|
1585
|
+
packageName: pkg.packageName,
|
|
1586
|
+
severity: rule.severity,
|
|
1587
|
+
message: rule.message
|
|
1588
|
+
});
|
|
1589
|
+
break;
|
|
1294
1590
|
}
|
|
1591
|
+
return violations;
|
|
1295
1592
|
}
|
|
1296
|
-
function
|
|
1297
|
-
|
|
1298
|
-
|
|
1593
|
+
function detectRequiredPackages(distribution, versions, config) {
|
|
1594
|
+
const requireRules = toArray(config?.rules.require_packages);
|
|
1595
|
+
if (requireRules.length === 0) return [];
|
|
1596
|
+
const installedNames = /* @__PURE__ */ new Set([...Object.keys(versions), ...distribution.map((p) => p.packageName)]);
|
|
1597
|
+
const violations = [];
|
|
1598
|
+
for (const rule of requireRules) if (!rule.patterns.some((p) => [...installedNames].some((name) => micromatch.isMatch(name, p)))) violations.push({
|
|
1599
|
+
type: "require_packages",
|
|
1600
|
+
severity: rule.severity,
|
|
1601
|
+
patterns: rule.patterns,
|
|
1602
|
+
message: rule.message,
|
|
1603
|
+
matchedFiles: []
|
|
1604
|
+
});
|
|
1605
|
+
return violations;
|
|
1299
1606
|
}
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1607
|
+
//#endregion
|
|
1608
|
+
//#region src/utils/pattern-counter.ts
|
|
1609
|
+
function countPatterns(report, patternMap) {
|
|
1610
|
+
increment(patternMap, "imports.default", report.patterns.imports.default.length);
|
|
1611
|
+
increment(patternMap, "imports.named", report.patterns.imports.named.length);
|
|
1612
|
+
increment(patternMap, "imports.namespace", report.patterns.imports.namespace.length);
|
|
1613
|
+
increment(patternMap, "imports.aliased", report.patterns.imports.aliased.length);
|
|
1614
|
+
increment(patternMap, "usage.jsx", report.patterns.usage.jsx.length);
|
|
1615
|
+
increment(patternMap, "usage.variables", report.patterns.usage.variables.length);
|
|
1616
|
+
increment(patternMap, "usage.destructuring", report.patterns.usage.destructuring.length);
|
|
1617
|
+
increment(patternMap, "usage.conditional", report.patterns.usage.conditional.length);
|
|
1618
|
+
increment(patternMap, "usage.arrays", report.patterns.usage.arrays.length);
|
|
1619
|
+
increment(patternMap, "usage.objects", report.patterns.usage.objects.length);
|
|
1620
|
+
increment(patternMap, "advanced.lazy", report.patterns.advanced.lazy.length);
|
|
1621
|
+
increment(patternMap, "advanced.dynamic", report.patterns.advanced.dynamic.length);
|
|
1622
|
+
increment(patternMap, "advanced.hoc", report.patterns.advanced.hoc.length);
|
|
1623
|
+
increment(patternMap, "advanced.memo", report.patterns.advanced.memo.length);
|
|
1624
|
+
increment(patternMap, "advanced.forwardRef", report.patterns.advanced.forwardRef.length);
|
|
1625
|
+
increment(patternMap, "advanced.portal", report.patterns.advanced.portal.length);
|
|
1318
1626
|
}
|
|
1319
|
-
function
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1627
|
+
function increment(map, key, value) {
|
|
1628
|
+
map.set(key, (map.get(key) || 0) + value);
|
|
1629
|
+
}
|
|
1630
|
+
function getPatternDisplayName(patternType) {
|
|
1631
|
+
return {
|
|
1632
|
+
"imports.default": "Default Imports",
|
|
1633
|
+
"imports.named": "Named Imports",
|
|
1634
|
+
"imports.namespace": "Namespace Imports",
|
|
1635
|
+
"imports.aliased": "Aliased Imports",
|
|
1636
|
+
"usage.jsx": "JSX Usage",
|
|
1637
|
+
"usage.variables": "Variable Assignments",
|
|
1638
|
+
"usage.destructuring": "Destructuring",
|
|
1639
|
+
"usage.conditional": "Conditional Usage",
|
|
1640
|
+
"usage.arrays": "Array Mappings",
|
|
1641
|
+
"usage.objects": "Object Mappings",
|
|
1642
|
+
"advanced.lazy": "Lazy Loading",
|
|
1643
|
+
"advanced.dynamic": "Dynamic Imports",
|
|
1644
|
+
"advanced.hoc": "Higher-Order Components",
|
|
1645
|
+
"advanced.memo": "Memoized Components",
|
|
1646
|
+
"advanced.forwardRef": "Forward Refs",
|
|
1647
|
+
"advanced.portal": "Portal Usage"
|
|
1648
|
+
}[patternType] || patternType;
|
|
1649
|
+
}
|
|
1650
|
+
//#endregion
|
|
1651
|
+
//#region src/utils/versus.ts
|
|
1652
|
+
function toPercentage(count, total) {
|
|
1653
|
+
return total > 0 ? count / total * 100 : 0;
|
|
1654
|
+
}
|
|
1655
|
+
function calculateVersusResults(distribution, versusConfigs) {
|
|
1656
|
+
const distMap = new Map(distribution.map((p) => [p.packageName, p]));
|
|
1657
|
+
return versusConfigs.map((vc) => {
|
|
1658
|
+
const entries = vc.packages.map((pkgName) => {
|
|
1659
|
+
const pkg = distMap.get(pkgName);
|
|
1660
|
+
return {
|
|
1661
|
+
packageName: pkgName,
|
|
1662
|
+
count: pkg?.usageCount ?? 0,
|
|
1663
|
+
percentage: 0,
|
|
1664
|
+
components: pkg?.components ?? []
|
|
1665
|
+
};
|
|
1666
|
+
});
|
|
1667
|
+
const totalCount = entries.reduce((sum, e) => sum + e.count, 0);
|
|
1668
|
+
for (const entry of entries) entry.percentage = toPercentage(entry.count, totalCount);
|
|
1669
|
+
entries.sort((a, b) => b.count - a.count);
|
|
1670
|
+
return {
|
|
1671
|
+
name: vc.name,
|
|
1672
|
+
packages: vc.packages,
|
|
1673
|
+
entries,
|
|
1674
|
+
totalCount
|
|
1675
|
+
};
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
//#endregion
|
|
1679
|
+
//#region src/utils/aggregator-core.ts
|
|
1680
|
+
function aggregateReports(reports, versions = {}, config, multiVersions = {}) {
|
|
1681
|
+
const componentUsageMap = /* @__PURE__ */ new Map();
|
|
1682
|
+
let totalImports = 0;
|
|
1683
|
+
let totalUsagePatterns = 0;
|
|
1684
|
+
const patternCountMap = /* @__PURE__ */ new Map();
|
|
1685
|
+
const availablePackages = Object.keys(versions);
|
|
1686
|
+
for (const report of reports) {
|
|
1687
|
+
totalImports += report.summary.totalImports;
|
|
1688
|
+
totalUsagePatterns += report.summary.totalUsagePatterns;
|
|
1689
|
+
for (const jsx of report.patterns.usage.jsx) {
|
|
1690
|
+
const key = jsx.component;
|
|
1691
|
+
const existing = componentUsageMap.get(key);
|
|
1692
|
+
if (existing) {
|
|
1693
|
+
existing.count++;
|
|
1694
|
+
existing.files.add(report.filePath);
|
|
1695
|
+
} else {
|
|
1696
|
+
const source = findComponentSource(jsx.component, report, availablePackages);
|
|
1697
|
+
componentUsageMap.set(key, {
|
|
1698
|
+
name: jsx.component,
|
|
1699
|
+
source,
|
|
1700
|
+
count: 1,
|
|
1701
|
+
files: /* @__PURE__ */ new Set([report.filePath])
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1342
1704
|
}
|
|
1705
|
+
countPatterns(report, patternCountMap);
|
|
1343
1706
|
}
|
|
1344
|
-
const
|
|
1345
|
-
const
|
|
1346
|
-
const
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1707
|
+
const topComponents = Array.from(componentUsageMap.values()).sort((a, b) => b.count - a.count);
|
|
1708
|
+
const allComponents = Array.from(componentUsageMap.keys()).sort();
|
|
1709
|
+
const patternCounts = Array.from(patternCountMap.entries()).map(([type, count]) => ({
|
|
1710
|
+
patternType: type,
|
|
1711
|
+
displayName: getPatternDisplayName(type),
|
|
1712
|
+
count
|
|
1713
|
+
})).sort((a, b) => b.count - a.count);
|
|
1714
|
+
const packageDistribution = calculatePackageDistribution(componentUsageMap, versions, config, multiVersions);
|
|
1715
|
+
const versusResults = calculateVersusResults(packageDistribution, config?.versus ?? []);
|
|
1716
|
+
const bannedPackageViolations = detectBannedPackages(packageDistribution, config);
|
|
1717
|
+
const requiredPackageViolations = detectRequiredPackages(packageDistribution, versions, config);
|
|
1718
|
+
return {
|
|
1719
|
+
filesAnalyzed: reports.length,
|
|
1720
|
+
totalImports,
|
|
1721
|
+
totalComponents: componentUsageMap.size,
|
|
1722
|
+
totalUsagePatterns,
|
|
1723
|
+
patternCounts,
|
|
1724
|
+
componentUsage: componentUsageMap,
|
|
1725
|
+
topComponents,
|
|
1726
|
+
allComponents,
|
|
1727
|
+
packageDistribution,
|
|
1728
|
+
versusResults,
|
|
1729
|
+
ruleViolations: requiredPackageViolations,
|
|
1730
|
+
bannedPackageViolations,
|
|
1731
|
+
reports
|
|
1732
|
+
};
|
|
1350
1733
|
}
|
|
1351
1734
|
//#endregion
|
|
1352
1735
|
//#region src/utils/print-errors.ts
|
|
1353
|
-
function printErrors(errors) {
|
|
1736
|
+
function printErrors(errors, isJson) {
|
|
1354
1737
|
if (errors.length === 0) return;
|
|
1355
|
-
|
|
1738
|
+
const stream = isJson ? process.stderr : process.stdout;
|
|
1739
|
+
stream.write(chalk.yellow(`\nā ${errors.length} file(s) failed to parse:\n`));
|
|
1356
1740
|
for (const { file, message } of errors) {
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
}
|
|
1360
|
-
console.log("");
|
|
1361
|
-
}
|
|
1362
|
-
//#endregion
|
|
1363
|
-
//#region src/utils/version.ts
|
|
1364
|
-
let cachedVersion;
|
|
1365
|
-
/**
|
|
1366
|
-
* Walks up from `dir` to find the nearest package.json.
|
|
1367
|
-
* Needed because this module's own path differs between the tsdown-bundled
|
|
1368
|
-
* single-file `dist/cli.mjs` and the unbundled source used by tests.
|
|
1369
|
-
*/
|
|
1370
|
-
function findPackageJson(dir) {
|
|
1371
|
-
let current = dir;
|
|
1372
|
-
while (true) {
|
|
1373
|
-
const candidate = path.join(current, "package.json");
|
|
1374
|
-
if (existsSync(candidate)) return candidate;
|
|
1375
|
-
const parent = path.dirname(current);
|
|
1376
|
-
if (parent === current) throw new Error(`Could not locate package.json above ${dir}`);
|
|
1377
|
-
current = parent;
|
|
1741
|
+
stream.write(chalk.yellow(` ${file}\n`));
|
|
1742
|
+
stream.write(chalk.gray(` ${message}\n`));
|
|
1378
1743
|
}
|
|
1379
|
-
|
|
1380
|
-
function getVersion() {
|
|
1381
|
-
if (cachedVersion) return cachedVersion;
|
|
1382
|
-
const pkgPath = findPackageJson(path.dirname(fileURLToPath(import.meta.url)));
|
|
1383
|
-
cachedVersion = JSON.parse(readFileSync(pkgPath, "utf8")).version;
|
|
1384
|
-
return cachedVersion;
|
|
1385
|
-
}
|
|
1386
|
-
//#endregion
|
|
1387
|
-
//#region src/utils/print-json.ts
|
|
1388
|
-
function printJson(aggregated) {
|
|
1389
|
-
const result = {
|
|
1390
|
-
version: getVersion(),
|
|
1391
|
-
summary: {
|
|
1392
|
-
filesAnalyzed: aggregated.filesAnalyzed,
|
|
1393
|
-
totalImports: aggregated.totalImports,
|
|
1394
|
-
totalComponents: aggregated.totalComponents,
|
|
1395
|
-
totalUsagePatterns: aggregated.totalUsagePatterns
|
|
1396
|
-
},
|
|
1397
|
-
packages: aggregated.packageDistribution,
|
|
1398
|
-
components: aggregated.topComponents.map((c) => ({
|
|
1399
|
-
...c,
|
|
1400
|
-
files: [...c.files]
|
|
1401
|
-
})),
|
|
1402
|
-
patterns: aggregated.patternCounts,
|
|
1403
|
-
versus: aggregated.versusResults,
|
|
1404
|
-
ruleViolations: aggregated.ruleViolations,
|
|
1405
|
-
bannedPackageViolations: aggregated.bannedPackageViolations
|
|
1406
|
-
};
|
|
1407
|
-
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
1744
|
+
stream.write("\n");
|
|
1408
1745
|
}
|
|
1409
1746
|
//#endregion
|
|
1410
1747
|
//#region src/utils/file-utils.ts
|
|
@@ -1486,6 +1823,20 @@ var NpmLockfileAdapter = class {
|
|
|
1486
1823
|
};
|
|
1487
1824
|
//#endregion
|
|
1488
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
|
+
}
|
|
1489
1840
|
var PnpmLockfileAdapter = class {
|
|
1490
1841
|
name = "pnpm";
|
|
1491
1842
|
supportedVersions = [
|
|
@@ -1530,9 +1881,34 @@ var PnpmLockfileAdapter = class {
|
|
|
1530
1881
|
return {};
|
|
1531
1882
|
}
|
|
1532
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
|
+
}
|
|
1533
1901
|
};
|
|
1534
1902
|
//#endregion
|
|
1535
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
|
+
}
|
|
1536
1912
|
var YarnLockfileAdapter = class {
|
|
1537
1913
|
name = "yarn";
|
|
1538
1914
|
supportedVersions = ["v1", "v2+"];
|
|
@@ -1550,15 +1926,8 @@ var YarnLockfileAdapter = class {
|
|
|
1550
1926
|
}
|
|
1551
1927
|
const versions = {};
|
|
1552
1928
|
Object.entries(parsed.object).forEach(([key, value]) => {
|
|
1553
|
-
|
|
1554
|
-
if (
|
|
1555
|
-
const match = key.match(/^(@[^@]+\/[^@]+)@/);
|
|
1556
|
-
if (match) pkgName = match[1];
|
|
1557
|
-
} else {
|
|
1558
|
-
const match = key.match(/^([^@]+)@/);
|
|
1559
|
-
if (match) pkgName = match[1];
|
|
1560
|
-
}
|
|
1561
|
-
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;
|
|
1562
1931
|
});
|
|
1563
1932
|
return versions;
|
|
1564
1933
|
} catch (error) {
|
|
@@ -1567,6 +1936,25 @@ var YarnLockfileAdapter = class {
|
|
|
1567
1936
|
return {};
|
|
1568
1937
|
}
|
|
1569
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
|
+
}
|
|
1570
1958
|
};
|
|
1571
1959
|
//#endregion
|
|
1572
1960
|
//#region src/lock-parser/index.ts
|
|
@@ -1602,110 +1990,6 @@ function findAndParseLockfile(projectPath) {
|
|
|
1602
1990
|
throw new Error("No supported lockfile found");
|
|
1603
1991
|
}
|
|
1604
1992
|
//#endregion
|
|
1605
|
-
//#region src/config/schema.ts
|
|
1606
|
-
const RuleSeveritySchema = z.enum(["error", "warn"]);
|
|
1607
|
-
const RuleConfigSchema = z.object({
|
|
1608
|
-
severity: RuleSeveritySchema,
|
|
1609
|
-
patterns: z.array(z.string()),
|
|
1610
|
-
message: z.string().optional()
|
|
1611
|
-
});
|
|
1612
|
-
const RuleConfigOrArraySchema = z.union([RuleConfigSchema, z.array(RuleConfigSchema)]);
|
|
1613
|
-
const EngineVersionRuleSchema = z.object({
|
|
1614
|
-
severity: RuleSeveritySchema,
|
|
1615
|
-
range: z.string(),
|
|
1616
|
-
message: z.string().optional()
|
|
1617
|
-
});
|
|
1618
|
-
const ThresholdSchema = z.union([z.number(), z.literal(false)]);
|
|
1619
|
-
const HermexConfigSchema = z.object({
|
|
1620
|
-
includes: z.array(z.string()).default(["**/*.{tsx,jsx,ts,js}"]),
|
|
1621
|
-
excludes: z.array(z.string()).default([
|
|
1622
|
-
"**/node_modules/**",
|
|
1623
|
-
"**/dist/**",
|
|
1624
|
-
"**/build/**"
|
|
1625
|
-
]),
|
|
1626
|
-
packages: z.object({
|
|
1627
|
-
internal: z.array(z.string()).default([]),
|
|
1628
|
-
ignore: z.array(z.string()).default([])
|
|
1629
|
-
}).default(() => ({
|
|
1630
|
-
internal: [],
|
|
1631
|
-
ignore: []
|
|
1632
|
-
})),
|
|
1633
|
-
versus: z.array(z.object({
|
|
1634
|
-
name: z.string(),
|
|
1635
|
-
packages: z.array(z.string()).min(2)
|
|
1636
|
-
})).default([]),
|
|
1637
|
-
rules: z.object({
|
|
1638
|
-
forbid_files: RuleConfigOrArraySchema.default([]),
|
|
1639
|
-
require_files: RuleConfigOrArraySchema.default([]),
|
|
1640
|
-
allow_files: RuleConfigOrArraySchema.default([]),
|
|
1641
|
-
forbid_packages: RuleConfigOrArraySchema.default([]),
|
|
1642
|
-
require_packages: RuleConfigOrArraySchema.default([]),
|
|
1643
|
-
require_scripts: RuleConfigOrArraySchema.default([]),
|
|
1644
|
-
require_package_fields: RuleConfigOrArraySchema.default([]),
|
|
1645
|
-
engine_version: z.union([EngineVersionRuleSchema, z.array(EngineVersionRuleSchema)]).optional()
|
|
1646
|
-
}).default(() => ({
|
|
1647
|
-
forbid_files: [],
|
|
1648
|
-
require_files: [],
|
|
1649
|
-
allow_files: [],
|
|
1650
|
-
forbid_packages: [],
|
|
1651
|
-
require_packages: [],
|
|
1652
|
-
require_scripts: [],
|
|
1653
|
-
require_package_fields: []
|
|
1654
|
-
})),
|
|
1655
|
-
output: z.object({
|
|
1656
|
-
summary: z.union([z.literal("log"), z.literal(false)]).default("log"),
|
|
1657
|
-
components: z.union([z.enum(["table", "chart"]), z.literal(false)]).default("table"),
|
|
1658
|
-
packages: z.union([z.enum(["table", "chart"]), z.literal(false)]).default("table"),
|
|
1659
|
-
patterns: z.union([z.enum(["table", "chart"]), z.literal(false)]).default("table"),
|
|
1660
|
-
details: z.boolean().default(false),
|
|
1661
|
-
versus: z.boolean().default(true),
|
|
1662
|
-
rules: z.boolean().default(true),
|
|
1663
|
-
format: z.enum(["human", "json"]).default("human")
|
|
1664
|
-
}).default(() => ({
|
|
1665
|
-
summary: "log",
|
|
1666
|
-
components: "table",
|
|
1667
|
-
packages: "table",
|
|
1668
|
-
patterns: "table",
|
|
1669
|
-
details: false,
|
|
1670
|
-
versus: true,
|
|
1671
|
-
rules: true,
|
|
1672
|
-
format: "human"
|
|
1673
|
-
})),
|
|
1674
|
-
releaseAge: z.object({
|
|
1675
|
-
enabled: z.boolean().default(false),
|
|
1676
|
-
registry: z.string().default("https://registry.npmjs.org"),
|
|
1677
|
-
authToken: z.string().optional(),
|
|
1678
|
-
thresholds: z.object({
|
|
1679
|
-
patch: ThresholdSchema.default(30),
|
|
1680
|
-
minor: ThresholdSchema.default(45),
|
|
1681
|
-
major: ThresholdSchema.default(60)
|
|
1682
|
-
}).default(() => ({
|
|
1683
|
-
patch: 30,
|
|
1684
|
-
minor: 45,
|
|
1685
|
-
major: 60
|
|
1686
|
-
}))
|
|
1687
|
-
}).default(() => ({
|
|
1688
|
-
enabled: false,
|
|
1689
|
-
registry: "https://registry.npmjs.org",
|
|
1690
|
-
thresholds: {
|
|
1691
|
-
patch: 30,
|
|
1692
|
-
minor: 45,
|
|
1693
|
-
major: 60
|
|
1694
|
-
}
|
|
1695
|
-
}))
|
|
1696
|
-
});
|
|
1697
|
-
//#endregion
|
|
1698
|
-
//#region src/config/loader.ts
|
|
1699
|
-
async function loadConfig(cwd, explicitPath) {
|
|
1700
|
-
const configPath = explicitPath ? resolve(explicitPath) : join(cwd, "hermex.config.ts");
|
|
1701
|
-
if (explicitPath && !existsSync(configPath)) throw new Error(`Config file not found: ${configPath}`);
|
|
1702
|
-
if (existsSync(configPath)) {
|
|
1703
|
-
const mod = await import(pathToFileURL(configPath).href);
|
|
1704
|
-
return HermexConfigSchema.parse(mod.default ?? mod);
|
|
1705
|
-
}
|
|
1706
|
-
return HermexConfigSchema.parse({});
|
|
1707
|
-
}
|
|
1708
|
-
//#endregion
|
|
1709
1993
|
//#region src/npm-registry/client.ts
|
|
1710
1994
|
async function fetchPackageInfo(name, registryUrl, authToken) {
|
|
1711
1995
|
const url = `${registryUrl.replace(/\/$/, "")}/${encodeURIComponent(name).replace("%40", "@")}`;
|
|
@@ -1727,6 +2011,53 @@ async function fetchPackageInfo(name, registryUrl, authToken) {
|
|
|
1727
2011
|
}
|
|
1728
2012
|
}
|
|
1729
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
|
|
1730
2061
|
//#region src/npm-registry/enricher.ts
|
|
1731
2062
|
const CONCURRENCY = 8;
|
|
1732
2063
|
function daysSince(dateStr) {
|
|
@@ -1741,41 +2072,86 @@ function classifyBump(installed, candidate) {
|
|
|
1741
2072
|
if (diff === "major" || diff === "premajor") return "major";
|
|
1742
2073
|
return null;
|
|
1743
2074
|
}
|
|
2075
|
+
function pickNewest(versions) {
|
|
2076
|
+
return versions.reduce((a, b) => a.daysAgo < b.daysAgo ? a : b);
|
|
2077
|
+
}
|
|
1744
2078
|
function upgradeLevel(daysAgo, bump, thresholds) {
|
|
1745
2079
|
const threshold = thresholds[bump];
|
|
1746
2080
|
if (threshold === false || threshold === void 0) return null;
|
|
1747
2081
|
if (daysAgo > threshold) return bump === "major" ? "mandatory_upgrade" : "needs_upgrade";
|
|
1748
2082
|
return null;
|
|
1749
2083
|
}
|
|
1750
|
-
function computeReleaseAge(installedVersion, timeMap, deprecated, thresholds) {
|
|
1751
|
-
const
|
|
2084
|
+
function computeReleaseAge(installedVersion, timeMap, deprecated, thresholds, distTags, severity) {
|
|
2085
|
+
const byBump = /* @__PURE__ */ new Map();
|
|
2086
|
+
let minCompliantVersion;
|
|
2087
|
+
let minCompliantReleasedDaysAgo;
|
|
1752
2088
|
for (const [version, dateStr] of Object.entries(timeMap)) {
|
|
1753
2089
|
if (version === "created" || version === "modified") continue;
|
|
1754
2090
|
if (!semver.valid(version)) continue;
|
|
2091
|
+
if (semver.prerelease(version)) continue;
|
|
1755
2092
|
if (semver.lte(version, installedVersion)) continue;
|
|
1756
2093
|
const bump = classifyBump(installedVersion, version);
|
|
1757
2094
|
if (!bump) continue;
|
|
1758
2095
|
const daysAgo = daysSince(dateStr);
|
|
1759
|
-
const
|
|
2096
|
+
const list = byBump.get(bump) ?? [];
|
|
2097
|
+
list.push({
|
|
2098
|
+
version,
|
|
2099
|
+
daysAgo
|
|
2100
|
+
});
|
|
2101
|
+
byBump.set(bump, list);
|
|
2102
|
+
const threshold = thresholds[bump];
|
|
2103
|
+
if (threshold !== false && threshold !== void 0 && daysAgo <= threshold && (minCompliantReleasedDaysAgo === void 0 || daysAgo > minCompliantReleasedDaysAgo)) {
|
|
2104
|
+
minCompliantVersion = version;
|
|
2105
|
+
minCompliantReleasedDaysAgo = daysAgo;
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
const upgrades = [];
|
|
2109
|
+
for (const [bump, versions] of byBump.entries()) {
|
|
2110
|
+
const level = upgradeLevel(Math.max(...versions.map((v) => v.daysAgo)), bump, thresholds);
|
|
1760
2111
|
if (!level) continue;
|
|
2112
|
+
const newest = pickNewest(versions);
|
|
1761
2113
|
upgrades.push({
|
|
1762
|
-
version,
|
|
1763
|
-
releasedDaysAgo: daysAgo,
|
|
2114
|
+
version: newest.version,
|
|
2115
|
+
releasedDaysAgo: newest.daysAgo,
|
|
1764
2116
|
semverBump: bump,
|
|
1765
|
-
level
|
|
2117
|
+
level,
|
|
2118
|
+
thresholdDays: thresholds[bump]
|
|
1766
2119
|
});
|
|
1767
2120
|
}
|
|
1768
|
-
const
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
2121
|
+
const finalUpgrades = upgrades.sort((a, b) => b.releasedDaysAgo - a.releasedDaysAgo);
|
|
2122
|
+
const latestVersion = distTags?.["latest"];
|
|
2123
|
+
const latestEntry = latestVersion ? timeMap[latestVersion] : void 0;
|
|
2124
|
+
const latestReleasedDaysAgo = latestEntry ? daysSince(latestEntry) : void 0;
|
|
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
|
+
}
|
|
1772
2143
|
}
|
|
1773
|
-
const finalUpgrades = Array.from(worstPerBump.values()).sort((a, b) => b.releasedDaysAgo - a.releasedDaysAgo);
|
|
1774
2144
|
return {
|
|
1775
2145
|
installedVersion,
|
|
1776
2146
|
upgrades: finalUpgrades,
|
|
1777
|
-
worstLevel
|
|
1778
|
-
|
|
2147
|
+
worstLevel,
|
|
2148
|
+
pendingUpgrade,
|
|
2149
|
+
deprecated,
|
|
2150
|
+
latestVersion,
|
|
2151
|
+
latestReleasedDaysAgo,
|
|
2152
|
+
minCompliantVersion,
|
|
2153
|
+
minCompliantReleasedDaysAgo,
|
|
2154
|
+
severity
|
|
1779
2155
|
};
|
|
1780
2156
|
}
|
|
1781
2157
|
async function enrichWithReleaseAge(packages, config) {
|
|
@@ -1784,10 +2160,15 @@ async function enrichWithReleaseAge(packages, config) {
|
|
|
1784
2160
|
const targets = packages.filter((p) => !p.internal && p.version);
|
|
1785
2161
|
const enriched = [...packages];
|
|
1786
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
|
+
};
|
|
1787
2168
|
for (let i = 0; i < targets.length; i += CONCURRENCY) {
|
|
1788
2169
|
const batch = targets.slice(i, i + CONCURRENCY);
|
|
1789
2170
|
const results = await Promise.all(batch.map(async (pkg) => {
|
|
1790
|
-
const info = await
|
|
2171
|
+
const info = await getPackageInfo(pkg.packageName, registryUrl, authToken, cacheOptions);
|
|
1791
2172
|
if (!info || !info.time) {
|
|
1792
2173
|
skipped++;
|
|
1793
2174
|
return {
|
|
@@ -1796,9 +2177,10 @@ async function enrichWithReleaseAge(packages, config) {
|
|
|
1796
2177
|
};
|
|
1797
2178
|
}
|
|
1798
2179
|
const deprecated = info.versions?.[pkg.version]?.deprecated ?? info.deprecated;
|
|
2180
|
+
const severity = config.enforceOn.length === 0 || micromatch.isMatch(pkg.packageName, config.enforceOn) ? "error" : "warn";
|
|
1799
2181
|
return {
|
|
1800
2182
|
pkg,
|
|
1801
|
-
entry: computeReleaseAge(pkg.version, info.time, typeof deprecated === "string" ? deprecated : void 0, config.thresholds)
|
|
2183
|
+
entry: computeReleaseAge(pkg.version, info.time, typeof deprecated === "string" ? deprecated : void 0, config.thresholds, info["dist-tags"], severity)
|
|
1802
2184
|
};
|
|
1803
2185
|
}));
|
|
1804
2186
|
for (const { pkg, entry } of results) {
|
|
@@ -1816,69 +2198,102 @@ async function enrichWithReleaseAge(packages, config) {
|
|
|
1816
2198
|
};
|
|
1817
2199
|
}
|
|
1818
2200
|
//#endregion
|
|
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
|
+
}
|
|
2206
|
+
/**
|
|
2207
|
+
* Runs the shared parse ā aggregate ā rules ā release-age pipeline used by
|
|
2208
|
+
* both `scan` and `comply`. Returns `null` if no files matched (the spinner
|
|
2209
|
+
* has already reported the failure); throws on unexpected errors.
|
|
2210
|
+
*/
|
|
2211
|
+
async function runPipeline(config, spinner, isJson) {
|
|
2212
|
+
const lockfileResult = findAndParseLockfile(process.cwd());
|
|
2213
|
+
spinner.succeed(chalk.blue(`š¦ Found ${lockfileResult.lockfileType} lockfile (supports: ${lockfileResult.supportedVersions.join(", ")}) - ${Object.keys(lockfileResult.versions).length} packages`));
|
|
2214
|
+
spinner.start("Finding files...");
|
|
2215
|
+
const files = (await findFiles(config.includes, config.excludes)).filter((f) => !isDeclarationFile(f));
|
|
2216
|
+
if (files.length === 0) {
|
|
2217
|
+
spinner.fail(chalk.red(`No files found matching includes: ${config.includes.join(", ")}`));
|
|
2218
|
+
return null;
|
|
2219
|
+
}
|
|
2220
|
+
spinner.succeed(chalk.green(` Found ${files.length} files`));
|
|
2221
|
+
spinner.start("Analyzing files...");
|
|
2222
|
+
const reports = [];
|
|
2223
|
+
const parseErrors = [];
|
|
2224
|
+
for (let i = 0; i < files.length; i++) {
|
|
2225
|
+
const file = files[i];
|
|
2226
|
+
spinner.text = `Analyzing files... (${i + 1}/${files.length})`;
|
|
2227
|
+
try {
|
|
2228
|
+
const report = parseFile(file);
|
|
2229
|
+
if (report) reports.push(report);
|
|
2230
|
+
} catch (error) {
|
|
2231
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2232
|
+
parseErrors.push({
|
|
2233
|
+
file,
|
|
2234
|
+
message
|
|
2235
|
+
});
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
spinner.succeed(chalk.green(`Analysis complete! Analyzed ${reports.length}/${files.length} files`));
|
|
2239
|
+
printErrors(parseErrors, isJson);
|
|
2240
|
+
const aggregated = aggregateReports(reports, lockfileResult.versions, config, lockfileResult.multiVersions);
|
|
2241
|
+
const evaluatorViolations = evaluateRules(process.cwd(), config.rules, config.excludes, files);
|
|
2242
|
+
aggregated.ruleViolations = [...aggregated.ruleViolations, ...evaluatorViolations];
|
|
2243
|
+
if (config.releaseAge.enabled) {
|
|
2244
|
+
spinner.start("Fetching release age from registry...");
|
|
2245
|
+
const { enriched, skipped } = await enrichWithReleaseAge(aggregated.packageDistribution, config.releaseAge);
|
|
2246
|
+
aggregated.packageDistribution = enriched;
|
|
2247
|
+
spinner.succeed(chalk.blue(`š
Release age fetched${skipped > 0 ? chalk.gray(` (${skipped} packages skipped ā registry unreachable or not found)`) : ""}`));
|
|
2248
|
+
}
|
|
2249
|
+
return aggregated;
|
|
2250
|
+
}
|
|
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
|
|
1819
2274
|
//#region src/commands/scan.ts
|
|
1820
2275
|
function registerScanCommand(program) {
|
|
1821
|
-
program.command("scan").description("Scan and analyze local files").option("--config <path>", "Path to hermex config file (overrides CWD discovery)").action(async (options) => {
|
|
1822
|
-
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
|
+
});
|
|
1823
2281
|
});
|
|
1824
2282
|
}
|
|
1825
|
-
async function executeScan(config) {
|
|
1826
|
-
const
|
|
1827
|
-
const isJson = config.output.format === "json";
|
|
1828
|
-
(isJson ? process.stderr : process.stdout).write(chalk.gray(`hermex v${getVersion()}\n`));
|
|
1829
|
-
const spinner = ora({
|
|
1830
|
-
text: "Parsing lockfile...",
|
|
1831
|
-
stream: isJson ? process.stderr : process.stdout
|
|
1832
|
-
}).start();
|
|
2283
|
+
async function executeScan(config, contextOptions = {}) {
|
|
2284
|
+
const { isJson, spinner } = createCommandContext(config, contextOptions);
|
|
1833
2285
|
try {
|
|
1834
|
-
const
|
|
1835
|
-
|
|
1836
|
-
spinner.start("Finding files...");
|
|
1837
|
-
const files = await findFiles(config.includes, config.excludes);
|
|
1838
|
-
if (files.length === 0) {
|
|
1839
|
-
spinner.fail(chalk.red(`No files found matching includes: ${config.includes.join(", ")}`));
|
|
1840
|
-
return;
|
|
1841
|
-
}
|
|
1842
|
-
spinner.succeed(chalk.green(` Found ${files.length} files`));
|
|
1843
|
-
spinner.start("Analyzing files...");
|
|
1844
|
-
const reports = [];
|
|
1845
|
-
const parseErrors = [];
|
|
1846
|
-
for (let i = 0; i < files.length; i++) {
|
|
1847
|
-
const file = files[i];
|
|
1848
|
-
spinner.text = `Analyzing files... (${i + 1}/${files.length})`;
|
|
1849
|
-
try {
|
|
1850
|
-
const report = parseFile(file);
|
|
1851
|
-
if (report) reports.push(report);
|
|
1852
|
-
} catch (error) {
|
|
1853
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1854
|
-
parseErrors.push({
|
|
1855
|
-
file,
|
|
1856
|
-
message
|
|
1857
|
-
});
|
|
1858
|
-
}
|
|
1859
|
-
}
|
|
1860
|
-
spinner.succeed(chalk.green(`Analysis complete! Analyzed ${reports.length}/${files.length} files`));
|
|
1861
|
-
printErrors(parseErrors);
|
|
1862
|
-
const elapsedTime = (Date.now() - startTime) / 1e3;
|
|
1863
|
-
const aggregated = aggregateReports(reports, lockfileResult.versions, config, lockfileResult.multiVersions);
|
|
1864
|
-
const evaluatorViolations = evaluateRules(process.cwd(), config.rules, config.excludes);
|
|
1865
|
-
aggregated.ruleViolations = [...aggregated.ruleViolations, ...evaluatorViolations];
|
|
1866
|
-
if (config.releaseAge.enabled) {
|
|
1867
|
-
spinner.start("Fetching release age from registry...");
|
|
1868
|
-
const { enriched, skipped } = await enrichWithReleaseAge(aggregated.packageDistribution, config.releaseAge);
|
|
1869
|
-
aggregated.packageDistribution = enriched;
|
|
1870
|
-
spinner.succeed(chalk.blue(`š
Release age fetched${skipped > 0 ? chalk.gray(` (${skipped} packages skipped ā registry unreachable or not found)`) : ""}`));
|
|
1871
|
-
}
|
|
2286
|
+
const aggregated = await runPipeline(config, spinner, isJson);
|
|
2287
|
+
if (!aggregated) return;
|
|
1872
2288
|
if (isJson) printJson(aggregated);
|
|
1873
|
-
else printScanResults(aggregated, config
|
|
2289
|
+
else printScanResults(aggregated, config);
|
|
1874
2290
|
} catch (error) {
|
|
1875
2291
|
const message = error instanceof Error ? error.message : String(error);
|
|
1876
2292
|
spinner.fail(chalk.red("Analysis failed: " + message));
|
|
1877
|
-
|
|
1878
|
-
process.exit(1);
|
|
2293
|
+
process.exitCode = 1;
|
|
1879
2294
|
}
|
|
1880
2295
|
}
|
|
1881
|
-
function printScanResults(aggregated, config
|
|
2296
|
+
function printScanResults(aggregated, config) {
|
|
1882
2297
|
if (config.output.packages) printPackages(aggregated, config.output.packages);
|
|
1883
2298
|
if (config.output.versus) printVersus(aggregated);
|
|
1884
2299
|
if (config.output.rules) printRules(aggregated);
|
|
@@ -1888,10 +2303,81 @@ function printScanResults(aggregated, config, _elapsedTime) {
|
|
|
1888
2303
|
if (config.output.summary) printSummary(aggregated);
|
|
1889
2304
|
}
|
|
1890
2305
|
//#endregion
|
|
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
|
+
*/
|
|
2312
|
+
function printComplianceVerdict(result) {
|
|
2313
|
+
const mandatoryCount = result.errorRuleViolations.length + result.errorBannedPackageViolations.length + result.mandatoryReleaseAgeViolations.length;
|
|
2314
|
+
if (result.compliant) {
|
|
2315
|
+
console.log(chalk.greenBright.bold(`\n${severityIcon("success")} COMPLIANT\n`));
|
|
2316
|
+
return;
|
|
2317
|
+
}
|
|
2318
|
+
console.log(chalk.redBright.bold(`\n${severityIcon("error")} NOT COMPLIANT`));
|
|
2319
|
+
console.log(chalk.red(` ${mandatoryCount} mandatory violation${mandatoryCount > 1 ? "s" : ""} found`));
|
|
2320
|
+
console.log();
|
|
2321
|
+
}
|
|
2322
|
+
//#endregion
|
|
2323
|
+
//#region src/utils/compliance.ts
|
|
2324
|
+
/**
|
|
2325
|
+
* A package is a mandatory compliance failure only when its releaseAge
|
|
2326
|
+
* severity is 'error' (i.e. it's in scope per `releaseAge.enforceOn`) AND
|
|
2327
|
+
* its worstLevel is 'mandatory_upgrade' ā 'needs_upgrade' (patch/minor) is
|
|
2328
|
+
* advisory even for enforced packages, matching the existing "mandatory"
|
|
2329
|
+
* vocabulary already used by upgradeLevel().
|
|
2330
|
+
*/
|
|
2331
|
+
function computeCompliance(aggregated) {
|
|
2332
|
+
const errorRuleViolations = aggregated.ruleViolations.filter((v) => v.severity === "error");
|
|
2333
|
+
const errorBannedPackageViolations = aggregated.bannedPackageViolations.filter((v) => v.severity === "error");
|
|
2334
|
+
const mandatoryReleaseAgeViolations = aggregated.packageDistribution.filter((p) => p.releaseAge?.severity === "error" && p.releaseAge?.worstLevel === "mandatory_upgrade");
|
|
2335
|
+
return {
|
|
2336
|
+
compliant: errorRuleViolations.length === 0 && errorBannedPackageViolations.length === 0 && mandatoryReleaseAgeViolations.length === 0,
|
|
2337
|
+
errorRuleViolations,
|
|
2338
|
+
errorBannedPackageViolations,
|
|
2339
|
+
mandatoryReleaseAgeViolations
|
|
2340
|
+
};
|
|
2341
|
+
}
|
|
2342
|
+
//#endregion
|
|
2343
|
+
//#region src/commands/comply.ts
|
|
2344
|
+
function registerComplyCommand(program) {
|
|
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
|
+
});
|
|
2350
|
+
});
|
|
2351
|
+
}
|
|
2352
|
+
async function executeComply(config, contextOptions = {}) {
|
|
2353
|
+
const { isJson, spinner } = createCommandContext(config, contextOptions);
|
|
2354
|
+
try {
|
|
2355
|
+
const aggregated = await runPipeline(config, spinner, isJson);
|
|
2356
|
+
if (!aggregated) {
|
|
2357
|
+
process.exitCode = 2;
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
const compliance = computeCompliance(aggregated);
|
|
2361
|
+
if (isJson) printJson(aggregated);
|
|
2362
|
+
else {
|
|
2363
|
+
printRules(aggregated);
|
|
2364
|
+
if (config.releaseAge.enabled) printPackages(aggregated, "table");
|
|
2365
|
+
if (config.output.versus) printVersus(aggregated);
|
|
2366
|
+
printComplianceVerdict(compliance);
|
|
2367
|
+
}
|
|
2368
|
+
process.exitCode = compliance.compliant ? 0 : 1;
|
|
2369
|
+
} catch (error) {
|
|
2370
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2371
|
+
spinner.fail(chalk.red("Compliance check failed: " + message));
|
|
2372
|
+
process.exitCode = 2;
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
//#endregion
|
|
1891
2376
|
//#region src/cli.ts
|
|
1892
2377
|
const program = new Command();
|
|
1893
2378
|
program.name("hermex").description("Analyze React component usage patterns in your codebase").version(getVersion());
|
|
1894
2379
|
registerScanCommand(program);
|
|
2380
|
+
registerComplyCommand(program);
|
|
1895
2381
|
program.parse(process.argv);
|
|
1896
2382
|
//#endregion
|
|
1897
2383
|
export { program };
|