From 64c41fe1e6a003e1056e24231d1f1ed6866beadf Mon Sep 17 00:00:00 2001 From: halaprix <6533433+halaprix@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:28:58 +0200 Subject: [PATCH] fix(web): stop double-counting the "N entries seen" chip on a reference row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single history.push(7) reported "2 entries seen" instead of 1. runtimeView's derivedCountByBase counted distinct resolution LABELS per baseVariable, but a dynamic array push touches two storage slots under the same baseVariable: the declared length slot itself (read on entry, rewritten on exit — labeled "history.length") and the actual new element at a keccak-derived slot (labeled "history[0]"). Both got counted as if they were derived entries, so one push always overcounted by exactly one. derivedRows.ts's buildDerivedChildren already solves this correctly for the child-row rendering added in v1.4: it explicitly excludes a variable's own declared slot from the derived set (`if (declaredSlots.has(slot)) continue`), counting only slots that are genuinely keccak-derived. StorageGrid.tsx already computes that exact list per row as `children`; the chip now reads its length instead of the separate, buggy derivedCountByBase mechanism. This also fixes the struct-nesting gap flagged when v1.4 shipped: derivedCountByBase keyed on baseVariable (a name), which never matched a mapping declared inside a struct (s.m vs baseVariable "m"). byParentSlot keys on the actual slot number, so it's correct regardless of nesting. derivedCountByBase is now dead (was used nowhere else) and removed, along with its now-obsolete unit test. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/e2e/storage.spec.ts | 9 ++++++ apps/web/src/storage/StorageGrid.tsx | 7 +++-- apps/web/src/storage/runtimeView.test.ts | 35 +----------------------- apps/web/src/storage/runtimeView.ts | 16 +---------- 4 files changed, 16 insertions(+), 51 deletions(-) diff --git a/apps/web/e2e/storage.spec.ts b/apps/web/e2e/storage.spec.ts index e315b19..ddd3365 100644 --- a/apps/web/e2e/storage.spec.ts +++ b/apps/web/e2e/storage.spec.ts @@ -180,6 +180,15 @@ test('a reserved slot shows the value the constructor put in it', async ({ page const reserved = grid.locator('[data-testid="storage-reference"]'); await expect(reserved.nth(0)).toContainText('length = 1', { timeout: 60_000 }); await expect(reserved.nth(1)).toContainText('"hi"'); + + // A single push touches two slots (the length it rewrites, plus the new + // element) but names only one derived entry — the length slot is the + // reserved slot itself, not a derived one, and must not be double-counted. + const historyRecord = grid + .locator('[data-testid="storage-record"]') + .filter({ hasText: 'history' }); + await expect(historyRecord).toContainText('1 entry seen'); + await expect(historyRecord).not.toContainText('2 entries seen'); }); test('the default source shows its reserved slots, then their values on deploy', async ({ diff --git a/apps/web/src/storage/StorageGrid.tsx b/apps/web/src/storage/StorageGrid.tsx index d851711..0f8c66b 100644 --- a/apps/web/src/storage/StorageGrid.tsx +++ b/apps/web/src/storage/StorageGrid.tsx @@ -265,8 +265,11 @@ export function StorageGrid({ // prose that used to live under "Not expanded". const head = row.segments[0]; const referenceSpan = head?.kind === 'reference' ? head.span : null; - const derivedSeen = - referenceSpan === null ? 0 : (runtime?.derivedCountByBase.get(referenceSpan.path) ?? 0); + // The same count that drives the child rows below, so the chip and + // the rows can never disagree. Counting resolutions by baseVariable + // instead double-counted a dynamic array's own length slot (read on + // entry, written on exit) as if it were a second entry. + const derivedSeen = children.length; const recordCells = row.segments .filter((segment) => segment.kind === 'value') .map((segment) => (segment as Extract).cell) diff --git a/apps/web/src/storage/runtimeView.test.ts b/apps/web/src/storage/runtimeView.test.ts index 1faa78b..3ad7536 100644 --- a/apps/web/src/storage/runtimeView.test.ts +++ b/apps/web/src/storage/runtimeView.test.ts @@ -1,4 +1,4 @@ -import type { SemanticResolution, StorageAccessObservation } from '@slotscope/domain'; +import type { StorageAccessObservation } from '@slotscope/domain'; import { describe, expect, it } from 'vitest'; import { buildRuntimeView } from './runtimeView'; @@ -17,28 +17,10 @@ function trace(storage: StorageAccessObservation[]) { return { observations: { keccak: [], storage } }; } -function resolution(partial: Partial): SemanticResolution { - return { - accessId: 'step-0', - stepIndex: 0, - kind: 'sstore', - status: 'exact', - label: null, - decodedValues: [], - reasoning: [], - baseVariable: null, - accessCostNote: null, - migration: null, - previousValueHex: null, - ...partial, - }; -} - describe('buildRuntimeView', () => { it('is empty without a trace', () => { const view = buildRuntimeView(null, [], 0); expect(view.wordsBySlot.size).toBe(0); - expect(view.derivedCountByBase.size).toBe(0); }); it('keys words by decimal slot and lets the last observation win', () => { @@ -79,19 +61,4 @@ describe('buildRuntimeView', () => { ); expect(view.wordsBySlot.get('1')).toBe(`${'0'.repeat(63)}4`); }); - - it('counts distinct derived entries per base variable', () => { - const view = buildRuntimeView( - trace([]), - [ - resolution({ baseVariable: 'balances', label: 'balances[0x01]' }), - resolution({ baseVariable: 'balances', label: 'balances[0x01]' }), - resolution({ baseVariable: 'balances', label: 'balances[0x02]' }), - resolution({ baseVariable: null, label: 'raw' }), - ], - 9, - ); - expect(view.derivedCountByBase.get('balances')).toBe(2); - expect(view.derivedCountByBase.has('raw')).toBe(false); - }); }); diff --git a/apps/web/src/storage/runtimeView.ts b/apps/web/src/storage/runtimeView.ts index bf8031e..3cc1e59 100644 --- a/apps/web/src/storage/runtimeView.ts +++ b/apps/web/src/storage/runtimeView.ts @@ -3,8 +3,6 @@ import type { SemanticResolution, TraceObservations } from '@slotscope/domain'; export interface StorageRuntimeView { /** Decimal slot → the last 32-byte word observed at or before the cursor. */ readonly wordsBySlot: ReadonlyMap; - /** Base variable → how many distinct derived entries this run has named. */ - readonly derivedCountByBase: ReadonlyMap; /** Raw Layer-1 facts, so the grid can reconstruct derived slots itself. It * needs the declared-slot set to do that, which only the grid knows. */ readonly observations: TraceObservations | null; @@ -13,7 +11,6 @@ export interface StorageRuntimeView { const EMPTY: StorageRuntimeView = { wordsBySlot: new Map(), - derivedCountByBase: new Map(), observations: null, resolutions: [], }; @@ -49,16 +46,5 @@ export function buildRuntimeView( } } - const derivedLabels = new Map>(); - for (const resolution of resolutions) { - const base = resolution.baseVariable; - if (base === null) continue; - const labels = derivedLabels.get(base) ?? new Set(); - labels.add(resolution.label ?? resolution.accessId); - derivedLabels.set(base, labels); - } - const derivedCountByBase = new Map(); - for (const [base, labels] of derivedLabels) derivedCountByBase.set(base, labels.size); - - return { wordsBySlot, derivedCountByBase, observations, resolutions }; + return { wordsBySlot, observations, resolutions }; }