dsh-mobilecode 0.11.9 → 0.12.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.
@@ -0,0 +1,283 @@
1
+ /**
2
+ * dsh-mobilecode — reference-comparison workspace (Feature A vertical slice).
3
+ *
4
+ * A project-scoped store of imported design references and device captures,
5
+ * persisted under ~/.dsh/mobilecode/reference/ (Setup.HOME), surviving GUI
6
+ * reloads. The store never trusts caller-supplied paths: images are written
7
+ * under generated ids, records carry metadata only, and association happens
8
+ * through the project/screen/stateId keys.
9
+ *
10
+ * Originals are preserved verbatim; alignment/crop transforms are recorded as
11
+ * metadata on the record (applied at compare time), never baked into the image.
12
+ *
13
+ * Image imports are guarded before any decode: header-only dimension parsing
14
+ * (PNG IHDR, JPEG SOF, WebP chunks) enforces a decompression limit — no pixel
15
+ * buffer is ever materialized by this module.
16
+ *
17
+ * Compare rendering (side-by-side / opacity overlay / swipe slider) is a
18
+ * client concern; this module serves the pair + metadata.
19
+ */
20
+
21
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, rmSync, renameSync } from "node:fs"
22
+ import path from "node:path"
23
+ import crypto from "node:crypto"
24
+
25
+ export const MAX_IMPORT_BYTES = 25 * 1024 * 1024 // 25 MB raw body cap (base64 inside JSON)
26
+ export const MAX_IMAGE_DIMENSION = 8192 // header-only decompression fence
27
+ const ALLOWED_EXT = new Set([".png", ".jpg", ".jpeg", ".webp"])
28
+ const META = "meta.json"
29
+ const IMAGES = "images"
30
+
31
+ /** Header-only dimension read. Returns {width,height} or null when undetectable. */
32
+ export function imageDimensions(buf) {
33
+ if (!Buffer.isBuffer(buf) || buf.length < 12) return null
34
+ // PNG: 8-byte signature, IHDR length+type, then width/height big-endian.
35
+ if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
36
+ if (buf.length < 24 || buf.toString("latin1", 12, 16) !== "IHDR") return null
37
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }
38
+ }
39
+ // JPEG: scan markers for SOF0..SOF15 (skip SOF1-style 0xC0..0xCF except DHT C4).
40
+ if (buf[0] === 0xff && buf[1] === 0xd8) {
41
+ for (let i = 2; i + 9 < buf.length;) {
42
+ if (buf[i] !== 0xff) { i += 1; continue }
43
+ const marker = buf[i + 1]
44
+ if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue }
45
+ const len = buf.readUInt16BE(i + 2)
46
+ if (len < 2 || i + 2 + len > buf.length) break
47
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
48
+ return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) }
49
+ }
50
+ i += 2 + len
51
+ }
52
+ return null
53
+ }
54
+ // WebP: RIFF....WEBP + chunk header.
55
+ if (buf.toString("latin1", 0, 4) === "RIFF" && buf.toString("latin1", 8, 12) === "WEBP") {
56
+ const four = buf.toString("latin1", 12, 16)
57
+ if (four === "VP8X" && buf.length >= 30) {
58
+ const w = 1 + buf.readUIntLE(24, 3)
59
+ const h = 1 + buf.readUIntLE(27, 3)
60
+ return { width: w, height: h }
61
+ }
62
+ if (four === "VP8L" && buf.length >= 25) {
63
+ const bits = buf.readUInt32LE(21)
64
+ return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 }
65
+ }
66
+ if (four === "VP8 " && buf.length >= 30) {
67
+ return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff }
68
+ }
69
+ }
70
+ return null
71
+ }
72
+
73
+ function nowIso() { return new Date().toISOString() }
74
+
75
+ /** Validate + normalize the caller-declared import metadata. Throws on bad shape. */
76
+ export function validateImportMeta({ project, screen, stateId, filename, provenance, intendedUse } = {}) {
77
+ const clean = (value, label, max) => {
78
+ if (typeof value !== "string" || value.trim() === "" || value.length > max) {
79
+ throw new Error(`${label} is required and must be a non-empty string <= ${max} chars`)
80
+ }
81
+ return value.trim()
82
+ }
83
+ const opt = (value, max) => (typeof value === "string" && value.trim() !== "" ? value.trim().slice(0, max) : undefined)
84
+ return {
85
+ project: clean(project, "project", 120),
86
+ screen: clean(screen, "screen", 120),
87
+ stateId: clean(stateId, "stateId", 120),
88
+ filename: opt(filename, 200),
89
+ provenance: opt(provenance, 1000),
90
+ intendedUse: opt(intendedUse, 500),
91
+ }
92
+ }
93
+
94
+ export class ReferenceWorkspace {
95
+ constructor(home) {
96
+ this.root = path.join(home, "reference")
97
+ this.imagesDir = path.join(this.root, IMAGES)
98
+ this.metaFile = path.join(this.root, META)
99
+ }
100
+
101
+ ensure() {
102
+ if (!existsSync(this.imagesDir)) mkdirSync(this.imagesDir, { recursive: true })
103
+ }
104
+
105
+ /** Tolerant read: malformed meta is quarantined (original preserved), never crashes the store. */
106
+ readMeta() {
107
+ this.ensure()
108
+ if (!existsSync(this.metaFile)) return []
109
+ let raw
110
+ try { raw = readFileSync(this.metaFile, "utf8") } catch { return [] }
111
+ let parsed
112
+ try { parsed = JSON.parse(raw) } catch {
113
+ const corrupt = `${this.metaFile}.corrupt-${Date.now()}`
114
+ try { copyFileSync(this.metaFile, corrupt); rmSync(this.metaFile) } catch { /* keep serving empty */ }
115
+ return []
116
+ }
117
+ if (!Array.isArray(parsed)) return []
118
+ // Drop records that no longer satisfy the shape (tolerate unknowns, keep knowns).
119
+ return parsed.filter((r) => r && typeof r === "object" && typeof r.id === "string" && typeof r.project === "string")
120
+ }
121
+
122
+ writeMeta(records) {
123
+ this.ensure()
124
+ // atomic (audit F2): a torn meta.json would quarantine the whole store
125
+ const tmp = this.metaFile + ".tmp"
126
+ writeFileSync(tmp, JSON.stringify(records, null, 2) + "\n", "utf8")
127
+ renameSync(tmp, this.metaFile)
128
+ }
129
+
130
+ #nextId(records) {
131
+ let id
132
+ do { id = crypto.randomBytes(8).toString("hex") } while (records.some((r) => r.id === id))
133
+ return id
134
+ }
135
+
136
+ #resolveFile(record) {
137
+ // Never accept caller input as a path: id + stored extension only.
138
+ const ext = typeof record.ext === "string" && ALLOWED_EXT.has(record.ext.toLowerCase()) ? record.ext.toLowerCase() : ".png"
139
+ return path.join(this.imagesDir, `${record.id}${ext}`)
140
+ }
141
+
142
+ list({ project } = {}) {
143
+ const records = this.readMeta()
144
+ const scoped = project === undefined ? records : records.filter((r) => r.project === project)
145
+ return scoped.map((r) => ({ ...r, file: this.#resolveFile(r) }))
146
+ }
147
+
148
+ get(id) {
149
+ const record = this.readMeta().find((r) => r.id === id)
150
+ return record === undefined ? undefined : { ...record, file: this.#resolveFile(record) }
151
+ }
152
+
153
+ latest(project, screen, stateId, kind) {
154
+ const records = this.readMeta()
155
+ const matches = records.filter((r) =>
156
+ r.project === project && r.screen === screen && r.stateId === stateId && r.kind === kind)
157
+ if (matches.length === 0) return undefined
158
+ matches.sort((a, b) => String(b.importedAt ?? "").localeCompare(String(a.importedAt ?? "")))
159
+ const record = matches[0]
160
+ return { ...record, file: this.#resolveFile(record) }
161
+ }
162
+
163
+ active(project) {
164
+ const record = this.readMeta().find((r) => r.project === project && r.active === true)
165
+ return record === undefined ? undefined : { ...record, file: this.#resolveFile(record) }
166
+ }
167
+
168
+ setActive(id) {
169
+ const records = this.readMeta()
170
+ const target = records.find((r) => r.id === id)
171
+ if (target === undefined) throw new Error(`unknown reference id: ${id}`)
172
+ for (const record of records) record.active = record.id === id
173
+ this.writeMeta(records)
174
+ return { ...target, active: true, file: this.#resolveFile(target) }
175
+ }
176
+
177
+ /** Baseline approval — bound to the stored BYTES and recorded capture config at
178
+ * approval time. A later byte change or config change silently invalidates it
179
+ * (the diff route re-verifies the binding); candidates never need approval. */
180
+ setApproved(id, approved = true) {
181
+ const records = this.readMeta()
182
+ const target = records.find((r) => r.id === id)
183
+ if (target === undefined) throw new Error(`unknown reference id: ${id}`)
184
+ if (approved) {
185
+ target.approved = true
186
+ target.approval = {
187
+ sha256: crypto.createHash("sha256").update(this.readImage(target)).digest("hex"),
188
+ config: target.device ?? null,
189
+ approvedAt: nowIso(),
190
+ }
191
+ } else {
192
+ target.approved = false
193
+ delete target.approval
194
+ }
195
+ this.writeMeta(records)
196
+ return { ...target, file: this.#resolveFile(target) }
197
+ }
198
+
199
+ /**
200
+ * Import a design reference from a base64 data URL (data:image/png;base64,..)
201
+ * or a raw base64 string. Decode-free validation: extension whitelist, byte
202
+ * cap, header-only dimension fence. Original bytes are stored verbatim.
203
+ */
204
+ import(meta, data) {
205
+ const validated = validateImportMeta(meta)
206
+ const match = /^data:image\/(png|jpeg|jpg|webp);base64,([A-Za-z0-9+/=]+)$/.exec(String(data ?? ""))
207
+ const b64 = match ? match[2] : (() => {
208
+ const cleaned = String(data ?? "").replace(/^data:image\/[a-z]+;base64,/, "")
209
+ return /^[A-Za-z0-9+/=]+$/.test(cleaned) ? cleaned : ""
210
+ })()
211
+ if (b64 === "") throw new Error("image data must be a base64 data URL (data:image/png;base64,...)")
212
+ const buf = Buffer.from(b64, "base64")
213
+ if (buf.length === 0) throw new Error("image data is empty")
214
+ if (buf.length > MAX_IMPORT_BYTES) throw new Error(`image exceeds ${MAX_IMPORT_BYTES} bytes`)
215
+ const ext = match ? `.${match[1]}` : path.extname(validated.filename ?? "ref.png").toLowerCase()
216
+ // Note: path.extname(".png") returns "" (dotfile semantics), so the matched
217
+ // data-URL extension bypasses extname entirely.
218
+ if (!ALLOWED_EXT.has(ext)) throw new Error(`unsupported image type "${ext}" (png/jpg/webp only)`)
219
+ const dims = imageDimensions(buf)
220
+ if (dims === null) throw new Error("unable to read image dimensions from header")
221
+ if (dims.width > MAX_IMAGE_DIMENSION || dims.height > MAX_IMAGE_DIMENSION) {
222
+ throw new Error(`image is ${dims.width}x${dims.height} — exceeds ${MAX_IMAGE_DIMENSION}px fence`)
223
+ }
224
+ const records = this.readMeta()
225
+ const id = this.#nextId(records)
226
+ const record = {
227
+ id, kind: "reference", project: validated.project, screen: validated.screen, stateId: validated.stateId,
228
+ ext, width: dims.width, height: dims.height,
229
+ source: validated.filename, provenance: validated.provenance, intendedUse: validated.intendedUse,
230
+ importedAt: nowIso(), approved: false, active: false, transform: null,
231
+ }
232
+ this.writeFile(id, ext, buf)
233
+ records.push(record)
234
+ this.writeMeta(records)
235
+ return { ...record, file: this.#resolveFile(record) }
236
+ }
237
+
238
+ /**
239
+ * Associate an existing device capture (PNG path from DeviceBuild.screenCapture)
240
+ * with a project/screen/state. The original capture file is copied into the
241
+ * store verbatim; device configuration is recorded as metadata, never edited.
242
+ */
243
+ capture({ project, screen, stateId, pngPath, device }) {
244
+ const validated = validateImportMeta({ project, screen, stateId })
245
+ if (typeof pngPath !== "string" || !existsSync(pngPath)) throw new Error(`capture file not found: ${pngPath}`)
246
+ const buf = readFileSync(pngPath)
247
+ if (buf.length === 0) throw new Error("capture file is empty")
248
+ const dims = imageDimensions(buf)
249
+ if (dims === null) throw new Error("capture is not a readable PNG/JPEG/WebP")
250
+ const records = this.readMeta()
251
+ const id = this.#nextId(records)
252
+ const record = {
253
+ id, kind: "capture", project: validated.project, screen: validated.screen, stateId: validated.stateId,
254
+ ext: ".png", width: dims.width, height: dims.height,
255
+ source: path.basename(pngPath), provenance: "device-capture", intendedUse: "baseline-evidence",
256
+ importedAt: nowIso(), approved: false, active: false, transform: null,
257
+ device: device ?? {},
258
+ }
259
+ copyFileSync(pngPath, this.#resolveFile(record))
260
+ records.push(record)
261
+ this.writeMeta(records)
262
+ return { ...record, file: this.#resolveFile(record) }
263
+ }
264
+
265
+ writeFile(id, ext, buf) {
266
+ this.ensure()
267
+ writeFileSync(path.join(this.imagesDir, `${id}${ext}`), buf)
268
+ }
269
+
270
+ readImage(record) {
271
+ return readFileSync(this.#resolveFile(record))
272
+ }
273
+
274
+ remove(id) {
275
+ const records = this.readMeta()
276
+ const target = records.find((r) => r.id === id)
277
+ if (target === undefined) return false
278
+ const file = this.#resolveFile(target)
279
+ try { rmSync(file, { force: true }) } catch { /* best-effort file removal */ }
280
+ this.writeMeta(records.filter((r) => r.id !== id))
281
+ return true
282
+ }
283
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * dsh-mobilecode — visual comparison core (pure, dependency-free).
3
+ *
4
+ * Operates on decoded RGBA frames ({width, height, rgba:Uint8ClampedArray|Buffer}).
5
+ * Decoding is the CALLER's boundary: the browser canvas for the GUI, test
6
+ * fixtures for headless checks. The plugin server never pixel-decodes, so no
7
+ * image library is added to the base plugin.
8
+ *
9
+ * Two modes, kept SEPARATE by design (they answer different questions and
10
+ * must never share a single pass/fail threshold):
11
+ * - regression: same environment, approved native baseline vs current.
12
+ * Absolute pixel diff is meaningful here.
13
+ * - design-reference: implementation vs design target image. Pixel equality
14
+ * is NOT expected (different renderer, font stack,
15
+ * typography metrics) — this reports structure and
16
+ * regions, it does not grade a score.
17
+ *
18
+ * Findings are concrete (regions that changed, where, by how much); no
19
+ * "UI quality 98/100" anywhere. OCR corroboration is passed IN as boxes by
20
+ * the caller — disagreement between observers is reported, never averaged.
21
+ */
22
+
23
+ /** RGBA L1 distance > per-channel threshold counts as a changed pixel. */
24
+ export function diffFrames(a, b, { threshold = 24, masks = [], skip = [] } = {}) {
25
+ if (a.width !== b.width || a.height !== b.height) {
26
+ // Frames of different sizes cannot be diffed as-is: the caller must align
27
+ // (record explicit transform) or this is honestly blocked, not approximated.
28
+ return { status: "blocked", reason: `frame size mismatch ${a.width}x${a.height} vs ${b.width}x${b.height} — align first`, changedPixels: 0, totalPixels: 0 }
29
+ }
30
+ const w = a.width, h = a.height
31
+ const skipAll = [...masks, ...skip]
32
+ let changed = 0
33
+ const marks = new Uint8Array(w * h)
34
+ for (let i = 0; i < w * h; i++) {
35
+ const x = i % w, y = (i / w) | 0
36
+ if (skipAll.some((r) => x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height)) continue
37
+ const p = i * 4
38
+ const d = Math.abs(a.rgba[p] - b.rgba[p]) + Math.abs(a.rgba[p + 1] - b.rgba[p + 1]) + Math.abs(a.rgba[p + 2] - b.rgba[p + 2]) + Math.abs(a.rgba[p + 3] - b.rgba[p + 3])
39
+ if (d > threshold) { changed += 1; marks[i] = 1 }
40
+ }
41
+ const total = w * h - skipAll.reduce((acc, r) => acc + r.width * r.height, 0)
42
+ const filterStats = { droppedClusters: 0, droppedPixels: 0 }
43
+ const clusters = clusterMarks(marks, w, h, 40, filterStats)
44
+ return {
45
+ status: "ok",
46
+ changedPixels: changed,
47
+ totalPixels: Math.max(total, 1),
48
+ changePct: Number(((changed / Math.max(total, 1)) * 100).toFixed(3)),
49
+ threshold,
50
+ clusters, // [{x,y,width,height,pixels}] — VISUALIZATION only
51
+ // The verdict (changedPixels) is INDEPENDENT of cluster filtering: small
52
+ // clusters dropped below minPixels never hide a change from regression.
53
+ clusterFilter: { minPixels: 40, ...filterStats },
54
+ }
55
+ }
56
+
57
+ /** Connected components (4-neighbour flood fill) over changed-pixel marks. */
58
+ export function clusterMarks(marks, w, h, minPixels = 40, stats = null) {
59
+ const seen = new Uint8Array(w * h)
60
+ const clusters = []
61
+ const stack = new Int32Array(w * h)
62
+ for (let start = 0; start < w * h; start++) {
63
+ if (!marks[start] || seen[start]) continue
64
+ let top = 0
65
+ stack[top++] = start
66
+ seen[start] = 1
67
+ let minX = w, minY = h, maxX = 0, maxY = 0, pixels = 0
68
+ while (top > 0) {
69
+ const i = stack[--top]
70
+ const x = i % w, y = (i / w) | 0
71
+ pixels += 1
72
+ if (x < minX) minX = x; if (x > maxX) maxX = x
73
+ if (y < minY) minY = y; if (y > maxY) maxY = y
74
+ if (x > 0 && marks[i - 1] && !seen[i - 1]) { seen[i - 1] = 1; stack[top++] = i - 1 }
75
+ if (x < w - 1 && marks[i + 1] && !seen[i + 1]) { seen[i + 1] = 1; stack[top++] = i + 1 }
76
+ if (y > 0 && marks[i - w] && !seen[i - w]) { seen[i - w] = 1; stack[top++] = i - w }
77
+ if (y < h - 1 && marks[i + w] && !seen[i + w]) { seen[i + w] = 1; stack[top++] = i + w }
78
+ }
79
+ if (pixels >= minPixels) clusters.push({ x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1, pixels })
80
+ else if (stats) { stats.droppedClusters += 1; stats.droppedPixels += pixels }
81
+ }
82
+ clusters.sort((p, q) => q.pixels - p.pixels)
83
+ return clusters.slice(0, 50)
84
+ }
85
+
86
+ /**
87
+ * Explicit alignment transform (recorded, never baked into stored images).
88
+ * Output-space contract: out(x,y) samples in((x-dx)/scaleX,(y-dy)/scaleY).
89
+ * dx/dy are output-space offsets — THE SAME transform the panel must render
90
+ * (audit §3: displayed overlay and computed diff share one contract).
91
+ */
92
+ export function applyAlign(frame, { scaleX = 1, scaleY = 1, dx = 0, dy = 0 } = {}) {
93
+ if (scaleX === 1 && scaleY === 1 && dx === 0 && dy === 0) return frame
94
+ const w = Math.max(1, Math.round(frame.width * scaleX) + Math.abs(dx))
95
+ const h = Math.max(1, Math.round(frame.height * scaleY) + Math.abs(dy))
96
+ const out = new Uint8ClampedArray(w * h * 4)
97
+ for (let y = 0; y < h; y++) {
98
+ const sy = Math.floor((y - dy) / scaleY)
99
+ if (sy < 0 || sy >= frame.height) continue
100
+ for (let x = 0; x < w; x++) {
101
+ const sx = Math.floor((x - dx) / scaleX)
102
+ if (sx < 0 || sx >= frame.width) continue
103
+ const t = (y * w + x) * 4
104
+ const s = (sy * frame.width + sx) * 4
105
+ out[t] = frame.rgba[s]; out[t + 1] = frame.rgba[s + 1]; out[t + 2] = frame.rgba[s + 2]; out[t + 3] = frame.rgba[s + 3]
106
+ }
107
+ }
108
+ return { width: w, height: h, rgba: out, align: { scaleX, scaleY, dx, dy } }
109
+ }
110
+
111
+ /**
112
+ * Map a region from ALIGNED comparison space back to the ORIGINAL frame's
113
+ * pixel space, so reported findings are reviewable on stored originals.
114
+ */
115
+ export function mapRegionToFrame(region, { scaleX = 1, scaleY = 1, dx = 0, dy = 0 } = {}) {
116
+ const r = (v) => Math.max(0, Math.round(v))
117
+ return {
118
+ x: r((region.x - dx) / scaleX), y: r((region.y - dy) / scaleY),
119
+ width: Math.max(1, r(region.width / scaleX)), height: Math.max(1, r(region.height / scaleY)),
120
+ }
121
+ }
122
+
123
+ /** Integer box-average downsample — simulates a canvas reduction; used by tests to PROVE reduced-resolution diffs can lose real changes. */
124
+ export function boxAverage(frame, factor) {
125
+ const f = Math.max(1, Math.round(factor))
126
+ const w = Math.max(1, Math.floor(frame.width / f))
127
+ const h = Math.max(1, Math.floor(frame.height / f))
128
+ const out = new Uint8ClampedArray(w * h * 4)
129
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
130
+ let r = 0, g = 0, b = 0, a = 0, n = 0
131
+ for (let yy = y * f; yy < Math.min((y + 1) * f, frame.height); yy++) for (let xx = x * f; xx < Math.min((x + 1) * f, frame.width); xx++, n++) {
132
+ const p = (yy * frame.width + xx) * 4
133
+ r += frame.rgba[p]; g += frame.rgba[p + 1]; b += frame.rgba[p + 2]; a += frame.rgba[p + 3]
134
+ }
135
+ const t = (y * w + x) * 4
136
+ out[t] = r / n; out[t + 1] = g / n; out[t + 2] = b / n; out[t + 3] = a / n
137
+ }
138
+ return { width: w, height: h, rgba: out }
139
+ }
140
+
141
+ /** Masks are opt-in exclusions with a REQUIRED documented reason — no silent masking (audit §2). */
142
+ export function validateMasks(masks) {
143
+ return (masks ?? []).map((m, i) => {
144
+ if (!m || typeof m !== "object") throw new Error(`mask ${i}: must be an object`)
145
+ if (typeof m.reason !== "string" || m.reason.trim() === "") throw new Error(`mask ${i}: a documented reason is required (masks hide defects — never adjust them to make a comparison pass)`)
146
+ for (const k of ["x", "y", "width", "height"]) if (typeof m[k] !== "number" || !Number.isFinite(m[k]) || m[k] < 0) throw new Error(`mask ${i}: ${k} must be a non-negative number`)
147
+ return { x: m.x | 0, y: m.y | 0, width: m.width | 0, height: m.height | 0, reason: m.reason.trim().slice(0, 500) }
148
+ })
149
+ }
150
+
151
+ /**
152
+ * Corroborate clusters against OCR text boxes passed in by the caller.
153
+ * Reports DISAGREEMENT explicitly: changed pixels with no text, text with no
154
+ * change — evidence to look at, not averaged truth.
155
+ */
156
+ export function ocrCorroboration(clusters, ocrBoxes = []) {
157
+ const inCluster = (box) => clusters.some((c) =>
158
+ box.x < c.x + c.width && box.x + box.width > c.x && box.y < c.y + c.height && box.y + box.height > c.y)
159
+ return {
160
+ textInChangedRegions: ocrBoxes.filter(inCluster).map((b) => b.text ?? "(unlabeled)"),
161
+ changedRegionsWithoutText: clusters.filter((c) => !ocrBoxes.some((b) => inCluster([{ ...b }]) &&
162
+ b.x < c.x + c.width && b.x + b.width > c.x && b.y < c.y + c.height && b.y + b.height > c.y)).length,
163
+ note: "disagreements listed; never averaged into a single score",
164
+ }
165
+ }
166
+
167
+ /** Unified result envelope — status is about the OPERATION, never a verdict on UI quality. */
168
+ export function compareEnvelope({ runId, project, screen, stateId, mode, diff, observations = [], evidenceRefs = [], env = {}, comparisonSpace = "native", transforms = null, limitations = [] }) {
169
+ if (mode !== "regression" && mode !== "design-reference") throw new Error('mode must be "regression" | "design-reference"')
170
+ if (comparisonSpace !== "native" && comparisonSpace !== "downsampled") throw new Error('comparisonSpace must be "native" | "downsampled"')
171
+ let checkStatus
172
+ if (diff.status === "blocked") checkStatus = "blocked"
173
+ else if (mode === "regression") {
174
+ // A REDUCED-resolution comparison can never prove native pixel equality
175
+ // (downsampling can erase small changes) — it is triage, needs_review at best.
176
+ if (diff.changedPixels > 0) checkStatus = "failed"
177
+ else checkStatus = comparisonSpace === "native" ? "passed" : "needs_review"
178
+ } else checkStatus = "needs_review"
179
+ return {
180
+ runId, project, screen, stateId, mode,
181
+ operation: diff.status === "blocked" ? "blocked" : "completed",
182
+ checkStatus,
183
+ comparisonSpace,
184
+ transforms: transforms ?? undefined,
185
+ diff,
186
+ observations, // [{source, finding}] — concrete observations only
187
+ evidenceRefs,
188
+ env,
189
+ limitations: [
190
+ ...(mode === "design-reference" ? ["design-reference mode compares structure, not pixels; renderer and typography differ by nature"] : []),
191
+ ...(comparisonSpace === "downsampled" ? ["reduced-resolution comparison is triage only — it CANNOT establish native pixel equality (small changes may be erased); run full-resolution regression for a passed verdict"] : []),
192
+ ...limitations,
193
+ ],
194
+ note: "tool ran; this is NOT a visual pass. regression (native): any changed pixel fails; masks require documented reasons and are shown in review, never adjusted to hide defects. design-reference: human/critic review required.",
195
+ }
196
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-mobilecode",
3
3
  "description": "MobileCode for the dsh web GUI: detect iOS/Android projects, run preview servers, and drive the simulator/emulator from the session — 37 agent tools (device_run, device_screen, device_ui_tree, device_ui_rows, device_tap_row, device_tap_element, device_wait_for, device_scroll_to, device_input, device_batch, device_intent, device_connect, device_pair_qr, device_perf, device_meminfo, device_backtrace, device_display, device_avd_create, device_pair_capture, device_app_info, device_install, device_uninstall, device_reboot, device_log, live screen stream, multimodal screenshots) plus a host-mediated co-op mesh hub (random callsigns, JSON pub/sub, tunable latency/jitter/drop/dup/throttle) so two virtual devices — and the AI watching them — can talk in real time. One classified adb boundary and a Wi-Fi connect/pair QR flow in the Connection settings tab. Hot-pluggable — mounted via the profile bundle list + cordis.patch.yml, no dsh source changes.",
4
- "version": "0.11.9",
4
+ "version": "0.12.0",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.22.0",
7
7
  "engines": {