@sangheepark/figma-ds-mcp 0.2.3 → 0.2.5
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/tools/pipeline-tools.js +328 -45
- package/package.json +1 -1
|
@@ -21,6 +21,7 @@ const FIGMA_STYLE_KEYS = new Set([
|
|
|
21
21
|
'opacity', 'font-family', 'font-size', 'font-weight', 'color',
|
|
22
22
|
'text-align', 'line-height', 'letter-spacing', 'text-decoration',
|
|
23
23
|
'text-style', 'effect-style',
|
|
24
|
+
'backdrop-filter',
|
|
24
25
|
]);
|
|
25
26
|
// emoji 감지 정규식 (surrogate pairs + common symbols)
|
|
26
27
|
const EMOJI_PATTERN = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{200D}\u{20E3}\u{E0020}-\u{E007F}▶♡►◄●○■□★☆♪♫✓✗←→↑↓]/u;
|
|
@@ -111,18 +112,52 @@ function enrichSpec(traversal, mapping) {
|
|
|
111
112
|
if (component) {
|
|
112
113
|
node['library-key'] = component['library-key'];
|
|
113
114
|
// Step 2: _ds_props → variant-props + overrides 분리
|
|
115
|
+
// value 역검색 + default fallback 로직 포함
|
|
114
116
|
if (node._ds_props) {
|
|
115
117
|
const variantProps = {};
|
|
116
118
|
const overrides = {};
|
|
117
119
|
for (const [key, value] of Object.entries(node._ds_props)) {
|
|
118
120
|
if (component.variants && key in component.variants) {
|
|
119
|
-
//
|
|
121
|
+
// 1) exact key match → variant-props
|
|
120
122
|
if (typeof value === 'string' && component.defaults?.[key] !== value) {
|
|
121
123
|
variantProps[key] = value;
|
|
122
124
|
}
|
|
123
125
|
}
|
|
126
|
+
else if (component.variants && typeof value === 'string') {
|
|
127
|
+
// 2) exact match 실패 → value 역검색: 이 값을 가진 variant axis 찾기
|
|
128
|
+
const matchingAxes = [];
|
|
129
|
+
for (const [axis, options] of Object.entries(component.variants)) {
|
|
130
|
+
if (axis in variantProps)
|
|
131
|
+
continue; // 이미 매핑된 axis 스킵
|
|
132
|
+
if (options.includes(value) && component.defaults?.[axis] !== value) {
|
|
133
|
+
matchingAxes.push(axis);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (matchingAxes.length === 1) {
|
|
137
|
+
// 정확히 1개 axis에서만 발견 → 매핑
|
|
138
|
+
variantProps[matchingAxes[0]] = value;
|
|
139
|
+
}
|
|
140
|
+
// 0개 또는 2개 이상 → 매핑 포기 (default variant 사용)
|
|
141
|
+
}
|
|
142
|
+
else if (component.properties && typeof value === 'string') {
|
|
143
|
+
// 3) variant가 아닌 prop → property name 역검색
|
|
144
|
+
if (key in component.properties) {
|
|
145
|
+
// exact property name match
|
|
146
|
+
overrides[key] = value;
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
// exact match 실패 → TEXT property 자동 매핑
|
|
150
|
+
const textProps = Object.entries(component.properties)
|
|
151
|
+
.filter(([, type]) => type === 'TEXT')
|
|
152
|
+
.map(([name]) => name);
|
|
153
|
+
if (textProps.length === 1) {
|
|
154
|
+
overrides[textProps[0]] = value;
|
|
155
|
+
}
|
|
156
|
+
// 0개 또는 2개 이상 → 매핑 포기
|
|
157
|
+
}
|
|
158
|
+
}
|
|
124
159
|
else {
|
|
125
|
-
//
|
|
160
|
+
// boolean 등 기타 → overrides에 직접
|
|
126
161
|
overrides[key] = value;
|
|
127
162
|
}
|
|
128
163
|
}
|
|
@@ -169,8 +204,8 @@ function enrichSpec(traversal, mapping) {
|
|
|
169
204
|
if (normalized)
|
|
170
205
|
fontWeight = normalized;
|
|
171
206
|
}
|
|
172
|
-
if (fontSize
|
|
173
|
-
const key = `${fontSize}/${fontWeight}`;
|
|
207
|
+
if (fontSize) {
|
|
208
|
+
const key = `${fontSize}/${fontWeight || '400'}`;
|
|
174
209
|
const textStyle = mapping.textStyles[key];
|
|
175
210
|
if (textStyle) {
|
|
176
211
|
node.style['text-style'] = textStyle;
|
|
@@ -200,6 +235,12 @@ function enrichSpec(traversal, mapping) {
|
|
|
200
235
|
}
|
|
201
236
|
}
|
|
202
237
|
// Step 6: CSS → Figma 정규화
|
|
238
|
+
// 6-pre: overflow:hidden → layout.clip-content 변환 (6-A 필터링 전에 처리)
|
|
239
|
+
if (node.style && node.style['overflow'] === 'hidden') {
|
|
240
|
+
node.layout = node.layout || {};
|
|
241
|
+
node.layout['clip-content'] = true;
|
|
242
|
+
delete node.style['overflow'];
|
|
243
|
+
}
|
|
203
244
|
// 6-A: CSS-only 속성 필터링 (Figma가 지원하지 않는 CSS 속성 제거)
|
|
204
245
|
if (node.style) {
|
|
205
246
|
for (const key of Object.keys(node.style)) {
|
|
@@ -215,6 +256,19 @@ function enrichSpec(traversal, mapping) {
|
|
|
215
256
|
node.layout.direction = 'column';
|
|
216
257
|
}
|
|
217
258
|
}
|
|
259
|
+
// 6-D: absolute + fill 정규화
|
|
260
|
+
// Figma에서 absolute 요소는 parent auto-layout에 참여하지 않으므로
|
|
261
|
+
// fill은 layoutSizingHorizontal/Vertical = 'FILL'로 처리 → constraints STRETCH 동작
|
|
262
|
+
// 별도 변환 불필요 — NodeBuilder가 absolute child에 fill을 적용하면 STRETCH로 동작
|
|
263
|
+
// 다만 x/y가 없으면 0으로 기본값 설정하여 parent 좌상단 정렬
|
|
264
|
+
if (node.layout?.positioning === 'absolute') {
|
|
265
|
+
if ((node.layout.width === 'fill' || node.layout.width === '100%') && node.layout.x === undefined) {
|
|
266
|
+
node.layout.x = '0';
|
|
267
|
+
}
|
|
268
|
+
if ((node.layout.height === 'fill' || node.layout.height === '100%') && node.layout.y === undefined) {
|
|
269
|
+
node.layout.y = '0';
|
|
270
|
+
}
|
|
271
|
+
}
|
|
218
272
|
// children 재귀
|
|
219
273
|
if (node.children) {
|
|
220
274
|
node.children.forEach((child, i) => {
|
|
@@ -434,26 +488,42 @@ function splitWords(name) {
|
|
|
434
488
|
.split('_')
|
|
435
489
|
.filter(w => w.length > 0);
|
|
436
490
|
}
|
|
437
|
-
//
|
|
491
|
+
// Thresholds for word-split matching
|
|
492
|
+
const AUTO_MATCH_THRESHOLD = 0.80;
|
|
493
|
+
const REVIEW_THRESHOLD = 0.40;
|
|
494
|
+
function buildHint(entry) {
|
|
495
|
+
const parts = [];
|
|
496
|
+
if (entry.variants) {
|
|
497
|
+
const axes = Object.keys(entry.variants).join(', ');
|
|
498
|
+
parts.push(`variants: ${axes}`);
|
|
499
|
+
}
|
|
500
|
+
if (entry.properties) {
|
|
501
|
+
const props = Object.entries(entry.properties)
|
|
502
|
+
.map(([k, v]) => `${k}(${v.type})`)
|
|
503
|
+
.join(', ');
|
|
504
|
+
parts.push(`props: ${props}`);
|
|
505
|
+
}
|
|
506
|
+
return parts.join(' | ') || 'no variants/props';
|
|
507
|
+
}
|
|
508
|
+
// 3-stage component matching with review tier
|
|
438
509
|
function matchComponent(dsName, allComponents) {
|
|
439
510
|
// Stage 1: exact match (case-sensitive)
|
|
440
511
|
const exact = allComponents.get(dsName);
|
|
441
512
|
if (exact)
|
|
442
|
-
return { ...exact, matchType: 'exact' };
|
|
513
|
+
return { match: { ...exact, matchType: 'exact' } };
|
|
443
514
|
// Stage 2: case-insensitive match
|
|
444
515
|
const dsLower = dsName.toLowerCase();
|
|
445
516
|
for (const [name, data] of allComponents) {
|
|
446
517
|
if (name.toLowerCase() === dsLower) {
|
|
447
|
-
return { ...data, matchType: 'case-insensitive' };
|
|
518
|
+
return { match: { ...data, matchType: 'case-insensitive' } };
|
|
448
519
|
}
|
|
449
520
|
}
|
|
450
521
|
// Stage 3: word-split intersection match
|
|
451
522
|
const inputWords = new Set(splitWords(dsName));
|
|
452
523
|
if (inputWords.size === 0)
|
|
453
|
-
return null;
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
let bestName = '';
|
|
524
|
+
return { match: null };
|
|
525
|
+
// Collect all scores
|
|
526
|
+
const scored = [];
|
|
457
527
|
for (const [name, data] of allComponents) {
|
|
458
528
|
const dataWords = new Set(splitWords(name));
|
|
459
529
|
let intersection = 0;
|
|
@@ -461,19 +531,44 @@ function matchComponent(dsName, allComponents) {
|
|
|
461
531
|
if (dataWords.has(w))
|
|
462
532
|
intersection++;
|
|
463
533
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
534
|
+
// Dice coefficient: balanced scoring that penalizes asymmetric matches
|
|
535
|
+
// e.g., "DropdownSingleSelect" (3 words) → "Select" (1 word): 2*1/(3+1) = 0.50
|
|
536
|
+
// vs "dropdown_unique" (2 words): 2*1/(3+2) = 0.40
|
|
537
|
+
const total = inputWords.size + dataWords.size;
|
|
538
|
+
const score = total > 0 ? (2 * intersection) / total : 0;
|
|
539
|
+
if (score >= REVIEW_THRESHOLD) {
|
|
540
|
+
scored.push({ name, data, score });
|
|
469
541
|
}
|
|
470
542
|
}
|
|
471
|
-
|
|
472
|
-
|
|
543
|
+
scored.sort((a, b) => b.score - a.score);
|
|
544
|
+
// Auto-match: top score >= 0.80
|
|
545
|
+
if (scored.length > 0 && scored[0].score >= AUTO_MATCH_THRESHOLD) {
|
|
546
|
+
return {
|
|
547
|
+
match: { ...scored[0].data, matchType: `word-split(${scored[0].score.toFixed(2)})` },
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
// Review tier: 0.40 <= score < 0.80
|
|
551
|
+
if (scored.length > 0) {
|
|
552
|
+
const reviewCandidates = scored.slice(0, 3).map(s => ({
|
|
553
|
+
name: s.name,
|
|
554
|
+
score: parseFloat(s.score.toFixed(2)),
|
|
555
|
+
library: s.data.library,
|
|
556
|
+
hint: buildHint(s.data.entry),
|
|
557
|
+
}));
|
|
558
|
+
return { match: null, reviewCandidates };
|
|
473
559
|
}
|
|
474
|
-
|
|
560
|
+
// No match at all
|
|
561
|
+
return { match: null };
|
|
475
562
|
}
|
|
476
563
|
function generateMapping(traversals, dataDir) {
|
|
564
|
+
// 0. Load _config.json for primary library
|
|
565
|
+
let primaryLibrary = null;
|
|
566
|
+
try {
|
|
567
|
+
const config = JSON.parse(readFileSync(join(dataDir, '_config.json'), 'utf-8'));
|
|
568
|
+
if (config.primary)
|
|
569
|
+
primaryLibrary = config.primary;
|
|
570
|
+
}
|
|
571
|
+
catch { /* _config.json not found — no primary, all libraries equal */ }
|
|
477
572
|
// 1. Load all data files
|
|
478
573
|
const files = readdirSync(dataDir);
|
|
479
574
|
const allComponents = new Map();
|
|
@@ -529,8 +624,8 @@ function generateMapping(traversals, dataDir) {
|
|
|
529
624
|
if (normalized)
|
|
530
625
|
fontWeight = normalized;
|
|
531
626
|
}
|
|
532
|
-
if (fontSize
|
|
533
|
-
fontSpecs.add(`${fontSize}/${fontWeight}`);
|
|
627
|
+
if (fontSize) {
|
|
628
|
+
fontSpecs.add(`${fontSize}/${fontWeight || '400'}`);
|
|
534
629
|
}
|
|
535
630
|
}
|
|
536
631
|
// Collect top-level named frames as potential elements
|
|
@@ -542,49 +637,70 @@ function generateMapping(traversals, dataDir) {
|
|
|
542
637
|
traversals.forEach(extractFromNode);
|
|
543
638
|
// 3. Match components
|
|
544
639
|
const components = {};
|
|
640
|
+
const reviewNeeded = [];
|
|
545
641
|
for (const dsName of dsNames) {
|
|
546
|
-
const
|
|
547
|
-
if (match) {
|
|
642
|
+
const result = matchComponent(dsName, allComponents);
|
|
643
|
+
if (result.match) {
|
|
548
644
|
const comp = {
|
|
549
|
-
library: match.library,
|
|
550
|
-
'library-key': match.entry.key,
|
|
645
|
+
library: result.match.library,
|
|
646
|
+
'library-key': result.match.entry.key,
|
|
551
647
|
};
|
|
552
|
-
if (match.entry.variants) {
|
|
648
|
+
if (result.match.entry.variants) {
|
|
553
649
|
const variants = {};
|
|
554
650
|
const defaults = {};
|
|
555
|
-
for (const [axis, info] of Object.entries(match.entry.variants)) {
|
|
651
|
+
for (const [axis, info] of Object.entries(result.match.entry.variants)) {
|
|
556
652
|
variants[axis] = info.options;
|
|
557
653
|
defaults[axis] = info.default;
|
|
558
654
|
}
|
|
559
655
|
comp.variants = variants;
|
|
560
656
|
comp.defaults = defaults;
|
|
561
657
|
}
|
|
562
|
-
if (match.entry.properties) {
|
|
658
|
+
if (result.match.entry.properties) {
|
|
563
659
|
const properties = {};
|
|
564
|
-
for (const [propName, propInfo] of Object.entries(match.entry.properties)) {
|
|
660
|
+
for (const [propName, propInfo] of Object.entries(result.match.entry.properties)) {
|
|
565
661
|
properties[propName] = propInfo.type;
|
|
566
662
|
}
|
|
567
663
|
comp.properties = properties;
|
|
568
664
|
}
|
|
569
665
|
components[dsName] = comp;
|
|
570
666
|
}
|
|
667
|
+
else if (result.reviewCandidates && result.reviewCandidates.length > 0) {
|
|
668
|
+
reviewNeeded.push({ dsName, candidates: result.reviewCandidates });
|
|
669
|
+
}
|
|
571
670
|
}
|
|
572
671
|
// 4. Match tokens (HEX → variable name)
|
|
573
672
|
const tokens = {};
|
|
574
|
-
// Build reverse map: normalized HEX → variable name
|
|
673
|
+
// Build reverse map: normalized HEX → variable name
|
|
674
|
+
// Priority: primary library > semantic name > last seen
|
|
575
675
|
const hexToVar = new Map();
|
|
576
676
|
for (const v of allVariables) {
|
|
577
677
|
const hex = normalizeHex(v.value);
|
|
578
678
|
const existing = hexToVar.get(hex);
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
679
|
+
if (!existing) {
|
|
680
|
+
hexToVar.set(hex, { name: v.name, library: v.library });
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
const existingIsPrimary = existing.library === primaryLibrary;
|
|
684
|
+
const newIsPrimary = v.library === primaryLibrary;
|
|
685
|
+
if (newIsPrimary && !existingIsPrimary) {
|
|
686
|
+
// Primary library always wins
|
|
687
|
+
hexToVar.set(hex, { name: v.name, library: v.library });
|
|
688
|
+
}
|
|
689
|
+
else if (existingIsPrimary && !newIsPrimary) {
|
|
690
|
+
// Existing is primary, keep it
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
// Same priority (both primary or both non-primary): prefer semantic name
|
|
694
|
+
if (!isPrimitive(v.name) && isPrimitive(existing.name)) {
|
|
695
|
+
hexToVar.set(hex, { name: v.name, library: v.library });
|
|
696
|
+
}
|
|
697
|
+
}
|
|
582
698
|
}
|
|
583
699
|
}
|
|
584
700
|
for (const hex of hexColors) {
|
|
585
|
-
const
|
|
586
|
-
if (
|
|
587
|
-
tokens[hex] =
|
|
701
|
+
const match = hexToVar.get(hex);
|
|
702
|
+
if (match)
|
|
703
|
+
tokens[hex] = `{${match.name}}`;
|
|
588
704
|
}
|
|
589
705
|
// 5. Match text styles (fontSize/fontWeight → style name)
|
|
590
706
|
const textStyles = {};
|
|
@@ -619,7 +735,7 @@ function generateMapping(traversals, dataDir) {
|
|
|
619
735
|
};
|
|
620
736
|
}
|
|
621
737
|
}
|
|
622
|
-
return { elements, components, tokens, textStyles };
|
|
738
|
+
return { elements, components, tokens, textStyles, reviewNeeded };
|
|
623
739
|
}
|
|
624
740
|
function normalizeHex(hex) {
|
|
625
741
|
let h = hex.toUpperCase();
|
|
@@ -735,6 +851,20 @@ Saves result to outputPath. Replaces ds-data-lookup sub-agent.`, {
|
|
|
735
851
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
736
852
|
writeFileSync(outputPath, JSON.stringify(result, null, 2));
|
|
737
853
|
// Summary for context (not the full mapping)
|
|
854
|
+
const matchedDs = new Set(Object.keys(result.components || {}));
|
|
855
|
+
const reviewDs = new Set((result.reviewNeeded || []).map(r => r.dsName));
|
|
856
|
+
const allDs = new Set();
|
|
857
|
+
function collectDs(node) {
|
|
858
|
+
if (node._ds)
|
|
859
|
+
allDs.add(node._ds);
|
|
860
|
+
node.children?.forEach(collectDs);
|
|
861
|
+
}
|
|
862
|
+
traversals.forEach(collectDs);
|
|
863
|
+
const unmatchedDs = [];
|
|
864
|
+
for (const ds of allDs) {
|
|
865
|
+
if (!matchedDs.has(ds) && !reviewDs.has(ds))
|
|
866
|
+
unmatchedDs.push(ds);
|
|
867
|
+
}
|
|
738
868
|
const summary = {
|
|
739
869
|
success: true,
|
|
740
870
|
savedTo: outputPath,
|
|
@@ -744,25 +874,177 @@ Saves result to outputPath. Replaces ds-data-lookup sub-agent.`, {
|
|
|
744
874
|
textStyles: Object.keys(result.textStyles || {}).length,
|
|
745
875
|
elements: Object.keys(result.elements || {}).length,
|
|
746
876
|
},
|
|
747
|
-
|
|
877
|
+
reviewNeeded: result.reviewNeeded || [],
|
|
878
|
+
unmatchedDs,
|
|
748
879
|
};
|
|
749
|
-
|
|
750
|
-
|
|
880
|
+
return {
|
|
881
|
+
content: [{
|
|
882
|
+
type: 'text',
|
|
883
|
+
text: JSON.stringify(summary, null, 2),
|
|
884
|
+
}],
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
catch (err) {
|
|
888
|
+
return {
|
|
889
|
+
content: [{
|
|
890
|
+
type: 'text',
|
|
891
|
+
text: JSON.stringify({
|
|
892
|
+
success: false,
|
|
893
|
+
error: err instanceof Error ? err.message : String(err),
|
|
894
|
+
}, null, 2),
|
|
895
|
+
}],
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
// run_pipeline — unified pipeline tool
|
|
900
|
+
server.tool('run_pipeline', `Run the full spec pipeline: generate_mapping → validate → enrich → gate → tree in one call.
|
|
901
|
+
Supports 2-pass pattern: Pass 1 returns reviewNeeded (ambiguous matches). Agent decides, then Pass 2 with resolutions auto-applies decisions.
|
|
902
|
+
If no reviewNeeded, completes in 1 pass.`, {
|
|
903
|
+
traversals: z.array(z.record(z.unknown())).describe('Array of traversal JSON root nodes'),
|
|
904
|
+
dataDir: z.string().describe('Path to blueprint data directory (e.g., .claude/blueprint/data/)'),
|
|
905
|
+
outputPath: z.string().describe('File path to save spec.json (mapping.json saved alongside)'),
|
|
906
|
+
resolutions: z.record(z.string()).optional().describe('Agent-decided mappings: { dsName: resolvedName }. Pass 2 only.'),
|
|
907
|
+
}, async ({ traversals, dataDir, outputPath, resolutions }) => {
|
|
908
|
+
try {
|
|
909
|
+
let tNodes = traversals;
|
|
910
|
+
// Step 0: Apply resolutions — replace _ds in traversal nodes
|
|
911
|
+
if (resolutions && Object.keys(resolutions).length > 0) {
|
|
912
|
+
// Deep clone to avoid mutating input
|
|
913
|
+
tNodes = JSON.parse(JSON.stringify(tNodes));
|
|
914
|
+
const res = resolutions;
|
|
915
|
+
function applyResolutions(node) {
|
|
916
|
+
if (node._ds && res[node._ds]) {
|
|
917
|
+
node._ds = res[node._ds];
|
|
918
|
+
}
|
|
919
|
+
node.children?.forEach(applyResolutions);
|
|
920
|
+
}
|
|
921
|
+
tNodes.forEach(applyResolutions);
|
|
922
|
+
}
|
|
923
|
+
// Step 1: Generate mapping
|
|
924
|
+
const mappingResult = generateMapping(tNodes, dataDir);
|
|
925
|
+
const mappingPath = outputPath.replace(/\.json$/, '') + '_mapping.json';
|
|
926
|
+
mkdirSync(dirname(mappingPath), { recursive: true });
|
|
927
|
+
writeFileSync(mappingPath, JSON.stringify(mappingResult, null, 2));
|
|
928
|
+
// If reviewNeeded exists and no resolutions provided → return early for agent decision
|
|
929
|
+
const review = mappingResult.reviewNeeded || [];
|
|
930
|
+
if (review.length > 0 && (!resolutions || Object.keys(resolutions).length === 0)) {
|
|
931
|
+
// Also report truly unmatched
|
|
932
|
+
const matchedDs = new Set(Object.keys(mappingResult.components || {}));
|
|
933
|
+
const reviewDs = new Set(review.map(r => r.dsName));
|
|
934
|
+
const allDs = new Set();
|
|
935
|
+
function collectAllDs(node) {
|
|
936
|
+
if (node._ds)
|
|
937
|
+
allDs.add(node._ds);
|
|
938
|
+
node.children?.forEach(collectAllDs);
|
|
939
|
+
}
|
|
940
|
+
tNodes.forEach(collectAllDs);
|
|
941
|
+
const unresolved = [];
|
|
942
|
+
for (const ds of allDs) {
|
|
943
|
+
if (!matchedDs.has(ds) && !reviewDs.has(ds))
|
|
944
|
+
unresolved.push(ds);
|
|
945
|
+
}
|
|
946
|
+
return {
|
|
947
|
+
content: [{
|
|
948
|
+
type: 'text',
|
|
949
|
+
text: JSON.stringify({
|
|
950
|
+
success: false,
|
|
951
|
+
phase: 'review',
|
|
952
|
+
message: `${review.length} component(s) need agent review. Provide resolutions and call again.`,
|
|
953
|
+
reviewNeeded: review,
|
|
954
|
+
unresolved,
|
|
955
|
+
mappingSavedTo: mappingPath,
|
|
956
|
+
stats: {
|
|
957
|
+
components: Object.keys(mappingResult.components || {}).length,
|
|
958
|
+
tokens: Object.keys(mappingResult.tokens || {}).length,
|
|
959
|
+
textStyles: Object.keys(mappingResult.textStyles || {}).length,
|
|
960
|
+
},
|
|
961
|
+
}, null, 2),
|
|
962
|
+
}],
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
// Step 2: Validate traversal(s)
|
|
966
|
+
const validationErrors = [];
|
|
967
|
+
const validationWarnings = [];
|
|
968
|
+
for (const t of tNodes) {
|
|
969
|
+
const vResult = validateTraversal(t);
|
|
970
|
+
validationErrors.push(...vResult.errors);
|
|
971
|
+
validationWarnings.push(...vResult.warnings);
|
|
972
|
+
}
|
|
973
|
+
// Step 3: Enrich spec for each traversal
|
|
974
|
+
const specs = [];
|
|
975
|
+
const enrichErrors = [];
|
|
976
|
+
for (const t of tNodes) {
|
|
977
|
+
const eResult = enrichSpec(t, mappingResult);
|
|
978
|
+
if (eResult.errors.length > 0) {
|
|
979
|
+
enrichErrors.push(...eResult.errors);
|
|
980
|
+
}
|
|
981
|
+
specs.push(eResult.spec);
|
|
982
|
+
}
|
|
983
|
+
if (enrichErrors.length > 0) {
|
|
984
|
+
return {
|
|
985
|
+
content: [{
|
|
986
|
+
type: 'text',
|
|
987
|
+
text: JSON.stringify({
|
|
988
|
+
success: false,
|
|
989
|
+
phase: 'enrich',
|
|
990
|
+
errors: enrichErrors,
|
|
991
|
+
validationWarnings,
|
|
992
|
+
}, null, 2),
|
|
993
|
+
}],
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
// Step 4: Gate check each spec
|
|
997
|
+
const gateResults = [];
|
|
998
|
+
for (const spec of specs) {
|
|
999
|
+
gateResults.push(gateCheck(spec));
|
|
1000
|
+
}
|
|
1001
|
+
const allGatesPass = gateResults.every(g => g.pass);
|
|
1002
|
+
// Step 5: Save specs and generate trees
|
|
1003
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
1004
|
+
const trees = [];
|
|
1005
|
+
const allStats = [];
|
|
1006
|
+
if (specs.length === 1) {
|
|
1007
|
+
writeFileSync(outputPath, JSON.stringify(specs[0], null, 2));
|
|
1008
|
+
trees.push(specToTree(specs[0]));
|
|
1009
|
+
allStats.push(countSpecNodes(specs[0]));
|
|
1010
|
+
}
|
|
1011
|
+
else {
|
|
1012
|
+
// Multiple specs → save each with index
|
|
1013
|
+
for (let i = 0; i < specs.length; i++) {
|
|
1014
|
+
const specPath = outputPath.replace(/\.json$/, `_${i}.json`);
|
|
1015
|
+
writeFileSync(specPath, JSON.stringify(specs[i], null, 2));
|
|
1016
|
+
trees.push(specToTree(specs[i]));
|
|
1017
|
+
allStats.push(countSpecNodes(specs[i]));
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
// Collect unresolved
|
|
1021
|
+
const matchedDs = new Set(Object.keys(mappingResult.components || {}));
|
|
751
1022
|
const allDs = new Set();
|
|
752
|
-
function
|
|
1023
|
+
function collectFinalDs(node) {
|
|
753
1024
|
if (node._ds)
|
|
754
1025
|
allDs.add(node._ds);
|
|
755
|
-
node.children?.forEach(
|
|
1026
|
+
node.children?.forEach(collectFinalDs);
|
|
756
1027
|
}
|
|
757
|
-
|
|
1028
|
+
tNodes.forEach(collectFinalDs);
|
|
1029
|
+
const unresolved = [];
|
|
758
1030
|
for (const ds of allDs) {
|
|
759
1031
|
if (!matchedDs.has(ds))
|
|
760
|
-
|
|
1032
|
+
unresolved.push(ds);
|
|
761
1033
|
}
|
|
762
1034
|
return {
|
|
763
1035
|
content: [{
|
|
764
1036
|
type: 'text',
|
|
765
|
-
text: JSON.stringify(
|
|
1037
|
+
text: JSON.stringify({
|
|
1038
|
+
success: allGatesPass,
|
|
1039
|
+
savedTo: outputPath,
|
|
1040
|
+
mappingSavedTo: mappingPath,
|
|
1041
|
+
tree: trees.length === 1 ? trees[0] : trees,
|
|
1042
|
+
stats: allStats.length === 1 ? allStats[0] : allStats,
|
|
1043
|
+
gateResult: gateResults.length === 1 ? gateResults[0] : gateResults,
|
|
1044
|
+
validationWarnings: validationWarnings.length > 0 ? validationWarnings : undefined,
|
|
1045
|
+
unresolved: unresolved.length > 0 ? unresolved : undefined,
|
|
1046
|
+
reviewNeeded: review.length > 0 ? review : undefined,
|
|
1047
|
+
}, null, 2),
|
|
766
1048
|
}],
|
|
767
1049
|
};
|
|
768
1050
|
}
|
|
@@ -772,6 +1054,7 @@ Saves result to outputPath. Replaces ds-data-lookup sub-agent.`, {
|
|
|
772
1054
|
type: 'text',
|
|
773
1055
|
text: JSON.stringify({
|
|
774
1056
|
success: false,
|
|
1057
|
+
phase: 'error',
|
|
775
1058
|
error: err instanceof Error ? err.message : String(err),
|
|
776
1059
|
}, null, 2),
|
|
777
1060
|
}],
|
package/package.json
CHANGED