Static Analysis Tools for Multiple Languages

·14 min read·Tools and Utilitiesintermediate

Delivering code quality and security checks early, across your polyglot stack

A developer workstation with multiple code editors open, showing different programming languages in use, symbolizing polyglot static analysis across a stack

Modern software projects rarely live in a single language. A typical web application might ship with JavaScript on the frontend, Python for data pipelines, Go or Rust for microservices, and even a bit of shell scripting for deployment. Static analysis tools help you keep this mix healthy by catching bugs, enforcing style, and spotting security issues before the code ever runs. This matters now because teams are shipping faster, using more dependencies, and dealing with stricter compliance requirements. Manual code reviews can’t cover everything, and tests that rely on runtime execution miss problems that tools can find in seconds. If you want predictable reviews, safer refactors, and fewer surprises in production, building a multi-language static analysis strategy is one of the highest-leverage things you can do.

Where static analysis fits today

Static analysis examines source code without executing it to find potential issues, security vulnerabilities, and style violations. It is a standard practice in CI pipelines, pre-commit hooks, and IDEs. Teams use it to reduce review friction, automate boring checks, and maintain consistency across large codebases and multiple languages. In polyglot environments, the main challenge is choosing the right tool for each language, ensuring consistent configuration, and integrating results into developer workflows without creating noise.

High level comparison:

  • Linters (e.g., ESLint for JavaScript, Pylint for Python) focus on style, idiomatic usage, and some correctness checks. They are lightweight and great for everyday feedback.
  • Formatters (e.g., Prettier for JavaScript, Black for Python) enforce consistent formatting automatically, eliminating debates over style in code reviews.
  • Security-focused tools (e.g., Bandit for Python, Semgrep for multi-language rule sets) look for common vulnerabilities and insecure patterns.
  • Data-flow and taint analyzers (e.g., CodeQL) model how data moves through code and can find complex bugs, though they often require more setup and compute time.
  • Platform-specific ecosystems exist for many languages, and unified tools like Semgrep, SonarQube, and CodeQL allow teams to centralize checks across languages.

The trend is toward faster feedback loops, better IDE integrations, and shared rule sets across languages to reduce context switching for developers.

Core concepts and practical patterns

Choosing a toolchain by language and workflow

When choosing static analysis tools, consider your language mix, desired feedback speed, and where you want checks to run:

  • Pre-commit hooks for immediate feedback and fast failure on trivial issues.
  • IDE integrations for inline hints and quick fixes.
  • CI pipelines for comprehensive, enforced checks and historical reporting.
  • Policy gates for critical issues that should block merges.

A practical approach is to combine a formatter, a linter, and a security scanner per language. Formatters handle style automatically, linters catch idiomatic issues, and security scanners look for dangerous patterns.

Project layout and configuration management

In polyglot repositories, keep configurations next to the code they govern. This reduces confusion and helps tooling auto-detect settings. A typical layout might look like:

project/
├── services/
│   ├── api-go/
│   │   ├── Makefile
│   │   ├── go.mod
│   │   ├── .golangci.yml
│   │   └── main.go
│   ├── worker-py/
│   │   ├── pyproject.toml
│   │   ├── requirements-dev.txt
│   │   ├── Makefile
│   │   └── worker.py
│   └── web-js/
│       ├── package.json
│       ├── eslint.config.mjs
│       ├── prettier.config.js
│       └── src/
├── .pre-commit-config.yaml
└── .github/workflows/
    ├── ci-go.yml
    ├── ci-py.yml
    └── ci-js.yml

Pre-commit is a helpful unifier because it supports language-specific hooks and can run tools before commits land in CI. Here is a minimal example for a project using Python, Go, and JavaScript:

repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-added-large-files

  - repo: https://github.com/psf/black
    rev: 24.8.0
    hooks:
      - id: black
        files: ^services/worker-py/

  - repo: https://github.com/pycqa/isort
    rev: 5.13.2
    hooks:
      - id: isort
        files: ^services/worker-py/

  - repo: https://github.com/golangci/golangci-lint
    rev: v1.60.1
    hooks:
      - id: golangci-lint
        files: ^services/api-go/

  - repo: https://github.com/eslint/eslint
    rev: v9.9.0
    hooks:
      - id: eslint
        files: ^services/web-js/

