From 670a8966949073250a62eac5485094f0c1734371 Mon Sep 17 00:00:00 2001 From: AbuJulaybeeb Date: Fri, 28 Aug 2026 05:57:46 +0100 Subject: [PATCH 1/4] feat(infra): establish minimal git hooks, unified invariant engine, cross-runtime validation, and custom linting - Implemented Husky commit-msg and pre-push policy enforcer (Resolves #476) - Created scripts/check-invariants.js to run standalone tests on pre-commit (Resolves #473) - Scaffolded shared validation schema and replaced backend strkey regex (Resolves #474) - Scaffolded eslint-plugin-greenpay for internal rules (Resolves #475) --- .husky/commit-msg | 3 ++ .husky/pre-commit | 3 ++ .husky/pre-push | 3 ++ backend/.eslintrc.json | 5 ++- backend/src/schemas/common.js | 6 ++- scripts/check-invariants.js | 44 +++++++++++++++++++++ scripts/eslint-plugin-greenpay/index.js | 22 +++++++++++ scripts/eslint-plugin-greenpay/package.json | 6 +++ scripts/policy-enforcer.js | 29 ++++++++++++++ shared/rules/validation.json | 19 +++++++++ shared/validators/stellarValidator.js | 17 ++++++++ 11 files changed, 153 insertions(+), 4 deletions(-) create mode 100755 .husky/commit-msg create mode 100755 .husky/pre-commit create mode 100755 .husky/pre-push create mode 100644 scripts/check-invariants.js create mode 100644 scripts/eslint-plugin-greenpay/index.js create mode 100644 scripts/eslint-plugin-greenpay/package.json create mode 100644 scripts/policy-enforcer.js create mode 100644 shared/rules/validation.json create mode 100644 shared/validators/stellarValidator.js diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 00000000..6441cd31 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +node scripts/policy-enforcer.js commit-msg "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..21941b25 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +node scripts/check-invariants.js diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 00000000..2188fd49 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +node scripts/policy-enforcer.js pre-push diff --git a/backend/.eslintrc.json b/backend/.eslintrc.json index 89ef8eb2..69c4c392 100644 --- a/backend/.eslintrc.json +++ b/backend/.eslintrc.json @@ -9,7 +9,7 @@ "eslint:recommended", "plugin:security/recommended-legacy" ], - "plugins": ["security", "sql-injection"], + "plugins": ["security", "sql-injection", "greenpay"], "parserOptions": { "ecmaVersion": "latest" }, @@ -23,6 +23,7 @@ "varsIgnorePattern": "^_", "caughtErrorsIgnorePattern": "^_" }], - "sql-injection/no-sql-injection": "error" + "sql-injection/no-sql-injection": "error", + "greenpay/no-parsefloat-numeric": "warn" } } diff --git a/backend/src/schemas/common.js b/backend/src/schemas/common.js index f012c4a3..03cb78a8 100644 --- a/backend/src/schemas/common.js +++ b/backend/src/schemas/common.js @@ -10,13 +10,15 @@ const { z } = require("zod"); -const STELLAR_PUBLIC_KEY = /^G[A-Z0-9]{55}$/; +const { isValidStellarAddress } = require('../../../shared/validators/stellarValidator'); + +const STELLAR_PUBLIC_KEY = /^G[A-Z0-9]{55}$/; // Legacy export maintained for compatibility const TRANSACTION_HASH = /^[a-fA-F0-9]{64}$/; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const stellarPublicKey = z .string({ required_error: "Invalid Stellar public key" }) - .regex(STELLAR_PUBLIC_KEY, "Invalid Stellar public key"); + .refine(isValidStellarAddress, "Invalid Stellar public key"); const transactionHash = z .string({ required_error: "Invalid transaction hash" }) diff --git a/scripts/check-invariants.js b/scripts/check-invariants.js new file mode 100644 index 00000000..242b26ad --- /dev/null +++ b/scripts/check-invariants.js @@ -0,0 +1,44 @@ +#!/usr/bin/env node +const { spawnSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +console.log('šŸš€ Running Invariant Engine (Minimal Mode)...\n'); + +const scripts = [ + 'check-documented-commands.js', + 'check-env-example.js', + 'check-source-encoding.js', + 'check-translations.js', + 'check-k8s-manifests.py' +]; + +let failed = false; + +for (const script of scripts) { + const scriptPath = path.join(__dirname, script); + if (!fs.existsSync(scriptPath)) { + console.warn(`āš ļø Skipping ${script} (not found)`); + continue; + } + + console.log(`ā³ Running ${script}...`); + const isPython = script.endsWith('.py'); + const cmd = isPython ? 'python3' : 'node'; + + const result = spawnSync(cmd, [scriptPath], { stdio: 'inherit' }); + if (result.status !== 0) { + console.error(`āŒ ${script} failed!`); + failed = true; + } else { + console.log(`āœ… ${script} passed.`); + } +} + +if (failed) { + console.error('\nāŒ Invariant checks failed.'); + process.exit(1); +} else { + console.log('\nāœ… All invariants passed.'); + process.exit(0); +} diff --git a/scripts/eslint-plugin-greenpay/index.js b/scripts/eslint-plugin-greenpay/index.js new file mode 100644 index 00000000..b581f415 --- /dev/null +++ b/scripts/eslint-plugin-greenpay/index.js @@ -0,0 +1,22 @@ +module.exports = { + rules: { + "no-parsefloat-numeric": { + create: function (context) { + return { + CallExpression(node) { + if ( + node.callee.type === "Identifier" && + node.callee.name === "parseFloat" + ) { + context.report({ + node, + message: + "Do not use parseFloat on NUMERIC database columns. This causes precision loss. Use a BigNumber library or string-based decimal math.", + }); + } + }, + }; + }, + }, + }, +}; diff --git a/scripts/eslint-plugin-greenpay/package.json b/scripts/eslint-plugin-greenpay/package.json new file mode 100644 index 00000000..15e747c0 --- /dev/null +++ b/scripts/eslint-plugin-greenpay/package.json @@ -0,0 +1,6 @@ +{ + "name": "eslint-plugin-greenpay", + "version": "1.0.0", + "main": "index.js", + "dependencies": {} +} diff --git a/scripts/policy-enforcer.js b/scripts/policy-enforcer.js new file mode 100644 index 00000000..89889137 --- /dev/null +++ b/scripts/policy-enforcer.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node +const fs = require('fs'); + +const type = process.argv[2]; + +if (type === 'commit-msg') { + const msgFile = process.argv[3]; + if (!msgFile) process.exit(0); + + const msg = fs.readFileSync(msgFile, 'utf-8').trim(); + + // Basic conventional commit validation + const conventionalRegex = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9\-]+\))?:\s.+/; + if (!conventionalRegex.test(msg) && !msg.startsWith('Merge ') && !msg.startsWith('Revert ')) { + console.error(`\nāŒ ERROR: Invalid commit message format.`); + console.error(`Commit message must follow Conventional Commits format (e.g. "feat: add something").`); + console.error(`Your message: "${msg}"\n`); + process.exit(1); + } +} else if (type === 'pre-push') { + // Basic branch naming rule against CLAUDE.md guidelines (e.g., no model names like "claude") + const branchName = require('child_process').execSync('git rev-parse --abbrev-ref HEAD').toString().trim(); + if (branchName.toLowerCase().includes('claude') || branchName.toLowerCase().includes('gpt')) { + console.error(`\nāŒ ERROR: Branch name violates naming policy (no model names allowed).`); + process.exit(1); + } +} + +process.exit(0); diff --git a/shared/rules/validation.json b/shared/rules/validation.json new file mode 100644 index 00000000..8dbd9270 --- /dev/null +++ b/shared/rules/validation.json @@ -0,0 +1,19 @@ +{ + "stellar": { + "address": { + "type": "string", + "description": "Valid Stellar public key (ed25519) with CRC16 checksum validation", + "contractAuthoritative": true, + "rejectMuxed": true, + "rejectContract": true + }, + "amount": { + "type": "number", + "precision": "NUMERIC(20, 7)", + "min": 0 + }, + "projectStatus": { + "enum": ["proposed", "active", "funded", "completed", "cancelled"] + } + } +} diff --git a/shared/validators/stellarValidator.js b/shared/validators/stellarValidator.js new file mode 100644 index 00000000..6bc55a1f --- /dev/null +++ b/shared/validators/stellarValidator.js @@ -0,0 +1,17 @@ +const { StrKey } = require('@stellar/stellar-sdk'); + +/** + * Cross-runtime validation helper for Stellar addresses. + * Derived from shared/rules/validation.json definitions. + */ +function isValidStellarAddress(address) { + try { + return StrKey.isValidEd25519PublicKey(address); + } catch (err) { + return false; + } +} + +module.exports = { + isValidStellarAddress +}; From ab421af5f701aa87b2ff155020d5de9edf3416eb Mon Sep 17 00:00:00 2001 From: AbuJulaybeeb Date: Fri, 28 Aug 2026 09:53:03 +0100 Subject: [PATCH 2/4] feat(infra): unify one-off repository checks into an invariant engine - Authored scripts/engine/core.js to dynamically execute and aggregate invariant rules - Implemented scripts/engine/baseline.js to support .invariant-baseline.json suppressions - Created python-rule.js adapter to natively parse check-k8s-manifests.py JSON output - Migrated check-invariants.js to act as the primary engine registry --- .husky/commit-msg | 2 +- .husky/pre-push | 2 +- scripts/check-invariants.js | 78 ++++++++++++++------------ scripts/engine/adapters/python-rule.js | 45 +++++++++++++++ scripts/engine/baseline.js | 45 +++++++++++++++ scripts/engine/core.js | 55 ++++++++++++++++++ scripts/policy/branch-validator.js | 52 +++++++++++++++++ scripts/policy/commit-validator.js | 65 +++++++++++++++++++++ scripts/policy/enforcer.js | 55 ++++++++++++++++++ scripts/policy/test-validators.js | 39 +++++++++++++ 10 files changed, 399 insertions(+), 39 deletions(-) create mode 100644 scripts/engine/adapters/python-rule.js create mode 100644 scripts/engine/baseline.js create mode 100644 scripts/engine/core.js create mode 100644 scripts/policy/branch-validator.js create mode 100644 scripts/policy/commit-validator.js create mode 100644 scripts/policy/enforcer.js create mode 100644 scripts/policy/test-validators.js diff --git a/.husky/commit-msg b/.husky/commit-msg index 6441cd31..eadfdb3f 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,3 +1,3 @@ #!/usr/bin/env sh -node scripts/policy-enforcer.js commit-msg "$1" +node scripts/policy/enforcer.js commit-msg "$1" diff --git a/.husky/pre-push b/.husky/pre-push index 2188fd49..191c53fa 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,3 +1,3 @@ #!/usr/bin/env sh -node scripts/policy-enforcer.js pre-push +node scripts/policy/enforcer.js pre-push diff --git a/scripts/check-invariants.js b/scripts/check-invariants.js index 242b26ad..0aeee5fb 100644 --- a/scripts/check-invariants.js +++ b/scripts/check-invariants.js @@ -1,44 +1,48 @@ #!/usr/bin/env node -const { spawnSync } = require('child_process'); -const path = require('path'); -const fs = require('fs'); - -console.log('šŸš€ Running Invariant Engine (Minimal Mode)...\n'); +/** + * scripts/check-invariants.js + * + * Unified Invariant Engine entry point. + */ -const scripts = [ - 'check-documented-commands.js', - 'check-env-example.js', - 'check-source-encoding.js', - 'check-translations.js', - 'check-k8s-manifests.py' -]; +const path = require('path'); +const { runEngine } = require('./engine/core'); +const { createPythonRule } = require('./engine/adapters/python-rule'); +const { spawnSync } = require('child_process'); -let failed = false; +const isAutofix = process.argv.includes('--fix'); -for (const script of scripts) { - const scriptPath = path.join(__dirname, script); - if (!fs.existsSync(scriptPath)) { - console.warn(`āš ļø Skipping ${script} (not found)`); - continue; - } - - console.log(`ā³ Running ${script}...`); - const isPython = script.endsWith('.py'); - const cmd = isPython ? 'python3' : 'node'; - - const result = spawnSync(cmd, [scriptPath], { stdio: 'inherit' }); - if (result.status !== 0) { - console.error(`āŒ ${script} failed!`); - failed = true; - } else { - console.log(`āœ… ${script} passed.`); - } +// Wrap legacy Node scripts in the standard rule interface +function createLegacyNodeRule(ruleId, scriptName) { + return { + ruleId, + execute: async () => { + const scriptPath = path.join(__dirname, scriptName); + const result = spawnSync('node', [scriptPath], { encoding: 'utf-8' }); + + if (result.status !== 0) { + return [{ + ruleId, + file: 'N/A', + message: `Legacy check failed:\n${result.stderr || result.stdout}`, + isFixable: false + }]; + } + return []; + } + }; } -if (failed) { - console.error('\nāŒ Invariant checks failed.'); +const rules = [ + createLegacyNodeRule('documented-commands', 'check-documented-commands.js'), + createLegacyNodeRule('env-example', 'check-env-example.js'), + createLegacyNodeRule('source-encoding', 'check-source-encoding.js'), + createLegacyNodeRule('translations', 'check-translations.js'), + createPythonRule('k8s-manifests', path.join(__dirname, 'check-k8s-manifests.py')) +]; + +// Run the engine +runEngine(rules, isAutofix).catch(err => { + console.error("Engine failed critically:", err); process.exit(1); -} else { - console.log('\nāœ… All invariants passed.'); - process.exit(0); -} +}); diff --git a/scripts/engine/adapters/python-rule.js b/scripts/engine/adapters/python-rule.js new file mode 100644 index 00000000..3aa98861 --- /dev/null +++ b/scripts/engine/adapters/python-rule.js @@ -0,0 +1,45 @@ +/** + * scripts/engine/adapters/python-rule.js + * + * Adapter to run Python checks inside the Invariant Engine. + */ + +const { spawnSync } = require('child_process'); + +function createPythonRule(ruleId, scriptPath) { + return { + ruleId, + execute: async () => { + const result = spawnSync('python3', [scriptPath, '--json'], { encoding: 'utf-8' }); + + if (result.error) { + throw new Error(`Failed to spawn python3: ${result.error.message}`); + } + + // If the script exited cleanly without outputting JSON errors, it passed. + if (result.status === 0 && !result.stdout.trim()) { + return []; + } + + try { + const output = JSON.parse(result.stdout); + return output.violations || []; + } catch (err) { + // Fallback if the script hasn't been fully migrated to JSON yet + if (result.status !== 0) { + return [{ + ruleId, + file: 'N/A', + message: `Python script failed with exit code ${result.status}:\n${result.stderr || result.stdout}`, + isFixable: false + }]; + } + return []; + } + } + }; +} + +module.exports = { + createPythonRule +}; diff --git a/scripts/engine/baseline.js b/scripts/engine/baseline.js new file mode 100644 index 00000000..8286d1e5 --- /dev/null +++ b/scripts/engine/baseline.js @@ -0,0 +1,45 @@ +/** + * scripts/engine/baseline.js + * + * Manages .invariant-baseline.json to grandfather existing violations. + * A violation is suppressed if it matches ruleId, file, and line/context, + * AND hasn't expired. + */ + +const fs = require('fs'); +const path = require('path'); + +const BASELINE_FILE = path.join(__dirname, '../../.invariant-baseline.json'); + +function loadBaseline() { + if (fs.existsSync(BASELINE_FILE)) { + try { + const data = JSON.parse(fs.readFileSync(BASELINE_FILE, 'utf-8')); + return data.suppressions || []; + } catch (err) { + console.warn('āš ļø Warning: Failed to parse .invariant-baseline.json'); + return []; + } + } + return []; +} + +function saveBaseline(suppressions) { + fs.writeFileSync(BASELINE_FILE, JSON.stringify({ suppressions }, null, 2)); +} + +function isSuppressed(violation, suppressions) { + const now = new Date().toISOString(); + + return suppressions.some(sup => { + return sup.ruleId === violation.ruleId && + sup.file === violation.file && + (!sup.expiry || sup.expiry > now); + }); +} + +module.exports = { + loadBaseline, + saveBaseline, + isSuppressed +}; diff --git a/scripts/engine/core.js b/scripts/engine/core.js new file mode 100644 index 00000000..31011d08 --- /dev/null +++ b/scripts/engine/core.js @@ -0,0 +1,55 @@ +/** + * scripts/engine/core.js + * + * Main Invariant Engine runner. + */ + +const { loadBaseline, isSuppressed } = require('./baseline'); + +async function runEngine(rules, autofix = false) { + console.log('šŸš€ Running Invariant Engine...\n'); + + const suppressions = loadBaseline(); + const allViolations = []; + let fixedCount = 0; + + for (const rule of rules) { + console.log(`ā³ Executing rule: ${rule.ruleId}...`); + try { + const violations = await rule.execute(); + + for (const violation of violations) { + if (isSuppressed(violation, suppressions)) { + console.log(` -> 🤫 Suppressed [${violation.ruleId}] in ${violation.file}`); + continue; + } + + if (autofix && violation.isFixable && typeof violation.fix === 'function') { + console.log(` -> šŸ”§ Autofixing [${violation.ruleId}] in ${violation.file}`); + await violation.fix(); + fixedCount++; + } else { + allViolations.push(violation); + } + } + } catch (err) { + console.error(`āŒ Rule ${rule.ruleId} crashed:`, err.message); + allViolations.push({ ruleId: rule.ruleId, file: 'N/A', message: 'Rule crashed' }); + } + } + + if (allViolations.length > 0) { + console.error(`\nāŒ ${allViolations.length} invariant violations found:\n`); + allViolations.forEach(v => { + console.error(`[${v.ruleId}] ${v.file}: ${v.message}`); + }); + process.exit(1); + } + + console.log(`\nāœ… All invariants passed! ${fixedCount > 0 ? `(Autofixed ${fixedCount} issues)` : ''}`); + process.exit(0); +} + +module.exports = { + runEngine +}; diff --git a/scripts/policy/branch-validator.js b/scripts/policy/branch-validator.js new file mode 100644 index 00000000..569ccd8d --- /dev/null +++ b/scripts/policy/branch-validator.js @@ -0,0 +1,52 @@ +/** + * scripts/policy/branch-validator.js + * + * Enforces branch naming conventions. + */ + +const VALID_PREFIXES = ['feature/', 'fix/', 'chore/', 'hotfix/', 'release/', 'docs/']; + +function validateBranchName(branchName) { + // Skip standard main/master branches + if (branchName === 'main' || branchName === 'master' || branchName === 'develop') { + return { valid: true }; + } + + // Enforce prefix + const hasValidPrefix = VALID_PREFIXES.some(prefix => branchName.startsWith(prefix)); + if (!hasValidPrefix) { + return { + valid: false, + error: `Branch name "${branchName}" must start with one of the allowed prefixes: ${VALID_PREFIXES.join(', ')}` + }; + } + + // Enforce issue number for feature/fix branches (e.g. feature/123-description) + // This expects the format prefix/NUMBER-description + const namePart = branchName.substring(branchName.indexOf('/') + 1); + + if (branchName.startsWith('feature/') || branchName.startsWith('fix/')) { + const issueRegex = /^[0-9]+-[a-z0-9-]+$/; + if (!issueRegex.test(namePart)) { + return { + valid: false, + error: `Branch name "${branchName}" must include an issue number.\nExample: "feature/123-add-login"` + }; + } + } + + // Enforce CLAUDE.md naming guidelines (no AI model names) + if (branchName.toLowerCase().includes('claude') || branchName.toLowerCase().includes('gpt')) { + return { + valid: false, + error: `Branch name "${branchName}" violates CLAUDE.md naming policy (no AI model names allowed).` + }; + } + + return { valid: true }; +} + +module.exports = { + validateBranchName, + VALID_PREFIXES +}; diff --git a/scripts/policy/commit-validator.js b/scripts/policy/commit-validator.js new file mode 100644 index 00000000..a7f0159f --- /dev/null +++ b/scripts/policy/commit-validator.js @@ -0,0 +1,65 @@ +/** + * scripts/policy/commit-validator.js + * + * Enforces Conventional Commits formatting and scope constraints. + */ + +const VALID_TYPES = ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert']; +const VALID_SCOPES = ['backend', 'frontend', 'contracts', 'mobile', 'extension', 'infra', 'shared', 'deps', 'config']; +const MAX_SUBJECT_LENGTH = 50; + +function validateCommitMessage(message) { + // Allow merge commits and reverts + if (message.startsWith('Merge ') || message.startsWith('Revert ')) { + return { valid: true }; + } + + // Regex to parse Conventional Commits: type(scope)?!?: subject + const commitRegex = /^([a-z]+)(?:\(([^)]+)\))?(!)?:\s+(.*)$/; + const match = message.match(commitRegex); + + if (!match) { + return { + valid: false, + error: 'Commit message must follow Conventional Commits format.\nExample: "feat(backend): add user authentication"\nYour message: "' + message + '"' + }; + } + + const [_, type, scope, breaking, subject] = match; + + if (!VALID_TYPES.includes(type)) { + return { + valid: false, + error: `Invalid commit type: "${type}".\nAllowed types: ${VALID_TYPES.join(', ')}` + }; + } + + if (scope && !VALID_SCOPES.includes(scope)) { + return { + valid: false, + error: `Invalid commit scope: "${scope}".\nAllowed scopes: ${VALID_SCOPES.join(', ')}` + }; + } + + if (subject.trim().length === 0) { + return { + valid: false, + error: 'Commit subject cannot be empty.' + }; + } + + if (subject.length > MAX_SUBJECT_LENGTH) { + return { + valid: false, + error: `Commit subject exceeds maximum length of ${MAX_SUBJECT_LENGTH} characters (was ${subject.length}).\nPlease keep the subject concise and add a body for details.` + }; + } + + return { valid: true }; +} + +module.exports = { + validateCommitMessage, + VALID_TYPES, + VALID_SCOPES +}; diff --git a/scripts/policy/enforcer.js b/scripts/policy/enforcer.js new file mode 100644 index 00000000..b3436640 --- /dev/null +++ b/scripts/policy/enforcer.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node +/** + * scripts/policy/enforcer.js + * + * Central entry point for Git hooks driven by Husky. + * Usage: + * node scripts/policy/enforcer.js commit-msg + * node scripts/policy/enforcer.js pre-push + */ + +const fs = require('fs'); +const { execSync } = require('child_process'); +const { validateCommitMessage } = require('./commit-validator'); +const { validateBranchName } = require('./branch-validator'); + +const hookType = process.argv[2]; + +function exitWithError(message) { + console.error('\n' + message + '\n'); + process.exit(1); +} + +if (hookType === 'commit-msg') { + const msgFile = process.argv[3]; + if (!msgFile) { + exitWithError('āŒ ERROR: Missing commit message file argument.'); + } + + const msg = fs.readFileSync(msgFile, 'utf-8').trim(); + const result = validateCommitMessage(msg); + + if (!result.valid) { + exitWithError(`āŒ COMMIT VALIDATION FAILED\n\n${result.error}`); + } + +} else if (hookType === 'pre-push') { + // Get the current branch name from git + let branchName = 'unknown'; + try { + branchName = execSync('git rev-parse --abbrev-ref HEAD').toString().trim(); + } catch (err) { + console.warn('āš ļø Could not determine branch name, skipping branch validation.'); + process.exit(0); + } + + const result = validateBranchName(branchName); + if (!result.valid) { + exitWithError(`āŒ BRANCH VALIDATION FAILED\n\n${result.error}`); + } +} else { + console.error(`āš ļø Unknown hook type: ${hookType}`); + process.exit(0); // Soft fail on unknown hook +} + +process.exit(0); diff --git a/scripts/policy/test-validators.js b/scripts/policy/test-validators.js new file mode 100644 index 00000000..12055d8f --- /dev/null +++ b/scripts/policy/test-validators.js @@ -0,0 +1,39 @@ +const { validateCommitMessage } = require('./commit-validator'); +const { validateBranchName } = require('./branch-validator'); + +console.log("=== Testing Branch Validator ==="); +const branches = [ + "main", // Valid + "feature/123-add-login", // Valid + "fix/456-bug", // Valid + "chore/update-deps", // Valid + "claude-fix", // Invalid (CLAUDE.md policy) + "gpt-branch", // Invalid (CLAUDE.md policy) + "feature/add-login", // Invalid (missing issue number) + "random-branch" // Invalid (missing prefix) +]; + +branches.forEach(b => { + const res = validateBranchName(b); + console.log(`[${res.valid ? 'PASS' : 'FAIL'}] ${b}`); + if (!res.valid) console.log(` -> ${res.error.split('\n')[0]}`); +}); + +console.log("\n=== Testing Commit Validator ==="); +const commits = [ + "feat(backend): add authentication", // Valid + "fix(frontend): resolve UI glitch", // Valid + "docs: update readme", // Valid + "Merge branch 'main'", // Valid + "Revert \"feat: something\"", // Valid + "update: something", // Invalid (bad type) + "feat(random): add something", // Invalid (bad scope) + "feat(backend): ", // Invalid (empty subject) + "feat(backend): this is a very very very very very very very long commit message that exceeds the maximum limit of fifty characters" // Invalid (too long) +]; + +commits.forEach(c => { + const res = validateCommitMessage(c); + console.log(`[${res.valid ? 'PASS' : 'FAIL'}] ${c}`); + if (!res.valid) console.log(` -> ${res.error.split('\n')[0]}`); +}); From 3eb720387895cb678b51e4e1d3073a67ed208ca4 Mon Sep 17 00:00:00 2001 From: AbuJulaybeeb Date: Fri, 28 Aug 2026 12:38:29 +0100 Subject: [PATCH 3/4] refactor(shared): unify Stellar address validation across the stack - Created shared/validators/stellarValidator.js utilizing @stellar/stellar-sdk's StrKey - Replaced vulnerable hand-rolled regexes in backend schemas, event sourcing, and routes - Migrated extension session-state and frontend form UI to the SDK's CRC16 checksum check - Resolves #474 --- backend/src/eventSourcing/commands.js | 7 ++++--- backend/src/routes/impact.js | 3 ++- backend/src/routes/profiles.js | 3 ++- backend/src/schemas/common.js | 2 -- extension/src/session-state.ts | 4 ++-- frontend/pages/submit-project.tsx | 4 ++-- shared/validators/stellarValidator.js | 1 + 7 files changed, 13 insertions(+), 11 deletions(-) diff --git a/backend/src/eventSourcing/commands.js b/backend/src/eventSourcing/commands.js index a43c10b2..5751c3dc 100644 --- a/backend/src/eventSourcing/commands.js +++ b/backend/src/eventSourcing/commands.js @@ -2,6 +2,7 @@ const { v4: uuid } = require("uuid"); const { xlmToStroops, stroopsToXlm } = require("../utils/xlm"); +const { isValidStellarAddress } = require("../../../shared/validators/stellarValidator"); class Command { static COMMAND_TYPE = null; @@ -39,7 +40,7 @@ class RecordDonationCommand extends Command { validate() { const errors = []; if (!this.payload.projectId) errors.push("projectId is required"); - if (!this.payload.donorAddress || !/^G[A-Z0-9]{55}$/.test(this.payload.donorAddress)) { + if (!this.payload.donorAddress || !isValidStellarAddress(this.payload.donorAddress)) { errors.push("donorAddress must be a valid Stellar public key"); } if (!this.payload.transactionHash || !/^[a-fA-F0-9]{64}$/.test(this.payload.transactionHash)) { @@ -96,7 +97,7 @@ class ApplyMatchCommand extends Command { const errors = []; if (!this.payload.matchId) errors.push("matchId is required"); if (!this.payload.projectId) errors.push("projectId is required"); - if (!this.payload.donorAddress || !/^G[A-Z0-9]{55}$/.test(this.payload.donorAddress)) { + if (!this.payload.donorAddress || !isValidStellarAddress(this.payload.donorAddress)) { errors.push("donorAddress must be a valid Stellar public key"); } const matchAmt = Number.parseFloat(this.payload.matchAmount); @@ -168,7 +169,7 @@ class CreateMatchOfferCommand extends Command { validate() { const errors = []; if (!this.payload.projectId) errors.push("projectId is required"); - if (!this.payload.matcherAddress || !/^G[A-Z0-9]{55}$/.test(this.payload.matcherAddress)) { + if (!this.payload.matcherAddress || !isValidStellarAddress(this.payload.matcherAddress)) { errors.push("matcherAddress must be a valid Stellar public key"); } const cap = Number.parseFloat(this.payload.capXlm); diff --git a/backend/src/routes/impact.js b/backend/src/routes/impact.js index dff17600..7d3284c4 100644 --- a/backend/src/routes/impact.js +++ b/backend/src/routes/impact.js @@ -15,6 +15,7 @@ const cache = require("../services/cache"); const { UUID } = require("../schemas/common"); const { adminRequired } = require("../middleware/auth"); const { createApiError } = require("../middleware/apiEnvelope"); +const { isValidStellarAddress } = require("../../../shared/validators/stellarValidator"); const { CLAIM_TYPES, EVIDENCE_TYPES, @@ -31,7 +32,7 @@ const HASH_PATTERN = /^[0-9a-f]{64}$/; const PARTY_TYPES = ["project_operator", "data_provider"]; function validateKey(key) { - if (!key || !/^G[A-Z0-9]{55}$/.test(key)) { + if (!key || !isValidStellarAddress(key)) { throw createApiError(400, "INVALID_PUBLIC_KEY", "Invalid Stellar public key"); } } diff --git a/backend/src/routes/profiles.js b/backend/src/routes/profiles.js index 5ec14f86..416d660b 100644 --- a/backend/src/routes/profiles.js +++ b/backend/src/routes/profiles.js @@ -8,9 +8,10 @@ const pool = require("../db/pool"); const { mapProfileRow } = require("../services/store"); const { createLayeredRateLimiter } = require("../middleware/rateLimiter"); const { createApiError } = require("../middleware/apiEnvelope"); +const { isValidStellarAddress } = require("../../../shared/validators/stellarValidator"); function validateKey(k) { - if (!k || !/^G[A-Z0-9]{55}$/.test(k)) { + if (!k || !isValidStellarAddress(k)) { throw createApiError(400, "INVALID_PUBLIC_KEY", "Invalid public key"); } } diff --git a/backend/src/schemas/common.js b/backend/src/schemas/common.js index 03cb78a8..ea12c9f0 100644 --- a/backend/src/schemas/common.js +++ b/backend/src/schemas/common.js @@ -12,7 +12,6 @@ const { z } = require("zod"); const { isValidStellarAddress } = require('../../../shared/validators/stellarValidator'); -const STELLAR_PUBLIC_KEY = /^G[A-Z0-9]{55}$/; // Legacy export maintained for compatibility const TRANSACTION_HASH = /^[a-fA-F0-9]{64}$/; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -29,7 +28,6 @@ const uuid = z .regex(UUID, "Invalid identifier"); module.exports = { - STELLAR_PUBLIC_KEY, TRANSACTION_HASH, UUID, stellarPublicKey, diff --git a/extension/src/session-state.ts b/extension/src/session-state.ts index 2737ad7f..d378ddbc 100644 --- a/extension/src/session-state.ts +++ b/extension/src/session-state.ts @@ -84,7 +84,7 @@ function isWalletSession(value: unknown): value is WalletSession { return ( isRecord(value) && typeof value.publicKey === 'string' && - /^G[A-Z2-7]{55}$/.test(value.publicKey) && + isValidStellarAddress(value.publicKey) && typeof value.network === 'string' && value.network === manifest.network.toUpperCase() && typeof value.validatedAt === 'number' @@ -217,7 +217,7 @@ export class WorkerSessionState { async setWallet(publicKey: string): Promise { return this.runExclusive(async () => { await this.initialize(); - if (!/^G[A-Z2-7]{55}$/.test(publicKey)) { + if (!isValidStellarAddress(publicKey)) { throw new Error('Invalid Stellar public key'); } diff --git a/frontend/pages/submit-project.tsx b/frontend/pages/submit-project.tsx index 0007ba0a..e9b34ef3 100644 --- a/frontend/pages/submit-project.tsx +++ b/frontend/pages/submit-project.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useRouter } from "next/router"; import { getApiErrorMessage, submitProject } from "@/lib/api"; import { PROJECT_CATEGORIES } from "@/utils/format"; +import { isValidStellarAddress } from "@/lib/stellar"; type Step = "org" | "project" | "wallet" | "methodology" | "done"; @@ -35,7 +36,6 @@ const STEP_LABELS: Record = { done: "Submitted", }; -const STELLAR_ADDRESS_RE = /^G[A-Z2-7]{55}$/; function Field({ label, @@ -105,7 +105,7 @@ export default function SubmitProjectPage() { } if (step === "wallet") { - if (!STELLAR_ADDRESS_RE.test(form.walletAddress.trim())) + if (!isValidStellarAddress(form.walletAddress.trim())) errs.walletAddress = "Must be a valid Stellar address (starts with G, 56 chars)"; } diff --git a/shared/validators/stellarValidator.js b/shared/validators/stellarValidator.js index 6bc55a1f..7f825e92 100644 --- a/shared/validators/stellarValidator.js +++ b/shared/validators/stellarValidator.js @@ -5,6 +5,7 @@ const { StrKey } = require('@stellar/stellar-sdk'); * Derived from shared/rules/validation.json definitions. */ function isValidStellarAddress(address) { + if (typeof address !== 'string') return false; try { return StrKey.isValidEd25519PublicKey(address); } catch (err) { From 254e7818310d27431aa9e9604d2b1834ba03773b Mon Sep 17 00:00:00 2001 From: AbuJulaybeeb Date: Fri, 28 Aug 2026 12:58:33 +0100 Subject: [PATCH 4/4] feat(infra): build custom eslint plugin and baseline engine for internal invariants - Implemented eslint-plugin-greenpay containing 4 internal AST rules - Added 'no-parsefloat-numeric' to prevent precision loss on monetary types - Added 'no-nested-envelope' to flag redundant res.data.data reads with auto-fixer - Added 'no-cross-package-imports' to enforce backend/frontend/mobile architectural boundaries - Added 'no-undeclared-reachable' check - Implemented .greenpay-eslint-baseline.json to suppress existing violations and allow gradual rollout - Resolves #475 --- .greenpay-eslint-baseline.json | 11 +++ backend/.eslintrc.json | 6 +- frontend/.eslintrc.json | 6 +- scripts/eslint-plugin-greenpay/index.js | 39 ++++---- .../lib/rules/no-cross-package-imports.js | 62 +++++++++++++ .../lib/rules/no-nested-envelope.js | 43 +++++++++ .../lib/rules/no-parsefloat-numeric.js | 53 +++++++++++ .../lib/rules/no-undeclared-reachable.js | 72 +++++++++++++++ .../lib/utils/baseline.js | 88 +++++++++++++++++++ scripts/eslint-plugin-greenpay/package.json | 8 +- .../tests/index.test.js | 58 ++++++++++++ 11 files changed, 425 insertions(+), 21 deletions(-) create mode 100644 .greenpay-eslint-baseline.json create mode 100644 scripts/eslint-plugin-greenpay/lib/rules/no-cross-package-imports.js create mode 100644 scripts/eslint-plugin-greenpay/lib/rules/no-nested-envelope.js create mode 100644 scripts/eslint-plugin-greenpay/lib/rules/no-parsefloat-numeric.js create mode 100644 scripts/eslint-plugin-greenpay/lib/rules/no-undeclared-reachable.js create mode 100644 scripts/eslint-plugin-greenpay/lib/utils/baseline.js create mode 100644 scripts/eslint-plugin-greenpay/tests/index.test.js diff --git a/.greenpay-eslint-baseline.json b/.greenpay-eslint-baseline.json new file mode 100644 index 00000000..976da1d6 --- /dev/null +++ b/.greenpay-eslint-baseline.json @@ -0,0 +1,11 @@ +{ + "backend/src/db/schema.sql": { + "greenpay/no-parsefloat-numeric": true + }, + "frontend/lib/api.ts": { + "greenpay/no-nested-envelope": true + }, + "mobile/app/donate/[id].tsx": { + "greenpay/no-parsefloat-numeric": true + } +} diff --git a/backend/.eslintrc.json b/backend/.eslintrc.json index 69c4c392..748f44ef 100644 --- a/backend/.eslintrc.json +++ b/backend/.eslintrc.json @@ -7,7 +7,8 @@ }, "extends": [ "eslint:recommended", - "plugin:security/recommended-legacy" + "plugin:security/recommended-legacy", + "plugin:greenpay/recommended" ], "plugins": ["security", "sql-injection", "greenpay"], "parserOptions": { @@ -23,7 +24,6 @@ "varsIgnorePattern": "^_", "caughtErrorsIgnorePattern": "^_" }], - "sql-injection/no-sql-injection": "error", - "greenpay/no-parsefloat-numeric": "warn" + "sql-injection/no-sql-injection": "error" } } diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index bffb357a..38872379 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -1,3 +1,7 @@ { - "extends": "next/core-web-vitals" + "extends": [ + "next/core-web-vitals", + "plugin:greenpay/recommended" + ], + "plugins": ["greenpay"] } diff --git a/scripts/eslint-plugin-greenpay/index.js b/scripts/eslint-plugin-greenpay/index.js index b581f415..05457a97 100644 --- a/scripts/eslint-plugin-greenpay/index.js +++ b/scripts/eslint-plugin-greenpay/index.js @@ -1,21 +1,28 @@ +/** + * ESLint Plugin: GreenPay + * Custom rules for the GreenPay codebase + */ + +const noParsefloatNumeric = require('./lib/rules/no-parsefloat-numeric'); +const noNestedEnvelope = require('./lib/rules/no-nested-envelope'); +const noCrossPackageImports = require('./lib/rules/no-cross-package-imports'); +const noUndeclaredReachable = require('./lib/rules/no-undeclared-reachable'); + module.exports = { rules: { - "no-parsefloat-numeric": { - create: function (context) { - return { - CallExpression(node) { - if ( - node.callee.type === "Identifier" && - node.callee.name === "parseFloat" - ) { - context.report({ - node, - message: - "Do not use parseFloat on NUMERIC database columns. This causes precision loss. Use a BigNumber library or string-based decimal math.", - }); - } - }, - }; + 'no-parsefloat-numeric': noParsefloatNumeric, + 'no-nested-envelope': noNestedEnvelope, + 'no-cross-package-imports': noCrossPackageImports, + 'no-undeclared-reachable': noUndeclaredReachable, + }, + configs: { + recommended: { + plugins: ['greenpay'], + rules: { + 'greenpay/no-parsefloat-numeric': 'warn', + 'greenpay/no-nested-envelope': 'error', + 'greenpay/no-cross-package-imports': 'error', + 'greenpay/no-undeclared-reachable': 'error', }, }, }, diff --git a/scripts/eslint-plugin-greenpay/lib/rules/no-cross-package-imports.js b/scripts/eslint-plugin-greenpay/lib/rules/no-cross-package-imports.js new file mode 100644 index 00000000..2c61942a --- /dev/null +++ b/scripts/eslint-plugin-greenpay/lib/rules/no-cross-package-imports.js @@ -0,0 +1,62 @@ +const { wrapReport } = require('../utils/baseline'); + +module.exports = { + meta: { + type: "problem", + docs: { + description: "Prevent unresolvable relative imports and cross-package boundary violations", + category: "Possible Errors", + recommended: true + }, + schema: [] // no options + }, + create: function(context) { + const originalReport = context.report; + context.report = wrapReport(context, 'greenpay/no-cross-package-imports', originalReport); + + const filename = context.getFilename(); + const isFrontend = filename.includes('/frontend/'); + const isBackend = filename.includes('/backend/'); + const isMobile = filename.includes('/mobile/'); + const isExtension = filename.includes('/extension/'); + + return { + ImportDeclaration(node) { + const importSource = node.source.value; + + // Allowed paths like @shared or relative paths inside the same package + if (importSource.startsWith('@shared/')) { + return; + } + + // Detect cross-package boundaries by looking for relative path escalations + if (importSource.includes('../backend/') || importSource.includes('../../backend/')) { + if (!isBackend) { + context.report({ + node, + message: "Cross-package boundary violation: Cannot import backend module from outside backend." + }); + } + } + + if (importSource.includes('../frontend/') || importSource.includes('../../frontend/')) { + if (!isFrontend) { + context.report({ + node, + message: "Cross-package boundary violation: Cannot import frontend module from outside frontend." + }); + } + } + + if (importSource.includes('../mobile/') || importSource.includes('../../mobile/')) { + if (!isMobile) { + context.report({ + node, + message: "Cross-package boundary violation: Cannot import mobile module from outside mobile." + }); + } + } + } + }; + } +}; diff --git a/scripts/eslint-plugin-greenpay/lib/rules/no-nested-envelope.js b/scripts/eslint-plugin-greenpay/lib/rules/no-nested-envelope.js new file mode 100644 index 00000000..e418aa7f --- /dev/null +++ b/scripts/eslint-plugin-greenpay/lib/rules/no-nested-envelope.js @@ -0,0 +1,43 @@ +const { wrapReport } = require('../utils/baseline'); + +module.exports = { + meta: { + type: "problem", + docs: { + description: "Do not read nested 'data' objects when the Axios interceptor has already unwrapped them", + category: "Possible Errors", + recommended: true + }, + fixable: "code", + schema: [] // no options + }, + create: function(context) { + const originalReport = context.report; + context.report = wrapReport(context, 'greenpay/no-nested-envelope', originalReport); + + return { + MemberExpression(node) { + // Look for res.data.data or response.data.data + if ( + node.property.type === 'Identifier' && + node.property.name === 'data' && + node.object.type === 'MemberExpression' && + node.object.property.type === 'Identifier' && + node.object.property.name === 'data' + ) { + // Check if root object is res or response + if (node.object.object.type === 'Identifier' && (node.object.object.name === 'res' || node.object.object.name === 'response')) { + context.report({ + node, + message: "Unnecessary nested '.data.data' envelope read. The Axios interceptor already unwraps responses.", + fix: function(fixer) { + // Replace `res.data.data` with `res.data` + return fixer.replaceText(node, `${node.object.object.name}.data`); + } + }); + } + } + } + }; + } +}; diff --git a/scripts/eslint-plugin-greenpay/lib/rules/no-parsefloat-numeric.js b/scripts/eslint-plugin-greenpay/lib/rules/no-parsefloat-numeric.js new file mode 100644 index 00000000..528eb12b --- /dev/null +++ b/scripts/eslint-plugin-greenpay/lib/rules/no-parsefloat-numeric.js @@ -0,0 +1,53 @@ +const { wrapReport } = require('../utils/baseline'); + +module.exports = { + meta: { + type: "problem", + docs: { + description: "Do not use parseFloat or Number() on numeric database columns or monetary values", + category: "Possible Errors", + recommended: true + }, + schema: [] // no options + }, + create: function(context) { + // Override report for baseline suppression + const originalReport = context.report; + context.report = wrapReport(context, 'greenpay/no-parsefloat-numeric', originalReport); + + // List of identifiers that strongly suggest monetary/numeric origin from the DB + const MONEY_VARS = ['amount', 'balance', 'total', 'xlm', 'usd', 'quantity']; + + return { + CallExpression(node) { + let isFlagged = false; + + if (node.callee.type === 'Identifier' && (node.callee.name === 'parseFloat' || node.callee.name === 'Number')) { + if (node.arguments.length > 0 && node.arguments[0].type === 'Identifier') { + const argName = node.arguments[0].name.toLowerCase(); + if (MONEY_VARS.some(v => argName.includes(v))) { + isFlagged = true; + } + } + // Also check member expressions (e.g. parseFloat(row.amount)) + if (node.arguments.length > 0 && node.arguments[0].type === 'MemberExpression') { + const prop = node.arguments[0].property; + if (prop.type === 'Identifier' && MONEY_VARS.some(v => prop.name.toLowerCase().includes(v))) { + isFlagged = true; + } + } + } + + if (isFlagged) { + context.report({ + node, + message: "Do not use {{callee}} on monetary values. This causes precision loss. Use a BigNumber library or string-based decimal math.", + data: { + callee: node.callee.name + } + }); + } + } + }; + } +}; diff --git a/scripts/eslint-plugin-greenpay/lib/rules/no-undeclared-reachable.js b/scripts/eslint-plugin-greenpay/lib/rules/no-undeclared-reachable.js new file mode 100644 index 00000000..885dafe1 --- /dev/null +++ b/scripts/eslint-plugin-greenpay/lib/rules/no-undeclared-reachable.js @@ -0,0 +1,72 @@ +const { wrapReport } = require('../utils/baseline'); + +module.exports = { + meta: { + type: "problem", + docs: { + description: "Flag identifiers used in reachable code but never imported or defined", + category: "Possible Errors", + recommended: true + }, + schema: [] // no options + }, + create: function(context) { + const originalReport = context.report; + context.report = wrapReport(context, 'greenpay/no-undeclared-reachable', originalReport); + + // Globals allowed in the environments + const ALLOWED_GLOBALS = new Set([ + 'console', 'process', 'require', 'module', 'exports', 'window', 'document', 'setTimeout', 'clearTimeout', + 'Promise', 'Error', 'Buffer', 'Array', 'Object', 'String', 'Number', 'Boolean', 'JSON', 'Math', 'Date', + 'fetch', 'describe', 'it', 'beforeEach', 'afterEach', 'expect', 'jest', '__dirname', 'global', 'localStorage' + ]); + + return { + Identifier(node) { + // We only care about variables being read + // Check if it's part of a declaration, assignment, property of an object, etc. + const parent = node.parent; + + // Ignore properties like obj.foo + if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) { + return; + } + + // Ignore object keys like { foo: 1 } + if (parent.type === 'Property' && parent.key === node) { + return; + } + + // Ignore variable declarations, function parameters + if (parent.type === 'VariableDeclarator' && parent.id === node) return; + if (parent.type === 'FunctionDeclaration' && (parent.id === node || parent.params.includes(node))) return; + if (parent.type === 'ArrowFunctionExpression' && parent.params.includes(node)) return; + + // Try to resolve in the ESLint scope + const scope = context.getScope(); + + // check if it's declared in current or any upper scope + let currentScope = scope; + let isDefined = false; + + while (currentScope) { + if (currentScope.set.has(node.name)) { + isDefined = true; + break; + } + currentScope = currentScope.upper; + } + + if (!isDefined && !ALLOWED_GLOBALS.has(node.name)) { + context.report({ + node, + message: "'{{name}}' is used but never imported or defined.", + data: { + name: node.name + } + }); + } + } + }; + } +}; diff --git a/scripts/eslint-plugin-greenpay/lib/utils/baseline.js b/scripts/eslint-plugin-greenpay/lib/utils/baseline.js new file mode 100644 index 00000000..569c5d69 --- /dev/null +++ b/scripts/eslint-plugin-greenpay/lib/utils/baseline.js @@ -0,0 +1,88 @@ +const fs = require('fs'); +const path = require('path'); + +let baselineSuppression = null; + +/** + * Loads the baseline suppression file once + */ +function loadBaseline() { + if (baselineSuppression !== null) { + return baselineSuppression; + } + + // Look for .greenpay-eslint-baseline.json in the repository root + // We assume the plugin is executed from the repository root (e.g. frontend/ or backend/ directory) + // But wait! frontend/ and backend/ are subdirectories. + // We should resolve the repo root. + const repoRoot = path.resolve(__dirname, '../../../../'); + const baselinePath = path.join(repoRoot, '.greenpay-eslint-baseline.json'); + + try { + if (fs.existsSync(baselinePath)) { + const content = fs.readFileSync(baselinePath, 'utf8'); + baselineSuppression = JSON.parse(content); + } else { + baselineSuppression = {}; + } + } catch (error) { + console.error('[eslint-plugin-greenpay] Failed to load baseline JSON', error); + baselineSuppression = {}; + } + + return baselineSuppression; +} + +/** + * Normalizes file path to be relative to the repo root + */ +function getRelativePath(absolutePath) { + const repoRoot = path.resolve(__dirname, '../../../../'); + if (absolutePath.startsWith(repoRoot)) { + return absolutePath.substring(repoRoot.length + 1); // remove leading slash + } + return absolutePath; +} + +/** + * Checks if a specific violation is suppressed in the baseline + */ +function isSuppressed(context, ruleId) { + const baseline = loadBaseline(); + if (!baseline || Object.keys(baseline).length === 0) { + return false; + } + + const filename = context.getFilename(); + if (!filename) return false; + + const relPath = getRelativePath(filename); + + if (baseline[relPath] && baseline[relPath][ruleId]) { + // If the file + rule is in the baseline, we suppress it completely for now + // A more advanced baseline would check line numbers or hashes + // Given the prompt requirement to allow adoption without fixing all 98 sites at once, + // a file-level + rule-level suppression is generally sufficient for a baseline rollout. + return true; + } + + return false; +} + +/** + * Wrap context.report to intercept violations + */ +function wrapReport(context, ruleId, reportFn) { + return function(descriptor) { + if (isSuppressed(context, ruleId)) { + return; // Squelched by baseline + } + return reportFn.call(context, descriptor); + }; +} + +module.exports = { + loadBaseline, + isSuppressed, + wrapReport, +}; diff --git a/scripts/eslint-plugin-greenpay/package.json b/scripts/eslint-plugin-greenpay/package.json index 15e747c0..bdf5dc2a 100644 --- a/scripts/eslint-plugin-greenpay/package.json +++ b/scripts/eslint-plugin-greenpay/package.json @@ -2,5 +2,11 @@ "name": "eslint-plugin-greenpay", "version": "1.0.0", "main": "index.js", - "dependencies": {} + "scripts": { + "test": "node --test tests/" + }, + "dependencies": {}, + "peerDependencies": { + "eslint": ">=8.0.0" + } } diff --git a/scripts/eslint-plugin-greenpay/tests/index.test.js b/scripts/eslint-plugin-greenpay/tests/index.test.js new file mode 100644 index 00000000..ac7bdc6d --- /dev/null +++ b/scripts/eslint-plugin-greenpay/tests/index.test.js @@ -0,0 +1,58 @@ +const { RuleTester } = require('eslint'); +const noParsefloatNumeric = require('../lib/rules/no-parsefloat-numeric'); +const noNestedEnvelope = require('../lib/rules/no-nested-envelope'); +const noCrossPackageImports = require('../lib/rules/no-cross-package-imports'); +const noUndeclaredReachable = require('../lib/rules/no-undeclared-reachable'); + +const tester = new RuleTester({ parserOptions: { ecmaVersion: 2021, sourceType: 'module' } }); + +// Tests for no-parsefloat-numeric +tester.run('no-parsefloat-numeric', noParsefloatNumeric, { + valid: [ + { code: "const val = new BigNumber(amount);" }, + { code: "parseInt('123', 10);" }, + { code: "parseFloat(someRandomString);" } + ], + invalid: [ + { + code: "const x = parseFloat(amount);", + errors: [{ message: "Do not use parseFloat on monetary values. This causes precision loss. Use a BigNumber library or string-based decimal math." }] + }, + { + code: "const y = Number(row.total);", + errors: [{ message: "Do not use Number on monetary values. This causes precision loss. Use a BigNumber library or string-based decimal math." }] + } + ] +}); + +// Tests for no-nested-envelope +tester.run('no-nested-envelope', noNestedEnvelope, { + valid: [ + { code: "const data = res.data;" }, + { code: "const info = response.data;" } + ], + invalid: [ + { + code: "const info = res.data.data;", + errors: [{ message: "Unnecessary nested '.data.data' envelope read. The Axios interceptor already unwraps responses." }], + output: "const info = res.data;" + } + ] +}); + +// Tests for no-cross-package-imports +tester.run('no-cross-package-imports', noCrossPackageImports, { + valid: [ + { code: "import { foo } from '@shared/utils';", filename: "/home/user/repo/frontend/src/index.js" }, + { code: "import { bar } from './local';", filename: "/home/user/repo/frontend/src/index.js" } + ], + invalid: [ + { + code: "import { db } from '../../backend/src/db';", + filename: "/home/user/repo/frontend/src/index.js", + errors: [{ message: "Cross-package boundary violation: Cannot import backend module from outside backend." }] + } + ] +}); + +console.log("All rule tests passed.");