diff --git a/.github/workflows/validate-blueprints.yml b/.github/workflows/validate-blueprints.yml new file mode 100644 index 0000000..52c7a58 --- /dev/null +++ b/.github/workflows/validate-blueprints.yml @@ -0,0 +1,40 @@ +name: Validate Translation Blueprints + +on: + pull_request: + paths: + - "lib/translator/blueprints/**" + - "lib/translator/blueprint.schema.ts" + - "scripts/validate-blueprints.ts" + - ".github/workflows/validate-blueprints.yml" + push: + branches: [main, master] + paths: + - "lib/translator/blueprints/**" + - "lib/translator/blueprint.schema.ts" + - "scripts/validate-blueprints.ts" + - ".github/workflows/validate-blueprints.yml" + +jobs: + validate: + name: Blueprint Schema & Test Validation + runs-on: ubuntu-latest + + steps: + - name: ๐Ÿ“ฅ Checkout Repository + uses: actions/checkout@v4 + + - name: ๐ŸŸข Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: ๐Ÿ“ฆ Install Dependencies + run: npm ci + + - name: ๐Ÿ” Run Schema Validation + run: npm run validate:blueprints + + - name: ๐Ÿงช Run Blueprint Unit Tests + run: npx vitest run lib/translator/__tests__/blueprint-validation.test.ts diff --git a/lib/translator/__tests__/blueprint-validation.test.ts b/lib/translator/__tests__/blueprint-validation.test.ts new file mode 100644 index 0000000..d42f613 --- /dev/null +++ b/lib/translator/__tests__/blueprint-validation.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest"; +import { BlueprintSchema } from "../blueprint.schema"; +import { createSacTransferBlueprint, createAllSacBlueprints } from "../blueprints/sac-transfer"; +import { createSacMintBurnBlueprint } from "../blueprints/sac-mint-burn"; + +describe("Translation Blueprint Schema Validation", () => { + describe("Existing Blueprints", () => { + it("should validate a single SAC Transfer blueprint", () => { + const blueprint = createSacTransferBlueprint("CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"); + const parseResult = BlueprintSchema.safeParse(blueprint); + expect(parseResult.success).toBe(true); + }); + + it("should validate all SAC Transfer blueprints returned by createAllSacBlueprints", () => { + const blueprints = createAllSacBlueprints(); + expect(blueprints.length).toBeGreaterThan(0); + for (const blueprint of blueprints) { + const parseResult = BlueprintSchema.safeParse(blueprint); + expect(parseResult.success).toBe(true); + } + }); + + it("should validate a single SAC Mint/Burn blueprint", () => { + const blueprint = createSacMintBurnBlueprint("CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"); + const parseResult = BlueprintSchema.safeParse(blueprint); + expect(parseResult.success).toBe(true); + }); + }); + + describe("Schema Boundaries", () => { + it("should allow a valid blueprint object", () => { + const valid = { + contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + contractName: "Test Contract", + translate: () => null, + }; + expect(BlueprintSchema.safeParse(valid).success).toBe(true); + }); + + it("should allow optional fields", () => { + const valid = { + contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + contractName: "Test Contract", + matches: () => true, + translate: () => null, + validFromLedger: 100, + version: "v2", + }; + expect(BlueprintSchema.safeParse(valid).success).toBe(true); + }); + + it("should reject contractId with incorrect length", () => { + const invalid = { + contractId: "CDLZFC3SYJYDZT7K6", // too short + contractName: "Test Contract", + translate: () => null, + }; + const result = BlueprintSchema.safeParse(invalid); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain("contractId must be a valid 56-to-58-character"); + } + }); + + it("should reject contractId that does not start with C", () => { + const invalid = { + contractId: "GDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", // starts with G + contractName: "Test Contract", + translate: () => null, + }; + const result = BlueprintSchema.safeParse(invalid); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain("contractId must be a valid 56-to-58-character"); + } + }); + + it("should reject empty contractName", () => { + const invalid = { + contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + contractName: "", + translate: () => null, + }; + const result = BlueprintSchema.safeParse(invalid); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toContain("contractName"); + } + }); + + it("should reject missing translate function", () => { + const invalid = { + contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + contractName: "Test Contract", + }; + const result = BlueprintSchema.safeParse(invalid); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toContain("translate"); + } + }); + + it("should reject invalid translate types", () => { + const invalid = { + contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + contractName: "Test Contract", + translate: "not a function", + }; + const result = BlueprintSchema.safeParse(invalid); + expect(result.success).toBe(false); + }); + + it("should reject negative validFromLedger", () => { + const invalid = { + contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + contractName: "Test Contract", + translate: () => null, + validFromLedger: -1, + }; + const result = BlueprintSchema.safeParse(invalid); + expect(result.success).toBe(false); + }); + + it("should reject non-integer validFromLedger", () => { + const invalid = { + contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + contractName: "Test Contract", + translate: () => null, + validFromLedger: 12.34, + }; + const result = BlueprintSchema.safeParse(invalid); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/lib/translator/blueprint.schema.ts b/lib/translator/blueprint.schema.ts new file mode 100644 index 0000000..1cf6d72 --- /dev/null +++ b/lib/translator/blueprint.schema.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; + +/** + * Zod Schema for RawEvent (input to matches and translate functions). + */ +export const RawEventSchema = z.object({ + id: z.string(), + contractId: z.string().regex(/^C[A-Z0-9]{55,57}$/i, { + message: "contractId must be a valid 56-to-58-character Stellar contract ID starting with C", + }), + topics: z.array(z.string()), + data: z.string(), + ledger: z.number().int().nonnegative(), + timestamp: z.number().int().nonnegative(), + txHash: z.string(), +}); + +/** + * Zod Schema for supported languages. + */ +export const LanguageSchema = z.union([ + z.literal("en"), + z.literal("es"), + z.literal("fr"), + z.literal("zh"), +]); + +/** + * Zod Schema for TranslationResult (return type of translate function). + */ +export const TranslationResultSchema = z.object({ + description: z.string().min(1, { message: "description must be a non-empty string" }), + eventType: z.string().min(1, { message: "eventType must be a non-empty string" }), +}); + +/** + * Zod Schema for a TranslationBlueprint and VersionedTranslationBlueprint. + */ +export const BlueprintSchema = z.object({ + contractId: z.string().regex(/^C[A-Z0-9]{55,57}$/i, { + message: "contractId must be a valid 56-to-58-character Stellar contract ID starting with C", + }), + contractName: z.string().min(1, { + message: "contractName must be a non-empty string", + }), + matches: z.function().optional(), + translate: z.function(), + validFromLedger: z.number().int().nonnegative().optional(), + version: z.string().min(1).optional(), +}); diff --git a/package-lock.json b/package-lock.json index 2c624ba..b3de60e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -260,35 +260,74 @@ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" ], "license": "MIT-0", "engines": { "node": ">=20.19.0" } }, - "node_modules/@csstools/css-calc": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", - "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" ], "license": "MIT", "engines": { @@ -327,20 +366,28 @@ "@csstools/css-tokenizer": "^4.0.0" } }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" ], "license": "MIT", "engines": { @@ -350,20 +397,17 @@ "@csstools/css-tokenizer": "^4.0.0" } }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", - "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" ], "license": "MIT-0", "peerDependencies": { @@ -375,26 +419,30 @@ } } }, - "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" ], "license": "MIT", "engines": { "node": ">=20.19.0" } }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -407,6 +455,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], "node_modules/@emnapi/runtime": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", @@ -418,6 +473,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -14269,6 +14331,15 @@ "resolved": "https://registry.npmjs.org/zenscroll/-/zenscroll-4.0.2.tgz", "integrity": "sha512-jEA1znR7b4C/NnaycInCU6h/d15ZzCd1jmsruqOKnZP6WXQSMH3W2GL+OXbkruslU4h+Tzuos0HdswzRUk/Vgg==", "license": "Unlicense" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/scripts/validate-blueprints.ts b/scripts/validate-blueprints.ts new file mode 100644 index 0000000..80f4571 --- /dev/null +++ b/scripts/validate-blueprints.ts @@ -0,0 +1,246 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { BlueprintSchema } from "../lib/translator/blueprint.schema"; +import { ZodError } from "zod"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ROOT = path.resolve(__dirname, ".."); +const BLUEPRINTS_DIR = path.join(ROOT, "lib", "translator", "blueprints"); + +interface ValidationError { + file: string; + exportName?: string; + error: string; +} + +const errors: ValidationError[] = []; + +/** + * Validates a blueprint object against the Zod schema. + */ +function validateBlueprintObject(obj: unknown, file: string, exportName?: string): boolean { + try { + BlueprintSchema.parse(obj); + return true; + } catch (err) { + if (err instanceof ZodError) { + for (const issue of err.issues) { + errors.push({ + file, + exportName, + error: `Path "${issue.path.join(".")}": ${issue.message}`, + }); + } + } else { + errors.push({ + file, + exportName, + error: String(err), + }); + } + return false; + } +} + +/** + * Determines if a value looks like a blueprint object (has key properties). + */ +function looksLikeBlueprint(val: unknown): boolean { + if (val && typeof val === "object" && !Array.isArray(val)) { + const obj = val as Record; + return typeof obj.contractId === "string" || typeof obj.contractName === "string"; + } + return false; +} + +/** + * Validates a JSON blueprint file. + */ +function validateJsonFile(filePath: string): void { + const relPath = path.relative(ROOT, filePath); + try { + const content = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(content); + + if (Array.isArray(parsed)) { + if (parsed.length === 0) { + errors.push({ + file: relPath, + error: "JSON file contains an empty array", + }); + return; + } + parsed.forEach((item, index) => { + validateBlueprintObject(item, relPath, `index ${index}`); + }); + } else if (looksLikeBlueprint(parsed)) { + validateBlueprintObject(parsed, relPath); + } else { + errors.push({ + file: relPath, + error: "JSON file does not contain a valid blueprint structure (object or array of objects)", + }); + } + } catch (err) { + errors.push({ + file: relPath, + error: `Failed to read or parse JSON file: ${String(err)}`, + }); + } +} + +/** + * Validates a TS/JS blueprint file. + */ +async function validateTsJsFile(filePath: string): Promise { + const relPath = path.relative(ROOT, filePath); + try { + // Dynamically import the module + const mod = await import(filePath); + const exports = Object.keys(mod); + + if (exports.length === 0) { + errors.push({ + file: relPath, + error: "Module has no exports. A blueprint file must export blueprint definitions or factory functions.", + }); + return; + } + + for (const key of exports) { + const val = mod[key]; + + if (typeof val === "function") { + // Factory function validation + const functionName = val.name || key; + const isFactory = key.startsWith("create") || key.includes("Blueprint"); + + if (isFactory) { + try { + let result: unknown; + if (val.length === 0) { + result = val(); + } else if (val.length === 1) { + // Call with mock contract ID + result = val("CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"); + } else { + errors.push({ + file: relPath, + exportName: key, + error: `Factory function has arity ${val.length} which is not automatically testable (expected 0 or 1 parameter).`, + }); + continue; + } + + if (Array.isArray(result)) { + if (result.length === 0) { + errors.push({ + file: relPath, + exportName: key, + error: "Factory function returned an empty array of blueprints", + }); + } + result.forEach((item, index) => { + validateBlueprintObject(item, relPath, `${functionName}() -> index ${index}`); + }); + } else if (looksLikeBlueprint(result)) { + validateBlueprintObject(result, relPath, `${functionName}()`); + } else { + errors.push({ + file: relPath, + exportName: key, + error: `Factory function returned a value that does not look like a blueprint: ${typeof result}`, + }); + } + } catch (err) { + errors.push({ + file: relPath, + exportName: key, + error: `Factory function crashed during validation: ${String(err)}`, + }); + } + } + } else if (Array.isArray(val)) { + if (val.some(looksLikeBlueprint)) { + val.forEach((item, index) => { + validateBlueprintObject(item, relPath, `${key}[${index}]`); + }); + } + } else if (looksLikeBlueprint(val)) { + validateBlueprintObject(val, relPath, key); + } + } + } catch (err) { + errors.push({ + file: relPath, + error: `Failed to import module: ${String(err)}`, + }); + } +} + +/** + * Main execution. + */ +async function main(): Promise { + console.log("๐Ÿ” Scanning and validating blueprints in /lib/translator/blueprints/...\n"); + + if (!fs.existsSync(BLUEPRINTS_DIR)) { + console.error(`โŒ ERROR: Blueprints directory not found: ${BLUEPRINTS_DIR}`); + process.exit(1); + } + + const files = fs.readdirSync(BLUEPRINTS_DIR); + const blueprintFiles = files.filter(f => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".json")); + + if (blueprintFiles.length === 0) { + console.log("โš ๏ธ No blueprint files found to validate."); + process.exit(0); + } + + for (const file of blueprintFiles) { + const fullPath = path.join(BLUEPRINTS_DIR, file); + if (file.endsWith(".json")) { + validateJsonFile(fullPath); + } else { + await validateTsJsFile(fullPath); + } + } + + if (errors.length > 0) { + console.error("=".repeat(80)); + console.error("โŒ BLUEPRINT SCHEMA VALIDATION FAILED"); + console.error("=".repeat(80)); + + // Group errors by file + const grouped = new Map(); + for (const err of errors) { + const list = grouped.get(err.file) || []; + list.push(err); + grouped.set(err.file, list); + } + + for (const [file, fileErrors] of grouped) { + console.error(`\n๐Ÿ“„ File: ${file}`); + for (const err of fileErrors) { + const prefix = err.exportName ? `[Export: ${err.exportName}] ` : ""; + console.error(` - ${prefix}${err.error}`); + } + } + + console.error(`\nTotal: ${errors.length} validation error(s) found.\n`); + process.exit(1); + } else { + console.log("=".repeat(80)); + console.log("โœ… ALL BLUEPRINTS VALIDATED SUCCESSFULLY"); + console.log("=".repeat(80)); + console.log(`\nSuccessfully validated ${blueprintFiles.length} blueprint file(s).\n`); + process.exit(0); + } +} + +main().catch(err => { + console.error("Fatal error during blueprint validation:", err); + process.exit(1); +});