similarbuild 0.3.4 → 0.4.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 (26) hide show
  1. package/package.json +1 -1
  2. package/templates/commands/build-page.md +62 -493
  3. package/templates/commands/build-site.md +98 -705
  4. package/templates/commands/clip-section.md +102 -501
  5. package/templates/skills/sb-build-wp/SKILL.md +37 -164
  6. package/templates/skills/sb-build-wp/references/wp-build-rules.md +11 -1
  7. package/templates/skills/sb-inspect-live/scripts/inspect-live.mjs +4 -367
  8. package/templates/skills/sb-review-checks/SKILL.md +0 -113
  9. package/templates/skills/sb-review-checks/references/review-rules.md +0 -195
  10. package/templates/skills/sb-review-checks/scripts/lib/anti-patterns.mjs +0 -385
  11. package/templates/skills/sb-review-checks/scripts/lib/cross-reference.mjs +0 -115
  12. package/templates/skills/sb-review-checks/scripts/lib/design-quality.mjs +0 -541
  13. package/templates/skills/sb-review-checks/scripts/review-checks.mjs +0 -250
  14. package/templates/skills/sb-review-checks/scripts/tests/test-anti-patterns.mjs +0 -366
  15. package/templates/skills/sb-review-checks/scripts/tests/test-cross-reference.mjs +0 -170
  16. package/templates/skills/sb-review-checks/scripts/tests/test-design-quality.mjs +0 -493
  17. package/templates/skills/sb-review-checks/scripts/tests/test-review-checks.mjs +0 -267
  18. package/templates/skills/sb-test-interactivity/SKILL.md +0 -133
  19. package/templates/skills/sb-test-interactivity/scripts/test-interactivity.mjs +0 -970
  20. package/templates/skills/sb-test-interactivity/scripts/tests/fixtures/aria-controls-broken.html +0 -32
  21. package/templates/skills/sb-test-interactivity/scripts/tests/fixtures/aria-controls-good.html +0 -47
  22. package/templates/skills/sb-test-interactivity/scripts/tests/fixtures/deferred-listeners.html +0 -67
  23. package/templates/skills/sb-test-interactivity/scripts/tests/fixtures/details-good.html +0 -25
  24. package/templates/skills/sb-test-interactivity/scripts/tests/fixtures/dialog-good.html +0 -38
  25. package/templates/skills/sb-test-interactivity/scripts/tests/fixtures/no-interactive.html +0 -15
  26. package/templates/skills/sb-test-interactivity/scripts/tests/test-test-interactivity.mjs +0 -223
@@ -198,104 +198,11 @@ async function main() {
198
198
  }
199
199
  })
200
200
 
