One command that validates any project, from any directory, on any machine.
cd ~/anything
validatevalidate is not installed per project and does not live inside your repositories. It is a single global command that detects which languages a repository actually contains, resolves the right tools, and runs a fixed chain of non-overlapping quality gates. Python and TypeScript are supported today; a repo containing both gets both.
Two principles hold everywhere:
- The repository's configuration wins. Defaults are supplied only for what a repo has not declared, because this tool runs against repositories it does not own.
- Nothing is faked and nothing is written. A gate that cannot run honestly is reported as skipped, never quietly passed, and no file in the target directory is modified unless you pass
--fix.
On any machine:
curl -fsSL https://raw.githubusercontent.com/ricardofelixb/validate/main/install.sh | bashThat clones the repo to ~/projects/validate, symlinks ~/.local/bin/validate onto your PATH, and pre-warms the Python tool cache. Re-running it updates an existing install.
Manual install:
git clone https://github.com/ricardofelixb/validate.git ~/projects/validate
ln -sf ~/projects/validate/validate ~/.local/bin/validateCustom locations:
INSTALL_DIR=~/dev/validate BIN_DIR=~/bin ./install.shMake sure the bin directory is on your PATH:
export PATH="$HOME/.local/bin:$PATH"bash3.2 or newer, so stock macOS worksuvfor the Python track — it fetches and globally caches ruff, basedpyright, deptry, and pip-auditnodeand a package manager for the TypeScript track — pnpm, npm, yarn, or bun, detected from the lockfile
curl -LsSf https://astral.sh/uv/install.sh | shYou never install the Python tools yourself. TypeScript tools are a different story on purpose: see Why TypeScript tools are not fetched.
validate # fast: environment, format, lint, types, tests
validate static # no tests — good for a pre-commit hook
validate full # everything: coverage, production build, dead code, audits
validate --fix # apply formatting and safe lint fixes, then validateEvery language present is validated by default. Narrow when you want to:
validate py # Python only
validate ts # TypeScript / JavaScript only
validate ts full # combine freely with modes| Option | Meaning |
|---|---|
--fix |
Write formatting and safe lint fixes instead of only reporting them |
--min N |
Coverage floor when the repository declares none (default 85) |
--root PATH |
Validate PATH instead of the current directory |
--no-env |
Skip the lockfile / install gate |
-h, --help |
Usage |
-V, --version |
Version |
Environment variables: VALIDATE_MIN_COVERAGE, VALIDATE_LINE_LENGTH, NO_COLOR.
Exit codes: 0 every applicable gate passed, 1 a gate failed, 2 bad usage or no recognizable project.
Tracks are independent. Gates within a track run in order of cost and stop at the first failure, but a failing Python track never prevents the TypeScript track from running — otherwise a mostly-TypeScript repo with a few Python scripts would never reach its real gates.
Each gate is a distinct concern, deliberately non-overlapping. Ten redundant linters is not a validation stack.
| Gate | Tool | Purpose | Mode |
|---|---|---|---|
| Environment | uv sync --locked |
Lockfile matches project metadata | all |
| Formatting | ruff format --check |
Deterministic formatting | all |
| Static bugs | ruff check |
Undefined names, async and security mistakes | all |
| Type contracts | basedpyright |
Cross-module correctness | all |
| Behavior | pytest |
Runtime behavior | fast, full |
| Test effectiveness | coverage with branch coverage |
Untested branches | fast, full |
| Dependency hygiene | deptry |
Missing, unused, transitive dependencies | full |
| Dependency security | pip-audit |
Known vulnerabilities | full |
| Architecture | import-linter |
Declared dependency boundaries | full |
| Startup | your command | The app imports and wires up | full |
| Gate | Tool | Purpose | Mode |
|---|---|---|---|
| Environment | frozen lockfile install | Manifests and lockfile agree | all |
| Formatting | Prettier or Biome | Deterministic formatting | all |
| Semantics | ESLint or Biome | Type-aware lint rules | all |
| Type correctness | tsc --noEmit |
Type correctness | all |
| Behavior | Vitest, or your test script | Runtime behavior | fast, full |
| Test effectiveness | coverage thresholds | Untested branches | full |
| Production build | next build, vite build, … |
Real bundler and module graph | full |
| Dead code | Knip | Unused files, exports, dependencies | full |
| Generated code | opt-in check:generated |
Committed codegen is not stale | full |
| Architecture | dependency-cruiser | Declared dependency boundaries | full |
| Peer dependencies | pnpm peers check |
Unmet peers | full |
| Security | pnpm audit |
Known vulnerabilities | full |
| Signatures | pnpm audit signatures |
Registry signature verification | full |
| User flow | opt-in check:e2e |
The app actually works | full |
The production build is a hard gate, and it is the biggest difference from Python. tsc validates TypeScript's model of the code; only the real build validates the bundler, framework transforms, module graph, assets, and production configuration. Never rely on the build as your only typecheck, and never set something like Next's ignoreBuildErrors to make validation pass.
Other deliberate choices: a frozen install fails when manifests and lockfile disagree instead of quietly resolving something new. Ruff uses an explicit rule set rather than ALL, so upgrading Ruff never silently adds gates. Coverage measures branches, because line coverage calls an if covered when only one outcome was ever tested. Exactly one type checker is authoritative — running both mypy and basedpyright buys disagreements, not correctness. And validation never mutates: no eslint --fix, no knip --fix, no pnpm audit --fix.
Deliberately excluded: Black, Flake8, isort, Pylint, Bandit, compileall, Vulture, complexity limits, Biome alongside Prettier, Oxlint as a second mandatory linter, ts-prune/depcheck/unimported, and a 100% coverage mandate.
| Detected | Environment gate | Tools from |
|---|---|---|
pyproject.toml + uv.lock |
uv sync --locked |
project .venv, else uvx |
| Any existing virtualenv | skipped | that venv, else uvx |
| No environment | skipped | uvx |
With no environment, the gates that genuinely cannot work are skipped and reported. A type checker without installed dependencies emits thousands of phantom "unresolved import" errors, and tests cannot import a package that was never installed.
The package manager comes from the lockfile, with packageManager as a tiebreak: pnpm-lock.yaml, package-lock.json, yarn.lock, or bun.lock. The frozen install is spelled correctly for each — pnpm install --frozen-lockfile, npm ci, bun install --frozen-lockfile, and yarn install --frozen-lockfile or --immutable depending on whether yarn is classic or Berry.
Every gate prefers a script the repository already declares, since that is the project's own definition of the check, and falls back to invoking the tool directly. For types that means check:types, then typecheck, then check-types, then tsc -p tsconfig.json --noEmit, then a workspace-recursive run. Formatting and linting only run when the repo declares a config, because a formatter with no declared config would impose defaults the project never chose.
Scripts that would mutate code or hang are refused. Real repos define lint as eslint --fix and test as bare vitest, which is watch mode. Any script containing --fix, --write, --apply, --watch, or a bare vitest is skipped in favor of a safe alternative, and the ones ignored are reported at the end.
The Python track falls back to uvx when a tool is missing. The TypeScript track deliberately does not, and only uses binaries already in the project's node_modules/.bin. tsc and ESLint need the project's own dependencies and @types packages to say anything true; a freshly downloaded copy running against an uninstalled repo would report thousands of phantom errors rather than real ones. If dependencies are missing, validate says so and skips those gates.
If the repository declares [tool.ruff], [tool.basedpyright], [tool.pytest.ini_options], [tool.coverage], pytest.ini, ruff.toml, pyrightconfig.json, eslint.config.*, .prettierrc, biome.json, or tsconfig.json, that config is used untouched. Only when a setting is absent does validate supply its own:
- Ruff lint — the explicit rule set
E4,E7,E9,F,I,UP,B,SIM,ASYNC,S,RUF, withS101allowed undertests/. - Ruff format — no width override. A repo with no ruff config was formatted at ruff's own default width, and imposing a different one would report every file as misformatted. Set
VALIDATE_LINE_LENGTHto opt in. - basedpyright —
recommendedwith warnings fatal, minus the diagnostics that describe your dependencies rather than your code:reportMissingTypeStubs,reportAny,reportExplicitAny,reportUnusedCallResult,reportUnannotatedClassAttribute,reportImplicitStringConcatenation, and thereportUnknown*family. Untyped third-party packages are not a defect in the project being validated. A repo wanting purerecommendeddeclares it. - Coverage — branch coverage with a floor of 85%, overridable with
--min. - pytest —
-ra --strict-markers --strict-config. - Prettier fallback — source files only, never
., so lockfiles and generated artifacts are not reported as misformatted in repos without a.prettierignore.
Gates that only the repository can define activate when you opt in.
Python startup smoke test. The check that catches the most real breakage is whether the application still imports and wires itself up:
[tool.validate]
smoke = 'python -m your_package --help'Single quotes are a TOML literal string, so inner double quotes need no escaping. Double-quoted values work too and their \" escapes are resolved. The command should verify that imports succeed, config models construct, and plugin registration completes — without placing calls, contacting providers, mutating databases, or needing production secrets.
Committed generated code. For Convex and anything else that generates committed output, declare a script that regenerates and then fails if the commit was stale:
{
"scripts": {
"check:generated": "pnpm exec convex codegen && git diff --exit-code -- convex/_generated"
}
}Locally this leaves the fix in your working tree; in CI the dirty diff fails the run. Also give Convex its own typecheck — do not assume the web app's tsconfig.json covers everything under convex/.
Architecture contracts. Declare [tool.importlinter] in pyproject.toml, or a check:architecture script running dependency-cruiser. Worth adding once you have real boundaries: core logic that must not import provider SDKs, renderer code that must not import main-process modules, one company's plugins that must not import another's. Without genuine contracts it is ceremony.
End-to-end. A check:e2e script runs in full mode.
validate works with no configuration. When you want a repository to own its rules, this is the baseline it assumes.
[tool.ruff]
target-version = "py312"
line-length = 100
src = ["src"]
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I", "UP", "B", "SIM", "ASYNC", "S", "RUF"]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101"] # assertions belong in tests
[tool.basedpyright]
include = ["src", "tests"]
typeCheckingMode = "recommended"
failOnWarnings = true
pythonVersion = "3.12"
pythonPlatform = "Linux"
[tool.pytest.ini_options]
minversion = "9.0"
testpaths = ["tests"]
strict = true
xfail_strict = true
addopts = ["-ra"]
filterwarnings = ["error"]
[tool.coverage.run]
branch = true
source = ["your_package"]
relative_files = true
[tool.coverage.report]
show_missing = true
skip_covered = true
fail_under = 85
exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError"]uv add --dev ruff basedpyright pytest "coverage[toml]" deptry pip-auditpnpm add -D typescript eslint @eslint/js typescript-eslint \
eslint-config-prettier prettier globals \
vitest @vitest/coverage-v8 knip dependency-cruiserStrict compiler options worth adding beyond strict: true:
Use type-aware ESLint. Typed rules catch floating promises, misused promises, unsafe values, and invalid async callbacks that syntax-only linting cannot see:
// eslint.config.mjs
import js from "@eslint/js";
import { defineConfig, globalIgnores } from "eslint/config";
import eslintConfigPrettier from "eslint-config-prettier";
import globals from "globals";
import tseslint from "typescript-eslint";
export default defineConfig([
globalIgnores([
"**/.next/**", "**/.turbo/**", "**/build/**",
"**/coverage/**", "**/dist/**", "**/out/**",
"**/convex/_generated/**",
]),
{
files: ["**/*.{js,mjs,cjs}"],
extends: [js.configs.recommended],
languageOptions: { globals: globals.node },
},
{
files: ["**/*.{ts,tsx,mts,cts}"],
extends: [js.configs.recommended, tseslint.configs.strictTypeChecked],
languageOptions: {
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
linterOptions: { reportUnusedDisableDirectives: "error" },
rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/consistent-type-imports": [
"error",
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
],
"@typescript-eslint/switch-exhaustiveness-check": "error",
},
},
eslintConfigPrettier, // must be last
]);For React and Next.js, add their official configs rather than recreating hook and framework rules by hand. Do not enable every ESLint rule; a strict preset plus a few deliberate policies beats "everything on".
Coverage needs an explicit include, or uncovered files are simply absent from the report and coverage looks healthier than it is:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
clearMocks: true,
restoreMocks: true,
unstubEnvs: true,
unstubGlobals: true,
coverage: {
provider: "v8",
include: ["src/**/*.{ts,tsx}"],
exclude: ["**/*.d.ts", "**/*.{test,spec}.{ts,tsx}", "**/__fixtures__/**"],
reporter: ["text", "html", "json-summary"],
thresholds: { lines: 85, statements: 85, functions: 85, branches: 80 },
},
},
});Set thresholds to your honest baseline and ratchet upward. Coverage proves execution, not assertion quality; 85–90% with good boundary and failure-path tests beats a superficial 100%. Protect billing, auth, and data-mutation paths with stronger targeted requirements instead.
In a mature monorepo, prefer explicit filters over pnpm -r --if-present, which can silently skip a package that was supposed to expose a check.
- run: curl -fsSL https://raw.githubusercontent.com/ricardofelixb/validate/main/install.sh | bash
- run: ~/.local/bin/validate fullAs a pre-commit hook in .git/hooks/pre-commit:
#!/usr/bin/env bash
exec validate staticBecause it is an executable on PATH rather than a shell alias, it works from hooks, Makefiles, CI, and agent runs — an alias only exists in interactive shells.
A production build should be deterministic. Use placeholder or validation-environment configuration rather than requiring live production secrets, and never let a build in validation mutate real services.
rm ~/.local/bin/validate
rm -rf ~/projects/validateMIT
{ "compilerOptions": { "strict": true, "noEmit": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noImplicitOverride": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "noPropertyAccessFromIndexSignature": true, "noUncheckedSideEffectImports": true, "useUnknownInCatchVariables": true, "forceConsistentCasingInFileNames": true, // Appropriate when Next, Vite, SWC, or esbuild produces the JavaScript. "isolatedModules": true // For an intentionally ESM-only package, also: "verbatimModuleSyntax": true } }