diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 000000000..93acec6f4 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,10 @@ +#!/bin/bash + +# Run `npx husky` in root directory to initialize this pre-commit hook for local machine + +# Execute NodeJS script because +# i.) Husky requires NodeJS -> fair assumption that machine will have NodeJS +# ii.) Cleaner syntax and abstractions than shell scripting +node .husky/pre-commit.js + +exit 0 \ No newline at end of file diff --git a/.husky/pre-commit.js b/.husky/pre-commit.js new file mode 100644 index 000000000..86d60c090 --- /dev/null +++ b/.husky/pre-commit.js @@ -0,0 +1,217 @@ +/** + * Runs as git pre-commit hook + * Filters the list of changed files on 'git commit' + * If *.ts files in specified projects are detected, runs the 'lint:ts:fix' package.json script for that project + * E.g. if a *.ts file is changed in /sdk, then this script will run 'pnpm run lint:ts:fix' in the /sdk project + */ + +const fs = require('fs'); +const { execSync } = require('child_process'); + +/** + * ENUMS + */ + +// File extensions to filter for +const FILE_EXTENSION = { + TYPESCRIPT: "TYPESCRIPT", + SOLIDITY: "SOLIDITY", +} + +// Projects to filter for +const FOLDER = { + BRIDGEUI: "BRIDGEUI", + CONTRACTS: "CONTRACTS", + E2E: "E2E", + OPERATIONS: "OPERATIONS", + POSTMAN: "POSTMAN", + SDK: "SDK", +} + +// Project runtimes +const RUNTIME = { + NODEJS: "NODEJS" +} + +/** + * MAPPINGS + */ + +// File extension => regex +const FILE_EXTENSION_FILTERS = { + [FILE_EXTENSION.TYPESCRIPT]: "\.ts$", + [FILE_EXTENSION.SOLIDITY]: "\.sol$", +}; + +// File extension => script in package.json to run +const FILE_EXTENSION_LINTING_COMMAND = { + [FILE_EXTENSION.TYPESCRIPT]: "pnpm run lint:ts:fix", + [FILE_EXTENSION.SOLIDITY]: "pnpm run lint:sol:fix", +}; + +// Project => Path in monorepo +const FOLDER_PATH = { + [FOLDER.BRIDGEUI]: "bridge-ui/", + [FOLDER.CONTRACTS]: "contracts/", + [FOLDER.E2E]: "e2e/", + [FOLDER.OPERATIONS]: "operations/", + [FOLDER.POSTMAN]: "postman/", + [FOLDER.SDK]: "sdk/", +}; + +// Project => List of changed files +const FOLDER_CHANGED_FILES = { + [FOLDER.BRIDGEUI]: new Array(), + [FOLDER.CONTRACTS]: new Array(), + [FOLDER.E2E]: new Array(), + [FOLDER.OPERATIONS]: new Array(), + [FOLDER.POSTMAN]: new Array(), + [FOLDER.SDK]: new Array(), +}; + +// Project => Runtime +const FOLDER_RUNTIME = { + [FOLDER.BRIDGEUI]: RUNTIME.NODEJS, + [FOLDER.CONTRACTS]: RUNTIME.NODEJS, + [FOLDER.E2E]: RUNTIME.NODEJS, + [FOLDER.OPERATIONS]: RUNTIME.NODEJS, + [FOLDER.POSTMAN]: RUNTIME.NODEJS, + [FOLDER.SDK]: RUNTIME.NODEJS, +}; + +/** + * MAIN FUNCTION + */ + +main(); + +function main() { + const changedFileList = getChangedFileList(); + partitionChangedFileList(changedFileList); + + for (const folder in FOLDER) { + if (!isDependenciesInstalled(folder)) { + console.error(`Dependencies not installed in ${FOLDER_PATH[folder]}, exiting...`) + process.exit(1); + } + const changedFileExtensions = getChangedFileExtensions(folder); + executeLinting(folder, changedFileExtensions); + } + + updateGitIndex(); +} + +/** + * HELPER FUNCTIONS + */ + +/** + * Gets a list of changed files in the git commit + * @returns {string[]} + */ +function getChangedFileList() { + try { + const cmd = 'git diff --name-only HEAD' + const stdout = execSync(cmd, { encoding: 'utf8' }); + return stdout.split('\n').filter(file => file.trim() !== ''); + } catch (error) { + console.error($`Error running ${cmd}:`, error.message); + process.exit(1) + } +} + +/** + * Partitions list of changed files from getChangedFileList() by project + * Stores results in FOLDER_CHANGED_FILES + * @param {string[]} + */ +function partitionChangedFileList(_changedFileList) { + for (const file of _changedFileList) { + for (const path in FOLDER) { + if (file.match(new RegExp(`^${FOLDER_PATH[path]}`))) { + FOLDER_CHANGED_FILES[path].push(file); + } + } + } +} + +/** + * Checks if runtime dependencies are installed for a project + * @param {FOLDER} + * @returns {boolean} + */ +function isDependenciesInstalled(_folder) { + const runtime = FOLDER_RUNTIME[_folder]; + const path = FOLDER_PATH[_folder]; + + switch(runtime) { + case RUNTIME.NODEJS: + const dependencyFolder = `${path}node_modules` + return fs.existsSync(dependencyFolder) + default: + console.error(`${runtime} runtime not supported.`); + return false + } +} + +/** + * Resolve list of changed file extensions for a project + * @param {FOLDER} + * @returns {FILE_EXTENSION[]} + */ +function getChangedFileExtensions(_folder) { + // Use sets to implement early exit from loop, once we have matched all configured file extensions + const remainingFileExtensionsSet = new Set(Object.values(FILE_EXTENSION)); + const foundFileExtensionsSet = new Set(); + + for (const file of FOLDER_CHANGED_FILES[_folder]) { + for (const fileExtension of remainingFileExtensionsSet) { + if (file.match(new RegExp(FILE_EXTENSION_FILTERS[fileExtension]))) { + foundFileExtensionsSet.add(fileExtension); + remainingFileExtensionsSet.delete(fileExtension); + } + } + + // No more remaining file extensions to look for + if (remainingFileExtensionsSet.size == 0) break; + } + + return Array.from(foundFileExtensionsSet); +} + +/** + * Execute linting command + * @param {FOLDER, FILE_EXTENSION[]} + */ +function executeLinting(_folder, _changedFileExtensions) { + for (const fileExtension of _changedFileExtensions) { + const path = FOLDER_PATH[_folder]; + const cmd = FILE_EXTENSION_LINTING_COMMAND[fileExtension]; + console.log(`${fileExtension} change found in ${path}, linting...`); + try { + // Execute command synchronously and stream output directly to the current stdout + execSync(` + cd ${path}; + ${cmd}; + `, { stdio: 'inherit' }); + } catch (error) { + console.error(`Error:`, error.message); + console.error(`Exiting...`); + process.exit(1); + } + } +} + +/** + * Redo `git add` for files updated during executeLinting(), so that they are not left out of the commit + * The difference between 'git add .' and 'git update-index --again', is that the latter will not include untracked files + */ +function updateGitIndex() { + try { + const cmd = 'git update-index --again' + execSync(cmd, { stdio: 'inherit' }); + } catch (error) { + console.error($`Error running ${cmd}:`, error.message); + process.exit(1); + } +} \ No newline at end of file diff --git a/bridge-ui/package.json b/bridge-ui/package.json index f92a83960..06af155cc 100644 --- a/bridge-ui/package.json +++ b/bridge-ui/package.json @@ -8,7 +8,8 @@ "build": "next build", "start": "next start", "lint": "next lint", - "lint:fix": "next lint --fix", + "lint:fix": "pnpm run lint:ts:fix", + "lint:ts:fix": "next lint --fix", "clean": "rimraf node_modules .next .next-env.d.ts", "install:playwright": "playwright install --with-deps", "build:cache": "synpress", diff --git a/operations/package.json b/operations/package.json index a3d436369..d8762b068 100644 --- a/operations/package.json +++ b/operations/package.json @@ -9,7 +9,8 @@ "prettier": "prettier -c '**/*.{js,ts}'", "prettier:fix": "prettier -w '**/*.{js,ts}'", "lint": "eslint . --ext .ts", - "lint:fix": "eslint . --ext .ts --fix", + "lint:fix": "pnpm run lint:ts:fix", + "lint:ts:fix": "eslint . --ext .ts --fix", "test": "node --experimental-vm-modules node_modules/jest/bin/jest --bail --detectOpenHandles --forceExit", "clean": "rimraf node_modules dist coverage", "postpack": "shx rm -f oclif.manifest.json", diff --git a/package.json b/package.json index 77b4852bf..18f8e6971 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "lint:fix": "pnpm run -r --if-present lint:fix", "clean": "pnpm run -r --if-present clean && rm -rf node_modules", "test": "pnpm run -r --if-present test", - "build": "pnpm run -r --if-present build" + "build": "pnpm run -r --if-present build", + "prepare": "husky" }, "devDependencies": { "@types/node": "20.12.7", @@ -23,6 +24,7 @@ "eslint": "8.57.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-prettier": "5.1.3", + "husky": "9.1.7", "prettier": "3.2.5", "rimraf": "5.0.5", "ts-node": "10.9.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5e4ba5e5..852d395ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: eslint-plugin-prettier: specifier: 5.1.3 version: 5.1.3(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.2.5) + husky: + specifier: 9.1.7 + version: 9.1.7 prettier: specifier: 3.2.5 version: 3.2.5 @@ -260,6 +263,8 @@ importers: specifier: 17.7.2 version: 17.7.2 + contracts/lib/forge-std: {} + e2e: devDependencies: '@jest/globals': @@ -5861,6 +5866,11 @@ packages: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + i18next-browser-languagedetector@7.1.0: resolution: {integrity: sha512-cr2k7u1XJJ4HTOjM9GyOMtbOA47RtUoWRAtt52z43r3AoMs2StYKyjS3URPhzHaf+mn10hY9dZWamga5WPQjhA==} @@ -17287,6 +17297,8 @@ snapshots: human-signals@5.0.0: {} + husky@9.1.7: {} + i18next-browser-languagedetector@7.1.0: dependencies: '@babel/runtime': 7.25.7