Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .beads/interactions.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,6 @@
{"id":"int-51cd7e3fd904dfa823a0a73a0fb41156","kind":"field_change","created_at":"2026-07-30T10:36:21.003339065Z","actor":"halaprix","issue_id":"slotscope-zvd.5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixture gains balances/history/note at slots 5-7 plus a constructor that fills the array and string, so the default document demonstrates reserved rows and their measured values on first compile and deploy. Declared after positionValue: the editor virtualizes lines and relations.spec.ts clicks one inside that function, so keeping earlier lines in place was required (caught by the full browser suite). Goldens regenerated via the sanctioned --update; verified every existing variable kept its exact slot and offset. sourceHash, expectedLayoutHash and expectedRelationHash moved; expectedInstructionHash did not."}}
{"id":"int-eaddbbe40fad713f478dda7fcb0c3278","kind":"field_change","created_at":"2026-07-30T10:36:21.314921434Z","actor":"halaprix","issue_id":"slotscope-zvd","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"All five tasks complete and verified: format+lint+typecheck clean, 235/235 unit (incl. Forge cross-check and a 300-run fast-check gaplessness property), fixtures:verify no drift, 61/61 Playwright, build+build:check (224 KB main entry), benchmark p95 225ms action-to-paint / 657ms cold start PASS. Not committed: conservative profile, awaiting owner approval."}}
{"id":"int-d4e8aa7f76623df676964dd1a4c2a317","kind":"field_change","created_at":"2026-07-30T14:22:32.928557923Z","actor":"halaprix","issue_id":"slotscope-02a","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"buildStaticIndex now indexes nested reference heads from the decoded model's collapsed spans. Verified end to end: a write to a mapping inside a struct resolves exact as nest.inner[0xaa..] where it previously reported unresolved (test added to chains.test.ts, confirmed failing first). 236/236 unit, 61/61 e2e, fixtures no drift, release:verify passed. Committed on fix/nested-reference-heads; PR held until #4 merges, since the fix reads CollapsedStorageSpan.encoding which #4 introduces."}}
{"id":"int-50421840975e9832e8db08d0887f4aa2","kind":"field_change","created_at":"2026-07-30T14:55:20.061275583Z","actor":"halaprix","issue_id":"slotscope-xu8.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Derived entries render as child rows under their reserved row. Parent linkage is slot arithmetic over recorded keccak preimages, not name matching, so nested heads attach with no special case. Rail shows the derivation (keccak(0xaa.., 1), nested hops shown as keccak(k2, keccak(k1, 2)), array elements as keccak(3)+n). Roving focus generalized to a flat navigable-row list. 241 unit + 10 storage e2e green, axe clean."}}
{"id":"int-19379715946fac12b29eab1bcb3b26ce","kind":"field_change","created_at":"2026-07-30T14:58:46.581879605Z","actor":"halaprix","issue_id":"slotscope-xu8.2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"RuntimeStorage is now a chronological write log with step numbers, keeping the status pill, migration badge and constructor-origin heading. Grid is the only per-variable map. Verified: 241/241 unit, 62/62 e2e, fixtures no drift, release:verify passed, bundle 225 KB, benchmark p95 250ms/732ms PASS."}}
{"id":"int-ee68c253d1c8e02909fed8c2dddde679","kind":"field_change","created_at":"2026-07-30T14:58:46.737131043Z","actor":"halaprix","issue_id":"slotscope-xu8","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"RuntimeStorage is now a chronological write log with step numbers, keeping the status pill, migration badge and constructor-origin heading. Grid is the only per-variable map. Verified: 241/241 unit, 62/62 e2e, fixtures no drift, release:verify passed, bundle 225 KB, benchmark p95 250ms/732ms PASS."}}
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,35 @@ All notable changes to SlotScope. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow the
[roadmap](.resources/slotscope-final-scope-roadmap-v2.0.md) release plan.

## [1.4.0] — unreleased

### Added

- **Keccak-derived slots are visible, written as derivations.** A mapping entry or
array element the run touched now appears as a child row under its reserved
slot, and its address is written the way the EVM computes it —
`keccak(0xaa…, 1)`, or `keccak(0xbb…, keccak(0xaa…, 2))` for a nested hop, or
`keccak(3)+2` for an array element. The raw 32-byte slot is a hover detail; the
derivation is the identity, because that is the thing worth learning.
Entries that were only read are marked read-only — a fact no surface showed
before. Parent linkage is slot arithmetic over recorded keccak preimages, never
name matching, so a head nested inside a struct attaches with no special case.

### Changed

- **`RuntimeStorage` is now a write log, not a second map.** It lists every write
in the order it happened, with step numbers. The grid above is the map; two maps
of the same state, ordered differently, only made a reader decide which to
believe.
- Roving focus in the storage grid indexes a flat list of navigable rows, so arrow
keys reach a derived child exactly like a declared slot.

### Fixed

- A mapping, dynamic array or `bytes`/`string` declared inside a struct or array
element resolved as `unresolved`: the semantics index knew only top-level heads,
so `keccak(key ‖ slot)` could not be grounded for a nested one.

## [1.2.0] — unreleased

### Added
Expand Down
8 changes: 6 additions & 2 deletions apps/web/e2e/semantics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,15 @@ test('nested mappings show the collapsed KeccakFlow and the runtime storage grou
await expect(flow.locator('[data-depth="2"]').first()).toBeVisible();
await expect(write.locator('[data-testid="keccak-flow-collapsed"]')).toContainText('collapsed');

// The runtime group lives in the probe pane's Storage mode.
// The write log lives in the probe pane's Storage mode. It is a sequence, not
// a second map: the grid above owns the per-variable view.
await page.getByRole('tab', { name: 'Storage' }).click();
const runtime = page.getByRole('region', { name: 'Runtime storage' });
await expect(runtime).toBeVisible();
await expect(runtime.locator('[data-testid="runtime-group"]')).toContainText('allowance');
await expect(runtime).toContainText('Writes in order');
const entry = runtime.locator('[data-testid="runtime-write"]').first();
await expect(entry).toContainText('allowance');
await expect(entry).toContainText('step ');
await expect(runtime).toContainText('= 55');
await expect(runtime.locator('.ss-status-pill.is-exact')).toBeVisible();
});
32 changes: 32 additions & 0 deletions apps/web/e2e/storage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,35 @@ test('the default source shows its reserved slots, then their values on deploy',
// solc never writes a mapping head, so it stays blank.
await expect(reserved.nth(0)).not.toContainText('0x');
});

test('a mapping entry written by the constructor appears as a keccak-derived child', async ({
page,
}) => {
await page.goto('/');
await expect(page.locator('[role="status"]')).toHaveAttribute('data-status', 'ready', {
timeout: 60_000,
});
await page
.locator('.cm-content')
.fill(
'pragma solidity 0.8.36;\ncontract M {\n mapping(address => uint256) internal balances;\n constructor() {\n balances[address(0x1)] = 5;\n }\n}',
);
await page.getByRole('button', { name: 'Compile' }).click();
const grid = page.getByRole('grid', { name: 'Storage layout' });
await expect(grid).toBeVisible({ timeout: 60_000 });

// Before any run the head is reserved and has no children.
await expect(grid.locator('[data-testid="storage-derived"]')).toHaveCount(0);

await page.getByRole('button', { name: 'Deploy locally' }).click();
const derived = grid.locator('[data-testid="storage-derived"]');
await expect(derived).toHaveCount(1, { timeout: 60_000 });
// The address is the derivation, written the way the EVM computes it.
await expect(derived).toContainText('keccak(');
await expect(derived).toContainText('balances[');
await expect(derived).toContainText('5');
await expect(derived).toContainText('not the next slot');

const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
61 changes: 46 additions & 15 deletions apps/web/src/app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1294,6 +1294,44 @@ input[readonly] {
color: var(--ss-color-fg-muted);
}

/* Derived children: keccak addresses, deliberately set apart from the declared
sequence so the indent and the leader say "not the next slot" before the
words do. */
.storage-derived {
margin-left: 1.25rem;
border-left: 1px dashed var(--ss-color-border-strong);
padding-left: 0.5rem;
}

.storage-derived-note {
margin: 0.25rem 0;
color: var(--ss-color-fg-muted);
font-size: var(--ss-font-size-micro);
letter-spacing: var(--ss-tracking-label);
text-transform: uppercase;
}

.storage-slot-label.is-derived-rail .storage-slot-number {
font-size: var(--ss-font-size-micro);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.storage-cell.is-derived {
display: flex;
align-items: center;
gap: 0.5rem;
overflow: hidden;
padding-inline: 0.5rem;
border: 1px solid var(--ss-color-border-strong);
background: var(--ss-color-chip-bg);
}

.storage-cell.is-derived.is-read-only {
border-style: dashed;
}

.storage-record-note {
color: var(--ss-color-fg-muted);
}
Expand Down Expand Up @@ -1730,21 +1768,7 @@ input[readonly] {
color: var(--ss-color-label);
}

.ss-runtime-group {
border: var(--ss-border);
border-radius: var(--ss-radius);
padding: var(--ss-space-1) var(--ss-space-2);
margin-bottom: var(--ss-space-2);
}

.ss-runtime-var {
font-family: var(--ss-font-mono);
font-size: var(--ss-font-size-micro);
color: var(--ss-color-label);
letter-spacing: var(--ss-tracking-label);
}

.ss-runtime-group ul {
.ss-runtime-log {
list-style: none;
margin: 4px 0 0;
padding: 0;
Expand All @@ -1753,6 +1777,13 @@ input[readonly] {
gap: 3px;
}

.ss-runtime-step {
font-family: var(--ss-font-mono);
font-size: var(--ss-font-size-micro);
color: var(--ss-color-fg-muted);
font-variant-numeric: tabular-nums;
}

/* Long semantic labels wrap inside the pane instead of overflowing. */
.ss-obs {
overflow: hidden;
Expand Down
79 changes: 37 additions & 42 deletions apps/web/src/storage/RuntimeStorage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@ import type { SemanticResolution } from '@slotscope/domain';
import { MigrationBadge, StatusPill } from '@slotscope/ui';

/**
* Runtime storage grouping (S5): the slots the last traced execution
* actually wrote, grouped under the static variable they derive from and
* carrying their resolved labels — the runtime companion to the static
* grid. Also how initializer values become visible: a constructor is an
* execution like any other, so its writes appear here immediately after
* deploy — never parsed from source, only measured.
* The write log (S5): every persistent or transient write the last traced
* execution performed, in the order it performed them.
*
* It deliberately does NOT group by variable. The storage grid above is the
* map — declared slots with their reserved heads and keccak-derived children —
* and two maps of the same state, ordered differently, make a reader work out
* which one to believe. This answers the other question: what happened, and in
* what order. A repeated write to one slot appears once per write, because the
* sequence is the point.
*
* Also how initializer values become visible: a constructor is an execution
* like any other, so its writes appear here immediately after deploy — never
* parsed from source, only measured.
*/
export function RuntimeStorage({
resolutions,
Expand All @@ -20,46 +27,34 @@ export function RuntimeStorage({
const writes = resolutions.filter((r) => r.kind === 'sstore' || r.kind === 'tstore');
if (writes.length === 0) return null;

const groups = new Map<string, Map<string, SemanticResolution>>();
for (const resolution of writes) {
const groupKey = resolution.baseVariable ?? '(unresolved)';
const labelKey = resolution.label ?? `raw ${resolution.accessId}`;
const group = groups.get(groupKey) ?? new Map<string, SemanticResolution>();
group.set(labelKey, resolution); // later writes replace earlier: last value wins
groups.set(groupKey, group);
}

return (
<section aria-label="Runtime storage" className="ss-runtime-storage" tabIndex={0}>
<h3>
Runtime storage ({origin === 'constructor' ? 'from the constructor' : 'last traced call'})
Writes in order ({origin === 'constructor' ? 'from the constructor' : 'last traced call'})
</h3>
{[...groups.entries()].map(([groupName, byLabel]) => (
<div key={groupName} className="ss-runtime-group" data-testid="runtime-group">
<span className="ss-runtime-var">{groupName}</span>
<ul>
{[...byLabel.values()].map((resolution) => (
<li key={resolution.accessId}>
<StatusPill status={resolution.status} />{' '}
<code>
{resolution.label ?? 'unresolved slot'}
{resolution.decodedValues.length > 0 && (
<>
{' = '}
<span className="ss-measured">
{resolution.decodedValues.map((value) => value.display).join(' · ')}
</span>
</>
)}
</code>
{resolution.migration !== null && (
<MigrationBadge migration={resolution.migration} />
)}
</li>
))}
</ul>
</div>
))}
<ol className="ss-runtime-log">
{writes.map((resolution) => (
<li key={resolution.accessId} data-testid="runtime-write">
<span className="ss-runtime-step">step {resolution.stepIndex}</span>{' '}
<StatusPill status={resolution.status} />{' '}
<code>
{resolution.label ?? 'unresolved slot'}
{resolution.kind === 'tstore' && (
<span className="ss-runtime-step"> · transient</span>
)}
{resolution.decodedValues.length > 0 && (
<>
{' = '}
<span className="ss-measured">
{resolution.decodedValues.map((value) => value.display).join(' · ')}
</span>
</>
)}
</code>
{resolution.migration !== null && <MigrationBadge migration={resolution.migration} />}
</li>
))}
</ol>
</section>
);
}
Loading