@etus/seven-skill 0.1.0-beta.1

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 (42) hide show
  1. package/.claude/skills/seven/SKILL.md +162 -0
  2. package/.claude/skills/seven/reference/audit.md +120 -0
  3. package/.claude/skills/seven/reference/brand.md +24 -0
  4. package/.claude/skills/seven/reference/clarify.md +76 -0
  5. package/.claude/skills/seven/reference/color-and-contrast.md +84 -0
  6. package/.claude/skills/seven/reference/motion-design.md +71 -0
  7. package/.claude/skills/seven/reference/polish.md +55 -0
  8. package/.claude/skills/seven/reference/product.md +45 -0
  9. package/.claude/skills/seven/reference/shape.md +85 -0
  10. package/.claude/skills/seven/reference/spatial-design.md +60 -0
  11. package/.claude/skills/seven/reference/typography.md +43 -0
  12. package/.claude/skills/seven/reference/ux-writing.md +47 -0
  13. package/.claude/skills/seven/scripts/load-context.mjs +84 -0
  14. package/.claude-plugin/marketplace.json +34 -0
  15. package/.claude-plugin/plugin.json +12 -0
  16. package/LICENSE +190 -0
  17. package/NOTICE.md +26 -0
  18. package/README.md +118 -0
  19. package/cli/bin/commands/skills.mjs +664 -0
  20. package/cli/bin/seven.mjs +68 -0
  21. package/cli/engine/browser/injected/index.mjs +84 -0
  22. package/cli/engine/cli/main.mjs +215 -0
  23. package/cli/engine/detect-antipatterns-browser.js +3014 -0
  24. package/cli/engine/detect-antipatterns.mjs +44 -0
  25. package/cli/engine/engines/browser/detect-url.mjs +108 -0
  26. package/cli/engine/engines/regex/detect-text.mjs +508 -0
  27. package/cli/engine/engines/static-html/css-cascade.mjs +957 -0
  28. package/cli/engine/engines/static-html/detect-html.mjs +211 -0
  29. package/cli/engine/engines/visual/screenshot-contrast.mjs +192 -0
  30. package/cli/engine/findings.mjs +28 -0
  31. package/cli/engine/node/file-system.mjs +212 -0
  32. package/cli/engine/profile/profiler.mjs +169 -0
  33. package/cli/engine/registry/seven-antipatterns.mjs +494 -0
  34. package/cli/engine/rules/checks.mjs +1518 -0
  35. package/cli/engine/shared/color.mjs +204 -0
  36. package/cli/engine/shared/constants.mjs +91 -0
  37. package/cli/engine/shared/page.mjs +9 -0
  38. package/cli/engine/shared/tokens.mjs +153 -0
  39. package/cli/engine/token-data.generated.mjs +691 -0
  40. package/docs/detector-rules.md +605 -0
  41. package/docs/figma-token-rule-exploration.md +185 -0
  42. package/package.json +76 -0
