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.
- package/CHANGELOG.md +110 -0
- package/LICENSE +21 -0
- package/README.md +301 -0
- package/bin/install.js +256 -0
- package/lib/copy-templates.mjs +52 -0
- package/lib/install-deps.mjs +62 -0
- package/lib/prompt-config.mjs +83 -0
- package/lib/verify-env.mjs +19 -0
- package/package.json +63 -0
- package/scripts/sync-templates.mjs +71 -0
- package/templates/commands/build-page.md +490 -0
- package/templates/commands/build-site.md +548 -0
- package/templates/commands/clip-section.md +519 -0
- package/templates/memory/anti-patterns.md +212 -0
- package/templates/memory/design-knowledge.md +225 -0
- package/templates/memory/fixes.md +163 -0
- package/templates/memory/patterns.md +681 -0
- package/templates/presets/shopify-section.yaml +51 -0
- package/templates/presets/wp-elementor.yaml +49 -0
- package/templates/reports/fixtures/mock-run-1.json +115 -0
- package/templates/reports/fixtures/mock-run-2.json +72 -0
- package/templates/reports/report-renderer.mjs +218 -0
- package/templates/reports/report-template.html +571 -0
- package/templates/skills/sb-build-shopify/SKILL.md +104 -0
- package/templates/skills/sb-build-shopify/references/shopify-build-rules.md +563 -0
- package/templates/skills/sb-build-shopify/scripts/build-shopify.mjs +637 -0
- package/templates/skills/sb-build-shopify/scripts/tests/test-build-shopify.mjs +424 -0
- package/templates/skills/sb-build-wp/SKILL.md +83 -0
- package/templates/skills/sb-build-wp/references/wp-build-rules.md +376 -0
- package/templates/skills/sb-build-wp/scripts/build-wp.mjs +327 -0
- package/templates/skills/sb-build-wp/scripts/tests/test-build-wp.mjs +224 -0
- package/templates/skills/sb-compare-visual/SKILL.md +121 -0
- package/templates/skills/sb-compare-visual/scripts/compare-visual.mjs +387 -0
- package/templates/skills/sb-compare-visual/scripts/lib/compare-tokens.mjs +273 -0
- package/templates/skills/sb-compare-visual/scripts/tests/test-compare-tokens.mjs +350 -0
- package/templates/skills/sb-compare-visual/scripts/tests/test-compare-visual.mjs +626 -0
- package/templates/skills/sb-crawl-and-list/SKILL.md +99 -0
- package/templates/skills/sb-crawl-and-list/scripts/crawl-and-list.mjs +437 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/blocklist-filter.mjs +176 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/fallback-crawler.mjs +107 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/page-classifier.mjs +89 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/sitemap-parser.mjs +118 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-blocklist-filter.mjs +204 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-crawl-and-list.mjs +276 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-fallback-crawler.mjs +243 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-page-classifier.mjs +120 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-sitemap-parser.mjs +157 -0
- package/templates/skills/sb-extract-assets/SKILL.md +112 -0
- package/templates/skills/sb-extract-assets/scripts/extract-assets.mjs +484 -0
- package/templates/skills/sb-extract-assets/scripts/tests/test-extract-assets.mjs +112 -0
- package/templates/skills/sb-inspect-live/SKILL.md +105 -0
- package/templates/skills/sb-inspect-live/scripts/inspect-live.mjs +693 -0
- package/templates/skills/sb-inspect-live/scripts/tests/test-inspect-live.mjs +181 -0
- package/templates/skills/sb-review-checks/SKILL.md +113 -0
- package/templates/skills/sb-review-checks/references/review-rules.md +195 -0
- package/templates/skills/sb-review-checks/scripts/lib/anti-patterns.mjs +379 -0
- package/templates/skills/sb-review-checks/scripts/lib/cross-reference.mjs +115 -0
- package/templates/skills/sb-review-checks/scripts/lib/design-quality.mjs +541 -0
- package/templates/skills/sb-review-checks/scripts/review-checks.mjs +250 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-anti-patterns.mjs +343 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-cross-reference.mjs +170 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-design-quality.mjs +493 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-review-checks.mjs +267 -0
- package/templates/skills/sb-tweak/SKILL.md +130 -0
- package/templates/skills/sb-tweak/references/tweak-patterns.md +157 -0
- package/templates/skills/sb-tweak/scripts/lib/diff-summarizer.mjs +140 -0
- package/templates/skills/sb-tweak/scripts/lib/element-locator.mjs +507 -0
- package/templates/skills/sb-tweak/scripts/lib/intent-parser.mjs +324 -0
- package/templates/skills/sb-tweak/scripts/tests/test-diff-summarizer.mjs +248 -0
- package/templates/skills/sb-tweak/scripts/tests/test-element-locator.mjs +418 -0
- package/templates/skills/sb-tweak/scripts/tests/test-intent-parser.mjs +496 -0
- package/templates/skills/sb-tweak/scripts/tests/test-tweak.mjs +407 -0
- package/templates/skills/sb-tweak/scripts/tweak.mjs +656 -0
- package/templates/skills/sb-validate-render/SKILL.md +120 -0
- package/templates/skills/sb-validate-render/scripts/tests/test-validate-render.mjs +304 -0
- package/templates/skills/sb-validate-render/scripts/validate-render.mjs +645 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// compare-visual.mjs — Compare a live-page screenshot against a build screenshot
|
|
3
|
+
// pixel-by-pixel and (optionally) cross-check measured tokens. Returns a
|
|
4
|
+
// prioritized list of structured differences plus a red-overlay diff map.
|
|
5
|
+
//
|
|
6
|
+
// Pure determinism: pixelmatch + sharp + numeric comparison. No browser, no
|
|
7
|
+
// network. Lazy-imports sharp/pixelmatch/pngjs so --help and arg validation
|
|
8
|
+
// work without the deps installed.
|
|
9
|
+
//
|
|
10
|
+
// Outputs JSON to stdout AND writes diff-map.png + report.json into --output-dir.
|
|
11
|
+
// Exit codes: 0=passed, 1=script error, 2=invalid args, 3=comparison failed
|
|
12
|
+
// (diffPercent > threshold). Exit 3 lets the orchestrator branch on "needs
|
|
13
|
+
// re-roll vs ready to ship" without parsing JSON, while still surfacing the
|
|
14
|
+
// full report to the user / next stage.
|
|
15
|
+
|
|
16
|
+
import { parseArgs } from 'node:util'
|
|
17
|
+
import { mkdir, writeFile, readFile, access } from 'node:fs/promises'
|
|
18
|
+
import { join, resolve } from 'node:path'
|
|
19
|
+
import { compareTokens, compareGeometryBlocks, sortBySeverity } from './lib/compare-tokens.mjs'
|
|
20
|
+
|
|
21
|
+
const HELP = `
|
|
22
|
+
compare-visual.mjs — Diff a live screenshot vs a build screenshot + token cross-check.
|
|
23
|
+
|
|
24
|
+
Required:
|
|
25
|
+
--live-screenshot <path> PNG from sb-inspect-live.
|
|
26
|
+
--build-screenshot <path> PNG from sb-validate-render.
|
|
27
|
+
--output-dir <dir> Directory for diff-map.png + report.json.
|
|
28
|
+
|
|
29
|
+
Optional:
|
|
30
|
+
--tokens-live <path> JSON from sb-inspect-live (tokens block) for token diff.
|
|
31
|
+
--tokens-build <path> JSON from sb-validate-render (render.json) for token diff.
|
|
32
|
+
--threshold <percent> Pass threshold for diffPercent. Default 10.
|
|
33
|
+
--pixel-threshold <0..1> Per-pixel matching strictness for pixelmatch. Default 0.2.
|
|
34
|
+
--crop-live-bbox <x,y,w,h> Crop the live screenshot to this CSS-pixel bbox before
|
|
35
|
+
diffing. Pair with sectionBoundingBox from
|
|
36
|
+
sb-inspect-live to align scope (anti-pattern #10).
|
|
37
|
+
--crop-live-dpr <n> DPR multiplier for --crop-live-bbox (CSS px → screenshot px).
|
|
38
|
+
Default 3 (matches sb-inspect-live's iPhone 14 profile).
|
|
39
|
+
--crop-build-bbox <x,y,w,h> Crop the build screenshot to this CSS-pixel bbox before
|
|
40
|
+
diffing. Pair with render.geometry.sections[0].bbox (or
|
|
41
|
+
probeRoot) from sb-validate-render — eliminates dead
|
|
42
|
+
viewport space below short sections (Pattern #27,
|
|
43
|
+
symmetric of anti-pattern #10).
|
|
44
|
+
--crop-build-dpr <n> DPR multiplier for --crop-build-bbox. Default 3 (matches
|
|
45
|
+
sb-validate-render's deviceScaleFactor=3 alignment).
|
|
46
|
+
--help Show this message.
|
|
47
|
+
|
|
48
|
+
Exit codes: 0=passed, 1=script error, 2=invalid args, 3=comparison failed.
|
|
49
|
+
`
|
|
50
|
+
|
|
51
|
+
function fail(msg, code = 2) {
|
|
52
|
+
process.stderr.write(`[sb-compare-visual] ${msg}\n`)
|
|
53
|
+
process.exit(code)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function log(msg) {
|
|
57
|
+
process.stderr.write(`[sb-compare-visual] ${msg}\n`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { values } = parseArgs({
|
|
61
|
+
options: {
|
|
62
|
+
'live-screenshot': { type: 'string' },
|
|
63
|
+
'build-screenshot': { type: 'string' },
|
|
64
|
+
'tokens-live': { type: 'string' },
|
|
65
|
+
'tokens-build': { type: 'string' },
|
|
66
|
+
'output-dir': { type: 'string' },
|
|
67
|
+
threshold: { type: 'string', default: '10' },
|
|
68
|
+
'pixel-threshold': { type: 'string', default: '0.2' },
|
|
69
|
+
'crop-live-bbox': { type: 'string' },
|
|
70
|
+
'crop-live-dpr': { type: 'string', default: '3' },
|
|
71
|
+
'crop-build-bbox': { type: 'string' },
|
|
72
|
+
'crop-build-dpr': { type: 'string', default: '3' },
|
|
73
|
+
help: { type: 'boolean', default: false },
|
|
74
|
+
},
|
|
75
|
+
strict: false,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
if (values.help) {
|
|
79
|
+
process.stdout.write(HELP)
|
|
80
|
+
process.exit(0)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!values['live-screenshot']) fail('missing --live-screenshot')
|
|
84
|
+
if (!values['build-screenshot']) fail('missing --build-screenshot')
|
|
85
|
+
if (!values['output-dir']) fail('missing --output-dir')
|
|
86
|
+
|
|
87
|
+
const LIVE = resolve(values['live-screenshot'])
|
|
88
|
+
const BUILD = resolve(values['build-screenshot'])
|
|
89
|
+
const OUTPUT_DIR = resolve(values['output-dir'])
|
|
90
|
+
const TOKENS_LIVE = values['tokens-live'] ? resolve(values['tokens-live']) : null
|
|
91
|
+
const TOKENS_BUILD = values['tokens-build'] ? resolve(values['tokens-build']) : null
|
|
92
|
+
const THRESHOLD = parseFloat(values.threshold)
|
|
93
|
+
const PIXEL_THRESHOLD = parseFloat(values['pixel-threshold'])
|
|
94
|
+
|
|
95
|
+
if (!Number.isFinite(THRESHOLD) || THRESHOLD < 0 || THRESHOLD > 100) {
|
|
96
|
+
fail('--threshold must be a number between 0 and 100')
|
|
97
|
+
}
|
|
98
|
+
if (!Number.isFinite(PIXEL_THRESHOLD) || PIXEL_THRESHOLD < 0 || PIXEL_THRESHOLD > 1) {
|
|
99
|
+
fail('--pixel-threshold must be a number between 0 and 1')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Crop bbox parsing. Format: "x,y,w,h" in CSS pixels. Multiplied by the matching
|
|
103
|
+
// --*-dpr flag (default 3) to convert to screenshot pixels — both sb-inspect-live
|
|
104
|
+
// (iPhone 14 device profile) and sb-validate-render (deviceScaleFactor=3 alignment,
|
|
105
|
+
// Pattern #22) capture at DPR 3, so bbox values from upstream JSON are in CSS px.
|
|
106
|
+
function parseBboxFlag(raw, flagName) {
|
|
107
|
+
if (!raw) return null
|
|
108
|
+
const parts = raw.split(',').map((s) => parseFloat(s.trim()))
|
|
109
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
|
|
110
|
+
fail(`${flagName} must be four comma-separated numbers (x,y,w,h)`)
|
|
111
|
+
}
|
|
112
|
+
const [x, y, w, h] = parts
|
|
113
|
+
if (w <= 0 || h <= 0) fail(`${flagName} width and height must be positive`)
|
|
114
|
+
if (x < 0 || y < 0) fail(`${flagName} x and y must be ≥ 0`)
|
|
115
|
+
return { x, y, w, h }
|
|
116
|
+
}
|
|
117
|
+
function parseDprFlag(raw, flagName) {
|
|
118
|
+
const dpr = parseFloat(raw)
|
|
119
|
+
if (!Number.isFinite(dpr) || dpr <= 0) fail(`${flagName} must be a positive number`)
|
|
120
|
+
return dpr
|
|
121
|
+
}
|
|
122
|
+
const CROP_BBOX = parseBboxFlag(values['crop-live-bbox'], '--crop-live-bbox')
|
|
123
|
+
const CROP_DPR = parseDprFlag(values['crop-live-dpr'], '--crop-live-dpr')
|
|
124
|
+
const CROP_BUILD_BBOX = parseBboxFlag(values['crop-build-bbox'], '--crop-build-bbox')
|
|
125
|
+
const CROP_BUILD_DPR = parseDprFlag(values['crop-build-dpr'], '--crop-build-dpr')
|
|
126
|
+
|
|
127
|
+
async function ensureExists(path, label) {
|
|
128
|
+
try {
|
|
129
|
+
await access(path)
|
|
130
|
+
} catch {
|
|
131
|
+
fail(`${label} not found: ${path}`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Pad a PNG buffer (decoded as { data, width, height }) to target dims by
|
|
136
|
+
// extending right + bottom with transparent pixels. Padding (not resizing) is
|
|
137
|
+
// the right call: resizing distorts pixel positions and would trigger massive
|
|
138
|
+
// false positives across the whole image. Padding only flags areas where one
|
|
139
|
+
// side has content that the other lacks — which is exactly what we want.
|
|
140
|
+
async function padToDims(sharp, buffer, targetW, targetH) {
|
|
141
|
+
const meta = await sharp(buffer).metadata()
|
|
142
|
+
const padRight = Math.max(0, targetW - meta.width)
|
|
143
|
+
const padBottom = Math.max(0, targetH - meta.height)
|
|
144
|
+
if (padRight === 0 && padBottom === 0) return buffer
|
|
145
|
+
return sharp(buffer)
|
|
146
|
+
.extend({
|
|
147
|
+
top: 0,
|
|
148
|
+
left: 0,
|
|
149
|
+
right: padRight,
|
|
150
|
+
bottom: padBottom,
|
|
151
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
152
|
+
})
|
|
153
|
+
.png()
|
|
154
|
+
.toBuffer()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function loadJsonIfPresent(path) {
|
|
158
|
+
if (!path) return null
|
|
159
|
+
try {
|
|
160
|
+
const raw = await readFile(path, 'utf8')
|
|
161
|
+
return JSON.parse(raw)
|
|
162
|
+
} catch (err) {
|
|
163
|
+
log(`could not read tokens file ${path}: ${err.message} — skipping token diff`)
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function main() {
|
|
169
|
+
await ensureExists(LIVE, '--live-screenshot')
|
|
170
|
+
await ensureExists(BUILD, '--build-screenshot')
|
|
171
|
+
await mkdir(OUTPUT_DIR, { recursive: true })
|
|
172
|
+
|
|
173
|
+
const diffMapPath = join(OUTPUT_DIR, 'diff-map.png')
|
|
174
|
+
const reportPath = join(OUTPUT_DIR, 'report.json')
|
|
175
|
+
|
|
176
|
+
let sharp, pixelmatch, PNG
|
|
177
|
+
try {
|
|
178
|
+
sharp = (await import('sharp')).default
|
|
179
|
+
} catch (err) {
|
|
180
|
+
process.stderr.write(
|
|
181
|
+
`[sb-compare-visual] missing dependency 'sharp': ${err?.message || err}\n` +
|
|
182
|
+
`Install with: npm i sharp\n`,
|
|
183
|
+
)
|
|
184
|
+
process.exit(1)
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const mod = await import('pixelmatch')
|
|
188
|
+
pixelmatch = mod.default || mod
|
|
189
|
+
} catch (err) {
|
|
190
|
+
process.stderr.write(
|
|
191
|
+
`[sb-compare-visual] missing dependency 'pixelmatch': ${err?.message || err}\n` +
|
|
192
|
+
`Install with: npm i pixelmatch\n`,
|
|
193
|
+
)
|
|
194
|
+
process.exit(1)
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
;({ PNG } = await import('pngjs'))
|
|
198
|
+
} catch (err) {
|
|
199
|
+
process.stderr.write(
|
|
200
|
+
`[sb-compare-visual] missing dependency 'pngjs': ${err?.message || err}\n` +
|
|
201
|
+
`Install with: npm i pngjs\n`,
|
|
202
|
+
)
|
|
203
|
+
process.exit(1)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
log(`reading live=${LIVE}`)
|
|
207
|
+
log(`reading build=${BUILD}`)
|
|
208
|
+
const [liveRawOriginal, buildRawOriginal] = await Promise.all([readFile(LIVE), readFile(BUILD)])
|
|
209
|
+
|
|
210
|
+
// Crop helper. Used for both live (anti-pattern #10) and build (Pattern #27,
|
|
211
|
+
// symmetric of #10): sb-validate-render captures the full mobile viewport
|
|
212
|
+
// (390×844 CSS) but a single-section build often only fills part of it. The
|
|
213
|
+
// empty viewport space below the section pads against any cropped live and
|
|
214
|
+
// inflates diff% with "build has white space, live doesn't" pixels — same
|
|
215
|
+
// failure mode as #10, just on the other side. Cropping both sides to their
|
|
216
|
+
// measured bboxes aligns scope symmetrically.
|
|
217
|
+
async function applyCrop(buffer, bbox, dpr, label, debugFilename) {
|
|
218
|
+
const meta = await sharp(buffer).metadata()
|
|
219
|
+
const ow = meta.width || 0
|
|
220
|
+
const oh = meta.height || 0
|
|
221
|
+
const left = Math.max(0, Math.round(bbox.x * dpr))
|
|
222
|
+
const top = Math.max(0, Math.round(bbox.y * dpr))
|
|
223
|
+
let width = Math.round(bbox.w * dpr)
|
|
224
|
+
let height = Math.round(bbox.h * dpr)
|
|
225
|
+
// Clamp to fit. Sections can extend off-screen via sticky/clip-path edge
|
|
226
|
+
// cases — clamping keeps the comparison alive instead of bailing.
|
|
227
|
+
if (left + width > ow) width = Math.max(1, ow - left)
|
|
228
|
+
if (top + height > oh) height = Math.max(1, oh - top)
|
|
229
|
+
if (left >= ow || top >= oh || width <= 0 || height <= 0) {
|
|
230
|
+
fail(
|
|
231
|
+
`${label} is entirely outside the screenshot ` +
|
|
232
|
+
`(bbox=${bbox.x},${bbox.y},${bbox.w},${bbox.h} dpr=${dpr} src=${ow}x${oh})`,
|
|
233
|
+
1,
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
log(
|
|
237
|
+
`cropping ${label.replace('--crop-', '').replace('-bbox', '')}: ` +
|
|
238
|
+
`bbox=${bbox.x},${bbox.y},${bbox.w},${bbox.h} dpr=${dpr} → ` +
|
|
239
|
+
`screenshot-px ${left},${top},${width}x${height} (orig ${ow}x${oh})`,
|
|
240
|
+
)
|
|
241
|
+
const croppedBuffer = await sharp(buffer).extract({ left, top, width, height }).png().toBuffer()
|
|
242
|
+
const debugPath = join(OUTPUT_DIR, debugFilename)
|
|
243
|
+
await writeFile(debugPath, croppedBuffer)
|
|
244
|
+
return {
|
|
245
|
+
buffer: croppedBuffer,
|
|
246
|
+
record: {
|
|
247
|
+
x: bbox.x,
|
|
248
|
+
y: bbox.y,
|
|
249
|
+
w: bbox.w,
|
|
250
|
+
h: bbox.h,
|
|
251
|
+
dpr,
|
|
252
|
+
screenshotPx: { left, top, width, height },
|
|
253
|
+
path: debugPath,
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let liveRaw = liveRawOriginal
|
|
259
|
+
let croppedLiveTo = null
|
|
260
|
+
if (CROP_BBOX) {
|
|
261
|
+
const r = await applyCrop(liveRawOriginal, CROP_BBOX, CROP_DPR, '--crop-live-bbox', 'live-cropped.png')
|
|
262
|
+
liveRaw = r.buffer
|
|
263
|
+
croppedLiveTo = r.record
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let buildRaw = buildRawOriginal
|
|
267
|
+
let croppedBuildTo = null
|
|
268
|
+
if (CROP_BUILD_BBOX) {
|
|
269
|
+
const r = await applyCrop(buildRawOriginal, CROP_BUILD_BBOX, CROP_BUILD_DPR, '--crop-build-bbox', 'build-cropped.png')
|
|
270
|
+
buildRaw = r.buffer
|
|
271
|
+
croppedBuildTo = r.record
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Probe dimensions to compute the union canvas. Pad both inputs to the union
|
|
275
|
+
// size so pixelmatch sees identically-sized images.
|
|
276
|
+
const [liveMeta, buildMeta] = await Promise.all([
|
|
277
|
+
sharp(liveRaw).metadata(),
|
|
278
|
+
sharp(buildRaw).metadata(),
|
|
279
|
+
])
|
|
280
|
+
const targetW = Math.max(liveMeta.width || 0, buildMeta.width || 0)
|
|
281
|
+
const targetH = Math.max(liveMeta.height || 0, buildMeta.height || 0)
|
|
282
|
+
|
|
283
|
+
if (!targetW || !targetH) {
|
|
284
|
+
fail(`could not read dimensions from one of the screenshots (live=${liveMeta.width}x${liveMeta.height}, build=${buildMeta.width}x${buildMeta.height})`, 1)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
log(`canvas ${targetW}x${targetH} (live ${liveMeta.width}x${liveMeta.height}, build ${buildMeta.width}x${buildMeta.height})`)
|
|
288
|
+
|
|
289
|
+
// Pad as needed, then re-encode to PNG so pngjs can decode the raw RGBA buffer
|
|
290
|
+
// pixelmatch needs.
|
|
291
|
+
const [livePadded, buildPadded] = await Promise.all([
|
|
292
|
+
padToDims(sharp, liveRaw, targetW, targetH),
|
|
293
|
+
padToDims(sharp, buildRaw, targetW, targetH),
|
|
294
|
+
])
|
|
295
|
+
|
|
296
|
+
// sharp → raw RGBA via PNG re-encode + pngjs decode. We could go straight from
|
|
297
|
+
// sharp.raw() but pixelmatch expects 4-channel RGBA at the same byte layout
|
|
298
|
+
// pngjs produces, so stay on the PNG path for fewer surprises.
|
|
299
|
+
const livePng = PNG.sync.read(livePadded)
|
|
300
|
+
const buildPng = PNG.sync.read(buildPadded)
|
|
301
|
+
const diffPng = new PNG({ width: targetW, height: targetH })
|
|
302
|
+
|
|
303
|
+
log(`pixelmatch threshold=${PIXEL_THRESHOLD}`)
|
|
304
|
+
const diffPixels = pixelmatch(
|
|
305
|
+
livePng.data,
|
|
306
|
+
buildPng.data,
|
|
307
|
+
diffPng.data,
|
|
308
|
+
targetW,
|
|
309
|
+
targetH,
|
|
310
|
+
{
|
|
311
|
+
threshold: PIXEL_THRESHOLD,
|
|
312
|
+
includeAA: false,
|
|
313
|
+
alpha: 0.2,
|
|
314
|
+
diffColor: [255, 0, 0],
|
|
315
|
+
diffColorAlt: [255, 64, 0],
|
|
316
|
+
},
|
|
317
|
+
)
|
|
318
|
+
const totalPixels = targetW * targetH
|
|
319
|
+
const diffPercent = +((diffPixels / totalPixels) * 100).toFixed(4)
|
|
320
|
+
|
|
321
|
+
const diffBuffer = PNG.sync.write(diffPng)
|
|
322
|
+
await writeFile(diffMapPath, diffBuffer)
|
|
323
|
+
log(`diff ${diffPixels}/${totalPixels} px (${diffPercent}%) → ${diffMapPath}`)
|
|
324
|
+
|
|
325
|
+
// Token + geometry cross-check. Both files are emitted by sibling skills with
|
|
326
|
+
// the shape `{ tokens: {...}, geometry: {...} }` (sb-inspect-live for live,
|
|
327
|
+
// sb-validate-render for build). If either is missing we just skip token
|
|
328
|
+
// diffs — the pixel diff still tells the orchestrator something useful.
|
|
329
|
+
let structuredDiffs = []
|
|
330
|
+
let tokenDiffMeta = { compared: false, reason: 'tokens-not-supplied' }
|
|
331
|
+
if (TOKENS_LIVE && TOKENS_BUILD) {
|
|
332
|
+
const [liveJson, buildJson] = await Promise.all([
|
|
333
|
+
loadJsonIfPresent(TOKENS_LIVE),
|
|
334
|
+
loadJsonIfPresent(TOKENS_BUILD),
|
|
335
|
+
])
|
|
336
|
+
if (liveJson && buildJson) {
|
|
337
|
+
const tokenDiffs = compareTokens(liveJson.tokens, buildJson.tokens)
|
|
338
|
+
const geometryDiffs = compareGeometryBlocks(liveJson.geometry, buildJson.geometry)
|
|
339
|
+
structuredDiffs = sortBySeverity([...tokenDiffs, ...geometryDiffs])
|
|
340
|
+
tokenDiffMeta = { compared: true, count: structuredDiffs.length }
|
|
341
|
+
log(`token cross-check: ${structuredDiffs.length} diff(s)`)
|
|
342
|
+
} else {
|
|
343
|
+
tokenDiffMeta = { compared: false, reason: 'tokens-unreadable' }
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const passed = diffPercent <= THRESHOLD
|
|
348
|
+
const result = {
|
|
349
|
+
passed,
|
|
350
|
+
diffPercent,
|
|
351
|
+
diffPixels,
|
|
352
|
+
totalPixels,
|
|
353
|
+
threshold: THRESHOLD,
|
|
354
|
+
pixelThreshold: PIXEL_THRESHOLD,
|
|
355
|
+
diffMap: diffMapPath,
|
|
356
|
+
report: reportPath,
|
|
357
|
+
dimensions: {
|
|
358
|
+
live: { width: liveMeta.width || 0, height: liveMeta.height || 0 },
|
|
359
|
+
build: { width: buildMeta.width || 0, height: buildMeta.height || 0 },
|
|
360
|
+
canvas: { width: targetW, height: targetH },
|
|
361
|
+
},
|
|
362
|
+
tokenDiff: tokenDiffMeta,
|
|
363
|
+
structuredDiffs,
|
|
364
|
+
croppedLiveTo,
|
|
365
|
+
croppedBuildTo,
|
|
366
|
+
inputs: {
|
|
367
|
+
liveScreenshot: LIVE,
|
|
368
|
+
buildScreenshot: BUILD,
|
|
369
|
+
tokensLive: TOKENS_LIVE,
|
|
370
|
+
tokensBuild: TOKENS_BUILD,
|
|
371
|
+
},
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
await writeFile(reportPath, JSON.stringify(result, null, 2), 'utf8')
|
|
375
|
+
process.stdout.write(JSON.stringify(result))
|
|
376
|
+
process.stdout.write('\n')
|
|
377
|
+
|
|
378
|
+
// Exit 3 when the visual budget is blown. The full report is already on
|
|
379
|
+
// stdout + disk — the orchestrator parses it either way; the exit code is a
|
|
380
|
+
// shortcut for shell-level branching ("if compare; then ship; else re-roll").
|
|
381
|
+
process.exit(passed ? 0 : 3)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
main().catch((err) => {
|
|
385
|
+
process.stderr.write(`[sb-compare-visual] fatal: ${err?.stack || err}\n`)
|
|
386
|
+
process.exit(1)
|
|
387
|
+
})
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// compare-tokens.mjs — pure token diffing.
|
|
2
|
+
//
|
|
3
|
+
// Consumes the `tokens` blocks emitted by sb-inspect-live (live-page measurement)
|
|
4
|
+
// and sb-validate-render (build measurement) and produces a prioritized list of
|
|
5
|
+
// structured differences. Pure functions, no I/O, no deps — fully unit-testable.
|
|
6
|
+
//
|
|
7
|
+
// Severity rubric:
|
|
8
|
+
// high typography size mismatch on headings, brand color drift, viewport
|
|
9
|
+
// overflow regression — anything a user would notice at first glance.
|
|
10
|
+
// medium body typography drift, button geometry drift, font-weight mismatch —
|
|
11
|
+
// visible but not jarring.
|
|
12
|
+
// low line-height drift, font-family fallback divergence, container width
|
|
13
|
+
// jitter under a few pixels — pixel-peeping.
|
|
14
|
+
|
|
15
|
+
const HEADING_FONT_SIZE_PX_TOLERANCE = 1
|
|
16
|
+
const BODY_FONT_SIZE_PX_TOLERANCE = 1
|
|
17
|
+
const BUTTON_HEIGHT_PX_TOLERANCE = 2
|
|
18
|
+
const CONTAINER_WIDTH_PX_TOLERANCE = 5
|
|
19
|
+
const LINE_HEIGHT_PX_TOLERANCE = 2
|
|
20
|
+
const LINE_HEIGHT_UNITLESS_TOLERANCE = 0.1
|
|
21
|
+
|
|
22
|
+
// Parse "24px", "1.5rem", "120%" → number-of-px (rough — assumes 1rem=16px,
|
|
23
|
+
// 1em=16px, %=relative-of-16). The orchestrator passes computed styles which
|
|
24
|
+
// are nearly always in px already; the rem/em/% paths are belt-and-braces.
|
|
25
|
+
export function parsePx(value) {
|
|
26
|
+
if (value == null) return null
|
|
27
|
+
const s = String(value).trim().toLowerCase()
|
|
28
|
+
if (s === '' || s === 'auto' || s === 'none' || s === 'normal') return null
|
|
29
|
+
const m = s.match(/^(-?\d+(?:\.\d+)?)(px|rem|em|%)?$/)
|
|
30
|
+
if (!m) return null
|
|
31
|
+
const n = parseFloat(m[1])
|
|
32
|
+
const unit = m[2] || 'px'
|
|
33
|
+
if (unit === 'px') return n
|
|
34
|
+
if (unit === 'rem' || unit === 'em') return n * 16
|
|
35
|
+
if (unit === '%') return (n / 100) * 16
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Parse line-height which may be unitless ("1.5"), px ("24px"), or normal.
|
|
40
|
+
// Returns { kind: 'unitless'|'px'|'normal', value: number|null }.
|
|
41
|
+
export function parseLineHeight(value) {
|
|
42
|
+
if (value == null) return { kind: 'normal', value: null }
|
|
43
|
+
const s = String(value).trim().toLowerCase()
|
|
44
|
+
if (s === '' || s === 'normal') return { kind: 'normal', value: null }
|
|
45
|
+
if (/^-?\d+(?:\.\d+)?$/.test(s)) return { kind: 'unitless', value: parseFloat(s) }
|
|
46
|
+
const px = parsePx(s)
|
|
47
|
+
if (px != null) return { kind: 'px', value: px }
|
|
48
|
+
return { kind: 'normal', value: null }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Normalize colors to a comparable hex string. Accepts:
|
|
52
|
+
// #abc, #aabbcc, #aabbccdd, rgb(r,g,b), rgba(r,g,b,a), rgb(r g b / a), named.
|
|
53
|
+
// Returns lowercase #rrggbb (alpha dropped — alpha drift is rarely brand-critical
|
|
54
|
+
// and would explode the rule count). Returns null on unparseable input so the
|
|
55
|
+
// caller can skip the comparison rather than emit a bogus diff.
|
|
56
|
+
export function normalizeColor(value) {
|
|
57
|
+
if (value == null) return null
|
|
58
|
+
const s = String(value).trim().toLowerCase()
|
|
59
|
+
if (s === '' || s === 'transparent' || s === 'none' || s === 'currentcolor' || s === 'inherit') return null
|
|
60
|
+
if (s.startsWith('#')) {
|
|
61
|
+
const hex = s.slice(1)
|
|
62
|
+
if (/^[0-9a-f]{3}$/.test(hex)) return `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`
|
|
63
|
+
if (/^[0-9a-f]{4}$/.test(hex)) return `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`
|
|
64
|
+
if (/^[0-9a-f]{6}$/.test(hex)) return `#${hex}`
|
|
65
|
+
if (/^[0-9a-f]{8}$/.test(hex)) return `#${hex.slice(0, 6)}`
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
const rgbMatch = s.match(/^rgba?\s*\(\s*([^)]+?)\s*\)$/)
|
|
69
|
+
if (rgbMatch) {
|
|
70
|
+
const parts = rgbMatch[1].split(/[\s,/]+/).filter(Boolean)
|
|
71
|
+
if (parts.length < 3) return null
|
|
72
|
+
const channels = parts.slice(0, 3).map((p) => {
|
|
73
|
+
if (p.endsWith('%')) return Math.round((parseFloat(p) / 100) * 255)
|
|
74
|
+
return Math.round(parseFloat(p))
|
|
75
|
+
})
|
|
76
|
+
if (channels.some((c) => !Number.isFinite(c) || c < 0 || c > 255)) return null
|
|
77
|
+
return '#' + channels.map((c) => c.toString(16).padStart(2, '0')).join('')
|
|
78
|
+
}
|
|
79
|
+
// Named colors aren't worth a full table here — caller skips when null.
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function familyHead(value) {
|
|
84
|
+
if (value == null) return null
|
|
85
|
+
return String(value)
|
|
86
|
+
.split(',')[0]
|
|
87
|
+
.trim()
|
|
88
|
+
.replace(/^['"]|['"]$/g, '')
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 }
|
|
93
|
+
|
|
94
|
+
function pushDiff(diffs, area, issue, severity, extras = {}) {
|
|
95
|
+
diffs.push({ area, issue, severity, ...extras })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function compareTypography(area, live, build, fontSizeTol, sizeSeverity, diffs) {
|
|
99
|
+
if (!live || !build) return
|
|
100
|
+
if (live.present === false && build.present === false) return
|
|
101
|
+
if (live.present === false) {
|
|
102
|
+
pushDiff(diffs, area, `${area} present on build but missing on live`, 'medium')
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if (build.present === false) {
|
|
106
|
+
pushDiff(diffs, area, `${area} present on live but missing on build`, 'high', {
|
|
107
|
+
liveText: live.text,
|
|
108
|
+
})
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
const lFs = parsePx(live['font-size'])
|
|
112
|
+
const bFs = parsePx(build['font-size'])
|
|
113
|
+
if (lFs != null && bFs != null && Math.abs(lFs - bFs) > fontSizeTol) {
|
|
114
|
+
pushDiff(diffs, area, `font-size ${build['font-size']} vs ${live['font-size']} expected`, sizeSeverity, {
|
|
115
|
+
live: live['font-size'],
|
|
116
|
+
build: build['font-size'],
|
|
117
|
+
deltaPx: +(bFs - lFs).toFixed(2),
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
if (live['font-weight'] && build['font-weight'] && String(live['font-weight']) !== String(build['font-weight'])) {
|
|
121
|
+
pushDiff(diffs, area, `font-weight ${build['font-weight']} vs ${live['font-weight']} expected`, 'medium', {
|
|
122
|
+
live: String(live['font-weight']),
|
|
123
|
+
build: String(build['font-weight']),
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
const lFam = familyHead(live['font-family'])
|
|
127
|
+
const bFam = familyHead(build['font-family'])
|
|
128
|
+
if (lFam && bFam && lFam !== bFam) {
|
|
129
|
+
pushDiff(diffs, area, `font-family ${bFam} vs ${lFam} expected`, 'low', { live: lFam, build: bFam })
|
|
130
|
+
}
|
|
131
|
+
const lLh = parseLineHeight(live['line-height'])
|
|
132
|
+
const bLh = parseLineHeight(build['line-height'])
|
|
133
|
+
if (lLh.value != null && bLh.value != null && lLh.kind === bLh.kind) {
|
|
134
|
+
const tol = lLh.kind === 'px' ? LINE_HEIGHT_PX_TOLERANCE : LINE_HEIGHT_UNITLESS_TOLERANCE
|
|
135
|
+
if (Math.abs(lLh.value - bLh.value) > tol) {
|
|
136
|
+
pushDiff(diffs, area, `line-height ${build['line-height']} vs ${live['line-height']} expected`, 'low', {
|
|
137
|
+
live: live['line-height'],
|
|
138
|
+
build: build['line-height'],
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const lColor = normalizeColor(live.color)
|
|
143
|
+
const bColor = normalizeColor(build.color)
|
|
144
|
+
if (lColor && bColor && lColor !== bColor) {
|
|
145
|
+
pushDiff(diffs, area, `color ${bColor} vs ${lColor} expected`, 'high', { live: lColor, build: bColor })
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function compareButton(live, build, diffs) {
|
|
150
|
+
if (!live || !build) return
|
|
151
|
+
if (live.present === false && build.present === false) return
|
|
152
|
+
if (live.present === false) {
|
|
153
|
+
pushDiff(diffs, 'button', 'button present on build but absent on live', 'low')
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
if (build.present === false) {
|
|
157
|
+
pushDiff(diffs, 'button', 'button present on live but absent on build', 'high')
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
// Rendered height is the most reliable size signal — `height: auto` on the
|
|
161
|
+
// computed style is uninformative; the bounding-rect height is what the user
|
|
162
|
+
// actually sees.
|
|
163
|
+
const lH = live.renderedH != null ? Number(live.renderedH) : parsePx(live.height)
|
|
164
|
+
const bH = build.renderedH != null ? Number(build.renderedH) : parsePx(build.height)
|
|
165
|
+
if (lH != null && bH != null && Math.abs(lH - bH) > BUTTON_HEIGHT_PX_TOLERANCE) {
|
|
166
|
+
pushDiff(diffs, 'button', `button height ${bH}px vs ${lH}px expected`, 'medium', {
|
|
167
|
+
live: lH,
|
|
168
|
+
build: bH,
|
|
169
|
+
deltaPx: +(bH - lH).toFixed(2),
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
const lBg = normalizeColor(live['background-color'])
|
|
173
|
+
const bBg = normalizeColor(build['background-color'])
|
|
174
|
+
if (lBg && bBg && lBg !== bBg) {
|
|
175
|
+
pushDiff(diffs, 'button', `background-color ${bBg} vs ${lBg} expected`, 'high', { live: lBg, build: bBg })
|
|
176
|
+
}
|
|
177
|
+
const lFg = normalizeColor(live.color)
|
|
178
|
+
const bFg = normalizeColor(build.color)
|
|
179
|
+
if (lFg && bFg && lFg !== bFg) {
|
|
180
|
+
pushDiff(diffs, 'button', `color ${bFg} vs ${lFg} expected`, 'high', { live: lFg, build: bFg })
|
|
181
|
+
}
|
|
182
|
+
const lBr = parsePx(live['border-radius'])
|
|
183
|
+
const bBr = parsePx(build['border-radius'])
|
|
184
|
+
if (lBr != null && bBr != null && Math.abs(lBr - bBr) > 1) {
|
|
185
|
+
pushDiff(diffs, 'button', `border-radius ${build['border-radius']} vs ${live['border-radius']} expected`, 'low')
|
|
186
|
+
}
|
|
187
|
+
const lFs = parsePx(live['font-size'])
|
|
188
|
+
const bFs = parsePx(build['font-size'])
|
|
189
|
+
if (lFs != null && bFs != null && Math.abs(lFs - bFs) > BODY_FONT_SIZE_PX_TOLERANCE) {
|
|
190
|
+
pushDiff(diffs, 'button', `font-size ${build['font-size']} vs ${live['font-size']} expected`, 'medium')
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function compareContainer(live, build, diffs) {
|
|
195
|
+
if (!live || !build) return
|
|
196
|
+
const lW = live.computedRenderedW != null ? Number(live.computedRenderedW) : null
|
|
197
|
+
const bW = build.computedRenderedW != null ? Number(build.computedRenderedW) : null
|
|
198
|
+
if (lW != null && bW != null && Math.abs(lW - bW) > CONTAINER_WIDTH_PX_TOLERANCE) {
|
|
199
|
+
pushDiff(diffs, 'container', `container width ${bW}px vs ${lW}px expected`, 'low', {
|
|
200
|
+
live: lW,
|
|
201
|
+
build: bW,
|
|
202
|
+
deltaPx: +(bW - lW).toFixed(2),
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function compareImages(live, build, diffs) {
|
|
208
|
+
if (!live || !build) return
|
|
209
|
+
const lc = Number(live.count || 0)
|
|
210
|
+
const bc = Number(build.count || 0)
|
|
211
|
+
if (lc !== bc) {
|
|
212
|
+
pushDiff(diffs, 'images', `image count ${bc} vs ${lc} expected`, lc > bc ? 'high' : 'medium', {
|
|
213
|
+
live: lc,
|
|
214
|
+
build: bc,
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function compareGeometry(live, build, diffs) {
|
|
220
|
+
if (!live || !build) return
|
|
221
|
+
const lOver = !!live.viewportOverflow
|
|
222
|
+
const bOver = !!build.viewportOverflow
|
|
223
|
+
if (bOver && !lOver) {
|
|
224
|
+
pushDiff(diffs, 'geometry', 'build overflows the viewport but live does not', 'high')
|
|
225
|
+
} else if (!bOver && lOver) {
|
|
226
|
+
pushDiff(diffs, 'geometry', 'live overflows the viewport but build does not', 'low')
|
|
227
|
+
}
|
|
228
|
+
const lH = Number(live.totalHeight || 0)
|
|
229
|
+
const bH = Number(build.totalHeight || 0)
|
|
230
|
+
if (lH > 0 && bH > 0) {
|
|
231
|
+
const ratio = bH / lH
|
|
232
|
+
if (ratio < 0.7 || ratio > 1.4) {
|
|
233
|
+
pushDiff(diffs, 'geometry', `total height ${bH}px vs ${lH}px expected (${(ratio * 100).toFixed(0)}%)`, 'high', {
|
|
234
|
+
live: lH,
|
|
235
|
+
build: bH,
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function compareTokens(liveTokens, buildTokens) {
|
|
242
|
+
const diffs = []
|
|
243
|
+
if (!liveTokens || !buildTokens) return diffs
|
|
244
|
+
|
|
245
|
+
const live = liveTokens
|
|
246
|
+
const build = buildTokens
|
|
247
|
+
|
|
248
|
+
compareTypography('h1', live.h1, build.h1, HEADING_FONT_SIZE_PX_TOLERANCE, 'high', diffs)
|
|
249
|
+
compareTypography('h2', live.h2, build.h2, HEADING_FONT_SIZE_PX_TOLERANCE, 'high', diffs)
|
|
250
|
+
compareTypography('h3', live.h3, build.h3, HEADING_FONT_SIZE_PX_TOLERANCE, 'medium', diffs)
|
|
251
|
+
compareTypography('body', live.body, build.body, BODY_FONT_SIZE_PX_TOLERANCE, 'medium', diffs)
|
|
252
|
+
compareButton(live.button, build.button, diffs)
|
|
253
|
+
compareContainer(live.container, build.container, diffs)
|
|
254
|
+
compareImages(live.images, build.images, diffs)
|
|
255
|
+
|
|
256
|
+
return sortBySeverity(diffs)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function compareGeometryBlocks(liveGeometry, buildGeometry) {
|
|
260
|
+
const diffs = []
|
|
261
|
+
compareGeometry(liveGeometry, buildGeometry, diffs)
|
|
262
|
+
return sortBySeverity(diffs)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Stable sort: high first, then medium, then low. Within a severity, original
|
|
266
|
+
// order is preserved (V8's Array.sort is stable since 2018).
|
|
267
|
+
export function sortBySeverity(diffs) {
|
|
268
|
+
return [...diffs].sort((a, b) => {
|
|
269
|
+
const sa = SEVERITY_ORDER[a.severity] ?? 99
|
|
270
|
+
const sb = SEVERITY_ORDER[b.severity] ?? 99
|
|
271
|
+
return sa - sb
|
|
272
|
+
})
|
|
273
|
+
}
|