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:
- Accepts file paths or stdin
- Sends code to Claude Haiku (fast, cheap) with a lint-focused system prompt
- Returns structured JSON with issue location, severity, and description
- 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:
- Latency: 1.2-2.5 seconds
- Cost: ~$0.0008 per file
- Issue detection: high for logic bugs, medium for security issues
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.
Related Reading
- AI Code Review Automation Tools Comparison
- Prompt Engineering Patterns for Code Generation
- AI Debugging Assistants Compared 2026
Built by theluckystrike — More at zovo.one