@monoharada/wcf-mcp 0.1.1 → 0.2.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/validator.mjs CHANGED
@@ -3,6 +3,8 @@
3
3
  * Only includes the two functions used by the MCP server:
4
4
  * - collectCemCustomElements
5
5
  * - validateTextAgainstCem
6
+ * - detectTokenMisuseInInlineStyles
7
+ * - detectAccessibilityMisuseInMarkup
6
8
  */
7
9
 
8
10
  const GLOBAL_ATTR_ALLOW_PREFIXES = Object.freeze(['aria-', 'data-']);
@@ -25,6 +27,15 @@ const GLOBAL_ATTR_ALLOW_SET = Object.freeze(
25
27
  );
26
28
 
27
29
  const FORBIDDEN_ATTR_SET = Object.freeze(new Set(['placeholder']));
30
+ const TOKEN_MISUSE_STYLE_PROPS = Object.freeze(new Set([
31
+ 'color',
32
+ 'background-color',
33
+ 'padding',
34
+ 'padding-top',
35
+ 'padding-right',
36
+ 'padding-bottom',
37
+ 'padding-left',
38
+ ]));
28
39
 
29
40
  function isForbiddenAttr(attrName) {
30
41
  return FORBIDDEN_ATTR_SET.has(attrName.toLowerCase());
@@ -80,6 +91,130 @@ export function collectCemCustomElements(manifest) {
80
91
  return byTag;
81
92
  }
82
93
 
94
+ /**
95
+ * Parse a CEM type.text union like "'solid' | 'outlined' | 'text'" into a Set of valid values.
96
+ * Only handles string literal unions. Returns undefined for non-enum types.
97
+ * @param {string} typeText
98
+ * @returns {Set<string> | undefined}
99
+ */
100
+ function parseEnumTypeText(typeText) {
101
+ if (typeof typeText !== 'string' || !typeText) return undefined;
102
+ // Must contain at least one single-quoted value
103
+ const literals = typeText.match(/'([^']*)'/g);
104
+ if (!literals || literals.length === 0) return undefined;
105
+ // All parts separated by | must be quoted literals (allow whitespace)
106
+ const parts = typeText.split('|').map((s) => s.trim());
107
+ for (const part of parts) {
108
+ if (!/^'[^']*'$/.test(part)) return undefined;
109
+ }
110
+ const values = new Set();
111
+ for (const lit of literals) {
112
+ values.add(lit.slice(1, -1));
113
+ }
114
+ return values.size > 0 ? values : undefined;
115
+ }
116
+
117
+ /**
118
+ * Build a map of enum attributes from the CEM manifest.
119
+ * Returns Map<tagName, Map<attrName, Set<validValues>>>
120
+ * @param {object} manifest
121
+ * @returns {Map<string, Map<string, Set<string>>>}
122
+ */
123
+ export function buildEnumAttributeMap(manifest) {
124
+ const result = new Map();
125
+
126
+ const modules = Array.isArray(manifest?.modules) ? manifest.modules : [];
127
+ for (const mod of modules) {
128
+ const declarations = Array.isArray(mod?.declarations) ? mod.declarations : [];
129
+ for (const decl of declarations) {
130
+ const tagName = decl?.tagName;
131
+ const isCustomElement = decl?.customElement === true || decl?.kind === 'custom-element';
132
+ if (!isCustomElement || typeof tagName !== 'string' || !tagName) continue;
133
+ const tag = tagName.toLowerCase();
134
+
135
+ const attrEnums = new Map();
136
+ const declAttrs = Array.isArray(decl?.attributes) ? decl.attributes : [];
137
+ for (const a of declAttrs) {
138
+ if (typeof a?.name !== 'string' || !a.name) continue;
139
+ const typeText = a?.type?.text;
140
+ const enumValues = parseEnumTypeText(typeText);
141
+ if (enumValues) {
142
+ attrEnums.set(a.name.toLowerCase(), enumValues);
143
+ }
144
+ }
145
+
146
+ if (attrEnums.size > 0) {
147
+ result.set(tag, attrEnums);
148
+ }
149
+ }
150
+ }
151
+
152
+ return result;
153
+ }
154
+
155
+ /**
156
+ * Detect enum value misuse in HTML markup.
157
+ * @param {{
158
+ * filePath?: string;
159
+ * text: string;
160
+ * enumMap: Map<string, Map<string, Set<string>>>;
161
+ * severity?: string;
162
+ * }} params
163
+ */
164
+ export function detectEnumValueMisuse({
165
+ filePath = '<input>',
166
+ text,
167
+ enumMap,
168
+ severity = 'error',
169
+ }) {
170
+ const diagnostics = [];
171
+ if (!(enumMap instanceof Map) || enumMap.size === 0) return diagnostics;
172
+
173
+ const lineStarts = computeLineIndex(text);
174
+ const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
175
+ let m;
176
+
177
+ while ((m = tagRe.exec(text))) {
178
+ const tag = String(m[1] ?? '').toLowerCase();
179
+ if (!tag.includes('-')) continue;
180
+
181
+ const attrEnums = enumMap.get(tag);
182
+ if (!attrEnums) continue;
183
+
184
+ const attrChunk = String(m[2] ?? '');
185
+ const rawAttrsStart = m.index + 1 + tag.length;
186
+ const attrs = parseAttributes(attrChunk);
187
+
188
+ for (const { name, offset, value } of attrs) {
189
+ const attrName = name.toLowerCase();
190
+ const validValues = attrEnums.get(attrName);
191
+ if (!validValues) continue;
192
+
193
+ // Skip empty values (boolean-style attributes)
194
+ if (value === undefined || value === '') continue;
195
+
196
+ if (!validValues.has(value)) {
197
+ const startIndex = rawAttrsStart + offset;
198
+ const endIndex = startIndex + name.length;
199
+ const range = makeRange(lineStarts, startIndex, endIndex);
200
+ const validList = [...validValues].map((v) => `'${v}'`).join(' | ');
201
+ diagnostics.push({
202
+ file: filePath,
203
+ range,
204
+ severity,
205
+ code: 'invalidEnumValue',
206
+ message: `Invalid value "${value}" for attribute "${attrName}" on <${tag}>. Valid values: ${validList}`,
207
+ tagName: tag,
208
+ attrName,
209
+ hint: `Use one of: ${validList}`,
210
+ });
211
+ }
212
+ }
213
+ }
214
+
215
+ return diagnostics;
216
+ }
217
+
83
218
  function shouldSkipAttr(attrName) {
84
219
  const name = attrName.toLowerCase();
85
220
  if (GLOBAL_ATTR_ALLOW_SET.has(name)) return true;
@@ -96,7 +231,29 @@ function makeRange(lineStarts, startIndex, endIndex) {
96
231
  return { start, end };
97
232
  }
98
233
 
234
+ function normalizeStyleValue(value) {
235
+ return String(value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
236
+ }
237
+
238
+ function parseInlineStyleAttribute(attrChunk) {
239
+ const styleMatch = /\bstyle\s*=\s*("([^"]*)"|'([^']*)')/i.exec(attrChunk);
240
+ if (!styleMatch) return undefined;
241
+
242
+ const quoted = styleMatch[1] ?? '';
243
+ const styleValue = styleMatch[2] ?? styleMatch[3] ?? '';
244
+ if (!styleValue) return undefined;
245
+
246
+ return {
247
+ styleValue,
248
+ styleValueOffsetInAttr: styleMatch.index + quoted.indexOf(styleValue),
249
+ };
250
+ }
251
+
99
252
  function parseAttributeNames(rawAttrs) {
253
+ return parseAttributes(rawAttrs).map(({ name, offset }) => ({ name, offset }));
254
+ }
255
+
256
+ function parseAttributes(rawAttrs) {
100
257
  /** @type {{ name: string, offset: number }[]} */
101
258
  const out = [];
102
259
 
@@ -121,9 +278,7 @@ function parseAttributeNames(rawAttrs) {
121
278
  }
122
279
 
123
280
  const name = rawAttrs.slice(nameStart, i);
124
- if (name && !name.startsWith('${') && !name.includes('{') && !name.includes('}')) {
125
- out.push({ name, offset: nameStart });
126
- }
281
+ let value = '';
127
282
 
128
283
  while (i < len && isSpace(rawAttrs.charCodeAt(i))) i += 1;
129
284
 
@@ -135,16 +290,24 @@ function parseAttributeNames(rawAttrs) {
135
290
  const quote = rawAttrs[i];
136
291
  if (quote === '"' || quote === "'") {
137
292
  i += 1;
293
+ const valueStart = i;
138
294
  while (i < len && rawAttrs[i] !== quote) i += 1;
295
+ value = rawAttrs.slice(valueStart, i);
139
296
  if (i < len) i += 1;
140
297
  } else {
298
+ const valueStart = i;
141
299
  while (i < len) {
142
300
  const cc = rawAttrs[i];
143
301
  if (cc === '>' || cc === '/' || isSpace(rawAttrs.charCodeAt(i))) break;
144
302
  i += 1;
145
303
  }
304
+ value = rawAttrs.slice(valueStart, i);
146
305
  }
147
306
  }
307
+
308
+ if (name && !name.startsWith('${') && !name.includes('{') && !name.includes('}')) {
309
+ out.push({ name, offset: nameStart, value });
310
+ }
148
311
  }
149
312
 
150
313
  return out;
@@ -176,13 +339,13 @@ export function validateTextAgainstCem({
176
339
 
177
340
  const tagOffset = m.index + 1;
178
341
  const attrChunk = String(m[2] ?? '');
342
+ const rawAttrsStart = m.index + 1 + tag.length;
179
343
 
180
344
  const attrNames = parseAttributeNames(attrChunk);
181
345
  for (const { name, offset } of attrNames) {
182
346
  const attrName = name.toLowerCase();
183
347
  if (!isForbiddenAttr(attrName)) continue;
184
348
 
185
- const rawAttrsStart = m.index + 1 + tag.length;
186
349
  const startIndex = rawAttrsStart + offset;
187
350
  const endIndex = startIndex + attrName.length;
188
351
  const range = makeRange(lineStarts, startIndex, endIndex);
@@ -220,7 +383,6 @@ export function validateTextAgainstCem({
220
383
  if (shouldSkipAttr(attrName)) continue;
221
384
  if (meta.attributes.has(attrName)) continue;
222
385
 
223
- const rawAttrsStart = m.index + 1 + tag.length;
224
386
  const startIndex = rawAttrsStart + offset;
225
387
  const endIndex = startIndex + attrName.length;
226
388
  const range = makeRange(lineStarts, startIndex, endIndex);
@@ -238,3 +400,469 @@ export function validateTextAgainstCem({
238
400
 
239
401
  return diagnostics;
240
402
  }
403
+
404
+ /**
405
+ * @param {{
406
+ * filePath?: string;
407
+ * text: string;
408
+ * valueToToken?: Map<string, string>;
409
+ * severity?: string;
410
+ * }} params
411
+ */
412
+ export function detectTokenMisuseInInlineStyles({
413
+ filePath = '<input>',
414
+ text,
415
+ valueToToken = new Map(),
416
+ severity = 'warning',
417
+ }) {
418
+ const diagnostics = [];
419
+ if (!(valueToToken instanceof Map) || valueToToken.size === 0) return diagnostics;
420
+
421
+ const lineStarts = computeLineIndex(text);
422
+ const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
423
+ let m;
424
+
425
+ while ((m = tagRe.exec(text))) {
426
+ const tag = String(m[1] ?? '').toLowerCase();
427
+ const attrChunk = String(m[2] ?? '');
428
+ const inlineStyle = parseInlineStyleAttribute(attrChunk);
429
+ if (!inlineStyle) continue;
430
+
431
+ const { styleValue, styleValueOffsetInAttr } = inlineStyle;
432
+ const rawAttrsStart = m.index + 1 + tag.length;
433
+
434
+ const declarationRe = /([a-z-]+)\s*:\s*([^;]+)/gi;
435
+ let d;
436
+ while ((d = declarationRe.exec(styleValue))) {
437
+ const prop = String(d[1] ?? '').trim().toLowerCase();
438
+ if (!TOKEN_MISUSE_STYLE_PROPS.has(prop)) continue;
439
+
440
+ const valueRaw = String(d[2] ?? '').trim();
441
+ if (!valueRaw || /^var\(/i.test(valueRaw)) continue;
442
+
443
+ const normalizedValue = normalizeStyleValue(valueRaw);
444
+ const cssVariable = valueToToken.get(normalizedValue);
445
+ if (!cssVariable) continue;
446
+
447
+ const valueOffsetInDecl = d[0].indexOf(d[2]);
448
+ const valueOffsetInStyle = d.index + Math.max(0, valueOffsetInDecl);
449
+ const startIndex = rawAttrsStart + styleValueOffsetInAttr + valueOffsetInStyle;
450
+ const endIndex = startIndex + d[2].length;
451
+ const range = makeRange(lineStarts, startIndex, endIndex);
452
+
453
+ diagnostics.push({
454
+ file: filePath,
455
+ range,
456
+ severity,
457
+ code: 'tokenMisuse',
458
+ message: `Use var(${cssVariable}) instead of ${valueRaw} for ${prop}`,
459
+ tagName: tag,
460
+ attrName: 'style',
461
+ hint: `Replace ${prop}: ${valueRaw} with ${prop}: var(${cssVariable})`,
462
+ });
463
+ }
464
+ }
465
+
466
+ return diagnostics;
467
+ }
468
+
469
+ /**
470
+ * @param {{
471
+ * filePath?: string;
472
+ * text: string;
473
+ * severity?: string;
474
+ * }} params
475
+ */
476
+ export function detectAccessibilityMisuseInMarkup({
477
+ filePath = '<input>',
478
+ text,
479
+ severity = 'warning',
480
+ }) {
481
+ const diagnostics = [];
482
+ const lineStarts = computeLineIndex(text);
483
+ const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
484
+ let m;
485
+
486
+ while ((m = tagRe.exec(text))) {
487
+ const tag = String(m[1] ?? '').toLowerCase();
488
+ const attrChunk = String(m[2] ?? '');
489
+ const rawAttrsStart = m.index + 1 + tag.length;
490
+
491
+ const attrNames = parseAttributeNames(attrChunk);
492
+ for (const { name, offset } of attrNames) {
493
+ const attrName = String(name ?? '').toLowerCase();
494
+ if (attrName !== 'aria-live') continue;
495
+
496
+ const startIndex = rawAttrsStart + offset;
497
+ const endIndex = startIndex + attrName.length;
498
+ const range = makeRange(lineStarts, startIndex, endIndex);
499
+ diagnostics.push({
500
+ file: filePath,
501
+ range,
502
+ severity,
503
+ code: 'ariaLiveNotRecommended',
504
+ message: 'Avoid aria-live in component markup; use static text with aria-describedby instead.',
505
+ tagName: tag,
506
+ attrName: 'aria-live',
507
+ hint: 'Remove aria-live and connect error/support text via aria-describedby.',
508
+ });
509
+ }
510
+
511
+ const roleAttr = parseAttributes(attrChunk).find(({ name }) => String(name ?? '').toLowerCase() === 'role');
512
+ const roleValue = String(roleAttr?.value ?? '').trim().toLowerCase();
513
+ if (roleAttr && roleValue === 'alert') {
514
+ const attrName = 'role';
515
+ const roleOffsetInChunk = roleAttr.offset;
516
+ const startIndex = rawAttrsStart + roleOffsetInChunk;
517
+ const endIndex = startIndex + attrName.length;
518
+ const range = makeRange(lineStarts, startIndex, endIndex);
519
+ diagnostics.push({
520
+ file: filePath,
521
+ range,
522
+ severity,
523
+ code: 'roleAlertNotRecommended',
524
+ message: 'Avoid role=\"alert\" in component markup; prefer static error text and aria-describedby.',
525
+ tagName: tag,
526
+ attrName,
527
+ hint: 'Replace role=\"alert\" with non-live text associated to the control.',
528
+ });
529
+ }
530
+ }
531
+
532
+ return diagnostics;
533
+ }
534
+
535
+ /**
536
+ * Build a map of slot names per component from the CEM manifest.
537
+ * Returns Map<tagName, Set<validSlotNames>>
538
+ * @param {object} manifest
539
+ * @returns {Map<string, Set<string>>}
540
+ */
541
+ export function buildSlotNameMap(manifest) {
542
+ const result = new Map();
543
+
544
+ const modules = Array.isArray(manifest?.modules) ? manifest.modules : [];
545
+ for (const mod of modules) {
546
+ const declarations = Array.isArray(mod?.declarations) ? mod.declarations : [];
547
+ for (const decl of declarations) {
548
+ const tagName = decl?.tagName;
549
+ const isCustomElement = decl?.customElement === true || decl?.kind === 'custom-element';
550
+ if (!isCustomElement || typeof tagName !== 'string' || !tagName) continue;
551
+ const tag = tagName.toLowerCase();
552
+
553
+ const slotNames = new Set();
554
+ const declSlots = Array.isArray(decl?.slots) ? decl.slots : [];
555
+ for (const s of declSlots) {
556
+ if (typeof s?.name !== 'string') continue;
557
+ slotNames.add(s.name);
558
+ }
559
+
560
+ if (slotNames.size > 0) {
561
+ result.set(tag, slotNames);
562
+ }
563
+ }
564
+ }
565
+
566
+ return result;
567
+ }
568
+
569
+ /**
570
+ * Detect invalid slot names in HTML markup.
571
+ * Checks if `slot="name"` values match any known slot across the design system.
572
+ * @param {{
573
+ * filePath?: string;
574
+ * text: string;
575
+ * slotMap: Map<string, Set<string>>;
576
+ * severity?: string;
577
+ * }} params
578
+ */
579
+ export function detectInvalidSlotName({
580
+ filePath = '<input>',
581
+ text,
582
+ slotMap,
583
+ severity = 'error',
584
+ }) {
585
+ const diagnostics = [];
586
+ if (!(slotMap instanceof Map) || slotMap.size === 0) return diagnostics;
587
+
588
+ // Build global slot vocabulary (all valid slot names across all components)
589
+ const globalSlotNames = new Set();
590
+ for (const slotNames of slotMap.values()) {
591
+ for (const name of slotNames) globalSlotNames.add(name);
592
+ }
593
+
594
+ const lineStarts = computeLineIndex(text);
595
+ const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
596
+ let m;
597
+
598
+ while ((m = tagRe.exec(text))) {
599
+ const tag = String(m[1] ?? '').toLowerCase();
600
+ const attrChunk = String(m[2] ?? '');
601
+ const rawAttrsStart = m.index + 1 + tag.length;
602
+ const attrs = parseAttributes(attrChunk);
603
+
604
+ for (const { name, offset, value } of attrs) {
605
+ const attrName = name.toLowerCase();
606
+ if (attrName !== 'slot') continue;
607
+ if (value === undefined || value === '') continue;
608
+
609
+ // 'default' is always valid (unnamed slot)
610
+ if (value === 'default') continue;
611
+
612
+ if (!globalSlotNames.has(value)) {
613
+ const startIndex = rawAttrsStart + offset;
614
+ const endIndex = startIndex + name.length;
615
+ const range = makeRange(lineStarts, startIndex, endIndex);
616
+ diagnostics.push({
617
+ file: filePath,
618
+ range,
619
+ severity,
620
+ code: 'invalidSlotName',
621
+ message: `Unknown slot name "${value}". No component in the design system defines this slot.`,
622
+ tagName: tag,
623
+ attrName: 'slot',
624
+ hint: `Check the parent component's API for available slot names.`,
625
+ });
626
+ }
627
+ }
628
+ }
629
+
630
+ return diagnostics;
631
+ }
632
+
633
+ /**
634
+ * Parent-child constraints: child → expected parent.
635
+ * If a child tag appears without its parent wrapping it, emit a warning.
636
+ */
637
+ const PARENT_CHILD_CONSTRAINTS = new Map([
638
+ ['dads-accordion-item-details', 'dads-accordion-details'],
639
+ ['dads-breadcrumb-item', 'dads-breadcrumb'],
640
+ ['dads-list-item', 'dads-list'],
641
+ ['dads-step-navigation-item', 'dads-step-navigation'],
642
+ ['dads-global-menu-item', 'dads-global-menu'],
643
+ ['dads-menu-list-item', 'dads-menu-list'],
644
+ ]);
645
+
646
+ /**
647
+ * Detect orphaned child components (child appears without expected parent).
648
+ * Uses regex/text scan (DIG-13) — lower confidence, severity: warning.
649
+ * @param {{
650
+ * filePath?: string;
651
+ * text: string;
652
+ * prefix?: string;
653
+ * severity?: string;
654
+ * }} params
655
+ */
656
+ export function detectOrphanedChildComponents({
657
+ filePath = '<input>',
658
+ text,
659
+ prefix = 'dads',
660
+ severity = 'warning',
661
+ }) {
662
+ const diagnostics = [];
663
+ const lineStarts = computeLineIndex(text);
664
+ const p = prefix.toLowerCase();
665
+ const canonicalPrefix = 'dads';
666
+
667
+ // Build prefix-aware constraint map
668
+ const constraints = new Map();
669
+ for (const [child, parent] of PARENT_CHILD_CONSTRAINTS.entries()) {
670
+ const mappedChild = p !== canonicalPrefix ? child.replace(canonicalPrefix, p) : child;
671
+ const mappedParent = p !== canonicalPrefix ? parent.replace(canonicalPrefix, p) : parent;
672
+ constraints.set(mappedChild, mappedParent);
673
+ }
674
+
675
+ const textLower = text.toLowerCase();
676
+
677
+ const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
678
+ let m;
679
+
680
+ while ((m = tagRe.exec(text))) {
681
+ const tag = String(m[1] ?? '').toLowerCase();
682
+ const expectedParent = constraints.get(tag);
683
+ if (!expectedParent) continue;
684
+
685
+ // Check if the expected parent tag appears before this child in the text
686
+ const precedingText = textLower.slice(0, m.index);
687
+ const parentOpenPattern = `<${expectedParent}`;
688
+ const parentClosePattern = `</${expectedParent}`;
689
+
690
+ const lastParentOpen = precedingText.lastIndexOf(parentOpenPattern);
691
+ const lastParentClose = precedingText.lastIndexOf(parentClosePattern);
692
+
693
+ if (lastParentOpen === -1 || lastParentClose > lastParentOpen) {
694
+ const tagOffset = m.index + 1;
695
+ const range = makeRange(lineStarts, tagOffset, tagOffset + tag.length);
696
+ diagnostics.push({
697
+ file: filePath,
698
+ range,
699
+ severity,
700
+ code: 'orphanedChildComponent',
701
+ message: `<${tag}> should be a child of <${expectedParent}>.`,
702
+ tagName: tag,
703
+ hint: `Wrap <${tag}> inside <${expectedParent}>...</${expectedParent}>.`,
704
+ });
705
+ }
706
+ }
707
+
708
+ return diagnostics;
709
+ }
710
+
711
+ /**
712
+ * Interactive elements that should have content (text or slotted content).
713
+ */
714
+ const INTERACTIVE_ELEMENTS = new Set([
715
+ 'dads-button',
716
+ ]);
717
+
718
+ /**
719
+ * Detect empty interactive elements (e.g., button with no text content).
720
+ * Severity: warning (DIG-04).
721
+ * @param {{
722
+ * filePath?: string;
723
+ * text: string;
724
+ * prefix?: string;
725
+ * severity?: string;
726
+ * }} params
727
+ */
728
+ export function detectEmptyInteractiveElement({
729
+ filePath = '<input>',
730
+ text,
731
+ prefix = 'dads',
732
+ severity = 'warning',
733
+ }) {
734
+ const diagnostics = [];
735
+ const lineStarts = computeLineIndex(text);
736
+ const p = prefix.toLowerCase();
737
+ const canonicalPrefix = 'dads';
738
+
739
+ const elements = new Set();
740
+ for (const el of INTERACTIVE_ELEMENTS) {
741
+ elements.add(p !== canonicalPrefix ? el.replace(canonicalPrefix, p) : el);
742
+ }
743
+
744
+ // Match self-closing tags: <dads-button ... />
745
+ const selfClosingRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)\/>/gi;
746
+ let m;
747
+
748
+ while ((m = selfClosingRe.exec(text))) {
749
+ const tag = String(m[1] ?? '').toLowerCase();
750
+ if (!elements.has(tag)) continue;
751
+
752
+ // Check if aria-label is present
753
+ const attrChunk = String(m[2] ?? '');
754
+ const attrs = parseAttributes(attrChunk);
755
+ const hasAriaLabel = attrs.some(({ name }) => name.toLowerCase() === 'aria-label');
756
+ if (hasAriaLabel) continue;
757
+
758
+ const tagOffset = m.index + 1;
759
+ const range = makeRange(lineStarts, tagOffset, tagOffset + tag.length);
760
+ diagnostics.push({
761
+ file: filePath,
762
+ range,
763
+ severity,
764
+ code: 'emptyInteractiveElement',
765
+ message: `<${tag}> appears empty. Add text content or aria-label for accessibility.`,
766
+ tagName: tag,
767
+ hint: `Add visible text or aria-label="..." to <${tag}>.`,
768
+ });
769
+ }
770
+
771
+ // Match open+close with no content: <dads-button ...></dads-button>
772
+ for (const tag of elements) {
773
+ const emptyRe = new RegExp(`<(${tag})\\b([^<>]*?)>\\s*</${tag}>`, 'gi');
774
+ let em;
775
+ while ((em = emptyRe.exec(text))) {
776
+ const matchedTag = String(em[1] ?? '').toLowerCase();
777
+ const attrChunk = String(em[2] ?? '');
778
+ const attrs = parseAttributes(attrChunk);
779
+ const hasAriaLabel = attrs.some(({ name }) => name.toLowerCase() === 'aria-label');
780
+ if (hasAriaLabel) continue;
781
+
782
+ const tagOffset = em.index + 1;
783
+ const range = makeRange(lineStarts, tagOffset, tagOffset + matchedTag.length);
784
+ diagnostics.push({
785
+ file: filePath,
786
+ range,
787
+ severity,
788
+ code: 'emptyInteractiveElement',
789
+ message: `<${matchedTag}> appears empty. Add text content or aria-label for accessibility.`,
790
+ tagName: matchedTag,
791
+ hint: `Add visible text or aria-label="..." to <${matchedTag}>.`,
792
+ });
793
+ }
794
+ }
795
+
796
+ return diagnostics;
797
+ }
798
+
799
+ /**
800
+ * Hardcoded map of required attributes per form component (DIG-08).
801
+ * Only `label` for form inputs.
802
+ */
803
+ const REQUIRED_ATTRIBUTES = new Map([
804
+ ['dads-input-text', ['label']],
805
+ ['dads-textarea', ['label']],
806
+ ['dads-select', ['label']],
807
+ ['dads-checkbox', ['label']],
808
+ ['dads-radio', ['label']],
809
+ ]);
810
+
811
+ /**
812
+ * Detect missing required attributes on form elements.
813
+ * @param {{
814
+ * filePath?: string;
815
+ * text: string;
816
+ * prefix?: string;
817
+ * severity?: string;
818
+ * }} params
819
+ */
820
+ export function detectMissingRequiredAttributes({
821
+ filePath = '<input>',
822
+ text,
823
+ prefix = 'dads',
824
+ severity = 'error',
825
+ }) {
826
+ const diagnostics = [];
827
+ const lineStarts = computeLineIndex(text);
828
+ const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
829
+ let m;
830
+
831
+ // Build prefix-aware required map
832
+ const requiredMap = new Map();
833
+ for (const [tag, attrs] of REQUIRED_ATTRIBUTES.entries()) {
834
+ const p = prefix.toLowerCase();
835
+ const canonicalPrefix = 'dads';
836
+ const mappedTag = p !== canonicalPrefix ? tag.replace(canonicalPrefix, p) : tag;
837
+ requiredMap.set(mappedTag, attrs);
838
+ }
839
+
840
+ while ((m = tagRe.exec(text))) {
841
+ const tag = String(m[1] ?? '').toLowerCase();
842
+ const requiredAttrs = requiredMap.get(tag);
843
+ if (!requiredAttrs) continue;
844
+
845
+ const attrChunk = String(m[2] ?? '');
846
+ const attrs = parseAttributes(attrChunk);
847
+ const presentAttrs = new Set(attrs.map(({ name }) => name.toLowerCase()));
848
+
849
+ for (const required of requiredAttrs) {
850
+ if (!presentAttrs.has(required)) {
851
+ const tagOffset = m.index + 1;
852
+ const range = makeRange(lineStarts, tagOffset, tagOffset + tag.length);
853
+ diagnostics.push({
854
+ file: filePath,
855
+ range,
856
+ severity,
857
+ code: 'missingRequiredAttribute',
858
+ message: `<${tag}> requires attribute "${required}" for accessibility.`,
859
+ tagName: tag,
860
+ attrName: required,
861
+ hint: `Add ${required}="..." to <${tag}>.`,
862
+ });
863
+ }
864
+ }
865
+ }
866
+
867
+ return diagnostics;
868
+ }