Language-specific examples and patterns

Python: Black, Ruff, Bandit

Black formats code, Ruff provides an ultra-fast linter (a modern replacement for flake8 and many plugins), and Bandit flags common security pitfalls.

Here is a typical setup using pyproject.toml for configuration:

[tool.black]
line-length = 100
target-version = ['py311']

[tool.ruff]
line-length = 100
target-version = "py311"
select = ["E", "F", "I", "UP", "S", "C901"]
ignore = ["S101"]  # allow assert in tests

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S"]  # ignore security checks in test code

[tool.isort]
profile = "black"
line_length = 100

A helpful pattern is to compose a Makefile so developers can run checks consistently:

.PHONY: lint format security test

lint:
	ruff check .
format:
	black .
	isort .
security:
	bandit -r . -f json -o bandit-report.json
test:
	pytest

Example Python code where static analysis catches issues:

import os
from pathlib import Path

def load_secret(filename: str) -> str:
    # Bandit will flag this as a potential secret leak
    with open(filename, "r") as f:
        return f.read()

def parse_user_input(data: str) -> int:
    # Linters and type checkers will flag unsafe eval usage
    return eval(data)  # BAD: eval is unsafe

def safe_load_env(var: str) -> str:
    # Static analysis may suggest using os.getenv with a default
    return os.environ.get(var, "")

Black will reformat misaligned code, Ruff will flag eval usage and missing imports, and Bandit will warn about reading arbitrary files. In CI, these checks can be enforced with a simple workflow:

name: CI Python
on:
  push:
    paths: ["services/worker-py/**"]
  pull_request:
    paths: ["services/worker-py/**"]

jobs:
  lint:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: services/worker-py
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - run: pip install -r requirements-dev.txt
      - run: ruff check .
      - run: black --check .
      - run: bandit -r . -f json -o bandit-report.json

JavaScript/TypeScript: ESLint, Prettier, and types

ESLint catches problematic patterns, Prettier formats code, and TypeScript adds type safety which reduces whole classes of bugs. ESLint’s flat config makes it easy to tune rules.

Here is a compact setup using ESLint’s flat config and Prettier:

// eslint.config.mjs
import js from "@eslint/js";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
import prettier from "eslint-config-prettier";

export default [
  js.configs.recommended,
  {
    files: ["**/*.ts", "**/*.tsx"],
    languageOptions: {
      parser: tsParser,
      ecmaVersion: 2022,
      sourceType: "module",
    },
    plugins: {
      "@typescript-eslint": tsPlugin,
    },
    rules: {
      "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
      "@typescript-eslint/no-explicit-any": "warn",
      "no-console": "warn",
    },
  },
  prettier,
];
// prettier.config.js
module.exports = {
  semi: true,
  singleQuote: false,
  trailingComma: "es5",
  printWidth: 100,
};

Example TypeScript where analysis helps:

interface User {
  id: number;
  name: string;
}

// This pattern is common but brittle; static analysis and types help
function getUser(id: number, users: Record<string, User>): User | undefined {
  // Missing object key checks are flagged by lint rules
  return users[id];
}

export async function fetchAndLog(userId: string) {
  const res = await fetch(`/users/${userId}`);
  const user: User = await res.json();
  // TypeScript will flag missing properties
  console.log(user.name);
}

In CI, you can run both linters and formatters, and gate on Prettier formatting:

name: CI JavaScript
on:
  push:
    paths: ["services/web-js/**"]
  pull_request:
    paths: ["services/web-js/**"]

jobs:
  lint:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: services/web-js
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci
      - run: npx eslint .
      - run: npx prettier --check .

Go: golangci-lint, gofmt, and go vet

Go ships with vet and fmt, and golangci-lint aggregates many linters for comprehensive checks. It is fast enough for pre-commit and CI.

A minimal .golangci.yml to keep things focused:

