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