201
- // §V03-0a — lazy-hydration wait WHILE SCROLLED TO BOTTOM. Many
202
- // Shopify themes populate footer menus (Contact, Privacy Policy,
203
- // Collections links) via <store-footer-menu>-style custom elements
204
- // only AFTER the host intersects the viewport. Wait here, with the
205
- // footer in view, for any <footer> <ul> to gain children or for
206
- // <footer> <a href> count to reach >= 3.
207
- log('waiting for lazy-hydrated footer content (max 5s)')
208
- try {
209
- await page.waitForFunction(
210
- () => {
211
- const lists = document.querySelectorAll('footer ul, [class*="footer"] ul')
212
- for (const ul of lists) {
213
- if (ul.children.length > 0) return true
214
- }
215
- const links = document.querySelectorAll('footer a[href], [class*="footer"] a[href]')
216
- return links.length >= 3
217
- },
218
- { timeout: 5000 },
219
- )
220
- log('footer content hydrated')
221
- } catch (_) {
222
- log('footer hydration timeout — proceeding')
223
- }
224
-
225
- // §V03-0a — capture hydrated chrome HTML WHILE STILL IN VIEW.
226
- // Shopify themes that populate footer menus on viewport intersection
227
- // sometimes tear the content down when the host leaves the viewport.
228
- // The walker can't see torn-down menus. Snapshot the relevant
229
- // subtrees here, before we scroll back to the top for layout reads,
230
- // and stash them on `window` for extractInPage to read during walk.
231
- log('snapshotting hydrated header/footer HTML for downstream use')
232
- await page.evaluate(() => {
233
- function pickFooter() {
234
- const candidates = document.querySelectorAll(
235
- 'footer, [class*="footer"][class*="section"], [role="contentinfo"]',
236
- )
237
- let best = null
238
- for (const el of candidates) {
239
- const r = el.getBoundingClientRect()
240
- if (r.height < 50) continue
241
- const links = el.querySelectorAll('a[href]').length
242
- const inputs = el.querySelectorAll('input,form').length
243
- const score = links + inputs * 2 + r.height / 100
244
- if (!best || score > best.score) best = { el, score }
245
- }
246
- return best?.el || null
247
- }
248
- function pickHeader() {
249
- const candidates = document.querySelectorAll(
250
- 'header, [class*="header"][class*="section"], [role="banner"]',
251
- )
252
- let best = null
253
- for (const el of candidates) {
254
- const r = el.getBoundingClientRect()
255
- if (r.height < 30) continue
256
- const links = el.querySelectorAll('a[href]').length
257
- const score = links + r.height / 100
258
- if (!best || score > best.score) best = { el, score }
259
- }
260
- return best?.el || null
261
- }
262
- const footer = pickFooter()
263
- const header = pickHeader()
264
- window.__sbHydratedFooter = footer
265
- ? {
266
- html: footer.outerHTML,
267
- bbox: (() => {
268
- const r = footer.getBoundingClientRect()
269
- return {
270
- x: Math.round(r.x + window.scrollX),
271
- y: Math.round(r.y + window.scrollY),
272
- w: Math.round(r.width),
273
- h: Math.round(r.height),
274
- }
275
- })(),
276
- }
277
- : null
278
- window.__sbHydratedHeader = header
279
- ? {
280
- html: header.outerHTML,
281
- bbox: (() => {
282
- const r = header.getBoundingClientRect()
283
- return {
284
- x: Math.round(r.x + window.scrollX),
285
- y: Math.round(r.y + window.scrollY),
286
- w: Math.round(r.width),
287
- h: Math.round(r.height),
288
- }
289
- })(),
290
- }
291
- : null
292
- })
293
-
294
201
  // Return to the top before layout reads. content-visibility:auto on
295
- // viewport-distant nodes returns {w:0,h:0} bboxes — walker can't
296
- // read layout from the bottom. 1.2s settle for any re-hydration.
202
+ // viewport-distant nodes returns {w:0,h:0} bboxes — walker reads from
203
+ // the at-top state. 800ms settle for any re-hydration.
297
204
  await page.evaluate(() => window.scrollTo(0, 0))
298
- await page.waitForTimeout(1200)
205
+ await page.waitForTimeout(800)
299
206
 
300
207
  // Force-eager: any <img loading="lazy"> that didn't intersect during scroll
301
208
  // gets rewritten to eager and re-fetched. <picture> sources too.
@@ -454,88 +361,7 @@ async function main() {
454
361
  return slug || 'section'
455
362
  }
456
363
 
