AI Tools Compared

Static linters catch syntax errors and style violations. AI linters catch logic errors, security anti-patterns, and architectural problems that rules-based systems miss. This guide walks through building a working AI code linter: a Node.js CLI tool that calls an LLM API to analyze code and output structured lint results.

The Architecture

An AI linter differs from ESLint or Pylint in one key way: instead of matching patterns against an AST, it sends code to an LLM with a structured prompt and parses the JSON response. The tradeoff is latency and cost vs. catching nuanced issues.

The tool we’ll build:

  1. Accepts file paths or stdin
  2. Sends code to Claude Haiku (fast, cheap) with a lint-focused system prompt
  3. Returns structured JSON with issue location, severity, and description
  4. Exits with code 1 if errors are found (CI-compatible)

Setting Up the Project

mkdir ai-linter && cd ai-linter
npm init -y
npm install @anthropic-ai/sdk commander glob

Core Linter Implementation

// src/linter.js
import Anthropic from '@anthropic-ai/sdk';
import { readFileSync } from 'fs';

const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

const SYSTEM_PROMPT = `You are a code linter. Analyze code for real bugs, security issues, and bad practices.

Return ONLY valid JSON in this exact format:
{
  "issues": [
    {
      "line": <number>,
      "column": <number>,
      "severity": "error" | "warning" | "info",
      "rule": "<short-rule-name>",
      "message": "<description of the issue>"
    }
  ]
}

Focus on:
- Security vulnerabilities (SQL injection, XSS, hardcoded secrets)
- Logic errors (off-by-one, null dereference, unreachable code)
- Performance issues (N+1 queries, unnecessary re-renders)
- Bad patterns (mutable default args in Python, var in JS, etc.)

Do NOT report style issues like formatting or naming conventions.
Return an empty issues array if no problems found.`;

export async function lintFile(filePath) {
  const code = readFileSync(filePath, 'utf-8');
  const extension = filePath.split('.').pop();

  const response = await client.messages.create({
    model: 'claude-haiku-4-5',
    max_tokens: 1024,
    system: SYSTEM_PROMPT,
    messages: [{
      role: 'user',
      content: `File: ${filePath}\nLanguage: ${extension}\n\n\`\`\`${extension}\n${code}\n\`\`\``
    }]
  });

  const text = response.content[0].text;
  const jsonMatch = text.match(/\{[\s\S]*\}/);
  if (!jsonMatch) throw new Error(`Could not parse linter response for ${filePath}`);

  const result = JSON.parse(jsonMatch[0]);
  return { filePath, issues: result.issues || [] };
}

CLI Entry Point

// src/cli.js
import { program } from 'commander';
import { glob } from 'glob';
import { lintFile } from './linter.js';

program
  .name('ai-lint')
  .argument('[patterns...]', 'File patterns to lint', ['**/*.js', '**/*.ts', '**/*.py'])
  .option('--json', 'Output results as JSON')
  .option('--severity <level>', 'Minimum severity (error|warning|info)', 'warning')
  .action(async (patterns, options) => {
    const files = await glob(patterns, {
      ignore: ['node_modules/**', '.git/**', 'dist/**']
    });

    if (files.length === 0) { console.error('No files matched'); process.exit(0); }

    const severityOrder = { error: 0, warning: 1, info: 2 };
    const minSeverity = severityOrder[options.severity] ?? 1;
    let totalErrors = 0;
    const allResults = [];

    const batchSize = 5;
    for (let i = 0; i < files.length; i += batchSize) {
      const batch = files.slice(i, i + batchSize);
      const results = await Promise.all(batch.map(lintFile));

      for (const result of results) {
        const filtered = result.issues.filter(
          issue => severityOrder[issue.severity] <= minSeverity
        );
        if (filtered.length > 0) {
          allResults.push({ ...result, issues: filtered });
          totalErrors += filtered.filter(i => i.severity === 'error').length;
        }
      }
    }

    if (options.json) {
      console.log(JSON.stringify(allResults, null, 2));
    } else {
      for (const result of allResults) {
        for (const issue of result.issues) {
          const loc = `${result.filePath}:${issue.line}:${issue.column}`;
          const sev = issue.severity.toUpperCase().padEnd(7);
          console.log(`${loc}  ${sev}  ${issue.message}  (${issue.rule})`);
        }
      }
      console.log(`\n${totalErrors} error(s) found across ${allResults.length} file(s)`);
    }

    if (totalErrors > 0) process.exit(1);
  });

program.parse();

CI Integration

# .github/workflows/ai-lint.yml
name: AI Code Lint

on:
  pull_request:
    paths: ['**/*.js', '**/*.ts', '**/*.py']

jobs:
  ai-lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - name: Run AI linter on changed files
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: |
          git diff --name-only origin/main...HEAD \
            | grep -E '\.(js|ts|py)$' \
            | xargs node src/cli.js --severity error

Running only on changed files keeps CI costs under control. A typical PR with 10 changed files costs under $0.01 with Claude Haiku.

Adding File-Level Caching

import { createHash } from 'crypto';
import { existsSync, readFileSync, writeFileSync } from 'fs';

const CACHE_PATH = '.ai-lint-cache.json';

function getCache() {
  if (!existsSync(CACHE_PATH)) return {};
  return JSON.parse(readFileSync(CACHE_PATH, 'utf-8'));
}

function hashFile(content) {
  return createHash('sha256').update(content).digest('hex').slice(0, 16);
}

export async function lintFileWithCache(filePath) {
  const content = readFileSync(filePath, 'utf-8');
  const hash = hashFile(content);
  const cache = getCache();

  if (cache[filePath]?.hash === hash) {
    return cache[filePath].result;
  }

  const result = await lintFile(filePath);
  cache[filePath] = { hash, result };
  writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2));
  return result;
}

With caching, subsequent runs on unchanged files are instant and free.

Cost and Performance

On a 500-line TypeScript file with Claude Haiku:

For most teams linting a 50-file PR, total cost is under $0.05. The latency is the bigger concern — running 50 files in parallel takes 3-5 seconds total with the concurrency approach above.

Built by theluckystrike — More at zovo.one