From 2d8aac552371e2fa6788122c6f723370f8aac530 Mon Sep 17 00:00:00 2001 From: ThomazPassarelliOAB Date: Wed, 26 Aug 2026 09:19:12 -0300 Subject: [PATCH] fix(core): derive placeholder and expect-matcher validation from their Zod definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `${contracts..contractId}` grammar was spelled out twice — globally in placeholder-engine.ts for substitution, anchored in validate-contract-graph.ts for whole-value validation — and EXPECT_MATCHERS restated ExpectMatcherSchema by hand. Either copy could drift from its canonical definition. Export the placeholder grammar as a pattern source and compile it at each use site. The two uses need different flags, so sharing one compiled /g RegExp would leak lastIndex between calls; sharing the source keeps the grammar single-sourced without that hazard. Derive EXPECT_MATCHERS from ExpectMatcherSchema.options. Tests pin both invariants: every matcher the schema declares must be handled by evaluateMatcher, and the shared grammar must stay anchored in validation while not carrying regex state across resolutions. No behavior change, no public API change. Closes #158 --- .../src/config/validate-contract-graph.ts | 4 +++- .../src/contracts/placeholder-engine.test.ts | 19 ++++++++++++++++- .../core/src/contracts/placeholder-engine.ts | 10 ++++++++- .../core/src/contracts/verify-expect.test.ts | 21 +++++++++++++++++++ packages/core/src/contracts/verify-expect.ts | 16 ++++---------- 5 files changed, 55 insertions(+), 15 deletions(-) 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(", ")}.` ); } }