@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.
- package/.claude/skills/seven/SKILL.md +162 -0
- package/.claude/skills/seven/reference/audit.md +120 -0
- package/.claude/skills/seven/reference/brand.md +24 -0
- package/.claude/skills/seven/reference/clarify.md +76 -0
- package/.claude/skills/seven/reference/color-and-contrast.md +84 -0
- package/.claude/skills/seven/reference/motion-design.md +71 -0
- package/.claude/skills/seven/reference/polish.md +55 -0
- package/.claude/skills/seven/reference/product.md +45 -0
- package/.claude/skills/seven/reference/shape.md +85 -0
- package/.claude/skills/seven/reference/spatial-design.md +60 -0
- package/.claude/skills/seven/reference/typography.md +43 -0
- package/.claude/skills/seven/reference/ux-writing.md +47 -0
- package/.claude/skills/seven/scripts/load-context.mjs +84 -0
- package/.claude-plugin/marketplace.json +34 -0
- package/.claude-plugin/plugin.json +12 -0
- package/LICENSE +190 -0
- package/NOTICE.md +26 -0
- package/README.md +118 -0
- package/cli/bin/commands/skills.mjs +664 -0
- package/cli/bin/seven.mjs +68 -0
- package/cli/engine/browser/injected/index.mjs +84 -0
- package/cli/engine/cli/main.mjs +215 -0
- package/cli/engine/detect-antipatterns-browser.js +3014 -0
- package/cli/engine/detect-antipatterns.mjs +44 -0
- package/cli/engine/engines/browser/detect-url.mjs +108 -0
- package/cli/engine/engines/regex/detect-text.mjs +508 -0
- package/cli/engine/engines/static-html/css-cascade.mjs +957 -0
- package/cli/engine/engines/static-html/detect-html.mjs +211 -0
- package/cli/engine/engines/visual/screenshot-contrast.mjs +192 -0
- package/cli/engine/findings.mjs +28 -0
- package/cli/engine/node/file-system.mjs +212 -0
- package/cli/engine/profile/profiler.mjs +169 -0
- package/cli/engine/registry/seven-antipatterns.mjs +494 -0
- package/cli/engine/rules/checks.mjs +1518 -0
- package/cli/engine/shared/color.mjs +204 -0
- package/cli/engine/shared/constants.mjs +91 -0
- package/cli/engine/shared/page.mjs +9 -0
- package/cli/engine/shared/tokens.mjs +153 -0
- package/cli/engine/token-data.generated.mjs +691 -0
- package/docs/detector-rules.md +605 -0
- package/docs/figma-token-rule-exploration.md +185 -0
- 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
|
+
}
|