diff --git a/.prettierignore b/.prettierignore index b0eaf397c7..e8f1a65137 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,6 +8,7 @@ package-lock.json # Auto-generated files packages/format/src/schemas/yamls.ts +packages/format/src/version.ts packages/bugc/src/examples/generated.ts # Solidity fixtures are compiler inputs; this repo has no Solidity Prettier parser. diff --git a/CHANGELOG.md b/CHANGELOG.md index fca9b994eb..572f8f8b76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,29 @@ The sections do not signal obligations; the prefixes do. ## Unreleased +### Added + +- An `ethdebug` field on **ethdebug/format/info**, + **ethdebug/format/info/resources** and **ethdebug/format/program** names + the schema the object conforms to and the specification version that + defines it, through the new **ethdebug/format/identification** schema. + The field is optional now and becomes required at `0.1.0`. An object + without it predates the field ([#305]). + - Schemas: **ethdebug/format/identification**, **ethdebug/format/info**, + **ethdebug/format/info/resources**, **ethdebug/format/program** + - Producers: optional: emit `ethdebug: { schema, version }` with the + `@ethdebug/format` version the producer was built against; a program + inside a container may omit it, and when both carry it the versions + must be equal. + - Consumers: optional: read the field to learn which changelog entries + apply; reject an object only when its compatibility key (the major + version, or `major.minor` while the major is 0) differs from the + supported one, and warn when the version is newer. + **ethdebug/format/program** and **ethdebug/format/info** are closed + objects (`unevaluatedProperties: false`), so a consumer that + validates against the previous release's schemas rejects an + identified object until it updates its schemas. + ## 0.1.0-draft.0 — 2026-09-21 The version scheme changed: prerelease versions of the specification are now @@ -616,3 +639,4 @@ First published version of the specification. [#285]: https://github.com/ethdebug/format/pull/285 [#286]: https://github.com/ethdebug/format/pull/286 [#303]: https://github.com/ethdebug/format/pull/303 +[#305]: https://github.com/ethdebug/format/pull/305 diff --git a/RELEASING.md b/RELEASING.md index 5c72d5f445..668a40eeb3 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -34,6 +34,10 @@ guards that run in CI live in `bin/check-tarballs.ts` and workspace that depends on a moving one, directly or transitively. Every workspace depends on `@ethdebug/format`, so a specification change moves all ten. +- When `@ethdebug/format` moves, the `Publish` commit also rewrites + the specification version in the schema examples, so `schemas/` may + appear in that commit beside the manifests; the dry run prints the + count of version literals it rewrites. - Series convention, for now: all workspaces start a `major.minor` series together and graduate together with the spec; between those events each workspace moves only when it or a dependency changed, @@ -130,6 +134,21 @@ guards that run in CI live in `bin/check-tarballs.ts` and yarn tsx bin/version.ts [keyword] [--all] ``` + When `@ethdebug/format` moves, the `Publish` commit also rewrites + the specification version in the schema examples, so `schemas/` may + appear in that commit beside the manifests; the dry run prints the + count of version literals it rewrites. + + A `found N version literals, expected M` plan problem means a + schema example gained or lost an `ethdebug` block without its + `version:` line, or lost the line while keeping the block; edit + the example and re-run. + + After the bump, run `yarn build` before running + `yarn test packages/format` again: the generated + `src/version.ts` still names the old version until the build + regenerates it. CI does this step in the publish workflow. + The script never pushes. If it fails after it started writing, it prints the undo commands for the stage it reached. The dry run of step 3 reports the same guards and findings as this run, but it diff --git a/bin/version.test.ts b/bin/version.test.ts index e915f1d386..ad81f9950d 100644 --- a/bin/version.test.ts +++ b/bin/version.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { changelogProblems, + expectedVersionSites, forcedNames, hasReleaseSection, hasUnreleasedEntries, @@ -15,6 +16,7 @@ import { planProblems, requiredChangelogs, rewriteManifest, + rewriteSchemaVersions, undoAdvice, } from "./version.js"; @@ -608,7 +610,7 @@ describe("undoAdvice", () => { it("restores the manifests when nothing was committed or tagged", () => { expect(undoAdvice([], false)).toBe( - "undo: git checkout HEAD -- packages/*/package.json", + "undo: git checkout HEAD -- packages/*/package.json schemas/", ); }); }); @@ -647,3 +649,104 @@ describe("a plan of first releases only", () => { expect(rewriteManifest(text, versions)).toBe(text); }); }); + +describe("rewriteSchemaVersions", () => { + const text = [ + "examples:", + " - ethdebug:", + ' schema: "schema:ethdebug/format/program"', + ' version: "0.1.0-draft.0"', + " compilation:", + " compiler:", + " version: 0.2.3+commit.8b37fa7a", + ' # version: "0.1.0-draft.0" in a comment stays', + "", + ].join("\n"); + + it("rewrites the quoted literal and leaves compiler versions alone", () => { + const result = rewriteSchemaVersions( + text, + "0.1.0-draft.0", + "0.1.0-draft.1", + ); + expect(result.count).toBe(1); + expect(result.text).toContain('version: "0.1.0-draft.1"'); + expect(result.text).toContain("version: 0.2.3+commit.8b37fa7a"); + expect(result.text).toContain('# version: "0.1.0-draft.0" in a comment'); + }); + + it("accepts single quotes and no quotes, keeping the style", () => { + expect( + rewriteSchemaVersions( + "version: '0.1.0-draft.0'\n", + "0.1.0-draft.0", + "0.2.0", + ).text, + ).toBe("version: '0.2.0'\n"); + expect( + rewriteSchemaVersions( + " version: 0.1.0-draft.0\n", + "0.1.0-draft.0", + "0.2.0", + ).text, + ).toBe(" version: 0.2.0\n"); + }); + + it("does not match a prefix of a longer version", () => { + expect( + rewriteSchemaVersions('version: "0.1.0-draft.10"\n', "0.1.0-draft.1", "x") + .count, + ).toBe(0); + }); + + it("keeps a trailing comment on the line it rewrites", () => { + const result = rewriteSchemaVersions( + ' version: "0.1.0-draft.0" # the spec version\n', + "0.1.0-draft.0", + "0.1.0-draft.1", + ); + expect(result.count).toBe(1); + expect(result.text).toBe( + ' version: "0.1.0-draft.1" # the spec version\n', + ); + }); + + it("leaves the version mentioned in prose alone", () => { + const prose = " description: Written by 0.1.0-draft.0 producers.\n"; + expect(rewriteSchemaVersions(prose, "0.1.0-draft.0", "0.2.0")).toEqual({ + text: prose, + count: 0, + }); + }); +}); + +describe("expectedVersionSites", () => { + it("counts ethdebug blocks and the identification example", () => { + const withBlocks = + 'examples:\n - ethdebug:\n schema: x\n version: "1"\n' + + ' - foo:\n ethdebug:\n version: "1"\n'; + expect(expectedVersionSites(withBlocks)).toBe(2); + const identification = + '$id: "schema:ethdebug/format/identification"\n' + + 'examples:\n - schema: x\n version: "1"\n'; + expect(expectedVersionSites(identification)).toBe(1); + }); + + // the property that declares the field is not an example of it + it("ignores the ethdebug property of a root schema", () => { + const root = + '$id: "schema:ethdebug/format/program"\n' + + "properties:\n ethdebug:\n allOf:\n - $ref: x\n" + + 'examples:\n - ethdebug:\n version: "1"\n'; + expect(expectedVersionSites(root)).toBe(1); + }); + + // what the count check compares: a literal left behind falls short + it("exceeds the count when an example keeps the old version", () => { + const stale = + 'examples:\n - ethdebug:\n version: "0.1.0-draft.0"\n' + + ' - ethdebug:\n version: "0.1.0-draft.0"\n'; + const { count } = rewriteSchemaVersions(stale, "0.1.0-draft.1", "x"); + expect(count).toBeLessThan(expectedVersionSites(stale)); + }); +}); diff --git a/bin/version.ts b/bin/version.ts index 3a3e0607b3..899d3a7d43 100644 --- a/bin/version.ts +++ b/bin/version.ts @@ -409,6 +409,44 @@ export function rewriteManifest( return `${JSON.stringify(json, null, 2)}\n`; } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +// the version literal a schema example carries, whatever its quoting; +// a comment or a longer version is not a match +export function rewriteSchemaVersions( + text: string, + oldVersion: string, + newVersion: string, +): { text: string; count: number } { + const pattern = new RegExp( + `^(\\s*version:\\s*)(["']?)${escapeRegExp(oldVersion)}\\2(\\s*(?:#.*)?)$`, + "gm", + ); + let count = 0; + const rewritten = text.replace(pattern, (_, head, quote, tail) => { + count += 1; + return `${head}${quote}${newVersion}${quote}${tail}`; + }); + return { text: rewritten, count }; +} + +// how many version literals a schema file is expected to carry: one per +// `ethdebug:` block under its top-level `examples:`, plus one for the +// identification schema, whose own example carries the literal directly. +// The `ethdebug:` property that declares the field sits above +// `examples:` and does not count +export function expectedVersionSites(text: string): number { + const examples = text.search(/^examples:[ \t\r]*$/m); + const region = examples === -1 ? "" : text.slice(examples); + const blocks = region.match(/^\s*(?:- )?ethdebug:\s*$/gm)?.length ?? 0; + const own = /^\$id: "schema:ethdebug\/format\/identification"$/m.test(text) + ? 1 + : 0; + return blocks + own; +} + function git(root: string, args: string[]): string { return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); } @@ -603,6 +641,53 @@ function writeManifests( return written; } +// the schema files whose examples name the specification version, each +// with the text it gets when @ethdebug/format moves. `count` is what +// the rewrite found and `expected` what the files ask for; the two must +// agree, or an example no longer carries the version the release names +interface SchemaRewrites { + files: { path: string; text: string }[]; + count: number; + expected: number; +} + +function schemaPaths(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + return schemaPaths(path); + } + return entry.name.endsWith(".schema.yaml") ? [path] : []; + }); +} + +function planSchemaRewrites( + root: string, + from: string, + to: string, +): SchemaRewrites { + const files: { path: string; text: string }[] = []; + let count = 0; + let expected = 0; + for (const path of schemaPaths(join(root, "schemas"))) { + const before = readFileSync(path, "utf8"); + const rewritten = rewriteSchemaVersions(before, from, to); + count += rewritten.count; + expected += expectedVersionSites(before); + if (rewritten.text !== before) { + files.push({ path: relative(root, path), text: rewritten.text }); + } + } + return { files, count, expected }; +} + +function writeSchemas(root: string, rewrites: SchemaRewrites): string[] { + for (const { path, text } of rewrites.files) { + writeFileSync(join(root, path), text); + } + return rewrites.files.map(({ path }) => path); +} + // appends every tag it creates to `created`, so a failure partway // leaves the caller with the exact list to undo function commitAndTag( @@ -636,7 +721,7 @@ export function undoAdvice(created: string[], committed: boolean): string { if (tags.length > 0) { return `undo: ${tags}`; } - return "undo: git checkout HEAD -- packages/*/package.json"; + return "undo: git checkout HEAD -- packages/*/package.json schemas/"; } function report(plan: Move[]): void { @@ -681,6 +766,19 @@ export function main(argv: string[]): number { all, }); const problems = planProblems(plan, manifests, keyword); + // the schemas ship inside @ethdebug/format, so their examples name + // the version it moves to; when it stays put they are left alone + const specMove = plan.find((move) => move.name === specPackage); + const schemas = + specMove === undefined + ? undefined + : planSchemaRewrites(root, specMove.from, specMove.to); + if (schemas !== undefined && schemas.count !== schemas.expected) { + problems.push( + `schemas/: found ${schemas.count} version literals, ` + + `expected ${schemas.expected}`, + ); + } if (problems.length > 0) { for (const problem of problems) { console.error(problem); @@ -700,6 +798,10 @@ export function main(argv: string[]): number { } console.log(`${keyword}: ${plan.length} workspace(s) move`); report(plan); + if (specMove !== undefined && schemas !== undefined) { + const literals = `${schemas.count} version literals`; + console.log(` schemas: ${literals} -> ${specMove.to}`); + } const changelogs = changelogProblems( requiredChangelogs(plan, manifests, root).map(({ path, version }) => ({ @@ -729,9 +831,15 @@ export function main(argv: string[]): number { // every release const headBefore = git(root, ["rev-parse", "HEAD"]); let written: string[] = []; + let schemaCount = 0; const created: string[] = []; try { written = writeManifests(root, manifests, plan); + if (schemas !== undefined) { + const files = writeSchemas(root, schemas); + schemaCount = files.length; + written.push(...files); + } commitAndTag(root, written, plan, created); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -742,7 +850,12 @@ export function main(argv: string[]): number { } console.log(`tagged: ${created.join(", ")}`); if (written.length > 0) { - console.log(`committed Publish with ${written.length} manifest(s)`); + const schemaNote = + schemaCount > 0 ? ` and ${schemaCount} schema file(s)` : ""; + console.log( + `committed Publish with ${written.length - schemaCount} ` + + `manifest(s)${schemaNote}`, + ); console.log("next: git push --atomic origin main --follow-tags"); return 0; } diff --git a/eslint.config.js b/eslint.config.js index f375f13f1b..8e4aa8874b 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/format/src/version.ts", "packages/bugc/src/examples/generated.ts", "packages/web/.docusaurus/", "packages/web/build/", diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index 01d47d0d6e..1fc43992ea 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -7,6 +7,12 @@ support. Changes to the specification itself are tracked in the root ## Unreleased +### Changed + +- Every program `bugc` emits now carries an `ethdebug` field naming + **ethdebug/format/program** and the `@ethdebug/format` version `bugc` + was built against ([#305]). + ## 0.1.0-preview.0 — 2026-09-21 The version scheme changed: prerelease versions are now `preview.`, and @@ -35,3 +41,4 @@ First publication. [#286]: https://github.com/ethdebug/format/pull/286 [#298]: https://github.com/ethdebug/format/pull/298 [#300]: https://github.com/ethdebug/format/pull/300 +[#305]: https://github.com/ethdebug/format/pull/305 diff --git a/packages/bugc/src/evmgen/program-builder.test.ts b/packages/bugc/src/evmgen/program-builder.test.ts new file mode 100644 index 0000000000..f252c4b342 --- /dev/null +++ b/packages/bugc/src/evmgen/program-builder.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; + +import * as Format from "@ethdebug/format"; +import * as Ir from "#ir"; + +import { buildProgram } from "./program-builder.js"; + +describe("buildProgram", () => { + it("identifies the program with the specification version", () => { + const module: Ir.Module = { + name: "Test", + sourceId: "test", + functions: new Map(), + main: { + name: "main", + parameters: [], + entry: "entry", + blocks: new Map([ + [ + "entry", + { + id: "entry", + phis: [], + instructions: [], + terminator: { kind: "return", operationDebug: {} }, + predecessors: new Set(), + debug: {}, + } as Ir.Block, + ], + ]), + }, + }; + + const program = buildProgram([], "call", module); + + expect(program.ethdebug).toEqual({ + schema: "schema:ethdebug/format/program", + version: Format.version, + }); + }); +}); diff --git a/packages/bugc/src/evmgen/program-builder.ts b/packages/bugc/src/evmgen/program-builder.ts index cf33e20851..a9abd698d8 100644 --- a/packages/bugc/src/evmgen/program-builder.ts +++ b/packages/bugc/src/evmgen/program-builder.ts @@ -2,7 +2,7 @@ * Build Format.Program objects from EVM generation output */ -import type * as Format from "@ethdebug/format"; +import * as Format from "@ethdebug/format"; import type * as Evm from "#evm"; import type * as Ir from "#ir"; @@ -82,6 +82,7 @@ export function buildProgram( }; const program: Format.Program = { + ethdebug: Format.identify("schema:ethdebug/format/program"), contract, environment, instructions: formatInstructions, diff --git a/packages/conformance/src/adapters/bugc.ts b/packages/conformance/src/adapters/bugc.ts index d28b511f4e..b0c412a783 100644 --- a/packages/conformance/src/adapters/bugc.ts +++ b/packages/conformance/src/adapters/bugc.ts @@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import type { Materials } from "@ethdebug/format"; +import { identify } from "@ethdebug/format"; import { VERSION, compile } from "@ethdebug/bugc"; import type { BugcCompileOptions, EthdebugArtifact } from "../types.js"; @@ -113,6 +114,7 @@ export async function compileBugc( programs, compilation, resources: { + ethdebug: identify("schema:ethdebug/format/info/resources"), compilation, types: {}, pointers: {}, diff --git a/packages/conformance/src/runner.ts b/packages/conformance/src/runner.ts index 64d5bfcaf4..d3d34686f0 100644 --- a/packages/conformance/src/runner.ts +++ b/packages/conformance/src/runner.ts @@ -149,6 +149,22 @@ export async function validateStaticConformance( ); } + const resourcesVersion = artifact.resources?.ethdebug?.version; + if (resourcesVersion !== undefined) { + artifact.programs.forEach((program, index) => { + const programVersion = program.program.ethdebug?.version; + if (programVersion !== undefined && programVersion !== resourcesVersion) { + issues.push( + issue( + `programs[${index}].ethdebug.version`, + `${program.name} names ethdebug/format ${programVersion} but ` + + `resources names ${resourcesVersion}`, + ), + ); + } + }); + } + if (artifact.compilation && !Materials.isCompilation(artifact.compilation)) { issues.push( issue("compilation", "compilation is not valid materials/compilation"), diff --git a/packages/conformance/src/types.ts b/packages/conformance/src/types.ts index 14910bb65a..7a19ded09e 100644 --- a/packages/conformance/src/types.ts +++ b/packages/conformance/src/types.ts @@ -1,4 +1,4 @@ -import type { Materials, Program } from "@ethdebug/format"; +import type { Identification, Materials, Program } from "@ethdebug/format"; export type CompilerKind = "bugc" | "solc"; @@ -20,6 +20,7 @@ export interface EthdebugArtifact { programs: EthdebugProgramArtifact[]; compilation?: Materials.Compilation; resources?: { + ethdebug?: Identification; compilation: Materials.Compilation; types: Record; pointers: Record; diff --git a/packages/conformance/test/conformance.test.ts b/packages/conformance/test/conformance.test.ts index 1150cf394e..657104d58c 100644 --- a/packages/conformance/test/conformance.test.ts +++ b/packages/conformance/test/conformance.test.ts @@ -119,6 +119,20 @@ function validResources() { }; } +function programWithVersion(version: string) { + return { + ...validProgram(), + ethdebug: { schema: "schema:ethdebug/format/program", version }, + }; +} + +function resourcesWithVersion(version: string) { + return { + ...validResources(), + ethdebug: { schema: "schema:ethdebug/format/info/resources", version }, + }; +} + function validArtifact( overrides: Partial = {}, ): EthdebugArtifact { @@ -380,6 +394,53 @@ describe("@ethdebug/conformance", () => { ); }); + it("rejects a program naming a different specification version than resources", async () => { + const artifact = validArtifact({ + compilation: undefined, + programs: [ + { + name: "Counter:runtime", + program: programWithVersion("0.1.0-draft.0") as any, + }, + ], + resources: resourcesWithVersion("0.1.0-draft.1") as any, + }); + + const result = await validateStaticConformance(artifact); + + expect(result.ok).toBe(false); + expect( + result.issues.some( + (issue) => issue.path === "programs[0].ethdebug.version", + ), + ).toBe(true); + }); + + it("accepts a program and resources naming the same specification version", async () => { + const artifact = validArtifact({ + compilation: undefined, + programs: [ + { + name: "Counter:runtime", + program: programWithVersion("0.1.0-draft.0") as any, + }, + ], + resources: resourcesWithVersion("0.1.0-draft.0") as any, + }); + + const result = await validateStaticConformance(artifact); + + expect(result.issues).toEqual([]); + expect(result.ok).toBe(true); + }); + + it("accepts a program or resources with no specification version named", async () => { + const result = await validateStaticConformance(validArtifact()); + + expect(result.issues).toEqual([]); + expect(result.ok).toBe(true); + }); + it("materializes non-empty resources into SolDB debug directories", async () => { const debugDir = await writeSoldbDebugDir( validArtifact({ diff --git a/packages/format/.gitignore b/packages/format/.gitignore index 596a165405..4a20c9168f 100644 --- a/packages/format/.gitignore +++ b/packages/format/.gitignore @@ -1,2 +1,3 @@ dist src/schemas/yamls.ts +src/version.ts diff --git a/packages/format/CHANGELOG.md b/packages/format/CHANGELOG.md index 04f0823b3f..2115977401 100644 --- a/packages/format/CHANGELOG.md +++ b/packages/format/CHANGELOG.md @@ -6,6 +6,17 @@ tracked in the root [`CHANGELOG.md`](../../CHANGELOG.md). ## Unreleased +### Added + +- `version`, the package's own version string, generated at build time from + `package.json` ([#305]). +- `Identification` and `isIdentification`, the type and guard for the new + **ethdebug/format/identification** schema; `identify(schema)` builds one + from `version`, and `supports(version, supported)` judges a version + against the one a consumer supports, returning `"ok"`, `"newer"` or + `"unsupported"` ([#305]). +- `Program.ethdebug`, an optional `Identification` ([#305]). + ## 0.1.0-draft.0 — 2026-09-21 The version scheme changed: prerelease versions are now `draft.`, matching @@ -116,6 +127,7 @@ First publication. [#293]: https://github.com/ethdebug/format/pull/293 [#300]: https://github.com/ethdebug/format/pull/300 [#303]: https://github.com/ethdebug/format/pull/303 +[#305]: https://github.com/ethdebug/format/pull/305 [`0ef2f37`]: https://github.com/ethdebug/format/commit/0ef2f37 [`10ab103`]: https://github.com/ethdebug/format/commit/10ab103 [`21e532e`]: https://github.com/ethdebug/format/commit/21e532e diff --git a/packages/format/bin/generate-schema-yamls.js b/packages/format/bin/generate-schema-yamls.js index 181b41a859..2320d832fb 100644 --- a/packages/format/bin/generate-schema-yamls.js +++ b/packages/format/bin/generate-schema-yamls.js @@ -61,3 +61,13 @@ const tempPath = outputPath + ".tmp"; // Write to temp file, then rename atomically to avoid race conditions fs.writeFileSync(tempPath, output); fs.renameSync(tempPath, outputPath); + +const packageJson = JSON.parse( + fs.readFileSync(path.resolve(__dirname, "../package.json"), "utf8"), +); +const versionOutput = `// 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 const version: string = ${JSON.stringify(packageJson.version)}; +`; +fs.writeFileSync(path.resolve(__dirname, "../src/version.ts"), versionOutput); diff --git a/packages/format/package.json b/packages/format/package.json index 9188db2b8c..843ac394f2 100644 --- a/packages/format/package.json +++ b/packages/format/package.json @@ -35,6 +35,10 @@ "types": "./src/types/data/index.ts", "default": "./dist/src/types/data/index.js" }, + "#types/identification": { + "types": "./src/types/identification.ts", + "default": "./dist/src/types/identification.js" + }, "#types/materials": { "types": "./src/types/materials/index.ts", "default": "./dist/src/types/materials/index.js" @@ -51,13 +55,17 @@ "types": "./src/types/type/index.ts", "default": "./dist/src/types/type/index.js" }, + "#version": { + "types": "./src/version.ts", + "default": "./dist/src/version.js" + }, "#test/*": "./test/*.ts" }, "scripts": { "prepare:yamls": "node ./bin/generate-schema-yamls.js", "build": "yarn prepare:yamls && rm -rf dist && tsc --build tsconfig.build.json", "prepare": "yarn build", - "clean": "rm -rf dist && rm src/schemas/yamls.ts", + "clean": "rm -rf dist && rm -f src/schemas/yamls.ts src/version.ts", "typecheck": "tsc -p tsconfig.typecheck.json", "test": "vitest", "watch:typescript": "tsc --watch", @@ -66,11 +74,13 @@ }, "dependencies": { "json-schema-typed": "8.0.1", + "semver": "^7.7.3", "yaml": "^2.8.2" }, "devDependencies": { "@hyperjump/browser": "^1.3.1", "@hyperjump/json-schema": "^1.17.3", + "@types/semver": "^7.7.0", "chalk": "^4.1.0", "cli-highlight": "^2.1.11", "concurrently": "^8.2.2", diff --git a/packages/format/src/index.ts b/packages/format/src/index.ts index e39b6f9ba1..f16907fda8 100644 --- a/packages/format/src/index.ts +++ b/packages/format/src/index.ts @@ -2,3 +2,5 @@ export * from "#describe"; export { schemas, schemaIds, type Schema } from "#schemas"; export * from "#types"; + +export { version } from "#version"; diff --git a/packages/format/src/schemas/identification.test.ts b/packages/format/src/schemas/identification.test.ts new file mode 100644 index 0000000000..58ec84e4e6 --- /dev/null +++ b/packages/format/src/schemas/identification.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import "#test/hyperjump"; + +const program = { + contract: { + name: "A", + definition: { source: { id: 0 }, range: { offset: 0, length: 1 } }, + }, + environment: "call", + instructions: [{ offset: 0 }], +}; +const id = (schema: string, version = "0.1.0-draft.0") => ({ + schema, + version, +}); + +// Copied from schemas/info.schema.yaml's own example, so that this +// object satisfies schemas/materials/compilation.schema.yaml (id, +// compiler.name, compiler.version, sources). +const compilation = { + id: "__301f3b6d85831638", + compiler: { + name: "egc", + version: "0.2.3+commit.8b37fa7a", + }, + settings: { + turbo: true, + }, + sources: [ + { + id: 1, + path: "Escrow.eg", + language: "examplelang", + contents: `import { Asset } from std::asset::fungible; + +type State = !slots[ + ready: bool, + complete: bool, + + beneficiary: address, + + asset: Asset, + amount: uint256, + + canRemit: () -> bool, +] + +@create +func setup( + beneficiary: address, + asset: Asset, + canRemit: () -> bool, +) -> State: + return { + ready = False, + complete = False, + beneficiary, + asset, + amount = 0, + canRemit, + } + +@abi +@state(self: State) +@account(self) +func deposit(depositor: address, amount: uint256): + require(!self.ready) + require(!self.complete) + + # expects an existing allowance (also known as "approval") + self.asset.transferFrom(depositor, self, amount) + + self.amount = amount + self.ready = True + +@abi +@state(self: State) +func remit(): + require(self.ready) + require(!self.complete) + + require(self.canRemit()) + + asset.transfer(self.beneficiary, self.amount) + + self.complete = True +`, + }, + ], +}; + +describe("identification", () => { + it("accepts a program that names its own schema", async () => { + await expect({ + ethdebug: id("schema:ethdebug/format/program"), + ...program, + }).toValidate({ schema: { id: "schema:ethdebug/format/program" } }); + }); + + it("rejects a program that claims another schema", async () => { + await expect({ + ethdebug: id("schema:ethdebug/format/info"), + ...program, + }).not.toValidate({ schema: { id: "schema:ethdebug/format/program" } }); + }); + + it("rejects versions the pattern forbids", async () => { + for (const version of [ + "0.1", + "v0.1.0", + "01.1.0", + "0.1.0-draft.01", + "0.1.0+build", + "0.1.0-", + ]) { + await expect({ + ethdebug: id("schema:ethdebug/format/program", version), + ...program, + }).not.toValidate({ schema: { id: "schema:ethdebug/format/program" } }); + } + }); + + it("accepts numeric and named prereleases and stable versions", async () => { + for (const version of ["0.1.0-2", "0.1.0-draft.3", "0.1.0", "1.0.0-rc.1"]) { + await expect({ + ethdebug: id("schema:ethdebug/format/program", version), + ...program, + }).toValidate({ schema: { id: "schema:ethdebug/format/program" } }); + } + }); + + it("rejects an extra key inside the field", async () => { + await expect({ + ethdebug: { ...id("schema:ethdebug/format/program"), extra: 1 }, + ...program, + }).not.toValidate({ schema: { id: "schema:ethdebug/format/program" } }); + }); + + it("lets an info document name info, and resources accept it too", async () => { + const info = { + ethdebug: id("schema:ethdebug/format/info"), + compilation, + programs: [], + types: {}, + pointers: {}, + }; + await expect(info).toValidate({ + schema: { id: "schema:ethdebug/format/info" }, + }); + await expect(info).toValidate({ + schema: { id: "schema:ethdebug/format/info/resources" }, + }); + const resources = { + ...info, + ethdebug: id("schema:ethdebug/format/info/resources"), + }; + await expect(resources).not.toValidate({ + schema: { id: "schema:ethdebug/format/info" }, + }); + }); +}); diff --git a/packages/format/src/schemas/version-literals.test.ts b/packages/format/src/schemas/version-literals.test.ts new file mode 100644 index 0000000000..7df096b7b9 --- /dev/null +++ b/packages/format/src/schemas/version-literals.test.ts @@ -0,0 +1,40 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { version } from "#version"; + +const schemasRoot = fileURLToPath( + new URL("../../../../schemas/", import.meta.url), +); + +function* yamlFiles(dir: string): Generator { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) yield* yamlFiles(path); + else if (entry.endsWith(".schema.yaml")) yield path; + } +} + +describe("version literals in schema examples", () => { + it("every ethdebug.version equals this package's version", () => { + const wrong: string[] = []; + let sites = 0; + for (const file of yamlFiles(schemasRoot)) { + const lines = readFileSync(file, "utf8").split("\n"); + lines.forEach((line, i) => { + const match = /^\s*version:\s*"([^"]*)"\s*$/.exec(line); + if (!match) return; + const previous = lines.slice(Math.max(0, i - 2), i).join("\n"); + const inBlock = + /ethdebug:\s*$/.test(previous) || + /schema:\s*"schema:ethdebug\/format\//.test(previous); + if (!inBlock) return; + sites += 1; + if (match[1] !== version) wrong.push(`${file}:${i + 1}: ${match[1]}`); + }); + } + expect(sites).toBeGreaterThanOrEqual(4); + expect(wrong).toEqual([]); + }); +}); diff --git a/packages/format/src/types/identification.test.ts b/packages/format/src/types/identification.test.ts new file mode 100644 index 0000000000..2270bf3725 --- /dev/null +++ b/packages/format/src/types/identification.test.ts @@ -0,0 +1,78 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { parse } from "yaml"; +import { describe, expect, it } from "vitest"; +import { + identify, + isIdentification, + supports, + versionPattern, +} from "./identification.js"; +import { version } from "#version"; + +describe("isIdentification", () => { + it("accepts a schema id and a semver version", () => { + expect( + isIdentification({ + schema: "schema:ethdebug/format/program", + version: "0.1.0-draft.0", + }), + ).toBe(true); + expect(isIdentification({ schema: "x", version: "0.1.0-2" })).toBe(true); + }); + + it("rejects what the schema pattern rejects", () => { + for (const v of ["v0.1.0", "0.1.0+build", " 0.1.0", "01.1.0", "0.1"]) { + expect(isIdentification({ schema: "x", version: v })).toBe(false); + } + expect(isIdentification({ schema: "x" })).toBe(false); + expect(isIdentification({ schema: "x", version: "0.1.0", extra: 1 })).toBe( + false, + ); + }); +}); + +describe("versionPattern", () => { + it("matches the pattern in schemas/identification.schema.yaml", () => { + const schemaPath = fileURLToPath( + new URL( + "../../../../schemas/identification.schema.yaml", + import.meta.url, + ), + ); + const parsed = parse(readFileSync(schemaPath, "utf8")); + expect(versionPattern.source).toBe(parsed.properties.version.pattern); + }); +}); + +describe("identify", () => { + it("names the schema and this package's version", () => { + expect(identify("schema:ethdebug/format/program")).toEqual({ + schema: "schema:ethdebug/format/program", + version, + }); + }); +}); + +describe("supports", () => { + it("accepts the same key and warns on a newer version", () => { + expect(supports("0.1.0-draft.1", "0.1.0-draft.1")).toBe("ok"); + expect(supports("0.1.0-draft.3", "0.1.0-draft.1")).toBe("newer"); + expect(supports("0.1.0", "0.1.0-draft.1")).toBe("newer"); + expect(supports("0.1.0-draft.0", "0.1.0")).toBe("ok"); + expect(supports("0.1.0-2", "0.1.0-draft.0")).toBe("ok"); + expect(supports("1.3.0", "1.0.0")).toBe("newer"); + expect(supports("1.0.0-draft.0", "1.0.0")).toBe("ok"); + }); + + it("rejects a differing key", () => { + expect(supports("0.2.0-draft.0", "0.1.0-draft.1")).toBe("unsupported"); + expect(supports("2.0.0", "1.0.0")).toBe("unsupported"); + expect(supports("1.0.0", "0.1.0")).toBe("unsupported"); + }); + + it("rejects what semver cannot parse", () => { + expect(supports("banana", "0.1.0")).toBe("unsupported"); + expect(supports("0.1.0", "")).toBe("unsupported"); + }); +}); diff --git a/packages/format/src/types/identification.ts b/packages/format/src/types/identification.ts new file mode 100644 index 0000000000..20a10b5506 --- /dev/null +++ b/packages/format/src/types/identification.ts @@ -0,0 +1,55 @@ +import semver from "semver"; +import { version } from "#version"; + +export interface Identification { + schema: string; + version: string; +} + +// the same pattern as schemas/identification.schema.yaml: semver without +// build metadata; semver.valid alone would coerce a v prefix or whitespace +export const versionPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?$/; + +export const isIdentification = (value: unknown): value is Identification => + typeof value === "object" && + !!value && + "schema" in value && + typeof value.schema === "string" && + "version" in value && + typeof value.version === "string" && + versionPattern.test(value.version) && + Object.keys(value).length === 2; + +export type RootSchemaId = + | "schema:ethdebug/format/info" + | "schema:ethdebug/format/info/resources" + | "schema:ethdebug/format/program"; + +// what a producer built against this package writes +export const identify = (schema: RootSchemaId): Identification => ({ + schema, + version, +}); + +// the compatibility key: the major version, or major.minor while the +// major is 0 (semver treats 0.x minors as breaking) +function compatibilityKey(value: string): string | undefined { + const parsed = semver.parse(value); + if (!parsed) { + return undefined; + } + return parsed.major === 0 ? `0.${parsed.minor}` : String(parsed.major); +} + +export const supports = ( + version: string, + supported: string, +): "ok" | "newer" | "unsupported" => { + const key = compatibilityKey(version); + const supportedKey = compatibilityKey(supported); + if (key === undefined || supportedKey === undefined || key !== supportedKey) { + return "unsupported"; + } + return semver.gt(version, supported) ? "newer" : "ok"; +}; diff --git a/packages/format/src/types/index.ts b/packages/format/src/types/index.ts index 2fe2bc29fe..59a7e93e26 100644 --- a/packages/format/src/types/index.ts +++ b/packages/format/src/types/index.ts @@ -1,3 +1,4 @@ +export * from "#types/identification"; export * from "#types/data"; export * from "#types/materials"; export * from "#types/type"; diff --git a/packages/format/src/types/program/program.ts b/packages/format/src/types/program/program.ts index 62ff41ae46..115f0cae32 100644 --- a/packages/format/src/types/program/program.ts +++ b/packages/format/src/types/program/program.ts @@ -1,3 +1,4 @@ +import { Identification, isIdentification } from "#types/identification"; import { Materials } from "#types/materials"; import { Context as _Context, isContext as _isContext } from "./context.js"; @@ -8,6 +9,7 @@ import { } from "./instruction.js"; export interface Program { + ethdebug?: Identification; compilation?: Materials.Reference; contract: Program.Contract; environment: Program.Environment; @@ -27,7 +29,8 @@ export const isProgram = (value: unknown): value is Program => value.instructions.every(Program.isInstruction) && (!("compilation" in value) || Materials.isReference(value.compilation)) && - (!("context" in value) || Program.isContext(value.context)); + (!("context" in value) || Program.isContext(value.context)) && + (!("ethdebug" in value) || isIdentification(value.ethdebug)); export namespace Program { export import Context = _Context; diff --git a/packages/format/src/version.test.ts b/packages/format/src/version.test.ts new file mode 100644 index 0000000000..1a5f25789a --- /dev/null +++ b/packages/format/src/version.test.ts @@ -0,0 +1,12 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { version } from "#version"; + +describe("version", () => { + it("equals package.json's version", () => { + const manifest = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ) as { version: string }; + expect(version).toBe(manifest.version); + }); +}); diff --git a/packages/format/test/extensions.ts b/packages/format/test/extensions.ts index 20e07ec7d4..eedc5e338b 100644 --- a/packages/format/test/extensions.ts +++ b/packages/format/test/extensions.ts @@ -72,4 +72,7 @@ export const schemaExtensions: { "schema:ethdebug/format/type/complex/function": { extends: new Set(["schema:ethdebug/format/type/complex"]), }, + "schema:ethdebug/format/info": { + extends: new Set(["schema:ethdebug/format/info/resources"]), + }, }; diff --git a/packages/programs-react/CHANGELOG.md b/packages/programs-react/CHANGELOG.md index 37a1a95ae3..12c9d054c7 100644 --- a/packages/programs-react/CHANGELOG.md +++ b/packages/programs-react/CHANGELOG.md @@ -7,6 +7,14 @@ root [`CHANGELOG.md`](../../CHANGELOG.md). ## Unreleased +### Added + +- `specification` in the trace state: `undefined` when the program has + no `ethdebug` identification, otherwise the program's version, the + version this package supports, and a verdict of `"ok"`, `"newer"` or + `"unsupported"`. The Docusaurus trace viewer shows a notice in place + of the trace when the verdict is `"unsupported"` ([#305]). + ## 0.1.0-preview.0 — 2026-09-21 The version scheme changed: prerelease versions are now `preview.`, and @@ -33,3 +41,4 @@ First publication. [#298]: https://github.com/ethdebug/format/pull/298 [#299]: https://github.com/ethdebug/format/pull/299 [#300]: https://github.com/ethdebug/format/pull/300 +[#305]: https://github.com/ethdebug/format/pull/305 diff --git a/packages/programs-react/src/components/TraceContext.test.tsx b/packages/programs-react/src/components/TraceContext.test.tsx index cb7eef581f..64afeb394c 100644 --- a/packages/programs-react/src/components/TraceContext.test.tsx +++ b/packages/programs-react/src/components/TraceContext.test.tsx @@ -6,10 +6,10 @@ * at the first step). See effectiveContextForStep. */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { renderHook, act, waitFor } from "@testing-library/react"; import React from "react"; -import type { Program } from "@ethdebug/format"; +import { version as formatVersion, type Program } from "@ethdebug/format"; import { TraceProvider, useTraceContext } from "./TraceContext.js"; import type { TraceStep } from "#utils/mockTrace"; @@ -199,3 +199,78 @@ describe("TraceProvider call-stack timing", () => { expect(BigInt(x.value!)).toBe(42n); }); }); + +describe("TraceProvider specification verdict", () => { + const specTrace: TraceStep[] = [{ pc: 0, opcode: "JUMPDEST" }]; + + function renderWithProgram(specProgram: Program) { + return renderHook(() => useTraceContext(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + } + + it("judges a matching version as ok", () => { + const specProgram = { + ethdebug: { + schema: "schema:ethdebug/format/program", + version: formatVersion, + }, + instructions: [instr(0, {})], + } as unknown as Program; + + const { result } = renderWithProgram(specProgram); + expect(result.current.specification).toEqual({ + verdict: "ok", + version: formatVersion, + supported: formatVersion, + }); + }); + + it("judges a differing compatibility key as unsupported", () => { + const specProgram = { + ethdebug: { + schema: "schema:ethdebug/format/program", + version: "0.2.0-draft.0", + }, + instructions: [instr(0, {})], + } as unknown as Program; + + expect(() => { + const { result } = renderWithProgram(specProgram); + expect(result.current.specification?.verdict).toBe("unsupported"); + }).not.toThrow(); + }); + + it("leaves specification undefined when the program has no field", () => { + const specProgram = { + instructions: [instr(0, {})], + } as unknown as Program; + + const { result } = renderWithProgram(specProgram); + expect(result.current.specification).toBeUndefined(); + }); + + it("warns once when the program names a newer version", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const specProgram = { + ethdebug: { schema: "schema:ethdebug/format/program", version: "0.1.0" }, + instructions: [instr(0, {})], + } as unknown as Program; + + const { result } = renderWithProgram(specProgram); + expect(result.current.specification?.verdict).toBe("newer"); + expect(warn).toHaveBeenCalledTimes(1); + + warn.mockRestore(); + }); +}); diff --git a/packages/programs-react/src/components/TraceContext.tsx b/packages/programs-react/src/components/TraceContext.tsx index 943a3eec44..0f27a50db8 100644 --- a/packages/programs-react/src/components/TraceContext.tsx +++ b/packages/programs-react/src/components/TraceContext.tsx @@ -11,7 +11,12 @@ import React, { useMemo, useRef, } from "react"; -import type { Pointer, Program } from "@ethdebug/format"; +import { + supports, + version as supportedVersion, + type Pointer, + type Program, +} from "@ethdebug/format"; import { dereference, Data } from "@ethdebug/pointers"; import { type TraceStep, @@ -172,6 +177,17 @@ export interface TraceState { /** Whether we're at the last step */ isAtEnd: boolean; + /** The specification version the program names, judged against the + * version this package supports; undefined when the program has no + * identification */ + specification: + | { + verdict: "ok" | "newer" | "unsupported"; + version: string; + supported: string; + } + | undefined; + /** Move to the next trace step */ stepForward(): void; /** Move to the previous trace step */ @@ -278,6 +294,25 @@ export function TraceProvider({ [program], ); + const specification = useMemo(() => { + const identification = program.ethdebug; + if (!identification) { + return undefined; + } + const verdict = supports(identification.version, supportedVersion); + if (verdict === "newer") { + console.warn( + `ethdebug/format ${identification.version} is newer than the ` + + `supported ${supportedVersion}; proceeding`, + ); + } + return { + verdict, + version: identification.version, + supported: supportedVersion, + }; + }, [program]); + const currentStep = trace[currentStepIndex]; const currentInstruction = currentStep ? pcToInstruction.get(currentStep.pc) @@ -643,6 +678,7 @@ export function TraceProvider({ currentCallInfo, isAtStart: currentStepIndex === 0, isAtEnd: currentStepIndex >= trace.length - 1, + specification, stepForward, stepBackward, stepToNextSource, diff --git a/packages/web/spec/identification.mdx b/packages/web/spec/identification.mdx new file mode 100644 index 0000000000..c9f92a3ba5 --- /dev/null +++ b/packages/web/spec/identification.mdx @@ -0,0 +1,123 @@ +--- +sidebar_position: 5 +--- + +import SchemaViewer from "@site/src/components/SchemaViewer"; +import StatusBanner from "@site/src/components/StatusBanner"; + +# Identification + +:::tip[Summary] + +An object that arrives on its own — separated from whatever produced +it — cannot say what it is unless it says so itself. +**ethdebug/format/identification** is a small shared schema for +exactly that: it names the schema an object conforms to and the +version of the specification that defines that schema, so a consumer +can tell what it holds without any other context. + +::: + +This format defines the **ethdebug/format/identification** schema and +includes it, under the key `ethdebug`, in each of the three root +schemas: **ethdebug/format/program**, **ethdebug/format/info**, and +**ethdebug/format/info/resources**. An object in any of these schemas +may carry a field shaped like this: + +```json +{ + "ethdebug": { + "schema": "schema:ethdebug/format/program", + "version": "" + } +} +``` + +`schema` is the `$id` of the schema the object conforms to; each root +schema pins this to its own id (or, for +**ethdebug/format/info/resources**, to either its own id or +**ethdebug/format/info**'s — see below). `version` is the current +version of the specification, meaning the version of the +`@ethdebug/format` package whose schemas define `schema`. + + + +## Rules + +### Which version to write + +A producer writes the version of the `@ethdebug/format` package whose +schemas it targets — the version it was built against. Two producers +built against the same release write the same value. + +### Absent field + +An object without this field predates it. A consumer treats an object +with no `ethdebug` field as conforming to the last release before the +field existed — `0.1.0-draft.0` — and applies no version check. + +### Required later + +The field is optional for now. It becomes required in the stable +`0.1.0` release. **ethdebug/format/program** and +**ethdebug/format/info** are closed objects +(`unevaluatedProperties: false`), so a consumer that validates +against the previous release's schemas rejects an identified object +until it updates its schemas. + +### Nesting + +A program nested inside an info document (at `programs[i]`) may omit +the field even when the containing info document carries it. If both +the container and the nested program carry the field, their `version` +values **must** be equal; a consumer that finds them unequal reports +the mismatch and uses the container's version rather than rejecting +the document. If only the nested program carries the field, its +version stands on its own. + +The same rule applies when a program sits beside a resources object +instead of inside an info document — for example in solc's standard +JSON output, where `evm.bytecode.ethdebug` and +`evm.deployedBytecode.ethdebug` are programs that sit beside the +top-level `ethdebug` wrapper, whose `resources` member is the +resources object. There, the resources object plays the container's +role: on a mismatch, a consumer reports it and uses the resources +object's version. + +### Consumer rule + +A consumer rejects an object only when its compatibility key differs +from the one the consumer supports. It accepts every other version, +and **may** warn when the version is newer than the one it was built +against. + +The compatibility key follows semver: the major version at `1.0.0` +and above, or `major.minor` while the major version is `0`, since +semver — and npm's caret range — both treat a minor bump as breaking +during `0.x`. A `0.1` consumer accepts every `0.1.x` version, +drafts included, and rejects `0.2.0-draft.0` with "unsupported +specification version 0.2". A `version` that the semver parser cannot +read, or a `supported` version it cannot read, is treated as a +differing key: `unsupported`. Validate the field with +`isIdentification` first; `supports` is not a validity check. + +One consequence follows directly from that key: it cannot tell drafts +within one key apart. Every `0.1.x` document — drafts and numbered +prereleases alike — is accepted by a `0.1` consumer, even though the +changelog records obligations that differ between individual releases +before `0.1.0`. The field tells a consumer which changelog entries +might apply to a document; it does not enforce them. + +### Resolution of ids + +A `schema:ethdebug/format/` id names the schema whose source is +`schemas/.schema.yaml` in the ethdebug/format repository, at the +version named alongside it. The `@ethdebug/format` package for that +version exports the schema text under that same id, in its `schemas` +map. The id is an identifier, resolved through that package — it is +not a web address, and emitted data **should not** place it in a +`$schema` key: editors treat `$schema` as a URL and report an error. + +## Status + + diff --git a/packages/web/spec/info/info.mdx b/packages/web/spec/info/info.mdx index 797ba6a274..e996453727 100644 --- a/packages/web/spec/info/info.mdx +++ b/packages/web/spec/info/info.mdx @@ -9,3 +9,8 @@ import SchemaViewer from "@site/src/components/SchemaViewer"; # Schema + +## Identification + +This object may carry an `ethdebug` field; see +[Identification](/spec/identification). diff --git a/packages/web/spec/info/resources.mdx b/packages/web/spec/info/resources.mdx index 5335efbe31..bb99455d9b 100644 --- a/packages/web/spec/info/resources.mdx +++ b/packages/web/spec/info/resources.mdx @@ -7,3 +7,8 @@ import SchemaViewer from "@site/src/components/SchemaViewer"; # Resources lookup schema + +## Identification + +This object may carry an `ethdebug` field; see +[Identification](/spec/identification). diff --git a/packages/web/spec/overview.mdx b/packages/web/spec/overview.mdx index 44c623c878..fe1bff72e8 100644 --- a/packages/web/spec/overview.mdx +++ b/packages/web/spec/overview.mdx @@ -37,6 +37,12 @@ This specification currently contains the following primary schemas: programs, shared types, sources, and compilation metadata. +
[**ethdebug/format/identification**](/spec/identification)
+
+ A schema for naming the schema an object conforms to and the specification + version that defines it. +
+ In addition, this format defines namespaces containing schemas for common @@ -71,6 +77,20 @@ For the full collection of raw schema listings (in YAML format), please see the [`schemas/` directory](https://github.com/ethdebug/format/tree/main/schemas) in this project's GitHub repository. +A `schema:ethdebug/format/` id, as seen throughout this +specification, names the schema whose source is +`schemas/.schema.yaml` in that repository, at whatever version +of the specification is in question. The `@ethdebug/format` package +for that version exports the schema text under that same id, in its +`schemas` map — the id is an identifier, resolved through that +package, and not a web address. Emitted data **should not** place it +in a `$schema` key: editors treat `$schema` as a URL and report an +error. **ethdebug/format/info** and **ethdebug/format/program** reject +a `$schema` key outright, through `unevaluatedProperties: false`; +**ethdebug/format/info/resources** stays open to one for now. See +[Identification](/spec/identification) for how objects in this format +name the schema they conform to. + ## Conventions used by this format ### Terminology diff --git a/packages/web/spec/program/program.mdx b/packages/web/spec/program/program.mdx index 86c0403583..93e9231ac9 100644 --- a/packages/web/spec/program/program.mdx +++ b/packages/web/spec/program/program.mdx @@ -9,3 +9,8 @@ import SchemaViewer from "@site/src/components/SchemaViewer"; # Schema + +## Identification + +This object may carry an `ethdebug` field; see +[Identification](/spec/identification). diff --git a/packages/web/src/components/StatusTable.tsx b/packages/web/src/components/StatusTable.tsx index 920de2efb8..c7ccd2fe31 100644 --- a/packages/web/src/components/StatusTable.tsx +++ b/packages/web/src/components/StatusTable.tsx @@ -11,6 +11,7 @@ const schemaOrder = [ "data", "materials", "info", + "identification", ] as const; const schemaNames: Record = { @@ -20,6 +21,7 @@ const schemaNames: Record = { data: "ethdebug/format/data", materials: "ethdebug/format/materials", info: "ethdebug/format/info", + identification: "ethdebug/format/identification", }; export default function StatusTable(): JSX.Element { diff --git a/packages/web/src/schemas.ts b/packages/web/src/schemas.ts index 31496043aa..de9db58ecd 100644 --- a/packages/web/src/schemas.ts +++ b/packages/web/src/schemas.ts @@ -284,6 +284,10 @@ const infoSchemaIndex: SchemaIndex = { "schema:ethdebug/format/info/resources": { href: "/spec/info/resources", }, + "schema:ethdebug/format/identification": { + title: "ethdebug/format/identification", + href: "/spec/identification", + }, }; export const schemaIndex: SchemaIndex = { diff --git a/packages/web/src/status/status-config.ts b/packages/web/src/status/status-config.ts index bbd98eec4f..0a077db7f7 100644 --- a/packages/web/src/status/status-config.ts +++ b/packages/web/src/status/status-config.ts @@ -106,6 +106,14 @@ export const schemaStatus: Record = { caveats: [], detailsPath: "/spec/info/overview#status", }, + identification: { + level: "implementable", + summary: + "Names the schema an object conforms to and the specification " + + "version that defines it. Optional until 0.1.0.", + caveats: [], + detailsPath: "/spec/identification#status", + }, }; /** diff --git a/packages/web/src/theme/ProgramExample/TraceViewer.css b/packages/web/src/theme/ProgramExample/TraceViewer.css index 341f4603b9..fb7af17b0b 100644 --- a/packages/web/src/theme/ProgramExample/TraceViewer.css +++ b/packages/web/src/theme/ProgramExample/TraceViewer.css @@ -15,6 +15,11 @@ height: 100%; } +.trace-viewer-notice { + padding: 1rem; + color: var(--ifm-color-emphasis-600); +} + .trace-viewer-header { padding: 0.75rem 1rem; border-bottom: 1px solid var(--ifm-color-emphasis-200); diff --git a/packages/web/src/theme/ProgramExample/TraceViewer.tsx b/packages/web/src/theme/ProgramExample/TraceViewer.tsx index c2348beb7b..61393b75cd 100644 --- a/packages/web/src/theme/ProgramExample/TraceViewer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceViewer.tsx @@ -82,7 +82,16 @@ function TraceViewerContent({ showVariables, showStack, }: TraceViewerContentProps): JSX.Element { - const { currentStep, currentInstruction } = useTraceContext(); + const { currentStep, currentInstruction, specification } = useTraceContext(); + + if (specification?.verdict === "unsupported") { + return ( +
+ This viewer supports ethdebug/format {specification.supported}; the + program names {specification.version}, which it cannot read. +
+ ); + } // Find source range for current instruction const sourceRange = diff --git a/schemas/identification.schema.yaml b/schemas/identification.schema.yaml new file mode 100644 index 0000000000..7e50b3f7b6 --- /dev/null +++ b/schemas/identification.schema.yaml @@ -0,0 +1,47 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "schema:ethdebug/format/identification" + +title: ethdebug/format/identification +description: | + Names the schema an object conforms to and the version of the + specification that defines that schema. + + `schema` is the `$id` of the schema; `version` is the version of the + `@ethdebug/format` package whose schemas define it. A producer writes + the version it was built against. An object without this field + predates it. Inside a container that carries the field, a nested + object may omit it; if both carry it, their versions must be equal. + + A consumer accepts an object whose version shares its compatibility + key (the major version, or `major.minor` while the major is 0), warns + when the version is newer than the one it supports, and rejects only + a differing key. + +type: object + +properties: + schema: + type: string + title: Schema identifier + description: | + The `$id` of the schema this object conforms to, for example + `schema:ethdebug/format/program`. + + version: + type: string + title: Specification version + description: | + The version of the specification (the version of the + `@ethdebug/format` package) whose schemas define `schema`, as a + semver string without build metadata. + pattern: "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?$" + +required: + - schema + - version + +additionalProperties: false + +examples: + - schema: "schema:ethdebug/format/program" + version: "0.1.0-draft.0" diff --git a/schemas/info.schema.yaml b/schemas/info.schema.yaml index e8cef1e175..4cb77adcd7 100644 --- a/schemas/info.schema.yaml +++ b/schemas/info.schema.yaml @@ -10,6 +10,17 @@ type: object $ref: "schema:ethdebug/format/info/resources" properties: + ethdebug: + title: Identification + description: | + Names this schema and the specification version. Optional until + the stable `0.1.0` release, where it becomes required. + allOf: + - $ref: "schema:ethdebug/format/identification" + - properties: + schema: + const: "schema:ethdebug/format/info" + programs: type: array items: @@ -25,7 +36,10 @@ required: unevaluatedProperties: false examples: - - compilation: + - ethdebug: + schema: "schema:ethdebug/format/info" + version: "0.1.0-draft.0" + compilation: id: __301f3b6d85831638 compiler: name: egc diff --git a/schemas/info/resources.schema.yaml b/schemas/info/resources.schema.yaml index 3e26221a50..3daa2d791f 100644 --- a/schemas/info/resources.schema.yaml +++ b/schemas/info/resources.schema.yaml @@ -8,6 +8,19 @@ description: | type: object properties: + ethdebug: + title: Identification + description: | + Names this schema and the specification version. Optional until + the stable `0.1.0` release, where it becomes required. + allOf: + - $ref: "schema:ethdebug/format/identification" + - properties: + schema: + enum: + - "schema:ethdebug/format/info/resources" + - "schema:ethdebug/format/info" + types: title: Types by name description: | @@ -32,7 +45,10 @@ required: - pointers examples: - - types: + - ethdebug: + schema: "schema:ethdebug/format/info/resources" + version: "0.1.0-draft.0" + types: "struct__Coordinate": kind: struct contains: diff --git a/schemas/program.schema.yaml b/schemas/program.schema.yaml index 889ac832a4..c347faca43 100644 --- a/schemas/program.schema.yaml +++ b/schemas/program.schema.yaml @@ -8,6 +8,17 @@ description: | type: object properties: + ethdebug: + title: Identification + description: | + Names this schema and the specification version. Optional until + the stable `0.1.0` release, where it becomes required. + allOf: + - $ref: "schema:ethdebug/format/identification" + - properties: + schema: + const: "schema:ethdebug/format/program" + compilation: title: Compilation reference by ID description: | @@ -77,6 +88,9 @@ examples: # storedValue += 1; # }; # ``` + ethdebug: + schema: "schema:ethdebug/format/program" + version: "0.1.0-draft.0" contract: name: "Incrementer" definition: