@rookcom/monarch-conformance 0.0.0-stage → 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.
Files changed (51) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +4 -2
  3. package/dist/check.d.ts +81 -0
  4. package/dist/check.d.ts.map +1 -0
  5. package/dist/check.js +350 -0
  6. package/dist/check.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +175 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/glob.d.ts +3 -0
  12. package/dist/glob.d.ts.map +1 -0
  13. package/dist/glob.js +70 -0
  14. package/dist/glob.js.map +1 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +8 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/monarch-components.d.ts +122 -0
  20. package/dist/monarch-components.d.ts.map +1 -0
  21. package/dist/monarch-components.js +47 -0
  22. package/dist/monarch-components.js.map +1 -0
  23. package/dist/registry.d.ts +77 -0
  24. package/dist/registry.d.ts.map +1 -0
  25. package/dist/registry.js +259 -0
  26. package/dist/registry.js.map +1 -0
  27. package/dist/report.d.ts +8 -0
  28. package/dist/report.d.ts.map +1 -0
  29. package/dist/report.js +122 -0
  30. package/dist/report.js.map +1 -0
  31. package/dist/rules.d.ts +149 -0
  32. package/dist/rules.d.ts.map +1 -0
  33. package/dist/rules.js +639 -0
  34. package/dist/rules.js.map +1 -0
  35. package/dist/schema.d.ts +24 -0
  36. package/dist/schema.d.ts.map +1 -0
  37. package/dist/schema.js +91 -0
  38. package/dist/schema.js.map +1 -0
  39. package/dist/semver.d.ts +27 -0
  40. package/dist/semver.d.ts.map +1 -0
  41. package/dist/semver.js +235 -0
  42. package/dist/semver.js.map +1 -0
  43. package/dist/types.d.ts +54 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +2 -0
  46. package/dist/types.js.map +1 -0
  47. package/dist/version.d.ts +3 -0
  48. package/dist/version.d.ts.map +1 -0
  49. package/dist/version.js +10 -0
  50. package/dist/version.js.map +1 -0
  51. package/package.json +48 -4
