@buoy-design/core 0.1.0 → 0.1.1
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/LICENSE +21 -0
- package/dist/analysis/config.d.ts +66 -0
- package/dist/analysis/config.d.ts.map +1 -0
- package/dist/analysis/config.js +68 -0
- package/dist/analysis/config.js.map +1 -0
- package/dist/analysis/index.d.ts +4 -1
- package/dist/analysis/index.d.ts.map +1 -1
- package/dist/analysis/index.js +7 -1
- package/dist/analysis/index.js.map +1 -1
- package/dist/analysis/semantic-diff.d.ts +50 -4
- package/dist/analysis/semantic-diff.d.ts.map +1 -1
- package/dist/analysis/semantic-diff.js +453 -212
- package/dist/analysis/semantic-diff.js.map +1 -1
- package/dist/analysis/string-utils.d.ts +18 -0
- package/dist/analysis/string-utils.d.ts.map +1 -0
- package/dist/analysis/string-utils.js +47 -0
- package/dist/analysis/string-utils.js.map +1 -0
- package/dist/analysis/token-suggestions.d.ts +59 -0
- package/dist/analysis/token-suggestions.d.ts.map +1 -0
- package/dist/analysis/token-suggestions.js +164 -0
- package/dist/analysis/token-suggestions.js.map +1 -0
- package/dist/models/drift.d.ts +9 -18
- package/dist/models/drift.d.ts.map +1 -1
- package/dist/models/drift.js +66 -58
- package/dist/models/drift.js.map +1 -1
- package/dist/models/index.d.ts +2 -2
- package/dist/models/index.d.ts.map +1 -1
- package/dist/models/index.js +1 -1
- package/dist/models/index.js.map +1 -1
- package/package.json +18 -10
|
@@ -1,39 +1,173 @@
|
|
|
1
|
-
import { createDriftId, normalizeComponentName, normalizeTokenName, tokensMatch, } from
|
|
1
|
+
import { createDriftId, normalizeComponentName, normalizeTokenName, tokensMatch, } from "../models/index.js";
|
|
2
|
+
import { TokenSuggestionService, } from "./token-suggestions.js";
|
|
3
|
+
import { stringSimilarity as calcStringSimilarity } from "./string-utils.js";
|
|
4
|
+
import { MATCHING_CONFIG, NAMING_CONFIG, getOutlierThreshold, } from "./config.js";
|
|
5
|
+
/**
|
|
6
|
+
* Semantic suffixes that represent legitimate separate components.
|
|
7
|
+
* Components with these suffixes are NOT duplicates of their base component.
|
|
8
|
+
* e.g., Button vs ButtonGroup are distinct components, not duplicates.
|
|
9
|
+
*/
|
|
10
|
+
const SEMANTIC_COMPONENT_SUFFIXES = [
|
|
11
|
+
// Compound component patterns
|
|
12
|
+
"group",
|
|
13
|
+
"list",
|
|
14
|
+
"item",
|
|
15
|
+
"items",
|
|
16
|
+
"container",
|
|
17
|
+
"wrapper",
|
|
18
|
+
"provider",
|
|
19
|
+
"context",
|
|
20
|
+
// Layout parts
|
|
21
|
+
"header",
|
|
22
|
+
"footer",
|
|
23
|
+
"body",
|
|
24
|
+
"content",
|
|
25
|
+
"section",
|
|
26
|
+
"sidebar",
|
|
27
|
+
"panel",
|
|
28
|
+
// Specific UI patterns
|
|
29
|
+
"trigger",
|
|
30
|
+
"target",
|
|
31
|
+
"overlay",
|
|
32
|
+
"portal",
|
|
33
|
+
"root",
|
|
34
|
+
"slot",
|
|
35
|
+
"action",
|
|
36
|
+
"actions",
|
|
37
|
+
"icon",
|
|
38
|
+
"label",
|
|
39
|
+
"text",
|
|
40
|
+
"title",
|
|
41
|
+
"description",
|
|
42
|
+
"separator",
|
|
43
|
+
"divider",
|
|
44
|
+
// Size/state variants that are distinct components
|
|
45
|
+
"small",
|
|
46
|
+
"large",
|
|
47
|
+
"mini",
|
|
48
|
+
"skeleton",
|
|
49
|
+
"placeholder",
|
|
50
|
+
"loading",
|
|
51
|
+
"error",
|
|
52
|
+
"empty",
|
|
53
|
+
// Form-related
|
|
54
|
+
"input",
|
|
55
|
+
"field",
|
|
56
|
+
"control",
|
|
57
|
+
"message",
|
|
58
|
+
"helper",
|
|
59
|
+
"hint",
|
|
60
|
+
// Navigation
|
|
61
|
+
"link",
|
|
62
|
+
"menu",
|
|
63
|
+
"submenu",
|
|
64
|
+
"tab",
|
|
65
|
+
"tabs",
|
|
66
|
+
// Data display
|
|
67
|
+
"cell",
|
|
68
|
+
"row",
|
|
69
|
+
"column",
|
|
70
|
+
"columns",
|
|
71
|
+
"head",
|
|
72
|
+
"view",
|
|
73
|
+
];
|
|
74
|
+
/**
|
|
75
|
+
* Version/status suffixes that indicate potential duplicates.
|
|
76
|
+
* Components with ONLY these suffixes are likely duplicates.
|
|
77
|
+
* e.g., Button vs ButtonNew, Card vs CardLegacy
|
|
78
|
+
*/
|
|
79
|
+
const VERSION_SUFFIXES_PATTERN = /(New|Old|V\d+|Legacy|Updated|Deprecated|Beta|Alpha|Experimental|Next|Previous|Original|Backup|Copy|Clone|Alt|Alternative|Temp|Temporary|WIP|Draft)$/i;
|
|
2
80
|
export class SemanticDiffEngine {
|
|
81
|
+
// Caches for O(1) lookups instead of repeated computations
|
|
82
|
+
nameCache = new Map();
|
|
83
|
+
componentMetadataCache = new Map();
|
|
84
|
+
// Delegated services
|
|
85
|
+
tokenSuggestionService = new TokenSuggestionService();
|
|
86
|
+
/**
|
|
87
|
+
* Cached version of normalizeComponentName to avoid repeated string operations
|
|
88
|
+
*/
|
|
89
|
+
cachedNormalizeName(name) {
|
|
90
|
+
let normalized = this.nameCache.get(name);
|
|
91
|
+
if (normalized === undefined) {
|
|
92
|
+
normalized = normalizeComponentName(name);
|
|
93
|
+
this.nameCache.set(name, normalized);
|
|
94
|
+
}
|
|
95
|
+
return normalized;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Get pre-computed component metadata for faster similarity calculations
|
|
99
|
+
*/
|
|
100
|
+
getComponentMetadata(component) {
|
|
101
|
+
let metadata = this.componentMetadataCache.get(component.id);
|
|
102
|
+
if (!metadata) {
|
|
103
|
+
metadata = {
|
|
104
|
+
props: new Set(component.props.map((p) => p.name.toLowerCase())),
|
|
105
|
+
variants: new Set(component.variants.map((v) => v.name.toLowerCase())),
|
|
106
|
+
dependencies: new Set(component.dependencies.map((d) => d.toLowerCase())),
|
|
107
|
+
};
|
|
108
|
+
this.componentMetadataCache.set(component.id, metadata);
|
|
109
|
+
}
|
|
110
|
+
return metadata;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Clear caches to prevent memory leaks between operations
|
|
114
|
+
*/
|
|
115
|
+
clearCaches() {
|
|
116
|
+
this.nameCache.clear();
|
|
117
|
+
this.componentMetadataCache.clear();
|
|
118
|
+
}
|
|
3
119
|
/**
|
|
4
120
|
* Check for framework sprawl - multiple UI frameworks in one codebase
|
|
5
121
|
*/
|
|
6
122
|
checkFrameworkSprawl(frameworks) {
|
|
7
123
|
// Only count UI/component frameworks, not backend frameworks
|
|
8
124
|
const uiFrameworkNames = [
|
|
9
|
-
|
|
10
|
-
|
|
125
|
+
"react",
|
|
126
|
+
"vue",
|
|
127
|
+
"svelte",
|
|
128
|
+
"angular",
|
|
129
|
+
"solid",
|
|
130
|
+
"preact",
|
|
131
|
+
"lit",
|
|
132
|
+
"stencil",
|
|
133
|
+
"nextjs",
|
|
134
|
+
"nuxt",
|
|
135
|
+
"astro",
|
|
136
|
+
"remix",
|
|
137
|
+
"sveltekit",
|
|
138
|
+
"gatsby",
|
|
139
|
+
"react-native",
|
|
140
|
+
"expo",
|
|
141
|
+
"flutter",
|
|
11
142
|
];
|
|
12
|
-
const uiFrameworks = frameworks.filter(f => uiFrameworkNames.includes(f.name));
|
|
143
|
+
const uiFrameworks = frameworks.filter((f) => uiFrameworkNames.includes(f.name));
|
|
13
144
|
if (uiFrameworks.length <= 1) {
|
|
14
145
|
return null; // No sprawl
|
|
15
146
|
}
|
|
16
|
-
const frameworkNames = uiFrameworks.map(f => f.name);
|
|
147
|
+
const frameworkNames = uiFrameworks.map((f) => f.name);
|
|
17
148
|
const primaryFramework = uiFrameworks[0];
|
|
18
149
|
return {
|
|
19
|
-
id: createDriftId(
|
|
20
|
-
type:
|
|
21
|
-
severity:
|
|
150
|
+
id: createDriftId("framework-sprawl", "project", frameworkNames.join("-")),
|
|
151
|
+
type: "framework-sprawl",
|
|
152
|
+
severity: "warning",
|
|
22
153
|
source: {
|
|
23
|
-
entityType:
|
|
24
|
-
entityId:
|
|
25
|
-
entityName:
|
|
26
|
-
location:
|
|
154
|
+
entityType: "component",
|
|
155
|
+
entityId: "project",
|
|
156
|
+
entityName: "Project Architecture",
|
|
157
|
+
location: "package.json",
|
|
27
158
|
},
|
|
28
|
-
message: `Framework sprawl detected: ${uiFrameworks.length} UI frameworks in use (${frameworkNames.join(
|
|
159
|
+
message: `Framework sprawl detected: ${uiFrameworks.length} UI frameworks in use (${frameworkNames.join(", ")})`,
|
|
29
160
|
details: {
|
|
30
161
|
expected: `Single framework (${primaryFramework.name})`,
|
|
31
162
|
actual: `${uiFrameworks.length} frameworks`,
|
|
32
|
-
frameworks: uiFrameworks.map(f => ({
|
|
163
|
+
frameworks: uiFrameworks.map((f) => ({
|
|
164
|
+
name: f.name,
|
|
165
|
+
version: f.version,
|
|
166
|
+
})),
|
|
33
167
|
suggestions: [
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
168
|
+
"Consider consolidating to a single UI framework",
|
|
169
|
+
"Document intentional multi-framework usage if required",
|
|
170
|
+
"Create migration plan if frameworks are being deprecated",
|
|
37
171
|
],
|
|
38
172
|
},
|
|
39
173
|
detectedAt: new Date(),
|
|
@@ -41,39 +175,60 @@ export class SemanticDiffEngine {
|
|
|
41
175
|
}
|
|
42
176
|
/**
|
|
43
177
|
* Compare components from different sources (e.g., React vs Figma)
|
|
178
|
+
* Optimized with Map-based indexing for O(n+m) instead of O(n×m)
|
|
44
179
|
*/
|
|
45
180
|
compareComponents(sourceComponents, targetComponents, options = {}) {
|
|
46
181
|
const matches = [];
|
|
47
182
|
const matchedSourceIds = new Set();
|
|
48
183
|
const matchedTargetIds = new Set();
|
|
49
|
-
//
|
|
184
|
+
// Build target component lookup map for O(1) exact matching - O(m) one-time cost
|
|
185
|
+
const targetNameMap = new Map();
|
|
186
|
+
for (const target of targetComponents) {
|
|
187
|
+
const normalizedName = this.cachedNormalizeName(target.name);
|
|
188
|
+
targetNameMap.set(normalizedName, target);
|
|
189
|
+
}
|
|
190
|
+
// Phase 1: Exact name matches - O(n) instead of O(n × m)
|
|
50
191
|
for (const source of sourceComponents) {
|
|
51
|
-
const
|
|
52
|
-
const exactMatch =
|
|
53
|
-
if (exactMatch) {
|
|
54
|
-
matches.push(this.createMatch(source, exactMatch,
|
|
192
|
+
const normalizedName = this.cachedNormalizeName(source.name);
|
|
193
|
+
const exactMatch = targetNameMap.get(normalizedName);
|
|
194
|
+
if (exactMatch && !matchedTargetIds.has(exactMatch.id)) {
|
|
195
|
+
matches.push(this.createMatch(source, exactMatch, "exact"));
|
|
55
196
|
matchedSourceIds.add(source.id);
|
|
56
197
|
matchedTargetIds.add(exactMatch.id);
|
|
57
198
|
}
|
|
58
199
|
}
|
|
59
|
-
// Phase 2: Fuzzy matching for remaining
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
200
|
+
// Phase 2: Fuzzy matching for remaining - optimized candidate tracking
|
|
201
|
+
const unmatchedTargetMap = new Map();
|
|
202
|
+
for (const target of targetComponents) {
|
|
203
|
+
if (!matchedTargetIds.has(target.id)) {
|
|
204
|
+
unmatchedTargetMap.set(target.id, target);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const minConfidence = options.minMatchConfidence || MATCHING_CONFIG.minMatchConfidence;
|
|
208
|
+
for (const source of sourceComponents) {
|
|
209
|
+
if (matchedSourceIds.has(source.id))
|
|
210
|
+
continue;
|
|
211
|
+
// Convert to array only for remaining unmatched targets
|
|
212
|
+
const candidates = Array.from(unmatchedTargetMap.values());
|
|
213
|
+
const bestMatch = this.findBestMatch(source, candidates);
|
|
65
214
|
if (bestMatch && bestMatch.confidence >= minConfidence) {
|
|
66
215
|
matches.push(bestMatch);
|
|
67
216
|
matchedSourceIds.add(source.id);
|
|
68
217
|
matchedTargetIds.add(bestMatch.target.id);
|
|
218
|
+
// Remove matched target to prevent re-matching and reduce candidates
|
|
219
|
+
unmatchedTargetMap.delete(bestMatch.target.id);
|
|
69
220
|
}
|
|
70
221
|
}
|
|
71
222
|
// Phase 3: Generate drift signals
|
|
72
|
-
const
|
|
223
|
+
const orphanedSource = sourceComponents.filter((c) => !matchedSourceIds.has(c.id));
|
|
224
|
+
const orphanedTarget = targetComponents.filter((c) => !matchedTargetIds.has(c.id));
|
|
225
|
+
const drifts = this.generateComponentDrifts(matches, orphanedSource, orphanedTarget);
|
|
226
|
+
// Clear caches to prevent memory leaks
|
|
227
|
+
this.clearCaches();
|
|
73
228
|
return {
|
|
74
229
|
matches,
|
|
75
|
-
orphanedSource
|
|
76
|
-
orphanedTarget
|
|
230
|
+
orphanedSource,
|
|
231
|
+
orphanedTarget,
|
|
77
232
|
drifts,
|
|
78
233
|
};
|
|
79
234
|
}
|
|
@@ -87,7 +242,7 @@ export class SemanticDiffEngine {
|
|
|
87
242
|
const matchedTargetIds = new Set();
|
|
88
243
|
for (const source of sourceTokens) {
|
|
89
244
|
const sourceName = normalizeTokenName(source.name);
|
|
90
|
-
const target = targetTokens.find(t => normalizeTokenName(t.name) === sourceName);
|
|
245
|
+
const target = targetTokens.find((t) => normalizeTokenName(t.name) === sourceName);
|
|
91
246
|
if (!target)
|
|
92
247
|
continue;
|
|
93
248
|
matchedSourceIds.add(source.id);
|
|
@@ -96,46 +251,46 @@ export class SemanticDiffEngine {
|
|
|
96
251
|
// Check for value divergence
|
|
97
252
|
if (!tokensMatch(source.value, target.value)) {
|
|
98
253
|
drifts.push({
|
|
99
|
-
id: createDriftId(
|
|
100
|
-
type:
|
|
101
|
-
severity:
|
|
254
|
+
id: createDriftId("value-divergence", source.id, target.id),
|
|
255
|
+
type: "value-divergence",
|
|
256
|
+
severity: "warning",
|
|
102
257
|
source: this.tokenToDriftSource(source),
|
|
103
258
|
target: this.tokenToDriftSource(target),
|
|
104
259
|
message: `Token "${source.name}" has different values between sources`,
|
|
105
260
|
details: {
|
|
106
261
|
expected: source.value,
|
|
107
262
|
actual: target.value,
|
|
108
|
-
suggestions: [
|
|
263
|
+
suggestions: ["Align token values between design and code"],
|
|
109
264
|
},
|
|
110
265
|
detectedAt: new Date(),
|
|
111
266
|
});
|
|
112
267
|
}
|
|
113
268
|
}
|
|
114
269
|
// Orphaned tokens
|
|
115
|
-
const orphanedSource = sourceTokens.filter(t => !matchedSourceIds.has(t.id));
|
|
116
|
-
const orphanedTarget = targetTokens.filter(t => !matchedTargetIds.has(t.id));
|
|
270
|
+
const orphanedSource = sourceTokens.filter((t) => !matchedSourceIds.has(t.id));
|
|
271
|
+
const orphanedTarget = targetTokens.filter((t) => !matchedTargetIds.has(t.id));
|
|
117
272
|
for (const token of orphanedSource) {
|
|
118
273
|
drifts.push({
|
|
119
|
-
id: createDriftId(
|
|
120
|
-
type:
|
|
121
|
-
severity:
|
|
274
|
+
id: createDriftId("orphaned-token", token.id),
|
|
275
|
+
type: "orphaned-token",
|
|
276
|
+
severity: "info",
|
|
122
277
|
source: this.tokenToDriftSource(token),
|
|
123
278
|
message: `Token "${token.name}" exists in ${token.source.type} but not in design`,
|
|
124
279
|
details: {
|
|
125
|
-
suggestions: [
|
|
280
|
+
suggestions: ["Add token to design system or remove if unused"],
|
|
126
281
|
},
|
|
127
282
|
detectedAt: new Date(),
|
|
128
283
|
});
|
|
129
284
|
}
|
|
130
285
|
for (const token of orphanedTarget) {
|
|
131
286
|
drifts.push({
|
|
132
|
-
id: createDriftId(
|
|
133
|
-
type:
|
|
134
|
-
severity:
|
|
287
|
+
id: createDriftId("orphaned-token", token.id),
|
|
288
|
+
type: "orphaned-token",
|
|
289
|
+
severity: "info",
|
|
135
290
|
source: this.tokenToDriftSource(token),
|
|
136
291
|
message: `Token "${token.name}" exists in design but not implemented`,
|
|
137
292
|
details: {
|
|
138
|
-
suggestions: [
|
|
293
|
+
suggestions: ["Implement token in code or mark as planned"],
|
|
139
294
|
},
|
|
140
295
|
detectedAt: new Date(),
|
|
141
296
|
});
|
|
@@ -155,14 +310,15 @@ export class SemanticDiffEngine {
|
|
|
155
310
|
// Check for deprecation
|
|
156
311
|
if (options.checkDeprecated && component.metadata.deprecated) {
|
|
157
312
|
drifts.push({
|
|
158
|
-
id: createDriftId(
|
|
159
|
-
type:
|
|
160
|
-
severity:
|
|
313
|
+
id: createDriftId("deprecated-pattern", component.id),
|
|
314
|
+
type: "deprecated-pattern",
|
|
315
|
+
severity: "warning",
|
|
161
316
|
source: this.componentToDriftSource(component),
|
|
162
317
|
message: `Component "${component.name}" is marked as deprecated`,
|
|
163
318
|
details: {
|
|
164
319
|
suggestions: [
|
|
165
|
-
component.metadata.deprecationReason ||
|
|
320
|
+
component.metadata.deprecationReason ||
|
|
321
|
+
"Migrate to recommended alternative",
|
|
166
322
|
],
|
|
167
323
|
},
|
|
168
324
|
detectedAt: new Date(),
|
|
@@ -173,9 +329,9 @@ export class SemanticDiffEngine {
|
|
|
173
329
|
const namingIssue = this.checkNamingConsistency(component.name, namingPatterns);
|
|
174
330
|
if (namingIssue) {
|
|
175
331
|
drifts.push({
|
|
176
|
-
id: createDriftId(
|
|
177
|
-
type:
|
|
178
|
-
severity:
|
|
332
|
+
id: createDriftId("naming-inconsistency", component.id),
|
|
333
|
+
type: "naming-inconsistency",
|
|
334
|
+
severity: "info",
|
|
179
335
|
source: this.componentToDriftSource(component),
|
|
180
336
|
message: namingIssue.message,
|
|
181
337
|
details: {
|
|
@@ -190,16 +346,18 @@ export class SemanticDiffEngine {
|
|
|
190
346
|
const typeConflict = this.checkPropTypeConsistency(prop, propTypeMap);
|
|
191
347
|
if (typeConflict) {
|
|
192
348
|
drifts.push({
|
|
193
|
-
id: createDriftId(
|
|
194
|
-
type:
|
|
195
|
-
severity:
|
|
349
|
+
id: createDriftId("semantic-mismatch", component.id, prop.name),
|
|
350
|
+
type: "semantic-mismatch",
|
|
351
|
+
severity: "warning",
|
|
196
352
|
source: this.componentToDriftSource(component),
|
|
197
353
|
message: `Prop "${prop.name}" in "${component.name}" uses type "${prop.type}" but other components use "${typeConflict.dominantType}"`,
|
|
198
354
|
details: {
|
|
199
355
|
expected: typeConflict.dominantType,
|
|
200
356
|
actual: prop.type,
|
|
201
357
|
usedIn: typeConflict.examples,
|
|
202
|
-
suggestions: [
|
|
358
|
+
suggestions: [
|
|
359
|
+
"Standardize prop types across components for consistency",
|
|
360
|
+
],
|
|
203
361
|
},
|
|
204
362
|
detectedAt: new Date(),
|
|
205
363
|
});
|
|
@@ -209,9 +367,9 @@ export class SemanticDiffEngine {
|
|
|
209
367
|
const propNamingIssues = this.checkPropNamingConsistency(component, propNamingMap);
|
|
210
368
|
for (const issue of propNamingIssues) {
|
|
211
369
|
drifts.push({
|
|
212
|
-
id: createDriftId(
|
|
213
|
-
type:
|
|
214
|
-
severity:
|
|
370
|
+
id: createDriftId("naming-inconsistency", component.id, issue.propName),
|
|
371
|
+
type: "naming-inconsistency",
|
|
372
|
+
severity: "info",
|
|
215
373
|
source: this.componentToDriftSource(component),
|
|
216
374
|
message: issue.message,
|
|
217
375
|
details: {
|
|
@@ -225,50 +383,91 @@ export class SemanticDiffEngine {
|
|
|
225
383
|
const a11yIssues = this.checkAccessibility(component);
|
|
226
384
|
for (const issue of a11yIssues) {
|
|
227
385
|
drifts.push({
|
|
228
|
-
id: createDriftId(
|
|
229
|
-
type:
|
|
230
|
-
severity:
|
|
386
|
+
id: createDriftId("accessibility-conflict", component.id),
|
|
387
|
+
type: "accessibility-conflict",
|
|
388
|
+
severity: "critical",
|
|
231
389
|
source: this.componentToDriftSource(component),
|
|
232
390
|
message: `Component "${component.name}" has accessibility issues: ${issue}`,
|
|
233
391
|
details: {
|
|
234
|
-
suggestions: [
|
|
392
|
+
suggestions: [
|
|
393
|
+
"Fix accessibility issue to ensure inclusive design",
|
|
394
|
+
],
|
|
235
395
|
},
|
|
236
396
|
detectedAt: new Date(),
|
|
237
397
|
});
|
|
238
398
|
}
|
|
239
399
|
}
|
|
240
400
|
// Check for hardcoded values that should be tokens
|
|
241
|
-
if (component.metadata.hardcodedValues &&
|
|
401
|
+
if (component.metadata.hardcodedValues &&
|
|
402
|
+
component.metadata.hardcodedValues.length > 0) {
|
|
242
403
|
const hardcoded = component.metadata.hardcodedValues;
|
|
243
|
-
const colorCount = hardcoded.filter(h => h.type ===
|
|
244
|
-
const spacingCount = hardcoded.filter(h => h.type ===
|
|
404
|
+
const colorCount = hardcoded.filter((h) => h.type === "color").length;
|
|
405
|
+
const spacingCount = hardcoded.filter((h) => h.type === "spacing" || h.type === "fontSize").length;
|
|
406
|
+
// Generate token suggestions if available tokens provided
|
|
407
|
+
const tokenSuggestions = options.availableTokens
|
|
408
|
+
? this.generateTokenSuggestions(hardcoded, options.availableTokens)
|
|
409
|
+
: new Map();
|
|
245
410
|
// Group by type for cleaner messaging
|
|
246
411
|
if (colorCount > 0) {
|
|
247
|
-
const colorValues = hardcoded.filter(h => h.type ===
|
|
412
|
+
const colorValues = hardcoded.filter((h) => h.type === "color");
|
|
413
|
+
// Build actionable suggestions
|
|
414
|
+
const suggestions = [];
|
|
415
|
+
const tokenReplacements = [];
|
|
416
|
+
for (const cv of colorValues) {
|
|
417
|
+
const suggs = tokenSuggestions.get(cv.value);
|
|
418
|
+
if (suggs && suggs.length > 0) {
|
|
419
|
+
const bestMatch = suggs[0];
|
|
420
|
+
tokenReplacements.push(`${cv.value} → ${bestMatch.suggestedToken} (${Math.round(bestMatch.confidence * 100)}% match)`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (tokenReplacements.length > 0) {
|
|
424
|
+
suggestions.push(`Suggested replacements:\n ${tokenReplacements.join("\n ")}`);
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
suggestions.push("Replace hardcoded colors with design tokens (e.g., var(--primary) or theme.colors.primary)");
|
|
428
|
+
}
|
|
248
429
|
drifts.push({
|
|
249
|
-
id: createDriftId(
|
|
250
|
-
type:
|
|
251
|
-
severity:
|
|
430
|
+
id: createDriftId("hardcoded-value", component.id, "color"),
|
|
431
|
+
type: "hardcoded-value",
|
|
432
|
+
severity: "warning",
|
|
252
433
|
source: this.componentToDriftSource(component),
|
|
253
|
-
message: `Component "${component.name}" has ${colorCount} hardcoded color${colorCount > 1 ?
|
|
434
|
+
message: `Component "${component.name}" has ${colorCount} hardcoded color${colorCount > 1 ? "s" : ""}: ${colorValues.map((h) => h.value).join(", ")}`,
|
|
254
435
|
details: {
|
|
255
|
-
suggestions
|
|
256
|
-
affectedFiles: colorValues.map(h => `${h.property}: ${h.value} (${h.location})`),
|
|
436
|
+
suggestions,
|
|
437
|
+
affectedFiles: colorValues.map((h) => `${h.property}: ${h.value} (${h.location})`),
|
|
438
|
+
tokenSuggestions: tokenReplacements.length > 0 ? tokenReplacements : undefined,
|
|
257
439
|
},
|
|
258
440
|
detectedAt: new Date(),
|
|
259
441
|
});
|
|
260
442
|
}
|
|
261
443
|
if (spacingCount > 0) {
|
|
262
|
-
const spacingValues = hardcoded.filter(h => h.type ===
|
|
444
|
+
const spacingValues = hardcoded.filter((h) => h.type === "spacing" || h.type === "fontSize");
|
|
445
|
+
// Build actionable suggestions
|
|
446
|
+
const suggestions = [];
|
|
447
|
+
const tokenReplacements = [];
|
|
448
|
+
for (const sv of spacingValues) {
|
|
449
|
+
const suggs = tokenSuggestions.get(sv.value);
|
|
450
|
+
if (suggs && suggs.length > 0) {
|
|
451
|
+
const bestMatch = suggs[0];
|
|
452
|
+
tokenReplacements.push(`${sv.value} → ${bestMatch.suggestedToken} (${Math.round(bestMatch.confidence * 100)}% match)`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (tokenReplacements.length > 0) {
|
|
456
|
+
suggestions.push(`Suggested replacements:\n ${tokenReplacements.join("\n ")}`);
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
suggestions.push("Consider using spacing tokens for consistency");
|
|
460
|
+
}
|
|
263
461
|
drifts.push({
|
|
264
|
-
id: createDriftId(
|
|
265
|
-
type:
|
|
266
|
-
severity:
|
|
462
|
+
id: createDriftId("hardcoded-value", component.id, "spacing"),
|
|
463
|
+
type: "hardcoded-value",
|
|
464
|
+
severity: "info",
|
|
267
465
|
source: this.componentToDriftSource(component),
|
|
268
|
-
message: `Component "${component.name}" has ${spacingCount} hardcoded size value${spacingCount > 1 ?
|
|
466
|
+
message: `Component "${component.name}" has ${spacingCount} hardcoded size value${spacingCount > 1 ? "s" : ""}: ${spacingValues.map((h) => h.value).join(", ")}`,
|
|
269
467
|
details: {
|
|
270
|
-
suggestions
|
|
271
|
-
affectedFiles: spacingValues.map(h => `${h.property}: ${h.value} (${h.location})`),
|
|
468
|
+
suggestions,
|
|
469
|
+
affectedFiles: spacingValues.map((h) => `${h.property}: ${h.value} (${h.location})`),
|
|
470
|
+
tokenSuggestions: tokenReplacements.length > 0 ? tokenReplacements : undefined,
|
|
272
471
|
},
|
|
273
472
|
detectedAt: new Date(),
|
|
274
473
|
});
|
|
@@ -280,14 +479,16 @@ export class SemanticDiffEngine {
|
|
|
280
479
|
const duplicates = this.detectPotentialDuplicates(components);
|
|
281
480
|
for (const dup of duplicates) {
|
|
282
481
|
drifts.push({
|
|
283
|
-
id: createDriftId(
|
|
284
|
-
type:
|
|
285
|
-
severity:
|
|
482
|
+
id: createDriftId("naming-inconsistency", dup.components[0].id, "duplicate"),
|
|
483
|
+
type: "naming-inconsistency",
|
|
484
|
+
severity: "warning",
|
|
286
485
|
source: this.componentToDriftSource(dup.components[0]),
|
|
287
|
-
message: `Potential duplicate components: ${dup.components.map(c => c.name).join(
|
|
486
|
+
message: `Potential duplicate components: ${dup.components.map((c) => c.name).join(", ")}`,
|
|
288
487
|
details: {
|
|
289
|
-
suggestions: [
|
|
290
|
-
|
|
488
|
+
suggestions: [
|
|
489
|
+
"Consider consolidating these components or clarifying their distinct purposes",
|
|
490
|
+
],
|
|
491
|
+
relatedComponents: dup.components.map((c) => c.name),
|
|
291
492
|
},
|
|
292
493
|
detectedAt: new Date(),
|
|
293
494
|
});
|
|
@@ -301,7 +502,7 @@ export class SemanticDiffEngine {
|
|
|
301
502
|
const patterns = {
|
|
302
503
|
PascalCase: 0,
|
|
303
504
|
camelCase: 0,
|
|
304
|
-
|
|
505
|
+
"kebab-case": 0,
|
|
305
506
|
snake_case: 0,
|
|
306
507
|
other: 0,
|
|
307
508
|
};
|
|
@@ -309,12 +510,13 @@ export class SemanticDiffEngine {
|
|
|
309
510
|
const pattern = this.identifyNamingPattern(comp.name);
|
|
310
511
|
patterns[pattern]++;
|
|
311
512
|
}
|
|
312
|
-
// Find dominant pattern (must
|
|
513
|
+
// Find dominant pattern (must exceed threshold to be considered dominant)
|
|
313
514
|
const total = components.length;
|
|
314
515
|
let dominant = null;
|
|
315
516
|
let dominantCount = 0;
|
|
316
517
|
for (const [pattern, count] of Object.entries(patterns)) {
|
|
317
|
-
if (count > dominantCount &&
|
|
518
|
+
if (count > dominantCount &&
|
|
519
|
+
count / total > NAMING_CONFIG.dominantPatternThreshold) {
|
|
318
520
|
dominant = pattern;
|
|
319
521
|
dominantCount = count;
|
|
320
522
|
}
|
|
@@ -323,14 +525,14 @@ export class SemanticDiffEngine {
|
|
|
323
525
|
}
|
|
324
526
|
identifyNamingPattern(name) {
|
|
325
527
|
if (/^[A-Z][a-zA-Z0-9]*$/.test(name))
|
|
326
|
-
return
|
|
528
|
+
return "PascalCase";
|
|
327
529
|
if (/^[a-z][a-zA-Z0-9]*$/.test(name))
|
|
328
|
-
return
|
|
530
|
+
return "camelCase";
|
|
329
531
|
if (/^[a-z][a-z0-9-]*$/.test(name))
|
|
330
|
-
return
|
|
532
|
+
return "kebab-case";
|
|
331
533
|
if (/^[a-z][a-z0-9_]*$/.test(name))
|
|
332
|
-
return
|
|
333
|
-
return
|
|
534
|
+
return "snake_case";
|
|
535
|
+
return "other";
|
|
334
536
|
}
|
|
335
537
|
checkNamingConsistency(name, patterns) {
|
|
336
538
|
if (!patterns.dominant)
|
|
@@ -339,7 +541,7 @@ export class SemanticDiffEngine {
|
|
|
339
541
|
if (thisPattern === patterns.dominant)
|
|
340
542
|
return null;
|
|
341
543
|
// Only flag if this is a clear outlier
|
|
342
|
-
const outlierThreshold =
|
|
544
|
+
const outlierThreshold = getOutlierThreshold(patterns.total);
|
|
343
545
|
if (patterns.patterns[patterns.dominant] < outlierThreshold)
|
|
344
546
|
return null;
|
|
345
547
|
return {
|
|
@@ -359,7 +561,10 @@ export class SemanticDiffEngine {
|
|
|
359
561
|
map.set(normalizedName, { types: new Map(), total: 0 });
|
|
360
562
|
}
|
|
361
563
|
const usage = map.get(normalizedName);
|
|
362
|
-
const typeCount = usage.types.get(prop.type) || {
|
|
564
|
+
const typeCount = usage.types.get(prop.type) || {
|
|
565
|
+
count: 0,
|
|
566
|
+
examples: [],
|
|
567
|
+
};
|
|
363
568
|
typeCount.count++;
|
|
364
569
|
if (typeCount.examples.length < 3) {
|
|
365
570
|
typeCount.examples.push(comp.name);
|
|
@@ -375,7 +580,7 @@ export class SemanticDiffEngine {
|
|
|
375
580
|
if (!usage || usage.total < 3)
|
|
376
581
|
return null; // Not enough data
|
|
377
582
|
// Find dominant type
|
|
378
|
-
let dominantType =
|
|
583
|
+
let dominantType = "";
|
|
379
584
|
let dominantCount = 0;
|
|
380
585
|
for (const [type, data] of usage.types) {
|
|
381
586
|
if (data.count > dominantCount) {
|
|
@@ -383,10 +588,11 @@ export class SemanticDiffEngine {
|
|
|
383
588
|
dominantCount = data.count;
|
|
384
589
|
}
|
|
385
590
|
}
|
|
386
|
-
// Only flag if this prop's type differs and dominant
|
|
591
|
+
// Only flag if this prop's type differs and dominant exceeds threshold
|
|
387
592
|
if (prop.type === dominantType)
|
|
388
593
|
return null;
|
|
389
|
-
if (dominantCount / usage.total <
|
|
594
|
+
if (dominantCount / usage.total <
|
|
595
|
+
NAMING_CONFIG.establishedConventionThreshold)
|
|
390
596
|
return null;
|
|
391
597
|
const examples = usage.types.get(dominantType)?.examples || [];
|
|
392
598
|
return { dominantType, examples };
|
|
@@ -402,16 +608,16 @@ export class SemanticDiffEngine {
|
|
|
402
608
|
for (const comp of components) {
|
|
403
609
|
for (const prop of comp.props) {
|
|
404
610
|
const lower = prop.name.toLowerCase();
|
|
405
|
-
if (lower.includes(
|
|
611
|
+
if (lower.includes("click") || lower.includes("press")) {
|
|
406
612
|
clickHandlers.push(prop.name);
|
|
407
613
|
}
|
|
408
|
-
if (lower.includes(
|
|
614
|
+
if (lower.includes("change")) {
|
|
409
615
|
changeHandlers.push(prop.name);
|
|
410
616
|
}
|
|
411
617
|
}
|
|
412
618
|
}
|
|
413
|
-
map.set(
|
|
414
|
-
map.set(
|
|
619
|
+
map.set("click", clickHandlers);
|
|
620
|
+
map.set("change", changeHandlers);
|
|
415
621
|
return map;
|
|
416
622
|
}
|
|
417
623
|
checkPropNamingConsistency(component, propNamingMap) {
|
|
@@ -419,8 +625,8 @@ export class SemanticDiffEngine {
|
|
|
419
625
|
for (const prop of component.props) {
|
|
420
626
|
const lower = prop.name.toLowerCase();
|
|
421
627
|
// Check click handler naming
|
|
422
|
-
if (lower.includes(
|
|
423
|
-
const allClickHandlers = propNamingMap.get(
|
|
628
|
+
if (lower.includes("click") || lower.includes("press")) {
|
|
629
|
+
const allClickHandlers = propNamingMap.get("click") || [];
|
|
424
630
|
if (allClickHandlers.length >= 5) {
|
|
425
631
|
const dominant = this.findDominantPropPattern(allClickHandlers);
|
|
426
632
|
if (dominant && !prop.name.startsWith(dominant.prefix)) {
|
|
@@ -429,7 +635,7 @@ export class SemanticDiffEngine {
|
|
|
429
635
|
issues.push({
|
|
430
636
|
propName: prop.name,
|
|
431
637
|
message: `"${prop.name}" in "${component.name}" - ${dominantPct}% of click handlers use "${dominant.prefix}..." pattern`,
|
|
432
|
-
suggestion: `Consider using "${dominant.prefix}${prop.name.replace(/^(on|handle)/i,
|
|
638
|
+
suggestion: `Consider using "${dominant.prefix}${prop.name.replace(/^(on|handle)/i, "")}" for consistency`,
|
|
433
639
|
});
|
|
434
640
|
}
|
|
435
641
|
}
|
|
@@ -441,10 +647,10 @@ export class SemanticDiffEngine {
|
|
|
441
647
|
findDominantPropPattern(propNames) {
|
|
442
648
|
const prefixes = {};
|
|
443
649
|
for (const name of propNames) {
|
|
444
|
-
if (name.startsWith(
|
|
445
|
-
prefixes[
|
|
446
|
-
else if (name.startsWith(
|
|
447
|
-
prefixes[
|
|
650
|
+
if (name.startsWith("on"))
|
|
651
|
+
prefixes["on"] = (prefixes["on"] || 0) + 1;
|
|
652
|
+
else if (name.startsWith("handle"))
|
|
653
|
+
prefixes["handle"] = (prefixes["handle"] || 0) + 1;
|
|
448
654
|
}
|
|
449
655
|
let dominant = null;
|
|
450
656
|
for (const [prefix, count] of Object.entries(prefixes)) {
|
|
@@ -455,7 +661,9 @@ export class SemanticDiffEngine {
|
|
|
455
661
|
return dominant;
|
|
456
662
|
}
|
|
457
663
|
/**
|
|
458
|
-
* Detect potential duplicate components based on similar names
|
|
664
|
+
* Detect potential duplicate components based on similar names.
|
|
665
|
+
* Only flags true duplicates like Button vs ButtonNew or Card vs CardLegacy.
|
|
666
|
+
* Does NOT flag compound components like Button vs ButtonGroup or Card vs CardHeader.
|
|
459
667
|
*/
|
|
460
668
|
detectPotentialDuplicates(components) {
|
|
461
669
|
const duplicates = [];
|
|
@@ -463,33 +671,59 @@ export class SemanticDiffEngine {
|
|
|
463
671
|
for (const comp of components) {
|
|
464
672
|
if (processed.has(comp.id))
|
|
465
673
|
continue;
|
|
466
|
-
//
|
|
467
|
-
const baseName = comp.name
|
|
468
|
-
|
|
469
|
-
.replace(/\d+$/, '')
|
|
470
|
-
.toLowerCase();
|
|
471
|
-
const similar = components.filter(c => {
|
|
674
|
+
// Extract base name and check if it has a version suffix
|
|
675
|
+
const { baseName, hasVersionSuffix } = this.extractBaseName(comp.name);
|
|
676
|
+
const similar = components.filter((c) => {
|
|
472
677
|
if (c.id === comp.id)
|
|
473
678
|
return false;
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
679
|
+
const other = this.extractBaseName(c.name);
|
|
680
|
+
// Only match if base names are identical
|
|
681
|
+
if (baseName !== other.baseName || baseName.length < 3) {
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
// At least one component must have a version suffix to be a duplicate
|
|
685
|
+
// This prevents Button vs ButtonGroup from matching
|
|
686
|
+
// But allows Button vs ButtonNew to match
|
|
687
|
+
return hasVersionSuffix || other.hasVersionSuffix;
|
|
479
688
|
});
|
|
480
689
|
if (similar.length > 0) {
|
|
481
690
|
const group = [comp, ...similar];
|
|
482
|
-
group.forEach(c => processed.add(c.id));
|
|
691
|
+
group.forEach((c) => processed.add(c.id));
|
|
483
692
|
duplicates.push({ components: group });
|
|
484
693
|
}
|
|
485
694
|
}
|
|
486
695
|
return duplicates;
|
|
487
696
|
}
|
|
697
|
+
/**
|
|
698
|
+
* Extract the base name from a component name, stripping version suffixes.
|
|
699
|
+
* Returns whether the name had a version suffix (indicating potential duplicate).
|
|
700
|
+
*/
|
|
701
|
+
extractBaseName(name) {
|
|
702
|
+
const lowerName = name.toLowerCase();
|
|
703
|
+
// Check if the name ends with a semantic suffix (legitimate separate component)
|
|
704
|
+
for (const suffix of SEMANTIC_COMPONENT_SUFFIXES) {
|
|
705
|
+
if (lowerName.endsWith(suffix) &&
|
|
706
|
+
lowerName.length > suffix.length &&
|
|
707
|
+
// Ensure the suffix is at a word boundary (e.g., "ButtonGroup" not "Buttong")
|
|
708
|
+
lowerName[lowerName.length - suffix.length - 1]?.match(/[a-z]/)) {
|
|
709
|
+
// This is a compound component, not a duplicate candidate
|
|
710
|
+
// Return the full name as the base (it's distinct)
|
|
711
|
+
return { baseName: lowerName, hasVersionSuffix: false };
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
// Check for version suffixes that indicate duplicates
|
|
715
|
+
const hasVersionSuffix = VERSION_SUFFIXES_PATTERN.test(name);
|
|
716
|
+
const strippedName = name
|
|
717
|
+
.replace(VERSION_SUFFIXES_PATTERN, "")
|
|
718
|
+
.replace(/\d+$/, "") // Strip trailing numbers
|
|
719
|
+
.toLowerCase();
|
|
720
|
+
return { baseName: strippedName, hasVersionSuffix };
|
|
721
|
+
}
|
|
488
722
|
createMatch(source, target, matchType) {
|
|
489
723
|
return {
|
|
490
724
|
source,
|
|
491
725
|
target,
|
|
492
|
-
confidence: matchType ===
|
|
726
|
+
confidence: matchType === "exact" ? 1 : 0,
|
|
493
727
|
matchType,
|
|
494
728
|
differences: this.findDifferences(source, target),
|
|
495
729
|
};
|
|
@@ -501,7 +735,7 @@ export class SemanticDiffEngine {
|
|
|
501
735
|
const score = this.calculateSimilarity(source, candidate);
|
|
502
736
|
if (score > bestScore) {
|
|
503
737
|
bestScore = score;
|
|
504
|
-
const matchType = score >
|
|
738
|
+
const matchType = score > MATCHING_CONFIG.similarMatchThreshold ? "similar" : "partial";
|
|
505
739
|
bestMatch = {
|
|
506
740
|
source,
|
|
507
741
|
target: candidate,
|
|
@@ -515,67 +749,39 @@ export class SemanticDiffEngine {
|
|
|
515
749
|
}
|
|
516
750
|
calculateSimilarity(a, b) {
|
|
517
751
|
let score = 0;
|
|
518
|
-
const weights =
|
|
752
|
+
const weights = MATCHING_CONFIG.similarityWeights;
|
|
519
753
|
// Name similarity (Levenshtein-based)
|
|
520
754
|
score += weights.name * this.stringSimilarity(a.name, b.name);
|
|
521
|
-
//
|
|
522
|
-
const
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
const variantsIntersection = [...
|
|
531
|
-
const variantsUnion = new Set([...
|
|
532
|
-
score +=
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
const depsIntersection = [...
|
|
537
|
-
const depsUnion = new Set([...
|
|
538
|
-
|
|
755
|
+
// Use pre-computed metadata for faster overlap calculation
|
|
756
|
+
const aMeta = this.getComponentMetadata(a);
|
|
757
|
+
const bMeta = this.getComponentMetadata(b);
|
|
758
|
+
// Props overlap (using pre-computed Sets)
|
|
759
|
+
const propsIntersection = [...aMeta.props].filter((p) => bMeta.props.has(p)).length;
|
|
760
|
+
const propsUnion = new Set([...aMeta.props, ...bMeta.props]).size;
|
|
761
|
+
score +=
|
|
762
|
+
weights.props * (propsUnion > 0 ? propsIntersection / propsUnion : 0);
|
|
763
|
+
// Variant overlap (using pre-computed Sets)
|
|
764
|
+
const variantsIntersection = [...aMeta.variants].filter((v) => bMeta.variants.has(v)).length;
|
|
765
|
+
const variantsUnion = new Set([...aMeta.variants, ...bMeta.variants]).size;
|
|
766
|
+
score +=
|
|
767
|
+
weights.variants *
|
|
768
|
+
(variantsUnion > 0 ? variantsIntersection / variantsUnion : 0);
|
|
769
|
+
// Dependencies overlap (using pre-computed Sets)
|
|
770
|
+
const depsIntersection = [...aMeta.dependencies].filter((d) => bMeta.dependencies.has(d)).length;
|
|
771
|
+
const depsUnion = new Set([...aMeta.dependencies, ...bMeta.dependencies])
|
|
772
|
+
.size;
|
|
773
|
+
score +=
|
|
774
|
+
weights.dependencies * (depsUnion > 0 ? depsIntersection / depsUnion : 0);
|
|
539
775
|
return score;
|
|
540
776
|
}
|
|
541
777
|
stringSimilarity(a, b) {
|
|
542
|
-
|
|
543
|
-
if (maxLen === 0)
|
|
544
|
-
return 1;
|
|
545
|
-
const distance = this.levenshteinDistance(a.toLowerCase(), b.toLowerCase());
|
|
546
|
-
return 1 - distance / maxLen;
|
|
547
|
-
}
|
|
548
|
-
levenshteinDistance(a, b) {
|
|
549
|
-
// Create matrix with proper initialization
|
|
550
|
-
const rows = b.length + 1;
|
|
551
|
-
const cols = a.length + 1;
|
|
552
|
-
const matrix = Array.from({ length: rows }, () => Array(cols).fill(0));
|
|
553
|
-
// Initialize first column
|
|
554
|
-
for (let i = 0; i <= b.length; i++) {
|
|
555
|
-
matrix[i][0] = i;
|
|
556
|
-
}
|
|
557
|
-
// Initialize first row
|
|
558
|
-
for (let j = 0; j <= a.length; j++) {
|
|
559
|
-
matrix[0][j] = j;
|
|
560
|
-
}
|
|
561
|
-
// Fill in the rest of the matrix
|
|
562
|
-
for (let i = 1; i <= b.length; i++) {
|
|
563
|
-
for (let j = 1; j <= a.length; j++) {
|
|
564
|
-
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
|
565
|
-
matrix[i][j] = matrix[i - 1][j - 1];
|
|
566
|
-
}
|
|
567
|
-
else {
|
|
568
|
-
matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
return matrix[b.length][a.length];
|
|
778
|
+
return calcStringSimilarity(a.toLowerCase(), b.toLowerCase());
|
|
573
779
|
}
|
|
574
780
|
findDifferences(source, target) {
|
|
575
781
|
const differences = [];
|
|
576
782
|
// Compare props
|
|
577
|
-
const sourceProps = new Map(source.props.map(p => [p.name.toLowerCase(), p]));
|
|
578
|
-
const targetProps = new Map(target.props.map(p => [p.name.toLowerCase(), p]));
|
|
783
|
+
const sourceProps = new Map(source.props.map((p) => [p.name.toLowerCase(), p]));
|
|
784
|
+
const targetProps = new Map(target.props.map((p) => [p.name.toLowerCase(), p]));
|
|
579
785
|
for (const [name, prop] of sourceProps) {
|
|
580
786
|
const targetProp = targetProps.get(name);
|
|
581
787
|
if (!targetProp) {
|
|
@@ -583,7 +789,7 @@ export class SemanticDiffEngine {
|
|
|
583
789
|
field: `props.${prop.name}`,
|
|
584
790
|
sourceValue: prop,
|
|
585
791
|
targetValue: undefined,
|
|
586
|
-
severity: prop.required ?
|
|
792
|
+
severity: prop.required ? "warning" : "info",
|
|
587
793
|
});
|
|
588
794
|
}
|
|
589
795
|
else if (prop.type !== targetProp.type) {
|
|
@@ -591,7 +797,7 @@ export class SemanticDiffEngine {
|
|
|
591
797
|
field: `props.${prop.name}.type`,
|
|
592
798
|
sourceValue: prop.type,
|
|
593
799
|
targetValue: targetProp.type,
|
|
594
|
-
severity:
|
|
800
|
+
severity: "warning",
|
|
595
801
|
});
|
|
596
802
|
}
|
|
597
803
|
}
|
|
@@ -601,7 +807,7 @@ export class SemanticDiffEngine {
|
|
|
601
807
|
field: `props.${prop.name}`,
|
|
602
808
|
sourceValue: undefined,
|
|
603
809
|
targetValue: prop,
|
|
604
|
-
severity:
|
|
810
|
+
severity: "info",
|
|
605
811
|
});
|
|
606
812
|
}
|
|
607
813
|
}
|
|
@@ -611,18 +817,18 @@ export class SemanticDiffEngine {
|
|
|
611
817
|
const drifts = [];
|
|
612
818
|
// Drifts from matches with significant differences
|
|
613
819
|
for (const match of matches) {
|
|
614
|
-
const significantDiffs = match.differences.filter(d => d.severity ===
|
|
820
|
+
const significantDiffs = match.differences.filter((d) => d.severity === "warning" || d.severity === "critical");
|
|
615
821
|
if (significantDiffs.length > 0) {
|
|
616
822
|
drifts.push({
|
|
617
|
-
id: createDriftId(
|
|
618
|
-
type:
|
|
823
|
+
id: createDriftId("semantic-mismatch", match.source.id, match.target.id),
|
|
824
|
+
type: "semantic-mismatch",
|
|
619
825
|
severity: this.getHighestSeverity(match.differences),
|
|
620
826
|
source: this.componentToDriftSource(match.source),
|
|
621
827
|
target: this.componentToDriftSource(match.target),
|
|
622
828
|
message: `Component "${match.source.name}" has ${significantDiffs.length} differences between sources`,
|
|
623
829
|
details: {
|
|
624
830
|
diff: JSON.stringify(match.differences, null, 2),
|
|
625
|
-
suggestions: [
|
|
831
|
+
suggestions: ["Review component definitions for consistency"],
|
|
626
832
|
},
|
|
627
833
|
detectedAt: new Date(),
|
|
628
834
|
});
|
|
@@ -631,13 +837,15 @@ export class SemanticDiffEngine {
|
|
|
631
837
|
// Orphaned source components
|
|
632
838
|
for (const comp of orphanedSource) {
|
|
633
839
|
drifts.push({
|
|
634
|
-
id: createDriftId(
|
|
635
|
-
type:
|
|
636
|
-
severity:
|
|
840
|
+
id: createDriftId("orphaned-component", comp.id),
|
|
841
|
+
type: "orphaned-component",
|
|
842
|
+
severity: "warning",
|
|
637
843
|
source: this.componentToDriftSource(comp),
|
|
638
844
|
message: `Component "${comp.name}" exists in ${comp.source.type} but has no match in design`,
|
|
639
845
|
details: {
|
|
640
|
-
suggestions: [
|
|
846
|
+
suggestions: [
|
|
847
|
+
"Add component to Figma or document as intentional deviation",
|
|
848
|
+
],
|
|
641
849
|
},
|
|
642
850
|
detectedAt: new Date(),
|
|
643
851
|
});
|
|
@@ -645,13 +853,13 @@ export class SemanticDiffEngine {
|
|
|
645
853
|
// Orphaned target components
|
|
646
854
|
for (const comp of orphanedTarget) {
|
|
647
855
|
drifts.push({
|
|
648
|
-
id: createDriftId(
|
|
649
|
-
type:
|
|
650
|
-
severity:
|
|
856
|
+
id: createDriftId("orphaned-component", comp.id),
|
|
857
|
+
type: "orphaned-component",
|
|
858
|
+
severity: "info",
|
|
651
859
|
source: this.componentToDriftSource(comp),
|
|
652
860
|
message: `Component "${comp.name}" exists in design but not implemented`,
|
|
653
861
|
details: {
|
|
654
|
-
suggestions: [
|
|
862
|
+
suggestions: ["Implement component or mark as planned"],
|
|
655
863
|
},
|
|
656
864
|
detectedAt: new Date(),
|
|
657
865
|
});
|
|
@@ -659,58 +867,91 @@ export class SemanticDiffEngine {
|
|
|
659
867
|
return drifts;
|
|
660
868
|
}
|
|
661
869
|
componentToDriftSource(comp) {
|
|
662
|
-
let location =
|
|
663
|
-
if (comp.source.type ===
|
|
870
|
+
let location = "";
|
|
871
|
+
if (comp.source.type === "react") {
|
|
664
872
|
location = `${comp.source.path}:${comp.source.line || 0}`;
|
|
665
873
|
}
|
|
666
|
-
else if (comp.source.type ===
|
|
874
|
+
else if (comp.source.type === "figma") {
|
|
667
875
|
location = comp.source.url || comp.source.nodeId;
|
|
668
876
|
}
|
|
669
|
-
else if (comp.source.type ===
|
|
877
|
+
else if (comp.source.type === "storybook") {
|
|
670
878
|
location = comp.source.url || comp.source.storyId;
|
|
671
879
|
}
|
|
672
880
|
return {
|
|
673
|
-
entityType:
|
|
881
|
+
entityType: "component",
|
|
674
882
|
entityId: comp.id,
|
|
675
883
|
entityName: comp.name,
|
|
676
884
|
location,
|
|
677
885
|
};
|
|
678
886
|
}
|
|
679
887
|
tokenToDriftSource(token) {
|
|
680
|
-
let location =
|
|
681
|
-
if (token.source.type ===
|
|
888
|
+
let location = "";
|
|
889
|
+
if (token.source.type === "json" ||
|
|
890
|
+
token.source.type === "css" ||
|
|
891
|
+
token.source.type === "scss") {
|
|
682
892
|
location = token.source.path;
|
|
683
893
|
}
|
|
684
|
-
else if (token.source.type ===
|
|
894
|
+
else if (token.source.type === "figma") {
|
|
685
895
|
location = token.source.fileKey;
|
|
686
896
|
}
|
|
687
897
|
return {
|
|
688
|
-
entityType:
|
|
898
|
+
entityType: "token",
|
|
689
899
|
entityId: token.id,
|
|
690
900
|
entityName: token.name,
|
|
691
901
|
location,
|
|
692
902
|
};
|
|
693
903
|
}
|
|
694
904
|
getHighestSeverity(differences) {
|
|
695
|
-
if (differences.some(d => d.severity ===
|
|
696
|
-
return
|
|
697
|
-
if (differences.some(d => d.severity ===
|
|
698
|
-
return
|
|
699
|
-
return
|
|
905
|
+
if (differences.some((d) => d.severity === "critical"))
|
|
906
|
+
return "critical";
|
|
907
|
+
if (differences.some((d) => d.severity === "warning"))
|
|
908
|
+
return "warning";
|
|
909
|
+
return "info";
|
|
700
910
|
}
|
|
701
911
|
checkAccessibility(component) {
|
|
702
912
|
const issues = [];
|
|
703
913
|
// Check if interactive components have required ARIA props
|
|
704
|
-
const interactiveComponents = [
|
|
705
|
-
|
|
914
|
+
const interactiveComponents = [
|
|
915
|
+
"Button",
|
|
916
|
+
"Link",
|
|
917
|
+
"Input",
|
|
918
|
+
"Select",
|
|
919
|
+
"Checkbox",
|
|
920
|
+
"Radio",
|
|
921
|
+
];
|
|
922
|
+
const isInteractive = interactiveComponents.some((ic) => component.name.toLowerCase().includes(ic.toLowerCase()));
|
|
706
923
|
if (isInteractive) {
|
|
707
|
-
const hasAriaLabel = component.props.some(p => p.name.toLowerCase().includes(
|
|
708
|
-
|
|
709
|
-
|
|
924
|
+
const hasAriaLabel = component.props.some((p) => p.name.toLowerCase().includes("arialabel") ||
|
|
925
|
+
p.name.toLowerCase().includes("aria-label"));
|
|
926
|
+
const hasChildren = component.props.some((p) => p.name.toLowerCase() === "children");
|
|
927
|
+
if (!hasAriaLabel &&
|
|
928
|
+
!hasChildren &&
|
|
929
|
+
component.metadata.accessibility?.issues) {
|
|
710
930
|
issues.push(...component.metadata.accessibility.issues);
|
|
711
931
|
}
|
|
712
932
|
}
|
|
713
933
|
return issues;
|
|
714
934
|
}
|
|
935
|
+
/**
|
|
936
|
+
* Find token suggestions for a hardcoded color value
|
|
937
|
+
* Delegates to TokenSuggestionService
|
|
938
|
+
*/
|
|
939
|
+
findColorTokenSuggestions(hardcodedValue, tokens, maxSuggestions = 3) {
|
|
940
|
+
return this.tokenSuggestionService.findColorTokenSuggestions(hardcodedValue, tokens, maxSuggestions);
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Find token suggestions for a hardcoded spacing value
|
|
944
|
+
* Delegates to TokenSuggestionService
|
|
945
|
+
*/
|
|
946
|
+
findSpacingTokenSuggestions(hardcodedValue, tokens, maxSuggestions = 3) {
|
|
947
|
+
return this.tokenSuggestionService.findSpacingTokenSuggestions(hardcodedValue, tokens, maxSuggestions);
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Generate actionable suggestions for hardcoded values
|
|
951
|
+
* Delegates to TokenSuggestionService
|
|
952
|
+
*/
|
|
953
|
+
generateTokenSuggestions(hardcodedValues, tokens) {
|
|
954
|
+
return this.tokenSuggestionService.generateTokenSuggestions(hardcodedValues, tokens);
|
|
955
|
+
}
|
|
715
956
|
}
|
|
716
957
|
//# sourceMappingURL=semantic-diff.js.map
|