457
- // §V03-4 image-block detection. A section is an image-block when
458
- // its visible content is dominated by a single <img>: either by
459
- // bbox coverage (img takes ≥80% of section area) OR by a known
460
- // wrapper-class pattern (Shopify Dawn / OS 2.0 themes use specific
461
- // wrapper classes like `product-info__image` to delimit a region
462
- // whose entire visual content is one CDN-hosted SVG/PNG/JPG asset).
463
- // The composer (sb-build-wp §V03-4 rule A) treats image-block
464
- // sections as "download asset + emit <img src>" — NEVER tries to
465
- // reproduce the visual contents in HTML/CSS.
466
- const IMAGE_BLOCK_WRAPPER_PATTERNS = [
467
- /product-info__image/i,
468
- /image-with-text/i,
469
- /\bhero-image\b/i,
470
- /\btrust-badges\b/i,
471
- /\bfeatures-points\b/i,
472
- /\bmothers?-day\b/i,
473
- /\bsingle-image\b/i,
474
- /\bbanner-image\b/i,
475
- /\bguarantee-card\b/i,
476
- /\bbest-fit-size-chart\b/i,
477
- ]
478
- function classifyAsImageBlock(node) {
479
- if (!node || typeof node !== 'object') return null
480
- const sectionBbox = node.bbox
481
- if (!sectionBbox || !sectionBbox.h || !sectionBbox.w) return null
482
- const sectionArea = sectionBbox.h * sectionBbox.w
483
- if (sectionArea <= 0) return null
484
- // Match by wrapper-class pattern on the node itself OR any
485
- // descendant (the wrapper might be a child of the section root).
486
- function findClassMatch(n) {
487
- if (!n || typeof n !== 'object') return null
488
- if (Array.isArray(n.classes)) {
489
- for (const cls of n.classes) {
490
- for (const pat of IMAGE_BLOCK_WRAPPER_PATTERNS) {
491
- if (pat.test(cls)) return cls
492
- }
493
- }
494
- }
495
- if (Array.isArray(n.children)) {
496
- for (const c of n.children) {
497
- const m = findClassMatch(c)
498
- if (m) return m
499
- }
500
- }
501
- return null
502
- }
503
- const classMatch = findClassMatch(node)
504
- // Find the largest <img> descendant by bbox area.
505
- let bestImg = null
506
- function findBiggestImg(n) {
507
- if (!n || typeof n !== 'object') return
508
- if (n.tag === 'img' && n.bbox && n.bbox.h > 0 && n.bbox.w > 0) {
509
- const a = n.bbox.h * n.bbox.w
510
- if (!bestImg || a > bestImg.area) {
511
- const src = (n.attrs && (n.attrs.src || n.attrs.srcset)) || ''
512
- bestImg = { area: a, bbox: n.bbox, src }
513
- }
514
- }
515
- if (Array.isArray(n.children)) {
516
- for (const c of n.children) findBiggestImg(c)
517
- }
518
- }
519
- findBiggestImg(node)
520
- const imgArea = bestImg ? bestImg.area : 0
521
- const coverage = sectionArea > 0 ? imgArea / sectionArea : 0
522
- if (classMatch || coverage >= 0.8) {
523
- return {
524
- reason: classMatch
525
- ? `wrapper-class:${classMatch}`
526
- : `img-coverage:${(coverage * 100).toFixed(0)}%`,
527
- imgSrc: bestImg ? bestImg.src : null,
528
- imgBbox: bestImg ? bestImg.bbox : null,
529
- coverage,
530
- }
531
- }
532
- return null
533
- }
534
- const sourceDom = result.domLive
535
- ? Array.isArray(result.domLive)
536
- ? result.domLive
537
- : [result.domLive]
538
- : result.dom
364
+ const sourceDom = result.dom
539
365
 
540
366
  // Find the host container (body or main) — walk in one level and
541
367
  // pick the node with the most children + largest bbox.
@@ -574,7 +400,6 @@ async function main() {
574
400
  sectionList.push({
575
401
  sectionType: labelFromNode(grand),
576
402
  bbox: grand.bbox,
577
- imageBlock: classifyAsImageBlock(grand),
578
403
  })
579
404
  }
580
405
  }
@@ -586,7 +411,6 @@ async function main() {
586
411
  sectionList.push({
587
412
  sectionType: labelFromNode(child),
588
413
  bbox,
589
- imageBlock: classifyAsImageBlock(child),
590
414
  })
591
415
  }
592
416
 
