From 1bd32a1c367373660a979a5cfb444f0204a90cf9 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Fri, 10 Jul 2026 21:26:53 -0400 Subject: [PATCH 01/30] programs-react: fix call stack for the flat tail-call back-edge (#248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compiler emits a tail-call-optimized back-edge as a single instruction carrying both a `return` (the iteration that is ending) and an `invoke` (the iteration that is beginning) on one context. `buildCallStack` had no case for this: it read whichever discriminant it encountered first and either pushed a second frame (invoke) or popped the frame away (return), so a tail-recursive loop's call stack grew without bound or collapsed to empty instead of staying at constant depth. Detect the combined shape structurally — an instruction whose context carries both an invoke and a return — and reuse the top frame in place, taking the next iteration's identity from the invoke leaf. Depth is unchanged, which is what a reused activation should show. Adds unit coverage for the flat back-edge (constant depth, reused identity) alongside a regression check that ordinary calls still push and pop. --- .../src/utils/mockTrace.test.ts | 89 +++++++++++++++++++ .../programs-react/src/utils/mockTrace.ts | 56 ++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 packages/programs-react/src/utils/mockTrace.test.ts diff --git a/packages/programs-react/src/utils/mockTrace.test.ts b/packages/programs-react/src/utils/mockTrace.test.ts new file mode 100644 index 0000000000..531cfab43a --- /dev/null +++ b/packages/programs-react/src/utils/mockTrace.test.ts @@ -0,0 +1,89 @@ +/** + * Tests for call-stack construction, with focus on the flat + * tail-call back-edge shape: a single instruction that carries + * both a `return` and an `invoke` context. + */ + +import { describe, it, expect } from "vitest"; +import type { Program } from "@ethdebug/format"; +import { + buildCallStack, + buildPcToInstructionMap, + type TraceStep, +} from "./mockTrace.js"; + +/** Build a minimal instruction with a context at an offset. */ +function instr(offset: number, context: unknown): Program.Instruction { + return { + offset, + operation: { mnemonic: "JUMPDEST", arguments: [] }, + context, + } as unknown as Program.Instruction; +} + +describe("buildCallStack flat return+invoke back-edge", () => { + // A self-recursive tail loop: + // pc 0 — entry, invoke `sum` (push a frame) + // pc 4 — loop body (no call context) + // pc 10 — back-edge JUMP: flat context carrying BOTH the + // previous iteration's `return` and the next + // iteration's `invoke` on one instruction (the + // shape bugc emits for a TCO-replaced tail call). + const backEdge = instr(10, { + return: { identifier: "sum" }, + invoke: { jump: true, identifier: "sum" }, + }); + + const program = { + instructions: [ + instr(0, { invoke: { jump: true, identifier: "sum" } }), + instr(4, { code: {} }), + backEdge, + ], + } as unknown as Program; + + const pcToInstruction = buildPcToInstructionMap(program); + + // Entry and back-edge are NOT consecutive steps, so the + // caller-JUMP/callee-JUMPDEST dedup does not apply — this is + // the arrangement that exposes the bug. + const trace: TraceStep[] = [ + { pc: 0, opcode: "JUMPDEST" }, // step 0: push sum + { pc: 4, opcode: "JUMPDEST" }, // step 1: loop body + { pc: 10, opcode: "JUMP" }, // step 2: back-edge → reuse + { pc: 4, opcode: "JUMPDEST" }, // step 3: loop body + { pc: 10, opcode: "JUMP" }, // step 4: back-edge → reuse + ]; + + it("keeps the stack at constant depth across the back-edge", () => { + // Reused in place: one frame in, one frame out — depth 1. + expect(buildCallStack(trace, pcToInstruction, 2)).toHaveLength(1); + expect(buildCallStack(trace, pcToInstruction, 4)).toHaveLength(1); + }); + + it("reuses the top frame with the next iteration's identity", () => { + const stack = buildCallStack(trace, pcToInstruction, 2); + expect(stack[0].identifier).toBe("sum"); + expect(stack[0].callType).toBe("internal"); + // Points at the back-edge step, not the original entry. + expect(stack[0].stepIndex).toBe(2); + }); + + it("still pushes and pops ordinary (non-flat) calls", () => { + // A normal invoke on one instruction, a normal return on + // another — depth should rise then fall. + const normalProgram = { + instructions: [ + instr(0, { invoke: { jump: true, identifier: "helper" } }), + instr(8, { return: { identifier: "helper" } }), + ], + } as unknown as Program; + const map = buildPcToInstructionMap(normalProgram); + const normalTrace: TraceStep[] = [ + { pc: 0, opcode: "JUMPDEST" }, + { pc: 8, opcode: "JUMP" }, + ]; + expect(buildCallStack(normalTrace, map, 0)).toHaveLength(1); + expect(buildCallStack(normalTrace, map, 1)).toHaveLength(0); + }); +}); diff --git a/packages/programs-react/src/utils/mockTrace.ts b/packages/programs-react/src/utils/mockTrace.ts index 26a912fc73..7710b724d4 100644 --- a/packages/programs-react/src/utils/mockTrace.ts +++ b/packages/programs-react/src/utils/mockTrace.ts @@ -299,6 +299,33 @@ export function buildCallStack( continue; } + // A tail-call back-edge carries both a `return` (the previous + // iteration) and an `invoke` (the next iteration) on a single + // instruction. The activation is reused, not nested or + // unwound, so depth is unchanged: replace the top frame in + // place rather than pushing a second frame or popping it away. + // Identity comes from the invoke leaf. + const ctx = instruction.context as Record | undefined; + const backEdgeInvoke = ctx ? findInvokeField(ctx) : undefined; + if (ctx && backEdgeInvoke && hasReturnContext(ctx)) { + const argResult = extractArgInfo(instruction); + const frame: CallFrame = { + identifier: + (backEdgeInvoke.identifier as string | undefined) ?? + callInfo.identifier, + stepIndex: i, + callType: invokeCallType(backEdgeInvoke), + argumentNames: argResult?.names, + argumentPointers: argResult?.pointers, + }; + if (stack.length > 0) { + stack[stack.length - 1] = frame; + } else { + stack.push(frame); + } + continue; + } + if (callInfo.kind === "invoke") { // The compiler emits invoke on both the caller JUMP // and callee entry JUMPDEST for the same call. These @@ -384,6 +411,35 @@ function extractArgInfo( }; } +/** + * Determine the call type of a raw invoke record from its + * discriminant key. + */ +function invokeCallType(inv: Record): CallFrame["callType"] { + if ("jump" in inv) return "internal"; + if ("message" in inv) return "external"; + if ("create" in inv) return "create"; + return undefined; +} + +/** + * Whether an instruction's context carries a `return` — either + * directly or nested one level inside a gather. Mirrors + * findInvokeField so the flat (multi-discriminator) and gather + * back-edge shapes are both recognized. + */ +function hasReturnContext(ctx: Record): boolean { + if ("return" in ctx) { + return true; + } + if ("gather" in ctx && Array.isArray(ctx.gather)) { + return ctx.gather.some( + (item) => item && typeof item === "object" && "return" in item, + ); + } + return false; +} + function findInvokeField( ctx: Record, ): Record | undefined { From fedeb9a87775a5a3ffa0ab2963ffb85eacd1dbb6 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Fri, 10 Jul 2026 23:32:09 -0400 Subject: [PATCH 02/30] bugc: expose canonical BUG examples via a subpath export (#249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * bugc: expose canonical BUG examples via a subpath export The .bug files under packages/bugc/examples are bugc's canonical example programs and double as its behavioral test fixtures. Rendering them elsewhere meant copying sources by hand or globbing the raw files, which webpack-based bundlers can't do. Expose them through a new @ethdebug/bugc/examples subpath export: - A build step (bin/generate-examples.js) reads the .bug files and emits src/examples/generated.ts, a source-by-path map — the same generated module pattern as @ethdebug/format's schema yamls. The generated file is gitignored and (re)produced by prepare. - src/examples/index.ts surfaces exampleSources, examplePaths, and stripTestAnnotations(), which removes the inline /*@test*/ blocks and // @wip / // @skip / // @expect-* directives the raw files carry so a source reads cleanly in an editor. - Adds a package exports map (root "." plus "./examples"); the internal #-imports are a separate field and are unaffected. Stripping is purely cosmetic: the tests confirm a stripped example compiles to byte-identical bytecode as its raw counterpart. * bugc: regenerate examples module in the root build The root build calls tsc --build directly and bypasses each package's prepare script, so add the examples generate step alongside the existing schema-yamls step. This keeps the generated module current on the standalone yarn build / yarn start paths, which don't reinstall. --- .prettierignore | 1 + eslint.config.js | 1 + package.json | 2 +- packages/bugc/.gitignore | 2 + packages/bugc/bin/generate-examples.js | 68 +++++++++++ packages/bugc/package.json | 15 ++- .../bugc/src/examples/annotations.test.ts | 106 ++++++++++++++++++ packages/bugc/src/examples/annotations.ts | 38 +++++++ packages/bugc/src/examples/index.test.ts | 79 +++++++++++++ packages/bugc/src/examples/index.ts | 30 +++++ 10 files changed, 339 insertions(+), 3 deletions(-) create mode 100644 packages/bugc/.gitignore create mode 100644 packages/bugc/bin/generate-examples.js create mode 100644 packages/bugc/src/examples/annotations.test.ts create mode 100644 packages/bugc/src/examples/annotations.ts create mode 100644 packages/bugc/src/examples/index.test.ts create mode 100644 packages/bugc/src/examples/index.ts diff --git a/.prettierignore b/.prettierignore index 5ed4b7aadf..b0eaf397c7 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,6 +8,7 @@ package-lock.json # Auto-generated files packages/format/src/schemas/yamls.ts +packages/bugc/src/examples/generated.ts # Solidity fixtures are compiler inputs; this repo has no Solidity Prettier parser. packages/conformance/test/fixtures/solc/**/*.sol diff --git a/eslint.config.js b/eslint.config.js index a80e6d847f..f375f13f1b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -72,6 +72,7 @@ export default tseslint.config( "**/*.config.js", "**/*.config.ts", "packages/format/src/schemas/yamls.ts", + "packages/bugc/src/examples/generated.ts", "packages/web/.docusaurus/", "packages/web/build/", ], diff --git a/package.json b/package.json index 58c4647fb6..31e535ccd6 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "packages/*" ], "scripts": { - "build": "yarn --cwd packages/format prepare:yamls && tsc --build packages/format packages/pointers packages/evm packages/bugc packages/conformance packages/programs-react packages/pointers-react", + "build": "yarn --cwd packages/format prepare:yamls && yarn --cwd packages/bugc prepare:examples && tsc --build packages/format packages/pointers packages/evm packages/bugc packages/conformance packages/programs-react packages/pointers-react", "bundle": "tsx ./bin/bundle-schema.ts", "test": "vitest", "test:coverage": "vitest run --coverage", diff --git a/packages/bugc/.gitignore b/packages/bugc/.gitignore new file mode 100644 index 0000000000..df29bf777c --- /dev/null +++ b/packages/bugc/.gitignore @@ -0,0 +1,2 @@ +# Auto-generated from the canonical examples/*.bug files at build time. +src/examples/generated.ts diff --git a/packages/bugc/bin/generate-examples.js b/packages/bugc/bin/generate-examples.js new file mode 100644 index 0000000000..989a83e918 --- /dev/null +++ b/packages/bugc/bin/generate-examples.js @@ -0,0 +1,68 @@ +// Generates src/examples/generated.ts from the canonical `.bug` example +// files under packages/bugc/examples. Bundlers like webpack (used by the +// docs site) can't glob raw `.bug` files the way Vite can, so we surface +// the sources as an importable module of string literals — the same +// pattern as @ethdebug/format's generated schemas/yamls.ts. + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const examplesRoot = path.resolve(__dirname, "../examples"); + +// Walk examplesRoot recursively, collecting `.bug` sources keyed by their +// path relative to examplesRoot (POSIX separators, so keys are stable +// across platforms). +const readExamples = (directory) => { + const sources = {}; + const entries = fs.readdirSync(directory, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + Object.assign(sources, readExamples(fullPath)); + } else if (entry.isFile() && entry.name.endsWith(".bug")) { + const relativePath = path + .relative(examplesRoot, fullPath) + .split(path.sep) + .join("/"); + sources[relativePath] = fs.readFileSync(fullPath, "utf8"); + } + } + + return sources; +}; + +// Sort keys so the generated output is deterministic. +const collected = readExamples(examplesRoot); +const exampleSources = {}; +for (const key of Object.keys(collected).sort()) { + exampleSources[key] = collected[key]; +} + +const output = `// THIS FILE GETS AUTO-GENERATED AS PART OF THIS PACKAGE'S BUILD PROCESS +// Please do not modify it directly or allow it to get checked into source control. + +export type ExampleSourcesByPath = { + [path: string]: string; +}; + +export const exampleSources: ExampleSourcesByPath = ${JSON.stringify( + exampleSources, + undefined, + 2, +)}; +`; + +const outputDir = path.resolve(__dirname, "../src/examples"); +const outputPath = path.join(outputDir, "generated.ts"); +const tempPath = outputPath + ".tmp"; + +fs.mkdirSync(outputDir, { recursive: true }); + +// Write to a temp file, then rename atomically to avoid race conditions. +fs.writeFileSync(tempPath, output); +fs.renameSync(tempPath, outputPath); diff --git a/packages/bugc/package.json b/packages/bugc/package.json index 84723504b0..932b73d829 100644 --- a/packages/bugc/package.json +++ b/packages/bugc/package.json @@ -5,6 +5,16 @@ "type": "module", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + }, + "./examples": { + "types": "./dist/src/examples/index.d.ts", + "default": "./dist/src/examples/index.js" + } + }, "files": [ "dist", "bin" @@ -54,7 +64,8 @@ "#test/*": "./dist/test/*.js" }, "scripts": { - "build": "tsc", + "prepare:examples": "node ./bin/generate-examples.js", + "build": "yarn prepare:examples && tsc", "build:watch": "tsc --watch --preserveWatchOutput", "watch": "tsc --watch --preserveWatchOutput", "test": "vitest run", @@ -64,7 +75,7 @@ "typecheck": "tsc --noEmit", "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", - "prepare": "tsc" + "prepare": "yarn prepare:examples && tsc" }, "keywords": [ "ethereum", diff --git a/packages/bugc/src/examples/annotations.test.ts b/packages/bugc/src/examples/annotations.test.ts new file mode 100644 index 0000000000..5ca3e6c0dc --- /dev/null +++ b/packages/bugc/src/examples/annotations.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from "vitest"; + +import { stripTestAnnotations } from "./annotations.js"; + +describe("stripTestAnnotations", () => { + it("removes a /*@test*/ block sitting on its own lines", () => { + const source = [ + "create {", + " value = 1;", + " /*@test value-set", + " variables:", + " value:", + " value: 1", + " */", + "}", + "", + ].join("\n"); + + expect(stripTestAnnotations(source)).toBe( + ["create {", " value = 1;", "}", ""].join("\n"), + ); + }); + + it("removes a /**@test*/ JSDoc-style block", () => { + const source = [ + "create {", + " value = 1;", + " /**@test value-set", + " * variables:", + " * value:", + " * value: 1", + " */", + "}", + "", + ].join("\n"); + + expect(stripTestAnnotations(source)).toBe( + ["create {", " value = 1;", "}", ""].join("\n"), + ); + }); + + it("collapses the blank lines left around removed blocks", () => { + const source = [ + " lastSender = msg.sender;", + "", + " /*@test a", + " variables: { x: 1 }", + " */", + "", + " /*@test b", + " variables: { y: 2 }", + " */", + " return;", + "", + ].join("\n"); + + expect(stripTestAnnotations(source)).toBe( + [" lastSender = msg.sender;", "", " return;", ""].join("\n"), + ); + }); + + it("strips // @wip, @skip, and @expect-* directive lines", () => { + const source = [ + "// @wip", + "// @skip needs work", + "// @expect-parse-error", + "// @expect-bytecode-error", + "name Thing;", + "", + ].join("\n"); + + expect(stripTestAnnotations(source)).toBe(["name Thing;", ""].join("\n")); + }); + + it("leaves ordinary // comments and code untouched", () => { + const source = [ + "code {", + " // a normal comment", + " x = 1; // trailing comment", + "}", + "", + ].join("\n"); + + expect(stripTestAnnotations(source)).toBe(source); + }); + + it("does not touch block comments that are not @test", () => { + const source = ["/* just a comment */", "name Thing;", ""].join("\n"); + + expect(stripTestAnnotations(source)).toBe(source); + }); + + it("normalizes to a single trailing newline and no leading blanks", () => { + const source = ["", "", "name Thing;", "", "", ""].join("\n"); + + expect(stripTestAnnotations(source)).toBe(["name Thing;", ""].join("\n")); + }); + + it("removes an inline @test block but keeps the code before it", () => { + const source = [" x = 1; /*@test t\n variables: {}\n */", ""].join( + "\n", + ); + + expect(stripTestAnnotations(source)).toBe([" x = 1;", ""].join("\n")); + }); +}); diff --git a/packages/bugc/src/examples/annotations.ts b/packages/bugc/src/examples/annotations.ts new file mode 100644 index 0000000000..fedf4a36cf --- /dev/null +++ b/packages/bugc/src/examples/annotations.ts @@ -0,0 +1,38 @@ +/** + * The canonical `.bug` example files double as bugc's behavioral test + * fixtures, so they carry inline test metadata: `/*@test … *\/` YAML + * blocks and `// @wip` / `// @skip` / `// @expect-*` directive lines. + * That metadata is noise when the same source is shown in an editor. + * + * `stripTestAnnotations` removes it, leaving clean, display-ready BUG + * source. The canonical files stay the single source of truth; consumers + * that render examples (the playground, the docs widget) strip on the way + * out. + */ + +// Matches a `/*@test … *\/` or `/**@test … *\/` block, together with any +// indentation on the line it opens and the trailing newline — so a block +// alone on its line disappears without leaving a blank behind. +const TEST_BLOCK = /[ \t]*\/\*\*?@test\b[\s\S]*?\*\/[ \t]*\n?/g; + +// Matches a whole-line `// @wip` / `// @skip …` / `// @expect-*` directive. +const DIRECTIVE_LINE = + /^[ \t]*\/\/[ \t]*@(?:wip|skip|expect-[a-z-]+)\b.*(?:\n|$)/gm; + +/** + * Strip bugc test annotations from BUG source, yielding display-ready text. + */ +export function stripTestAnnotations(source: string): string { + const stripped = source + .replace(TEST_BLOCK, "") + .replace(DIRECTIVE_LINE, "") + // Trim whitespace an inline removal may have left at a line's end. + .replace(/[ \t]+$/gm, "") + // Collapse the blank runs that removals leave behind to one blank line. + .replace(/\n{3,}/g, "\n\n") + // No leading blank lines; end with exactly one trailing newline. + .replace(/^\n+/, "") + .replace(/\s+$/, ""); + + return stripped === "" ? "" : stripped + "\n"; +} diff --git a/packages/bugc/src/examples/index.test.ts b/packages/bugc/src/examples/index.test.ts new file mode 100644 index 0000000000..8c1bc00a00 --- /dev/null +++ b/packages/bugc/src/examples/index.test.ts @@ -0,0 +1,79 @@ +/** + * These tests guard the example subpath export: that the generated source + * map is populated, and — crucially — that stripping the test annotations + * is purely cosmetic. Because the strip only removes comments, a stripped + * example must compile to byte-identical bytecode as its raw counterpart; + * if it doesn't, the strip has eaten real code. + */ +import { describe, it, expect } from "vitest"; + +import { compile } from "#compiler"; +import { exampleSources, examplePaths, stripTestAnnotations } from "./index.js"; + +// A representative set of examples that compile cleanly. Kept small so the +// test stays fast; the point is to exercise strip/compile equivalence, not +// to re-test the whole example corpus (test/examples covers that). +const COMPILABLE = [ + "basic/minimal.bug", + "basic/functions.bug", + "basic/conditionals.bug", + "basic/array-length.bug", + "intermediate/arrays.bug", + "intermediate/mappings.bug", +]; + +const hex = (bytes?: Uint8Array): string => + bytes ? Buffer.from(bytes).toString("hex") : ""; + +describe("example sources", () => { + it("surfaces the canonical .bug files keyed by relative path", () => { + expect(examplePaths.length).toBeGreaterThan(0); + expect(examplePaths).toEqual(Object.keys(exampleSources)); + expect(exampleSources["basic/minimal.bug"]).toContain("name Minimal;"); + }); + + it("keeps the raw sources' test annotations intact", () => { + // The raw map is the test fixtures verbatim — annotations included. + const anyHasTestBlock = examplePaths.some((p) => + exampleSources[p].includes("@test"), + ); + expect(anyHasTestBlock).toBe(true); + }); +}); + +describe("stripTestAnnotations on real examples", () => { + for (const path of COMPILABLE) { + it(`yields annotation-free source for ${path}`, () => { + const clean = stripTestAnnotations(exampleSources[path]); + expect(clean).not.toContain("@test"); + expect(clean).not.toMatch(/\/\/\s*@(?:wip|skip|expect-)/); + }); + + it(`compiles ${path} to the same bytecode raw and stripped`, async () => { + const raw = exampleSources[path]; + const clean = stripTestAnnotations(raw); + + const rawResult = await compile({ + to: "bytecode", + source: raw, + optimizer: { level: 0 }, + }); + const cleanResult = await compile({ + to: "bytecode", + source: clean, + optimizer: { level: 0 }, + }); + + expect(rawResult.success).toBe(true); + expect(cleanResult.success).toBe(true); + if (!rawResult.success || !cleanResult.success) return; + + expect(hex(cleanResult.value.bytecode.runtime)).toBe( + hex(rawResult.value.bytecode.runtime), + ); + expect(hex(cleanResult.value.bytecode.create)).toBe( + hex(rawResult.value.bytecode.create), + ); + }); + } +}); diff --git a/packages/bugc/src/examples/index.ts b/packages/bugc/src/examples/index.ts new file mode 100644 index 0000000000..1597c7ee7c --- /dev/null +++ b/packages/bugc/src/examples/index.ts @@ -0,0 +1,30 @@ +/** + * Canonical BUG example sources, surfaced for editors and playgrounds. + * + * The `.bug` files under `packages/bugc/examples` are the single source of + * truth: bugc's behavioral tests read them from disk, and this module + * exposes the same sources as an importable string map so bundlers (webpack, + * Vite) can ship them without globbing the filesystem. + * + * The raw sources carry bugc's test annotations (`/*@test … *\/` blocks and + * `// @wip` / `// @skip` / `// @expect-*` directives). Call + * {@link stripTestAnnotations} to get display-ready source for an editor. + * Which examples to show, and how to label them, is left to each consumer. + */ + +import { exampleSources } from "./generated.js"; + +export { exampleSources } from "./generated.js"; +export type { ExampleSourcesByPath } from "./generated.js"; +export { stripTestAnnotations } from "./annotations.js"; + +/** Relative paths of every canonical example (e.g. `"basic/minimal.bug"`). */ +export const examplePaths: string[] = Object.keys(exampleSources); + +/** A single BUG example: its canonical path and raw source. */ +export interface BugExample { + /** Path relative to `packages/bugc/examples` (e.g. `"basic/minimal.bug"`). */ + path: string; + /** Raw source, including bugc's inline test annotations. */ + source: string; +} From b018789bef84a09490cc9944a40fe8d58630c85f Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Sun, 12 Jul 2026 17:14:19 -0400 Subject: [PATCH 03/30] web/playground: source BUG examples from @ethdebug/bugc/examples (#250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both playgrounds showed BUG programs that were maintained separately from the canonical `.bug` files under packages/bugc/examples: the docs BugPlayground had no example picker at all, and the standalone playground reached across packages with a Vite `import.meta.glob(... ?raw)` that only works under Vite. Neither stayed in step with the sources bugc actually tests. Both now import the shared `@ethdebug/bugc/examples` map, which ships the canonical sources as inlined string literals so webpack (docs) and Vite (playground) consume them without a filesystem glob. Each consumer keeps a small curated list — which examples to show, their labels, and (for the playground) their category — and applies `stripTestAnnotations` so the editor shows clean source without bugc's inline `@test`/`@expect` directives. - docs BugPlayground: add an example selector (curated trio: an owned counter, functions, arrays & loops), shown when the embed hasn't pinned a specific program via `initialCode`. - standalone playground: drop `import.meta.glob`; keep the same curated, categorized set, now keyed by canonical example path. - unit coverage that the curated sets resolve to real sources, strip clean, and compile to bytecode. --- packages/bugc-react/src/examples.test.ts | 59 ++++++++++++ packages/bugc-react/src/examples.ts | 52 +++++++++++ packages/bugc-react/src/index.ts | 3 + packages/bugc-react/vitest.config.ts | 13 +++ .../playground/src/playground/examples.ts | 68 ++++++++------ .../src/theme/BugcExample/BugPlayground.css | 6 +- .../src/theme/BugcExample/BugPlayground.tsx | 90 +++++++++++++------ 7 files changed, 233 insertions(+), 58 deletions(-) create mode 100644 packages/bugc-react/src/examples.test.ts create mode 100644 packages/bugc-react/src/examples.ts create mode 100644 packages/bugc-react/vitest.config.ts diff --git a/packages/bugc-react/src/examples.test.ts b/packages/bugc-react/src/examples.test.ts new file mode 100644 index 0000000000..bda50ae694 --- /dev/null +++ b/packages/bugc-react/src/examples.test.ts @@ -0,0 +1,59 @@ +/** + * The curated example set is sourced from `@ethdebug/bugc/examples` + * (the canonical `.bug` files), sliced down and stripped for display. + * These guards ensure the selection stays valid and editor-clean: the + * strings ship verbatim into the docs playground editor. + */ +import { describe, it, expect } from "vitest"; +import { compile } from "@ethdebug/bugc"; +import { exampleSources } from "@ethdebug/bugc/examples"; +import { bugExamples } from "./examples.js"; + +describe("bugExamples", () => { + it("is the curated trio (counter, functions, arrays)", () => { + expect(bugExamples.map((e) => e.name)).toEqual([ + "counter", + "functions", + "arrays", + ]); + }); + + it("gives every example a display name and non-empty source", () => { + for (const ex of bugExamples) { + expect(ex.displayName.trim().length).toBeGreaterThan(0); + expect(ex.code.trim().length).toBeGreaterThan(0); + } + }); + + it("draws its sources from the canonical bugc examples", () => { + // Sanity: the raw sources the curation selects really exist + // upstream, so the selection can't silently drift to empty. + expect( + Object.prototype.hasOwnProperty.call( + exampleSources, + "intermediate/owner-counter.bug", + ), + ).toBe(true); + expect(bugExamples.length).toBeGreaterThan(0); + }); + + it("strips bugc's inline test annotations for display", () => { + for (const ex of bugExamples) { + expect(ex.code).not.toContain("@test"); + expect(ex.code).not.toContain("@expect"); + } + }); + + // Each curated source must compile cleanly to bytecode — this is + // the guard that matters, since these ship straight to the editor. + for (const ex of bugExamples) { + it(`compiles ${ex.name} to bytecode without errors`, async () => { + const result = await compile({ + to: "bytecode", + source: ex.code, + optimizer: { level: 0 }, + }); + expect(result.success).toBe(true); + }); + } +}); diff --git a/packages/bugc-react/src/examples.ts b/packages/bugc-react/src/examples.ts new file mode 100644 index 0000000000..80dc840fe9 --- /dev/null +++ b/packages/bugc-react/src/examples.ts @@ -0,0 +1,52 @@ +/** + * Curated BUG examples for the docs playground's example selector. + * + * The sources are the canonical `.bug` files shipped by + * `@ethdebug/bugc/examples` — the same files bugc's behavioral tests + * compile — so the playground never drifts from a hand-maintained copy. + * We select a small subset, label it for display, and strip bugc's + * inline test annotations so the editor shows clean source. + */ + +import { exampleSources, stripTestAnnotations } from "@ethdebug/bugc/examples"; + +/** A named, display-labelled BUG source for the example selector. */ +export interface BugExample { + /** Stable identifier (used as the