From 3e8b0fcf8970eeeb5eea9b7e2fc1a395170e7191 Mon Sep 17 00:00:00 2001 From: mertcano <35747700+mertcano@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:34:29 +0300 Subject: [PATCH 1/2] Fix: Harden metadata validation, error propagation, and CI automation ### Description This PR addresses several reliability, security, and accuracy defects identified during the workspace-wide audit[cite: 63]. It improves file path validation, ensures robust error propagation to GitHub comments, prevents false-green validation states, and hardens the release automation workflows[cite: 63]. ### Key Changes * **Validation & Path Integrity (`src/scripts/validate-fs.ts`, `src/scripts/validate-entity.ts`):** * Replaced platform-dependent path splitting with normalized, exact-segment validation enforcing the `{entityType}/{identifier}/{fileName}` structure[cite: 63]. * Scoped changed-file detection specifically to the selected entity and added explicit errors for missing valid entity files[cite: 63]. * Replaced eager registry-address reads with lazy input-name resolution so optional validation does not strictly depend on unrelated inputs[cite: 63]. * **Error Propagation & Source Maps (`src/scripts/validate-*.ts`, `src/main.ts`, `src/scripts/github.ts`):** * File-read, JSON-parse, image-decode, and RPC failures now correctly post an English GitHub comment/review before throwing an error[cite: 63]. * Replaced `any` with a typed source-map shape in `normalizeErrors` and used nullish coalescing to prevent `|| 1` from incorrectly converting source-map line 0, preserving exact zero-based source positions[cite: 63]. * Modified `Promise.allSettled()` handling to normalize non-`Error` rejection values so they are no longer silently discarded, ensuring every thrown value reaches `core.setFailed()`[cite: 63]. * Added `viem.isAddress()` checks for reward addresses prior to executing contract calls[cite: 63]. * **Release Automation (`.github/workflows/full-info.yml`, `extract-metadata.mjs`):** * Removed ad-hoc runtime dependency installations (`npm install`) and TypeScript compilation (`npx`)[cite: 63]. * The metadata generator was rewritten as a dependency-free standard-library Node.js `.mjs` module that the workflow now executes directly[cite: 63, 66, 67]. * **Workflow Least Privilege (`.github/workflows/Validate_Pull_Request.yml`):** * Removed the potentially unsafe `pull_request_target` trigger[cite: 63]. The PR-agent action now strictly runs only for same-repository pull requests (`github.event.pull_request.head.repo.full_name == github.repository`) to safeguard the OpenAI secret boundary[cite: 63, 68]. * **Documentation (`README.md`, `.github/copilot-instructions.md`):** * Updated documentation to reflect the bundled `dist/index.cjs` distribution, enforce space-separated file inputs, and include `adapters`[cite: 63, 64, 65]. * Clarified that token identifiers are no longer checked against a registry, distinguishing strictly between on-chain identifiers and actively configured registries[cite: 63, 64, 65]. --- README.md | 335 +++++++++++++++-------------- src/main.ts | 81 +++---- src/scripts/github.ts | 193 +++++++++-------- src/scripts/messages.ts | 91 ++++---- src/scripts/validate-collateral.ts | 87 +++++--- src/scripts/validate-entity.ts | 20 +- src/scripts/validate-fs.ts | 211 +++++++++--------- src/scripts/validate-logo.ts | 72 ++++--- src/scripts/validate-metadata.ts | 74 +++++-- src/scripts/validate-rewards.ts | 215 +++++++++--------- 10 files changed, 739 insertions(+), 640 deletions(-) diff --git a/README.md b/README.md index 39bd261..6daceb3 100644 --- a/README.md +++ b/README.md @@ -1,166 +1,169 @@ -## Symbiotic Metadata Validator - -A GitHub Action that validates metadata changes for Symbiotic ecosystem entities (vaults, operators, networks, tokens, curators, points). Enforces file structure, JSON schema compliance, logo requirements, and on-chain registry state validation via RPC calls. - -The action is distributed as a bundled single-file Node.js application (`dist/index.js`) using `tsup`. - -### Entities Metadata Structure - -Entities are organized as: `{entityType}/{identifier}/{info.json,logo.png}` - -**Entity types:** - -- **On-chain** (require registry validation): `vaults`, `operators`, `networks`, `tokens` -- **Off-chain** (no registry check): `points`, `curators` - -### Architecture - -**Entry point**: `src/main.ts` orchestrates all validation steps in parallel - -**Key modules**: - -- [`src/scripts/validate-fs.ts`](src/scripts/validate-fs.ts) - File system structure validation -- [`src/scripts/validate-entity.ts`](src/scripts/validate-entity.ts) - On-chain registry checks -- [`src/scripts/validate-metadata.ts`](src/scripts/validate-metadata.ts) - JSON schema validation with line-number error reporting -- [`src/scripts/validate-logo.ts`](src/scripts/validate-logo.ts) - Image validation (256x256 PNG, <100KB) -- [`src/scripts/validate-collateral.ts`](src/scripts/validate-collateral.ts) - Vault collateral token validation -- [`src/scripts/validate-rewards.ts`](src/scripts/validate-rewards.ts) - Vault rewards contract validation -- [`src/scripts/blockchain.ts`](src/scripts/blockchain.ts) - Ethereum client (viem) for RPC calls -- [`src/scripts/github.ts`](src/scripts/github.ts) - PR comment and review posting -- [`src/scripts/messages.ts`](src/scripts/messages.ts) - Error message templates - -**Technologies**: TypeScript, tsup (bundler), viem (Ethereum client), ajv (JSON schema), image-js (logo validation), json-source-map (error line numbers) - -### Validation Pipeline - -All validations run in parallel via `Promise.allSettled()` to collect all errors before failing: - -1. **File System** - Validates directory structure, ensures one entity per PR -2. **Entity Registry** - Checks on-chain registry using `isEntity()` contract call (skipped for off-chain entities) -3. **Metadata Schema** - Validates `info.json` against type-specific schemas -4. **Logo** - Enforces 256x256 PNG, max 100KB -5. **Collateral** - For vaults only, validates collateral token exists in repo -6. **Rewards** - For vaults with rewards, validates contracts via rewards-factory registry - -Validation skips remaining steps if entity is deleted. - -### Inputs - -- `files` (required): Comma/space-separated list of changed files to validate. -- `issue` (required): Issue/PR number to comment on (e.g., `${{ github.event.pull_request.number }}`). -- `token` (required): `GITHUB_TOKEN` for commenting. -- `vault-registry` (required): Vaults registry contract address. -- `operator-registry` (required): Operators registry contract address. -- `network-registry` (required): Networks registry contract address. -- `chain-id` (required): Chain ID for on-chain validation. -- `rpc-url` (optional): RPC URL for on-chain validation. -- `upstream-checkout-path` (optional): Path to upstream repo checkout. -- `rewards-factory` (optional): Rewards factory contract address. - -### Outputs - -- `error`: Validation error message (empty if successful). - -### Usage - -Integrate into your workflow to validate metadata changes on pull requests: - -```yaml -name: Validate metadata -on: - pull_request: - types: [opened, synchronize, reopened] - -jobs: - validate: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # Collect changed files (example using tj-actions/changed-files) - - uses: tj-actions/changed-files@v45 - id: changes - - - name: Run validator - uses: symbioticfi/metadata-validation-scripts@main - with: - files: ${{ steps.changes.outputs.all_changed_files }} - issue: ${{ github.event.pull_request.number }} - token: ${{ secrets.GITHUB_TOKEN }} - chain-id: 1 - rpc-url: ${{ secrets.RPC_URL }} - vault-registry: "0x..." - operator-registry: "0x..." - network-registry: "0x..." - rewards-factory: "0x..." -``` - -### Local Development - -The `metadata/` directory contains test files for local validation. - -1. **Add test metadata files** to `metadata/` following the standard structure: - - ``` - metadata/ - points/symbiotic/info.json - vaults/0xabc.../info.json - ``` - -2. **Configure environment**: - - ```bash - cp .env.template .env - ``` - - Edit `.env` with required inputs (**files must be space-separated**): - - For off-chain entities: - - ```bash - INPUT_FILES="points/symbiotic/info.json points/symbiotic/logo.png" - ``` - - For on-chain entities: - - ```bash - INPUT_FILES="vaults/0xabc.../info.json vaults/0xabc.../logo.png" - INPUT_CHAIN-ID="560048" # Hoodi testnet - INPUT_VAULT-REGISTRY="0x407a039d94948484d356efb765b3c74382a050b4" - INPUT_OPERATOR-REGISTRY="0x6f75a4fff97326a00e52662d82ea4fde86a2c548" - INPUT_NETWORK-REGISTRY="0x7d03b7343bf8d5cec7c0c27ece084a20113d15c9" - ``` - -3. **Run the action**: - ```bash - npm run local-action - ``` - -When `LOCAL_ACTION_RUN=true`, PR comments are logged to console instead of posting to GitHub. - -**Watch mode**: Use `npm run package:watch` for automatic rebundling during development. - -### Build and Release - -1. **Bundle the action**: - - ```bash - npm run bundle - ``` - -2. **Commit and push** the bundled distribution: - - ```bash - git add dist/ - git commit -m "Bundle changes" - git push origin - ``` - -3. **Create a PR** to merge into `main` (protected branch) - -**Requirements**: Node.js 22+ +## Symbiotic Metadata Validator + +A GitHub Action that validates metadata changes for Symbiotic ecosystem entities (vaults, operators, networks, tokens, adapters, curators, points). It enforces file structure, JSON schema compliance, logo requirements, and on-chain registry state validation via RPC calls. + +The action is distributed as a bundled single-file Node.js application (`dist/index.cjs`) using `tsup`. + +### Entities Metadata Structure + +Entities are organized as: `{entityType}/{identifier}/{info.json,logo.png}` + +**Entity types:** + +- **On-chain identifiers**: `vaults`, `operators`, `networks`, `tokens`, `adapters` +- **Registry validation**: `vaults`, `operators`, `networks`, `adapters` +- **Off-chain identifiers** (no registry check): `points`, `curators` + +### Architecture + +**Entry point**: `src/main.ts` orchestrates all validation steps in parallel + +**Key modules**: + +- [`src/scripts/validate-fs.ts`](src/scripts/validate-fs.ts) - File system structure validation +- [`src/scripts/validate-entity.ts`](src/scripts/validate-entity.ts) - On-chain registry checks +- [`src/scripts/validate-metadata.ts`](src/scripts/validate-metadata.ts) - JSON schema validation with line-number error reporting +- [`src/scripts/validate-logo.ts`](src/scripts/validate-logo.ts) - Image validation (256x256 PNG, <100KB) +- [`src/scripts/validate-collateral.ts`](src/scripts/validate-collateral.ts) - Vault collateral token validation +- [`src/scripts/validate-rewards.ts`](src/scripts/validate-rewards.ts) - Vault rewards contract validation +- [`src/scripts/blockchain.ts`](src/scripts/blockchain.ts) - Ethereum client (viem) for RPC calls +- [`src/scripts/github.ts`](src/scripts/github.ts) - PR comment and review posting +- [`src/scripts/messages.ts`](src/scripts/messages.ts) - Error message templates + +**Technologies**: TypeScript, tsup (bundler), viem (Ethereum client), ajv (JSON schema), image-js (logo validation), json-source-map (error line numbers) + +### Validation Pipeline + +All validations run in parallel via `Promise.allSettled()` to collect all errors before failing: + +1. **File System** - Validates directory structure, ensures one entity per PR +2. **Entity Registry** - Checks on-chain registry using `isEntity()` contract call (skipped for off-chain entities) +3. **Metadata Schema** - Validates `info.json` against type-specific schemas +4. **Logo** - Enforces 256x256 PNG, max 100KB +5. **Collateral** - For vaults only, validates collateral token exists in repo +6. **Rewards** - For vaults with rewards, validates contracts via rewards-factory registry + +Validation skips remaining steps if entity is deleted. + +### Inputs + +- `files` (required): Space-separated list of changed files to validate. + +- `issue` (required): Issue/PR number to comment on (e.g., `${{ github.event.pull_request.number }}`). +- `token` (required): `GITHUB_TOKEN` for commenting. +- `vault-registry` (required): Vaults registry contract address. +- `operator-registry` (required): Operators registry contract address. +- `network-registry` (required): Networks registry contract address. +- `chain-id` (required): Chain ID for on-chain validation. +- `rpc-url` (optional): RPC URL for on-chain validation. +- `upstream-checkout-path` (optional): Path to upstream repo checkout. +- `rewards-factory` (optional): Rewards factory contract address. + +### Outputs + +- `error`: Validation error message (empty if successful). + +### Usage + +Integrate into your workflow to validate metadata changes on pull requests: + +```yaml +name: Validate metadata +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + validate: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Collect changed files (example using tj-actions/changed-files) + - uses: tj-actions/changed-files@v45 + id: changes + + - name: Run validator + uses: symbioticfi/metadata-validation-scripts@a21a6f01a2b6c5fda769e0c7c0b55d79172568f6 # pin@main (audited commit) + + with: + files: ${{ steps.changes.outputs.all_changed_files }} + issue: ${{ github.event.pull_request.number }} + token: ${{ secrets.GITHUB_TOKEN }} + chain-id: 1 + rpc-url: ${{ secrets.RPC_URL }} + vault-registry: "0x..." + operator-registry: "0x..." + network-registry: "0x..." + rewards-factory: "0x..." +``` + +### Local Development + +The `metadata/` directory contains test files for local validation. + +1. **Add test metadata files** to `metadata/` following the standard structure: + + ``` + metadata/ + points/symbiotic/info.json + vaults/0xabc.../info.json + ``` + +2. **Configure environment**: + + ```bash + cp .env.template .env + ``` + + Edit `.env` with required inputs (**files must be space-separated**): + + For off-chain entities: + + ```bash + INPUT_FILES="points/symbiotic/info.json points/symbiotic/logo.png" + ``` + + For on-chain entities: + + ```bash + INPUT_FILES="vaults/0xabc.../info.json vaults/0xabc.../logo.png" + INPUT_CHAIN-ID="560048" # Hoodi testnet + INPUT_VAULT-REGISTRY="0x407a039d94948484d356efb765b3c74382a050b4" + INPUT_OPERATOR-REGISTRY="0x6f75a4fff97326a00e52662d82ea4fde86a2c548" + INPUT_NETWORK-REGISTRY="0x7d03b7343bf8d5cec7c0c27ece084a20113d15c9" + ``` + +3. **Run the action**: + ```bash + npm run local-action + ``` + +When `LOCAL_ACTION_RUN=true`, PR comments are logged to console instead of posting to GitHub. + +**Watch mode**: Use `npm run package:watch` for automatic rebundling during development. + +### Build and Release + +1. **Bundle the action**: + + ```bash + npm run bundle + ``` + +2. **Commit and push** the bundled distribution: + + ```bash + git add dist/ + git commit -m "Bundle changes" + git push origin + ``` + +3. **Create a PR** to merge into `main` (protected branch) + +**Requirements**: Node.js 22+ diff --git a/src/main.ts b/src/main.ts index c18bcb1..1426313 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,42 +1,43 @@ -import { getInput, run as runAction } from "./scripts/github"; -import { validateCollateral } from "./scripts/validate-collateral"; -import { validateEntity } from "./scripts/validate-entity"; -import { validateFs } from "./scripts/validate-fs.js"; -import { validateLogo } from "./scripts/validate-logo.js"; -import { validateMetadata } from "./scripts/validate-metadata.js"; -import { validateRewards } from "./scripts/validate-rewards"; - -const main = async () => { - const inputFiles = getInput("files", { - required: true, - trimWhitespace: true, - }); - - const files = inputFiles.split(" ").filter(Boolean); - const entity = await validateFs(files); - - /** - * Skip the rest of the validation if the entity is deleted - */ - if (entity.isDeleted) { - return; - } - - const result = await Promise.allSettled([ - validateEntity(entity), - validateMetadata(entity), - validateLogo(entity), - validateCollateral(entity), - validateRewards(entity), - ]); - +import { getInput, run as runAction } from "./scripts/github"; +import { validateCollateral } from "./scripts/validate-collateral"; +import { validateEntity } from "./scripts/validate-entity"; +import { validateFs } from "./scripts/validate-fs.js"; +import { validateLogo } from "./scripts/validate-logo.js"; +import { validateMetadata } from "./scripts/validate-metadata.js"; +import { validateRewards } from "./scripts/validate-rewards"; + +const main = async () => { + const inputFiles = getInput("files", { + required: true, + trimWhitespace: true, + }); + + const files = inputFiles.split(" ").filter(Boolean); + const entity = await validateFs(files); + + /** + * Skip the rest of the validation if the entity is deleted + */ + if (entity.isDeleted) { + return; + } + + const result = await Promise.allSettled([ + validateEntity(entity), + validateMetadata(entity), + validateLogo(entity), + validateCollateral(entity), + validateRewards(entity), + ]); + const errors = result - .map((r) => r && r.status === "rejected" && r.reason.message) + .filter((validation): validation is PromiseRejectedResult => validation.status === "rejected") + .map(({ reason }) => (reason instanceof Error ? reason.message : String(reason))) .filter(Boolean); - - if (errors.length) { - throw new Error(`Validation failed:\n${errors.map((e) => `- ${e}`).join("\n")}`); - } -}; - -export const run = () => runAction(main); + + if (errors.length) { + throw new Error(`Validation failed:\n${errors.map((e) => `- ${e}`).join("\n")}`); + } +}; + +export const run = () => runAction(main); diff --git a/src/scripts/github.ts b/src/scripts/github.ts index cf7927c..81dbf3a 100644 --- a/src/scripts/github.ts +++ b/src/scripts/github.ts @@ -1,99 +1,98 @@ -import * as core from "@actions/core"; -import * as github from "@actions/github"; - -export type ReviewComment = { - path: string; - body: string; - line?: number; - position?: number; -}; - -export type Review = { - body?: string; - comments?: ReviewComment[]; -}; - -let octokit: ReturnType; -const isLocalRun = process.env.LOCAL_ACTION_RUN === "true"; - -export const repoPath = [github.context.repo.owner, github.context.repo.repo].join("/"); -export const getInput = core.getInput; - -const getIssueNumber = () => { - const inputNumber = getInput("issue", { - required: true, - trimWhitespace: true, - }); - - return +inputNumber; -}; - -const getToken = () => { - const token = getInput("token", { - required: true, - trimWhitespace: true, - }); - - return token; -}; - -const getOctokit = () => { - const token = getToken(); - - if (!token) { - throw new Error("GITHUB_TOKEN env variable is required"); - } - - octokit = octokit || github.getOctokit(token); - - return octokit; -}; - -export const addComment = async (body: string) => { - const { owner, repo } = github.context.issue; - - if (isLocalRun) { - console.group("Add Comment"); - console.log("Add comment:", body); - console.groupEnd(); - - return; - } - - await getOctokit().rest.issues.createComment({ - owner, - repo, - issue_number: getIssueNumber(), - body, - }); -}; - -export const addReview = async (review: Review) => { - const { owner, repo } = github.context.issue; - - if (isLocalRun) { - console.group("Add review"); - console.log(review); - console.groupEnd(); - - return; - } - - await getOctokit().rest.pulls.createReview({ - owner, - repo, - pull_number: getIssueNumber(), - event: "COMMENT", - ...review, - }); -}; - -export const run = async (command: () => Promise) => { - try { - await command(); +import * as core from "@actions/core"; +import * as github from "@actions/github"; + +export type ReviewComment = { + path: string; + body: string; + line?: number; + position?: number; +}; + +export type Review = { + body?: string; + comments?: ReviewComment[]; +}; + +let octokit: ReturnType; +const isLocalRun = process.env.LOCAL_ACTION_RUN === "true"; + +export const repoPath = [github.context.repo.owner, github.context.repo.repo].join("/"); +export const getInput = core.getInput; + +const getIssueNumber = () => { + const inputNumber = getInput("issue", { + required: true, + trimWhitespace: true, + }); + + return +inputNumber; +}; + +const getToken = () => { + const token = getInput("token", { + required: true, + trimWhitespace: true, + }); + + return token; +}; + +const getOctokit = () => { + const token = getToken(); + + if (!token) { + throw new Error("GITHUB_TOKEN env variable is required"); + } + + octokit = octokit || github.getOctokit(token); + + return octokit; +}; + +export const addComment = async (body: string) => { + const { owner, repo } = github.context.issue; + + if (isLocalRun) { + console.group("Add Comment"); + console.log("Add comment:", body); + console.groupEnd(); + + return; + } + + await getOctokit().rest.issues.createComment({ + owner, + repo, + issue_number: getIssueNumber(), + body, + }); +}; + +export const addReview = async (review: Review) => { + const { owner, repo } = github.context.issue; + + if (isLocalRun) { + console.group("Add review"); + console.log(review); + console.groupEnd(); + + return; + } + + await getOctokit().rest.pulls.createReview({ + owner, + repo, + pull_number: getIssueNumber(), + event: "COMMENT", + ...review, + }); +}; + +export const run = async (command: () => Promise) => { + try { + await command(); } catch (error) { - if (error instanceof Error) { - core.setFailed(error.message); - } + const message = error instanceof Error ? error.message : String(error); + core.setFailed(message); } -}; +}; diff --git a/src/scripts/messages.ts b/src/scripts/messages.ts index 7fce202..7974362 100644 --- a/src/scripts/messages.ts +++ b/src/scripts/messages.ts @@ -1,14 +1,14 @@ -import { repoPath } from "./github"; - -const contributionGuidelines = `Please, follow the [contribution guidelines](https://github.com/${repoPath}/blob/main/README.md).`; - -export const notAllowedChanges = (files: string[]) => - `We detected changes in the pull request that are not allowed. ${contributionGuidelines} - - **Not allowed files:** - ${files.map((file) => `- ${file}`).join("\n")} -`; - +import { repoPath } from "./github"; + +const contributionGuidelines = `Please, follow the [contribution guidelines](https://github.com/${repoPath}/blob/main/README.md).`; + +export const notAllowedChanges = (files: string[]) => + `We detected changes in the pull request that are not allowed. ${contributionGuidelines} + + **Not allowed files:** + ${files.map((file) => `- ${file}`).join("\n")} +`; + export const onlyOneEntityPerPr = (dirs: string[]) => `It is not allowed to change more than one entity in a single pull request. ${contributionGuidelines} @@ -16,41 +16,50 @@ export const onlyOneEntityPerPr = (dirs: string[]) => ${dirs.map((file) => `- ${file}`).join("\n")} `; -export const noInfoJson = (dir: string) => - `The entity folder \`${dir}\` should have \`info.json\` file. ${contributionGuidelines}`; +export const noEntityChanges = () => + `No valid entity metadata files were found in the pull request. ${contributionGuidelines}`; +export const noInfoJson = (dir: string) => + `The entity folder \`${dir}\` should have \`info.json\` file. ${contributionGuidelines}`; + export const invalidInfoJson = () => `The \`info.json\` file is invalid. ${contributionGuidelines}`; -export const invalidLogo = (path: string, errors: string[]) => - `The logo image is invalid. ${contributionGuidelines} +export const unreadableInfoJson = (path: string) => + `The \`info.json\` file at \`${path}\` could not be read. ${contributionGuidelines}`; - **Unmet requirements:** - ${errors.map((error) => `- ${error}`).join("\n")} -`; - -export const notRegisteredEntity = ( - label: string, - address: string, - chain: string, - registryContract: string, -) => - `${label} \`${address}\` is not registered in ${label.toLowerCase()} registry on ${chain} network (registry address: \`${registryContract}\`). ${contributionGuidelines}`; - -export const invalidVault = (address: string, chain: string) => - `Contract \`${address}\` is not a valid Vault on ${chain} network. ${contributionGuidelines}`; - -export const noVaultTokenInfo = (tokenAddress: string) => - `Information for the vault collateral is not found in the repository. \nPlease, make sure info for token \`${tokenAddress}\` is present in this repository. If not, please create Pull Request for it first. ${contributionGuidelines}`; +export const invalidLogo = (path: string, errors: string[]) => + `The logo image is invalid. ${contributionGuidelines} + + **Unmet requirements:** + ${errors.map((error) => `- ${error}`).join("\n")} +`; + +export const notRegisteredEntity = ( + label: string, + address: string, + chain: string, + registryContract: string, +) => + `${label} \`${address}\` is not registered in ${label.toLowerCase()} registry on ${chain} network (registry address: \`${registryContract}\`). ${contributionGuidelines}`; + +export const invalidVault = (address: string, chain: string) => + `Contract \`${address}\` is not a valid Vault on ${chain} network. ${contributionGuidelines}`; + +export const noVaultTokenInfo = (tokenAddress: string) => + `Information for the vault collateral is not found in the repository. \nPlease, make sure info for token \`${tokenAddress}\` is present in this repository. If not, please create Pull Request for it first. ${contributionGuidelines}`; + +export const invalidRewardsAddress = (address: string) => + `Rewards contract address \`${address}\` is not a valid Ethereum address. ${contributionGuidelines}`; export const invalidRewardsType = (address: string, type: string) => `Rewards contract \`${address}\` has invalid type \`${type}\`. Expected type is \`defaultStakingRewardsV2\`. ${contributionGuidelines}`; -export const rewardsNotFromFactory = (address: string, factoryAddress: string, chain: string) => - `Rewards contract \`${address}\` is not deployed by the rewards factory \`${factoryAddress}\` on ${chain} network. ${contributionGuidelines}`; - -export const rewardsVaultMismatch = ( - rewardsAddress: string, - actualVault: string, - expectedVault: string, -) => - `Rewards contract \`${rewardsAddress}\` is associated with vault \`${actualVault}\`, but expected \`${expectedVault}\`. ${contributionGuidelines}`; +export const rewardsNotFromFactory = (address: string, factoryAddress: string, chain: string) => + `Rewards contract \`${address}\` is not deployed by the rewards factory \`${factoryAddress}\` on ${chain} network. ${contributionGuidelines}`; + +export const rewardsVaultMismatch = ( + rewardsAddress: string, + actualVault: string, + expectedVault: string, +) => + `Rewards contract \`${rewardsAddress}\` is associated with vault \`${actualVault}\`, but expected \`${expectedVault}\`. ${contributionGuidelines}`; diff --git a/src/scripts/validate-collateral.ts b/src/scripts/validate-collateral.ts index ca211cc..1be59fc 100644 --- a/src/scripts/validate-collateral.ts +++ b/src/scripts/validate-collateral.ts @@ -1,45 +1,62 @@ -import fs from "fs/promises"; -import * as path from "path"; -import { Address } from "viem"; - -import { createClient, getChain } from "./blockchain"; -import { getVaultTokenAddress } from "./get-vault-token-address"; -import * as github from "./github"; -import * as messages from "./messages"; -import { Entity } from "./validate-fs"; +import fs from "fs/promises"; +import * as path from "path"; +import { Address } from "viem"; + +import { createClient, getChain } from "./blockchain"; +import { getVaultTokenAddress } from "./get-vault-token-address"; +import * as github from "./github"; +import * as messages from "./messages"; +import { Entity } from "./validate-fs"; + +export const validateCollateral = async ({ entityType, entityId: vaultAddress }: Entity) => { + if (entityType !== "vaults") { + return; + } + + const chain = getChain(); + const client = createClient(); + const upstreamDir = github.getInput("upstream-checkout-path", { + required: false, + }); + + let tokenAddress: Address | undefined; + try { + tokenAddress = await getVaultTokenAddress(client, vaultAddress as Address); + } catch (error) { + await github.addComment(messages.invalidVault(vaultAddress, chain.name)); -export const validateCollateral = async ({ entityType, entityId: vaultAddress }: Entity) => { - if (entityType !== "vaults") { - return; + throw new Error(`Failed to read vault collateral for \`${vaultAddress}\``, { cause: error }); } - const chain = getChain(); - const client = createClient(); - const upstreamDir = github.getInput("upstream-checkout-path", { - required: false, - }); - - const tokenAddress = await getVaultTokenAddress(client, vaultAddress as Address); - if (!tokenAddress) { - await github.addComment(messages.invalidVault(vaultAddress, chain.name)); + await github.addComment(messages.invalidVault(vaultAddress, chain.name)); + + throw new Error( + `Contract \`${vaultAddress}\` is not a valid Vault on ${chain.name} network.`, + ); + } + + const tokensDir = upstreamDir ? path.join(upstreamDir, "tokens") : "tokens"; + let dirItems: string[]; + try { + dirItems = await fs.readdir(tokensDir); + } catch (error) { + await github.addComment(messages.noVaultTokenInfo(tokenAddress)); - throw new Error( - `Contract \`${vaultAddress}\` is not a valid Vault on ${chain.name} network.`, - ); + throw new Error(`Unable to read the token metadata directory \`${tokensDir}\``, { + cause: error, + }); } - const tokensDir = upstreamDir ? path.join(upstreamDir, "tokens") : "tokens"; - const dirItems = await fs.readdir(tokensDir); const tokenInfoExists = dirItems.some( (item) => item.toLowerCase() === tokenAddress.toLowerCase(), ); - - if (!tokenInfoExists) { - await github.addComment(messages.noVaultTokenInfo(tokenAddress)); - - throw new Error( - `Information for the vault collateral \`${tokenAddress}\` is not found in the repository.`, - ); - } -}; + + if (!tokenInfoExists) { + await github.addComment(messages.noVaultTokenInfo(tokenAddress)); + + throw new Error( + `Information for the vault collateral \`${tokenAddress}\` is not found in the repository.`, + ); + } +}; diff --git a/src/scripts/validate-entity.ts b/src/scripts/validate-entity.ts index 4cbce66..5415028 100644 --- a/src/scripts/validate-entity.ts +++ b/src/scripts/validate-entity.ts @@ -6,7 +6,7 @@ import * as messages from "./messages"; import { Entity, EntityType } from "./validate-fs"; type EntityMeta = { - contract: string; + registryInput: string; label: string; }; @@ -23,22 +23,19 @@ const isEntityAbi = [ const entityMetaMap: Partial> = { vaults: { label: "Vault", - contract: github.getInput("vault-registry", { required: true }), + registryInput: "vault-registry", }, - networks: { label: "Network", - contract: github.getInput("network-registry", { required: true }), + registryInput: "network-registry", }, - operators: { label: "Operator", - contract: github.getInput("operator-registry", { required: true }), + registryInput: "operator-registry", }, - adapters: { label: "Adapter", - contract: github.getInput("adapter-registry", { required: true }), + registryInput: "adapter-registry", }, }; @@ -52,8 +49,9 @@ export const validateEntity = async ({ entityType, entityId }: Entity) => { const chain = getChain(); const client = createClient(); + const registryContract = github.getInput(entityMeta.registryInput, { required: true }); const isEntity = await client.readContract({ - address: entityMeta.contract as Address, + address: registryContract as Address, abi: isEntityAbi, functionName: "isEntity", args: [entityAddress], @@ -65,12 +63,12 @@ export const validateEntity = async ({ entityType, entityId }: Entity) => { entityMeta.label, entityAddress, chain.name, - entityMeta.contract, + registryContract, ), ); throw new Error( - `${entityMeta.label} \`${entityAddress}\` is not registered in ${entityMeta.label.toLowerCase()} registry on ${chain.name} network (registry address: \`${entityMeta.contract}\`)`, + `${entityMeta.label} \`${entityAddress}\` is not registered in ${entityMeta.label.toLowerCase()} registry on ${chain.name} network (registry address: \`${registryContract}\`)`, ); } }; diff --git a/src/scripts/validate-fs.ts b/src/scripts/validate-fs.ts index ab1ddfb..64afee9 100644 --- a/src/scripts/validate-fs.ts +++ b/src/scripts/validate-fs.ts @@ -1,39 +1,45 @@ -import fs from "fs/promises"; -import path from "path"; - -import * as github from "./github"; -import * as messages from "./messages"; - -const onChainTypes = ["vaults", "operators", "networks", "tokens", "adapters"] as const; -const offChainTypes = ["points", "curators"] as const; - -const allowedTypes = [...onChainTypes, ...offChainTypes]; -const allowedFiles = ["info.json", "logo.png"]; - -const addressRegex = /^0x[a-fA-F0-9]{40}$/; -const nameRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; - -type OnChainEntityType = (typeof onChainTypes)[number]; -export type EntityType = (typeof allowedTypes)[number]; -export type Entity = { - metadata?: string; - logo?: string; - isDeleted?: boolean; - entityId: string; - entityType: EntityType; -}; - -const isValidEntity = (entityType: string) => allowedTypes.includes(entityType as EntityType); - -export async function validateFs(changedFiles: string[]): Promise { - const notAllowed = new Set(); - const entityDirs = new Set(); - +import fs from "fs/promises"; +import path from "path"; + +import * as github from "./github"; +import * as messages from "./messages"; + +const onChainTypes = ["vaults", "operators", "networks", "tokens", "adapters"] as const; +const offChainTypes = ["points", "curators"] as const; + +const allowedTypes = [...onChainTypes, ...offChainTypes]; +const allowedFiles = ["info.json", "logo.png"]; + +const addressRegex = /^0x[a-fA-F0-9]{40}$/; +const nameRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +type OnChainEntityType = (typeof onChainTypes)[number]; +export type EntityType = (typeof allowedTypes)[number]; +export type Entity = { + metadata?: string; + logo?: string; + isDeleted?: boolean; + entityId: string; + entityType: EntityType; +}; + +const isValidEntity = (entityType: string) => allowedTypes.includes(entityType as EntityType); + +export async function validateFs(changedFiles: string[]): Promise { + const notAllowed = new Set(); + const entityDirs = new Set(); + for (const filePath of changedFiles) { - const dir = path.dirname(filePath); - const [type, identifier, fileName] = filePath.split(path.sep); - - if (!isValidEntity(type) || !allowedFiles.includes(fileName)) { + const normalizedFilePath = filePath.replaceAll("\\", "/"); + const segments = normalizedFilePath.split("/"); + const [type, identifier, fileName] = segments; + + if ( + segments.length !== 3 || + !isValidEntity(type) || + !identifier || + !allowedFiles.includes(fileName) + ) { notAllowed.add(filePath); continue; @@ -44,77 +50,88 @@ export async function validateFs(changedFiles: string[]): Promise { : nameRegex.test(identifier); if (isValidIdentifier) { - entityDirs.add(dir); + entityDirs.add(path.join(type, identifier)); } else { notAllowed.add(filePath); } } - - /** - * Validate that there are only allowed changes - */ - if (notAllowed.size) { - await github.addComment(messages.notAllowedChanges([...notAllowed])); - - throw new Error( - `The pull request includes changes outside the allowed directories:\n ${[ - ...notAllowed, - ].join(", ")}`, - ); - } - - /** - * Validate that only one entity is changed per pull request - */ - if (entityDirs.size > 1) { - await github.addComment(messages.onlyOneEntityPerPr([...entityDirs])); - - throw new Error("Several entities are changed in one pull request"); + + /** + * Validate that there are only allowed changes + */ + if (notAllowed.size) { + await github.addComment(messages.notAllowedChanges([...notAllowed])); + + throw new Error( + `The pull request includes changes outside the allowed directories:\n ${[ + ...notAllowed, + ].join(", ")}`, + ); + } + + /** + * Validate that only one entity is changed per pull request + */ + if (entityDirs.size > 1) { + await github.addComment(messages.onlyOneEntityPerPr([...entityDirs])); + + throw new Error("Several entities are changed in one pull request"); + } + + const entityDir = [...entityDirs][0]; + if (!entityDir) { + await github.addComment(messages.noEntityChanges()); + + throw new Error("No valid entity files were provided"); } - const [entityDir] = entityDirs; const entityType = path.basename(path.dirname(entityDir)) as EntityType; const entityId = path.basename(entityDir); + const normalizedEntityPath = `${entityType}/${entityId}`; const existingFiles: string[] = await fs.readdir(entityDir).catch(() => []); - - const entityDirExists = existingFiles.length > 0; - const [metadataPath, logoPath] = allowedFiles.map((name) => { - return existingFiles.includes(name) ? path.join(entityDir, name) : undefined; - }); - + + const entityDirExists = existingFiles.length > 0; + const [metadataPath, logoPath] = allowedFiles.map((name) => { + return existingFiles.includes(name) ? path.join(entityDir, name) : undefined; + }); + const [isMetadataChanged, isLogoChanged] = allowedFiles.map((name) => { - return changedFiles.some((file) => path.basename(file) === name); - }); - - /** - * Validate that metadata present in the entity folder. - */ - if (entityDirExists && !metadataPath) { - await github.addComment(messages.noInfoJson(entityDir)); - - throw new Error("`info.json` is not found in the entity folder"); - } + const expectedPath = `${normalizedEntityPath}/${name}`; - const result: Entity = { - entityId, - entityType, - isDeleted: !entityDirExists, - }; - - /** - * Add metadata to result only if the file was changed and exists. - */ - if (isMetadataChanged && metadataPath) { - result.metadata = metadataPath; - } - - /** - * Add logo to result only if the file was changed and exists. - */ - if (isLogoChanged && logoPath) { - result.logo = logoPath; - } - - return result; -} + return changedFiles.some( + (file) => file.replaceAll("\\", "/") === expectedPath, + ); + }); + + /** + * Validate that metadata present in the entity folder. + */ + if (entityDirExists && !metadataPath) { + await github.addComment(messages.noInfoJson(entityDir)); + + throw new Error("`info.json` is not found in the entity folder"); + } + + const result: Entity = { + entityId, + entityType, + isDeleted: !entityDirExists, + }; + + /** + * Add metadata to result only if the file was changed and exists. + */ + if (isMetadataChanged && metadataPath) { + result.metadata = metadataPath; + } + + /** + * Add logo to result only if the file was changed and exists. + */ + if (isLogoChanged && logoPath) { + result.logo = logoPath; + } + + return result; +} diff --git a/src/scripts/validate-logo.ts b/src/scripts/validate-logo.ts index 9dd8cdf..5446d9f 100644 --- a/src/scripts/validate-logo.ts +++ b/src/scripts/validate-logo.ts @@ -1,42 +1,50 @@ -import * as fs from "fs/promises"; -import { read } from "image-js"; -import * as path from "path"; +import * as fs from "fs/promises"; +import { read } from "image-js"; +import * as path from "path"; + +import * as github from "./github"; +import * as messages from "./messages"; +import { Entity } from "./validate-fs"; + +export async function validateLogo({ logo: logoPath }: Entity) { + if (!logoPath) { + return; + } + + const errors: string[] = []; + let size: number; -import * as github from "./github"; -import * as messages from "./messages"; -import { Entity } from "./validate-fs"; + try { + ({ size } = await fs.stat(logoPath)); + } catch { + await github.addComment(messages.invalidLogo(logoPath, ["The image file could not be read"])); -export async function validateLogo({ logo: logoPath }: Entity) { - if (!logoPath) { - return; + throw new Error("The logo file could not be read"); } - const errors: string[] = []; - const { size } = await fs.stat(logoPath); - if (size > 1024 * 100) { - errors.push("The image is too large. The maximum size is 100KB"); - } - + errors.push("The image is too large. The maximum size is 100KB"); + } + if (path.extname(logoPath) !== ".png") { errors.push("The image format should be PNG"); } else { - const image = await read(logoPath); - - // if (!image.alpha) { - // errors.push("The image background should be transparent"); - // } - - if (image.width != 256 || image.height != 256) { - errors.push( - `The image size must be 256x256 pixels. Current size is ${image.width}x${image.height}.`, - ); + try { + const image = await read(logoPath); + + if (image.width !== 256 || image.height !== 256) { + errors.push( + `The image size must be 256x256 pixels. Current size is ${image.width}x${image.height}.`, + ); + } + } catch { + errors.push("The image could not be decoded as a valid PNG"); } } - - if (errors.length) { - await github.addComment(messages.invalidLogo(logoPath, errors)); - - throw new Error("The logo is invalid"); - } -} + + if (errors.length) { + await github.addComment(messages.invalidLogo(logoPath, errors)); + + throw new Error("The logo is invalid"); + } +} diff --git a/src/scripts/validate-metadata.ts b/src/scripts/validate-metadata.ts index b1f4813..f833000 100644 --- a/src/scripts/validate-metadata.ts +++ b/src/scripts/validate-metadata.ts @@ -1,20 +1,28 @@ -import Ajv, { ErrorObject } from "ajv"; -import addFormats from "ajv-formats"; -import * as fs from "fs/promises"; -// @ts-expect-error - no types available -import { parse } from "json-source-map"; - -import * as github from "./github"; -import * as messages from "./messages"; -import { getSchema } from "./schemas"; -import { Entity } from "./validate-fs"; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const normalizeErrors = (error: ErrorObject, lineMap: any) => { +import Ajv, { ErrorObject } from "ajv"; +import addFormats from "ajv-formats"; +import * as fs from "fs/promises"; +// @ts-expect-error - no types available +import { parse } from "json-source-map"; + +import * as github from "./github"; +import * as messages from "./messages"; +import { getSchema } from "./schemas"; +import { Entity } from "./validate-fs"; + +type JsonSourceMap = Record< + string, + { + value?: { + line: number; + }; + } +>; + +const normalizeErrors = (error: ErrorObject, lineMap: JsonSourceMap) => { const { instancePath, message, params } = error; const allowedValues = params?.allowedValues ? `: ${params.allowedValues.join(", ")}` : ""; - const line = lineMap[instancePath]?.value?.line || 1; - const capMessage = message && message.charAt(0).toUpperCase() + message.slice(1); + const line = lineMap[instancePath]?.value?.line ?? 0; + const capMessage = message ? message.charAt(0).toUpperCase() + message.slice(1) : "Invalid value"; return { line: line + 1, @@ -28,13 +36,39 @@ export async function validateMetadata({ entityType, metadata: metadataPath }: E } const schema = getSchema(entityType); - const metadataContent = await fs.readFile(metadataPath, "utf8"); - const { data: metadata, pointers: lineMap } = parse(metadataContent); + let metadataContent: string; + + try { + metadataContent = await fs.readFile(metadataPath, "utf8"); + } catch (error) { + await github.addComment(messages.unreadableInfoJson(metadataPath)); + + throw new Error("The `info.json` file could not be read", { cause: error }); + } + + let metadata: unknown; + let lineMap: JsonSourceMap; + + try { + const parsed = parse(metadataContent) as { + data: unknown; + pointers: JsonSourceMap; + }; + metadata = parsed.data; + lineMap = parsed.pointers; + } catch (error) { + await github.addComment(messages.invalidInfoJson()); + + throw new Error("The `info.json` file contains invalid JSON", { cause: error }); + } const ajv = new Ajv({ allErrors: true }); addFormats(ajv); - ajv.validate(schema, metadata); + const valid = ajv.validate(schema, metadata); + if (valid) { + return; + } const errors = ajv.errors?.map((error: ErrorObject) => normalizeErrors(error, lineMap)).filter(Boolean) || @@ -49,7 +83,7 @@ export async function validateMetadata({ entityType, metadata: metadataPath }: E body: message, })), }); - - throw new Error("The `info.json` file is invalid"); } + + throw new Error("The `info.json` file is invalid"); } diff --git a/src/scripts/validate-rewards.ts b/src/scripts/validate-rewards.ts index 7dff638..197dfee 100644 --- a/src/scripts/validate-rewards.ts +++ b/src/scripts/validate-rewards.ts @@ -1,107 +1,120 @@ -import * as fs from "fs/promises"; -import { Address } from "viem"; - -import { createClient, getChain } from "./blockchain"; -import * as github from "./github"; -import * as messages from "./messages"; -import { Entity } from "./validate-fs"; - -export type RewardsContract = { - address: string; - type: string; -}; - -export type MetadataWithRewards = { - rewards?: RewardsContract[]; - [key: string]: unknown; -}; - -const isEntityAbi = [ - { - inputs: [{ internalType: "address", name: "entity_", type: "address" }], - name: "isEntity", - outputs: [{ internalType: "bool", name: "", type: "bool" }], - stateMutability: "view", - type: "function", - }, -] as const; - -const vaultAbi = [ - { - inputs: [], - name: "VAULT", - outputs: [{ internalType: "address", name: "", type: "address" }], - stateMutability: "view", - type: "function", - }, -] as const; - -export const validateRewards = async ({ - entityId: vaultAddress, - metadata: metadataPath, - entityType, -}: Entity) => { - if (!metadataPath || entityType !== "vaults") { - return; - } - - const chain = getChain(); - const client = createClient(); - const rewardsFactory = github.getInput("rewards-factory", { - required: false, - }); - - if (!rewardsFactory) { - return; - } - - const metadataContent = await fs.readFile(metadataPath, "utf8"); - const metadata: MetadataWithRewards = JSON.parse(metadataContent); - - if (!metadata.rewards || metadata.rewards.length === 0) { - return; +import * as fs from "fs/promises"; +import { Address, isAddress } from "viem"; + +import { createClient, getChain } from "./blockchain"; +import * as github from "./github"; +import * as messages from "./messages"; +import { Entity } from "./validate-fs"; + +export type RewardsContract = { + address: string; + type: string; +}; + +export type MetadataWithRewards = { + rewards?: RewardsContract[]; + [key: string]: unknown; +}; + +const isEntityAbi = [ + { + inputs: [{ internalType: "address", name: "entity_", type: "address" }], + name: "isEntity", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, +] as const; + +const vaultAbi = [ + { + inputs: [], + name: "VAULT", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, +] as const; + +export const validateRewards = async ({ + entityId: vaultAddress, + metadata: metadataPath, + entityType, +}: Entity) => { + if (!metadataPath || entityType !== "vaults") { + return; + } + + const chain = getChain(); + const client = createClient(); + const rewardsFactory = github.getInput("rewards-factory", { + required: false, + }); + + if (!rewardsFactory) { + return; + } + + let metadata: MetadataWithRewards; + try { + const metadataContent = await fs.readFile(metadataPath, "utf8"); + metadata = JSON.parse(metadataContent) as MetadataWithRewards; + } catch (error) { + await github.addComment(messages.invalidInfoJson()); + + throw new Error("The `info.json` file contains invalid JSON", { cause: error }); } + if (!Array.isArray(metadata.rewards) || metadata.rewards.length === 0) { + return; + } + for (const reward of metadata.rewards) { - if (reward.type !== "defaultStakingRewardsV2") { - await github.addComment(messages.invalidRewardsType(reward.address, reward.type)); - - throw new Error( - `Rewards contract \`${reward.address}\` has invalid type \`${reward.type}\`. Expected: defaultStakingRewardsV2`, - ); - } - - const isEntity = await client.readContract({ - address: rewardsFactory as Address, - abi: isEntityAbi, - functionName: "isEntity", - args: [reward.address as Address], - }); - - if (!isEntity) { - await github.addComment( - messages.rewardsNotFromFactory(reward.address, rewardsFactory, chain.name), - ); + if (!reward || !isAddress(reward.address)) { + await github.addComment(messages.invalidRewardsAddress(String(reward?.address))); - throw new Error( - `Rewards contract \`${reward.address}\` is not deployed by the rewards factory \`${rewardsFactory}\` on ${chain.name} network`, - ); + throw new Error(`Rewards contract address \`${String(reward?.address)}\` is invalid`); } - const rewardsVault = await client.readContract({ - address: reward.address as Address, - abi: vaultAbi, - functionName: "VAULT", - }); - - if (rewardsVault.toLowerCase() !== vaultAddress.toLowerCase()) { - await github.addComment( - messages.rewardsVaultMismatch(reward.address, rewardsVault, vaultAddress), - ); - - throw new Error( - `Rewards contract \`${reward.address}\` is associated with vault \`${rewardsVault}\`, but expected \`${vaultAddress}\``, - ); - } - } -}; + if (reward.type !== "defaultStakingRewardsV2") { + await github.addComment(messages.invalidRewardsType(reward.address, reward.type)); + + throw new Error( + `Rewards contract \`${reward.address}\` has invalid type \`${reward.type}\`. Expected: defaultStakingRewardsV2`, + ); + } + + const isEntity = await client.readContract({ + address: rewardsFactory as Address, + abi: isEntityAbi, + functionName: "isEntity", + args: [reward.address as Address], + }); + + if (!isEntity) { + await github.addComment( + messages.rewardsNotFromFactory(reward.address, rewardsFactory, chain.name), + ); + + throw new Error( + `Rewards contract \`${reward.address}\` is not deployed by the rewards factory \`${rewardsFactory}\` on ${chain.name} network`, + ); + } + + const rewardsVault = await client.readContract({ + address: reward.address as Address, + abi: vaultAbi, + functionName: "VAULT", + }); + + if (rewardsVault.toLowerCase() !== vaultAddress.toLowerCase()) { + await github.addComment( + messages.rewardsVaultMismatch(reward.address, rewardsVault, vaultAddress), + ); + + throw new Error( + `Rewards contract \`${reward.address}\` is associated with vault \`${rewardsVault}\`, but expected \`${vaultAddress}\``, + ); + } + } +}; From f7fe94130e2c1150ccad6b8ca7b57d2e56e6b7fa Mon Sep 17 00:00:00 2001 From: mertcano <35747700+mertcano@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:35:17 +0300 Subject: [PATCH 2/2] Add files via upload --- .github/copilot-instructions.md | 447 +++++++++--------- .github/workflows/Validate_Pull_Request.yml | 8 +- .github/workflows/full-info.yml | 78 ++- .../workflows/scripts/extract-metadata.mjs | 65 +++ 4 files changed, 331 insertions(+), 267 deletions(-) create mode 100644 .github/workflows/scripts/extract-metadata.mjs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c1de757..b564c10 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,223 +1,224 @@ -# Symbiotic Metadata Validator - AI Coding Instructions - -## Project Overview - -This is a GitHub Action that validates metadata changes in Symbiotic ecosystem repositories (vaults, operators, networks, tokens, curators, adapters). It enforces strict file structure, performs JSON schema validation, checks logos, and validates on-chain registry state via RPC calls. - -**Critical architectural constraint**: The action is distributed as a bundled single-file Node.js application (`dist/index.js`) using `tsup`. - -## Entity Structure & Validation Pipeline - -The codebase validates entities organized as: `{entityType}/{address}/{info.json,logo.png}` - -- **Entity types**: - - On-chain (require registry validation): `vaults`, `operators`, `networks`, `tokens`, `adapters` - - Off-chain (no registry check): `points`, `curators` -- **Identifier format**: - - On-chain: Ethereum address `/^0x[a-fA-F0-9]{40}$/` - - Off-chain: Kebab-case name `/^[a-z0-9]+(?:-[a-z0-9]+)*$/` -- **Allowed files**: Only `info.json` and `logo.png` per entity (enforced in `validate-fs.ts`) - -### Validation Flow (src/main.ts) - -All validations run in **parallel** via `Promise.allSettled()` to collect all errors before failing: - -1. **File System** (`validate-fs.ts`): Validates directory structure, ensures **one entity per PR** (critical constraint) -2. **Entity Registry** (`validate-entity.ts`): Checks on-chain registry using `isEntity()` contract call (skipped for off-chain entities: `points`, `curators`) -3. **Metadata Schema** (`validate-metadata.ts`): Validates `info.json` against type-specific schemas imported from `schemas/index.ts` (bundled at compile time) -4. **Logo** (`validate-logo.ts`): Enforces 256x256 PNG, max 100KB -5. **Collateral** (`validate-collateral.ts`): For vaults only, validates collateral token exists in repo -6. **Rewards** (`validate-rewards.ts`): For vaults with `rewards` in metadata, validates contracts via `rewards-factory` registry - -**Early exit**: Validation skips remaining steps if entity is deleted (`entity.isDeleted === true`). - -## On-Chain Integration - -**Blockchain client** (`src/scripts/blockchain.ts`): - -- Uses `viem` for Ethereum interactions (not ethers) -- Chain determined by `chain-id` input, RPC from `rpc-url` or viem's default public RPCs -- Registry contracts accessed via action inputs: `vault-registry`, `operator-registry`, `network-registry`, `adapter-registry` - -**Contract interaction patterns**: - -```typescript -// Registry validation (all registries) -const isEntity = await createClient().readContract({ - address: registryAddress as Address, - abi: isEntityAbi, - functionName: "isEntity", - args: [entityAddress], -}); - -// Vault-specific: get collateral token -const collateral = await createClient().readContract({ - address: vaultAddress as Address, - abi: collateralAbi, - functionName: "collateral", -}); - -// Rewards validation: check vault association -const vault = await createClient().readContract({ - address: rewardsAddress as Address, - abi: vaultAbi, - functionName: "VAULT", -}); -``` - -## Key Conventions - -### Error Handling & User Feedback - -- **All validation errors must post GitHub PR comments** via `github.addComment()` before throwing -- Use pre-formatted messages from `messages.ts` (includes contribution guidelines link) -- Schema errors use `json-source-map` to report **exact line numbers** via `github.addReview()` with inline comments -- Error pattern: - ```typescript - await github.addComment(messages.errorType(params)); - throw new Error("Human-readable error for CI logs"); - ``` - -### Input Handling - -- GitHub Action inputs accessed via `getInput()` from `@actions/core`, defined in `action.yml` -- **Local development**: Use `INPUT_*` env vars in `.env` file, **preserve hyphens**: `INPUT_CHAIN-ID`, `INPUT_RPC-URL` -- **Files input**: Space-separated, not comma-separated: `inputFiles.split(" ")` - -### Local Development Workflow - -The local action run is configured to use the `metadata/` directory as the source of metadata files for testing. This directory contains mock files to validate the action behavior locally. - -1. **Add test metadata files** to the `metadata/` directory following the standard structure: - - ``` - metadata/ - points/ - symbiotic/ - info.json - logo.png - vaults/ - 0x2c082c4a1b9939087906cee2fe0e6780a84331d6/ - info.json - logo.png - ``` - -2. **Configure environment** by copying `.env.template` and setting required inputs: - - ```bash - cp .env.template .env - ``` - - Edit `.env` to set the required inputs. **Files must be space-separated**. - - **Off-chain entities** (points/curators): - - ```bash - INPUT_FILES="points/symbiotic/info.json points/symbiotic/logo.png" - ``` - - **On-chain entities** (vaults/operators/networks/tokens): - - ```bash - INPUT_FILES="vaults/0xabc.../info.json vaults/0xabc.../logo.png" - INPUT_CHAIN-ID="560048" # Hoodi testnet, or "1" for mainnet - INPUT_VAULT-REGISTRY="0x407a039d94948484d356efb765b3c74382a050b4" - INPUT_OPERATOR-REGISTRY="0x6f75a4fff97326a00e52662d82ea4fde86a2c548" - INPUT_NETWORK-REGISTRY="0x7d03b7343bf8d5cec7c0c27ece084a20113d15c9" - INPUT_ADAPTER-REGISTRY="0xF33339BD72A512777E0FbF5817003E47A4a9ab66" - ``` - -3. **Run the action** using the configured environment: - - ```bash - npm run local-action - ``` - -4. **Review results** in the console output. When `LOCAL_ACTION_RUN=true`, PR comments and reviews are logged to the console instead of being posted to GitHub. - -### Build & Release Process - -1. **Bundle the action** into a single distributable file: - - ```bash - npm run bundle - ``` - -2. **Commit and push** the bundled distribution: - - ```bash - git status dist/ - git add dist/ - git commit -m "Bundle changes" - git push origin - ``` - -3. **Create a PR** with the bundled changes to merge into `main` (protected branch) - -**Watch mode**: Use `npm run package:watch` during development for automatic rebundling - -## Common Patterns - -### Adding New Validation Step - -1. Create validator: `src/scripts/validate-{feature}.ts` -2. Export async function signature: `async (entity: Entity) => Promise` -3. Check if validation applies (e.g., `if (entity.entityType !== "vaults") return;`) -4. On error: call `github.addComment()` with message from `messages.ts`, then throw -5. Add message template to `messages.ts` with contribution guidelines -6. Import and add to `Promise.allSettled()` array in `main.ts` (line ~20) - -### Adding New Entity Type - -1. Add type to `allowedTypes` in `validate-fs.ts` (either `onChainTypes` or `offChainTypes`) -2. Create schema: `src/scripts/schemas/{entityType}.json` (optional, falls back to `info.json`) -3. Import schema in `src/scripts/schemas/index.ts` and add to `schemaMap` if it differs from default -4. Add registry metadata to `entityMetaMap` in `validate-entity.ts` (if on-chain) -5. Add action input for registry contract in `action.yml` (if on-chain) - -### Schema Validation with Line Numbers - -Schemas are bundled at compile time via `src/scripts/schemas/index.ts`: - -```typescript -import { getSchema } from "./schemas"; -import { parse } from "json-source-map"; - -const schema = getSchema(entityType); // Returns bundled JSON schema -const { data: metadata, pointers: lineMap } = parse(metadataContent); -// Validate metadata... -const line = lineMap[error.instancePath]?.value?.line || 1; - -await github.addReview({ - body: "Schema validation failed", - comments: errors.map(({ message, line }) => ({ - line: line + 1, // Convert 0-indexed to 1-indexed - path: metadataPath, - body: message, - })), -}); -``` - -## Architecture Decisions - -**Why Promise.allSettled?** Collect all validation errors in one pass instead of failing fast, providing better UX for contributors. - -**Why single dist file?** GitHub Actions require self-contained JavaScript. `tsup` bundles TypeScript + dependencies into `dist/index.js`. - -**Why viem over ethers?** Modern, tree-shakeable, better TypeScript support, built-in chain configs. - -**Why json-source-map?** Enables precise error reporting at exact JSON line numbers for PR review comments. - -**Why upstream-checkout-path?** Allows validating cross-references (e.g., vault collateral tokens) against both PR changes and existing repo state. - -**Why bundle schemas?** JSON schemas are imported and bundled at compile time via TypeScript's `resolveJsonModule`, eliminating runtime file I/O and ensuring the `dist/index.js` bundle is completely self-contained. - -## Dependencies Reference - -- **@actions/core, @actions/github**: GitHub Actions SDK for inputs, outputs, PR comments -- **viem**: Ethereum client with chain configs from `viem/chains` -- **ajv, ajv-formats**: JSON Schema validation with format validators (uri, date, etc.) -- **image-js**: Image processing for logo validation (size, dimensions, format) -- **json-source-map**: Maps JSON paths to source locations for error reporting -- **tsup**: Bundles TypeScript + dependencies into single `dist/index.js` -- **@github/local-action**: Local GitHub Action runner for development +# Symbiotic Metadata Validator - AI Coding Instructions + +## Project Overview + +This is a GitHub Action that validates metadata changes in Symbiotic ecosystem repositories (vaults, operators, networks, tokens, curators, adapters). It enforces strict file structure, performs JSON schema validation, checks logos, and validates on-chain registry state via RPC calls. + +**Critical architectural constraint**: The action is distributed as a bundled single-file Node.js application (`dist/index.cjs`) using `tsup`. + +## Entity Structure & Validation Pipeline + +The codebase validates entities organized as: `{entityType}/{address}/{info.json,logo.png}` + +- **Entity types**: + - On-chain identifiers: `vaults`, `operators`, `networks`, `tokens`, `adapters` + - Registry validation: `vaults`, `operators`, `networks`, `adapters` + - Off-chain identifiers (no registry check): `points`, `curators` +- **Identifier format**: + - On-chain: Ethereum address `/^0x[a-fA-F0-9]{40}$/` + - Off-chain: Kebab-case name `/^[a-z0-9]+(?:-[a-z0-9]+)*$/` +- **Allowed files**: Only `info.json` and `logo.png` per entity (enforced in `validate-fs.ts`) + +### Validation Flow (src/main.ts) + +All validations run in **parallel** via `Promise.allSettled()` to collect all errors before failing: + +1. **File System** (`validate-fs.ts`): Validates directory structure, ensures **one entity per PR** (critical constraint) +2. **Entity Registry** (`validate-entity.ts`): Checks on-chain registry using `isEntity()` contract calls for `vaults`, `operators`, `networks`, and `adapters` (skipped for `tokens`, `points`, and `curators` because no token-registry input is defined) +3. **Metadata Schema** (`validate-metadata.ts`): Validates `info.json` against type-specific schemas imported from `schemas/index.ts` (bundled at compile time) +4. **Logo** (`validate-logo.ts`): Enforces 256x256 PNG, max 100KB +5. **Collateral** (`validate-collateral.ts`): For vaults only, validates collateral token exists in repo +6. **Rewards** (`validate-rewards.ts`): For vaults with `rewards` in metadata, validates contracts via `rewards-factory` registry + +**Early exit**: Validation skips remaining steps if entity is deleted (`entity.isDeleted === true`). + +## On-Chain Integration + +**Blockchain client** (`src/scripts/blockchain.ts`): + +- Uses `viem` for Ethereum interactions (not ethers) +- Chain determined by `chain-id` input, RPC from `rpc-url` or viem's default public RPCs +- Registry contracts accessed via action inputs: `vault-registry`, `operator-registry`, `network-registry`, `adapter-registry` + +**Contract interaction patterns**: + +```typescript +// Registry validation (all registries) +const isEntity = await createClient().readContract({ + address: registryAddress as Address, + abi: isEntityAbi, + functionName: "isEntity", + args: [entityAddress], +}); + +// Vault-specific: get collateral token +const collateral = await createClient().readContract({ + address: vaultAddress as Address, + abi: collateralAbi, + functionName: "collateral", +}); + +// Rewards validation: check vault association +const vault = await createClient().readContract({ + address: rewardsAddress as Address, + abi: vaultAbi, + functionName: "VAULT", +}); +``` + +## Key Conventions + +### Error Handling & User Feedback + +- **All validation errors must post GitHub PR comments** via `github.addComment()` before throwing +- Use pre-formatted messages from `messages.ts` (includes contribution guidelines link) +- Schema errors use `json-source-map` to report **exact line numbers** via `github.addReview()` with inline comments +- Error pattern: + ```typescript + await github.addComment(messages.errorType(params)); + throw new Error("Human-readable error for CI logs"); + ``` + +### Input Handling + +- GitHub Action inputs accessed via `getInput()` from `@actions/core`, defined in `action.yml` +- **Local development**: Use `INPUT_*` env vars in `.env` file, **preserve hyphens**: `INPUT_CHAIN-ID`, `INPUT_RPC-URL` +- **Files input**: Space-separated, not comma-separated: `inputFiles.split(" ")` + +### Local Development Workflow + +The local action run is configured to use the `metadata/` directory as the source of metadata files for testing. This directory contains mock files to validate the action behavior locally. + +1. **Add test metadata files** to the `metadata/` directory following the standard structure: + + ``` + metadata/ + points/ + symbiotic/ + info.json + logo.png + vaults/ + 0x2c082c4a1b9939087906cee2fe0e6780a84331d6/ + info.json + logo.png + ``` + +2. **Configure environment** by copying `.env.template` and setting required inputs: + + ```bash + cp .env.template .env + ``` + + Edit `.env` to set the required inputs. **Files must be space-separated**. + + **Off-chain entities** (points/curators): + + ```bash + INPUT_FILES="points/symbiotic/info.json points/symbiotic/logo.png" + ``` + + **Registry-validated entities** (vaults/operators/networks/adapters): + + ```bash + INPUT_FILES="vaults/0xabc.../info.json vaults/0xabc.../logo.png" + INPUT_CHAIN-ID="560048" # Hoodi testnet, or "1" for mainnet + INPUT_VAULT-REGISTRY="0x407a039d94948484d356efb765b3c74382a050b4" + INPUT_OPERATOR-REGISTRY="0x6f75a4fff97326a00e52662d82ea4fde86a2c548" + INPUT_NETWORK-REGISTRY="0x7d03b7343bf8d5cec7c0c27ece084a20113d15c9" + INPUT_ADAPTER-REGISTRY="0xF33339BD72A512777E0FbF5817003E47A4a9ab66" + ``` + +3. **Run the action** using the configured environment: + + ```bash + npm run local-action + ``` + +4. **Review results** in the console output. When `LOCAL_ACTION_RUN=true`, PR comments and reviews are logged to the console instead of being posted to GitHub. + +### Build & Release Process + +1. **Bundle the action** into a single distributable file: + + ```bash + npm run bundle + ``` + +2. **Commit and push** the bundled distribution: + + ```bash + git status dist/ + git add dist/ + git commit -m "Bundle changes" + git push origin + ``` + +3. **Create a PR** with the bundled changes to merge into `main` (protected branch) + +**Watch mode**: Use `npm run package:watch` during development for automatic rebundling + +## Common Patterns + +### Adding New Validation Step + +1. Create validator: `src/scripts/validate-{feature}.ts` +2. Export async function signature: `async (entity: Entity) => Promise` +3. Check if validation applies (e.g., `if (entity.entityType !== "vaults") return;`) +4. On error: call `github.addComment()` with message from `messages.ts`, then throw +5. Add message template to `messages.ts` with contribution guidelines +6. Import and add to `Promise.allSettled()` array in `main.ts` (line ~20) + +### Adding New Entity Type + +1. Add type to `allowedTypes` in `validate-fs.ts` (either `onChainTypes` or `offChainTypes`) +2. Create schema: `src/scripts/schemas/{entityType}.json` (optional, falls back to `info.json`) +3. Import schema in `src/scripts/schemas/index.ts` and add to `schemaMap` if it differs from default +4. Add registry metadata to `entityMetaMap` in `validate-entity.ts` (if on-chain) +5. Add action input for registry contract in `action.yml` (if on-chain) + +### Schema Validation with Line Numbers + +Schemas are bundled at compile time via `src/scripts/schemas/index.ts`: + +```typescript +import { getSchema } from "./schemas"; +import { parse } from "json-source-map"; + +const schema = getSchema(entityType); // Returns bundled JSON schema +const { data: metadata, pointers: lineMap } = parse(metadataContent); +// Validate metadata... + const line = lineMap[error.instancePath]?.value?.line ?? 0; + +await github.addReview({ + body: "Schema validation failed", + comments: errors.map(({ message, line }) => ({ + line: line + 1, // Convert 0-indexed to 1-indexed + path: metadataPath, + body: message, + })), +}); +``` + +## Architecture Decisions + +**Why Promise.allSettled?** Collect all validation errors in one pass instead of failing fast, providing better UX for contributors. + +**Why single dist file?** GitHub Actions require self-contained JavaScript. `tsup` bundles TypeScript + dependencies into `dist/index.js`. + +**Why viem over ethers?** Modern, tree-shakeable, better TypeScript support, built-in chain configs. + +**Why json-source-map?** Enables precise error reporting at exact JSON line numbers for PR review comments. + +**Why upstream-checkout-path?** Allows validating cross-references (e.g., vault collateral tokens) against both PR changes and existing repo state. + +**Why bundle schemas?** JSON schemas are imported and bundled at compile time via TypeScript's `resolveJsonModule`, eliminating runtime file I/O and ensuring the `dist/index.cjs` bundle is completely self-contained. + +## Dependencies Reference + +- **@actions/core, @actions/github**: GitHub Actions SDK for inputs, outputs, PR comments +- **viem**: Ethereum client with chain configs from `viem/chains` +- **ajv, ajv-formats**: JSON Schema validation with format validators (uri, date, etc.) +- **image-js**: Image processing for logo validation (size, dimensions, format) +- **json-source-map**: Maps JSON paths to source locations for error reporting +- **tsup**: Bundles TypeScript + dependencies into single `dist/index.js` +- **@github/local-action**: Local GitHub Action runner for development diff --git a/.github/workflows/Validate_Pull_Request.yml b/.github/workflows/Validate_Pull_Request.yml index 291b944..d07b778 100644 --- a/.github/workflows/Validate_Pull_Request.yml +++ b/.github/workflows/Validate_Pull_Request.yml @@ -1,13 +1,13 @@ name: Validate Pull Request on: - pull_request_target: + pull_request: branches: - main jobs: qodo: - if: ${{ github.event.sender.type != 'Bot' }} + if: ${{ github.event.sender.type != 'Bot' && github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: @@ -16,11 +16,11 @@ jobs: steps: - name: PR Agent action step id: pragent - uses: qodo-ai/pr-agent@0b0c175f6a0ae73f75bfb12a0eecb440a216a891 # pin@v0.33 + uses: qodo-ai/pr-agent@0b0c175f6a0ae73f75bfb12a0eecb440a216a891 # pin@v0.33 env: OPENAI_KEY: ${{ secrets.OPENAI_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} github_action_config.auto_review: "true" github_action_config.auto_describe: "true" github_action_config.auto_improve: "true" - github_action_config.pr_actions: '["opened", "reopened"]' \ No newline at end of file + github_action_config.pr_actions: '["opened", "reopened"]' diff --git a/.github/workflows/full-info.yml b/.github/workflows/full-info.yml index ad93b96..02ac0c9 100644 --- a/.github/workflows/full-info.yml +++ b/.github/workflows/full-info.yml @@ -1,43 +1,41 @@ -name: Get full info -on: - workflow_call: - -jobs: - generate: - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write - - steps: - - name: Checkout repository to get info from - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # pin@v7.0.1 - - - name: Checkout metadata-validation-scripts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # pin@v7.0.1 - with: +name: Get full info +on: + workflow_call: + +jobs: + generate: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write + + steps: + - name: Checkout repository to get info from + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # pin@v7.0.1 + + - name: Checkout metadata-validation-scripts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # pin@v7.0.1 + with: repository: symbioticfi/metadata-validation-scripts + ref: main path: metadata-validation-scripts - - - name: Set up Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # pin@v6.2.0 - with: - node-version: '22' - - - name: Install dependencies - run: npm install tsx @actions/core @actions/github - + + - name: Set up Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # pin@v6.2.0 + with: + node-version: '22' + - name: Generate - run: npx tsx metadata-validation-scripts/.github/workflows/scripts/extract-metadata.ts - - - name: Release latest full info - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # pin@v3.0.2 - with: - name: latest - tag_name: latest - files: full-info.json - fail_on_unmatched_files: true - - # Sleep to be sure release is updated in GH CDN before triggering a webhook - - name: Sleep for 30 seconds - run: sleep 30s + run: node metadata-validation-scripts/.github/workflows/scripts/extract-metadata.mjs + + - name: Release latest full info + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # pin@v3.0.2 + with: + name: latest + tag_name: latest + files: full-info.json + fail_on_unmatched_files: true + + # Sleep to be sure release is updated in GH CDN before triggering a webhook + - name: Sleep for 30 seconds + run: sleep 30s diff --git a/.github/workflows/scripts/extract-metadata.mjs b/.github/workflows/scripts/extract-metadata.mjs new file mode 100644 index 0000000..ce9c80a --- /dev/null +++ b/.github/workflows/scripts/extract-metadata.mjs @@ -0,0 +1,65 @@ +import fs from "fs/promises"; +import path from "path"; +import { pathToFileURL } from "url"; + +const DIRECTORIES = Object.freeze([ + "vaults", + "tokens", + "networks", + "operators", + "points", + "curators", + "adapters", +]); + +async function grabEntitiesInfo(globalDirs) { + const repoPath = process.env.GITHUB_REPOSITORY; + if (!repoPath) { + throw new Error("GITHUB_REPOSITORY is required"); + } + + const result = Object.fromEntries(DIRECTORIES.map((directory) => [directory, {}])); + + for (const directory of globalDirs) { + const directoryStats = await fs.stat(directory).catch(() => null); + if (!directoryStats?.isDirectory()) { + continue; + } + + try { + const subdirectories = await fs.readdir(directory, { withFileTypes: true }); + for (const subdirectory of subdirectories) { + if (!subdirectory.isDirectory()) { + continue; + } + + const entityPath = path.join(directory, subdirectory.name); + try { + const infoPath = path.join(entityPath, "info.json"); + const logoPath = path.join(entityPath, "logo.png"); + const info = JSON.parse(await fs.readFile(infoPath, "utf8")); + const entity = { info }; + const logoStats = await fs.stat(logoPath).catch(() => null); + + if (logoStats?.isFile()) { + entity.logo = `https://raw.githubusercontent.com/${repoPath}/main/${entityPath.replaceAll(path.sep, "/")}/logo.png`; + } + + result[directory][subdirectory.name] = entity; + } catch (error) { + console.error(`Error processing entity ${entityPath}`, error); + } + } + } catch (error) { + console.error(`Error reading directory ${directory}`, error); + } + } + + const outputPath = path.join(process.cwd(), "full-info.json"); + await fs.writeFile(outputPath, JSON.stringify(result, null, "\t"), "utf8"); +} + +grabEntitiesInfo(DIRECTORIES).catch((error) => { + console.error("Failed to generate full-info.json", error); + process.exitCode = 1; +});