diff --git a/packages/format/src/types/pointer/pointer.test.ts b/packages/format/src/types/pointer/pointer.test.ts index eb2457a20e..583178acdb 100644 --- a/packages/format/src/types/pointer/pointer.test.ts +++ b/packages/format/src/types/pointer/pointer.test.ts @@ -1,9 +1,57 @@ +import { describe, it, expect } from "vitest"; + import { testSchemaGuards } from "#test/guards"; +import { describeSchema } from "#describe"; import { Pointer, isPointer } from "./pointer.js"; +const identifierSchema = "schema:ethdebug/format/pointer/identifier"; + +describe("Pointer.isIdentifier", () => { + const valid = ["a", "a0", "-$", "__init__", "_x", "A", "foo-bar", "x$y"]; + + const invalid = [ + "", // empty + "0abc", // leading digit + "$x", // leading $ + "a b", // space + "foo\\bar", // embedded backslash — accepted before the fix + "\\", // lone backslash + "foo\tbar", // tab + "café", // non-ASCII letter + ]; + + it.each(valid)("accepts %j", (value) => { + expect(Pointer.isIdentifier(value)).toBe(true); + }); + + it.each(invalid)("rejects %j", (value) => { + expect(Pointer.isIdentifier(value)).toBe(false); + }); + + it("agrees with the identifier schema pattern over a corpus", () => { + const { schema } = describeSchema({ schema: identifierSchema }); + const { pattern } = schema as { pattern: string }; + const schemaRegex = new RegExp(pattern); + + for (const value of [...valid, ...invalid]) { + expect(Pointer.isIdentifier(value)).toBe(schemaRegex.test(value)); + } + }); + + it("rejects non-string inputs", () => { + for (const value of [undefined, null, 42, {}, ["a"]]) { + expect(Pointer.isIdentifier(value)).toBe(false); + } + }); +}); + const expressionSchema = "schema:ethdebug/format/pointer/expression"; testSchemaGuards("ethdebug/format/pointer", [ + { + schema: identifierSchema, + guard: Pointer.isIdentifier, + }, { schema: expressionSchema, guard: Pointer.isExpression, diff --git a/packages/format/src/types/pointer/pointer.ts b/packages/format/src/types/pointer/pointer.ts index 1b4930ec8c..11f38de8b4 100644 --- a/packages/format/src/types/pointer/pointer.ts +++ b/packages/format/src/types/pointer/pointer.ts @@ -6,7 +6,7 @@ export const isPointer = (value: unknown): value is Pointer => export namespace Pointer { export type Identifier = string; export const isIdentifier = (value: unknown): value is Identifier => - typeof value === "string" && /^[a-zA-Z_\\-]+[a-zA-Z0-9$_\\-]*$/.test(value); + typeof value === "string" && /^[a-zA-Z_-]+[a-zA-Z0-9$_-]*$/.test(value); export type Region = | Region.Stack diff --git a/packages/format/src/types/type/index.test.ts b/packages/format/src/types/type/index.test.ts index cc461540ae..7747395dc7 100644 --- a/packages/format/src/types/type/index.test.ts +++ b/packages/format/src/types/type/index.test.ts @@ -1,4 +1,9 @@ +import { describe, it, expect } from "vitest"; + import { testSchemaGuards } from "#test/guards"; +// loads schemas into the global hyperjump validator + `toValidate` matcher +import "#test/hyperjump"; + import { Type, isType } from "./index.js"; testSchemaGuards("ethdebug/format/type", [ @@ -96,3 +101,54 @@ testSchemaGuards("ethdebug/format/type", [ guard: Type.isWrapper, }, ] as const); + +// The contract type's schema uses a oneOf to distinguish normal / +// library / interface contracts via the `library` and `interface` +// flags. The guard must carry those flags and agree with the schema — +// in particular it must reject a contract that sets both flags true +// (which matches two oneOf branches at once). +describe("Type.Elementary.isContract flag semantics", () => { + const contractSchema = "schema:ethdebug/format/type/elementary/contract"; + + const legal = [ + { title: "normal (flags omitted)", value: { kind: "contract" } }, + { + title: "normal (flags explicitly false)", + value: { kind: "contract", library: false, interface: false }, + }, + { title: "library", value: { kind: "contract", library: true } }, + { title: "interface", value: { kind: "contract", interface: true } }, + ] as const; + + const illegal = [ + { + title: "both library and interface true", + value: { kind: "contract", library: true, interface: true }, + }, + ] as const; + + for (const { title, value } of legal) { + it(`accepts the ${title} contract shape`, async () => { + expect(Type.Elementary.isContract(value)).toBe(true); + // guard agrees with the schema + await expect(value).toValidate({ schema: contractSchema }); + }); + } + + for (const { title, value } of illegal) { + it(`rejects a contract with ${title}`, async () => { + expect(Type.Elementary.isContract(value)).toBe(false); + // guard agrees with the schema + await expect(value).not.toValidate({ schema: contractSchema }); + }); + } + + it("rejects non-boolean flag values", () => { + expect( + Type.Elementary.isContract({ kind: "contract", library: "true" }), + ).toBe(false); + expect(Type.Elementary.isContract({ kind: "contract", interface: 1 })).toBe( + false, + ); + }); +}); diff --git a/packages/format/src/types/type/index.ts b/packages/format/src/types/type/index.ts index 7d06497496..32c8bee84e 100644 --- a/packages/format/src/types/type/index.ts +++ b/packages/format/src/types/type/index.ts @@ -237,6 +237,8 @@ export namespace Type { class?: "elementary"; kind: "contract"; payable?: boolean; + library?: boolean; + interface?: boolean; definition?: Definition; } @@ -246,6 +248,18 @@ export namespace Type { mayHaveClass(value, "elementary") && hasKind(value, "contract") && (!("payable" in value) || typeof value.payable === "boolean") && + (!("library" in value) || typeof value.library === "boolean") && + (!("interface" in value) || typeof value.interface === "boolean") && + // The schema's oneOf splits contract types three ways (normal / + // library / interface). `library` and `interface` cannot both be + // true: that shape satisfies two branches at once, which the + // oneOf rejects. + !( + "library" in value && + value.library === true && + "interface" in value && + value.interface === true + ) && (!("definition" in value) || isDefinition(value.definition)); export interface Enum {