@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.
- package/README.md +30 -2
- package/core.mjs +125 -10
- package/package.json +2 -1
- package/plugins/design-system-skills/check-drift.mjs +685 -0
- package/plugins/design-system-skills/get-skill-manifest.mjs +193 -0
- package/plugins/design-system-skills/index.mjs +20 -0
- package/plugins/design-system-skills/list-skills.mjs +78 -0
- package/plugins/design-system-skills/shared.mjs +75 -0
- package/validator.mjs +54 -25
- package/wcf-mcp.config.example.json +3 -0
|
@@ -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
|
+
};
|