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,379 @@
1
+ // anti-patterns.mjs — Pure detection functions for the canon visual anti-patterns
2
+ // from `<plugin>/memory/anti-patterns.md` (the "bootstrap" list). Each check is
3
+ // a pure function `(ctx) => violations[]` with no I/O and no global state, so
4
+ // every rule is unit-testable in isolation.
5
+ //
6
+ // `ctx` shape:
7
+ // { html: string, css: string, $: cheerio.CheerioAPI, preset: 'wp-elementor'|'shopify-section', styleBlocks: [{ css, htmlOffset }] }
8
+ //
9
+ // Each violation:
10
+ // { group: 'anti-pattern', id: '#1', location: '...', issue: '...',
11
+ // candidateFix: '...', severity: 'high'|'medium'|'low' }
12
+ //
13
+ // The candidateFix string is the contract with sb-build-{wp,shopify}: it must be
14
+ // specific enough to apply programmatically. A generic "improve specificity" is
15
+ // useless — a builder can't act on it. Every fix names the selector, attribute,
16
+ // or property it wants changed.
17
+
18
+ const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 }
19
+
20
+ export function sortBySeverity(violations) {
21
+ return [...violations].sort((a, b) => {
22
+ const sa = SEVERITY_ORDER[a.severity] ?? 99
23
+ const sb = SEVERITY_ORDER[b.severity] ?? 99
24
+ return sa - sb
25
+ })
26
+ }
27
+
28
+ // Find which line `index` falls on in `source`. 1-based.
29
+ export function lineOf(source, index) {
30
+ if (index < 0) return 0
31
+ let line = 1
32
+ for (let i = 0; i < index && i < source.length; i++) {
33
+ if (source.charCodeAt(i) === 10) line++
34
+ }
35
+ return line
36
+ }
37
+
38
+ // Walk all CSS rule blocks `selector { ...decls... }` from the combined CSS.
39
+ // Returns { selector, body, openIndex } per rule (open brace index in source).
40
+ // Skips at-rules' own bodies but recurses into them so nested rules are visited.
41
+ export function walkRules(css) {
42
+ const rules = []
43
+ walk(css, 0, css.length, rules)
44
+ return rules
45
+
46
+ function walk(src, start, end, out) {
47
+ let i = start
48
+ while (i < end) {
49
+ // Comment
50
+ if (src[i] === '/' && src[i + 1] === '*') {
51
+ const close = src.indexOf('*/', i + 2)
52
+ if (close === -1) return
53
+ i = close + 2
54
+ continue
55
+ }
56
+ // Whitespace
57
+ if (/\s/.test(src[i])) { i++; continue }
58
+ // At-rule
59
+ if (src[i] === '@') {
60
+ const semi = findStmtEnd(src, i, end)
61
+ if (semi.kind === 'block') {
62
+ // recurse into the at-rule body so nested rules are picked up
63
+ walk(src, semi.bodyStart + 1, semi.bodyEnd, out)
64
+ i = semi.bodyEnd + 1
65
+ } else {
66
+ i = semi.semiIndex + 1
67
+ }
68
+ continue
69
+ }
70
+ // Rule
71
+ const open = findOpenBrace(src, i, end)
72
+ if (open === -1) return
73
+ const close = findMatchingBrace(src, open)
74
+ if (close === -1) return
75
+ const selector = src.slice(i, open).trim()
76
+ const body = src.slice(open + 1, close)
77
+ out.push({ selector, body, openIndex: open })
78
+ i = close + 1
79
+ }
80
+ }
81
+ }
82
+
83
+ function findOpenBrace(src, start, end) {
84
+ for (let i = start; i < end; i++) {
85
+ if (src[i] === '{') return i
86
+ if (src[i] === ';') return -1 // declaration-without-rule (shouldn't happen at this level)
87
+ }
88
+ return -1
89
+ }
90
+
91
+ function findMatchingBrace(src, openIndex) {
92
+ let depth = 1
93
+ for (let i = openIndex + 1; i < src.length; i++) {
94
+ if (src[i] === '/' && src[i + 1] === '*') {
95
+ const close = src.indexOf('*/', i + 2)
96
+ if (close === -1) return -1
97
+ i = close + 1
98
+ continue
99
+ }
100
+ if (src[i] === '{') depth++
101
+ else if (src[i] === '}') {
102
+ depth--
103
+ if (depth === 0) return i
104
+ }
105
+ }
106
+ return -1
107
+ }
108
+
109
+ function findStmtEnd(src, start, end) {
110
+ for (let i = start; i < end; i++) {
111
+ if (src[i] === '{') {
112
+ const close = findMatchingBrace(src, i)
113
+ return { kind: 'block', bodyStart: i, bodyEnd: close }
114
+ }
115
+ if (src[i] === ';') return { kind: 'stmt', semiIndex: i }
116
+ }
117
+ return { kind: 'stmt', semiIndex: end - 1 }
118
+ }
119
+
120
+ // ─── #1 — 100vh in WP/Elementor (clips on mobile) ───────────────────────────
121
+ export function check100vh(ctx) {
122
+ if (ctx.preset !== 'wp-elementor') return []
123
+ const violations = []
124
+ for (const block of ctx.styleBlocks) {
125
+ let m
126
+ const re = /\b100vh\b/g
127
+ while ((m = re.exec(block.css)) !== null) {
128
+ violations.push({
129
+ group: 'anti-pattern',
130
+ id: '#1',
131
+ location: `style block line ${lineOf(block.css, m.index)}`,
132
+ issue: '100vh inside Elementor clips on mobile (browser chrome eats viewport)',
133
+ candidateFix: 'replace `100vh` with `aspect-ratio: 9 / 16; max-height: 700px` on the hero container',
134
+ severity: 'high',
135
+ })
136
+ }
137
+ }
138
+ return violations
139
+ }
140
+
141
+ // ─── #2 — <img> with height: 100% in hero (theme overrides win) ────────────
142
+ // Heuristic: any `<img>` element under a class containing "hero" with an
143
+ // inline `style` declaring `height: 100%` (or `height:100%`). Catches the
144
+ // most common variant where the build tries to fill a hero box with <img>.
145
+ export function checkImgHeight100Hero(ctx) {
146
+ const { $ } = ctx
147
+ const violations = []
148
+ $('img[style]').each((_, el) => {
149
+ const $el = $(el)
150
+ const style = String($el.attr('style') || '')
151
+ if (!/height\s*:\s*100\s*%/i.test(style)) return
152
+ if (!hasHeroAncestor($, $el)) return
153
+ const cls = $el.attr('class') || ''
154
+ violations.push({
155
+ group: 'anti-pattern',
156
+ id: '#2',
157
+ location: cls ? `<img class="${cls}">` : '<img> with inline height:100%',
158
+ issue: '<img> with height:100% in hero — Elementor injects `img { height: auto !important }` and clobbers it',
159
+ candidateFix: 'replace the <img> hero with a `background-image` on the hero container; keep alt text on a sibling visually-hidden <img> if needed',
160
+ severity: 'high',
161
+ })
162
+ })
163
+ return violations
164
+ }
165
+
166
+ function hasHeroAncestor($, $el) {
167
+ let cur = $el
168
+ for (let i = 0; i < 8 && cur.length; i++) {
169
+ const cls = String(cur.attr('class') || '').toLowerCase()
170
+ if (/(^|[\s_-])hero([\s_-]|$)/.test(cls)) return true
171
+ cur = cur.parent()
172
+ }
173
+ return false
174
+ }
175
+
176
+ // ─── #3 — Round-number opacity (likely guessed, not measured) ───────────────
177
+ // Flags `opacity: 0.3 | 0.5 | 0.7` (the round eyeballs). Other values are
178
+ // probably real readings or token-derived. Severity low — this is a nudge.
179
+ export function checkRoundOpacity(ctx) {
180
+ const violations = []
181
+ for (const block of ctx.styleBlocks) {
182
+ const re = /opacity\s*:\s*(0?\.[357])\b/g
183
+ let m
184
+ while ((m = re.exec(block.css)) !== null) {
185
+ violations.push({
186
+ group: 'anti-pattern',
187
+ id: '#3',
188
+ location: `style block line ${lineOf(block.css, m.index)}`,
189
+ issue: `opacity: ${m[1]} looks eyeballed — round values rarely match the source`,
190
+ candidateFix: `verify the exact alpha against inspection.tokens; if measured, leave a comment \`/* alpha from inspection */\` so the next reviewer doesn't re-flag it`,
191
+ severity: 'low',
192
+ })
193
+ }
194
+ }
195
+ return violations
196
+ }
197
+
198
+ // ─── #4 — `a { color: inherit }` in scoped CSS ──────────────────────────────
199
+ // Inherits whatever ancestor color leaked through. Specifically problematic
200
+ // when the ancestor is themed (button color via parent, link color via theme).
201
+ export function checkAnchorColorInherit(ctx) {
202
+ const violations = []
203
+ for (const rule of walkRules(ctx.css)) {
204
+ const sel = rule.selector
205
+ // Match selectors that target <a> as the rightmost simple selector
206
+ if (!/(?:^|[\s>+~])a(?:[.:#\[][^,\s]*)?\s*$/.test(sel)) continue
207
+ if (!/color\s*:\s*inherit\b/i.test(rule.body)) continue
208
+ violations.push({
209
+ group: 'anti-pattern',
210
+ id: '#4',
211
+ location: `selector \`${sel}\``,
212
+ issue: '`a { color: inherit }` clobbers brand color when the ancestor is themed',
213
+ candidateFix: `replace \`color: inherit\` in \`${sel}\` with a literal hex from inspection.tokens (e.g. \`color: #d8112a\`)`,
214
+ severity: 'medium',
215
+ })
216
+ }
217
+ return violations
218
+ }
219
+
220
+ // ─── #5 — <button> decorative without defensive specificity ─────────────────
221
+ // Heuristic: a rule whose selector ends in `.{token}` (single class) and styles
222
+ // background/color/font on a button-shaped pattern (selector contains "button"
223
+ // or "btn" or "cta"). Real defensive specificity is ancestor-chained, e.g.
224
+ // `.scope .scope__cta.scope__cta`.
225
+ export function checkButtonNoChain(ctx) {
226
+ const violations = []
227
+ for (const rule of walkRules(ctx.css)) {
228
+ const sel = rule.selector
229
+ if (!/(button|btn|cta)/i.test(sel)) continue
230
+ // Defensive specificity = ancestor present (descendant combinator) OR class chain
231
+ const looksDefensive = /\s/.test(sel.trim()) || /\.[\w-]+\.[\w-]+/.test(sel)
232
+ if (looksDefensive) continue
233
+ // Must actually style something theme will fight over
234
+ if (!/(background|color|font-)/i.test(rule.body)) continue
235
+ violations.push({
236
+ group: 'anti-pattern',
237
+ id: '#5',
238
+ location: `selector \`${sel}\``,
239
+ issue: 'button-like selector without ancestor scope or class-chain — theme `.elementor *` rules will win',
240
+ candidateFix: `chain the scope as ancestor: rewrite \`${sel}\` as \`.{scope} ${sel}${sel}\` (double the modifier class to raise specificity past .elementor *)`,
241
+ severity: 'high',
242
+ })
243
+ }
244
+ return violations
245
+ }
246
+
247
+ // ─── #5b — `!important` without ancestor prefix ─────────────────────────────
248
+ // Useless on its own — theme rules carry both ancestor specificity AND
249
+ // !important. The build needs both too.
250
+ export function checkImportantNoAncestor(ctx) {
251
+ const violations = []
252
+ for (const rule of walkRules(ctx.css)) {
253
+ const body = rule.body
254
+ if (!/!\s*important/i.test(body)) continue
255
+ const sel = rule.selector
256
+ // Ancestor present means: there's whitespace combinator inside the selector
257
+ // (excluding child/+/~ — those still satisfy "has ancestor" if anything is
258
+ // before the rightmost simple selector). Concretely: split on ',' and check
259
+ // each branch has an internal combinator.
260
+ const branches = splitTopLevel(sel, ',').map((s) => s.trim()).filter(Boolean)
261
+ const allHaveAncestor = branches.every((b) => /[\s>+~]/.test(b.replace(/^[.#:\[\w-]+/, '')))
262
+ if (allHaveAncestor) continue
263
+ violations.push({
264
+ group: 'anti-pattern',
265
+ id: '#5b',
266
+ location: `selector \`${sel}\``,
267
+ issue: '`!important` without an ancestor in the selector — theme has both ancestor + !important so this loses anyway',
268
+ candidateFix: `prefix \`${sel}\` with the scope as ancestor: rewrite as \`.{scope} ${sel}\` so the chained-scope specificity matches the theme's ancestor-prefixed !important rules`,
269
+ severity: 'high',
270
+ })
271
+ }
272
+ return violations
273
+ }
274
+
275
+ function splitTopLevel(str, ch) {
276
+ const out = []
277
+ let depth = 0
278
+ let start = 0
279
+ for (let i = 0; i < str.length; i++) {
280
+ const c = str[i]
281
+ if (c === '(' || c === '[') depth++
282
+ else if (c === ')' || c === ']') depth--
283
+ else if (c === ch && depth === 0) {
284
+ out.push(str.slice(start, i))
285
+ start = i + 1
286
+ }
287
+ }
288
+ out.push(str.slice(start))
289
+ return out
290
+ }
291
+
292
+ // ─── #6 — Overlap-image modeled as a sibling box on the wrapper ─────────────
293
+ // Should be a `::before` on the SECTION. Heuristic: a `::before` rule whose
294
+ // selector targets a wrapper class (matches /wrap|wrapper|container/) AND the
295
+ // declaration uses `position: absolute` with edge insets that look full-bleed.
296
+ export function checkOverlapBoxInWrapper(ctx) {
297
+ const violations = []
298
+ for (const rule of walkRules(ctx.css)) {
299
+ if (!/::before\b/.test(rule.selector)) continue
300
+ if (!/(wrap|wrapper|container|inner)/i.test(rule.selector)) continue
301
+ if (!/position\s*:\s*absolute/i.test(rule.body)) continue
302
+ if (!/(inset|top|right|bottom|left)\s*:/i.test(rule.body)) continue
303
+ violations.push({
304
+ group: 'anti-pattern',
305
+ id: '#6',
306
+ location: `selector \`${rule.selector}\``,
307
+ issue: '`::before` on a wrapper instead of the section — overlap bands belong to the section so they sit behind the image, not the column',
308
+ candidateFix: `move the \`::before\` from \`${rule.selector}\` to the section selector (the outermost scope element); set \`position: relative\` on the section and \`z-index: -1\` on the ::before`,
309
+ severity: 'medium',
310
+ })
311
+ }
312
+ return violations
313
+ }
314
+
315
+ // ─── #8 — Inline SVG impostor where a raster <img> belongs ─────────────────
316
+ // Heuristic: large viewBox (any dim ≥ 400) + ≥2 `<path>` children + nearby
317
+ // "product" / "photo" / hero context. SVGs that came from the source as icons
318
+ // (small viewBox) are fine — those are the legitimate use case.
319
+ export function checkSvgRasterImpostor(ctx) {
320
+ const { $ } = ctx
321
+ const violations = []
322
+ $('svg').each((_, el) => {
323
+ const $el = $(el)
324
+ const vb = String($el.attr('viewBox') || '').trim()
325
+ const m = vb.match(/-?\d+(?:\.\d+)?\s+-?\d+(?:\.\d+)?\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/)
326
+ if (!m) return
327
+ const w = parseFloat(m[1])
328
+ const h = parseFloat(m[2])
329
+ if (Math.max(w, h) < 400) return
330
+ const paths = $el.find('path').length
331
+ if (paths < 2) return
332
+ const ctxLower = (
333
+ String($el.attr('class') || '') + ' ' +
334
+ String($el.parent().attr('class') || '') + ' ' +
335
+ String($el.closest('section,figure,article').attr('class') || '')
336
+ ).toLowerCase()
337
+ if (!/(product|photo|hero|image|card|variant)/.test(ctxLower)) return
338
+ violations.push({
339
+ group: 'anti-pattern',
340
+ id: '#8',
341
+ location: `<svg viewBox="${vb}"> with ${paths} <path> children`,
342
+ issue: 'inline SVG where a real raster image belongs — SVGs reproduced from photos look "off" and miss real-world detail',
343
+ candidateFix: `replace this <svg> with an <img src="{assetsMap[originalUrl].localPath}" alt="..." loading="lazy"> using the actual asset from sb-extract-assets`,
344
+ severity: 'medium',
345
+ })
346
+ })
347
+ return violations
348
+ }
349
+
350
+ // Public registry — orchestrator iterates this so adding a check is one line.
351
+ export const ANTI_PATTERN_CHECKS = [
352
+ check100vh,
353
+ checkImgHeight100Hero,
354
+ checkRoundOpacity,
355
+ checkAnchorColorInherit,
356
+ checkButtonNoChain,
357
+ checkImportantNoAncestor,
358
+ checkOverlapBoxInWrapper,
359
+ checkSvgRasterImpostor,
360
+ ]
361
+
362
+ export function runAntiPatterns(ctx) {
363
+ const out = []
364
+ for (const fn of ANTI_PATTERN_CHECKS) {
365
+ try {
366
+ out.push(...fn(ctx))
367
+ } catch (err) {
368
+ out.push({
369
+ group: 'anti-pattern',
370
+ id: fn.name || 'unknown',
371
+ location: 'check',
372
+ issue: `check threw: ${err.message}`,
373
+ candidateFix: 'inspect the input file — it may be malformed HTML/CSS',
374
+ severity: 'low',
375
+ })
376
+ }
377
+ }
378
+ return sortBySeverity(out)
379
+ }
@@ -0,0 +1,115 @@
1
+ // cross-reference.mjs — Optional layer that combines this skill's static
2
+ // violations with `compareDiffs` from sb-compare-visual. The contract:
3
+ //
4
+ // - When a violation's `area`/`location` overlaps a structuredDiff's `area`,
5
+ // and that diff is severity high, escalate the violation to high (or keep
6
+ // it high) and tag it with `correlatedDiff`.
7
+ // - Add a synthetic violation for each high-severity diff that no static
8
+ // check caught — the user still needs to know about it.
9
+ //
10
+ // This is what makes sb-review-checks the engine of the auto-correct loop:
11
+ // the orchestrator can take the prioritized output and feed it straight back
12
+ // to sb-build-{wp,shopify} as `fixHints`, ranked by both static rules AND
13
+ // the actual visual delta.
14
+ //
15
+ // Pure function, no I/O. Tested in isolation.
16
+
17
+ const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 }
18
+
19
+ // Map a structured diff's `area` to substrings we look for in violation
20
+ // locations. Loose, on purpose — better to over-correlate (LLM filters) than
21
+ // miss obvious matches.
22
+ const AREA_HINTS = {
23
+ h1: ['h1', '<h1'],
24
+ h2: ['h2', '<h2'],
25
+ h3: ['h3', '<h3'],
26
+ body: ['body', 'paragraph'],
27
+ button: ['button', 'btn', 'cta'],
28
+ container: ['container', 'wrapper', 'section'],
29
+ images: ['img', 'image', '<img', 'svg'],
30
+ geometry: ['hero', 'section', 'overflow', 'height'],
31
+ }
32
+
33
+ function locationMatchesArea(location, area) {
34
+ if (!location || !area) return false
35
+ const hints = AREA_HINTS[area] || [area]
36
+ const lc = String(location).toLowerCase()
37
+ return hints.some((h) => lc.includes(h))
38
+ }
39
+
40
+ function violationKey(v) {
41
+ return `${v.group}|${v.id}|${v.location}`
42
+ }
43
+
44
+ export function applyCrossReference(violations, compareDiffs) {
45
+ if (!Array.isArray(compareDiffs) || compareDiffs.length === 0) {
46
+ return [...violations]
47
+ }
48
+ const highDiffs = compareDiffs.filter((d) => d && d.severity === 'high')
49
+ if (highDiffs.length === 0) return [...violations]
50
+
51
+ const annotated = violations.map((v) => {
52
+ const matches = highDiffs.filter((d) => locationMatchesArea(v.location, d.area))
53
+ if (matches.length === 0) return { ...v }
54
+ return {
55
+ ...v,
56
+ severity: 'high',
57
+ correlatedDiff: {
58
+ area: matches[0].area,
59
+ issue: matches[0].issue,
60
+ deltaPx: matches[0].deltaPx,
61
+ },
62
+ }
63
+ })
64
+
65
+ // Diffs that no violation covered → synthesize one. The orchestrator wants
66
+ // the full picture in one list, not "look at violations AND compareDiffs."
67
+ const seenAreas = new Set(
68
+ annotated
69
+ .filter((v) => v.correlatedDiff)
70
+ .map((v) => v.correlatedDiff.area),
71
+ )
72
+ for (const diff of highDiffs) {
73
+ if (seenAreas.has(diff.area)) continue
74
+ annotated.push({
75
+ group: 'visual-diff',
76
+ id: `diff-${diff.area}`,
77
+ location: diff.area,
78
+ issue: diff.issue,
79
+ candidateFix: synthesizeFixFromDiff(diff),
80
+ severity: 'high',
81
+ correlatedDiff: { area: diff.area, issue: diff.issue, deltaPx: diff.deltaPx },
82
+ })
83
+ }
84
+
85
+ return annotated
86
+ }
87
+
88
+ // When a visual diff has no static violation backing it, generate a fix
89
+ // instruction directly from the diff's measured values.
90
+ function synthesizeFixFromDiff(diff) {
91
+ const { area, live, build, deltaPx } = diff
92
+ if (live != null && build != null && deltaPx != null) {
93
+ if (typeof live === 'string' && typeof build === 'string') {
94
+ return `update \`${area}\` from build value \`${build}\` to live-page value \`${live}\` (delta ${deltaPx}px)`
95
+ }
96
+ return `update \`${area}\` from build value ${build}px to live-page value ${live}px (delta ${deltaPx}px)`
97
+ }
98
+ if (live != null && build != null) {
99
+ return `update \`${area}\` from build value \`${build}\` to live-page value \`${live}\``
100
+ }
101
+ return `align \`${area}\` with the live-page value — see compareDiffs entry for measurements`
102
+ }
103
+
104
+ export function sortByPriority(violations) {
105
+ // High severity first; within severity, correlated-with-diff items rank
106
+ // ahead of non-correlated. Stable beyond that.
107
+ return [...violations].sort((a, b) => {
108
+ const sa = SEVERITY_ORDER[a.severity] ?? 99
109
+ const sb = SEVERITY_ORDER[b.severity] ?? 99
110
+ if (sa !== sb) return sa - sb
111
+ const ca = a.correlatedDiff ? 0 : 1
112
+ const cb = b.correlatedDiff ? 0 : 1
113
+ return ca - cb
114
+ })
115
+ }