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/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 { parseSync } from "@swc/core";
4
+ import Table from "cli-table3";
6
5
  import fs, { existsSync, readFileSync } from "node:fs";
7
- import path, { join, resolve } from "node:path";
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 { z } from "zod";
18
- //#region src/swc-parser/core/state.ts
19
- function createState() {
20
- return {
21
- usagePatterns: {
22
- namedImports: /* @__PURE__ */ new Set(),
23
- namespaceImports: /* @__PURE__ */ new Set(),
24
- defaultImports: /* @__PURE__ */ new Set(),
25
- aliasedImports: /* @__PURE__ */ new Map(),
26
- variableAssignments: /* @__PURE__ */ new Map(),
27
- lazyImports: /* @__PURE__ */ new Set(),
28
- dynamicImports: /* @__PURE__ */ new Set(),
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
- * Analyzes import declarations and tracks all types:
48
- * - Default imports
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 analyzeImportDeclaration(node, state) {
54
- const source = node.source.value;
55
- for (const spec of node.specifiers) switch (spec.type) {
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
- function analyzeDefaultImport(spec, source, node, state) {
68
- const name = spec.local.value;
69
- state.usagePatterns.defaultImports.add({
70
- name,
71
- source,
72
- line: node.span?.start || 0
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
- state.componentNames.add(name);
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
- function analyzeNamespaceImport(spec, source, node, state) {
77
- const name = spec.local.value;
78
- state.usagePatterns.namespaceImports.add({
79
- name,
80
- source,
81
- line: node.span?.start || 0
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
- state.allIdentifiers.add(name);
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 analyzeNamedImport(spec, source, node, state) {
86
- const importedName = spec.imported ? spec.imported.value : spec.local.value;
87
- const localName = spec.local.value;
88
- state.usagePatterns.namedImports.add({
89
- name: importedName,
90
- source,
91
- line: node.span?.start || 0
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
- if (importedName !== localName) state.usagePatterns.aliasedImports.set(localName, {
94
- imported: importedName,
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
- state.componentNames.add(localName);
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/swc-parser/utils/jsx-helpers.ts
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
- * Extracts the name from a JSX element (handles identifiers and member expressions)
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 getJSXElementName(nameNode) {
107
- if (!nameNode) return "";
108
- switch (nameNode.type) {
109
- case "Identifier": return nameNode.value;
110
- case "JSXMemberExpression": return `${getJSXElementName(nameNode.object)}.${nameNode.property.value}`;
111
- default: return "";
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
- * Checks if a JSX member expression is a known component
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 isMemberExpressionComponent(nameNode, state) {
118
- if (nameNode?.type === "JSXMemberExpression") {
119
- const objectName = getJSXElementName(nameNode.object);
120
- return state.allIdentifiers.has(objectName);
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
- * Extracts props from JSX attributes
126
- */
127
- function extractJSXProps(attributes) {
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
- * Extracts value from JSX attribute
144
- */
145
- function extractJSXAttributeValue(value) {
146
- if (!value) return true;
147
- switch (value.type) {
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
- * Extracts a readable value from an expression
155
- */
156
- function extractExpressionValue(expr) {
157
- if (!expr) return "[unknown]";
158
- switch (expr.type) {
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
- * Determines the context where a component is being used
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
- //#endregion
185
- //#region src/swc-parser/patterns/props.ts
186
- /**
187
- * Analyzes props in detail for a component
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
- * Determines the type of a prop value
229
- */
230
- function getPropType(value) {
231
- if (!value) return "boolean";
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
- * Checks if a prop value is complex (object, array, call, conditional)
254
- */
255
- function isComplexProp(value) {
256
- if (!value) return false;
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
- return false;
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/swc-parser/patterns/jsx.ts
266
- /**
267
- * Analyzes JSX element usage
268
- */
269
- function analyzeJSXElement(node, state) {
270
- if (node.opening) analyzeJSXOpeningElement(node.opening, state, node);
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
- * Analyzes JSX opening element and tracks component usage
274
- */
275
- function analyzeJSXOpeningElement(node, state, parent) {
276
- const elementName = getJSXElementName(node.name);
277
- if (!state.componentNames.has(elementName) && !isMemberExpressionComponent(node.name, state)) return;
278
- const propsAnalysis = analyzePropsInDetail(node.attributes, elementName, state);
279
- const usage = {
280
- component: elementName,
281
- props: extractJSXProps(node.attributes).map((p) => p.name),
282
- propsAnalysis,
283
- line: node.span?.start || 0,
284
- context: getUsageContext(parent)
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
- //#endregion
289
- //#region src/swc-parser/utils/matchers.ts
290
- /**
291
- * Checks if a name is a known component from imports
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/swc-parser/patterns/variables.ts
298
- /**
299
- * Analyzes variable declarations for component assignments
300
- */
301
- function analyzeVariableDeclaration(node, state) {
302
- if (!node.declarations) return;
303
- for (const decl of node.declarations) {
304
- if (decl.id?.type === "Identifier") {
305
- const varName = decl.id.value;
306
- if (decl.init) {
307
- const assignment = extractAssignmentInfo(decl.init);
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
- * Analyzes destructuring patterns
322
- */
323
- function analyzeDestructuringPattern(pattern, init, state) {
324
- if (!pattern.properties) return;
325
- for (const prop of pattern.properties) if (prop.type === "AssignmentPatternProperty" && prop.key?.type === "Identifier") {
326
- const propName = prop.key.value;
327
- if (init?.type === "Identifier" && state.allIdentifiers.has(init.value)) {
328
- state.usagePatterns.destructuredUsage.add({
329
- property: propName,
330
- source: init.value,
331
- line: pattern.span?.start || 0
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
- * Extracts assignment information from various node types
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
- //#endregion
349
- //#region src/swc-parser/patterns/conditionals.ts
350
- /**
351
- * Analyzes conditional expressions (ternary operators) with components
352
- */
353
- function analyzeConditionalExpression(node, state) {
354
- const consequent = node.consequent?.type === "Identifier" ? node.consequent.value : null;
355
- const alternate = node.alternate?.type === "Identifier" ? node.alternate.value : null;
356
- if (consequent && state.componentNames.has(consequent) || alternate && state.componentNames.has(alternate)) state.usagePatterns.conditionalUsage.add({
357
- consequent: consequent || "",
358
- alternate: alternate || "",
359
- line: node.span?.start || 0
360
- });
361
- }
362
- //#endregion
363
- //#region src/swc-parser/patterns/collections.ts
364
- /**
365
- * Analyzes array expressions containing components
366
- */
367
- function analyzeArrayExpression(node, state) {
368
- if (node.elements?.some((elem) => {
369
- if (elem?.type === "Identifier") return state.componentNames.has(elem.value);
370
- return false;
371
- })) state.usagePatterns.arrayMappings.add({
372
- components: node.elements?.map((elem) => elem?.value).filter(Boolean),
373
- line: node.span?.start || 0
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/swc-parser/patterns/lazy-dynamic.ts
445
+ //#region src/utils/version.ts
446
+ let cachedVersion;
394
447
  /**
395
- * Analyzes React.lazy() imports
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 analyzeLazyImport(node, state) {
398
- const arg = node.arguments?.[0];
399
- if (arg?.type === "ArrowFunctionExpression" && arg.body?.type === "CallExpression") {
400
- const importCall = arg.body;
401
- if (importCall.callee?.type === "Import") {
402
- const source = importCall.arguments?.[0]?.value;
403
- if (source) state.usagePatterns.lazyImports.add({
404
- source,
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
- * Analyzes dynamic import() calls
412
- */
413
- function analyzeDynamicImport(node, state) {
414
- const source = node.arguments?.[0]?.value;
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/swc-parser/patterns/advanced.ts
422
- /**
423
- * Analyzes Higher-Order Component (HOC) usage
424
- */
425
- function analyzeHOCUsage(node, state) {
426
- state.usagePatterns.hocUsage.add({
427
- function: node.callee?.value || "[unknown]",
428
- component: node.arguments?.[0]?.value || "[unknown]",
429
- line: node.span?.start || 0
430
- });
431
- }
432
- /**
433
- * Analyzes React.memo() usage
434
- */
435
- function analyzeMemoUsage(node, state) {
436
- const component = node.arguments?.[0];
437
- if (component?.type === "Identifier" && state.componentNames.has(component.value)) state.usagePatterns.memoizedComponents.add({
438
- component: component.value,
439
- line: node.span?.start || 0
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/swc-parser/core/visitor.ts
471
- /**
472
- * Main AST visitor that routes nodes to appropriate pattern analyzers
473
- */
474
- function visitNode(node, state, context = {}) {
475
- if (!node) return;
476
- switch (node.type) {
477
- case "Module":
478
- if (node.body) {
479
- for (const item of node.body) if (item.type === "ImportDeclaration") visitNode(item, state, context);
480
- for (const item of node.body) if (item.type !== "ImportDeclaration") visitNode(item, state, {
481
- ...context,
482
- parent: node
483
- });
484
- }
485
- break;
486
- case "ImportDeclaration":
487
- analyzeImportDeclaration(node, state);
488
- break;
489
- case "CallExpression":
490
- analyzeCallExpression(node, state, context);
491
- break;
492
- case "VariableDeclaration":
493
- analyzeVariableDeclaration(node, state);
494
- visitChildren(node, state, context);
495
- break;
496
- case "JSXElement":
497
- case "JSXFragment":
498
- analyzeJSXElement(node, state);
499
- visitChildren(node, state, context);
500
- break;
501
- case "JSXOpeningElement":
502
- analyzeJSXOpeningElement(node, state, context.parent);
503
- break;
504
- case "ArrayExpression":
505
- analyzeArrayExpression(node, state);
506
- visitChildren(node, state, context);
507
- break;
508
- case "ObjectExpression":
509
- analyzeObjectExpression(node, state);
510
- visitChildren(node, state, context);
511
- break;
512
- case "MemberExpression":
513
- analyzeMemberExpression(node, state);
514
- visitChildren(node, state, context);
515
- break;
516
- case "ConditionalExpression":
517
- analyzeConditionalExpression(node, state);
518
- visitChildren(node, state, context);
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 "FunctionDeclaration":
521
- case "ClassDeclaration":
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
- default:
533
- visitChildren(node, state, context);
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
- * Analyzes call expressions and routes to specific analyzers
700
+ * Extracts the name from a JSX element (handles identifiers and member expressions)
539
701
  */
540
- function analyzeCallExpression(node, state, context) {
541
- if (node.callee?.value === "lazy" || node.callee?.object?.value === "React" && node.callee?.property?.value === "lazy") analyzeLazyImport(node, state);
542
- if (node.callee?.type === "Import") analyzeDynamicImport(node, state);
543
- if (isHOCPattern(node, state)) analyzeHOCUsage(node, state);
544
- if (node.callee?.object?.value === "React") {
545
- if (node.callee?.property?.value === "memo") analyzeMemoUsage(node, state);
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
- * Visits all children of a node
711
+ * Checks if a JSX member expression is a known component
553
712
  */
554
- function visitChildren(node, state, context) {
555
- if (!node) return;
556
- for (const key in node) {
557
- const value = node[key];
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
- * Generates a comprehensive usage report from parser state
721
+ * Extracts props from JSX attributes
573
722
  */
574
- function generateReport(state) {
575
- return {
576
- summary: {
577
- totalImports: state.usagePatterns.defaultImports.size + state.usagePatterns.namedImports.size + state.usagePatterns.namespaceImports.size,
578
- totalComponents: state.componentNames.size,
579
- totalUsagePatterns: calculateTotalPatterns(state)
580
- },
581
- patterns: {
582
- imports: {
583
- default: Array.from(state.usagePatterns.defaultImports),
584
- named: Array.from(state.usagePatterns.namedImports),
585
- namespace: Array.from(state.usagePatterns.namespaceImports),
586
- aliased: Array.from(state.usagePatterns.aliasedImports.values())
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
- * Calculates total number of usage patterns found
739
+ * Extracts value from JSX attribute
617
740
  */
618
- function calculateTotalPatterns(state) {
619
- return Object.values(state.usagePatterns).reduce((sum, collection) => {
620
- if (collection instanceof Set || collection instanceof Map) return sum + collection.size;
621
- return sum;
622
- }, 0);
623
- }
624
- //#endregion
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
- function parseCode(code, filePath = "file.tsx") {
654
- const state = createState();
655
- visitNode(parseSync(code, swcOptionsForFile(filePath)), state);
656
- return generateReport(state);
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
- function parseFile(filePath) {
659
- return parseCode(fs.readFileSync(filePath, "utf8"), filePath);
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/utils/package-distribution.ts
663
- function resolvePackageFromImportPath(importPath, availablePackages) {
664
- if (importPath.startsWith(".") || importPath.startsWith("/")) return "local";
665
- const sortedPackages = [...availablePackages].sort((a, b) => b.length - a.length);
666
- for (const pkg of sortedPackages) {
667
- if (importPath === pkg) return pkg;
668
- if (importPath.startsWith(`${pkg}/`)) return pkg;
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
- return "unknown";
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
- function getPackageVersion(packageName, versions) {
682
- if (versions[packageName]) return versions[packageName];
683
- if (packageName.includes("/")) {
684
- const parts = packageName.split("/");
685
- if (packageName.startsWith("@") && parts.length > 2) {
686
- const basePackage = `${parts[0]}/${parts[1]}`;
687
- if (versions[basePackage]) return versions[basePackage];
688
- }
689
- if (!packageName.startsWith("@") && parts.length > 1) {
690
- if (versions[parts[0]]) return versions[parts[0]];
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
- function calculatePackageDistribution(componentUsageMap, versions, config, multiVersions = {}) {
696
- const ignorePatterns = config?.packages.ignore ?? [];
697
- const internalPatterns = config?.packages.internal ?? [];
698
- const packageMap = /* @__PURE__ */ new Map();
699
- for (const component of componentUsageMap.values()) {
700
- if (component.source === "unknown" || component.source === "local") continue;
701
- if (ignorePatterns.length > 0 && micromatch.isMatch(component.source, ignorePatterns)) continue;
702
- const existing = packageMap.get(component.source);
703
- if (existing) {
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
- const distribution = Array.from(packageMap.values());
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/rules/shared.ts
731
- function toArray(val) {
732
- if (!val) return [];
733
- return Array.isArray(val) ? val : [val];
734
- }
735
- function findMatches(patterns, repoPath, ignore) {
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
- function readPackageJson(repoPath) {
748
- try {
749
- const content = fs$1.readFileSync(path$1.join(repoPath, "package.json"), "utf-8");
750
- return JSON.parse(content);
751
- } catch {
752
- return null;
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/rules/file-rules.ts
757
- function evaluateFileRules(repoPath, rulesConfig, excludes) {
758
- const violations = [];
759
- for (const rule of toArray(rulesConfig.forbid_files)) {
760
- const matches = findMatches(rule.patterns, repoPath, excludes);
761
- if (matches.length > 0) violations.push({
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/rules/script-rules.ts
787
- function evaluateScriptRules(repoPath, rulesConfig) {
788
- const rules = toArray(rulesConfig.require_scripts);
789
- if (rules.length === 0) return [];
790
- const pkg = readPackageJson(repoPath);
791
- const scriptKeys = Object.keys(pkg?.scripts ?? {});
792
- return rules.filter((rule) => !rule.patterns.some((p) => scriptKeys.some((k) => micromatch.isMatch(k, p)))).map((rule) => ({
793
- type: "require_scripts",
794
- severity: rule.severity,
795
- patterns: rule.patterns,
796
- message: rule.message,
797
- matchedFiles: []
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
- //#endregion
801
- //#region src/rules/package-field-rules.ts
802
- function evaluatePackageFieldRules(repoPath, rulesConfig) {
803
- const rules = toArray(rulesConfig.require_package_fields);
804
- if (rules.length === 0) return [];
805
- const pkg = readPackageJson(repoPath);
806
- const fieldKeys = pkg ? Object.keys(pkg) : [];
807
- return rules.filter((rule) => !rule.patterns.some((p) => fieldKeys.includes(p))).map((rule) => ({
808
- type: "require_package_fields",
809
- severity: rule.severity,
810
- patterns: rule.patterns,
811
- message: rule.message,
812
- matchedFiles: []
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/rules/engine-version.ts
817
- function evaluateEngineVersion(repoPath, rulesConfig) {
818
- const rules = toArray(rulesConfig.engine_version);
819
- if (rules.length === 0) return [];
820
- const nodeRange = (readPackageJson(repoPath)?.engines)?.node;
821
- return rules.flatMap((rule) => {
822
- if (!nodeRange) return [{
823
- type: "engine_version",
824
- severity: rule.severity,
825
- patterns: [],
826
- message: rule.message ?? "engines.node not specified in package.json",
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/rules/evaluator.ts
845
- function evaluateRules(repoPath, rulesConfig, excludes) {
846
- return [
847
- ...evaluateFileRules(repoPath, rulesConfig, excludes),
848
- ...evaluateScriptRules(repoPath, rulesConfig),
849
- ...evaluatePackageFieldRules(repoPath, rulesConfig),
850
- ...evaluateEngineVersion(repoPath, rulesConfig)
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/utils/package-rules.ts
855
- function detectBannedPackages(distribution, config) {
856
- const forbidRules = toArray(config?.rules.forbid_packages);
857
- if (forbidRules.length === 0) return [];
858
- const violations = [];
859
- for (const pkg of distribution) for (const rule of forbidRules) if (micromatch.isMatch(pkg.packageName, rule.patterns)) {
860
- violations.push({
861
- packageName: pkg.packageName,
862
- severity: rule.severity,
863
- message: rule.message
864
- });
865
- break;
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
- function detectRequiredPackages(distribution, versions, config) {
870
- const requireRules = toArray(config?.rules.require_packages);
871
- if (requireRules.length === 0) return [];
872
- const installedNames = /* @__PURE__ */ new Set([...Object.keys(versions), ...distribution.map((p) => p.packageName)]);
873
- const violations = [];
874
- for (const rule of requireRules) if (!rule.patterns.some((p) => [...installedNames].some((name) => micromatch.isMatch(name, p)))) violations.push({
875
- type: "require_packages",
876
- severity: rule.severity,
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/utils/pattern-counter.ts
885
- function countPatterns(report, patternMap) {
886
- increment(patternMap, "imports.default", report.patterns.imports.default.length);
887
- increment(patternMap, "imports.named", report.patterns.imports.named.length);
888
- increment(patternMap, "imports.namespace", report.patterns.imports.namespace.length);
889
- increment(patternMap, "imports.aliased", report.patterns.imports.aliased.length);
890
- increment(patternMap, "usage.jsx", report.patterns.usage.jsx.length);
891
- increment(patternMap, "usage.variables", report.patterns.usage.variables.length);
892
- increment(patternMap, "usage.destructuring", report.patterns.usage.destructuring.length);
893
- increment(patternMap, "usage.conditional", report.patterns.usage.conditional.length);
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
- function increment(map, key, value) {
904
- map.set(key, (map.get(key) || 0) + value);
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
- function getPatternDisplayName(patternType) {
907
- return {
908
- "imports.default": "Default Imports",
909
- "imports.named": "Named Imports",
910
- "imports.namespace": "Namespace Imports",
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
- //#endregion
927
- //#region src/utils/versus.ts
928
- function toPercentage(count, total) {
929
- return total > 0 ? count / total * 100 : 0;
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
- function calculateVersusResults(distribution, versusConfigs) {
932
- const distMap = new Map(distribution.map((p) => [p.packageName, p]));
933
- return versusConfigs.map((vc) => {
934
- const entries = vc.packages.map((pkgName) => {
935
- const pkg = distMap.get(pkgName);
936
- return {
937
- packageName: pkgName,
938
- count: pkg?.usageCount ?? 0,
939
- percentage: 0,
940
- components: pkg?.components ?? []
941
- };
942
- });
943
- const totalCount = entries.reduce((sum, e) => sum + e.count, 0);
944
- for (const entry of entries) entry.percentage = toPercentage(entry.count, totalCount);
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/utils/aggregator-core.ts
956
- function aggregateReports(reports, versions = {}, config, multiVersions = {}) {
957
- const componentUsageMap = /* @__PURE__ */ new Map();
958
- let totalImports = 0;
959
- let totalUsagePatterns = 0;
960
- const patternCountMap = /* @__PURE__ */ new Map();
961
- const availablePackages = Object.keys(versions);
962
- for (const report of reports) {
963
- totalImports += report.summary.totalImports;
964
- totalUsagePatterns += report.summary.totalUsagePatterns;
965
- for (const jsx of report.patterns.usage.jsx) {
966
- const key = jsx.component;
967
- const existing = componentUsageMap.get(key);
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
- countPatterns(report, patternCountMap);
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
- const topComponents = Array.from(componentUsageMap.values()).sort((a, b) => b.count - a.count);
982
- const allComponents = Array.from(componentUsageMap.keys()).sort();
983
- const patternCounts = Array.from(patternCountMap.entries()).map(([type, count]) => ({
984
- patternType: type,
985
- displayName: getPatternDisplayName(type),
986
- count
987
- })).sort((a, b) => b.count - a.count);
988
- const packageDistribution = calculatePackageDistribution(componentUsageMap, versions, config, multiVersions);
989
- const versusResults = calculateVersusResults(packageDistribution, config?.versus ?? []);
990
- const bannedPackageViolations = detectBannedPackages(packageDistribution, config);
991
- const requiredPackageViolations = detectRequiredPackages(packageDistribution, versions, config);
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
- filesAnalyzed: reports.length,
994
- totalImports,
995
- totalComponents: componentUsageMap.size,
996
- totalUsagePatterns,
997
- patternCounts,
998
- componentUsage: componentUsageMap,
999
- topComponents,
1000
- allComponents,
1001
- packageDistribution,
1002
- versusResults,
1003
- ruleViolations: requiredPackageViolations,
1004
- bannedPackageViolations,
1005
- reports
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
- * Format a number with thousand separators
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 formatCount(num) {
1016
- return num.toLocaleString();
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/utils/print-summary.ts
1020
- function printHeader$4() {
1021
- console.log(chalk.green.bold("\nšŸ“Š Summary\n"));
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 printSummary(aggregated) {
1024
- printHeader$4();
1025
- const table = new Table({
1026
- head: ["Metric", "Count"],
1027
- style: {
1028
- head: ["cyan"],
1029
- border: ["gray"]
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 externalComponents = aggregated.topComponents.filter((comp) => comp.source !== "unknown" && comp.source !== "local").length;
1033
- const totalExternalUsage = aggregated.packageDistribution.reduce((sum, pkg) => sum + pkg.usageCount, 0);
1034
- table.push(["Files Analyzed", formatCount(aggregated.filesAnalyzed)], ["External Packages", formatCount(aggregated.packageDistribution.length)], ["External Components", formatCount(externalComponents)], ["Total Usages", formatCount(totalExternalUsage)]);
1035
- console.log(table.toString());
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/utils/print-details.ts
1039
- function printHeader$3() {
1040
- console.log(chalk.cyan.bold("\nšŸ“‹ Details\n"));
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
- //#endregion
1048
- //#region src/utils/chart-renderer.ts
1049
- function renderBarChart(data, options = {}) {
1050
- const { maxWidth = 50, showValues = true, barChar = "ā–ˆ", emptyChar = "ā–‘" } = options;
1051
- if (data.length === 0) {
1052
- console.log(chalk.gray(" No data to display"));
1053
- return;
1054
- }
1055
- const maxValue = Math.max(...data.map((d) => d.value));
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
- const maxLabelLength = Math.max(...data.map((d) => d.label.length));
1061
- for (const item of data) {
1062
- const percentage = item.value / maxValue;
1063
- const barLength = Math.round(percentage * maxWidth);
1064
- const emptyLength = maxWidth - barLength;
1065
- const paddedLabel = item.label.padEnd(maxLabelLength, " ");
1066
- const bar = chalk.green(barChar.repeat(barLength)) + chalk.gray(emptyChar.repeat(emptyLength));
1067
- const valueStr = showValues ? ` ${formatCount(item.value)}` : "";
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/utils/print-components.ts
1073
- function printHeader$2() {
1074
- console.log(chalk.magenta.bold("\nāš›ļø Components\n"));
1075
- }
1076
- function printComponents(aggregated, mode) {
1077
- const components = aggregated.topComponents;
1078
- if (mode === "table") printComponentsTable(components);
1079
- else if (mode === "chart") printComponentsChart(components);
1080
- }
1081
- function printComponentsTable(components) {
1082
- printHeader$2();
1083
- const externalComponents = components.filter((comp) => comp.source !== "unknown" && comp.source !== "local");
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 table = new Table({
1089
- head: [
1090
- "Component",
1091
- "Package",
1092
- "Count"
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
- console.log(table.toString());
1373
+ return violations;
1107
1374
  }
1108
- function printComponentsChart(components) {
1109
- printHeader$2();
1110
- const externalComponents = components.filter((comp) => comp.source !== "unknown" && comp.source !== "local");
1111
- if (externalComponents.length === 0) {
1112
- console.log(chalk.gray(" No external components found"));
1113
- return;
1114
- }
1115
- renderBarChart(externalComponents.map((comp) => ({
1116
- label: comp.name,
1117
- value: comp.count
1118
- })), { maxWidth: 50 });
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/utils/print-patterns.ts
1122
- function printHeader$1() {
1123
- console.log(chalk.blue.bold("\nšŸ” Code Patterns\n"));
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
- function printPatterns(aggregated, mode) {
1126
- const patterns = aggregated.patternCounts.filter((p) => p.count > 0);
1127
- if (mode === "table") printPatternsTable(patterns);
1128
- else if (mode === "chart") printPatternsChart(patterns);
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 printPatternsTable(patterns) {
1131
- printHeader$1();
1132
- if (patterns.length === 0) {
1133
- console.log(chalk.gray(" No patterns found"));
1134
- return;
1135
- }
1136
- const table = new Table({
1137
- head: ["Pattern", "Count"],
1138
- style: {
1139
- head: ["cyan"],
1140
- border: ["gray"]
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
- renderBarChart(patterns.map((pattern) => ({
1157
- label: pattern.displayName,
1158
- value: pattern.count
1159
- })), { maxWidth: 50 });
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/utils/print-packages.ts
1163
- function printHeader() {
1164
- console.log(chalk.blueBright.bold("\nšŸ“¦ Packages\n"));
1165
- }
1166
- function formatPackageName(pkg, banned) {
1167
- let prefix = "";
1168
- if (pkg.releaseAge?.deprecated) prefix += chalk.red("[DEPRECATED] ");
1169
- if (banned) prefix += banned.severity === "error" ? chalk.red("[BANNED] ") : chalk.yellow("[RESTRICTED] ");
1170
- else if (pkg.internal) prefix += chalk.yellow("[int] ");
1171
- return prefix + pkg.packageName;
1172
- }
1173
- function formatUpgradeCell(releaseAge) {
1174
- if (!releaseAge) return "";
1175
- const { worstLevel, upgrades } = releaseAge;
1176
- if (!worstLevel) return chalk.green("āœ“");
1177
- const top = upgrades[0];
1178
- if (!top) return chalk.green("āœ“");
1179
- if (worstLevel === "mandatory_upgrade") return chalk.red(`⚠ ${top.semverBump} ${top.version} (${top.releasedDaysAgo}d)`);
1180
- return chalk.yellow(`↑ ${top.semverBump} ${top.version} (${top.releasedDaysAgo}d)`);
1181
- }
1182
- function getBannedViolation(pkg, violations) {
1183
- return violations.find((v) => v.packageName === pkg.packageName);
1184
- }
1185
- function printPackages(aggregated, mode) {
1186
- const packages = aggregated.packageDistribution;
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
- function printPackagesChart(packages, violations) {
1231
- printHeader();
1232
- if (packages.length === 0) {
1233
- console.log(chalk.gray(" No packages found"));
1234
- return;
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
- const maxBarWidth = 40;
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
- //#endregion
1248
- //#region src/utils/print-versus.ts
1249
- const BAR_WIDTH = 30;
1250
- function renderBar(percentage) {
1251
- const filled = Math.round(percentage / 100 * BAR_WIDTH);
1252
- const empty = BAR_WIDTH - filled;
1253
- return chalk.cyan("ā–ˆ".repeat(filled)) + chalk.gray("ā–‘".repeat(empty));
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
- function formatComponents(components, max = 3) {
1256
- if (components.length === 0) return "";
1257
- const shown = components.slice(0, max);
1258
- const rest = components.length - max;
1259
- const list = shown.join(", ");
1260
- return rest > 0 ? `${list} (+${rest} more)` : list;
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 printVersusResult(result) {
1263
- console.log(chalk.bold(` ${result.name}`));
1264
- console.log(chalk.gray(` ${"─".repeat(50)}`));
1265
- const maxNameLen = Math.max(...result.entries.map((e) => e.packageName.length));
1266
- for (const entry of result.entries) {
1267
- const name = entry.packageName.padEnd(maxNameLen);
1268
- const bar = renderBar(entry.percentage);
1269
- const pct = chalk.bold(`${entry.percentage.toFixed(1)}%`);
1270
- const usage = chalk.gray(`(${entry.count} usages)`);
1271
- const components = entry.components.length > 0 ? chalk.gray(` ${formatComponents(entry.components)}`) : "";
1272
- console.log(` ${name} ${bar} ${pct} ${usage}${components}`);
1273
- }
1274
- if (result.totalCount === 0) console.log(chalk.gray(" No usage detected for any package in this group."));
1275
- console.log();
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
- function printVersus(aggregated) {
1278
- if (aggregated.versusResults.length === 0) return;
1279
- console.log(chalk.magentaBright.bold("\nāš–ļø Versus\n"));
1280
- for (const result of aggregated.versusResults) printVersusResult(result);
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/print-rules.ts
1284
- function formatRuleType(type) {
1285
- switch (type) {
1286
- case "forbid_files": return "forbid_files";
1287
- case "require_files": return "require_files";
1288
- case "allow_files": return "allow_files";
1289
- case "forbid_packages": return "forbid_packages";
1290
- case "require_packages": return "require_packages";
1291
- case "require_scripts": return "require_scripts";
1292
- case "require_package_fields": return "pkg_fields";
1293
- case "engine_version": return "engine_version";
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 ruleIcon(violation) {
1297
- if (violation.severity === "error") return chalk.red("āœ—");
1298
- return chalk.yellow("⚠");
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
- function describeViolation(v) {
1301
- const patterns = v.patterns.join(", ");
1302
- const suffix = v.message ? chalk.gray(` — ${v.message}`) : "";
1303
- if (v.type === "forbid_files") return `${patterns} found (${v.matchedFiles.map((f) => {
1304
- const parts = f.replace(/\\/g, "/").split("/");
1305
- return parts[parts.length - 1];
1306
- }).join(", ")})${suffix}`;
1307
- if (v.type === "require_files") return `${patterns} not found${suffix}`;
1308
- if (v.type === "allow_files") return `${patterns} not present${suffix}`;
1309
- if (v.type === "require_packages") return `${patterns} not installed${suffix}`;
1310
- if (v.type === "forbid_packages") return `${patterns} is forbidden${suffix}`;
1311
- if (v.type === "require_scripts") return `script ${patterns} missing in package.json${suffix}`;
1312
- if (v.type === "require_package_fields") return `field ${patterns} missing in package.json${suffix}`;
1313
- if (v.type === "engine_version") {
1314
- if (!v.installedRange) return `engines.node not specified (required ${v.requiredRange})${suffix}`;
1315
- return `engines.node is ${chalk.yellow(v.installedRange)}, required ${chalk.cyan(v.requiredRange)}${suffix}`;
1316
- }
1317
- return `${patterns} not present${suffix}`;
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 printRules(aggregated) {
1320
- const { ruleViolations, bannedPackageViolations } = aggregated;
1321
- const hasRuleViolations = ruleViolations.length > 0;
1322
- const hasBannedViolations = bannedPackageViolations.length > 0;
1323
- if (!hasRuleViolations && !hasBannedViolations) {
1324
- console.log(chalk.greenBright.bold("\nāœ“ Compliance\n"));
1325
- console.log(chalk.gray(" All compliance checks passed"));
1326
- return;
1327
- }
1328
- console.log(chalk.blueBright.bold("\nšŸ” Compliance\n"));
1329
- if (hasRuleViolations) for (const v of ruleViolations) {
1330
- const icon = ruleIcon(v);
1331
- const type = chalk.gray(formatRuleType(v.type).padEnd(14));
1332
- const severityTag = v.severity === "error" ? chalk.red("[ERROR]") : chalk.yellow("[WARN]");
1333
- console.log(` ${icon} ${type} ${describeViolation(v)} ${severityTag}`);
1334
- }
1335
- if (hasBannedViolations) {
1336
- if (hasRuleViolations) console.log();
1337
- for (const v of bannedPackageViolations) {
1338
- const icon = v.severity === "error" ? chalk.red("āœ—") : chalk.yellow("⚠");
1339
- const tag = v.severity === "error" ? chalk.red("[BANNED]") : chalk.yellow("[RESTRICTED]");
1340
- const msg = v.message ? chalk.gray(` — ${v.message}`) : "";
1341
- console.log(` ${icon} ${tag} ${v.packageName}${msg}`);
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 errorCount = [...ruleViolations.filter((v) => v.severity === "error"), ...bannedPackageViolations.filter((v) => v.severity === "error")].length;
1345
- const warnCount = [...ruleViolations.filter((v) => v.severity === "warn"), ...bannedPackageViolations.filter((v) => v.severity === "warn")].length;
1346
- const parts = [];
1347
- if (errorCount > 0) parts.push(chalk.red(`${errorCount} error${errorCount > 1 ? "s" : ""}`));
1348
- if (warnCount > 0) parts.push(chalk.yellow(`${warnCount} warning${warnCount > 1 ? "s" : ""}`));
1349
- console.log(chalk.gray(`\n ${parts.join(", ")}`));
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
- console.log(chalk.yellow(`\n⚠ ${errors.length} file(s) failed to parse:`));
1738
+ const stream = isJson ? process.stderr : process.stdout;
1739
+ stream.write(chalk.yellow(`\n⚠ ${errors.length} file(s) failed to parse:\n`));
1356
1740
  for (const { file, message } of errors) {
1357
- console.log(chalk.yellow(` ${file}`));
1358
- console.log(chalk.gray(` ${message}`));
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
- let pkgName = key;
1554
- if (key.startsWith("@")) {
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 upgrades = [];
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 level = upgradeLevel(daysAgo, bump, thresholds);
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 worstPerBump = /* @__PURE__ */ new Map();
1769
- for (const upgrade of upgrades) {
1770
- const existing = worstPerBump.get(upgrade.semverBump);
1771
- if (!existing || upgrade.releasedDaysAgo > existing.releasedDaysAgo) worstPerBump.set(upgrade.semverBump, upgrade);
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: finalUpgrades.some((u) => u.level === "mandatory_upgrade") ? "mandatory_upgrade" : finalUpgrades.length > 0 ? "needs_upgrade" : null,
1778
- deprecated
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 fetchPackageInfo(pkg.packageName, registryUrl, authToken);
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 startTime = Date.now();
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 lockfileResult = findAndParseLockfile(process.cwd());
1835
- spinner.succeed(chalk.blue(`šŸ“¦ Found ${lockfileResult.lockfileType} lockfile (supports: ${lockfileResult.supportedVersions.join(", ")}) - ${Object.keys(lockfileResult.versions).length} packages`));
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, elapsedTime);
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
- console.error(error);
1878
- process.exit(1);
2293
+ process.exitCode = 1;
1879
2294
  }
1880
2295
  }
1881
- function printScanResults(aggregated, config, _elapsedTime) {
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 };