@ryuxdsgn/design-intelligence 2.14.2 → 2.18.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 +7 -3
- package/dist/cli/hook.js +55 -0
- package/dist/cli/index.js +106 -26
- package/dist/cli/page-probe.js +228 -0
- package/dist/cli/page-scan.js +70 -0
- package/dist/cli/scan.js +160 -0
- package/dist/intelligence/content.js +51 -11
- package/dist/intelligence/guides.js +23 -11
- package/dist/render.js +9 -2
- package/dist/scan.js +343 -0
- package/package.json +1 -1
package/dist/scan.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// Deterministic detectors for RYUX rules a machine can check in source code. Pure: takes file
|
|
2
|
+
// contents, returns findings, no LLM and no network. Each detector names the RX rule it checks; the
|
|
3
|
+
// rule text stays in intelligence/content.ts. "fail" is for a pattern that is wrong wherever it
|
|
4
|
+
// appears; "warn" is for a signal a person or agent should look at.
|
|
5
|
+
/** What `ryux scan <url>` measures on the rendered page (cli/page-probe.ts), for --list-rules and the
|
|
6
|
+
* rule text. */
|
|
7
|
+
export const RENDER_CHECKS = [
|
|
8
|
+
{ rule: "RX-RD-01", level: "fail", looksFor: "a page that scrolls sideways at a scanned width, naming the widest elements" },
|
|
9
|
+
{ rule: "RX-A11Y-01", level: "fail", looksFor: "text under 4.5:1 contrast (3:1 when large) on a solid background; text over images or gradients is skipped" },
|
|
10
|
+
{ rule: "RX-A11Y-02", level: "fail", looksFor: "controls under 24x24px that are crowded by neighbours (warn when spaced as WCAG 2.5.8 allows); rendered text under 12px (warn)" },
|
|
11
|
+
{ rule: "RX-QA-03", level: "warn", looksFor: "text boxes of unrelated elements that overlap" },
|
|
12
|
+
{ rule: "RX-QA-08", level: "warn", looksFor: "script errors thrown while the page loads" },
|
|
13
|
+
];
|
|
14
|
+
const MARKUP = ["html", "htm", "jsx", "tsx", "vue", "svelte", "astro"];
|
|
15
|
+
const STYLE = ["css", "scss", "sass", "less"];
|
|
16
|
+
const SCRIPT = ["js", "mjs", "cjs", "ts"];
|
|
17
|
+
const CODE = [...MARKUP, ...SCRIPT];
|
|
18
|
+
/** Every extension `ryux scan` reads. Markdown is left out: docs quote wrong examples on purpose. */
|
|
19
|
+
export const SCAN_EXTS = [...MARKUP, ...STYLE, ...SCRIPT];
|
|
20
|
+
const short = (s) => {
|
|
21
|
+
const t = s.replace(/\s+/g, " ").trim();
|
|
22
|
+
return t.length > 48 ? `${t.slice(0, 45)}...` : t;
|
|
23
|
+
};
|
|
24
|
+
/** Every match of each pattern, as hits with the message built from the matched text. */
|
|
25
|
+
function matches(text, patterns) {
|
|
26
|
+
const hits = [];
|
|
27
|
+
for (const [re, message] of patterns) {
|
|
28
|
+
for (const m of text.matchAll(new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`))) {
|
|
29
|
+
const msg = message(m);
|
|
30
|
+
if (msg)
|
|
31
|
+
hits.push({ index: m.index, length: m[0].length, message: msg });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return hits;
|
|
35
|
+
}
|
|
36
|
+
/** The end of the tag that opens at `start` (index of "<"), skipping ">" inside quotes and {...}. */
|
|
37
|
+
function tagEnd(text, start) {
|
|
38
|
+
let quote = "";
|
|
39
|
+
let depth = 0;
|
|
40
|
+
for (let i = start + 1; i < text.length; i++) {
|
|
41
|
+
const c = text[i];
|
|
42
|
+
if (quote) {
|
|
43
|
+
if (c === quote)
|
|
44
|
+
quote = "";
|
|
45
|
+
}
|
|
46
|
+
else if (c === '"' || c === "'" || c === "`")
|
|
47
|
+
quote = c;
|
|
48
|
+
else if (c === "{")
|
|
49
|
+
depth++;
|
|
50
|
+
else if (c === "}")
|
|
51
|
+
depth = Math.max(0, depth - 1);
|
|
52
|
+
else if (c === ">" && depth === 0)
|
|
53
|
+
return i;
|
|
54
|
+
}
|
|
55
|
+
return -1;
|
|
56
|
+
}
|
|
57
|
+
/** Each opening tag of the given names, with its attribute text. */
|
|
58
|
+
function tags(text, names) {
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const m of text.matchAll(new RegExp(`<(${names})(?=[\\s/>])`, "g"))) {
|
|
61
|
+
const end = tagEnd(text, m.index);
|
|
62
|
+
if (end < 0)
|
|
63
|
+
continue;
|
|
64
|
+
out.push({ index: m.index, length: end - m.index + 1, name: m[1], attrs: text.slice(m.index + m[0].length, end) });
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
const RUPIAH_FIX = "write Rp1.250.000 (Rp right before the number, dots for thousands, no decimals)";
|
|
69
|
+
const px = (v) => Number.parseFloat(v);
|
|
70
|
+
export const DETECTORS = [
|
|
71
|
+
{
|
|
72
|
+
rule: "RX-CD-02",
|
|
73
|
+
level: "fail",
|
|
74
|
+
looksFor: "Rupiah written as Rp 1.000, Rp. 1.000, Rp1,000, Rp1.000,00, Rp1000, IDR 1000, or Rp followed by a space before a value",
|
|
75
|
+
exts: SCAN_EXTS,
|
|
76
|
+
find: ({ text }) => matches(text, [
|
|
77
|
+
[/\bRp\.?[ \u00a0]*\d[\d.,]*/, (m) => (/^Rp\d{1,3}(?:(?:\.\d{3})*|,\d{1,2})$/.test(m[0].replace(/[.,]$/, "")) && !/,00$/.test(m[0]) ? null : `"${short(m[0])}": ${RUPIAH_FIX}`)],
|
|
78
|
+
[/\bRp[ \u00a0]+(?:\{|\$\{)/, (m) => `"${short(m[0])}...": Rupiah with a space before the value; ${RUPIAH_FIX}`],
|
|
79
|
+
[/["'`]Rp\.?[ \u00a0]["'`]/, (m) => `${short(m[0])}: a Rupiah prefix with a space; ${RUPIAH_FIX}`],
|
|
80
|
+
[/\bIDR[ \u00a0]?\d[\d.,]*/, (m) => `"${short(m[0])}": ${RUPIAH_FIX}`],
|
|
81
|
+
]),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
rule: "RX-FE-12",
|
|
85
|
+
level: "warn",
|
|
86
|
+
looksFor: "Intl currency style with IDR, which prints Rp 1.250.000,00 (a space, and decimals unless set to 0)",
|
|
87
|
+
exts: CODE,
|
|
88
|
+
find: ({ text }) => matches(text, [
|
|
89
|
+
[/currency\s*:\s*["'`]IDR["'`]/, () => "Intl currency style writes a space after Rp (and ,00 unless maximumFractionDigits is 0): format with id-ID grouping and prepend Rp"],
|
|
90
|
+
]),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
rule: "RX-CD-09",
|
|
94
|
+
level: "warn",
|
|
95
|
+
looksFor: "dates as 10/02/2026 and times as 2:30 PM in copy",
|
|
96
|
+
exts: CODE,
|
|
97
|
+
find: ({ text }) => matches(text, [
|
|
98
|
+
[/(?<![\d/])\d{1,2}\/\d{1,2}\/\d{4}(?![\d/])/, (m) => `"${m[0]}": in Indonesian copy write 2 Okt 2026 (other markets follow their locale)`],
|
|
99
|
+
[/\b\d{1,2}[:.]\d{2}\s?(?:AM|PM|am|pm)\b/, (m) => `"${m[0]}": in Indonesian copy write 14.30 in 24-hour form (other markets follow their locale)`],
|
|
100
|
+
]),
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
rule: "RX-AS-03",
|
|
104
|
+
level: "fail",
|
|
105
|
+
looksFor: "lorem ipsum filler",
|
|
106
|
+
exts: CODE,
|
|
107
|
+
find: ({ text }) => matches(text, [[/\blorem ipsum\b/i, () => "lorem ipsum: use realistic sample content from the brief, labeled as sample data"]]),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
rule: "RX-AS-03",
|
|
111
|
+
level: "warn",
|
|
112
|
+
looksFor: "placeholder content: John Doe, Jane Doe, [DATA], [TBD], XXXX",
|
|
113
|
+
exts: CODE,
|
|
114
|
+
find: ({ text }) => matches(text, [
|
|
115
|
+
[/\b(?:John|Jane) Doe\b/, (m) => `"${m[0]}": a placeholder person; use realistic sample data and label it, or the real name`],
|
|
116
|
+
[/\[(?:REAL DATA|DATA|TBD|TODO|PLACEHOLDER)\]/i, (m) => `"${m[0]}": a placeholder where sample data would do; keep a visible placeholder only for an asset that does not exist yet`],
|
|
117
|
+
[/(?<![A-Za-z])X{4,}(?![A-Za-z])/, (m) => `"${m[0]}": placeholder characters; use realistic sample data, labeled`],
|
|
118
|
+
]),
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
rule: "RX-AS-04",
|
|
122
|
+
level: "warn",
|
|
123
|
+
looksFor: "urgency copy (only 2 left, hurry, offer ends tonight, hanya tersisa 2, buruan, stok terbatas)",
|
|
124
|
+
exts: CODE,
|
|
125
|
+
find: ({ text }) => matches(text, [
|
|
126
|
+
[
|
|
127
|
+
/\bonly \d+ (?:left|remaining)\b|\b(?:offer|sale|deal|promo) ends (?:tonight|today|soon)\b|\bhurry\b|\blimited time only\b|\bdon'?t miss out\b|\bhanya tersisa \d+|\btinggal \d+ lagi\b|\bburuan\b|\b(?:promo|diskon|penawaran) berakhir (?:malam ini|hari ini|segera)\b|\bjangan sampai kehabisan\b|\bstok terbatas\b/i,
|
|
128
|
+
(m) => `"${m[0]}": urgency copy; keep it only when the deadline or quota is real, stated as a plain fact`,
|
|
129
|
+
],
|
|
130
|
+
]),
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
rule: "RX-A11Y-02",
|
|
134
|
+
level: "fail",
|
|
135
|
+
looksFor: "zoom turned off: user-scalable=no, maximum-scale=1, userScalable: false, maximumScale: 1",
|
|
136
|
+
exts: CODE,
|
|
137
|
+
find: ({ text }) => matches(text, [
|
|
138
|
+
[/user-scalable\s*=\s*["']?(?:no|0)\b|maximum-scale\s*=\s*["']?1(?:\.0+)?(?![\d.])|userScalable\s*:\s*false|maximumScale\s*:\s*1(?![\d.])/i, (m) => `"${m[0]}": people must be able to zoom; remove it`],
|
|
139
|
+
]),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
rule: "RX-A11Y-02",
|
|
143
|
+
level: "warn",
|
|
144
|
+
looksFor: "text set below 12px (CSS font-size, Tailwind text-[Npx], fontSize in style objects)",
|
|
145
|
+
exts: [...MARKUP, ...STYLE, ...SCRIPT],
|
|
146
|
+
find: ({ text }) => matches(text, [
|
|
147
|
+
[/font-size\s*:\s*(\d+(?:\.\d+)?)px|\btext-\[(\d+(?:\.\d+)?)px\]|fontSize\s*:\s*["'`]?(\d+(?:\.\d+)?)(?:px)?["'`]?(?=\s*[,}])/, (m) => {
|
|
148
|
+
const v = px(m[1] ?? m[2] ?? m[3]);
|
|
149
|
+
return v < 12 ? `${v}px text: body text is 14 to 16px on mobile; below 12px is hard to read` : null;
|
|
150
|
+
}],
|
|
151
|
+
]),
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
rule: "RX-A11Y-03",
|
|
155
|
+
level: "fail",
|
|
156
|
+
looksFor: "a positive tabindex, which takes the element out of the visual focus order",
|
|
157
|
+
exts: MARKUP,
|
|
158
|
+
find: ({ text }) => matches(text, [[/\btab[iI]ndex\s*=\s*(?:["']|\{\s*)?([1-9]\d*)/, (m) => `tabindex ${m[1]}: keep focus order the same as the visual order; use 0 or the DOM order`]]),
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
rule: "RX-A11Y-03",
|
|
162
|
+
level: "warn",
|
|
163
|
+
looksFor: "focus outlines removed on a control (a button, link, input, or :focus rule) with no focus-visible replacement",
|
|
164
|
+
exts: [...MARKUP, ...STYLE],
|
|
165
|
+
find: ({ ext, text }) => {
|
|
166
|
+
const removed = /outline\s*:\s*["']?(?:none|0)\b|\boutline-none\b/;
|
|
167
|
+
const replaced = /focus-visible|focus:(?:ring|border|shadow|bg|underline|outline-(?!none))/;
|
|
168
|
+
const message = "focus outline removed on a control with no focus-visible style: show a visible focus indicator";
|
|
169
|
+
if (STYLE.includes(ext)) {
|
|
170
|
+
if (replaced.test(text))
|
|
171
|
+
return [];
|
|
172
|
+
const hits = [];
|
|
173
|
+
for (const m of text.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
|
|
174
|
+
const target = /(?:^|[\s,>+~])(?:button|a|input|select|textarea|summary)\b|:focus(?!-visible|-within)|\[role|\.btn|\*/.test(m[1]);
|
|
175
|
+
const r = removed.exec(m[2]);
|
|
176
|
+
if (target && r)
|
|
177
|
+
hits.push({ index: m.index + m[1].length + 1 + r.index, length: r[0].length, message });
|
|
178
|
+
}
|
|
179
|
+
return hits;
|
|
180
|
+
}
|
|
181
|
+
return tags(text, "button|a|input|select|textarea|summary|div|span|li")
|
|
182
|
+
.filter((t) => {
|
|
183
|
+
if (!removed.test(t.attrs) || replaced.test(t.attrs) || /\btab[iI]ndex\s*=\s*(?:["']|\{\s*)?-1/.test(t.attrs))
|
|
184
|
+
return false;
|
|
185
|
+
return !/^(?:div|span|li)$/.test(t.name) || /\brole\s*=\s*["']?(?:button|link|tab|menuitem|option|checkbox|switch)|(?:^|\s)(?:onClick|onclick|@click|on:click)\s*=/.test(t.attrs);
|
|
186
|
+
})
|
|
187
|
+
.map((t) => ({ index: t.index, length: t.length, message }));
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
rule: "RX-A11Y-04",
|
|
192
|
+
level: "fail",
|
|
193
|
+
looksFor: "<img> with no alt attribute",
|
|
194
|
+
exts: MARKUP,
|
|
195
|
+
find: ({ text }) => tags(text, "img")
|
|
196
|
+
.filter((t) => !/\balt\s*=/.test(t.attrs) && !/\{\s*\.\.\./.test(t.attrs))
|
|
197
|
+
.map((t) => ({ index: t.index, length: t.length, message: 'an <img> with no alt: describe meaningful images, use alt="" for decorative ones' })),
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
rule: "RX-A11Y-04",
|
|
201
|
+
level: "warn",
|
|
202
|
+
looksFor: "a clickable <div> or <span> with no role",
|
|
203
|
+
exts: MARKUP,
|
|
204
|
+
find: ({ text }) => tags(text, "div|span")
|
|
205
|
+
.filter((t) => /(?:^|\s)(?:onClick|onclick|@click|v-on:click|on:click)\s*=/.test(t.attrs) && !/\brole\s*=/.test(t.attrs))
|
|
206
|
+
.map((t) => ({ index: t.index, length: t.length, message: `a clickable <${t.name}>: use a <button> or <a> so it works from the keyboard and has a role` })),
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
rule: "RX-A11Y-04",
|
|
210
|
+
level: "warn",
|
|
211
|
+
looksFor: "an icon-only <button> with no accessible name",
|
|
212
|
+
exts: MARKUP,
|
|
213
|
+
find: ({ text }) => tags(text, "button").flatMap((t) => {
|
|
214
|
+
if (/\baria-label(?:ledby)?\s*=|\btitle\s*=|\{\s*\.\.\./.test(t.attrs))
|
|
215
|
+
return [];
|
|
216
|
+
const close = text.indexOf("</button>", t.index + t.length);
|
|
217
|
+
if (close < 0)
|
|
218
|
+
return [];
|
|
219
|
+
const inner = text.slice(t.index + t.length, close);
|
|
220
|
+
const icon = /<svg\b|<i\b|<[A-Z]\w*/.test(inner);
|
|
221
|
+
const named = /\balt\s*=\s*["'][^"']+["']|\baria-label\s*=/.test(inner);
|
|
222
|
+
const words = inner.replace(/<[^>]*>/g, "").trim();
|
|
223
|
+
return icon && !named && !words ? [{ index: t.index, length: t.length, message: "an icon-only <button> with no name: add aria-label or visually hidden text" }] : [];
|
|
224
|
+
}),
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
rule: "RX-A11Y-04",
|
|
228
|
+
level: "warn",
|
|
229
|
+
looksFor: "a heading that skips a level (h2 straight to h4)",
|
|
230
|
+
exts: MARKUP,
|
|
231
|
+
find: ({ text }) => {
|
|
232
|
+
const hits = [];
|
|
233
|
+
let prev = 0;
|
|
234
|
+
for (const m of text.matchAll(/<h([1-6])(?=[\s>])/g)) {
|
|
235
|
+
const level = Number(m[1]);
|
|
236
|
+
if (prev && level > prev + 1)
|
|
237
|
+
hits.push({ index: m.index, length: m[0].length, message: `h${prev} then h${level}: headings skip a level; use h${prev + 1}` });
|
|
238
|
+
prev = level;
|
|
239
|
+
}
|
|
240
|
+
return hits;
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
rule: "RX-A11Y-08",
|
|
245
|
+
level: "warn",
|
|
246
|
+
looksFor: "animation in a folder where nothing handles prefers-reduced-motion",
|
|
247
|
+
exts: [...MARKUP, ...STYLE, ...SCRIPT],
|
|
248
|
+
folder: true,
|
|
249
|
+
find: ({ text }, ctx) => {
|
|
250
|
+
if (ctx.reducedMotion !== false)
|
|
251
|
+
return [];
|
|
252
|
+
const m = /@keyframes\b|\banimation\s*:|\banimate-[a-z]|<motion\.\w+/.exec(text);
|
|
253
|
+
return m ? [{ index: m.index, length: m[0].length, message: `"${m[0]}" and nothing in the scanned folder handles prefers-reduced-motion: replace large movement with a fade or a cut when it is set` }] : [];
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
rule: "RX-DS-03",
|
|
258
|
+
level: "warn",
|
|
259
|
+
looksFor: "three or more one-off hex colors in a file that is not a token or theme file",
|
|
260
|
+
exts: [...MARKUP, ...STYLE, ...SCRIPT],
|
|
261
|
+
find: ({ path, text }) => {
|
|
262
|
+
if (/token|theme|colou?r|palette|tailwind\.config/i.test(path))
|
|
263
|
+
return [];
|
|
264
|
+
const lines = text.split("\n");
|
|
265
|
+
let offset = 0;
|
|
266
|
+
const found = [];
|
|
267
|
+
for (const line of lines) {
|
|
268
|
+
if (!/--[\w-]+\s*:/.test(line)) {
|
|
269
|
+
for (const m of line.matchAll(/(?<=[:(,[]\s*|["'`])(?<!(?:fill|stroke|stop-color)=["'])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b/g)) {
|
|
270
|
+
found.push({ index: offset + m.index, value: m[0].toLowerCase() });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
offset += line.length + 1;
|
|
274
|
+
}
|
|
275
|
+
const distinct = [...new Set(found.map((f) => f.value))];
|
|
276
|
+
if (distinct.length < 3)
|
|
277
|
+
return [];
|
|
278
|
+
return [{ index: found[0].index, length: found[0].value.length, message: `${distinct.length} one-off colors (${distinct.slice(0, 3).join(", ")}${distinct.length > 3 ? ", ..." : ""}): use the color tokens, or define them by role` }];
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
];
|
|
282
|
+
/** Blank out comments (keeping length and line breaks) so commented-out code is not scanned. */
|
|
283
|
+
export function stripComments(raw, ext) {
|
|
284
|
+
const blank = (s) => s.replace(/[^\n]/g, " ");
|
|
285
|
+
let text = raw.replace(/\/\*[\s\S]*?\*\//g, blank).replace(/<!--[\s\S]*?-->/g, blank);
|
|
286
|
+
if (ext !== "css") {
|
|
287
|
+
text = text.replace(/(^|[^:\\"'`])(\/\/[^\n]*)/g, (_, pre, c) => pre + blank(c));
|
|
288
|
+
}
|
|
289
|
+
return text;
|
|
290
|
+
}
|
|
291
|
+
export function toSource(path, raw) {
|
|
292
|
+
const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
|
|
293
|
+
return { path, ext, raw, text: stripComments(raw, ext) };
|
|
294
|
+
}
|
|
295
|
+
/** Rules switched off by `ryux-ignore RX-..` (this line and the next) or `ryux-ignore-file RX-..`.
|
|
296
|
+
* With no IDs, every rule is switched off there. */
|
|
297
|
+
function ignores(raw) {
|
|
298
|
+
const file = new Set();
|
|
299
|
+
const lines = new Map();
|
|
300
|
+
raw.split("\n").forEach((line, i) => {
|
|
301
|
+
const m = /ryux-ignore(-file)?((?:[\s,]+RX-[A-Z0-9]+-\d+)*)/.exec(line);
|
|
302
|
+
if (!m)
|
|
303
|
+
return;
|
|
304
|
+
const ids = m[2].match(/RX-[A-Z0-9]+-\d+/g) ?? ["*"];
|
|
305
|
+
if (m[1])
|
|
306
|
+
ids.forEach((id) => file.add(id));
|
|
307
|
+
else
|
|
308
|
+
for (const n of [i + 1, i + 2])
|
|
309
|
+
lines.set(n, new Set([...(lines.get(n) ?? []), ...ids]));
|
|
310
|
+
});
|
|
311
|
+
return { file, lines };
|
|
312
|
+
}
|
|
313
|
+
function lineCol(raw, index) {
|
|
314
|
+
const before = raw.slice(0, index);
|
|
315
|
+
const line = before.split("\n").length;
|
|
316
|
+
return { line, column: index - before.lastIndexOf("\n") };
|
|
317
|
+
}
|
|
318
|
+
/** Run every detector over the sources. Findings are ordered by file, then line. */
|
|
319
|
+
export function scanSources(sources, opts) {
|
|
320
|
+
const ctx = opts.folder
|
|
321
|
+
? { reducedMotion: sources.some((s) => /prefers-reduced-motion|motion-(?:reduce|safe):|useReducedMotion|reducedMotion/.test(s.text)) }
|
|
322
|
+
: {};
|
|
323
|
+
const findings = [];
|
|
324
|
+
for (const src of sources) {
|
|
325
|
+
const ign = ignores(src.raw);
|
|
326
|
+
for (const d of DETECTORS) {
|
|
327
|
+
if (!d.exts.includes(src.ext) || opts.skip?.has(d.rule) || ign.file.has(d.rule) || ign.file.has("*"))
|
|
328
|
+
continue;
|
|
329
|
+
if (d.folder && !opts.folder)
|
|
330
|
+
continue;
|
|
331
|
+
const seen = new Set();
|
|
332
|
+
for (const hit of d.find(src, ctx)) {
|
|
333
|
+
const { line, column } = lineCol(src.raw, hit.index);
|
|
334
|
+
const off = ign.lines.get(line);
|
|
335
|
+
if (seen.has(hit.index) || off?.has(d.rule) || off?.has("*"))
|
|
336
|
+
continue;
|
|
337
|
+
seen.add(hit.index);
|
|
338
|
+
findings.push({ level: d.level, rule: d.rule, file: src.path, line, column, message: hit.message });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column);
|
|
343
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ryuxdsgn/design-intelligence",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.18.0",
|
|
4
4
|
"description": "RYUX: design intelligence for AI agents and designers. One skill, five entry points: Analyze, Design, Build, Critique, and QA, for Claude Code, Codex, Cursor, Gemini CLI, OpenCode, Cline, Copilot, and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|