From 811374cb663ae1046ce94dd418a926b093afa2af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 21 Jul 2026 08:27:30 +0200 Subject: [PATCH 1/5] chore: add freerange check --- .github/workflows/ci.yml | 14 ++++++++++ package.json | 4 ++- pnpm-lock.yaml | 18 +++++++++++++ pnpm-workspace.yaml | 2 ++ src/contracts/scroll-gesture.test.ts | 6 +++++ src/contracts/scroll-gesture.ts | 26 ++++++++++++------- src/daemon/handlers/record-trace-android.ts | 6 +++++ .../screenshot-diff-region-overlay.ts | 9 ++++--- 8 files changed, 71 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0980c195d..0954bd4e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,6 +240,20 @@ jobs: - name: Run typecheck run: pnpm typecheck + freerange: + name: FreeRange + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup toolchain + uses: ./.github/actions/setup-node-pnpm + + - name: Check numeric ranges + run: pnpm check:freerange + integration: name: Integration Tests runs-on: ubuntu-latest diff --git a/package.json b/package.json index e2e7113a5..905843e45 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "check:layering": "node --experimental-strip-types --test scripts/layering/model.test.ts && node --experimental-strip-types scripts/layering/check.ts", "check:production-exports": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --fail-on-issues", "check:bundle-owner-files": "node --experimental-strip-types scripts/check-bundle-owner-files.ts", + "check:freerange": "fr", "check:quick": "pnpm lint && pnpm typecheck", "sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs", "check:mcp-metadata": "node scripts/sync-mcp-metadata.mjs --check", @@ -224,8 +225,8 @@ "yaml": "^2.9.0" }, "devDependencies": { + "@chenglou/freerange": "^0.0.1", "@types/node": "^22.19.21", - "typescript": "^7.0.2", "@vitest/coverage-v8": "4.1.8", "fallow": "^2.95.0", "oxc-parser": "^0.138.0", @@ -233,6 +234,7 @@ "oxlint": "^1.69.0", "skillgym": "^0.9.1", "tsdown": "^0.22.4", + "typescript": "^7.0.2", "vite": "^8.0.16", "vitest": "^4.1.8" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d16213c19..25529ab4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ importers: specifier: ^2.9.0 version: 2.9.0 devDependencies: + '@chenglou/freerange': + specifier: ^0.0.1 + version: 0.0.1 '@types/node': specifier: ^22.19.21 version: 22.19.21 @@ -93,6 +96,10 @@ packages: react: ^19.2.4 react-dom: ^19.2.4 + '@chenglou/freerange@0.0.1': + resolution: {integrity: sha512-RCdvTZX66Dp5roRrld+2GH4tJV+uyo21nEsF/lxwDBjzDFagG9CnJ7go5Qim2ZDHTC40lQWNF1AprDxTDQTxfg==} + hasBin: true + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -2265,6 +2272,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -2486,6 +2498,10 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + '@chenglou/freerange@0.0.1': + dependencies: + typescript: 6.0.3 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -4702,6 +4718,8 @@ snapshots: tslib@2.8.1: {} + typescript@6.0.3: {} + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9b90dcfdd..0347364c9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,3 +5,5 @@ pmOnFail: warn verifyDepsBeforeRun: warn allowBuilds: fallow: true +minimumReleaseAgeExclude: + - '@chenglou/freerange@0.0.1' diff --git a/src/contracts/scroll-gesture.test.ts b/src/contracts/scroll-gesture.test.ts index 56f82b6ac..12edc8bd7 100644 --- a/src/contracts/scroll-gesture.test.ts +++ b/src/contracts/scroll-gesture.test.ts @@ -221,3 +221,9 @@ test('clampGestureCoordinate rounds values and clamps them into the safe gesture assert.equal(clampGestureCoordinate(2.6, 8, 100), 8); assert.equal(clampGestureCoordinate(97.6, 8, 100), 92); }); + +test('clampGestureCoordinate returns the lower bound for missing or non-finite coordinates', () => { + assert.equal(clampGestureCoordinate(undefined, 8, 100), 8); + assert.equal(clampGestureCoordinate(Number.POSITIVE_INFINITY, 8, 100), 8); + assert.equal(clampGestureCoordinate(10, Number.POSITIVE_INFINITY, 100), 0); +}); diff --git a/src/contracts/scroll-gesture.ts b/src/contracts/scroll-gesture.ts index 359814ef2..e020026ae 100644 --- a/src/contracts/scroll-gesture.ts +++ b/src/contracts/scroll-gesture.ts @@ -241,14 +241,16 @@ function pointFromPercent( xPercent: number, yPercent: number, ): GesturePoint { + const x = Math.trunc((frame.referenceWidth * xPercent) / 100); + const y = Math.trunc((frame.referenceHeight * yPercent) / 100); return { - x: Math.trunc((frame.referenceWidth * xPercent) / 100), - y: Math.trunc((frame.referenceHeight * yPercent) / 100), + x, + y, }; } function clampGesturePoint( - point: GesturePoint, + point: { x: number | undefined; y: number | undefined }, frame: GestureReferenceFrame, marginPx: number, ): GesturePoint { @@ -306,12 +308,16 @@ function normalizeRequestedPixels(pixels: number): number { return Math.max(1, Math.round(pixels)); } -export function clampGestureCoordinate(value: number, marginPx: number, size: number): number { - const min = marginPx; - const max = Math.max(min, size - marginPx); - return clampToRange(value, min, max); -} +export function clampGestureCoordinate( + value: number | undefined, + marginPx: number, + size: number, +): number { + const min = Math.round(marginPx); + if (!Number.isFinite(min)) return 0; + + const max = Math.max(min, Math.round(size - marginPx)); + if (!Number.isFinite(max) || value === undefined || !Number.isFinite(value)) return min; -function clampToRange(value: number, min: number, max: number): number { - return Math.min(Math.round(max), Math.max(Math.round(min), Math.round(value))); + return Math.min(max, Math.max(min, Math.round(value))); } diff --git a/src/daemon/handlers/record-trace-android.ts b/src/daemon/handlers/record-trace-android.ts index e496caeac..91e1002dd 100644 --- a/src/daemon/handlers/record-trace-android.ts +++ b/src/daemon/handlers/record-trace-android.ts @@ -215,7 +215,13 @@ function scaledSizeToMax(size: AndroidRecordingSize & { maxSize: number }): Andr if (longest <= size.maxSize) { return { width: size.width, height: size.height }; } + if (longest === 0) { + return { width: size.width, height: size.height }; + } const scale = size.maxSize / longest; + if (!Number.isFinite(scale)) { + return { width: size.width, height: size.height }; + } return { width: scaledEvenDimension(size.width, scale), height: scaledEvenDimension(size.height, scale), diff --git a/src/screenshot-diff/screenshot-diff-region-overlay.ts b/src/screenshot-diff/screenshot-diff-region-overlay.ts index 5148a4111..4b8460f94 100644 --- a/src/screenshot-diff/screenshot-diff-region-overlay.ts +++ b/src/screenshot-diff/screenshot-diff-region-overlay.ts @@ -20,8 +20,10 @@ export function annotateDiffRegions(diff: PNG, regions: ScreenshotDiffRegion[]): function drawRect(diff: PNG, rect: ScreenshotDiffRegion['rect']): void { const minX = clamp(rect.x, 0, diff.width - 1); const minY = clamp(rect.y, 0, diff.height - 1); - const maxX = clamp(rect.x + rect.width - 1, 0, diff.width - 1); - const maxY = clamp(rect.y + rect.height - 1, 0, diff.height - 1); + const right = rect.x + rect.width - 1; + const bottom = rect.y + rect.height - 1; + const maxX = clamp(right, 0, diff.width - 1); + const maxY = clamp(bottom, 0, diff.height - 1); for (let thickness = 0; thickness < REGION_BORDER_THICKNESS; thickness += 1) { for (let x = minX; x <= maxX; x += 1) { setPixel(diff, x, minY + thickness, REGION_BORDER_COLOR); @@ -48,6 +50,7 @@ function setPixel( diff.data[index + 3] = color[3]; } -function clamp(value: number, min: number, max: number): number { +function clamp(value: number | undefined, min: number, max: number): number { + if (value === undefined || !Number.isFinite(value)) return min; return Math.min(Math.max(value, min), max); } From b7bf0684988a5d729948967aa4216b9c5a8ba344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 21 Jul 2026 10:14:29 +0200 Subject: [PATCH 2/5] ci: install bun for freerange check --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0954bd4e2..6a93af796 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -251,6 +251,9 @@ jobs: - name: Setup toolchain uses: ./.github/actions/setup-node-pnpm + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - name: Check numeric ranges run: pnpm check:freerange From 69a8169742e8ca5043298a95822a58ed12fa5bae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 21 Jul 2026 10:35:41 +0200 Subject: [PATCH 3/5] fix: preserve numeric range contracts --- src/contracts/scroll-gesture.test.ts | 4 +--- src/contracts/scroll-gesture.ts | 14 +++++--------- .../screenshot-diff-region-overlay.ts | 4 ++-- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/contracts/scroll-gesture.test.ts b/src/contracts/scroll-gesture.test.ts index 12edc8bd7..42c3745e7 100644 --- a/src/contracts/scroll-gesture.test.ts +++ b/src/contracts/scroll-gesture.test.ts @@ -222,8 +222,6 @@ test('clampGestureCoordinate rounds values and clamps them into the safe gesture assert.equal(clampGestureCoordinate(97.6, 8, 100), 92); }); -test('clampGestureCoordinate returns the lower bound for missing or non-finite coordinates', () => { - assert.equal(clampGestureCoordinate(undefined, 8, 100), 8); +test('clampGestureCoordinate returns the lower bound for non-finite coordinates', () => { assert.equal(clampGestureCoordinate(Number.POSITIVE_INFINITY, 8, 100), 8); - assert.equal(clampGestureCoordinate(10, Number.POSITIVE_INFINITY, 100), 0); }); diff --git a/src/contracts/scroll-gesture.ts b/src/contracts/scroll-gesture.ts index e020026ae..4ff88cd73 100644 --- a/src/contracts/scroll-gesture.ts +++ b/src/contracts/scroll-gesture.ts @@ -244,13 +244,13 @@ function pointFromPercent( const x = Math.trunc((frame.referenceWidth * xPercent) / 100); const y = Math.trunc((frame.referenceHeight * yPercent) / 100); return { - x, - y, + x: Number.isFinite(x) ? x : 0, + y: Number.isFinite(y) ? y : 0, }; } function clampGesturePoint( - point: { x: number | undefined; y: number | undefined }, + point: GesturePoint, frame: GestureReferenceFrame, marginPx: number, ): GesturePoint { @@ -308,16 +308,12 @@ function normalizeRequestedPixels(pixels: number): number { return Math.max(1, Math.round(pixels)); } -export function clampGestureCoordinate( - value: number | undefined, - marginPx: number, - size: number, -): number { +export function clampGestureCoordinate(value: number, marginPx: number, size: number): number { const min = Math.round(marginPx); if (!Number.isFinite(min)) return 0; const max = Math.max(min, Math.round(size - marginPx)); - if (!Number.isFinite(max) || value === undefined || !Number.isFinite(value)) return min; + if (!Number.isFinite(max) || !Number.isFinite(value)) return min; return Math.min(max, Math.max(min, Math.round(value))); } diff --git a/src/screenshot-diff/screenshot-diff-region-overlay.ts b/src/screenshot-diff/screenshot-diff-region-overlay.ts index 4b8460f94..6fc847c10 100644 --- a/src/screenshot-diff/screenshot-diff-region-overlay.ts +++ b/src/screenshot-diff/screenshot-diff-region-overlay.ts @@ -50,7 +50,7 @@ function setPixel( diff.data[index + 3] = color[3]; } -function clamp(value: number | undefined, min: number, max: number): number { - if (value === undefined || !Number.isFinite(value)) return min; +function clamp(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; return Math.min(Math.max(value, min), max); } From dab38a733c39f0df21fb80561d65df2dc97f0647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 21 Jul 2026 10:56:58 +0200 Subject: [PATCH 4/5] fix: guard diff overlay geometry --- src/screenshot-diff/screenshot-diff-region-overlay.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/screenshot-diff/screenshot-diff-region-overlay.ts b/src/screenshot-diff/screenshot-diff-region-overlay.ts index 6fc847c10..230fd4630 100644 --- a/src/screenshot-diff/screenshot-diff-region-overlay.ts +++ b/src/screenshot-diff/screenshot-diff-region-overlay.ts @@ -18,10 +18,19 @@ export function annotateDiffRegions(diff: PNG, regions: ScreenshotDiffRegion[]): } function drawRect(diff: PNG, rect: ScreenshotDiffRegion['rect']): void { + if ( + !Number.isFinite(rect.x) || + !Number.isFinite(rect.y) || + !Number.isFinite(rect.width) || + !Number.isFinite(rect.height) + ) { + return; + } const minX = clamp(rect.x, 0, diff.width - 1); const minY = clamp(rect.y, 0, diff.height - 1); const right = rect.x + rect.width - 1; const bottom = rect.y + rect.height - 1; + if (!Number.isFinite(right) || !Number.isFinite(bottom)) return; const maxX = clamp(right, 0, diff.width - 1); const maxY = clamp(bottom, 0, diff.height - 1); for (let thickness = 0; thickness < REGION_BORDER_THICKNESS; thickness += 1) { From 26ba5ed81fb4d61572c773c79c6869a7762f8902 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 21 Jul 2026 11:02:23 +0200 Subject: [PATCH 5/5] refactor: isolate diff overlay bounds --- .../screenshot-diff-region-overlay.ts | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/screenshot-diff/screenshot-diff-region-overlay.ts b/src/screenshot-diff/screenshot-diff-region-overlay.ts index 230fd4630..22b74fcb8 100644 --- a/src/screenshot-diff/screenshot-diff-region-overlay.ts +++ b/src/screenshot-diff/screenshot-diff-region-overlay.ts @@ -18,21 +18,12 @@ export function annotateDiffRegions(diff: PNG, regions: ScreenshotDiffRegion[]): } function drawRect(diff: PNG, rect: ScreenshotDiffRegion['rect']): void { - if ( - !Number.isFinite(rect.x) || - !Number.isFinite(rect.y) || - !Number.isFinite(rect.width) || - !Number.isFinite(rect.height) - ) { - return; - } - const minX = clamp(rect.x, 0, diff.width - 1); - const minY = clamp(rect.y, 0, diff.height - 1); - const right = rect.x + rect.width - 1; - const bottom = rect.y + rect.height - 1; - if (!Number.isFinite(right) || !Number.isFinite(bottom)) return; - const maxX = clamp(right, 0, diff.width - 1); - const maxY = clamp(bottom, 0, diff.height - 1); + const bounds = resolveFiniteRectBounds(rect); + if (bounds == null) return; + const minX = clamp(bounds.x, 0, diff.width - 1); + const minY = clamp(bounds.y, 0, diff.height - 1); + const maxX = clamp(bounds.right, 0, diff.width - 1); + const maxY = clamp(bounds.bottom, 0, diff.height - 1); for (let thickness = 0; thickness < REGION_BORDER_THICKNESS; thickness += 1) { for (let x = minX; x <= maxX; x += 1) { setPixel(diff, x, minY + thickness, REGION_BORDER_COLOR); @@ -45,6 +36,19 @@ function drawRect(diff: PNG, rect: ScreenshotDiffRegion['rect']): void { } } +function resolveFiniteRectBounds(rect: ScreenshotDiffRegion['rect']): { + x: number; + y: number; + right: number; + bottom: number; +} | null { + const right = rect.x + rect.width - 1; + const bottom = rect.y + rect.height - 1; + const values = [rect.x, rect.y, rect.width, rect.height, right, bottom]; + if (!values.every(Number.isFinite)) return null; + return { x: rect.x, y: rect.y, right, bottom }; +} + function setPixel( diff: PNG, x: number,