@@ -0,0 +1,44 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // ETUS Digital — Seven Design System. Detector architecture adapted from
3
+ // impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
4
+
5
+ /**
6
+ * Public API facade for the Seven anti-pattern detector. Runtime engines live
7
+ * under cli/engine/engines/. Importing this module gives programmatic access;
8
+ * running it directly invokes the CLI.
9
+ */
10
+
11
+ import { detectCli } from './cli/main.mjs'
12
+
13
+ export {
14
+ ANTIPATTERNS,
15
+ RULE_ENGINE_SUPPORT,
16
+ RULE_EXEMPTIONS,
17
+ getAntipattern,
18
+ getRulesForCategory,
19
+ getRuleEngineSupport,
20
+ getRuleExemption,
21
+ getRuleIds,
22
+ } from './registry/seven-antipatterns.mjs'
23
+ export { CHECKS, runChecks } from './rules/checks.mjs'
24
+ export { finding } from './findings.mjs'
25
+ export { detectText } from './engines/regex/detect-text.mjs'
26
+ export { detectHtml } from './engines/static-html/detect-html.mjs'
27
+ export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.mjs'
28
+ export {
29
+ walkDir,
30
+ findOwningPackageName,
31
+ buildImportGraph,
32
+ detectFrameworkConfig,
33
+ isPortListening,
34
+ SCANNABLE_EXTENSIONS,
35
+ HTML_EXTENSIONS,
36
+ SKIP_DIRS,
37
+ FRAMEWORK_CONFIGS,
38
+ } from './node/file-system.mjs'
39
+ export { detectCli, formatFindings, collectFindings } from './cli/main.mjs'
40
+
41
+ const invokedDirectly =
42
+ process.argv[1]?.endsWith('detect-antipatterns.mjs') ||
43
+ process.argv[1]?.endsWith('detect-antipatterns.mjs/')
44
+ if (invokedDirectly) detectCli()
@@ -0,0 +1,108 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // ETUS Digital — Seven Design System. Detector architecture adapted from
3
+ // impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
4
+
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ import { finding } from '../../findings.mjs'
10
+
11
+ // ─── Browser engine ─────────────────────────────────────────────────────────
12
+ // Live-URL detector. Renders the page with Puppeteer (an optionalDependency),
13
+ // injects the generated browser bundle (cli/engine/detect-antipatterns-
14
+ // browser.js — registry + checks + the in-browser detector concatenated into
15
+ // one IIFE), and calls `window.sevenDetect()`. Every element is checked with
16
+ // the real `getComputedStyle`, so cascade-, layout-, and runtime-CSS-
17
+ // dependent drift surfaces — the deep DOM walk static analysis cannot do.
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
20
+ const BROWSER_BUNDLE_PATH = path.resolve(__dirname, '..', '..', 'detect-antipatterns-browser.js')
21
+
22
+ async function loadPuppeteer() {
23
+ try {
24
+ const mod = await import('puppeteer')
25
+ return mod.default || mod
26
+ } catch {
27
+ throw new Error(
28
+ 'The browser engine needs the optional `puppeteer` dependency. Install it with `pnpm add -D puppeteer`, or scan local files instead of a URL.'
29
+ )
30
+ }
31
+ }
32
+
33
+ function readBrowserBundle() {
34
+ try {
35
+ return fs.readFileSync(BROWSER_BUNDLE_PATH, 'utf-8')
36
+ } catch {
37
+ throw new Error(
38
+ `Browser detector bundle not found at ${BROWSER_BUNDLE_PATH}. Rebuild it with \`node build/build-browser-detector.mjs\`.`
39
+ )
40
+ }
41
+ }
42
+
43
+ // CI runners (GitHub Actions Ubuntu) block unprivileged user namespaces, so
44
+ // Chrome cannot initialize its sandbox there. Disable the sandbox only in CI.
45
+ function launchArgs() {
46
+ return process.env['CI'] ? ['--no-sandbox', '--disable-setuid-sandbox'] : []
47
+ }
48
+
49
+ /**
50
+ * Scan one already-loaded page: inject the bundle, run the in-browser
51
+ * detector, normalize the findings. Shared by the one-shot and reusable paths.
52
+ */
53
+ async function scanPage(page, url, browserBundle, options = {}) {
54
+ const { ruleIds = null, only = null } = options
55
+ await page.evaluate(browserBundle)
56
+ const raw = await page.evaluate(() => {
57
+ if (typeof window.sevenDetect !== 'function') return []
58
+ return window.sevenDetect()
59
+ })
60
+ const restrict = ruleIds || only
61
+ let findings = raw.map((f) => finding(f.type, url, f.detail))
62
+ if (restrict && restrict.length > 0) {
63
+ findings = findings.filter((f) => restrict.includes(f.antipattern))
64
+ }
65
+ return findings
66
+ }
67
+
68
+ /**
69
+ * Long-lived browser detector for scanning several URLs in one process.
70
+ * @returns {Promise<{ browser: object, detectUrl: Function, close: Function }>}
71
+ */
72
+ export async function createBrowserDetector(options = {}) {
73
+ const puppeteer = await loadPuppeteer()
74
+ const browserBundle = readBrowserBundle()
75
+ const browser = await puppeteer.launch({ headless: true, args: launchArgs() })
76
+ const viewport = options.viewport || { width: 1280, height: 800 }
77
+ return {
78
+ browser,
79
+ async detectUrl(url, scanOptions = {}) {
80
+ const page = await browser.newPage()
81
+ try {
82
+ await page.setViewport(viewport)
83
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 })
84
+ return await scanPage(page, url, browserBundle, scanOptions)
85
+ } finally {
86
+ await page.close().catch(() => {})
87
+ }
88
+ },
89
+ async close() {
90
+ await browser.close().catch(() => {})
91
+ },
92
+ }
93
+ }
94
+
95
+ /**
96
+ * One-shot URL scan. Boots a browser, scans, tears down.
97
+ * @param {string} url
98
+ * @param {{ ruleIds?: string[], only?: string[] }} [options]
99
+ * @returns {Promise<Array>} normalized findings
100
+ */
101
+ export async function detectUrl(url, options = {}) {
102
+ const detector = await createBrowserDetector()
103
+ try {
104
+ return await detector.detectUrl(url, options)
105
+ } finally {
106
+ await detector.close()
107
+ }
108
+ }
@@ -0,0 +1,508 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // ETUS Digital — Seven Design System. Detector architecture adapted from
3
+ // impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
4
+
5
+ import { isFullPage } from '../../shared/page.mjs'
6
+ import { finding } from '../../findings.mjs'
7
+ import { profileFindings, profileStep } from '../../profile/profiler.mjs'
8
+ import { runChecks } from '../../rules/checks.mjs'
9
+
10
+ // ─── Regex engine ───────────────────────────────────────────────────────────
11
+ // Source-file detector. Two pipelines run over the raw file text:
12
+ // 1. The Seven text checks (`runChecks`) — font, token, type-hint, fuchsia,
13
+ // pt-PT vocab, pure black/white, emoji, em-dash. They scan the whole file,
14
+ // so inline style= and class= attributes are covered without extraction.
15
+ // 2. Regex matchers + page analyzers — the CSS/Tailwind shapes of the
16
+ // element- and page-level rules (side stripe, gradient text, bounce
17
+ // easing, layout transition, monotonous spacing, flat hierarchy, centered
18
+ // everything, glow). These mirror what the static-html cascade resolves,
19
+ // so a CSS or JSX file with no live document still surfaces the drift.
20
+ // This is the default engine for non-HTML files and for `--fast` mode.
21
+
22
+ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line)
23
+ const hasBorderRadius = (line) => /border-radius/i.test(line)
24
+ const isSafeElement = (line) =>
25
+ /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line)
26
+
27
+ function isNeutralBorderColor(str) {
28
+ const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i)
29
+ if (!m) return false
30
+ const c = m[1].toLowerCase()
31
+ if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true
32
+ const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/)
33
+ if (hex) {
34
+ const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)]
35
+ return Math.max(r, g, b) - Math.min(r, g, b) < 30
36
+ }
37
+ const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/)
38
+ if (shex) {
39
+ const [r, g, b] = [
40
+ parseInt(shex[1] + shex[1], 16),
41
+ parseInt(shex[2] + shex[2], 16),
42
+ parseInt(shex[3] + shex[3], 16),
43
+ ]
44
+ return Math.max(r, g, b) - Math.min(r, g, b) < 30
45
+ }
46
+ return false
47
+ }
48
+
49
+ // A Tailwind border class carrying a real hue or a Seven semantic color —
50
+ // i.e. an accent, not a neutral structural divider. `border-muted`,
51
+ // `border-border`, `border-gray-*` are neutral and never a side-stripe.
52
+ const COLORED_BORDER_CLASS_RE =
53
+ /\bborder-(?:[lrtbxy]-)?(?:(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}|primary|secondary|destructive|warning|success|info|accent|brand)\b/
54
+ const hasColoredSideAccent = (line) => COLORED_BORDER_CLASS_RE.test(line)
55
+
56
+ // Per-line matchers — each emits one Seven rule ID. `test(match, line)` gates
57
+ // the hit; `fmt(match, line)` builds the snippet.
58
+ const REGEX_MATCHERS = [
59
+ // --- Side-stripe border ---
60
+ // A Tailwind side border is a stripe only when it is >=2px AND carries a
61
+ // colored accent. A bare `border-l-2` (default/neutral color) is a
62
+ // structural divider, not the AI side-tab anti-pattern.
63
+ {
64
+ id: 'side-stripe-border',
65
+ regex: /\bborder-[lr]-(\d+)\b/g,
66
+ test: (m, line) => +m[1] >= 2 && hasColoredSideAccent(line),
67
+ fmt: (m) => m[0],
68
+ },
69
+ {
70
+ id: 'side-stripe-border',
71
+ regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
72
+ test: (m, line) => {
73
+ if (isSafeElement(line)) return false
74
+ if (isNeutralBorderColor(m[0])) return false
75
+ return +m[1] >= 2
76
+ },
77
+ fmt: (m) => m[0].replace(/\s*;?\s*$/, ''),
78
+ },
79
+ {
80
+ id: 'side-stripe-border',
81
+ regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
82
+ test: (m, line) => !isSafeElement(line) && +m[1] >= 2,
83
+ fmt: (m) => m[0],
84
+ },
85
+ {
86
+ id: 'side-stripe-border',
87
+ regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi,
88
+ test: (m, line) => !isSafeElement(line) && +m[1] >= 2,
89
+ fmt: (m) => m[0],
90
+ },
91
+ {
92
+ id: 'side-stripe-border',
93
+ regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi,
94
+ test: (m, line) => !isSafeElement(line) && +m[1] >= 2,
95
+ fmt: (m) => m[0],
96
+ },
97
+ {
98
+ id: 'side-stripe-border',
99
+ regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g,
100
+ test: (m) => +m[1] >= 2,
101
+ fmt: (m) => m[0],
102
+ },
103
+ // --- Pill on button ---
104
+ {
105
+ id: 'pill-on-button',
106
+ regex: /<button\b[^>]*\brounded-full\b[^>]*>/gi,
107
+ test: () => true,
108
+ fmt: () => 'rounded-full on <button>',
109
+ },
110
+ {
111
+ id: 'pill-on-button',
112
+ regex: /<button\b[^>]*border-radius\s*:\s*9999/gi,
113
+ test: () => true,
114
+ fmt: () => 'border-radius: 9999 on <button>',
115
+ },
116
+ // --- Decorative gradient text ---
117
+ {
118
+ id: 'gradient-text-decorative',
119
+ regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
120
+ test: (m, line) => /gradient/i.test(line),
121
+ fmt: () => 'background-clip: text + gradient',
122
+ },
123
+ {
124
+ id: 'gradient-text-decorative',
125
+ regex: /\bbg-clip-text\b/g,
126
+ test: (m, line) => /\bbg-gradient-to-/i.test(line),
127
+ fmt: () => 'bg-clip-text + bg-gradient',
128
+ },
129
+ // --- Gray on colored background ---
130
+ {
131
+ id: 'gray-on-color',
132
+ regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
133
+ test: (m, line) =>
134
+ /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(
135
+ line
136
+ ),
137
+ fmt: (m, line) => {
138
+ const bg = line.match(
139
+ /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/
140
+ )
141
+ return `${m[0]} on ${bg?.[0] || '?'}`
142
+ },
143
+ },
144
+ // --- Bounce / elastic easing ---
145
+ {
146
+ id: 'bounce-easing',
147
+ regex: /\banimate-bounce\b/g,
148
+ test: () => true,
149
+ fmt: () => 'animate-bounce (Tailwind)',
150
+ },
151
+ {
152
+ id: 'bounce-easing',
153
+ regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
154
+ test: () => true,
155
+ fmt: (m) => m[0],
156
+ },
157
+ {
158
+ id: 'bounce-easing',
159
+ regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
160
+ test: (m) => {
161
+ const y1 = parseFloat(m[2])
162
+ const y2 = parseFloat(m[4])
163
+ return y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1
164
+ },
165
+ fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})`,
166
+ },
167
+ // --- Layout property transition ---
168
+ {
169
+ id: 'layout-transition',
170
+ regex: /transition\s*:\s*([^;{}]+)/gi,
171
+ test: (m) => {
172
+ const val = m[1].toLowerCase()
173
+ if (/\ball\b/.test(val)) return false
174
+ return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val)
175
+ },
176
+ fmt: (m) => {
177
+ const found = m[1].match(
178
+ /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi
179
+ )
180
+ return `transition: ${found ? found.join(', ') : m[1].trim()}`
181
+ },
182
+ },
183
+ {
184
+ id: 'layout-transition',
185
+ regex: /transition-property\s*:\s*([^;{}]+)/gi,
186
+ test: (m) => {
187
+ const val = m[1].toLowerCase()
188
+ if (/\ball\b/.test(val)) return false
189
+ return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val)
190
+ },
191
+ fmt: (m) => {
192
+ const found = m[1].match(
193
+ /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi
194
+ )
195
+ return `transition-property: ${found ? found.join(', ') : m[1].trim()}`
196
+ },
197
+ },
198
+ ]
199
+
200
+ // Whole-file analyzers — one per page-level rule. Each returns an array of
201
+ // `finding(...)` records and runs only when the content is a full page.
202
+ const REGEX_ANALYZERS = [
203
+ // Flat type hierarchy
204
+ (content, filePath) => {
205
+ const sizes = new Set()
206
+ const REM = 16
207
+ let m
208
+ const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi
209
+ while ((m = sizeRe.exec(content)) !== null) {
210
+ const px = m[2] === 'px' ? +m[1] : +m[1] * REM
211
+ if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10)
212
+ }
213
+ const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi
214
+ while ((m = clampRe.exec(content)) !== null) {
215
+ sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10)
216
+ sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10)
217
+ }
218
+ const TW = {
219
+ 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20,
220
+ 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60,
221
+ 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128,
222
+ }
223
+ for (const [cls, px] of Object.entries(TW)) {
224
+ if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px)
225
+ }
226
+ if (sizes.size < 3) return []
227
+ const sorted = [...sizes].sort((a, b) => a - b)
228
+ const ratio = sorted[sorted.length - 1] / sorted[0]
229
+ if (ratio >= 2.0) return []
230
+ const lines = content.split('\n')
231
+ let line = 1
232
+ for (let i = 0; i < lines.length; i++) {
233
+ if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) {
234
+ line = i + 1
235
+ break
236
+ }
237
+ }
238
+ return [
239
+ finding(
240
+ 'flat-type-hierarchy',
241
+ filePath,
242
+ `Sizes: ${sorted.map((s) => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`,
243
+ line
244
+ ),
245
+ ]
246
+ },
247
+ // Monotonous spacing
248
+ (content, filePath) => {
249
+ const vals = []
250
+ let m
251
+ const pxRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi
252
+ while ((m = pxRe.exec(content)) !== null) {
253
+ const v = +m[1]
254
+ if (v > 0 && v < 200) vals.push(v)
255
+ }
256
+ const remRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi
257
+ while ((m = remRe.exec(content)) !== null) {
258
+ const v = Math.round(parseFloat(m[1]) * 16)
259
+ if (v > 0 && v < 200) vals.push(v)
260
+ }
261
+ const gapRe = /gap\s*:\s*(\d+)px/gi
262
+ while ((m = gapRe.exec(content)) !== null) vals.push(+m[1])
263
+ const twRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g
264
+ while ((m = twRe.exec(content)) !== null) vals.push(+m[1] * 4)
265
+ const rounded = vals.map((v) => Math.round(v / 4) * 4)
266
+ if (rounded.length < 10) return []
267
+ const counts = {}
268
+ for (const v of rounded) counts[v] = (counts[v] || 0) + 1
269
+ const maxCount = Math.max(...Object.values(counts))
270
+ const pct = maxCount / rounded.length
271
+ const unique = [...new Set(rounded)].filter((v) => v > 0)
272
+ if (pct <= 0.6 || unique.length > 3) return []
273
+ const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]
274
+ return [
275
+ finding(
276
+ 'monotonous-spacing',
277
+ filePath,
278
+ `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`
279
+ ),
280
+ ]
281
+ },
282
+ // Everything centered
283
+ (content, filePath) => {
284
+ const lines = content.split('\n')
285
+ let centered = 0
286
+ let total = 0
287
+ for (const line of lines) {
288
+ if (/<(?:h[1-6]|p|div|li|button)\b[^>]*>/i.test(line) && line.trim().length > 20) {
289
+ total++
290
+ if (/text-align\s*:\s*center/i.test(line) || /\btext-center\b/.test(line)) centered++
291
+ }
292
+ }
293
+ if (total < 5 || centered / total <= 0.7) return []
294
+ return [
295
+ finding(
296
+ 'everything-centered',
297
+ filePath,
298
+ `${centered}/${total} text elements centered (${Math.round((centered / total) * 100)}%)`
299
+ ),
300
+ ]
301
+ },
302
+ // Glow on a dark surface
303
+ (content, filePath) => {
304
+ const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi
305
+ const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/
306
+ const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content)
307
+ if (!hasDarkBg) return []
308
+ const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi
309
+ let m
310
+ while ((m = shadowRe.exec(content)) !== null) {
311
+ const val = m[1]
312
+ const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/)
313
+ if (!colorMatch) continue
314
+ const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]]
315
+ if (Math.max(r, g, b) - Math.min(r, g, b) < 30) continue
316
+ const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map((p) => +(p[1] || p[2]))
317
+ if (pxVals.length >= 3 && pxVals[2] > 4) {
318
+ const lines = content.substring(0, m.index).split('\n')
319
+ return [finding('shadows-not-borders', filePath, `colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)]
320
+ }
321
+ }
322
+ return []
323
+ },
324
+ ]
325
+
326
+ // Rule IDs in REGEX_ANALYZERS index order — used to gate analyzers by ruleIds
327
+ // and to label profiler events.
328
+ const ANALYZER_RULE_IDS = [
329
+ 'flat-type-hierarchy',
330
+ 'monotonous-spacing',
331
+ 'everything-centered',
332
+ 'shadows-not-borders',
333
+ ]
334
+
335
+ // ─── Style-block / CSS-in-JS extraction ─────────────────────────────────────
336
+
337
+ function extractStyleBlocks(content, ext) {
338
+ ext = ext.toLowerCase()
339
+ if (ext !== '.vue' && ext !== '.svelte') return []
340
+ const blocks = []
341
+ const re = /<style[^>]*>([\s\S]*?)<\/style>/gi
342
+ let m
343
+ while ((m = re.exec(content)) !== null) {
344
+ const before = content.substring(0, m.index)
345
+ const startLine = before.split('\n').length + 1
346
+ blocks.push({ content: m[1], startLine })
347
+ }
348
+ return blocks
349
+ }
350
+
351
+ const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx'])
352
+
353
+ function extractCSSinJS(content, ext) {
354
+ ext = ext.toLowerCase()
355
+ if (!CSS_IN_JS_EXTENSIONS.has(ext)) return []
356
+ const blocks = []
357
+ const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g
358
+ let m
359
+ while ((m = re.exec(content)) !== null) {
360
+ const before = content.substring(0, m.index)
361
+ const startLine = before.split('\n').length
362
+ blocks.push({ content: m[1], startLine })
363
+ }
364
+ return blocks
365
+ }
366
+
367
+ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
368
+ const { profile, phase = 'regex-matchers', ruleIds = null } = options || {}
369
+ const findings = []
370
+ const matchers = ruleIds ? REGEX_MATCHERS.filter((m) => ruleIds.includes(m.id)) : REGEX_MATCHERS
371
+ const scan = (matcher) => {
372
+ const matches = []
373
+ for (let i = 0; i < lines.length; i++) {
374
+ const line = lines[i]
375
+ matcher.regex.lastIndex = 0
376
+ let m
377
+ while ((m = matcher.regex.exec(line)) !== null) {
378
+ const context = blockContext
379
+ ? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')
380
+ : line
381
+ if (matcher.test(m, context)) {
382
+ matches.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset))
383
+ }
384
+ }
385
+ }
386
+ return matches
387
+ }
388
+ for (const matcher of matchers) {
389
+ if (!profile) {
390
+ findings.push(...scan(matcher))
391
+ continue
392
+ }
393
+ findings.push(
394
+ ...profileFindings(
395
+ profile,
396
+ { engine: 'regex', phase, ruleId: matcher.id, target: filePath },
397
+ () => scan(matcher)
398
+ )
399
+ )
400
+ }
401
+ return findings
402
+ }
403
+
404
+ /**
405
+ * @param {string} content raw file text
406
+ * @param {string} filePath path reported in findings
407
+ * @param {{ ruleIds?: string[], profile?: object }} [options]
408
+ * @returns {Array} normalized findings
409
+ */
410
+ function detectText(content, filePath, options = {}) {
411
+ const { ruleIds = null, profile } = options || {}
412
+ const findings = []
413
+ const lines = content.split('\n')
414
+ const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''
415
+
416
+ // Pipeline 1: the Seven text checks over the whole file.
417
+ const textFindings = profile
418
+ ? profileFindings(
419
+ profile,
420
+ { engine: 'regex', phase: 'text-checks', ruleId: 'seven-text-checks', target: filePath },
421
+ () => runChecks(content, ruleIds).map((r) => finding(r.id, filePath, r.snippet, r.line))
422
+ )
423
+ : runChecks(content, ruleIds).map((r) => finding(r.id, filePath, r.snippet, r.line))
424
+ findings.push(...textFindings)
425
+
426
+ // Pipeline 2: regex matchers over the source, plus extracted style blocks.
427
+ const cssLike = new Set(['.css', '.scss', '.less'])
428
+ findings.push(
429
+ ...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
430
+ profile,
431
+ phase: 'source',
432
+ ruleIds,
433
+ })
434
+ )
435
+
436
+ const styleBlocks = profile
437
+ ? profileStep(
438
+ profile,
439
+ { engine: 'regex', phase: 'extract', ruleId: 'style-blocks', target: filePath },
440
+ () => extractStyleBlocks(content, ext)
441
+ )
442
+ : extractStyleBlocks(content, ext)
443
+ for (const block of styleBlocks) {
444
+ const blockLines = block.content.split('\n')
445
+ findings.push(
446
+ ...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
447
+ profile,
448
+ phase: 'style-block',
449
+ ruleIds,
450
+ })
451
+ )
452
+ }
453
+
454
+ const cssJsBlocks = profile
455
+ ? profileStep(
456
+ profile,
457
+ { engine: 'regex', phase: 'extract', ruleId: 'css-in-js', target: filePath },
458
+ () => extractCSSinJS(content, ext)
459
+ )
460
+ : extractCSSinJS(content, ext)
461
+ for (const block of cssJsBlocks) {
462
+ const blockLines = block.content.split('\n')
463
+ findings.push(
464
+ ...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
465
+ profile,
466
+ phase: 'css-in-js',
467
+ ruleIds,
468
+ })
469
+ )
470
+ }
471
+
472
+ // Deduplicate (same antipattern + snippet within 2 lines).
473
+ const deduped = []
474
+ for (const f of findings) {
475
+ const isDupe = deduped.some(
476
+ (d) => d.antipattern === f.antipattern && d.snippet === f.snippet && Math.abs(d.line - f.line) <= 2
477
+ )
478
+ if (!isDupe) deduped.push(f)
479
+ }
480
+
481
+ // Pipeline 3: page-level analyzers — only on full pages.
482
+ if (isFullPage(content)) {
483
+ for (let i = 0; i < REGEX_ANALYZERS.length; i++) {
484
+ const ruleId = ANALYZER_RULE_IDS[i] || `analyzer-${i + 1}`
485
+ if (ruleIds && !ruleIds.includes(ruleId)) continue
486
+ const analyzer = REGEX_ANALYZERS[i]
487
+ deduped.push(
488
+ ...profileFindings(
489
+ profile,
490
+ { engine: 'regex', phase: 'page-analyzer', ruleId, target: filePath },
491
+ () => analyzer(content, filePath)
492
+ )
493
+ )
494
+ }
495
+ }
496
+
497
+ return deduped
498
+ }
499
+
500
+ export {
501
+ REGEX_MATCHERS,
502
+ REGEX_ANALYZERS,
503
+ ANALYZER_RULE_IDS,
504
+ extractStyleBlocks,
505
+ extractCSSinJS,
506
+ runRegexMatchers,
507
+ detectText,
508
+ }