@@ -647,7 +471,6 @@ async function main() {
647
471
  sectionType: sec.sectionType,
648
472
  bbox: sec.bbox,
649
473
  path: cropPath,
650
- imageBlock: sec.imageBlock || null,
651
474
  })
652
475
  } catch (err) {
653
476
  log(`section crop ${idx} ${sec.sectionType} failed: ${err?.message || err}`)
@@ -1297,11 +1120,6 @@ function extractInPage({ selector, maxDepth, maxChildren, maxText }) {
1297
1120
  // iframes (Klaviyo embeds, recaptcha) are recorded as opaque rectangles.
1298
1121
  let shadowDOMTraversed = false
1299
1122
  let shadowRootCount = 0
1300
- // §V03-C — counts hosts whose shadow tree was successfully re-serialized
1301
- // via getHTML+parseHTMLUnsafe and re-walked into a flattened light-DOM
1302
- // representation. 0 means the post-render shadow-flatten phase was either
1303
- // unavailable, skipped, or hit zero open roots.
1304
- let shadowSerializedHostCount = 0
1305
1123
  const warnings = []
1306
1124
  const externalIframes = []
1307
1125
  function classifyIframePurpose(src) {
@@ -1752,198 +1570,17 @@ function extractInPage({ selector, maxDepth, maxChildren, maxText }) {
1752
1570
  const dom = [walk(root, 0)].filter(Boolean)
1753
1571
  const { sectionType, sectionBoundingBox } = findSectionAndBox(root, !!selector)
1754
1572
 
1755
- // §V03-C — Shadow DOM serialization via getHTML + parseHTMLUnsafe.
1756
- // walk() above only sees light DOM and `.shadowRoot.children` direct.
1757
- // Custom elements whose shadow tree is populated by JS (Shopify
1758
- // <x-product-form>, <price-list>, <variant-radios>, <store-footer-menu>)
1759
- // expose content that the children-only walker can technically read,
1760
- // but `<slot>`-projected content and content composed from declarative
1761
- // shadow DOM gets fragmented. getHTML({ serializableShadowRoots: true })
1762
- // emits a single HTML string with `<template shadowrootmode="open">`
1763
- // declarations inline; parseHTMLUnsafe re-attaches those as live shadow
1764
- // roots in a parsed document, which the same walk() can then flatten
1765
- // uniformly. Layout (bbox/computedStyle) on the parsed doc is detached
1766
- // and returns UA defaults — that's an acknowledged trade-off; the
1767
- // structural content (tags, classes, attrs, text, src) is what makes
1768
- // PDP gallery/price/variants visible to the composer downstream.
1769
- const originalShadowRootCount = shadowRootCount
1770
- const originalShadowDOMTraversed = shadowDOMTraversed
1771
- // §V03-C — preserve a snapshot of the live-walker dom[] before any
1772
- // potential substitution. If the re-walk replaces dom[] with the
1773
- // shadow-flattened tree (which carries detached parsedDoc bboxes
1774
- // === {0,0,0,0}), downstream consumers that need real layout (e.g.
1775
- // /build-site Step 3.5e --crop-live-bbox for header/footer compare)
1776
- // can fall back to domLive. When substitution doesn't fire, domLive
1777
- // is left null and consumers use dom[] as-is.
1778
- let domLive = null
1779
- try {
1780
- if (typeof document.documentElement.getHTML === 'function' &&
1781
- typeof Document.parseHTMLUnsafe === 'function') {
1782
- const hostsSeen = new Set()
1783
- const hostsCollected = []
1784
- function collectHostsFrom(el) {
1785
- if (!el) return
1786
- if (el.shadowRoot && !hostsSeen.has(el)) {
1787
- hostsSeen.add(el)
1788
- hostsCollected.push(el)
1789
- for (const child of el.shadowRoot.children) collectHostsFrom(child)
1790
- }
1791
- for (const child of el.children) collectHostsFrom(child)
1792
- }
1793
- collectHostsFrom(document.documentElement)
1794
-
1795
- if (hostsCollected.length > 0) {
1796
- const html = document.documentElement.getHTML({
1797
- serializableShadowRoots: true,
1798
- shadowRoots: hostsCollected.map((h) => h.shadowRoot),
1799
- })
1800
- const parsedDoc = Document.parseHTMLUnsafe(html)
1801
- const parsedRoot = selector
1802
- ? parsedDoc.querySelector(selector)
1803
- : parsedDoc.body
1804
- if (parsedRoot) {
1805
- const flattened = walk(parsedRoot, 0)
1806
- // §V03-C safety guard: only substitute the live dom[] if the
1807
- // re-walk did not lose value on EITHER axis (nodes or aggregate
1808
- // text chars). parseHTMLUnsafe returns a detached doc;
1809
- // getComputedStyle/getBoundingClientRect degrade to UA defaults
1810
- // (zero bbox, empty computed). On pages where shadow flattening
1811
- // adds value (PDPs Shopify with populated custom elements) the
1812
- // gain is large; on pages without that workload (policies,
1813
- // plain HTML), the re-walk can lose content because hydrated
1814
- // shadow content visible to the live walker doesn't reproduce
1815
- // on the detached tree. When that happens, keep the live
1816
- // walker result and surface a warning.
1817
- function measureTree(arr) {
1818
- let nodes = 0
1819
- let textChars = 0
1820
- const stack = Array.isArray(arr) ? [...arr] : [arr]
1821
- while (stack.length) {
1822
- const node = stack.pop()
1823
- if (!node || typeof node !== 'object') continue
1824
- nodes++
1825
- if (typeof node.text === 'string') textChars += node.text.length
1826
- if (Array.isArray(node.children)) {
1827
- for (const c of node.children) stack.push(c)
1828
- }
1829
- }
1830
- return { nodes, textChars }
1831
- }
1832
- if (flattened) {
1833
- const orig = measureTree(dom)
1834
- const flat = measureTree([flattened])
1835
- if (flat.nodes >= orig.nodes && flat.textChars >= orig.textChars) {
1836
- // Snapshot the live walker result BEFORE substitution so
1837
- // downstream consumers that depend on real layout (bbox
1838
- // values are zero on the parsed detached doc) can fall
1839
- // back when needed — see §V03-C domLive comment above.
1840
- domLive = dom.length === 1 ? dom[0] : [...dom]
1841
- dom.length = 0
1842
- dom.push(flattened)
1843
- shadowSerializedHostCount = hostsCollected.length
1844
- } else {
1845
- warnings.push({
1846
- code: 'shadow-flatten-skipped-lossy',
1847
- message: `re-walk lossy: nodes ${flat.nodes} vs ${orig.nodes}, textChars ${flat.textChars} vs ${orig.textChars}; keeping live walker result`,
1848
- })
1849
- }
1850
- }
1851
- // The re-walk of the parsed (detached) doc re-discovers shadow
1852
- // roots that parseHTMLUnsafe re-attached, so it would
1853
- // double-count if we let those increments leak. Restore the
1854
- // canonical live counts here.
1855
- shadowRootCount = originalShadowRootCount
1856
- shadowDOMTraversed = originalShadowDOMTraversed
1857
- }
1858
- }
1859
- } else {
1860
- warnings.push({
1861
- code: 'shadow-serialize-unavailable',
1862
- message:
1863
- 'getHTML or Document.parseHTMLUnsafe not available; shadow DOM falls back to .shadowRoot.children walk (v0.2.x behavior)',
1864
- })
1865
- }
1866
- } catch (err) {
1867
- shadowRootCount = originalShadowRootCount
1868
- shadowDOMTraversed = originalShadowDOMTraversed
1869
- warnings.push({
1870
- code: 'shadow-serialize-failed',
1871
- message: String(err && err.message ? err.message : err),
1872
- })
1873
- }
1874
-
1875
- // §V03-0a — extract structured content from the hydrated-chrome
1876
- // HTML snapshots taken before scroll-back. Shopify themes that
1877
- // populate menus on viewport intersection may have torn those down
1878
- // by the time the walker runs. The snapshots preserve the truth.
1879
- function extractChrome(snapshot) {
1880
- if (!snapshot || !snapshot.html) return null
1881
- let parsed
1882
- try {
1883
- parsed = new DOMParser().parseFromString(snapshot.html, 'text/html')
1884
- } catch (_) {
1885
- return { ...snapshot, links: [], headings: [], inputs: [], forms: [], images: [] }
1886
- }
1887
- const root = parsed.body.firstElementChild || parsed.body
1888
- const norm = (s) => (s || '').replace(/\s+/g, ' ').trim()
1889
- const links = Array.from(root.querySelectorAll('a[href]'))
1890
- .map((a) => ({
1891
- href: a.getAttribute('href') || '',
1892
- text: norm(a.textContent),
1893
- label: a.getAttribute('aria-label') || null,
1894
- }))
1895
- .filter((l) => l.href && (l.text || l.label))
1896
- const headings = Array.from(root.querySelectorAll('h1, h2, h3, h4, h5, h6, p.bold, .footer__block-title, [class*="heading"]'))
1897
- .map((h) => ({ tag: h.tagName.toLowerCase(), text: norm(h.textContent) }))
1898
- .filter((h) => h.text)
1899
- const inputs = Array.from(root.querySelectorAll('input')).map((i) => ({
1900
- type: i.getAttribute('type') || 'text',
1901
- name: i.getAttribute('name') || null,
1902
- placeholder: i.getAttribute('placeholder') || null,
1903
- required: i.hasAttribute('required'),
1904
- ariaLabel: i.getAttribute('aria-label') || null,
1905
- }))
1906
- const forms = Array.from(root.querySelectorAll('form')).map((f) => ({
1907
- action: f.getAttribute('action') || null,
1908
- method: f.getAttribute('method') || 'get',
1909
- id: f.getAttribute('id') || null,
1910
- }))
1911
- const images = Array.from(root.querySelectorAll('img'))
1912
- .map((img) => ({
1913
- src: img.getAttribute('src') || '',
1914
- alt: img.getAttribute('alt') || '',
1915
- width: img.getAttribute('width') || null,
1916
- height: img.getAttribute('height') || null,
1917
- }))
1918
- .filter((img) => img.src)
1919
- return {
1920
- html: snapshot.html,
1921
- bbox: snapshot.bbox,
1922
- links,
1923
- headings,
1924
- inputs,
1925
- forms,
1926
- images,
1927
- }
1928
- }
1929
- const hydratedHeader = extractChrome(window.__sbHydratedHeader)
1930
- const hydratedFooter = extractChrome(window.__sbHydratedFooter)
1931
-
1932
1573
  return {
1933
1574
  sectionType,
1934
1575
  sectionBoundingBox,
1935
1576
  tokens,
1936
1577
  dom,
1937
- domLive,
1938
1578
  pseudoElements,
1939
1579
  imgUrls,
1940
1580
  shadowDOMTraversed,
1941
1581
  shadowRootCount,
1942
- shadowSerializedHostCount,
1943
1582
  warnings,
1944
1583
  externalIframes,
1945
- hydratedHeader,
1946
- hydratedFooter,
1947
1584
  }
1948
1585
  }
1949
1586
 
@@ -1,113 +0,0 @@
1
- ---
2
- name: sb-review-checks
3
- description: Audits a built HTML/Liquid fragment against canonical visual anti-patterns, accessibility/performance/web-standards baselines, and (optionally) cross-references the violations with `sb-compare-visual` diffs to produce a prioritized list of specific candidate fixes for the auto-correct loop. Use when the SimilarBuild orchestrator (`/build-page`, `/build-site`, `/clip-section`) needs to audit a build before shipping or to generate `fixHints` for `sb-build-wp` / `sb-build-shopify`, or when the user asks to 'review checks' on a built fragment.
4
- ---
5
-
6
- # sb-review-checks
7
-
8
- ## Overview
9
-
10
- Closes the **build → validate → compare → review** cycle. Takes whatever `sb-build-wp` or `sb-build-shopify` produced, parses it (cheerio for HTML/Liquid, regex for CSS), and runs three independent groups of deterministic checks:
11
-
12
- 1. **Anti-patterns** — the canon `#1, #2, #3, #4, #5, #5b, #6, #8` from the migration bootstrap (`#7` and `#9` are process-level, prevented by other skills).
13
- 2. **Design quality** — the 12 a11y / performance / web-standards baselines that every production fragment must clear.
14
- 3. **Cross-reference** — when `--compare-diffs` is supplied, violations whose location overlaps a high-severity visual diff are escalated and tagged.
15
-
16
- Pure determinism — no chromium, no network. The script does the work; this SKILL.md only describes inputs, outputs, and how to react to a non-zero exit.
17
-
18
- This skill is the **engine of the auto-correct loop**. Its `candidateFix` strings are the contract with `sb-build-{wp,shopify}`: specific enough to apply programmatically as `fixHints`, never generic ("improve specificity" is useless — "rewrite `.hero__title` as `.hero .hero__title`" is actionable). When you generate or hand-edit fix text, treat it as patch instructions, not prose.
19
-
20
- The split between **mechanical** (script) and **semantic** (LLM) is explicit: regex/AST decides whether `alt` is *present*; you decide whether it's *meaningful*. The script never tries to judge wording or pattern correctness.
21
-
22
- ## Inputs
23
-
24
- | Argument | Required | Default | Notes |
25
- | ----------------- | -------- | ------- | -------------------------------------------------------------------------------------- |
26
- | `file` | yes | — | Path to the built fragment. HTML for `wp-elementor`, Liquid for `shopify-section`. |
27
- | `preset` | yes | — | `wp-elementor` or `shopify-section`. Determines which checks apply. |
28
- | `output-dir` | yes | — | Directory where `report.json` is written. |
29
- | `compare-diffs` | no | — | JSON from `sb-compare-visual` (full report or just the `structuredDiffs` array). |
30
- | `memory-dir` | no | (auto) | Override `<plugin>/memory` location. Auto-detects `~/.claude/similarbuild-memory` and the bundled `references/` fallback otherwise. |
31
-
32
- ## Output
33
-
34
- A single JSON object printed to stdout AND saved to `{output-dir}/report.json`:
35
-
36
- ```json
37
- {
38
- "passed": false,
39
- "file": "/abs/path/build.html",
40
- "preset": "wp-elementor",
41
- "violationCount": { "high": 2, "medium": 3, "low": 1 },
42
- "violations": [
43
- {
44
- "group": "anti-pattern",
45
- "id": "#5b",
46
- "location": "selector `.hero__title`",
47
- "issue": "`!important` without an ancestor in the selector — theme has both ancestor + !important so this loses anyway",
48
- "candidateFix": "prefix `.hero__title` with the scope as ancestor: rewrite as `.{scope} .hero__title`",
49
- "severity": "high",
50
- "correlatedDiff": { "area": "h1", "issue": "font-size 24px vs 27px expected", "deltaPx": -3 }
51
- }
52
- ],
53
- "report": "{output-dir}/report.json",
54
- "inputs": { "file": "...", "preset": "...", "compareDiffs": "...", "memoryDir": "...", "memorySource": "bundled|home|flag" }
55
- }
56
- ```
57
-
58
- `violations` is sorted **high → medium → low**, with cross-referenced (visual-diff-correlated) entries surfacing first within each severity. The orchestrator's first slice is always the most urgent fix.
59
-
60
- ## Dependencies
61
-
62
- The host project must have `cheerio` installed (the SimilarBuild installer handles it). No browser, no native bindings. Node ≥ 20.
63
-
64
- ## On Activation
65
-
66
- 1. **Resolve inputs.** Collect `file`, `preset`, `output-dir`. Optional: `compare-diffs`, `memory-dir`.
67
-
68
- 2. **Ensure `output-dir` exists.** `mkdir -p` it.
69
-
70
- 3. **Run the script.** From the project root:
71
-
72
- ```bash
73
- node {skill-root}/scripts/review-checks.mjs \
74
- --file "{built-file}" \
75
- --preset "wp-elementor" \
76
- --output-dir "{output-dir}" \
77
- [--compare-diffs "{compare-report.json}"] \
78
- [--memory-dir "{path-to-memory}"]
79
- ```
80
-
81
- The script handles: file read, cheerio parse, CSS extraction (also from `{% style %}` Liquid blocks), 8 anti-pattern checks, 12 design-quality checks, optional cross-reference with compare diffs, prioritization, and persistence. See `scripts/review-checks.mjs --help` for the full flag list.
82
-
83
- 4. **Branch on exit code.**
84
- - `0` — `passed: true`. Either zero violations or only `low`-severity ones. Ship it.
85
- - `3` — `passed: false`, medium/high violations exist. **Not a script error.** Parse the JSON; the `violations` list is exactly what `sb-build-{wp,shopify}` expects as `fixHints` for the next re-roll.
86
- - `1` — actual script error (missing dependency, malformed file). Surface stderr to the user and stop.
87
- - `2` — invalid args. Fix the call.
88
-
89
- 5. **Forward the JSON unchanged** to the caller. Do not summarize, mutate, or strip fields. Downstream consumers (the orchestrator's auto-correct loop, the HTML report) read every field directly.
90
-
91
- ## Memory and presets
92
-
93
- The skill consults a memory directory in this order: `--memory-dir` flag → `~/.claude/similarbuild-memory/` → bundled `references/`. When present, files there should override the bundled defaults — they reflect the user's most recent curated knowledge. The bundled `references/review-rules.md` ships the canonical anti-patterns and design-quality rules so the skill is fully functional out of the box.
94
-
95
- The memory files are **data**, not code: adding a new anti-pattern is a `.md` edit, not a `.mjs` change. The orchestrator can hand-curate them and `sb-review-checks` picks them up next run.
96
-
97
- ## Failure modes
98
-
99
- | Symptom | Likely cause | What to surface |
100
- | ---------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
101
- | Exit 1, stderr mentions `cheerio` | Dependency not installed | Pass stderr verbatim. Suggest `npm i cheerio`. |
102
- | Exit 1, "could not read" | Input file missing or not readable | Pass stderr verbatim. The build path may be stale — re-check what `sb-build-{wp,shopify}` returned. |
103
- | Exit 3 with only `low` severity in `violationCount` | Should not happen — exit 3 implies med/high | If you see this, file a bug — exit-code policy is wrong. |
104
- | `inputs.memorySource: "bundled"` but you expected curated rules | The user-curated memory dir is not where the skill looks | Pass `--memory-dir` explicitly, or move the curated files to `~/.claude/similarbuild-memory/`. |
105
- | `--compare-diffs` provided but no `correlatedDiff` annotations | The diffs file has no `severity: 'high'` entries | Cross-reference only escalates on `high` diffs; med/low are advisory. This is by design. |
106
-
107
- ## Conventions
108
-
109
- - Bare paths (e.g. `scripts/review-checks.mjs`) resolve from the skill root.
110
- - `{skill-root}` resolves to this skill's installed directory.
111
- - `{project-root}` resolves to the project working directory.
112
- - `<plugin>/memory/...` paths are inside the SimilarBuild plugin install — read them when present, fall back to bundled `references/` when absent.
113
- - `candidateFix` is a contract: every value names the selector, attribute, or property to change. Never emit prose-style suggestions ("improve X"). The auto-correct loop depends on this.