diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 666d18f..3e3bd8b 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -87,3 +87,4 @@ {"id":"int-b276681d8d66b2c92c0ead9c38ffd11d","kind":"field_change","created_at":"2026-07-30T10:31:04.75317577Z","actor":"halaprix","issue_id":"slotscope-zvd.4","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"runtimeView folds observed words (reads included, cursor-bounded, transient excluded) plus distinct derived-entry counts; referenceValue turns (span, word) into per-encoding copy and one measured value; StorageGrid renders reserved and elided rows with amber only on measured words, retires the 'Not expanded' list, and keeps roving focus/pin/zoom/axe green. 234/234 unit, 8/8 storage e2e, format+lint+typecheck clean."}} {"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."}} diff --git a/packages/semantics/src/chains.test.ts b/packages/semantics/src/chains.test.ts index 4efbd91..cdc4d4c 100644 --- a/packages/semantics/src/chains.test.ts +++ b/packages/semantics/src/chains.test.ts @@ -13,8 +13,10 @@ contract Corpus { uint256[] internal arr; // slot 2 uint64[] internal packedArr; // slot 3 mapping(bytes4 => uint256) internal mb; // slot 4 + Nested internal nest; // slot 5..6 struct Pair { uint256 a; uint256 b; } + struct Nested { uint256 head; mapping(address => uint256) inner; } function approve(address owner_, address spender, uint256 value) external { allowance[owner_][spender] = value; @@ -24,6 +26,7 @@ contract Corpus { function setArr(uint256 i, uint256 v) external { arr[i] = v; } function pushPacked(uint64 v) external { packedArr.push(v); } function setMb(bytes4 k, uint256 v) external { mb[k] = v; } + function setNested(address k, uint256 v) external { nest.inner[k] = v; } } `; @@ -140,3 +143,24 @@ describe('keccak-chain resolution (S2, roadmap acceptance)', () => { expect(dataWrite!.decodedValues[0]!.path).toBe('packedArr[0]'); }); }); + +describe('reference heads nested inside a struct', () => { + it('grounds a mapping declared inside a struct, instead of giving up', async () => { + const { executor, address, layout } = await deployedCorpus(); + const result = await executor.call( + { + toHex: address, + dataHex: encodeCall(fn('setNested', ['address', 'uint256']), [OWNER, '42']), + }, + { captureTrace: true }, + ); + expect(result.success).toBe(true); + const write = resolveAccesses(result.observations!, layout).find((r) => r.kind === 'sstore')!; + expect(write.status).toBe('exact'); + expect(write.label).toBe(`nest.inner[${OWNER}]`); + expect(write.baseVariable).toBe('nest.inner'); + expect(write.decodedValues).toEqual([ + { path: write.label, typeLabel: 'uint256', display: '42' }, + ]); + }); +}); diff --git a/packages/semantics/src/resolve.ts b/packages/semantics/src/resolve.ts index 004f51b..0ca3866 100644 --- a/packages/semantics/src/resolve.ts +++ b/packages/semantics/src/resolve.ts @@ -54,6 +54,14 @@ export function buildStaticIndex(layout: RawStorageLayout | null): StaticLayoutI try { const model = decodeStorageLayout(layout, () => null); for (const [key, cells] of groupCellsBySlot(model.items)) cellsBySlot.set(key, cells); + // Reference heads nested inside a struct or an array element are not + // top-level layout entries, so `layout.storage` alone cannot name them and + // their keccak chains resolve as unresolved. The decoded model carries + // every one of them with its exact base slot and dotted path. + for (const item of model.items) { + if (item.kind !== 'collapsed' || item.encoding === 'inplace') continue; + variableBaseSlots.set(item.path, { slot: item.startSlot, typeId: item.typeId }); + } } catch (error) { if (!(error instanceof LayoutDecodeError)) throw error; // A malformed layout resolves nothing; accesses degrade to unresolved.