package/dist/rules.js ADDED
@@ -0,0 +1,639 @@
1
+ import { MONARCH_COMPONENT_STEMS } from './monarch-components.js';
2
+ export const RULES = {
3
+ 'color/no-raw-palette': {
4
+ id: 'color/no-raw-palette',
5
+ severity: 'error',
6
+ summary: 'Raw Tailwind palette colour or arbitrary colour class',
7
+ help: 'Use a semantic colour (bg-mn-surface, text-mn-fg-muted, bg-mn-danger-subtle, or the legacy bg-primary / text-muted-foreground). Palette classes ignore theme, contrast mode and product accent.',
8
+ },
9
+ 'color/no-hard-coded-color': {
10
+ id: 'color/no-hard-coded-color',
11
+ severity: 'error',
12
+ summary: 'Hard-coded colour literal (hex, rgb(), hsl())',
13
+ help: 'Reference a token: var(--mn-color-…) in CSS, or a semantic class. For charts use var(--mn-color-data-1…5).',
14
+ },
15
+ 'typography/no-arbitrary-type': {
16
+ id: 'typography/no-arbitrary-type',
17
+ severity: 'warn',
18
+ summary: 'Arbitrary font size, weight, family, line height or letter spacing',
19
+ help: 'Use the Monarch type scale (text-2xs…text-5xl, font-medium, leading-snug, tracking-tight) and var(--mn-font-sans|mono).',
20
+ },
21
+ 'spacing/off-grid': {
22
+ id: 'spacing/off-grid',
23
+ severity: 'warn',
24
+ summary: 'Spacing value off the 4px grid',
25
+ help: 'Use the spacing scale (p-1 = 4px, p-1.5 = 6px, p-2 = 8px …). Arbitrary values must be multiples of 2px or a var(--mn-space-…).',
26
+ },
27
+ 'tokens/local-token-definitions': {
28
+ id: 'tokens/local-token-definitions',
29
+ severity: 'error',
30
+ summary: 'Design tokens defined locally instead of imported from Monarch',
31
+ help: 'Import @rookcom/monarch-tokens/css (plus /css/compat during migration) and set data-mn-product on <html>. Delete the local copy.',
32
+ },
33
+ 'components/duplicated-primitive': {
34
+ id: 'components/duplicated-primitive',
35
+ severity: 'warn',
36
+ summary: 'Local copy of a component Monarch provides',
37
+ help: 'Import the component from @rookcom/monarch-react and delete the local file once call sites are migrated.',
38
+ },
39
+ 'a11y/img-alt': {
40
+ id: 'a11y/img-alt',
41
+ severity: 'error',
42
+ summary: '<img> without alt',
43
+ help: 'Add alt text, or alt="" for decorative images (WCAG 1.1.1).',
44
+ },
45
+ 'a11y/click-without-key': {
46
+ id: 'a11y/click-without-key',
47
+ severity: 'error',
48
+ summary: 'Click handler on a non-interactive element without role and keyboard handler',
49
+ help: 'Use <button> (preferred), or add role and onKeyDown plus tabIndex={0} (WCAG 2.1.1).',
50
+ },
51
+ 'a11y/positive-tabindex': {
52
+ id: 'a11y/positive-tabindex',
53
+ severity: 'error',
54
+ summary: 'Positive tabIndex',
55
+ help: 'Use tabIndex={0} or -1. Positive values break the natural focus order (WCAG 2.4.3).',
56
+ },
57
+ 'a11y/outline-none-without-ring': {
58
+ id: 'a11y/outline-none-without-ring',
59
+ severity: 'error',
60
+ summary: 'Focus outline removed without a visible replacement',
61
+ help: 'Pair outline-none with focus-visible:ring-2 focus-visible:ring-ring (or a Monarch component). WCAG 2.4.7 needs a visible focus indicator.',
62
+ },
63
+ 'overrides/monarch-internals': {
64
+ id: 'overrides/monarch-internals',
65
+ severity: 'error',
66
+ summary: 'Overrides Monarch internals (.mn-* selectors or --mn-* variables)',
67
+ help: 'Monarch classes and variables are not a public styling API. Use component props, or request a variant or an exception through governance.',
68
+ },
69
+ 'deps/monarch-version': {
70
+ id: 'deps/monarch-version',
71
+ severity: 'error',
72
+ summary: 'Monarch package version status',
73
+ help: 'Upgrade to a supported version listed in registry/compatibility.json. See the migration guide for each release.',
74
+ },
75
+ 'api/deprecated': {
76
+ id: 'api/deprecated',
77
+ severity: 'warn',
78
+ summary: 'Deprecated Monarch API',
79
+ help: 'Move to the replacement named in the message before the removal version.',
80
+ },
81
+ 'legacy/bridge-variable': {
82
+ id: 'legacy/bridge-variable',
83
+ severity: 'info',
84
+ summary: 'Legacy shadcn variable used directly (var(--primary) …)',
85
+ help: 'Tracked for migration. Prefer var(--mn-color-…) so the legacy bridge can eventually be removed.',
86
+ },
87
+ 'governance/suppression-without-reason': {
88
+ id: 'governance/suppression-without-reason',
89
+ severity: 'warn',
90
+ summary: 'Suppression comment without a reason',
91
+ help: 'Write `monarch-ignore-next-line <rule> -- <reason>`. Suppressions without a reason are ignored.',
92
+ },
93
+ 'governance/expired-exception': {
94
+ id: 'governance/expired-exception',
95
+ severity: 'error',
96
+ summary: 'Registry exception has expired',
97
+ help: 'Fix the findings it covered, or renew it in the product registry entry with a new expiry and approver.',
98
+ },
99
+ };
100
+ export const RULE_IDS = Object.keys(RULES);
101
+ // ---------------------------------------------------------------- shared
102
+ const PALETTE = 'slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose';
103
+ const COLOR_PROPS = 'text|bg|border(?:-[xytrblse])?|ring(?:-offset)?|outline|from|via|to|fill|stroke|divide|placeholder|caret|accent|decoration|shadow';
104
+ const VARIANTS = '(?:(?:[\\w-]+|\\[[^\\]\\s]*\\]|[\\w-]+-\\[[^\\]\\s]*\\]):)*';
105
+ const PALETTE_RE = new RegExp(`(?<![\\w-])${VARIANTS}!?(?:${COLOR_PROPS})-(?:(?:${PALETTE})-(?:50|[1-9]00|950)|white|black)(?:\\/(?:\\d{1,3}|\\[[^\\]]+\\]))?(?![\\w-])`, 'g');
106
+ const ARBITRARY_COLOR_RE = new RegExp(`(?<![\\w-])${VARIANTS}!?(?:${COLOR_PROPS})-\\[(?:#[0-9a-fA-F]{3,8}|(?:rgba?|hsla?|oklch|oklab)\\(\\s*[\\d.])[^\\]]*\\]`, 'g');
107
+ const HEX_RE = /(?<=[\s'"`(,:=_])#([0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})(?![\w-])/g;
108
+ const FUNC_COLOR_RE = /\b(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(\s*-?[\d.]/g;
109
+ /** Legacy variables from the shadcn-era token copies. */
110
+ export const LEGACY_TOKEN_NAMES = new Set([
111
+ 'background',
112
+ 'foreground',
113
+ 'card',
114
+ 'card-foreground',
115
+ 'popover',
116
+ 'popover-foreground',
117
+ 'primary',
118
+ 'primary-foreground',
119
+ 'secondary',
120
+ 'secondary-foreground',
121
+ 'muted',
122
+ 'muted-foreground',
123
+ 'accent',
124
+ 'accent-foreground',
125
+ 'destructive',
126
+ 'destructive-foreground',
127
+ 'success',
128
+ 'success-foreground',
129
+ 'warning',
130
+ 'warning-foreground',
131
+ 'info',
132
+ 'info-foreground',
133
+ 'border',
134
+ 'input',
135
+ 'ring',
136
+ 'radius',
137
+ ]);
138
+ export function isLegacyToken(name) {
139
+ return LEGACY_TOKEN_NAMES.has(name) || name.startsWith('sidebar-') || /^chart-\d+$/.test(name);
140
+ }
141
+ const isCode = (ext) => /^\.(?:[cm]?[jt]sx?)$/.test(ext);
142
+ const isJsx = (ext) => ext === '.tsx' || ext === '.jsx';
143
+ const isCss = (ext) => ext === '.css' || ext === '.scss';
144
+ /** True for lines that are only a comment (we don't scan commented-out code). */
145
+ function isCommentLine(line) {
146
+ const t = line.trimStart();
147
+ return t.startsWith('//') || t.startsWith('/*') || t.startsWith('*') || t.startsWith('{/*');
148
+ }
149
+ function eachMatch(re, s, fn) {
150
+ re.lastIndex = 0;
151
+ let m;
152
+ while ((m = re.exec(s)) !== null) {
153
+ fn(m);
154
+ if (m[0].length === 0)
155
+ re.lastIndex++;
156
+ }
157
+ }
158
+ /** Index → 1-based line/column. */
159
+ function locator(text) {
160
+ const starts = [0];
161
+ for (let i = 0; i < text.length; i++)
162
+ if (text.charCodeAt(i) === 10)
163
+ starts.push(i + 1);
164
+ return (index) => {
165
+ let lo = 0;
166
+ let hi = starts.length - 1;
167
+ while (lo < hi) {
168
+ const mid = (lo + hi + 1) >> 1;
169
+ if (starts[mid] <= index)
170
+ lo = mid;
171
+ else
172
+ hi = mid - 1;
173
+ }
174
+ return { line: lo + 1, column: index - starts[lo] + 1 };
175
+ };
176
+ }
177
+ // ---------------------------------------------------------------- colour
178
+ function scanColor(ctx, out) {
179
+ ctx.lines.forEach((line, i) => {
180
+ if (isCommentLine(line))
181
+ return;
182
+ const n = i + 1;
183
+ // Design-token definitions are reported once per block by tokens/local-token-definitions
184
+ // (or overrides/monarch-internals for --mn-*); app-local variables are still checked.
185
+ const decl = isCss(ctx.ext) ? /^\s*--([\w-]+)\s*:/.exec(line) : null;
186
+ const cssDecl = decl
187
+ ? isLegacyToken(decl[1]) || decl[1].startsWith('mn-')
188
+ : false;
189
+ // Classes appear in TS strings and in CSS @apply.
190
+ if (isCode(ctx.ext) || (isCss(ctx.ext) && /@apply\b/.test(line))) {
191
+ eachMatch(PALETTE_RE, line, (m) => out.push({
192
+ rule: 'color/no-raw-palette',
193
+ line: n,
194
+ column: m.index + 1,
195
+ message: `Palette class \`${m[0]}\``,
196
+ }));
197
+ eachMatch(ARBITRARY_COLOR_RE, line, (m) => out.push({
198
+ rule: 'color/no-raw-palette',
199
+ line: n,
200
+ column: m.index + 1,
201
+ message: `Arbitrary colour class \`${m[0]}\``,
202
+ }));
203
+ }
204
+ if (cssDecl)
205
+ return; // reported by tokens/local-token-definitions
206
+ if (isCss(ctx.ext) && /^\s*@import\b/.test(line))
207
+ return;
208
+ eachMatch(HEX_RE, line, (m) => {
209
+ const before = line.slice(0, m.index);
210
+ // Anchors, routes and SVG references, not colours.
211
+ if (/(?:href|to|hash|anchor|id|path|url)\s*[=:]\s*\{?\s*['"`]?$/i.test(before))
212
+ return;
213
+ if (/url\(\s*['"]?$/.test(before))
214
+ return;
215
+ if (/&$/.test(before))
216
+ return;
217
+ const hex = m[1];
218
+ const after = line[m.index + m[0].length] ?? '';
219
+ // "Ticket #1234 created" / 'Order #1042': all-digit, followed by prose → an
220
+ // issue number. A 4-digit all-numeric token is never a colour in practice.
221
+ if (/^\d{4}$/.test(hex))
222
+ return;
223
+ if (/^\d+$/.test(hex) && !/^['"`;),\]}]?$/.test(after))
224
+ return;
225
+ if (/^\d+$/.test(hex) && /\s$/.test(before) && after === '')
226
+ return;
227
+ out.push({
228
+ rule: 'color/no-hard-coded-color',
229
+ line: n,
230
+ column: m.index + 1,
231
+ message: `Colour literal \`#${hex}\``,
232
+ });
233
+ });
234
+ eachMatch(FUNC_COLOR_RE, line, (m) => {
235
+ out.push({
236
+ rule: 'color/no-hard-coded-color',
237
+ line: n,
238
+ column: m.index + 1,
239
+ message: `Colour function \`${m[0].replace(/\s+$/, '')}…)\``,
240
+ });
241
+ });
242
+ });
243
+ }
244
+ // ---------------------------------------------------------------- typography & spacing
245
+ const ARB_TYPE_RE = new RegExp(`(?<![\\w-])${VARIANTS}!?(text|font|leading|tracking)-\\[([^\\]\\s]+)\\]`, 'g');
246
+ const ARB_SPACE_RE = new RegExp(`(?<![\\w-])${VARIANTS}!?-?(p[xytrblse]?|m[xytrblse]?|gap(?:-[xy])?|space-[xy]|inset(?:-[xy])?|scroll-[mp][xytrbl]?)-\\[([^\\]\\s]+)\\]`, 'g');
247
+ /** px value on the 2px half-grid (the scale has 0.5 = 2px steps), or not a length. */
248
+ function onGrid(value) {
249
+ if (/var\(|calc\(|env\(|%|vh|vw|dvh|svh|lvh|fr|auto|min\(|max\(|clamp\(/.test(value))
250
+ return true;
251
+ const lengths = value.split(/[_\s,]+/).filter(Boolean);
252
+ return lengths.every((len) => {
253
+ // em is relative to the element's font (prose rhythm), so only px/rem are checked.
254
+ const m = /^(-?\d*\.?\d+)(px|rem)?$/.exec(len);
255
+ if (!m)
256
+ return true;
257
+ const num = Number(m[1]);
258
+ const px = m[2] === 'rem' ? num * 16 : num;
259
+ if (px === 0 || Math.abs(px) === 1)
260
+ return true; // hairlines
261
+ return Math.abs(px) % 2 === 0;
262
+ });
263
+ }
264
+ function scanTypeAndSpace(ctx, out) {
265
+ ctx.lines.forEach((line, i) => {
266
+ if (isCommentLine(line))
267
+ return;
268
+ const n = i + 1;
269
+ if (isCode(ctx.ext) || (isCss(ctx.ext) && /@apply\b/.test(line))) {
270
+ eachMatch(ARB_TYPE_RE, line, (m) => {
271
+ const value = m[2];
272
+ if (/^(?:#|rgba?\(|hsla?\(|oklch|color-mix|var\(|length:var)/.test(value))
273
+ return;
274
+ out.push({
275
+ rule: 'typography/no-arbitrary-type',
276
+ line: n,
277
+ column: m.index + 1,
278
+ message: `Arbitrary type value \`${m[0]}\``,
279
+ });
280
+ });
281
+ eachMatch(ARB_SPACE_RE, line, (m) => {
282
+ if (onGrid(m[2]))
283
+ return;
284
+ out.push({
285
+ rule: 'spacing/off-grid',
286
+ line: n,
287
+ column: m.index + 1,
288
+ message: `Off-grid spacing \`${m[0]}\``,
289
+ });
290
+ });
291
+ }
292
+ if (isCode(ctx.ext)) {
293
+ const ff = /\bfontFamily\s*:\s*['"`]([^'"`]+)['"`]/.exec(line);
294
+ if (ff && !/var\(--/.test(ff[1]) && !/^inherit$/.test(ff[1])) {
295
+ out.push({
296
+ rule: 'typography/no-arbitrary-type',
297
+ line: n,
298
+ column: ff.index + 1,
299
+ message: `Inline font family \`${ff[1]}\``,
300
+ });
301
+ }
302
+ }
303
+ if (isCss(ctx.ext)) {
304
+ // Global matching: some hand-written CSS packs many rules on one line.
305
+ eachMatch(/(?:^|[;{\s])font-family\s*:\s*([^;}]+)/g, line, (ff) => {
306
+ if (/^\s*(?:inherit|initial|unset|var\(--)/.test(ff[1]))
307
+ return;
308
+ out.push({
309
+ rule: 'typography/no-arbitrary-type',
310
+ line: n,
311
+ column: ff.index + 1,
312
+ message: `font-family declared locally: \`${ff[1].trim().slice(0, 60)}\``,
313
+ });
314
+ });
315
+ eachMatch(/(?:^|[;{\s])(padding|margin|gap|row-gap|column-gap)(?:-[a-z]+)?\s*:\s*([^;}]+)/g, line, (sp) => {
316
+ if (onGrid(sp[2].trim()))
317
+ return;
318
+ out.push({
319
+ rule: 'spacing/off-grid',
320
+ line: n,
321
+ column: sp.index + 1,
322
+ message: `Off-grid ${sp[1]}: \`${sp[2].trim()}\``,
323
+ });
324
+ });
325
+ }
326
+ });
327
+ }
328
+ // ---------------------------------------------------------------- tokens, overrides, legacy
329
+ function scanCssStructure(ctx, out) {
330
+ if (!isCss(ctx.ext))
331
+ return;
332
+ let selector = '';
333
+ let pendingSelector = '';
334
+ let group = null;
335
+ const flush = () => {
336
+ if (group && group.names.length) {
337
+ out.push({
338
+ rule: 'tokens/local-token-definitions',
339
+ line: group.line,
340
+ column: 1,
341
+ message: `${group.names.length} design-token variable${group.names.length === 1 ? '' : 's'} defined in \`${group.selector || '(top level)'}\` (${group.names
342
+ .slice(0, 4)
343
+ .map((x) => '--' + x)
344
+ .join(', ')}${group.names.length > 4 ? ', …' : ''})`,
345
+ });
346
+ }
347
+ group = null;
348
+ };
349
+ ctx.lines.forEach((line, i) => {
350
+ const n = i + 1;
351
+ if (isCommentLine(line))
352
+ return;
353
+ const open = line.indexOf('{');
354
+ if (open !== -1) {
355
+ flush();
356
+ selector = (pendingSelector + ' ' + line.slice(0, open)).trim().replace(/\s+/g, ' ');
357
+ pendingSelector = '';
358
+ if (/(^|[\s,>+~(])\.mn-[a-z]/.test(selector)) {
359
+ out.push({
360
+ rule: 'overrides/monarch-internals',
361
+ line: n,
362
+ column: 1,
363
+ message: `Selector targets Monarch internals: \`${selector.slice(0, 80)}\``,
364
+ });
365
+ }
366
+ }
367
+ else if (/[,]\s*$/.test(line) && !line.includes(':')) {
368
+ pendingSelector += ' ' + line.trim();
369
+ }
370
+ if (line.includes('}'))
371
+ flush();
372
+ const def = /^\s*--([\w-]+)\s*:/.exec(line);
373
+ if (def) {
374
+ const name = def[1];
375
+ if (name.startsWith('mn-')) {
376
+ out.push({
377
+ rule: 'overrides/monarch-internals',
378
+ line: n,
379
+ column: (def.index ?? 0) + 1,
380
+ message: `Redefines Monarch variable \`--${name}\``,
381
+ });
382
+ }
383
+ else if (isLegacyToken(name)) {
384
+ if (!group)
385
+ group = { selector, line: n, names: [] };
386
+ group.names.push(name);
387
+ }
388
+ }
389
+ });
390
+ flush();
391
+ }
392
+ const LEGACY_VAR_RE = /var\(\s*--([\w-]+)/g;
393
+ const MN_SELECTOR_IN_CODE_RE = /(?:['"`\s&_>+~,(]|^)\.mn-[a-z][\w-]*/g;
394
+ function scanLegacyAndOverrides(ctx, out) {
395
+ ctx.lines.forEach((line, i) => {
396
+ if (isCommentLine(line))
397
+ return;
398
+ const n = i + 1;
399
+ eachMatch(LEGACY_VAR_RE, line, (m) => {
400
+ if (isLegacyToken(m[1])) {
401
+ out.push({
402
+ rule: 'legacy/bridge-variable',
403
+ line: n,
404
+ column: m.index + 1,
405
+ message: `Legacy variable \`--${m[1]}\``,
406
+ });
407
+ }
408
+ });
409
+ if (isCode(ctx.ext)) {
410
+ eachMatch(MN_SELECTOR_IN_CODE_RE, line, (m) => {
411
+ out.push({
412
+ rule: 'overrides/monarch-internals',
413
+ line: n,
414
+ column: m.index + 1,
415
+ message: `Targets Monarch class \`${m[0].trim().replace(/^['"`&_>+~,(]/, '')}\``,
416
+ });
417
+ });
418
+ if (/--mn-[\w-]+['"]?\s*:/.test(line) && !/var\(--mn-/.test(line.split(':')[0] ?? '')) {
419
+ const idx = line.search(/--mn-/);
420
+ out.push({
421
+ rule: 'overrides/monarch-internals',
422
+ line: n,
423
+ column: idx + 1,
424
+ message: 'Sets a Monarch variable inline',
425
+ });
426
+ }
427
+ }
428
+ });
429
+ }
430
+ // ---------------------------------------------------------------- components
431
+ export function kebab(stem) {
432
+ return stem
433
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
434
+ .replace(/_/g, '-')
435
+ .toLowerCase();
436
+ }
437
+ function scanDuplicate(ctx, out) {
438
+ const m = /(?:^|\/)components\/(?:ui|primitives|common)\/(?:([A-Za-z][\w-]*)\.(?:tsx|jsx|ts|js)|([A-Za-z][\w-]*)\/index\.(?:tsx|jsx|ts|js))$/.exec(ctx.file);
439
+ if (!m)
440
+ return;
441
+ const stem = kebab((m[1] ?? m[2]));
442
+ const name = MONARCH_COMPONENT_STEMS.get(stem);
443
+ if (!name)
444
+ return;
445
+ out.push({
446
+ rule: 'components/duplicated-primitive',
447
+ line: 1,
448
+ column: 1,
449
+ message: `Local \`${stem}\` duplicates Monarch \`${name}\``,
450
+ });
451
+ }
452
+ /** Reads opening JSX tags, balancing braces so arrow functions don't end a tag. */
453
+ export function readTags(text, names) {
454
+ const tags = [];
455
+ const re = new RegExp(`<(${names.source})(?=[\\s/>])`, 'g');
456
+ let m;
457
+ while ((m = re.exec(text)) !== null) {
458
+ let depth = 0;
459
+ let quote = null;
460
+ let end = -1;
461
+ for (let i = m.index + m[0].length; i < text.length && i < m.index + 4000; i++) {
462
+ const c = text[i];
463
+ if (quote) {
464
+ if (c === quote && text[i - 1] !== '\\')
465
+ quote = null;
466
+ continue;
467
+ }
468
+ if (c === '{')
469
+ depth++;
470
+ else if (c === '}')
471
+ depth--;
472
+ else if (depth === 0 && (c === '"' || c === "'"))
473
+ quote = c;
474
+ else if (depth > 0 && (c === '"' || c === "'" || c === '`'))
475
+ quote = c;
476
+ else if (c === '>' && depth === 0) {
477
+ end = i;
478
+ break;
479
+ }
480
+ }
481
+ if (end !== -1)
482
+ tags.push({ name: m[1], start: m.index, text: text.slice(m.index, end + 1) });
483
+ }
484
+ return tags;
485
+ }
486
+ const NON_INTERACTIVE = /div|span|li|td|tr|p|section|article|header|footer|main|nav|aside|ul|ol|img|h[1-6]|label/;
487
+ /** Items of composite widgets whose keyboard handling lives on the container. */
488
+ const COMPOSITE_ITEM_ROLE = /\srole\s*=\s*["'{]\s*['"]?(?:option|menuitem|tab|treeitem|gridcell)['"]?/;
489
+ function scanA11y(ctx, out) {
490
+ if (!isJsx(ctx.ext))
491
+ return;
492
+ const at = locator(ctx.text);
493
+ // aria-activedescendant: focus stays on the input/container, which handles
494
+ // every key, so its items legitimately take only pointer events.
495
+ const activeDescendant = /aria-activedescendant/.test(ctx.text);
496
+ for (const tag of readTags(ctx.text, /img/)) {
497
+ if (/\{\s*\.\.\./.test(tag.text))
498
+ continue;
499
+ if (!/\balt\s*=/.test(tag.text)) {
500
+ const { line, column } = at(tag.start);
501
+ out.push({ rule: 'a11y/img-alt', line, column, message: '<img> has no alt attribute' });
502
+ }
503
+ }
504
+ for (const tag of readTags(ctx.text, NON_INTERACTIVE)) {
505
+ if (!/\sonClick\s*=/.test(tag.text))
506
+ continue;
507
+ if (/\{\s*\.\.\./.test(tag.text))
508
+ continue;
509
+ if (/\saria-hidden(?!\s*=\s*(?:"false"|\{false\}))(?=[\s=/>])/.test(tag.text))
510
+ continue;
511
+ // Wrappers that only stop propagation are not controls.
512
+ const handler = /\sonClick\s*=\s*\{([\s\S]*?)\}\s*(?:\w+=|\/?>|\{)/.exec(tag.text)?.[1] ?? '';
513
+ if (/stopPropagation\(\)/.test(handler) &&
514
+ !/[;,]\s*\w+\(/.test(handler.replace(/\w+\.stopPropagation\(\)/, '')))
515
+ continue;
516
+ const hasRole = /\srole\s*=/.test(tag.text);
517
+ const hasKey = /\sonKey(?:Down|Up|Press)\s*=/.test(tag.text);
518
+ if (hasRole && hasKey)
519
+ continue;
520
+ if (activeDescendant && COMPOSITE_ITEM_ROLE.test(tag.text))
521
+ continue;
522
+ const { line, column } = at(tag.start);
523
+ const missing = [hasRole ? null : 'role', hasKey ? null : 'onKeyDown']
524
+ .filter(Boolean)
525
+ .join(' and ');
526
+ out.push({
527
+ rule: 'a11y/click-without-key',
528
+ line,
529
+ column,
530
+ message: `<${tag.name} onClick> without ${missing}`,
531
+ });
532
+ }
533
+ eachMatch(/\btabIndex\s*=\s*(?:\{\s*(\d+)\s*\}|["'](\d+)["'])/g, ctx.text, (m) => {
534
+ const v = Number(m[1] ?? m[2]);
535
+ if (v > 0) {
536
+ const { line, column } = at(m.index);
537
+ out.push({ rule: 'a11y/positive-tabindex', line, column, message: `tabIndex=${v}` });
538
+ }
539
+ });
540
+ }
541
+ const OUTLINE_NONE_RE = new RegExp(`(?<![\\w-])${VARIANTS}outline-none(?![\\w-])`, 'g');
542
+ /** A focus style on a wrapper, e.g. an input group that rings on `focus-within`. */
543
+ const FOCUS_WITHIN_RE = /focus-within:(?:ring-(?!0(?![\w.]))|ring(?![\w-])|outline-(?!none|0(?![\w.]))|outline(?![\w-])|shadow|border)/;
544
+ /** How far up to look for that wrapper. A heuristic: the ancestor is usually in the same JSX block. */
545
+ const FOCUS_WITHIN_LOOKBACK = 15;
546
+ const FOCUS_REPLACEMENT_RE = /(?:focus|focus-visible|focus-within|group-focus(?:-visible)?|peer-focus(?:-visible)?|data-\[focus[^\]]*\]|data-\[highlighted\]|data-highlighted|aria-selected|data-\[selected[^\]]*\]|has-\[:focus-visible\]):(?:ring-(?!0(?![\w.]))|ring(?![\w-])|outline-(?!none|0(?![\w.]))|outline(?![\w-])|shadow|border|bg-|underline)/;
547
+ function scanOutline(ctx, out) {
548
+ if (!isCode(ctx.ext))
549
+ return;
550
+ ctx.lines.forEach((line, i) => {
551
+ if (isCommentLine(line))
552
+ return;
553
+ eachMatch(OUTLINE_NONE_RE, line, (m) => {
554
+ // Classes for one element are often split across a few lines (cva, cn()).
555
+ const window = ctx.lines.slice(Math.max(0, i - 3), i + 4).join('\n');
556
+ if (FOCUS_REPLACEMENT_RE.test(window))
557
+ return;
558
+ const above = ctx.lines.slice(Math.max(0, i - FOCUS_WITHIN_LOOKBACK), i).join('\n');
559
+ if (FOCUS_WITHIN_RE.test(above))
560
+ return;
561
+ out.push({
562
+ rule: 'a11y/outline-none-without-ring',
563
+ line: i + 1,
564
+ column: m.index + 1,
565
+ message: `\`${m[0]}\` with no focus-visible replacement nearby`,
566
+ });
567
+ });
568
+ });
569
+ }
570
+ // ---------------------------------------------------------------- deprecated API
571
+ function scanDeprecated(ctx, out) {
572
+ if (!isCode(ctx.ext) || ctx.deprecations.length === 0)
573
+ return;
574
+ for (const d of ctx.deprecations) {
575
+ const importRe = new RegExp(`from\\s+['"]${d.package.replace(/[/.]/g, '\\$&')}(?:/[^'"]*)?['"]`);
576
+ if (!importRe.test(ctx.text))
577
+ continue;
578
+ const nameRe = new RegExp(`(?<![\\w$.])${d.name.replace(/[.$]/g, '\\$&')}(?![\\w$])`, 'g');
579
+ ctx.lines.forEach((line, i) => {
580
+ if (isCommentLine(line))
581
+ return;
582
+ eachMatch(nameRe, line, (m) => {
583
+ out.push({
584
+ rule: 'api/deprecated',
585
+ line: i + 1,
586
+ column: m.index + 1,
587
+ message: `\`${d.name}\` from ${d.package} is deprecated since ${d.since}${d.replacement ? `; use \`${d.replacement}\`` : ''}${d.message ? `. ${d.message}` : ''}`,
588
+ });
589
+ });
590
+ });
591
+ }
592
+ }
593
+ /** Runs every file-level rule. Package-level rules live in check.ts. */
594
+ export function scanFile(ctx) {
595
+ const out = [];
596
+ scanColor(ctx, out);
597
+ scanTypeAndSpace(ctx, out);
598
+ scanCssStructure(ctx, out);
599
+ scanLegacyAndOverrides(ctx, out);
600
+ scanDuplicate(ctx, out);
601
+ scanA11y(ctx, out);
602
+ scanOutline(ctx, out);
603
+ scanDeprecated(ctx, out);
604
+ return out;
605
+ }
606
+ const SUPPRESS_RE = /monarch-ignore-next-line\s+([\w/*,\s-]*?)(?:\s+--\s*(.*?))?\s*(?:\*\/\s*\}?)?\s*$/;
607
+ // A line holding only a comment: line comment, block comment, JSX comment, or a doc-comment continuation.
608
+ const COMMENT_ONLY = /^\s*(?:\/\/.*|\/\*.*\*\/\s*|\{\s*\/\*.*\*\/\s*\}\s*|\*.*)$/;
609
+ export function readSuppressions(lines) {
610
+ const out = [];
611
+ lines.forEach((line, i) => {
612
+ const idx = line.indexOf('monarch-ignore-next-line');
613
+ if (idx === -1)
614
+ return;
615
+ const m = SUPPRESS_RE.exec(line.slice(idx));
616
+ if (!m)
617
+ return;
618
+ const rules = (m[1] ?? '')
619
+ .split(/[\s,]+/)
620
+ .map((r) => r.trim())
621
+ .filter(Boolean);
622
+ const reason = m[2]?.replace(/\*\/\s*\}?\s*$/, '').trim() || null;
623
+ // Target the next line of code, skipping comment-only lines, so the
624
+ // directive can sit alongside eslint-disable-next-line and similar.
625
+ let t = i + 1;
626
+ while (t < lines.length && COMMENT_ONLY.test(lines[t] ?? ''))
627
+ t++;
628
+ out.push({ target: t + 1, rules, reason, line: i + 1, column: idx + 1 });
629
+ });
630
+ return out;
631
+ }
632
+ export function ruleMatches(pattern, rule) {
633
+ if (pattern === '*' || pattern === rule)
634
+ return true;
635
+ if (pattern.endsWith('/*'))
636
+ return rule.startsWith(pattern.slice(0, -1));
637
+ return false;
638
+ }
639
+ //# sourceMappingURL=rules.js.map