@buoy-design/cli 0.3.32 → 0.3.34
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/commands/check.d.ts.sync-conflict-20260305-170128-6PCZ3ZU.map +1 -0
- package/dist/commands/check.sync-conflict-20260305-155016-6PCZ3ZU.d.ts +26 -0
- package/dist/commands/check.sync-conflict-20260305-155016-6PCZ3ZU.d.ts.map +1 -0
- package/dist/commands/check.sync-conflict-20260305-155016-6PCZ3ZU.js +438 -0
- package/dist/commands/check.sync-conflict-20260305-155016-6PCZ3ZU.js.map +1 -0
- package/dist/commands/dock.sync-conflict-20260309-191923-6PCZ3ZU.js +1006 -0
- package/dist/commands/show.d.ts.map +1 -1
- package/dist/commands/show.d.ts.sync-conflict-20260306-165917-6PCZ3ZU.map +1 -0
- package/dist/commands/show.js +6 -0
- package/dist/commands/show.js.map +1 -1
- package/dist/commands/show.sync-conflict-20260305-140755-6PCZ3ZU.js +1735 -0
- package/dist/commands/show.sync-conflict-20260309-130326-6PCZ3ZU.d.ts +11 -0
- package/dist/commands/show.sync-conflict-20260309-130326-6PCZ3ZU.d.ts.map +1 -0
- package/dist/commands/show.sync-conflict-20260309-130326-6PCZ3ZU.js +1735 -0
- package/dist/commands/show.sync-conflict-20260309-130326-6PCZ3ZU.js.map +1 -0
- package/dist/config/loader.js +1 -1
- package/dist/config/loader.js.map +1 -1
- package/dist/config/loader.js.sync-conflict-20260309-033512-6PCZ3ZU.map +1 -0
- package/dist/config/loader.sync-conflict-20260307-175208-6PCZ3ZU.d.ts +8 -0
- package/dist/config/loader.sync-conflict-20260307-175208-6PCZ3ZU.d.ts.map +1 -0
- package/dist/config/loader.sync-conflict-20260307-175208-6PCZ3ZU.js +162 -0
- package/dist/config/loader.sync-conflict-20260307-175208-6PCZ3ZU.js.map +1 -0
- package/dist/config/schema.d.ts.sync-conflict-20260309-154654-6PCZ3ZU.map +1 -0
- package/dist/config/schema.sync-conflict-20260309-135703-6PCZ3ZU.js +214 -0
- package/dist/detect/frameworks.js.sync-conflict-20260306-123756-6PCZ3ZU.map +1 -0
- package/dist/detect/monorepo-patterns.js.sync-conflict-20260309-155400-6PCZ3ZU.map +1 -0
- package/dist/hooks/index.d.ts.sync-conflict-20260306-220901-6PCZ3ZU.map +1 -0
- package/dist/output/formatters.js.sync-conflict-20260306-134702-6PCZ3ZU.map +1 -0
- package/dist/output/formatters.sync-conflict-20260306-180804-6PCZ3ZU.js +867 -0
- package/dist/output/formatters.sync-conflict-20260307-131418-5SN2GZG.d.ts +29 -0
- package/dist/output/formatters.sync-conflict-20260307-131418-5SN2GZG.d.ts.map +1 -0
- package/dist/output/formatters.sync-conflict-20260307-131418-5SN2GZG.js +867 -0
- package/dist/output/formatters.sync-conflict-20260307-131418-5SN2GZG.js.map +1 -0
- package/dist/output/formatters.sync-conflict-20260307-143122-5SN2GZG.d.ts +29 -0
- package/dist/output/formatters.sync-conflict-20260307-143122-5SN2GZG.d.ts.map +1 -0
- package/dist/output/formatters.sync-conflict-20260307-143122-5SN2GZG.js +867 -0
- package/dist/output/formatters.sync-conflict-20260307-143122-5SN2GZG.js.map +1 -0
- package/dist/output/index.sync-conflict-20260309-222859-6PCZ3ZU.js +5 -0
- package/dist/output/reporters.d.sync-conflict-20260309-193820-6PCZ3ZU.ts +38 -0
- package/dist/output/reporters.d.ts.sync-conflict-20260306-193811-6PCZ3ZU.map +1 -0
- package/dist/output/reporters.sync-conflict-20260309-030558-6PCZ3ZU.js +182 -0
- package/dist/output/reports.d.ts.sync-conflict-20260307-172149-6PCZ3ZU.map +1 -0
- package/dist/output/reports.js.sync-conflict-20260305-161643-6PCZ3ZU.map +1 -0
- package/dist/output/reports.sync-conflict-20260305-211951-6PCZ3ZU.js +393 -0
- package/dist/output/visuals.d.ts +53 -0
- package/dist/output/visuals.d.ts.map +1 -0
- package/dist/output/visuals.js +194 -0
- package/dist/output/visuals.js.map +1 -0
- package/dist/services/drift-analysis.d.sync-conflict-20260306-151016-6PCZ3ZU.ts +194 -0
- package/dist/services/drift-analysis.d.ts.sync-conflict-20260307-175904-6PCZ3ZU.map +1 -0
- package/dist/services/drift-analysis.sync-conflict-20260309-133811-6PCZ3ZU.d.ts +194 -0
- package/dist/services/drift-analysis.sync-conflict-20260309-133811-6PCZ3ZU.d.ts.map +1 -0
- package/dist/services/drift-analysis.sync-conflict-20260309-133811-6PCZ3ZU.js +1022 -0
- package/dist/services/drift-analysis.sync-conflict-20260309-133811-6PCZ3ZU.js.map +1 -0
- package/dist/services/skill-export.d.ts.sync-conflict-20260309-171021-6PCZ3ZU.map +1 -0
- package/dist/services/skill-export.sync-conflict-20260309-144621-6PCZ3ZU.d.ts +109 -0
- package/dist/services/skill-export.sync-conflict-20260309-144621-6PCZ3ZU.d.ts.map +1 -0
- package/dist/services/skill-export.sync-conflict-20260309-144621-6PCZ3ZU.js +737 -0
- package/dist/services/skill-export.sync-conflict-20260309-144621-6PCZ3ZU.js.map +1 -0
- package/package.json +14 -14
- package/LICENSE +0 -21
|
@@ -0,0 +1,1022 @@
|
|
|
1
|
+
// apps/cli/src/services/drift-analysis.ts
|
|
2
|
+
/**
|
|
3
|
+
* DriftAnalysisService - Consolidated drift detection workflow
|
|
4
|
+
*
|
|
5
|
+
* Handles the common pattern of:
|
|
6
|
+
* 1. Scanning components via ScanOrchestrator
|
|
7
|
+
* 2. Running SemanticDiffEngine analysis
|
|
8
|
+
* 3. Applying ignore rules from config
|
|
9
|
+
* 4. Filtering against ignore list
|
|
10
|
+
*/
|
|
11
|
+
import { ScanOrchestrator } from "../scan/orchestrator.js";
|
|
12
|
+
import { getSeverityWeight } from "@buoy-design/core";
|
|
13
|
+
import { TailwindScanner, extractStaticClassStrings } from "@buoy-design/scanners";
|
|
14
|
+
import { detectRepeatedPatterns, checkVariantConsistency, checkExampleCompliance, detectTokenUtilities, checkTokenUtilityUsage, } from "@buoy-design/core";
|
|
15
|
+
import { glob } from "glob";
|
|
16
|
+
import { minimatch } from "minimatch";
|
|
17
|
+
import { readFile } from "fs/promises";
|
|
18
|
+
import { resolve } from "path";
|
|
19
|
+
/**
|
|
20
|
+
* Severity order for filtering and sorting (0 = lowest, 2 = highest)
|
|
21
|
+
* Use getSeverityWeight from @buoy-design/core for consistent ordering
|
|
22
|
+
*/
|
|
23
|
+
const SEVERITY_ORDER = {
|
|
24
|
+
info: 0,
|
|
25
|
+
warning: 1,
|
|
26
|
+
critical: 2,
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Calculate summary counts for drift signals
|
|
30
|
+
*/
|
|
31
|
+
export function calculateDriftSummary(drifts) {
|
|
32
|
+
return {
|
|
33
|
+
total: drifts.length,
|
|
34
|
+
critical: drifts.filter((d) => d.severity === "critical").length,
|
|
35
|
+
warning: drifts.filter((d) => d.severity === "warning").length,
|
|
36
|
+
info: drifts.filter((d) => d.severity === "info").length,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Determine if drifts exceed a severity threshold
|
|
41
|
+
*/
|
|
42
|
+
export function hasDriftsAboveThreshold(drifts, failOn) {
|
|
43
|
+
if (failOn === "none")
|
|
44
|
+
return false;
|
|
45
|
+
const threshold = SEVERITY_ORDER[failOn] ?? SEVERITY_ORDER.critical;
|
|
46
|
+
return drifts.some((d) => SEVERITY_ORDER[d.severity] >= threshold);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Sort drifts by severity (critical first)
|
|
50
|
+
*/
|
|
51
|
+
export function sortDriftsBySeverity(drifts) {
|
|
52
|
+
return [...drifts].sort((a, b) => getSeverityWeight(b.severity) - getSeverityWeight(a.severity));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Apply ignore rules from config to filter out matching drifts.
|
|
56
|
+
* Each rule can filter by type, file (glob), component (regex),
|
|
57
|
+
* token (regex), and/or value (regex). Multiple fields = AND.
|
|
58
|
+
* Multiple rules = OR (any rule can ignore a drift).
|
|
59
|
+
*/
|
|
60
|
+
export function applyIgnoreRules(drifts, ignoreRules, onWarning) {
|
|
61
|
+
if (ignoreRules.length === 0)
|
|
62
|
+
return drifts;
|
|
63
|
+
return drifts.filter((d) => {
|
|
64
|
+
// Keep drift if NO rule matches it (rules are OR'd)
|
|
65
|
+
for (const rule of ignoreRules) {
|
|
66
|
+
if (ruleMatches(d, rule, onWarning))
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Apply promote rules — elevate severity of matching drifts.
|
|
74
|
+
* Each rule specifies filter dimensions + `to` (target severity).
|
|
75
|
+
* Multiple rules = OR (first match wins).
|
|
76
|
+
*/
|
|
77
|
+
export function applyPromoteRules(drifts, rules, onWarning) {
|
|
78
|
+
if (rules.length === 0)
|
|
79
|
+
return drifts;
|
|
80
|
+
return drifts.map((d) => {
|
|
81
|
+
for (const rule of rules) {
|
|
82
|
+
if (ruleMatches(d, rule, onWarning)) {
|
|
83
|
+
return { ...d, severity: rule.to };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return d;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Apply enforce rules — always set matching drifts to critical.
|
|
91
|
+
* Delegates to applyPromoteRules with `to: 'critical'`.
|
|
92
|
+
*/
|
|
93
|
+
export function applyEnforceRules(drifts, rules, onWarning) {
|
|
94
|
+
if (rules.length === 0)
|
|
95
|
+
return drifts;
|
|
96
|
+
const promoteRules = rules.map((rule) => ({ ...rule, to: "critical" }));
|
|
97
|
+
return applyPromoteRules(drifts, promoteRules, onWarning);
|
|
98
|
+
}
|
|
99
|
+
function ruleMatches(d, rule, onWarning) {
|
|
100
|
+
const { type, severity, file, component, token, value } = rule;
|
|
101
|
+
// Rule with no filter dimensions does nothing
|
|
102
|
+
if (!type && !severity && !file && !component && !token && !value)
|
|
103
|
+
return false;
|
|
104
|
+
// All specified dimensions must match (AND logic)
|
|
105
|
+
if (type && d.type !== type)
|
|
106
|
+
return false;
|
|
107
|
+
if (severity && d.severity !== severity)
|
|
108
|
+
return false;
|
|
109
|
+
if (file) {
|
|
110
|
+
// Strip line numbers from location (e.g., "Button.tsx:42" → "Button.tsx")
|
|
111
|
+
const loc = d.source.location.replace(/:\d+$/, "");
|
|
112
|
+
if (!minimatch(loc, file))
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
if (component) {
|
|
116
|
+
if (d.source.entityType !== "component")
|
|
117
|
+
return false;
|
|
118
|
+
try {
|
|
119
|
+
if (!new RegExp(component).test(d.source.entityName))
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
onWarning?.(`Invalid regex "${component}" in ignore rule component field, skipping`);
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (token) {
|
|
128
|
+
if (d.source.entityType !== "token")
|
|
129
|
+
return false;
|
|
130
|
+
try {
|
|
131
|
+
if (!new RegExp(token).test(d.source.entityName))
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
onWarning?.(`Invalid regex "${token}" in ignore rule token field, skipping`);
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (value) {
|
|
140
|
+
const actual = d.details.actual != null ? String(d.details.actual) : "";
|
|
141
|
+
try {
|
|
142
|
+
if (!new RegExp(value).test(actual))
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
onWarning?.(`Invalid regex "${value}" in ignore rule value field, skipping`);
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Apply severity filter to drifts
|
|
154
|
+
*/
|
|
155
|
+
export function filterBySeverity(drifts, minSeverity) {
|
|
156
|
+
const minLevel = SEVERITY_ORDER[minSeverity] ?? 0;
|
|
157
|
+
return drifts.filter((d) => SEVERITY_ORDER[d.severity] >= minLevel);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Apply type filter to drifts
|
|
161
|
+
*/
|
|
162
|
+
export function filterByType(drifts, type) {
|
|
163
|
+
return drifts.filter((d) => d.type === type);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Apply per-type severity overrides from config
|
|
167
|
+
*/
|
|
168
|
+
export function applySeverityOverrides(drifts, overrides) {
|
|
169
|
+
if (!overrides || Object.keys(overrides).length === 0)
|
|
170
|
+
return drifts;
|
|
171
|
+
return drifts.map((d) => {
|
|
172
|
+
const override = overrides[d.type];
|
|
173
|
+
return override ? { ...d, severity: override } : d;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
// Entry point file patterns - these components are rendered by the framework
|
|
177
|
+
// router, not imported by other components, so they should never be flagged as unused
|
|
178
|
+
const ENTRY_POINT_PATTERNS = [
|
|
179
|
+
/\/pages?\//, // Next.js pages/ or page.tsx
|
|
180
|
+
/\/app\/.*page\./, // Next.js App Router page.tsx
|
|
181
|
+
/\/app\/.*layout\./, // Next.js App Router layout.tsx
|
|
182
|
+
/\/app\/.*loading\./, // Next.js App Router loading.tsx
|
|
183
|
+
/\/app\/.*error\./, // Next.js App Router error.tsx
|
|
184
|
+
/\/app\/.*not-found\./, // Next.js App Router not-found.tsx
|
|
185
|
+
/\/app\/.*template\./, // Next.js App Router template.tsx
|
|
186
|
+
/\/routes?\//, // Remix/SvelteKit routes
|
|
187
|
+
/\/\+page\./, // SvelteKit +page.svelte
|
|
188
|
+
/\/\+layout\./, // SvelteKit +layout.svelte
|
|
189
|
+
/\/\+error\./, // SvelteKit +error.svelte
|
|
190
|
+
/\/\+server\./, // SvelteKit +server.ts
|
|
191
|
+
/\/views?\//, // Vue views directory
|
|
192
|
+
/\/screens?\//, // React Native screens
|
|
193
|
+
/\.astro$/, // Astro page/layout components are auto-routed
|
|
194
|
+
/\/(app|main|index)\.(tsx|jsx)$/, // App root / main entry / index component files
|
|
195
|
+
/\/_app\./, // Next.js custom App
|
|
196
|
+
/\/_document\./, // Next.js custom Document
|
|
197
|
+
/\/root\./, // Remix root
|
|
198
|
+
/\/entry\./, // Entry files
|
|
199
|
+
];
|
|
200
|
+
function isEntryPointComponent(component) {
|
|
201
|
+
const source = component.source;
|
|
202
|
+
// Only file-based sources (react, vue, svelte) have a path field
|
|
203
|
+
if (source.type === 'figma' || source.type === 'storybook')
|
|
204
|
+
return false;
|
|
205
|
+
const location = source.path || '';
|
|
206
|
+
return ENTRY_POINT_PATTERNS.some(pattern => pattern.test(location));
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* DriftAnalysisService - Main entry point for drift detection
|
|
210
|
+
*/
|
|
211
|
+
export class DriftAnalysisService {
|
|
212
|
+
config;
|
|
213
|
+
constructor(config) {
|
|
214
|
+
this.config = config;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Run full drift analysis pipeline
|
|
218
|
+
*/
|
|
219
|
+
async analyze(options = {}) {
|
|
220
|
+
const { onProgress, includeIgnored, minSeverity, filterType, cache, checkVariants, checkTokenUtilities, checkExamples, } = options;
|
|
221
|
+
// Step 1: Scan components
|
|
222
|
+
onProgress?.("Scanning components...");
|
|
223
|
+
const orchestrator = new ScanOrchestrator(this.config, process.cwd(), {
|
|
224
|
+
cache,
|
|
225
|
+
});
|
|
226
|
+
const { components } = await orchestrator.scanComponents({
|
|
227
|
+
onProgress,
|
|
228
|
+
});
|
|
229
|
+
// Step 2: Scan tokens (before analysis, so suggestions can be generated)
|
|
230
|
+
onProgress?.("Scanning tokens...");
|
|
231
|
+
const { tokens: scannedTokens } = await orchestrator.scanTokens({ onProgress });
|
|
232
|
+
// Step 2.1: Run semantic diff analysis
|
|
233
|
+
onProgress?.("Analyzing drift...");
|
|
234
|
+
const { SemanticDiffEngine } = await import("@buoy-design/core/analysis");
|
|
235
|
+
const engine = new SemanticDiffEngine();
|
|
236
|
+
const diffResult = engine.analyzeComponents(components, {
|
|
237
|
+
checkDeprecated: true,
|
|
238
|
+
checkNaming: true,
|
|
239
|
+
checkDocumentation: true,
|
|
240
|
+
checkAccessibility: true,
|
|
241
|
+
availableTokens: scannedTokens,
|
|
242
|
+
});
|
|
243
|
+
let drifts = applySeverityOverrides(diffResult.drifts, this.config.drift.severity);
|
|
244
|
+
// Step 2.2: Check framework sprawl
|
|
245
|
+
onProgress?.("Checking for framework sprawl...");
|
|
246
|
+
const { ProjectDetector } = await import("../detect/project-detector.js");
|
|
247
|
+
const detector = new ProjectDetector(process.cwd());
|
|
248
|
+
const projectInfo = await detector.detect();
|
|
249
|
+
if (projectInfo.frameworks.length > 0) {
|
|
250
|
+
const sprawlDrift = engine.checkFrameworkSprawl(projectInfo.frameworks.map((f) => ({ name: f.name, version: f.version })));
|
|
251
|
+
if (sprawlDrift) {
|
|
252
|
+
drifts.push(...applySeverityOverrides([sprawlDrift], this.config.drift.severity));
|
|
253
|
+
onProgress?.(`Framework sprawl detected: ${projectInfo.frameworks.map((f) => f.name).join(", ")}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Step 2.3: Scan for unused components and tokens
|
|
257
|
+
onProgress?.("Scanning for unused components and tokens...");
|
|
258
|
+
const { collectUsages } = await import("@buoy-design/core");
|
|
259
|
+
const componentNames = components.map((c) => c.name);
|
|
260
|
+
const tokenNames = scannedTokens.map((t) => t.name);
|
|
261
|
+
const usageResult = await collectUsages({
|
|
262
|
+
projectRoot: process.cwd(),
|
|
263
|
+
knownComponents: componentNames,
|
|
264
|
+
knownTokens: tokenNames,
|
|
265
|
+
});
|
|
266
|
+
// Build usage count maps
|
|
267
|
+
const componentUsageMap = new Map();
|
|
268
|
+
for (const cu of usageResult.componentUsages) {
|
|
269
|
+
componentUsageMap.set(cu.componentName, (componentUsageMap.get(cu.componentName) || 0) + 1);
|
|
270
|
+
}
|
|
271
|
+
const tokenUsageMap = new Map();
|
|
272
|
+
for (const tu of usageResult.tokenUsages) {
|
|
273
|
+
tokenUsageMap.set(tu.tokenName, (tokenUsageMap.get(tu.tokenName) || 0) + 1);
|
|
274
|
+
}
|
|
275
|
+
// Fix 2: Count barrel file re-exports as usage
|
|
276
|
+
// Components re-exported from barrel files (index.ts) are part of the public API
|
|
277
|
+
await this.scanBarrelReExports(componentUsageMap);
|
|
278
|
+
// Fix 3: Count dynamic imports as usage
|
|
279
|
+
// React.lazy(() => import('./Component')) and next/dynamic patterns
|
|
280
|
+
await this.scanDynamicImports(componentUsageMap);
|
|
281
|
+
// Fix 4: Count Vue/Svelte template component references as usage
|
|
282
|
+
// <MyComponent /> in .vue and .svelte template blocks
|
|
283
|
+
await this.scanTemplateComponentUsage(componentUsageMap, componentNames);
|
|
284
|
+
// Fix 5: Count Vue auto-registration patterns as usage
|
|
285
|
+
// app.component('MyComponent', ...) and Vue.component('MyComponent', ...)
|
|
286
|
+
await this.scanAutoRegistration(componentUsageMap);
|
|
287
|
+
// Fix 6: Count Angular NgModule declarations as usage
|
|
288
|
+
// @NgModule({ declarations: [ButtonComponent, ...] })
|
|
289
|
+
await this.scanNgModuleDeclarations(componentUsageMap);
|
|
290
|
+
// Fix 7: Count Storybook story file imports as usage
|
|
291
|
+
// Button.stories.tsx importing { Button } means Button is documented/tested
|
|
292
|
+
await this.scanStoryFileUsages(componentUsageMap);
|
|
293
|
+
// Fix 8: Count Lit/Web Component registrations as usage
|
|
294
|
+
await this.scanWebComponentRegistrations(componentUsageMap);
|
|
295
|
+
// Fix 9: Treat Nuxt components/ dir as auto-imported
|
|
296
|
+
const componentNameSet = new Set(componentNames);
|
|
297
|
+
await this.scanNuxtAutoImports(componentUsageMap, componentNameSet);
|
|
298
|
+
// Fix 10: Count test file imports as usage
|
|
299
|
+
// Components imported in .test.tsx/.spec.tsx are actively maintained
|
|
300
|
+
await this.scanTestFileUsages(componentUsageMap);
|
|
301
|
+
// Fix 11: Count HOC/wrapper patterns as usage
|
|
302
|
+
// forwardRef(Component), memo(Component), styled(Component), withXxx(Component)
|
|
303
|
+
await this.scanHOCWrapperUsages(componentUsageMap);
|
|
304
|
+
// Fix 12: Component library detection — if package.json exports components,
|
|
305
|
+
// all exported components are the product's public API
|
|
306
|
+
await this.scanPackageExports(componentUsageMap);
|
|
307
|
+
// Fix 13: Detect components passed as values (props, object properties, arguments)
|
|
308
|
+
// e.g. transition={DialogTransition} or { toolbarAccount: AccountPopover }
|
|
309
|
+
await this.scanComponentAsValueUsages(componentUsageMap, componentNames);
|
|
310
|
+
// Fix 1: Exempt entry point components (pages, routes, layouts)
|
|
311
|
+
// These are rendered by the framework router, not imported by other components
|
|
312
|
+
const nonEntryPointComponents = components.filter(c => !isEntryPointComponent(c));
|
|
313
|
+
// Check for unused components (excluding entry points)
|
|
314
|
+
const unusedComponentDrifts = engine.checkUnusedComponents(nonEntryPointComponents, componentUsageMap);
|
|
315
|
+
if (unusedComponentDrifts.length > 0) {
|
|
316
|
+
drifts.push(...applySeverityOverrides(unusedComponentDrifts, this.config.drift.severity));
|
|
317
|
+
onProgress?.(`Found ${unusedComponentDrifts.length} unused components`);
|
|
318
|
+
}
|
|
319
|
+
// Check for unused tokens
|
|
320
|
+
const unusedTokenDrifts = engine.checkUnusedTokens(scannedTokens, tokenUsageMap);
|
|
321
|
+
if (unusedTokenDrifts.length > 0) {
|
|
322
|
+
drifts.push(...applySeverityOverrides(unusedTokenDrifts, this.config.drift.severity));
|
|
323
|
+
onProgress?.(`Found ${unusedTokenDrifts.length} unused tokens`);
|
|
324
|
+
}
|
|
325
|
+
// Step 2.4: Cross-source comparison (orphaned-component, orphaned-token, value-divergence)
|
|
326
|
+
const { classifyComponents, classifyTokens } = await import("./source-classifier.js");
|
|
327
|
+
const canonicalPatterns = this.config.sources.tokens?.canonical ?? [];
|
|
328
|
+
const classifiedComponents = classifyComponents(components);
|
|
329
|
+
const classifiedTokens = classifyTokens(scannedTokens, canonicalPatterns);
|
|
330
|
+
if (classifiedComponents.canonical.length > 0 && classifiedComponents.code.length > 0) {
|
|
331
|
+
onProgress?.(`Comparing ${classifiedComponents.code.length} code components against ${classifiedComponents.canonical.length} design components...`);
|
|
332
|
+
const componentDiff = engine.compareComponents(classifiedComponents.code, classifiedComponents.canonical);
|
|
333
|
+
if (componentDiff.drifts.length > 0) {
|
|
334
|
+
drifts.push(...applySeverityOverrides(componentDiff.drifts, this.config.drift.severity));
|
|
335
|
+
onProgress?.(`Found ${componentDiff.drifts.length} cross-source component issues`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (classifiedTokens.canonical.length > 0 && classifiedTokens.code.length > 0) {
|
|
339
|
+
onProgress?.(`Comparing ${classifiedTokens.code.length} code tokens against ${classifiedTokens.canonical.length} design tokens...`);
|
|
340
|
+
const tokenDiff = engine.compareTokens(classifiedTokens.code, classifiedTokens.canonical);
|
|
341
|
+
if (tokenDiff.drifts.length > 0) {
|
|
342
|
+
drifts.push(...applySeverityOverrides(tokenDiff.drifts, this.config.drift.severity));
|
|
343
|
+
onProgress?.(`Found ${tokenDiff.drifts.length} cross-source token issues`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// Step 2.5: Run Tailwind arbitrary value detection
|
|
347
|
+
if (this.config.sources.tailwind?.enabled) {
|
|
348
|
+
onProgress?.("Scanning for Tailwind arbitrary values...");
|
|
349
|
+
const tailwindScanner = new TailwindScanner({
|
|
350
|
+
projectRoot: process.cwd(),
|
|
351
|
+
include: this.config.sources.tailwind.files,
|
|
352
|
+
exclude: this.config.sources.tailwind.exclude,
|
|
353
|
+
detectArbitraryValues: true,
|
|
354
|
+
});
|
|
355
|
+
const tailwindResult = await tailwindScanner.scan();
|
|
356
|
+
if (tailwindResult.drifts.length > 0) {
|
|
357
|
+
drifts = [
|
|
358
|
+
...drifts,
|
|
359
|
+
...applySeverityOverrides(tailwindResult.drifts, this.config.drift.severity),
|
|
360
|
+
];
|
|
361
|
+
onProgress?.(`Found ${tailwindResult.drifts.length} Tailwind arbitrary value issues`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// Step 2.6: Repeated pattern detection (always-on, opt-out via config)
|
|
365
|
+
const repeatedPatternConfig = (this.config.drift?.types?.["repeated-pattern"] ?? {});
|
|
366
|
+
if (repeatedPatternConfig.enabled !== false) {
|
|
367
|
+
onProgress?.("Detecting repeated patterns...");
|
|
368
|
+
const patternDrifts = await this.detectRepeatedPatterns(repeatedPatternConfig);
|
|
369
|
+
drifts.push(...patternDrifts);
|
|
370
|
+
if (patternDrifts.length > 0) {
|
|
371
|
+
onProgress?.(`Found ${patternDrifts.length} repeated pattern issues`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// Step 2.7: Phase 4.1 - Cross-Variant Consistency Checking
|
|
375
|
+
const variantCheckEnabled = checkVariants ?? this.config.drift?.types?.["value-divergence"]?.checkVariants;
|
|
376
|
+
if (variantCheckEnabled) {
|
|
377
|
+
onProgress?.("Checking variant consistency...");
|
|
378
|
+
const variantDrifts = checkVariantConsistency(components);
|
|
379
|
+
if (variantDrifts.length > 0) {
|
|
380
|
+
drifts.push(...applySeverityOverrides(variantDrifts, this.config.drift.severity));
|
|
381
|
+
onProgress?.(`Found ${variantDrifts.length} variant inconsistencies`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Step 2.8: Phase 4.2 - Token Utility Function Detection
|
|
385
|
+
const tokenUtilityCheckEnabled = checkTokenUtilities ?? this.config.drift?.types?.["hardcoded-value"]?.checkUtilities;
|
|
386
|
+
if (tokenUtilityCheckEnabled) {
|
|
387
|
+
onProgress?.("Detecting token utility functions...");
|
|
388
|
+
const utilityAnalysis = detectTokenUtilities(components);
|
|
389
|
+
if (utilityAnalysis.availableUtilities.length > 0) {
|
|
390
|
+
onProgress?.(`Found ${utilityAnalysis.availableUtilities.length} token utilities: ${utilityAnalysis.availableUtilities.map((u) => u.name).join(", ")}`);
|
|
391
|
+
const utilityDrifts = checkTokenUtilityUsage(components, utilityAnalysis);
|
|
392
|
+
if (utilityDrifts.length > 0) {
|
|
393
|
+
drifts.push(...applySeverityOverrides(utilityDrifts, this.config.drift.severity));
|
|
394
|
+
onProgress?.(`Found ${utilityDrifts.length} hardcoded values that could use utilities`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// Step 2.9: Phase 4.3 - Example Code vs Production Code Analysis
|
|
399
|
+
const exampleCheckEnabled = checkExamples ?? this.config.drift?.types?.["missing-documentation"]?.checkExamples;
|
|
400
|
+
if (exampleCheckEnabled) {
|
|
401
|
+
onProgress?.("Analyzing example code compliance...");
|
|
402
|
+
const exampleDrifts = checkExampleCompliance(components);
|
|
403
|
+
if (exampleDrifts.length > 0) {
|
|
404
|
+
drifts.push(...applySeverityOverrides(exampleDrifts, this.config.drift.severity));
|
|
405
|
+
onProgress?.(`Found ${exampleDrifts.length} example/documentation issues`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// Step 3: Apply severity filter (before other filters for efficiency)
|
|
409
|
+
if (minSeverity) {
|
|
410
|
+
drifts = filterBySeverity(drifts, minSeverity);
|
|
411
|
+
}
|
|
412
|
+
// Step 4: Apply type filter
|
|
413
|
+
if (filterType) {
|
|
414
|
+
drifts = filterByType(drifts, filterType);
|
|
415
|
+
}
|
|
416
|
+
// Step 5: Apply promote rules from config
|
|
417
|
+
if (this.config.drift.promote && this.config.drift.promote.length > 0) {
|
|
418
|
+
drifts = applyPromoteRules(drifts, this.config.drift.promote, (msg) => {
|
|
419
|
+
onProgress?.(`Warning: ${msg}`);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
// Step 6: Apply enforce rules from config
|
|
423
|
+
if (this.config.drift.enforce && this.config.drift.enforce.length > 0) {
|
|
424
|
+
drifts = applyEnforceRules(drifts, this.config.drift.enforce, (msg) => {
|
|
425
|
+
onProgress?.(`Warning: ${msg}`);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
// Step 7: Apply ignore rules from config
|
|
429
|
+
drifts = applyIgnoreRules(drifts, this.config.drift.ignore, (msg) => {
|
|
430
|
+
onProgress?.(`Warning: ${msg}`);
|
|
431
|
+
});
|
|
432
|
+
// Step 8: Apply ignore list filtering
|
|
433
|
+
let ignoredCount = 0;
|
|
434
|
+
if (!includeIgnored) {
|
|
435
|
+
const { loadIgnoreList, filterIgnored } = await import("../commands/ignore.js");
|
|
436
|
+
const ignoreList = await loadIgnoreList();
|
|
437
|
+
const filtered = filterIgnored(drifts, ignoreList);
|
|
438
|
+
drifts = filtered.newDrifts;
|
|
439
|
+
ignoredCount = filtered.ignoredCount;
|
|
440
|
+
if (ignoredCount > 0) {
|
|
441
|
+
onProgress?.(`Filtered out ${ignoredCount} ignored drift signals.`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
drifts,
|
|
446
|
+
components,
|
|
447
|
+
ignoredCount,
|
|
448
|
+
summary: calculateDriftSummary(drifts),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Scan barrel files (index.ts) for re-exports and count re-exported components as used.
|
|
453
|
+
* Components re-exported from barrel files are part of the public API.
|
|
454
|
+
*/
|
|
455
|
+
async scanBarrelReExports(componentUsageMap) {
|
|
456
|
+
const cwd = process.cwd();
|
|
457
|
+
const barrelFiles = await glob('**/index.{ts,tsx,js,jsx}', {
|
|
458
|
+
cwd,
|
|
459
|
+
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**'],
|
|
460
|
+
nodir: true,
|
|
461
|
+
maxDepth: 6,
|
|
462
|
+
});
|
|
463
|
+
for (const barrelFile of barrelFiles.slice(0, 200)) {
|
|
464
|
+
try {
|
|
465
|
+
const content = await readFile(resolve(cwd, barrelFile), 'utf-8');
|
|
466
|
+
// Check for named re-exports: export { Button, Card } from './components'
|
|
467
|
+
const namedPattern = /export\s*\{\s*([^}]+)\s*\}\s*from/g;
|
|
468
|
+
let match;
|
|
469
|
+
while ((match = namedPattern.exec(content)) !== null) {
|
|
470
|
+
const names = match[1].split(',').map(n => {
|
|
471
|
+
// Handle "default as Name" and "Name as Alias"
|
|
472
|
+
const parts = n.trim().split(/\s+as\s+/);
|
|
473
|
+
return (parts[1] || parts[0] || '').trim();
|
|
474
|
+
}).filter(n => n && /^[A-Z]/.test(n)); // Only PascalCase (component names)
|
|
475
|
+
for (const name of names) {
|
|
476
|
+
componentUsageMap.set(name, (componentUsageMap.get(name) || 0) + 1);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// Check for default re-exports: export { default as Button } from './Button'
|
|
480
|
+
const defaultReExportPattern = /export\s*\{\s*default\s+as\s+([A-Z][a-zA-Z0-9]*)\s*\}\s*from/g;
|
|
481
|
+
while ((match = defaultReExportPattern.exec(content)) !== null) {
|
|
482
|
+
const name = match[1];
|
|
483
|
+
componentUsageMap.set(name, (componentUsageMap.get(name) || 0) + 1);
|
|
484
|
+
}
|
|
485
|
+
// Check for wildcard re-exports: export * from './Button'
|
|
486
|
+
// If the module name looks like a component name, count it
|
|
487
|
+
const wildcardPattern = /export\s*\*\s*from\s*['"]\.\/([^'"]+)['"]/g;
|
|
488
|
+
while ((match = wildcardPattern.exec(content)) !== null) {
|
|
489
|
+
const moduleName = match[1];
|
|
490
|
+
const segments = moduleName.split('/');
|
|
491
|
+
const last = segments[segments.length - 1] || '';
|
|
492
|
+
if (/^[A-Z]/.test(last)) {
|
|
493
|
+
componentUsageMap.set(last, (componentUsageMap.get(last) || 0) + 1);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
// ignore unreadable files
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Scan source files for dynamic imports and count imported components as used.
|
|
504
|
+
* Handles React.lazy(() => import('./Component')) and next/dynamic patterns.
|
|
505
|
+
*/
|
|
506
|
+
async scanDynamicImports(componentUsageMap) {
|
|
507
|
+
const cwd = process.cwd();
|
|
508
|
+
const sourceFiles = await glob('**/*.{tsx,jsx,ts,js}', {
|
|
509
|
+
cwd,
|
|
510
|
+
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/*.test.*', '**/*.spec.*'],
|
|
511
|
+
nodir: true,
|
|
512
|
+
maxDepth: 6,
|
|
513
|
+
});
|
|
514
|
+
for (const file of sourceFiles.slice(0, 200)) {
|
|
515
|
+
try {
|
|
516
|
+
const content = await readFile(resolve(cwd, file), 'utf-8');
|
|
517
|
+
if (!content.includes('import('))
|
|
518
|
+
continue; // Quick check before regex
|
|
519
|
+
const dynamicPattern = /import\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
520
|
+
let match;
|
|
521
|
+
while ((match = dynamicPattern.exec(content)) !== null) {
|
|
522
|
+
const modulePath = match[1];
|
|
523
|
+
const segments = modulePath.split('/');
|
|
524
|
+
const last = (segments[segments.length - 1] || '').replace(/\.(tsx?|jsx?)$/, '');
|
|
525
|
+
if (/^[A-Z]/.test(last)) {
|
|
526
|
+
componentUsageMap.set(last, (componentUsageMap.get(last) || 0) + 1);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
// ignore unreadable files
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Scan Vue, Svelte, and Angular template blocks for component usage.
|
|
537
|
+
* Vue SFCs reference components in <template> blocks as <MyComponent />,
|
|
538
|
+
* Angular templates use <app-my-component> selectors,
|
|
539
|
+
* which aren't detected by JS import scanning.
|
|
540
|
+
*/
|
|
541
|
+
async scanTemplateComponentUsage(componentUsageMap, knownComponents) {
|
|
542
|
+
if (knownComponents.length === 0)
|
|
543
|
+
return;
|
|
544
|
+
const cwd = process.cwd();
|
|
545
|
+
const templateFiles = await glob('**/*.{vue,svelte,html}', {
|
|
546
|
+
cwd,
|
|
547
|
+
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**'],
|
|
548
|
+
nodir: true,
|
|
549
|
+
maxDepth: 8,
|
|
550
|
+
});
|
|
551
|
+
const knownSet = new Set(knownComponents);
|
|
552
|
+
// Build a kebab-case lookup for Vue/Angular (MyComponent -> my-component)
|
|
553
|
+
const kebabMap = new Map();
|
|
554
|
+
for (const name of knownComponents) {
|
|
555
|
+
const kebab = name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
556
|
+
kebabMap.set(kebab, name);
|
|
557
|
+
// Angular selectors often use app- prefix: ButtonComponent -> app-button
|
|
558
|
+
const angularSelector = `app-${kebab.replace(/-component$/, '')}`;
|
|
559
|
+
kebabMap.set(angularSelector, name);
|
|
560
|
+
}
|
|
561
|
+
for (const file of templateFiles.slice(0, 300)) {
|
|
562
|
+
try {
|
|
563
|
+
const content = await readFile(resolve(cwd, file), 'utf-8');
|
|
564
|
+
// Match PascalCase component tags: <MyComponent or <MyComponent>
|
|
565
|
+
const pascalPattern = /<([A-Z][a-zA-Z0-9]*)\s*/g;
|
|
566
|
+
let match;
|
|
567
|
+
while ((match = pascalPattern.exec(content)) !== null) {
|
|
568
|
+
const name = match[1];
|
|
569
|
+
if (knownSet.has(name)) {
|
|
570
|
+
componentUsageMap.set(name, (componentUsageMap.get(name) || 0) + 1);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// Match kebab-case component tags in Vue: <my-component or <my-component>
|
|
574
|
+
const kebabPattern = /<([a-z][a-z0-9]*(?:-[a-z0-9]+)+)[\s>/]/g;
|
|
575
|
+
while ((match = kebabPattern.exec(content)) !== null) {
|
|
576
|
+
const kebab = match[1];
|
|
577
|
+
const pascal = kebabMap.get(kebab);
|
|
578
|
+
if (pascal) {
|
|
579
|
+
componentUsageMap.set(pascal, (componentUsageMap.get(pascal) || 0) + 1);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
// ignore unreadable files
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Scan for Vue auto-registration patterns.
|
|
590
|
+
* Detects app.component('Name', ...) and Vue.component('Name', ...) calls.
|
|
591
|
+
*/
|
|
592
|
+
async scanAutoRegistration(componentUsageMap) {
|
|
593
|
+
const cwd = process.cwd();
|
|
594
|
+
const sourceFiles = await glob('**/*.{ts,js,tsx,jsx}', {
|
|
595
|
+
cwd,
|
|
596
|
+
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**'],
|
|
597
|
+
nodir: true,
|
|
598
|
+
maxDepth: 4,
|
|
599
|
+
});
|
|
600
|
+
for (const file of sourceFiles.slice(0, 100)) {
|
|
601
|
+
try {
|
|
602
|
+
const content = await readFile(resolve(cwd, file), 'utf-8');
|
|
603
|
+
if (!content.includes('.component('))
|
|
604
|
+
continue;
|
|
605
|
+
// Match: app.component('Name', ...) or Vue.component('Name', ...)
|
|
606
|
+
const pattern = /\.component\(\s*['"]([A-Z][a-zA-Z0-9]*)['"]/g;
|
|
607
|
+
let match;
|
|
608
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
609
|
+
const name = match[1];
|
|
610
|
+
componentUsageMap.set(name, (componentUsageMap.get(name) || 0) + 1);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
// ignore unreadable files
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Scan Angular NgModule files for declared components.
|
|
620
|
+
* Components in declarations: [...] are registered and should count as used.
|
|
621
|
+
*/
|
|
622
|
+
async scanNgModuleDeclarations(componentUsageMap) {
|
|
623
|
+
const cwd = process.cwd();
|
|
624
|
+
const moduleFiles = await glob('**/*.module.ts', {
|
|
625
|
+
cwd,
|
|
626
|
+
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**'],
|
|
627
|
+
nodir: true,
|
|
628
|
+
maxDepth: 8,
|
|
629
|
+
});
|
|
630
|
+
for (const file of moduleFiles.slice(0, 50)) {
|
|
631
|
+
try {
|
|
632
|
+
const content = await readFile(resolve(cwd, file), 'utf-8');
|
|
633
|
+
if (!content.includes('declarations'))
|
|
634
|
+
continue;
|
|
635
|
+
// Match declarations: [Component1, Component2, ...]
|
|
636
|
+
const declMatch = content.match(/declarations\s*:\s*\[([\s\S]*?)\]/);
|
|
637
|
+
if (declMatch) {
|
|
638
|
+
const names = declMatch[1].match(/\b([A-Z][a-zA-Z]+(?:Component|Directive|Pipe))\b/g);
|
|
639
|
+
if (names) {
|
|
640
|
+
for (const name of names) {
|
|
641
|
+
componentUsageMap.set(name, (componentUsageMap.get(name) || 0) + 1);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
// Also match imports: [...] and exports: [...] arrays
|
|
646
|
+
for (const key of ['imports', 'exports']) {
|
|
647
|
+
const match = content.match(new RegExp(`${key}\\s*:\\s*\\[([\\s\\S]*?)\\]`));
|
|
648
|
+
if (match) {
|
|
649
|
+
const names = match[1].match(/\b([A-Z][a-zA-Z]+(?:Component|Module))\b/g);
|
|
650
|
+
if (names) {
|
|
651
|
+
for (const name of names) {
|
|
652
|
+
componentUsageMap.set(name, (componentUsageMap.get(name) || 0) + 1);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
// ignore unreadable files
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Scan Storybook story files for component imports.
|
|
665
|
+
* A component referenced in a .stories file is documented/tested and should count as used.
|
|
666
|
+
*/
|
|
667
|
+
async scanStoryFileUsages(componentUsageMap) {
|
|
668
|
+
const cwd = process.cwd();
|
|
669
|
+
const storyFiles = await glob('**/*.stories.{ts,tsx,js,jsx}', {
|
|
670
|
+
cwd,
|
|
671
|
+
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**'],
|
|
672
|
+
nodir: true,
|
|
673
|
+
maxDepth: 8,
|
|
674
|
+
});
|
|
675
|
+
for (const file of storyFiles.slice(0, 200)) {
|
|
676
|
+
try {
|
|
677
|
+
const content = await readFile(resolve(cwd, file), 'utf-8');
|
|
678
|
+
// Match named imports: import { Button, Card } from '...'
|
|
679
|
+
const importPattern = /import\s*\{\s*([^}]+)\s*\}\s*from/g;
|
|
680
|
+
let match;
|
|
681
|
+
while ((match = importPattern.exec(content)) !== null) {
|
|
682
|
+
const names = match[1].split(',').map(n => {
|
|
683
|
+
const parts = n.trim().split(/\s+as\s+/);
|
|
684
|
+
return (parts[0] || '').trim();
|
|
685
|
+
}).filter(n => n && /^[A-Z]/.test(n));
|
|
686
|
+
for (const name of names) {
|
|
687
|
+
componentUsageMap.set(name, (componentUsageMap.get(name) || 0) + 1);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
// Match default imports: import Button from '...'
|
|
691
|
+
const defaultImportPattern = /import\s+([A-Z][a-zA-Z0-9]*)\s+from/g;
|
|
692
|
+
while ((match = defaultImportPattern.exec(content)) !== null) {
|
|
693
|
+
const name = match[1];
|
|
694
|
+
componentUsageMap.set(name, (componentUsageMap.get(name) || 0) + 1);
|
|
695
|
+
}
|
|
696
|
+
// Match CSF3 component meta: component: Button or component: () => Button
|
|
697
|
+
const metaPattern = /component\s*:\s*([A-Z][a-zA-Z0-9]*)/g;
|
|
698
|
+
while ((match = metaPattern.exec(content)) !== null) {
|
|
699
|
+
const name = match[1];
|
|
700
|
+
componentUsageMap.set(name, (componentUsageMap.get(name) || 0) + 1);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
// ignore unreadable files
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Scan for Lit and Web Component registration patterns.
|
|
710
|
+
* customElements.define('my-button', MyButton) and @customElement('my-button')
|
|
711
|
+
* mean the component is registered and used by the browser.
|
|
712
|
+
*/
|
|
713
|
+
async scanWebComponentRegistrations(componentUsageMap) {
|
|
714
|
+
const cwd = process.cwd();
|
|
715
|
+
const files = await glob('**/*.{ts,js}', {
|
|
716
|
+
cwd,
|
|
717
|
+
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/*.d.ts', '**/*.spec.*', '**/*.test.*'],
|
|
718
|
+
nodir: true,
|
|
719
|
+
maxDepth: 8,
|
|
720
|
+
});
|
|
721
|
+
for (const file of files.slice(0, 500)) {
|
|
722
|
+
try {
|
|
723
|
+
const content = await readFile(resolve(cwd, file), 'utf-8');
|
|
724
|
+
// Match customElements.define('tag-name', ClassName)
|
|
725
|
+
const definePattern = /customElements\.define\s*\(\s*['"][^'"]+['"]\s*,\s*([A-Z][a-zA-Z0-9]*)/g;
|
|
726
|
+
let match;
|
|
727
|
+
while ((match = definePattern.exec(content)) !== null) {
|
|
728
|
+
componentUsageMap.set(match[1], (componentUsageMap.get(match[1]) || 0) + 1);
|
|
729
|
+
}
|
|
730
|
+
// Match @customElement('tag-name') decorator
|
|
731
|
+
const decoratorPattern = /@customElement\s*\(\s*['"][^'"]+['"]\s*\)/g;
|
|
732
|
+
if (decoratorPattern.test(content)) {
|
|
733
|
+
// The class following this decorator is registered
|
|
734
|
+
const classPattern = /class\s+([A-Z][a-zA-Z0-9]*)\s+extends/g;
|
|
735
|
+
while ((match = classPattern.exec(content)) !== null) {
|
|
736
|
+
componentUsageMap.set(match[1], (componentUsageMap.get(match[1]) || 0) + 1);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
// ignore unreadable files
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* In Nuxt 3, components in the components/ directory are auto-imported globally.
|
|
747
|
+
* If we detect Nuxt (nuxt.config.ts exists), mark all components in components/ as used.
|
|
748
|
+
*/
|
|
749
|
+
async scanNuxtAutoImports(componentUsageMap, componentNames) {
|
|
750
|
+
const cwd = process.cwd();
|
|
751
|
+
// Check for Nuxt config
|
|
752
|
+
const nuxtConfigs = await glob('nuxt.config.{ts,js,mjs}', { cwd, nodir: true });
|
|
753
|
+
if (nuxtConfigs.length === 0)
|
|
754
|
+
return;
|
|
755
|
+
// In Nuxt, all components in components/ are auto-imported
|
|
756
|
+
for (const name of componentNames) {
|
|
757
|
+
// Mark as used (they're available globally via auto-import)
|
|
758
|
+
componentUsageMap.set(name, (componentUsageMap.get(name) || 0) + 1);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Scan test files for component imports.
|
|
763
|
+
* Components imported in .test.tsx/.spec.tsx are actively maintained/tested.
|
|
764
|
+
*/
|
|
765
|
+
async scanTestFileUsages(componentUsageMap) {
|
|
766
|
+
const cwd = process.cwd();
|
|
767
|
+
const testFiles = await glob('**/*.{test,spec}.{ts,tsx,js,jsx}', {
|
|
768
|
+
cwd,
|
|
769
|
+
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**'],
|
|
770
|
+
nodir: true,
|
|
771
|
+
maxDepth: 8,
|
|
772
|
+
});
|
|
773
|
+
for (const file of testFiles.slice(0, 300)) {
|
|
774
|
+
try {
|
|
775
|
+
const content = await readFile(resolve(cwd, file), 'utf-8');
|
|
776
|
+
// Match named imports: import { Button, Card } from '...'
|
|
777
|
+
const importPattern = /import\s*\{\s*([^}]+)\s*\}\s*from/g;
|
|
778
|
+
let match;
|
|
779
|
+
while ((match = importPattern.exec(content)) !== null) {
|
|
780
|
+
const names = match[1].split(',').map(n => {
|
|
781
|
+
const parts = n.trim().split(/\s+as\s+/);
|
|
782
|
+
return (parts[0] || '').trim();
|
|
783
|
+
}).filter(n => n && /^[A-Z]/.test(n));
|
|
784
|
+
for (const name of names) {
|
|
785
|
+
componentUsageMap.set(name, (componentUsageMap.get(name) || 0) + 1);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
// Match default imports: import Button from '...'
|
|
789
|
+
const defaultImportPattern = /import\s+([A-Z][a-zA-Z0-9]*)\s+from/g;
|
|
790
|
+
while ((match = defaultImportPattern.exec(content)) !== null) {
|
|
791
|
+
const name = match[1];
|
|
792
|
+
componentUsageMap.set(name, (componentUsageMap.get(name) || 0) + 1);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
catch {
|
|
796
|
+
// ignore unreadable files
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Scan for HOC and wrapper patterns that count as component usage.
|
|
802
|
+
* Detects forwardRef(Component), memo(Component), styled(Component),
|
|
803
|
+
* withXxx(Component), Object.assign(Component, { ... }), and
|
|
804
|
+
* compound component patterns like Component.Sub = SubComponent.
|
|
805
|
+
*/
|
|
806
|
+
async scanHOCWrapperUsages(componentUsageMap) {
|
|
807
|
+
const cwd = process.cwd();
|
|
808
|
+
const sourceFiles = await glob('**/*.{ts,tsx,js,jsx}', {
|
|
809
|
+
cwd,
|
|
810
|
+
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/*.d.ts'],
|
|
811
|
+
nodir: true,
|
|
812
|
+
maxDepth: 8,
|
|
813
|
+
});
|
|
814
|
+
for (const file of sourceFiles.slice(0, 500)) {
|
|
815
|
+
try {
|
|
816
|
+
const content = await readFile(resolve(cwd, file), 'utf-8');
|
|
817
|
+
// forwardRef(Component) / React.forwardRef(Component)
|
|
818
|
+
const forwardRefPattern = /(?:React\.)?forwardRef\s*[(<]\s*(?:function\s+)?([A-Z][a-zA-Z0-9]*)/g;
|
|
819
|
+
let match;
|
|
820
|
+
while ((match = forwardRefPattern.exec(content)) !== null) {
|
|
821
|
+
componentUsageMap.set(match[1], (componentUsageMap.get(match[1]) || 0) + 1);
|
|
822
|
+
}
|
|
823
|
+
// Also: const X = forwardRef(...) — the wrapped result is the component
|
|
824
|
+
const forwardRefAssignPattern = /const\s+([A-Z][a-zA-Z0-9]*)\s*=\s*(?:React\.)?forwardRef/g;
|
|
825
|
+
while ((match = forwardRefAssignPattern.exec(content)) !== null) {
|
|
826
|
+
componentUsageMap.set(match[1], (componentUsageMap.get(match[1]) || 0) + 1);
|
|
827
|
+
}
|
|
828
|
+
// memo(Component) / React.memo(Component)
|
|
829
|
+
const memoPattern = /(?:React\.)?memo\(\s*([A-Z][a-zA-Z0-9]*)\s*[,)]/g;
|
|
830
|
+
while ((match = memoPattern.exec(content)) !== null) {
|
|
831
|
+
componentUsageMap.set(match[1], (componentUsageMap.get(match[1]) || 0) + 1);
|
|
832
|
+
}
|
|
833
|
+
// styled(Component) / styled.div / emotion patterns
|
|
834
|
+
const styledPattern = /styled\(\s*([A-Z][a-zA-Z0-9]*)\s*\)/g;
|
|
835
|
+
while ((match = styledPattern.exec(content)) !== null) {
|
|
836
|
+
componentUsageMap.set(match[1], (componentUsageMap.get(match[1]) || 0) + 1);
|
|
837
|
+
}
|
|
838
|
+
// withXxx(Component) — HOC patterns
|
|
839
|
+
const hocPattern = /with[A-Z][a-zA-Z]*\(\s*([A-Z][a-zA-Z0-9]*)\s*[,)]/g;
|
|
840
|
+
while ((match = hocPattern.exec(content)) !== null) {
|
|
841
|
+
componentUsageMap.set(match[1], (componentUsageMap.get(match[1]) || 0) + 1);
|
|
842
|
+
}
|
|
843
|
+
// Object.assign(Component, { Sub1, Sub2 }) — compound component pattern
|
|
844
|
+
const assignPattern = /Object\.assign\(\s*([A-Z][a-zA-Z0-9]*)\s*,\s*\{([^}]+)\}/g;
|
|
845
|
+
while ((match = assignPattern.exec(content)) !== null) {
|
|
846
|
+
// The base component is used
|
|
847
|
+
componentUsageMap.set(match[1], (componentUsageMap.get(match[1]) || 0) + 1);
|
|
848
|
+
// Each assigned sub-component is used
|
|
849
|
+
const subs = match[2].match(/\b([A-Z][a-zA-Z0-9]*)\b/g);
|
|
850
|
+
if (subs) {
|
|
851
|
+
for (const sub of subs) {
|
|
852
|
+
componentUsageMap.set(sub, (componentUsageMap.get(sub) || 0) + 1);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
// Component.Sub = SubComponent — compound component property assignment
|
|
857
|
+
const compoundPattern = /([A-Z][a-zA-Z0-9]*)\.([A-Z][a-zA-Z0-9]*)\s*=\s*([A-Z][a-zA-Z0-9]*)/g;
|
|
858
|
+
while ((match = compoundPattern.exec(content)) !== null) {
|
|
859
|
+
// Both the parent and the assigned component are used
|
|
860
|
+
componentUsageMap.set(match[1], (componentUsageMap.get(match[1]) || 0) + 1);
|
|
861
|
+
componentUsageMap.set(match[3], (componentUsageMap.get(match[3]) || 0) + 1);
|
|
862
|
+
}
|
|
863
|
+
// React.createElement(Component, ...) — non-JSX rendering
|
|
864
|
+
const createElementPattern = /React\.createElement\(\s*([A-Z][a-zA-Z0-9]*)/g;
|
|
865
|
+
while ((match = createElementPattern.exec(content)) !== null) {
|
|
866
|
+
componentUsageMap.set(match[1], (componentUsageMap.get(match[1]) || 0) + 1);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
catch {
|
|
870
|
+
// ignore unreadable files
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Detect component libraries and mark all exported components as used.
|
|
876
|
+
* If package.json has a "main" or "exports" field pointing to a barrel file,
|
|
877
|
+
* all components re-exported from that barrel are the product's public API.
|
|
878
|
+
*/
|
|
879
|
+
async scanPackageExports(componentUsageMap) {
|
|
880
|
+
const cwd = process.cwd();
|
|
881
|
+
try {
|
|
882
|
+
const pkgContent = await readFile(resolve(cwd, 'package.json'), 'utf-8');
|
|
883
|
+
const pkg = JSON.parse(pkgContent);
|
|
884
|
+
// Detect if this is a component library by checking for:
|
|
885
|
+
// - exports field with ./ entries
|
|
886
|
+
// - main/module pointing to index file
|
|
887
|
+
// - "react" or "vue" in peerDependencies (library pattern)
|
|
888
|
+
const hasPeerReact = pkg.peerDependencies?.react || pkg.peerDependencies?.vue;
|
|
889
|
+
const hasExports = pkg.exports && typeof pkg.exports === 'object';
|
|
890
|
+
const mainEntry = pkg.main || pkg.module || '';
|
|
891
|
+
const isLibraryPattern = hasPeerReact && (hasExports || mainEntry);
|
|
892
|
+
if (!isLibraryPattern)
|
|
893
|
+
return;
|
|
894
|
+
// Scan the root barrel file(s) for exports — these are the public API
|
|
895
|
+
const rootBarrels = await glob('src/index.{ts,tsx,js,jsx}', { cwd, nodir: true });
|
|
896
|
+
for (const barrel of rootBarrels) {
|
|
897
|
+
try {
|
|
898
|
+
const content = await readFile(resolve(cwd, barrel), 'utf-8');
|
|
899
|
+
// Named exports: export { Button, Card } from '...'
|
|
900
|
+
const namedPattern = /export\s*\{\s*([^}]+)\s*\}\s*from/g;
|
|
901
|
+
let match;
|
|
902
|
+
while ((match = namedPattern.exec(content)) !== null) {
|
|
903
|
+
const names = match[1].split(',').map(n => {
|
|
904
|
+
const parts = n.trim().split(/\s+as\s+/);
|
|
905
|
+
return (parts[1] || parts[0] || '').trim();
|
|
906
|
+
}).filter(n => n && /^[A-Z]/.test(n));
|
|
907
|
+
for (const name of names) {
|
|
908
|
+
componentUsageMap.set(name, (componentUsageMap.get(name) || 0) + 1);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
// Default re-exports: export { default as Button } from '...'
|
|
912
|
+
const defaultPattern = /export\s*\{\s*default\s+as\s+([A-Z][a-zA-Z0-9]*)\s*\}\s*from/g;
|
|
913
|
+
while ((match = defaultPattern.exec(content)) !== null) {
|
|
914
|
+
componentUsageMap.set(match[1], (componentUsageMap.get(match[1]) || 0) + 1);
|
|
915
|
+
}
|
|
916
|
+
// Wildcard re-exports: export * from './Button'
|
|
917
|
+
const wildcardPattern = /export\s*\*\s*from\s*['"]\.\/([^'"]+)['"]/g;
|
|
918
|
+
while ((match = wildcardPattern.exec(content)) !== null) {
|
|
919
|
+
const moduleName = match[1];
|
|
920
|
+
const segments = moduleName.split('/');
|
|
921
|
+
const last = segments[segments.length - 1] || '';
|
|
922
|
+
if (/^[A-Z]/.test(last)) {
|
|
923
|
+
componentUsageMap.set(last, (componentUsageMap.get(last) || 0) + 1);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
// Direct exports: export const Button = ... or export function Button
|
|
927
|
+
const directExportPattern = /export\s+(?:const|function|class)\s+([A-Z][a-zA-Z0-9]*)/g;
|
|
928
|
+
while ((match = directExportPattern.exec(content)) !== null) {
|
|
929
|
+
componentUsageMap.set(match[1], (componentUsageMap.get(match[1]) || 0) + 1);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
catch {
|
|
933
|
+
// ignore
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
catch {
|
|
938
|
+
// No package.json or not parseable — not a library
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Scan for components passed as values — JSX prop values, object property values,
|
|
943
|
+
* and array elements. Catches patterns like:
|
|
944
|
+
* transition={DialogTransition}
|
|
945
|
+
* { toolbarAccount: AccountPopover }
|
|
946
|
+
* [DialogTransition, BackdropTransition]
|
|
947
|
+
*/
|
|
948
|
+
async scanComponentAsValueUsages(componentUsageMap, knownComponents) {
|
|
949
|
+
if (knownComponents.length === 0)
|
|
950
|
+
return;
|
|
951
|
+
const cwd = process.cwd();
|
|
952
|
+
const sourceFiles = await glob('**/*.{tsx,jsx,ts,js}', {
|
|
953
|
+
cwd,
|
|
954
|
+
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/*.d.ts'],
|
|
955
|
+
nodir: true,
|
|
956
|
+
maxDepth: 8,
|
|
957
|
+
});
|
|
958
|
+
// Build a Set for O(1) lookup
|
|
959
|
+
const knownSet = new Set(knownComponents);
|
|
960
|
+
for (const file of sourceFiles.slice(0, 500)) {
|
|
961
|
+
try {
|
|
962
|
+
const content = await readFile(resolve(cwd, file), 'utf-8');
|
|
963
|
+
// Match component names used as values in these contexts:
|
|
964
|
+
// 1. JSX prop value: ={ComponentName} or ={ComponentName}
|
|
965
|
+
// 2. Object property: : ComponentName, or : ComponentName}
|
|
966
|
+
// 3. Ternary with component: ? ComponentName : or : ComponentName}
|
|
967
|
+
// 4. Array element: [ComponentName, or , ComponentName]
|
|
968
|
+
//
|
|
969
|
+
// We look for PascalCase identifiers preceded by value-assignment contexts
|
|
970
|
+
const valuePattern = /(?:[=:?]\s*|,\s*|\[\s*)([A-Z][a-zA-Z0-9]*)\b/g;
|
|
971
|
+
let match;
|
|
972
|
+
while ((match = valuePattern.exec(content)) !== null) {
|
|
973
|
+
const name = match[1];
|
|
974
|
+
if (knownSet.has(name)) {
|
|
975
|
+
componentUsageMap.set(name, (componentUsageMap.get(name) || 0) + 1);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
catch {
|
|
980
|
+
// ignore unreadable files
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Detect repeated class patterns across source files
|
|
986
|
+
*/
|
|
987
|
+
async detectRepeatedPatterns(config) {
|
|
988
|
+
const occurrences = [];
|
|
989
|
+
const cwd = process.cwd();
|
|
990
|
+
// Find all source files
|
|
991
|
+
const patterns = ["**/*.tsx", "**/*.jsx", "**/*.vue", "**/*.svelte"];
|
|
992
|
+
const ignore = ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/build/**"];
|
|
993
|
+
const files = await glob(patterns, { cwd, ignore, absolute: true });
|
|
994
|
+
for (const file of files) {
|
|
995
|
+
try {
|
|
996
|
+
const content = await readFile(file, "utf-8");
|
|
997
|
+
const relativePath = file.replace(cwd + "/", "");
|
|
998
|
+
// Extract static class strings using existing extractor
|
|
999
|
+
const classStrings = extractStaticClassStrings(content);
|
|
1000
|
+
for (const cs of classStrings) {
|
|
1001
|
+
// Combine all classes into a single string
|
|
1002
|
+
const allClasses = cs.classes.join(" ");
|
|
1003
|
+
if (allClasses.trim()) {
|
|
1004
|
+
occurrences.push({
|
|
1005
|
+
classes: allClasses,
|
|
1006
|
+
file: relativePath,
|
|
1007
|
+
line: cs.line,
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
catch {
|
|
1013
|
+
// Skip files that can't be read
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return detectRepeatedPatterns(occurrences, {
|
|
1017
|
+
minOccurrences: config.minOccurrences ?? 2,
|
|
1018
|
+
matching: config.matching ?? "exact",
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
//# sourceMappingURL=drift-analysis.sync-conflict-20260309-133811-6PCZ3ZU.js.map
|