diff --git a/packages/core/src/config/validate-contract-graph.ts b/packages/core/src/config/validate-contract-graph.ts index 48d4c19a..63945995 100644 --- a/packages/core/src/config/validate-contract-graph.ts +++ b/packages/core/src/config/validate-contract-graph.ts @@ -1,8 +1,10 @@ import type { ContractConfig } from "./config.schema.js"; import { CaatingaError, CaatingaErrorCode } from "../errors/CaatingaError.js"; import { resolveDeployOrder } from "../contracts/resolve-deploy-order.js"; +import { CONTRACT_ID_PLACEHOLDER_SOURCE } from "../contracts/placeholder-engine.js"; -const CONTRACT_ID_PLACEHOLDER = /^\$\{contracts\.([A-Za-z0-9_-]+)\.contractId\}$/; +/** Whole-value form of the shared placeholder grammar. */ +const CONTRACT_ID_PLACEHOLDER = new RegExp(`^${CONTRACT_ID_PLACEHOLDER_SOURCE}$`); function parseContractIdPlaceholder(value: string): string | undefined { return value.match(CONTRACT_ID_PLACEHOLDER)?.[1]; diff --git a/packages/core/src/contracts/placeholder-engine.test.ts b/packages/core/src/contracts/placeholder-engine.test.ts index a29ccd68..84ab39f1 100644 --- a/packages/core/src/contracts/placeholder-engine.test.ts +++ b/packages/core/src/contracts/placeholder-engine.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "vitest"; -import { resolvePlaceholders, type PlaceholderContext } from "./placeholder-engine.js"; +import { + CONTRACT_ID_PLACEHOLDER_SOURCE, + resolvePlaceholders, + type PlaceholderContext, +} from "./placeholder-engine.js"; import { CaatingaErrorCode } from "../errors/CaatingaError.js"; describe("resolvePlaceholders", () => { @@ -70,4 +74,17 @@ describe("resolvePlaceholders", () => { }) ); }); + it("should reuse the shared grammar for whole-value validation", () => { + const wholeValue = new RegExp(`^${CONTRACT_ID_PLACEHOLDER_SOURCE}$`); + + expect(wholeValue.exec("${contracts.token.contractId}")?.[1]).toBe("token"); + expect(wholeValue.test("Contract ${contracts.token.contractId} deployed")).toBe(false); + }); + + it("should not carry regex state across calls", () => { + // The shared grammar is compiled with /g here and anchored without /g in + // validation; reusing one RegExp instance across both would carry lastIndex. + expect(resolvePlaceholders("${contracts.token.contractId}", context)).toBe("CAS3JIO4YZHG45NVU"); + expect(resolvePlaceholders("${contracts.token.contractId}", context)).toBe("CAS3JIO4YZHG45NVU"); + }); }); diff --git a/packages/core/src/contracts/placeholder-engine.ts b/packages/core/src/contracts/placeholder-engine.ts index 5a788677..d5e34902 100644 --- a/packages/core/src/contracts/placeholder-engine.ts +++ b/packages/core/src/contracts/placeholder-engine.ts @@ -7,7 +7,15 @@ export type PlaceholderContext = { sourceAddress?: string; }; -const CONTRACT_ID_REGEX = /\$\{contracts\.([A-Za-z0-9_-]+)\.contractId\}/g; +/** + * Grammar for `${contracts..contractId}` placeholders. Exported so that + * config-time validation and resolve-time substitution cannot drift apart. + * Callers compile their own RegExp because the two uses need different flags: + * a global replace here, an anchored whole-value match in validation. + */ +export const CONTRACT_ID_PLACEHOLDER_SOURCE = String.raw`\$\{contracts\.([A-Za-z0-9_-]+)\.contractId\}`; + +const CONTRACT_ID_REGEX = new RegExp(CONTRACT_ID_PLACEHOLDER_SOURCE, "g"); const SOURCE_ADDRESS_REGEX = /\$\{source\.address\}/g; export function resolvePlaceholders(text: string, context: PlaceholderContext): string { diff --git a/packages/core/src/contracts/verify-expect.test.ts b/packages/core/src/contracts/verify-expect.test.ts index 9114d1a8..d136f860 100644 --- a/packages/core/src/contracts/verify-expect.test.ts +++ b/packages/core/src/contracts/verify-expect.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { assertExpect, parseExpectSpec, verifyExpect } from "./verify-expect.js"; import { CaatingaError, CaatingaErrorCode } from "../errors/CaatingaError.js"; +import { ExpectMatcherSchema } from "../config/config.schema.js"; +import type { ExpectMatcher } from "../config/config.schema.js"; describe("verifyExpect", () => { it("should_pass_string_equals_when_output_matches", () => { @@ -80,4 +82,23 @@ describe("verifyExpect", () => { CaatingaError ); }); + it("should_handle_every_matcher_declared_by_the_schema", () => { + for (const matcher of ExpectMatcherSchema.options) { + expect(() => verifyExpect("1", { matcher, value: 1 })).not.toThrow(); + } + }); + + it("should_list_every_schema_matcher_in_the_unknown_matcher_hint", () => { + let thrown: unknown; + try { + verifyExpect("x", { matcher: "notAMatcher" as ExpectMatcher }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(CaatingaError); + for (const matcher of ExpectMatcherSchema.options) { + expect((thrown as CaatingaError).hint).toContain(matcher); + } + }); }); diff --git a/packages/core/src/contracts/verify-expect.ts b/packages/core/src/contracts/verify-expect.ts index 2e06a366..1d34af73 100644 --- a/packages/core/src/contracts/verify-expect.ts +++ b/packages/core/src/contracts/verify-expect.ts @@ -1,4 +1,5 @@ import { CaatingaError, CaatingaErrorCode } from "../errors/CaatingaError.js"; +import { ExpectMatcherSchema } from "../config/config.schema.js"; import type { ExpectMatcher, ExpectSpec } from "../config/config.schema.js"; export type VerifyExpectResult = { @@ -14,17 +15,8 @@ export type VerifyExpectFailure = { export type VerifyExpectOutcome = VerifyExpectResult | VerifyExpectFailure; -const EXPECT_MATCHERS: ReadonlySet = new Set([ - "equals", - "reachable", - "isNull", - "isArray", - "minLength", - "maxLength", - "contains", - "matches", - "jsonEquals", -]); +/** Derived from the schema so `ExpectMatcherSchema` stays the single source of truth. */ +const EXPECT_MATCHERS: readonly ExpectMatcher[] = ExpectMatcherSchema.options; function describeExpectSpec(spec: ExpectSpec): string { if (typeof spec === "string") { @@ -190,7 +182,7 @@ function evaluateMatcher(actual: string, spec: Exclude): Ver throw new CaatingaError( `Unknown expect matcher "${unknownMatcher}".`, CaatingaErrorCode.INVALID_CONFIG, - `Supported matchers: ${[...EXPECT_MATCHERS].join(", ")}.` + `Supported matchers: ${EXPECT_MATCHERS.join(", ")}.` ); } }