similarbuild 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/LICENSE +21 -0
  3. package/README.md +301 -0
  4. package/bin/install.js +256 -0
  5. package/lib/copy-templates.mjs +52 -0
  6. package/lib/install-deps.mjs +62 -0
  7. package/lib/prompt-config.mjs +83 -0
  8. package/lib/verify-env.mjs +19 -0
  9. package/package.json +63 -0
  10. package/scripts/sync-templates.mjs +71 -0
  11. package/templates/commands/build-page.md +490 -0
  12. package/templates/commands/build-site.md +548 -0
  13. package/templates/commands/clip-section.md +519 -0
  14. package/templates/memory/anti-patterns.md +212 -0
  15. package/templates/memory/design-knowledge.md +225 -0
  16. package/templates/memory/fixes.md +163 -0
  17. package/templates/memory/patterns.md +681 -0
  18. package/templates/presets/shopify-section.yaml +51 -0
  19. package/templates/presets/wp-elementor.yaml +49 -0
  20. package/templates/reports/fixtures/mock-run-1.json +115 -0
  21. package/templates/reports/fixtures/mock-run-2.json +72 -0
  22. package/templates/reports/report-renderer.mjs +218 -0
  23. package/templates/reports/report-template.html +571 -0
  24. package/templates/skills/sb-build-shopify/SKILL.md +104 -0
  25. package/templates/skills/sb-build-shopify/references/shopify-build-rules.md +563 -0
  26. package/templates/skills/sb-build-shopify/scripts/build-shopify.mjs +637 -0
  27. package/templates/skills/sb-build-shopify/scripts/tests/test-build-shopify.mjs +424 -0
  28. package/templates/skills/sb-build-wp/SKILL.md +83 -0
  29. package/templates/skills/sb-build-wp/references/wp-build-rules.md +376 -0
  30. package/templates/skills/sb-build-wp/scripts/build-wp.mjs +327 -0
  31. package/templates/skills/sb-build-wp/scripts/tests/test-build-wp.mjs +224 -0
  32. package/templates/skills/sb-compare-visual/SKILL.md +121 -0
  33. package/templates/skills/sb-compare-visual/scripts/compare-visual.mjs +387 -0
  34. package/templates/skills/sb-compare-visual/scripts/lib/compare-tokens.mjs +273 -0
  35. package/templates/skills/sb-compare-visual/scripts/tests/test-compare-tokens.mjs +350 -0
  36. package/templates/skills/sb-compare-visual/scripts/tests/test-compare-visual.mjs +626 -0
  37. package/templates/skills/sb-crawl-and-list/SKILL.md +99 -0
  38. package/templates/skills/sb-crawl-and-list/scripts/crawl-and-list.mjs +437 -0
  39. package/templates/skills/sb-crawl-and-list/scripts/lib/blocklist-filter.mjs +176 -0
  40. package/templates/skills/sb-crawl-and-list/scripts/lib/fallback-crawler.mjs +107 -0
  41. package/templates/skills/sb-crawl-and-list/scripts/lib/page-classifier.mjs +89 -0
  42. package/templates/skills/sb-crawl-and-list/scripts/lib/sitemap-parser.mjs +118 -0
  43. package/templates/skills/sb-crawl-and-list/scripts/tests/test-blocklist-filter.mjs +204 -0
  44. package/templates/skills/sb-crawl-and-list/scripts/tests/test-crawl-and-list.mjs +276 -0
  45. package/templates/skills/sb-crawl-and-list/scripts/tests/test-fallback-crawler.mjs +243 -0
  46. package/templates/skills/sb-crawl-and-list/scripts/tests/test-page-classifier.mjs +120 -0
  47. package/templates/skills/sb-crawl-and-list/scripts/tests/test-sitemap-parser.mjs +157 -0
  48. package/templates/skills/sb-extract-assets/SKILL.md +112 -0
  49. package/templates/skills/sb-extract-assets/scripts/extract-assets.mjs +484 -0
  50. package/templates/skills/sb-extract-assets/scripts/tests/test-extract-assets.mjs +112 -0
  51. package/templates/skills/sb-inspect-live/SKILL.md +105 -0
  52. package/templates/skills/sb-inspect-live/scripts/inspect-live.mjs +693 -0
  53. package/templates/skills/sb-inspect-live/scripts/tests/test-inspect-live.mjs +181 -0
  54. package/templates/skills/sb-review-checks/SKILL.md +113 -0
  55. package/templates/skills/sb-review-checks/references/review-rules.md +195 -0
  56. package/templates/skills/sb-review-checks/scripts/lib/anti-patterns.mjs +379 -0
  57. package/templates/skills/sb-review-checks/scripts/lib/cross-reference.mjs +115 -0
  58. package/templates/skills/sb-review-checks/scripts/lib/design-quality.mjs +541 -0
  59. package/templates/skills/sb-review-checks/scripts/review-checks.mjs +250 -0
  60. package/templates/skills/sb-review-checks/scripts/tests/test-anti-patterns.mjs +343 -0
  61. package/templates/skills/sb-review-checks/scripts/tests/test-cross-reference.mjs +170 -0
  62. package/templates/skills/sb-review-checks/scripts/tests/test-design-quality.mjs +493 -0
  63. package/templates/skills/sb-review-checks/scripts/tests/test-review-checks.mjs +267 -0
  64. package/templates/skills/sb-tweak/SKILL.md +130 -0
  65. package/templates/skills/sb-tweak/references/tweak-patterns.md +157 -0
  66. package/templates/skills/sb-tweak/scripts/lib/diff-summarizer.mjs +140 -0
  67. package/templates/skills/sb-tweak/scripts/lib/element-locator.mjs +507 -0
  68. package/templates/skills/sb-tweak/scripts/lib/intent-parser.mjs +324 -0
  69. package/templates/skills/sb-tweak/scripts/tests/test-diff-summarizer.mjs +248 -0
  70. package/templates/skills/sb-tweak/scripts/tests/test-element-locator.mjs +418 -0
  71. package/templates/skills/sb-tweak/scripts/tests/test-intent-parser.mjs +496 -0
  72. package/templates/skills/sb-tweak/scripts/tests/test-tweak.mjs +407 -0
  73. package/templates/skills/sb-tweak/scripts/tweak.mjs +656 -0
  74. package/templates/skills/sb-validate-render/SKILL.md +120 -0
  75. package/templates/skills/sb-validate-render/scripts/tests/test-validate-render.mjs +304 -0
  76. package/templates/skills/sb-validate-render/scripts/validate-render.mjs +645 -0
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env node
2
+ // review-checks.mjs — Audit a built fragment (HTML or Liquid) against three
3
+ // independent groups of checks and emit a prioritized list of violations whose
4
+ // `candidateFix` strings are specific enough for sb-build-{wp,shopify} to
5
+ // apply programmatically as `fixHints`. This script is the engine of the
6
+ // auto-correct loop.
7
+ //
8
+ // Determinism only — no chromium, no network. cheerio is lazy-imported so
9
+ // --help and arg validation work without it installed.
10
+ //
11
+ // Outputs JSON to stdout AND writes report.json into --output-dir.
12
+ // Exit codes: 0=ok, 1=script error, 2=invalid args, 3=violations (med/high).
13
+
14
+ import { parseArgs } from 'node:util'
15
+ import { mkdir, writeFile, readFile, access } from 'node:fs/promises'
16
+ import { join, resolve, dirname } from 'node:path'
17
+ import { fileURLToPath } from 'node:url'
18
+ import { runAntiPatterns } from './lib/anti-patterns.mjs'
19
+ import { runDesignQuality } from './lib/design-quality.mjs'
20
+ import { applyCrossReference, sortByPriority } from './lib/cross-reference.mjs'
21
+
22
+ const HERE = dirname(fileURLToPath(import.meta.url))
23
+ const SKILL_ROOT = resolve(HERE, '..')
24
+
25
+ const HELP = `
26
+ review-checks.mjs — Audit an HTML/Liquid fragment for anti-patterns, design
27
+ quality, and visual-diff correlations. Emits a prioritized fix list.
28
+
29
+ Required:
30
+ --file <path> HTML or Liquid file to audit.
31
+ --preset <name> wp-elementor | shopify-section
32
+ --output-dir <dir> Directory for report.json.
33
+
34
+ Optional:
35
+ --compare-diffs <path> JSON from sb-compare-visual (cross-reference layer).
36
+ --memory-dir <dir> Override <plugin>/memory location. Auto-detects
37
+ \`~/.claude/similarbuild-memory\` and the bundled
38
+ references/ fallback otherwise.
39
+ --help Show this message.
40
+
41
+ Exit codes: 0=ok (zero or only-low violations), 1=script error,
42
+ 2=invalid args, 3=violations found (medium/high severity).
43
+ `
44
+
45
+ function fail(msg, code = 2) {
46
+ process.stderr.write(`[sb-review-checks] ${msg}\n`)
47
+ process.exit(code)
48
+ }
49
+
50
+ function log(msg) {
51
+ process.stderr.write(`[sb-review-checks] ${msg}\n`)
52
+ }
53
+
54
+ const { values } = parseArgs({
55
+ options: {
56
+ file: { type: 'string' },
57
+ preset: { type: 'string' },
58
+ 'output-dir': { type: 'string' },
59
+ 'compare-diffs': { type: 'string' },
60
+ 'memory-dir': { type: 'string' },
61
+ help: { type: 'boolean', default: false },
62
+ },
63
+ strict: false,
64
+ })
65
+
66
+ if (values.help) {
67
+ process.stdout.write(HELP)
68
+ process.exit(0)
69
+ }
70
+
71
+ if (!values.file) fail('missing --file')
72
+ if (!values.preset) fail('missing --preset')
73
+ if (!values['output-dir']) fail('missing --output-dir')
74
+
75
+ const VALID_PRESETS = new Set(['wp-elementor', 'shopify-section'])
76
+ if (!VALID_PRESETS.has(values.preset)) {
77
+ fail(`--preset must be one of: ${[...VALID_PRESETS].join(', ')} (got "${values.preset}")`)
78
+ }
79
+
80
+ const FILE = resolve(values.file)
81
+ const PRESET = values.preset
82
+ const OUTPUT_DIR = resolve(values['output-dir'])
83
+ const COMPARE_DIFFS = values['compare-diffs'] ? resolve(values['compare-diffs']) : null
84
+ const MEMORY_DIR = values['memory-dir'] ? resolve(values['memory-dir']) : null
85
+
86
+ async function exists(path) {
87
+ try {
88
+ await access(path)
89
+ return true
90
+ } catch {
91
+ return false
92
+ }
93
+ }
94
+
95
+ // Extract every <style>…</style> block. Liquid files often embed them inside
96
+ // `{% style %}` or `{% stylesheet %}` tags too — capture both. Returned objects
97
+ // keep the original substring so line numbers reported by checks point at the
98
+ // CSS-block-relative line, which is more actionable than file-relative.
99
+ function extractStyleBlocks(source) {
100
+ const blocks = []
101
+ const patterns = [
102
+ /<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi,
103
+ /\{%\s*style\s*%\}([\s\S]*?)\{%\s*endstyle\s*%\}/gi,
104
+ /\{%\s*stylesheet\s*%\}([\s\S]*?)\{%\s*endstylesheet\s*%\}/gi,
105
+ ]
106
+ for (const re of patterns) {
107
+ let m
108
+ while ((m = re.exec(source)) !== null) {
109
+ blocks.push({ css: m[1], htmlOffset: m.index })
110
+ }
111
+ }
112
+ return blocks
113
+ }
114
+
115
+ // Liquid → HTML-ish: cheerio parses HTML; Liquid expressions like {{ x }} and
116
+ // {% if %}…{% endif %} are foreign syntax that confuses no-op-tolerant parsers.
117
+ // We strip Liquid expressions to whitespace before parsing so cheerio sees
118
+ // well-formed HTML structure. The CSS path uses the raw source so {% style %}
119
+ // blocks are still picked up by extractStyleBlocks above.
120
+ function liquidToHtmlish(source) {
121
+ return source
122
+ .replace(/\{\{[\s\S]*?\}\}/g, ' ')
123
+ .replace(/\{%[\s\S]*?%\}/g, ' ')
124
+ }
125
+
126
+ async function loadComparisonDiffs(path) {
127
+ if (!path) return null
128
+ if (!(await exists(path))) {
129
+ log(`--compare-diffs not found: ${path} — skipping cross-reference`)
130
+ return null
131
+ }
132
+ try {
133
+ const raw = await readFile(path, 'utf8')
134
+ const json = JSON.parse(raw)
135
+ if (Array.isArray(json)) return json
136
+ if (Array.isArray(json.structuredDiffs)) return json.structuredDiffs
137
+ log(`--compare-diffs JSON has no structuredDiffs[] — skipping cross-reference`)
138
+ return null
139
+ } catch (err) {
140
+ log(`could not parse --compare-diffs: ${err.message} — skipping cross-reference`)
141
+ return null
142
+ }
143
+ }
144
+
145
+ // Resolve which memory directory to consult. Order:
146
+ // 1. --memory-dir flag
147
+ // 2. ~/.claude/similarbuild-memory
148
+ // 3. <skill-root>/references (always exists; bundled fallback)
149
+ async function resolveMemoryDir() {
150
+ if (MEMORY_DIR) return { dir: MEMORY_DIR, source: 'flag' }
151
+ const home = process.env.HOME || process.env.USERPROFILE
152
+ if (home) {
153
+ const candidate = join(home, '.claude', 'similarbuild-memory')
154
+ if (await exists(candidate)) return { dir: candidate, source: 'home' }
155
+ }
156
+ return { dir: join(SKILL_ROOT, 'references'), source: 'bundled' }
157
+ }
158
+
159
+ function counts(violations) {
160
+ const c = { high: 0, medium: 0, low: 0 }
161
+ for (const v of violations) c[v.severity] = (c[v.severity] || 0) + 1
162
+ return c
163
+ }
164
+
165
+ async function main() {
166
+ if (!(await exists(FILE))) fail(`--file not found: ${FILE}`)
167
+ await mkdir(OUTPUT_DIR, { recursive: true })
168
+
169
+ const reportPath = join(OUTPUT_DIR, 'report.json')
170
+
171
+ let cheerio
172
+ try {
173
+ cheerio = await import('cheerio')
174
+ } catch (err) {
175
+ process.stderr.write(
176
+ `[sb-review-checks] missing dependency 'cheerio': ${err?.message || err}\n` +
177
+ `Install with: npm i cheerio\n`,
178
+ )
179
+ process.exit(1)
180
+ }
181
+
182
+ log(`reading ${FILE} (preset=${PRESET})`)
183
+ const source = await readFile(FILE, 'utf8')
184
+ const looksLikeLiquid = /(\{%|\{\{)/.test(source)
185
+ const htmlForParse = looksLikeLiquid ? liquidToHtmlish(source) : source
186
+
187
+ const $ = cheerio.load(htmlForParse, { decodeEntities: false })
188
+ const styleBlocks = extractStyleBlocks(source)
189
+ const css = styleBlocks.map((b) => b.css).join('\n')
190
+
191
+ log(`parsed: ${$('*').length} elements, ${styleBlocks.length} style blocks, ${css.length} chars CSS`)
192
+
193
+ const memoryInfo = await resolveMemoryDir()
194
+ log(`memory dir: ${memoryInfo.dir} (${memoryInfo.source})`)
195
+
196
+ const ctx = {
197
+ html: source,
198
+ css,
199
+ $,
200
+ preset: PRESET,
201
+ styleBlocks,
202
+ memoryDir: memoryInfo.dir,
203
+ memorySource: memoryInfo.source,
204
+ }
205
+
206
+ const antiPatternViolations = runAntiPatterns(ctx)
207
+ const designQualityViolations = runDesignQuality(ctx)
208
+ log(`group 1 (anti-patterns): ${antiPatternViolations.length}`)
209
+ log(`group 2 (design quality): ${designQualityViolations.length}`)
210
+
211
+ const compareDiffs = await loadComparisonDiffs(COMPARE_DIFFS)
212
+ let allViolations = [...antiPatternViolations, ...designQualityViolations]
213
+ if (compareDiffs) {
214
+ allViolations = applyCrossReference(allViolations, compareDiffs)
215
+ log(`group 3 (cross-reference): ${compareDiffs.length} diff(s) considered`)
216
+ }
217
+ allViolations = sortByPriority(allViolations)
218
+
219
+ const c = counts(allViolations)
220
+ const passed = c.high === 0 && c.medium === 0
221
+ const result = {
222
+ passed,
223
+ file: FILE,
224
+ preset: PRESET,
225
+ violationCount: c,
226
+ violations: allViolations,
227
+ inputs: {
228
+ file: FILE,
229
+ preset: PRESET,
230
+ compareDiffs: COMPARE_DIFFS,
231
+ memoryDir: memoryInfo.dir,
232
+ memorySource: memoryInfo.source,
233
+ },
234
+ report: reportPath,
235
+ }
236
+
237
+ await writeFile(reportPath, JSON.stringify(result, null, 2), 'utf8')
238
+ process.stdout.write(JSON.stringify(result))
239
+ process.stdout.write('\n')
240
+
241
+ // Exit 3 when there's anything medium or high — that's what the orchestrator
242
+ // branches on to decide "re-roll the build" vs "ship it." Low-only is OK
243
+ // because low-severity is "nice to fix later," not a blocker.
244
+ process.exit(passed ? 0 : 3)
245
+ }
246
+
247
+ main().catch((err) => {
248
+ process.stderr.write(`[sb-review-checks] fatal: ${err?.stack || err}\n`)
249
+ process.exit(1)
250
+ })
@@ -0,0 +1,343 @@
1
+ #!/usr/bin/env node
2
+ // test-anti-patterns.mjs — Pure-function tests for the 8 anti-pattern checks.
3
+ // CSS-only checks need no deps; HTML-shape checks need cheerio (skipped when
4
+ // missing). No chromium gating, so coverage can go deep.
5
+
6
+ import { fileURLToPath } from 'node:url'
7
+ import { dirname, resolve } from 'node:path'
8
+ import { strict as assert } from 'node:assert'
9
+
10
+ const here = dirname(fileURLToPath(import.meta.url))
11
+ const LIB = resolve(here, '..', 'lib', 'anti-patterns.mjs')
12
+ const {
13
+ check100vh,
14
+ checkImgHeight100Hero,
15
+ checkRoundOpacity,
16
+ checkAnchorColorInherit,
17
+ checkButtonNoChain,
18
+ checkImportantNoAncestor,
19
+ checkOverlapBoxInWrapper,
20
+ checkSvgRasterImpostor,
21
+ runAntiPatterns,
22
+ walkRules,
23
+ lineOf,
24
+ sortBySeverity,
25
+ } = await import(LIB)
26
+
27
+ let cheerio = null
28
+ try { cheerio = await import('cheerio') } catch {}
29
+
30
+ let passed = 0
31
+ let failed = 0
32
+
33
+ function test(name, fn) {
34
+ try {
35
+ fn()
36
+ process.stdout.write(`ok - ${name}\n`)
37
+ passed++
38
+ } catch (err) {
39
+ process.stdout.write(`not ok - ${name}\n ${err.message}\n`)
40
+ failed++
41
+ }
42
+ }
43
+
44
+ function skip(name, reason) {
45
+ process.stdout.write(`ok - ${name} # SKIP ${reason}\n`)
46
+ passed++
47
+ }
48
+
49
+ // Build a ctx without cheerio (CSS-only checks).
50
+ function cssCtx({ css = '', preset = 'wp-elementor' } = {}) {
51
+ return {
52
+ html: `<style>${css}</style>`,
53
+ css,
54
+ $: cheerio ? cheerio.load('<div></div>') : null,
55
+ preset,
56
+ styleBlocks: [{ css, htmlOffset: 0 }],
57
+ }
58
+ }
59
+
60
+ // Build a ctx with cheerio for HTML-shape checks.
61
+ function htmlCtx({ html = '', css = '', preset = 'wp-elementor' } = {}) {
62
+ if (!cheerio) return null
63
+ return {
64
+ html: `${html}<style>${css}</style>`,
65
+ css,
66
+ $: cheerio.load(html),
67
+ preset,
68
+ styleBlocks: [{ css, htmlOffset: 0 }],
69
+ }
70
+ }
71
+
72
+ // ─── helpers ────────────────────────────────────────────────────────────────
73
+
74
+ test('walkRules: parses a simple rule', () => {
75
+ const rules = walkRules('.x { color: red; }')
76
+ assert.equal(rules.length, 1)
77
+ assert.equal(rules[0].selector, '.x')
78
+ assert.match(rules[0].body, /color\s*:\s*red/)
79
+ })
80
+
81
+ test('walkRules: parses multiple rules', () => {
82
+ const rules = walkRules('.a { color: red; } .b { color: blue; }')
83
+ assert.equal(rules.length, 2)
84
+ assert.equal(rules[0].selector, '.a')
85
+ assert.equal(rules[1].selector, '.b')
86
+ })
87
+
88
+ test('walkRules: skips comments', () => {
89
+ const rules = walkRules('/* comment */ .x { color: red; }')
90
+ assert.equal(rules.length, 1)
91
+ })
92
+
93
+ test('walkRules: handles @media at-rule', () => {
94
+ const rules = walkRules('@media (min-width: 1000px) { .x { color: red; } }')
95
+ assert.equal(rules.length, 1)
96
+ assert.equal(rules[0].selector, '.x')
97
+ })
98
+
99
+ test('walkRules: handles @import statement', () => {
100
+ const rules = walkRules('@import url("x.css"); .x { color: red; }')
101
+ assert.equal(rules.length, 1)
102
+ })
103
+
104
+ test('lineOf: 1-based line numbers', () => {
105
+ assert.equal(lineOf('a\nb\nc', 0), 1)
106
+ assert.equal(lineOf('a\nb\nc', 2), 2)
107
+ assert.equal(lineOf('a\nb\nc', 4), 3)
108
+ })
109
+
110
+ test('sortBySeverity: high → medium → low', () => {
111
+ const sorted = sortBySeverity([
112
+ { severity: 'low' }, { severity: 'high' }, { severity: 'medium' }, { severity: 'high' },
113
+ ])
114
+ assert.deepEqual(sorted.map((v) => v.severity), ['high', 'high', 'medium', 'low'])
115
+ })
116
+
117
+ // ─── #1 — 100vh ─────────────────────────────────────────────────────────────
118
+
119
+ test('#1: detects 100vh in wp-elementor preset', () => {
120
+ const ctx = cssCtx({ css: '.hero { height: 100vh; }', preset: 'wp-elementor' })
121
+ const v = check100vh(ctx)
122
+ assert.equal(v.length, 1)
123
+ assert.equal(v[0].id, '#1')
124
+ assert.equal(v[0].severity, 'high')
125
+ assert.match(v[0].candidateFix, /aspect-ratio/)
126
+ })
127
+
128
+ test('#1: ignores 100vh in shopify preset', () => {
129
+ const ctx = cssCtx({ css: '.hero { height: 100vh; }', preset: 'shopify-section' })
130
+ assert.equal(check100vh(ctx).length, 0)
131
+ })
132
+
133
+ test('#1: zero violations on clean CSS', () => {
134
+ const ctx = cssCtx({ css: '.hero { aspect-ratio: 9 / 16; max-height: 700px; }' })
135
+ assert.equal(check100vh(ctx).length, 0)
136
+ })
137
+
138
+ // ─── #2 — img height 100% in hero (cheerio-gated) ──────────────────────────
139
+
140
+ if (!cheerio) {
141
+ skip('#2: detects <img> with height:100% in hero', 'cheerio not installed')
142
+ skip('#2: ignores <img> outside hero', 'cheerio not installed')
143
+ } else {
144
+ test('#2: detects <img> with height:100% in hero', () => {
145
+ const ctx = htmlCtx({
146
+ html: '<section class="hero"><img src="x.jpg" style="height: 100%; width: 100%"></section>',
147
+ })
148
+ const v = checkImgHeight100Hero(ctx)
149
+ assert.equal(v.length, 1)
150
+ assert.equal(v[0].id, '#2')
151
+ assert.equal(v[0].severity, 'high')
152
+ assert.match(v[0].candidateFix, /background-image/)
153
+ })
154
+
155
+ test('#2: ignores <img> outside hero', () => {
156
+ const ctx = htmlCtx({
157
+ html: '<section class="other"><img src="x.jpg" style="height: 100%"></section>',
158
+ })
159
+ assert.equal(checkImgHeight100Hero(ctx).length, 0)
160
+ })
161
+
162
+ test('#2: ignores hero img without height:100%', () => {
163
+ const ctx = htmlCtx({
164
+ html: '<section class="hero"><img src="x.jpg" style="width: 100%"></section>',
165
+ })
166
+ assert.equal(checkImgHeight100Hero(ctx).length, 0)
167
+ })
168
+ }
169
+
170
+ // ─── #3 — Round opacity ─────────────────────────────────────────────────────
171
+
172
+ test('#3: flags opacity 0.5', () => {
173
+ const ctx = cssCtx({ css: '.x { opacity: 0.5; }' })
174
+ const v = checkRoundOpacity(ctx)
175
+ assert.equal(v.length, 1)
176
+ assert.equal(v[0].id, '#3')
177
+ assert.equal(v[0].severity, 'low')
178
+ })
179
+
180
+ test('#3: flags 0.3 and 0.7', () => {
181
+ const ctx = cssCtx({ css: '.a { opacity: 0.3; } .b { opacity: 0.7; }' })
182
+ const v = checkRoundOpacity(ctx)
183
+ assert.equal(v.length, 2)
184
+ })
185
+
186
+ test('#3: ignores measured 0.42', () => {
187
+ const ctx = cssCtx({ css: '.x { opacity: 0.42; }' })
188
+ assert.equal(checkRoundOpacity(ctx).length, 0)
189
+ })
190
+
191
+ test('#3: ignores fully opaque/transparent', () => {
192
+ const ctx = cssCtx({ css: '.a { opacity: 1; } .b { opacity: 0; }' })
193
+ assert.equal(checkRoundOpacity(ctx).length, 0)
194
+ })
195
+
196
+ // ─── #4 — color: inherit on <a> ─────────────────────────────────────────────
197
+
198
+ test('#4: flags `a { color: inherit }`', () => {
199
+ const ctx = cssCtx({ css: '.scope a { color: inherit; }' })
200
+ const v = checkAnchorColorInherit(ctx)
201
+ assert.equal(v.length, 1)
202
+ assert.equal(v[0].id, '#4')
203
+ assert.equal(v[0].severity, 'medium')
204
+ })
205
+
206
+ test('#4: ignores explicit color', () => {
207
+ const ctx = cssCtx({ css: '.scope a { color: #d8112a; }' })
208
+ assert.equal(checkAnchorColorInherit(ctx).length, 0)
209
+ })
210
+
211
+ test('#4: ignores div { color: inherit }', () => {
212
+ const ctx = cssCtx({ css: '.scope div { color: inherit; }' })
213
+ assert.equal(checkAnchorColorInherit(ctx).length, 0)
214
+ })
215
+
216
+ // ─── #5 — button without chain ──────────────────────────────────────────────
217
+
218
+ test('#5: flags single-class button selector with bg/color', () => {
219
+ const ctx = cssCtx({ css: '.cta { background: red; color: white; }' })
220
+ const v = checkButtonNoChain(ctx)
221
+ assert.equal(v.length, 1)
222
+ assert.equal(v[0].id, '#5')
223
+ assert.equal(v[0].severity, 'high')
224
+ })
225
+
226
+ test('#5: passes when scope is ancestor', () => {
227
+ const ctx = cssCtx({ css: '.scope .cta { background: red; }' })
228
+ assert.equal(checkButtonNoChain(ctx).length, 0)
229
+ })
230
+
231
+ test('#5: passes with doubled class chain', () => {
232
+ const ctx = cssCtx({ css: '.cta.cta { background: red; }' })
233
+ assert.equal(checkButtonNoChain(ctx).length, 0)
234
+ })
235
+
236
+ test('#5: ignores non-button selectors', () => {
237
+ const ctx = cssCtx({ css: '.title { background: red; }' })
238
+ assert.equal(checkButtonNoChain(ctx).length, 0)
239
+ })
240
+
241
+ // ─── #5b — !important without ancestor ──────────────────────────────────────
242
+
243
+ test('#5b: flags single-class !important', () => {
244
+ const ctx = cssCtx({ css: '.title { font-size: 24px !important; }' })
245
+ const v = checkImportantNoAncestor(ctx)
246
+ assert.equal(v.length, 1)
247
+ assert.equal(v[0].id, '#5b')
248
+ assert.equal(v[0].severity, 'high')
249
+ assert.match(v[0].candidateFix, /\.\{scope\}/)
250
+ })
251
+
252
+ test('#5b: passes with ancestor descendant combinator', () => {
253
+ const ctx = cssCtx({ css: '.scope .title { font-size: 24px !important; }' })
254
+ assert.equal(checkImportantNoAncestor(ctx).length, 0)
255
+ })
256
+
257
+ test('#5b: ignores rules without !important', () => {
258
+ const ctx = cssCtx({ css: '.title { font-size: 24px; }' })
259
+ assert.equal(checkImportantNoAncestor(ctx).length, 0)
260
+ })
261
+
262
+ // ─── #6 — overlap box on wrapper ────────────────────────────────────────────
263
+
264
+ test('#6: flags ::before on wrapper class with absolute', () => {
265
+ const ctx = cssCtx({
266
+ css: '.story__wrapper::before { content: ""; position: absolute; inset: 30% 0 0 0; background: #f4eee8; }',
267
+ })
268
+ const v = checkOverlapBoxInWrapper(ctx)
269
+ assert.equal(v.length, 1)
270
+ assert.equal(v[0].id, '#6')
271
+ assert.match(v[0].candidateFix, /move the/)
272
+ })
273
+
274
+ test('#6: passes when ::before is on the section', () => {
275
+ const ctx = cssCtx({
276
+ css: '.story::before { content: ""; position: absolute; inset: 30% 0 0 0; }',
277
+ })
278
+ assert.equal(checkOverlapBoxInWrapper(ctx).length, 0)
279
+ })
280
+
281
+ test('#6: ignores ::before without position absolute', () => {
282
+ const ctx = cssCtx({
283
+ css: '.story__wrapper::before { content: ""; display: block; }',
284
+ })
285
+ assert.equal(checkOverlapBoxInWrapper(ctx).length, 0)
286
+ })
287
+
288
+ // ─── #8 — SVG raster impostor (cheerio-gated) ───────────────────────────────
289
+
290
+ if (!cheerio) {
291
+ skip('#8: flags large SVG in product context', 'cheerio not installed')
292
+ skip('#8: ignores small icon SVG', 'cheerio not installed')
293
+ } else {
294
+ test('#8: flags large SVG in product context', () => {
295
+ const ctx = htmlCtx({
296
+ html: `<div class="product"><svg viewBox="0 0 600 600"><path d="M0 0h600"/><path d="M0 600h600"/></svg></div>`,
297
+ })
298
+ const v = checkSvgRasterImpostor(ctx)
299
+ assert.equal(v.length, 1)
300
+ assert.equal(v[0].id, '#8')
301
+ assert.match(v[0].candidateFix, /<img src=/)
302
+ })
303
+
304
+ test('#8: ignores small icon SVG', () => {
305
+ const ctx = htmlCtx({
306
+ html: `<div class="icon"><svg viewBox="0 0 24 24"><path d="M0 0h24"/></svg></div>`,
307
+ })
308
+ assert.equal(checkSvgRasterImpostor(ctx).length, 0)
309
+ })
310
+
311
+ test('#8: ignores large SVG outside product context', () => {
312
+ const ctx = htmlCtx({
313
+ html: `<div class="logo"><svg viewBox="0 0 600 600"><path d="M0 0h600"/><path d="M0 600h600"/></svg></div>`,
314
+ })
315
+ assert.equal(checkSvgRasterImpostor(ctx).length, 0)
316
+ })
317
+ }
318
+
319
+ // ─── runAntiPatterns: aggregator ────────────────────────────────────────────
320
+
321
+ if (!cheerio) {
322
+ skip('runAntiPatterns: aggregates across checks and sorts', 'cheerio not installed')
323
+ } else {
324
+ test('runAntiPatterns: aggregates across checks and sorts', () => {
325
+ const ctx = htmlCtx({
326
+ preset: 'wp-elementor',
327
+ html: '<section class="hero"><img src="x" style="height: 100%"></section>',
328
+ css: '.title { font-size: 24px !important; } .hero { height: 100vh; } .x { opacity: 0.5; }',
329
+ })
330
+ const v = runAntiPatterns(ctx)
331
+ // 100vh + img-height + !important + opacity = at least 4
332
+ assert.ok(v.length >= 4, `expected >=4 violations, got ${v.length}`)
333
+ // First entries should be high severity
334
+ assert.equal(v[0].severity, 'high')
335
+ })
336
+ }
337
+
338
+ if (failed > 0) {
339
+ process.stdout.write(`\n${failed} failed, ${passed} passed\n`)
340
+ process.exit(1)
341
+ }
342
+ process.stdout.write(`\n${passed} passed\n`)
343
+ process.exit(0)