linters:
  enable:
    - govet
    - staticcheck
    - ineffassign
    - unused
    - misspell
    - gofmt

linters-settings:
  gofmt:
    simplify: true

issues:
  exclude-use-default: false
  max-issues-per-linter: 0
  max-same-issues: 0

Example Go code where static analysis helps:

package main

import (
	"fmt"
	"strings"
)

// This function uses a pointer to a slice which is unusual and can be
// flagged by linters and code reviewers.
func badIdea(s *[]string) {
	*s = append(*s, "new")
}

// The idiomatic approach is to return a new slice.
func goodIdea(s []string) []string {
	return append(s, "new")
}

func main() {
	x := []string{"a", "b"}
	x = goodIdea(x)
	fmt.Println(strings.Join(x, ", "))
}

Running golangci-lint in CI:

name: CI Go
on:
  push:
    paths: ["services/api-go/**"]
  pull_request:
    paths: ["services/api-go/**"]

jobs:
  lint:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: services/api-go
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: "1.22"
      - run: go mod download
      - run: golangci-lint run

Rust: Clippy and fmt

Rust’s built-in tools are excellent. Clippy is a linter that catches common mistakes and suggests idiomatic patterns. Cargo fmt enforces consistent formatting.

Add a few clippy flags to tighten checks:

# In Cargo.toml under a [lints.clippy] section or via command line
# Example of stricter checks
# cargo clippy -- -D clippy::pedantic -D clippy::unwrap_used

Example Rust code where clippy helps:

use std::fs::read_to_string;

fn read_file(path: &str) -> Result<String, std::io::Error> {
    // Clippy may suggest using ? instead of match for simpler flow
    let contents = read_to_string(path)?;
    Ok(contents)
}

fn main() {
    let data = match read_file("config.txt") {
        Ok(s) => s,
        Err(e) => {
            eprintln!("Error: {}", e);
            return;
        }
    };
    println!("Config length: {}", data.len());
}

Multi-language: Semgrep and CodeQL

Semgrep is powerful for writing custom rules across languages, and CodeQL is excellent for deep data-flow analysis. Both are CI-friendly. Semgrep rules can be simple patterns or more complex metavariable-based checks.

Example Semgrep rule to flag unsafe deserialization in Python:

rules:
  - id: unsafe-pickle
    patterns:
      - import pickle
      - pattern: pickle.loads($DATA)
    message: Avoid using pickle.loads on untrusted data.
    languages: [python]
    severity: ERROR

Example Semgrep rule to catch eval usage in JavaScript:

rules:
  - id: no-eval
    pattern: eval(...)
    message: Avoid using eval due to security risks.
    languages: [javascript, typescript]
    severity: ERROR

For CodeQL, you can enable the standard CodeQL queries for your languages in GitHub Advanced Security. CodeQL supports JavaScript, Python, Go, Java, C#, and more. It runs in CI and provides findings that can be triaged in the security tab.

Honest evaluation: strengths, weaknesses, tradeoffs

Strengths:

  • Faster feedback: Issues caught in pre-commit and IDE reduce review cycles.
  • Consistency: Formatters remove style debates and keep diffs small.
  • Security: SAST tools like Bandit, Semgrep, and CodeQL surface vulnerabilities early.
  • Maintainability: Linters help keep code idiomatic and readable across teams.

Weaknesses:

  • Noise: Overly strict rules produce false positives and fatigue. Tuning is necessary.
  • Setup overhead: Each language needs configuration and CI integration.
  • Performance: Some analyzers are slow on large codebases; incremental runs and caching help.
  • Coverage gaps: No static analysis finds all issues; runtime tests and manual reviews remain essential.

Tradeoffs:

  • Pre-commit vs CI: Pre-commit gives instant feedback but can slow initial commits if too many checks run. CI gives thorough checks but delays feedback. A balanced approach is style and obvious issues in pre-commit, deep analysis in CI.
  • Formatters vs linters: Formatters automate style; linters catch logic issues. Use both but keep the rule set lean.
  • Tool choice: Unified tools (Semgrep, SonarQube) reduce cognitive load but may not be as deep as language-native tools. Consider combining both.

