@monoharada/wcf-mcp 0.6.0 → 0.8.0

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.
@@ -0,0 +1,685 @@
1
+ /**
2
+ * check_drift plugin tool for wcf-mcp.
3
+ * Checks consistency across 4 data sources (CEM, install-registry,
4
+ * skills-registry, pattern-registry) and detects drift (divergence).
5
+ * Phase 1: JSON comparison only (no SKILL.md content analysis).
6
+ * Plugin Contract v1.0+
7
+ */
8
+
9
+ import { access } from 'node:fs/promises';
10
+ import { resolve } from 'node:path';
11
+ import { REPO_ROOT, loadRegistry } from './shared.mjs';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Helper utilities
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /**
18
+ * Simple string hash for generating drift IDs.
19
+ * @param {string} str
20
+ * @returns {string} 8-char hex string
21
+ */
22
+ function hashId(str) {
23
+ let hash = 0;
24
+ for (let i = 0; i < str.length; i++) {
25
+ const char = str.charCodeAt(i);
26
+ hash = ((hash << 5) - hash) + char;
27
+ hash |= 0;
28
+ }
29
+ return Math.abs(hash).toString(16).padStart(8, '0').slice(0, 8);
30
+ }
31
+
32
+ /**
33
+ * Generate a unique drift ID from rule + detail string.
34
+ * @param {string} ruleId
35
+ * @param {string} detail
36
+ * @returns {string}
37
+ */
38
+ function driftId(ruleId, detail) {
39
+ return `DRIFT-${ruleId}-${hashId(detail)}`;
40
+ }
41
+
42
+ /**
43
+ * Create a drift report entry.
44
+ * @param {string} ruleId
45
+ * @param {string} severity HIGH | MEDIUM | LOW
46
+ * @param {string} source Data source name
47
+ * @param {string} target Target data source name
48
+ * @param {string} message Human-readable description
49
+ * @param {object} [details] Additional context
50
+ * @returns {object}
51
+ */
52
+ function createDrift(ruleId, severity, source, target, message, details = {}) {
53
+ return {
54
+ id: driftId(ruleId, message),
55
+ ruleId,
56
+ severity,
57
+ source,
58
+ target,
59
+ message,
60
+ details,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Create a suggestion entry tied to a drift.
66
+ * @param {string} id The drift ID this suggestion relates to
67
+ * @param {string} action add | remove | update | document | investigate
68
+ * @param {string} description Human-readable suggestion
69
+ * @param {string} target File or data source to act on
70
+ * @param {string} [priority] recommended | optional
71
+ * @returns {object}
72
+ */
73
+ function createSuggestion(id, action, description, target, priority = 'recommended') {
74
+ return { driftId: id, action, description, target, priority };
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Data extraction helpers
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * Extract custom-element-definition tags from CEM.
83
+ * @param {object|null} cem
84
+ * @returns {Map<string, string>} tagName -> modulePath
85
+ */
86
+ function extractCemTags(cem) {
87
+ const tags = new Map();
88
+ if (!cem?.modules) return tags;
89
+ for (const mod of cem.modules) {
90
+ if (!Array.isArray(mod.exports)) continue;
91
+ for (const exp of mod.exports) {
92
+ if (exp.kind === 'custom-element-definition' && exp.name) {
93
+ tags.set(exp.name, mod.path);
94
+ }
95
+ }
96
+ }
97
+ return tags;
98
+ }
99
+
100
+ /**
101
+ * Extract dads-* tag names from an HTML string.
102
+ * @param {string} html
103
+ * @returns {string[]} Unique sorted tag names
104
+ */
105
+ function extractDadsTags(html) {
106
+ const matches = [...String(html).matchAll(/<(dads-[a-z][a-z0-9-]*)/g)];
107
+ return [...new Set(matches.map((m) => m[1]))].sort();
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Rule implementations
112
+ // Each returns { drifts: DriftReport[], suggestions: DriftSuggestion[] }
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /**
116
+ * CIR01 - CEM component missing from install-registry.
117
+ * For each CEM dads-* tag, verify it exists in install-registry tags.
118
+ */
119
+ function ruleCIR01(cemTags, irTags) {
120
+ const drifts = [];
121
+ const suggestions = [];
122
+ for (const [tag] of cemTags) {
123
+ if (!tag.startsWith('dads-')) continue;
124
+ if (!(tag in irTags)) {
125
+ const d = createDrift(
126
+ 'CIR01',
127
+ 'HIGH',
128
+ 'custom-elements.json',
129
+ 'install-registry.json',
130
+ `CEM tag "${tag}" is missing from install-registry tags`,
131
+ { tag },
132
+ );
133
+ drifts.push(d);
134
+ suggestions.push(
135
+ createSuggestion(d.id, 'add', `Add "${tag}" to install-registry tags section`, 'registry/install-registry.json'),
136
+ );
137
+ }
138
+ }
139
+ return { drifts, suggestions };
140
+ }
141
+
142
+ /**
143
+ * CIR02 - Non-standard tags in CEM.
144
+ * CEM exports with kind=custom-element-definition where name does NOT match dads-*.
145
+ */
146
+ function ruleCIR02(cemTags) {
147
+ const drifts = [];
148
+ const suggestions = [];
149
+ for (const [tag, modulePath] of cemTags) {
150
+ if (!tag.startsWith('dads-')) {
151
+ const d = createDrift(
152
+ 'CIR02',
153
+ 'LOW',
154
+ 'custom-elements.json',
155
+ 'custom-elements.json',
156
+ `CEM tag "${tag}" does not follow the dads-* naming convention`,
157
+ { tag, modulePath },
158
+ );
159
+ drifts.push(d);
160
+ suggestions.push(
161
+ createSuggestion(d.id, 'investigate', `Verify if "${tag}" should follow the dads-* naming convention`, 'custom-elements.json', 'optional'),
162
+ );
163
+ }
164
+ }
165
+ return { drifts, suggestions };
166
+ }
167
+
168
+ /**
169
+ * CIT01 - install-registry tag missing from CEM.
170
+ * For each install-registry tags key, verify it exists in CEM exports.
171
+ */
172
+ function ruleCIT01(cemTags, irTags) {
173
+ const drifts = [];
174
+ const suggestions = [];
175
+ for (const tag of Object.keys(irTags)) {
176
+ if (!cemTags.has(tag)) {
177
+ const d = createDrift(
178
+ 'CIT01',
179
+ 'HIGH',
180
+ 'install-registry.json',
181
+ 'custom-elements.json',
182
+ `install-registry tag "${tag}" is not defined in CEM`,
183
+ { tag, componentId: irTags[tag] },
184
+ );
185
+ drifts.push(d);
186
+ suggestions.push(
187
+ createSuggestion(d.id, 'remove', `Remove "${tag}" from install-registry or add its CEM definition`, 'registry/install-registry.json'),
188
+ );
189
+ }
190
+ }
191
+ return { drifts, suggestions };
192
+ }
193
+
194
+ /**
195
+ * CIT02 - install-registry component tags mismatch with CEM.
196
+ * For each install-registry component, compare its tags array with CEM-derived
197
+ * tags for that component (by checking which CEM tags map to install-registry component IDs).
198
+ */
199
+ function ruleCIT02(cemTags, irTags, irComponents) {
200
+ const drifts = [];
201
+ const suggestions = [];
202
+
203
+ // Build reverse map: componentId -> Set<tag> from irTags
204
+ const tagsByComponent = {};
205
+ for (const [tag, compId] of Object.entries(irTags)) {
206
+ if (!tagsByComponent[compId]) tagsByComponent[compId] = new Set();
207
+ tagsByComponent[compId].add(tag);
208
+ }
209
+
210
+ for (const [compId, comp] of Object.entries(irComponents)) {
211
+ if (!Array.isArray(comp.tags)) continue;
212
+ const declaredTags = new Set(comp.tags);
213
+ const registeredTags = tagsByComponent[compId] ?? new Set();
214
+
215
+ // Check tags in component.tags that are not in the tags section
216
+ for (const tag of declaredTags) {
217
+ if (!registeredTags.has(tag)) {
218
+ const d = createDrift(
219
+ 'CIT02',
220
+ 'MEDIUM',
221
+ 'install-registry.json',
222
+ 'install-registry.json',
223
+ `Component "${compId}" declares tag "${tag}" but it is not in the tags section`,
224
+ { componentId: compId, tag },
225
+ );
226
+ drifts.push(d);
227
+ suggestions.push(
228
+ createSuggestion(d.id, 'update', `Add "${tag}" to install-registry tags section mapping to "${compId}"`, 'registry/install-registry.json'),
229
+ );
230
+ }
231
+ }
232
+
233
+ // Check tags in tags section that are not in component.tags
234
+ for (const tag of registeredTags) {
235
+ if (!declaredTags.has(tag)) {
236
+ const d = createDrift(
237
+ 'CIT02',
238
+ 'MEDIUM',
239
+ 'install-registry.json',
240
+ 'install-registry.json',
241
+ `Tag "${tag}" maps to "${compId}" in tags section but is not in component.tags`,
242
+ { componentId: compId, tag },
243
+ );
244
+ drifts.push(d);
245
+ suggestions.push(
246
+ createSuggestion(d.id, 'update', `Add "${tag}" to component "${compId}" tags array`, 'registry/install-registry.json'),
247
+ );
248
+ }
249
+ }
250
+ }
251
+ return { drifts, suggestions };
252
+ }
253
+
254
+ /**
255
+ * IRD01 - Broken internal dependency in install-registry.
256
+ * For each component's deps[], verify dep ID exists in components.
257
+ */
258
+ function ruleIRD01(irComponents) {
259
+ const drifts = [];
260
+ const suggestions = [];
261
+ const componentIds = new Set(Object.keys(irComponents));
262
+
263
+ for (const [compId, comp] of Object.entries(irComponents)) {
264
+ if (!Array.isArray(comp.deps)) continue;
265
+ for (const dep of comp.deps) {
266
+ if (!componentIds.has(dep)) {
267
+ const d = createDrift(
268
+ 'IRD01',
269
+ 'HIGH',
270
+ 'install-registry.json',
271
+ 'install-registry.json',
272
+ `Component "${compId}" depends on "${dep}" which does not exist in components`,
273
+ { componentId: compId, dependency: dep },
274
+ );
275
+ drifts.push(d);
276
+ suggestions.push(
277
+ createSuggestion(d.id, 'update', `Fix dependency "${dep}" in component "${compId}" or add the missing component`, 'registry/install-registry.json'),
278
+ );
279
+ }
280
+ }
281
+ }
282
+ return { drifts, suggestions };
283
+ }
284
+
285
+ /**
286
+ * IRT01 - install-registry tags/components inconsistency.
287
+ * tags keys must map to valid component IDs, and component tags must be in tags section.
288
+ */
289
+ function ruleIRT01(irTags, irComponents) {
290
+ const drifts = [];
291
+ const suggestions = [];
292
+ const componentIds = new Set(Object.keys(irComponents));
293
+
294
+ // Check tags section: each value must be a valid component ID
295
+ for (const [tag, compId] of Object.entries(irTags)) {
296
+ if (!componentIds.has(compId)) {
297
+ const d = createDrift(
298
+ 'IRT01',
299
+ 'HIGH',
300
+ 'install-registry.json',
301
+ 'install-registry.json',
302
+ `Tag "${tag}" maps to component "${compId}" which does not exist`,
303
+ { tag, componentId: compId },
304
+ );
305
+ drifts.push(d);
306
+ suggestions.push(
307
+ createSuggestion(d.id, 'update', `Fix tag "${tag}" mapping or add component "${compId}"`, 'registry/install-registry.json'),
308
+ );
309
+ }
310
+ }
311
+
312
+ // Check components section: each tag must be in tags section
313
+ for (const [compId, comp] of Object.entries(irComponents)) {
314
+ if (!Array.isArray(comp.tags)) continue;
315
+ for (const tag of comp.tags) {
316
+ if (!(tag in irTags)) {
317
+ const d = createDrift(
318
+ 'IRT01',
319
+ 'HIGH',
320
+ 'install-registry.json',
321
+ 'install-registry.json',
322
+ `Component "${compId}" tag "${tag}" is missing from tags section`,
323
+ { componentId: compId, tag },
324
+ );
325
+ drifts.push(d);
326
+ suggestions.push(
327
+ createSuggestion(d.id, 'add', `Add "${tag}": "${compId}" to install-registry tags section`, 'registry/install-registry.json'),
328
+ );
329
+ }
330
+ }
331
+ }
332
+ return { drifts, suggestions };
333
+ }
334
+
335
+ /**
336
+ * CPR01 - pattern requires references missing component.
337
+ * pattern.requires[] components must exist in install-registry components.
338
+ */
339
+ function ruleCPR01(patterns, irComponents) {
340
+ const drifts = [];
341
+ const suggestions = [];
342
+ const componentIds = new Set(Object.keys(irComponents));
343
+
344
+ for (const [patId, pat] of Object.entries(patterns)) {
345
+ if (!Array.isArray(pat.requires)) continue;
346
+ for (const req of pat.requires) {
347
+ if (!componentIds.has(req)) {
348
+ const d = createDrift(
349
+ 'CPR01',
350
+ 'HIGH',
351
+ 'pattern-registry.json',
352
+ 'install-registry.json',
353
+ `Pattern "${patId}" requires component "${req}" which is not in install-registry`,
354
+ { patternId: patId, requirement: req },
355
+ );
356
+ drifts.push(d);
357
+ suggestions.push(
358
+ createSuggestion(d.id, 'add', `Add component "${req}" to install-registry or remove it from pattern "${patId}" requires`, 'registry/install-registry.json'),
359
+ );
360
+ }
361
+ }
362
+ }
363
+ return { drifts, suggestions };
364
+ }
365
+
366
+ /**
367
+ * CPT01 - pattern HTML contains unknown CEM tags.
368
+ * Extract dads-* tags from pattern HTML, verify each exists in CEM.
369
+ */
370
+ function ruleCPT01(patterns, cemTags) {
371
+ const drifts = [];
372
+ const suggestions = [];
373
+
374
+ for (const [patId, pat] of Object.entries(patterns)) {
375
+ if (!pat.html) continue;
376
+ const tags = extractDadsTags(pat.html);
377
+ for (const tag of tags) {
378
+ if (!cemTags.has(tag)) {
379
+ const d = createDrift(
380
+ 'CPT01',
381
+ 'HIGH',
382
+ 'pattern-registry.json',
383
+ 'custom-elements.json',
384
+ `Pattern "${patId}" HTML uses tag "${tag}" which is not defined in CEM`,
385
+ { patternId: patId, tag },
386
+ );
387
+ drifts.push(d);
388
+ suggestions.push(
389
+ createSuggestion(d.id, 'investigate', `Verify tag "${tag}" in pattern "${patId}" HTML or add its CEM definition`, 'registry/pattern-registry.json'),
390
+ );
391
+ }
392
+ }
393
+ }
394
+ return { drifts, suggestions };
395
+ }
396
+
397
+ /**
398
+ * CPT02 - pattern HTML uses tags not declared in requires.
399
+ * dads-* tags in pattern HTML -> reverse lookup to component ID -> must be in pattern.requires.
400
+ */
401
+ function ruleCPT02(patterns, irTags) {
402
+ const drifts = [];
403
+ const suggestions = [];
404
+
405
+ for (const [patId, pat] of Object.entries(patterns)) {
406
+ if (!pat.html || !Array.isArray(pat.requires)) continue;
407
+ const tags = extractDadsTags(pat.html);
408
+ const requiresSet = new Set(pat.requires);
409
+
410
+ for (const tag of tags) {
411
+ const compId = irTags[tag];
412
+ if (compId && !requiresSet.has(compId)) {
413
+ const d = createDrift(
414
+ 'CPT02',
415
+ 'MEDIUM',
416
+ 'pattern-registry.json',
417
+ 'pattern-registry.json',
418
+ `Pattern "${patId}" HTML uses tag "${tag}" (component "${compId}") but "${compId}" is not in requires`,
419
+ { patternId: patId, tag, componentId: compId },
420
+ );
421
+ drifts.push(d);
422
+ suggestions.push(
423
+ createSuggestion(d.id, 'add', `Add "${compId}" to pattern "${patId}" requires array`, 'registry/pattern-registry.json'),
424
+ );
425
+ }
426
+ }
427
+ }
428
+ return { drifts, suggestions };
429
+ }
430
+
431
+ /**
432
+ * CPC01 - Pattern coverage of components.
433
+ * Count how many install-registry components are referenced by at least one pattern's requires.
434
+ */
435
+ function ruleCPC01(patterns, irComponents) {
436
+ const drifts = [];
437
+ const suggestions = [];
438
+ const componentIds = Object.keys(irComponents);
439
+ if (componentIds.length === 0) return { drifts, suggestions };
440
+
441
+ // Collect all component IDs referenced by any pattern
442
+ const coveredComponents = new Set();
443
+ for (const pat of Object.values(patterns)) {
444
+ if (!Array.isArray(pat.requires)) continue;
445
+ for (const req of pat.requires) {
446
+ coveredComponents.add(req);
447
+ }
448
+ }
449
+
450
+ const totalComponents = componentIds.length;
451
+ const uncoveredIds = [];
452
+ for (const id of componentIds) {
453
+ if (!coveredComponents.has(id)) uncoveredIds.push(id);
454
+ }
455
+ const coveredCount = totalComponents - uncoveredIds.length;
456
+ const coveragePercent = Math.round((coveredCount / totalComponents) * 100);
457
+ const severity = coveragePercent < 50 ? 'MEDIUM' : 'LOW';
458
+
459
+ if (uncoveredIds.length > 0) {
460
+ const d = createDrift(
461
+ 'CPC01',
462
+ severity,
463
+ 'pattern-registry.json',
464
+ 'install-registry.json',
465
+ `Pattern coverage: ${coveredCount}/${totalComponents} components (${coveragePercent}%). ${uncoveredIds.length} components not used in any pattern.`,
466
+ {
467
+ coverage: coveragePercent,
468
+ coveredCount,
469
+ totalComponents,
470
+ uncoveredComponents: uncoveredIds,
471
+ },
472
+ );
473
+ drifts.push(d);
474
+ suggestions.push(
475
+ createSuggestion(d.id, 'document', `Consider adding patterns for uncovered components: ${uncoveredIds.slice(0, 5).join(', ')}${uncoveredIds.length > 5 ? '...' : ''}`, 'registry/pattern-registry.json', 'optional'),
476
+ );
477
+ }
478
+ return { drifts, suggestions };
479
+ }
480
+
481
+ /**
482
+ * SIR01 - skills-registry path existence.
483
+ * For each skill, verify skill.path + '/' + skill.entry file exists on disk.
484
+ */
485
+ async function ruleSIR01(skillsRegistry) {
486
+ const drifts = [];
487
+ const suggestions = [];
488
+ if (!skillsRegistry?.skills) return { drifts, suggestions };
489
+
490
+ for (const skill of skillsRegistry.skills) {
491
+ const entry = skill.entry ?? 'SKILL.md';
492
+ const fullPath = resolve(REPO_ROOT, skill.path, entry);
493
+ try {
494
+ await access(fullPath);
495
+ } catch {
496
+ const d = createDrift(
497
+ 'SIR01',
498
+ 'HIGH',
499
+ 'skills-registry.json',
500
+ 'filesystem',
501
+ `Skill "${skill.name}" entry file not found at "${skill.path}/${entry}"`,
502
+ { skillName: skill.name, path: skill.path, entry, expected: fullPath },
503
+ );
504
+ drifts.push(d);
505
+ suggestions.push(
506
+ createSuggestion(d.id, 'add', `Create "${entry}" at "${skill.path}/" or update the skill registry path`, 'registry/skills-registry.json'),
507
+ );
508
+ }
509
+ }
510
+ return { drifts, suggestions };
511
+ }
512
+
513
+ /**
514
+ * SID01 - Skills dependency existence (v2 only).
515
+ * Each skill.dependencies[] name must exist as another skill.name in the registry.
516
+ */
517
+ function ruleSID01(skillsRegistry) {
518
+ const drifts = [];
519
+ const suggestions = [];
520
+ if (!skillsRegistry?.skills) return { drifts, suggestions };
521
+
522
+ const skillNames = new Set(skillsRegistry.skills.map((s) => s.name));
523
+
524
+ for (const skill of skillsRegistry.skills) {
525
+ if (!Array.isArray(skill.dependencies)) continue;
526
+ for (const dep of skill.dependencies) {
527
+ const depName = typeof dep === 'string' ? dep : dep?.name;
528
+ if (depName && !skillNames.has(depName)) {
529
+ const d = createDrift(
530
+ 'SID01',
531
+ 'HIGH',
532
+ 'skills-registry.json',
533
+ 'skills-registry.json',
534
+ `Skill "${skill.name}" depends on "${depName}" which does not exist in the registry`,
535
+ { skillName: skill.name, dependency: depName },
536
+ );
537
+ drifts.push(d);
538
+ suggestions.push(
539
+ createSuggestion(d.id, 'add', `Add skill "${depName}" to the registry or remove the dependency from "${skill.name}"`, 'registry/skills-registry.json'),
540
+ );
541
+ }
542
+ }
543
+ }
544
+ return { drifts, suggestions };
545
+ }
546
+
547
+ // ---------------------------------------------------------------------------
548
+ // Scope mapping
549
+ // ---------------------------------------------------------------------------
550
+
551
+ const SCOPE_RULES = {
552
+ all: ['CIR01', 'CIR02', 'CIT01', 'CIT02', 'IRD01', 'IRT01', 'CPR01', 'CPT01', 'CPT02', 'CPC01', 'SIR01', 'SID01'],
553
+ cem: ['CIR01', 'CIR02', 'CIT01', 'CIT02', 'IRD01', 'IRT01'],
554
+ skills: ['SIR01', 'SID01'],
555
+ tokens: [], // Reserved for future Phase 2
556
+ patterns: ['CPR01', 'CPT01', 'CPT02', 'CPC01'],
557
+ };
558
+
559
+ // ---------------------------------------------------------------------------
560
+ // Main handler
561
+ // ---------------------------------------------------------------------------
562
+
563
+ /**
564
+ * @param {object} args
565
+ * @param {{ helpers: { loadJsonData: Function, buildJsonToolResponse: Function } }} ctx
566
+ */
567
+ async function checkDriftHandler(args, { helpers }) {
568
+ const scope = args?.scope ?? 'all';
569
+ const activeRules = new Set(SCOPE_RULES[scope] ?? SCOPE_RULES.all);
570
+ const drifts = [];
571
+ const suggestions = [];
572
+ const rulesExecuted = [];
573
+
574
+ // -----------------------------------------------------------------------
575
+ // Load data sources
576
+ // -----------------------------------------------------------------------
577
+ const [cem, ir, pr, sr] = await Promise.all([
578
+ helpers.loadJsonData('custom-elements.json'),
579
+ helpers.loadJsonData('install-registry.json'),
580
+ helpers.loadJsonData('pattern-registry.json'),
581
+ loadRegistry(),
582
+ ]);
583
+
584
+ // -----------------------------------------------------------------------
585
+ // Extract commonly used data
586
+ // -----------------------------------------------------------------------
587
+ const cemTags = extractCemTags(cem);
588
+ const irTags = ir?.tags ?? {};
589
+ const irComponents = ir?.components ?? {};
590
+ const patterns = pr?.patterns ?? {};
591
+
592
+ // -----------------------------------------------------------------------
593
+ // Execute rules based on scope
594
+ // -----------------------------------------------------------------------
595
+
596
+ /** Collect results from a synchronous rule function */
597
+ function runSync(ruleId, fn) {
598
+ if (!activeRules.has(ruleId)) return;
599
+ rulesExecuted.push(ruleId);
600
+ const result = fn();
601
+ drifts.push(...result.drifts);
602
+ suggestions.push(...result.suggestions);
603
+ }
604
+
605
+ /** Collect results from an async rule function */
606
+ async function runAsync(ruleId, fn) {
607
+ if (!activeRules.has(ruleId)) return;
608
+ rulesExecuted.push(ruleId);
609
+ const result = await fn();
610
+ drifts.push(...result.drifts);
611
+ suggestions.push(...result.suggestions);
612
+ }
613
+
614
+ // CEM <-> install-registry rules
615
+ runSync('CIR01', () => ruleCIR01(cemTags, irTags));
616
+ runSync('CIR02', () => ruleCIR02(cemTags));
617
+ runSync('CIT01', () => ruleCIT01(cemTags, irTags));
618
+ runSync('CIT02', () => ruleCIT02(cemTags, irTags, irComponents));
619
+ runSync('IRD01', () => ruleIRD01(irComponents));
620
+ runSync('IRT01', () => ruleIRT01(irTags, irComponents));
621
+
622
+ // Pattern rules
623
+ runSync('CPR01', () => ruleCPR01(patterns, irComponents));
624
+ runSync('CPT01', () => ruleCPT01(patterns, cemTags));
625
+ runSync('CPT02', () => ruleCPT02(patterns, irTags));
626
+ runSync('CPC01', () => ruleCPC01(patterns, irComponents));
627
+
628
+ // Skills rules — require skills-registry for scopes that include skill rules
629
+ const hasSkillRules = ['SIR01', 'SID01'].some((id) => activeRules.has(id));
630
+ if (hasSkillRules && !sr) {
631
+ const d = createDrift(
632
+ 'SIR01',
633
+ 'HIGH',
634
+ 'skills-registry.json',
635
+ 'filesystem',
636
+ 'skills-registry.json is missing or corrupted — all skill rules skipped',
637
+ {},
638
+ );
639
+ drifts.push(d);
640
+ suggestions.push(
641
+ createSuggestion(d.id, 'add', 'Create or restore registry/skills-registry.json', 'registry/skills-registry.json'),
642
+ );
643
+ rulesExecuted.push(...['SIR01', 'SID01'].filter((id) => activeRules.has(id)));
644
+ } else {
645
+ await runAsync('SIR01', () => ruleSIR01(sr));
646
+ runSync('SID01', () => ruleSID01(sr));
647
+ }
648
+
649
+ // -----------------------------------------------------------------------
650
+ // Build output
651
+ // -----------------------------------------------------------------------
652
+ const summary = { total: drifts.length, high: 0, medium: 0, low: 0, ignored: 0 };
653
+ for (const d of drifts) {
654
+ if (d.severity === 'HIGH') summary.high++;
655
+ else if (d.severity === 'MEDIUM') summary.medium++;
656
+ else if (d.severity === 'LOW') summary.low++;
657
+ }
658
+
659
+ const meta = {
660
+ phase: 1,
661
+ scope,
662
+ rulesExecuted,
663
+ timestamp: new Date().toISOString(),
664
+ };
665
+
666
+ return helpers.buildJsonToolResponse({ drifts, suggestions, summary, meta });
667
+ }
668
+
669
+ // ---------------------------------------------------------------------------
670
+ // Plugin export
671
+ // ---------------------------------------------------------------------------
672
+
673
+ export default {
674
+ name: 'design-system-skills-drift',
675
+ version: '1.0.0',
676
+ tools: [
677
+ {
678
+ name: 'check_drift',
679
+ description:
680
+ 'Check consistency across CEM, install-registry, skills-registry, and pattern-registry. Detects drift (divergence) between data sources. When: before PR, after registry updates, periodic audits. Returns: {drifts[], suggestions[], summary{total,high,medium,low,ignored}, meta{phase,scope,rulesExecuted,timestamp}}. Args: scope? (all|cem|skills|tokens|patterns, default: all). Phase 1: 12 JSON comparison rules.',
681
+ inputSchema: {},
682
+ handler: checkDriftHandler,
683
+ },
684
+ ],
685
+ };