When static analysis is not ideal:

  • Prototyping or scratch scripts where speed matters more than quality.
  • Codebases with heavy metaprogramming where many tools produce noise and require custom rules.
  • Legacy code with thousands of warnings; adopt incremental fixes and baseline strategies instead of blocking everything immediately.

Personal experience and lessons learned

Over several years working in polyglot teams, I’ve found that the right small set of tools per language beats an exhaustive list. A formatter plus one strong linter plus one security scanner tends to cover 80% of needs. When we added Ruff to Python projects, it cut linting time from minutes to seconds, which made pre-commit hooks viable. For Go, golangci-lint’s aggregated checks kept CI green and reduced nitpicks in code reviews. For JavaScript/TypeScript, Prettier eliminated most style debates, and ESLint’s flat config made it easier to share rules across repositories.

Common mistakes I’ve seen and made:

  • Adding too many rules at once, creating a wall of warnings. Fixing gradually works better.
  • Inconsistent configs across repositories, causing confusion and rework. Centralizing config templates helps.
  • Treating linter output as noise. Instead, triage findings and baseline known issues.
  • Forgetting formatters in CI. Always enforce format checks in CI even if you run them pre-commit.

One moment I found especially valuable: during a refactor of a Python service, Bandit flagged a hidden deserialization of untrusted data using pickle. It was not covered by tests and would have been easy to miss in code review. The fix was simple, but the impact was significant. That incident cemented the practice of adding at least one security scanner to every language in our stack.

Getting started: workflow and mental models

Start with these steps:

  • Identify your languages and the critical paths where failures are expensive. Prioritize those for static analysis first.
  • Pick one formatter, one linter, and one security scanner per language. Favor tools with good IDE integration and CI support.
  • Add checks incrementally. Begin with pre-commit for style and obvious issues; add deeper checks in CI.
  • Make results actionable. Use JSON or SARIF outputs to feed dashboards and triage findings. Gate CI on specific severity levels.
  • Review configurations as a team and document the rationale. Avoid personal taste; use consensus.
  • Measure outcomes: time saved in reviews, reduction in defects, and CI duration improvements.

Example workflow for a new service in a polyglot repo:

# New Python service setup
cd services/worker-py
python -m venv .venv
source .venv/bin/activate
pip install black isort ruff bandit pytest

# Create minimal configs (pyproject.toml shown earlier)
# Run once to see baseline issues
ruff check .
black --check .
bandit -r .

# Add to pre-commit
cd ../../
pre-commit install
pre-commit run --all-files

# Add CI workflow for the service (see GitHub Actions examples above)

Example workflow for a new TypeScript service:

cd services/web-js
npm init -y
npm install --save-dev eslint prettier @typescript-eslint/eslint-plugin @typescript-eslint/parser typescript

# Initialize configs (eslint.config.mjs and prettier.config.js shown earlier)
# Check formatting and linting
npx prettier --check .
npx eslint .

# Add scripts to package.json for convenience

The mental model is to build a fast feedback loop: catch style and obvious issues locally, run deeper checks in CI, and monitor for new patterns that need custom rules. Over time, refine rules based on findings and team feedback.

Free learning resources

Summary: who should use static analysis across languages, and who might skip it

Teams working in polyglot environments should adopt multi-language static analysis. It is especially valuable when:

  • Multiple services in different languages share a single repository or deployment pipeline.
  • Security or compliance requirements demand consistent, automated checks.
  • Code review bandwidth is limited and you want to automate low-value checks.
  • You are refactoring or migrating codebases and want guardrails.

Teams that might skip it or defer it include:

  • Very small projects where overhead outweighs benefits and manual reviews are sufficient.
  • Rapid prototypes where speed is prioritized over quality, though even there a lightweight formatter can help.
  • Codebases relying heavily on dynamic features or macros that generate code, unless tooling is tuned to ignore noisy areas.

Static analysis is not a silver bullet, but it is a reliable way to raise the floor of code quality and security. If you select a small, well-integrated set of tools, configure them to be actionable, and evolve them based on real feedback, you will spend less time on trivial issues and more time building the right things.