UNPKG

@felixgeelhaar/cclint

Version:

Catch CLAUDE.md drift before Claude misbehaves. Lints CLAUDE.md, skills, subagents, and hooks for Claude Code projects.

185 lines 5.69 kB
import { execFileSync } from 'child_process'; import { existsSync } from 'fs'; import { join } from 'path'; /** * Provides git diff information for linting only changed files/lines. */ export class GitDiffProvider { rootDir; constructor(rootDir) { this.rootDir = rootDir ?? process.cwd(); } /** * Check if the directory is a git repository */ isGitRepository() { return existsSync(join(this.rootDir, '.git')); } /** * Get list of changed CLAUDE.md files */ getChangedClaudeMdFiles(options = {}) { if (!this.isGitRepository()) { return []; } try { const ref = options.ref ?? 'HEAD'; const args = options.staged ? ['diff', '--cached', '--name-only', '--diff-filter=ACM'] : ['diff', '--name-only', '--diff-filter=ACM', ref]; const output = this.execGit(args); const files = output .split('\n') .map(f => f.trim()) .filter(f => f.length > 0); // Filter to only CLAUDE.md files return files.filter(f => f.endsWith('CLAUDE.md')); } catch { return []; } } /** * Get detailed diff information for a file */ getFileDiffInfo(filePath, options = {}) { const info = { filePath, isNew: false, isDeleted: false, changedRanges: [], }; if (!this.isGitRepository()) { return info; } try { const ref = options.ref ?? 'HEAD'; const args = options.staged ? ['diff', '--cached', '--unified=0', '--', filePath] : ['diff', '--unified=0', ref, '--', filePath]; const output = this.execGit(args); // Check if file is new if (output.includes('new file mode')) { info.isNew = true; return info; } // Check if file is deleted if (output.includes('deleted file mode')) { info.isDeleted = true; return info; } // Parse hunk headers to get changed line ranges // Format: @@ -start,count +start,count @@ const hunkRegex = /@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,(\d+))?\s+@@/g; let match; while ((match = hunkRegex.exec(output)) !== null) { const startLine = parseInt(match[2] ?? '1', 10); const count = parseInt(match[3] ?? '1', 10); const endLine = startLine + count - 1; if (count > 0) { info.changedRanges.push({ startLine, endLine: Math.max(startLine, endLine), }); } } return info; } catch { return info; } } /** * Check if a line number is within changed ranges */ isLineChanged(line, diffInfo) { // New files have all lines "changed" if (diffInfo.isNew) { return true; } // Deleted files have no lines if (diffInfo.isDeleted) { return false; } // Check if line is in any changed range return diffInfo.changedRanges.some(range => line >= range.startLine && line <= range.endLine); } /** * Get current branch name */ getCurrentBranch() { if (!this.isGitRepository()) { return null; } try { const output = this.execGit(['rev-parse', '--abbrev-ref', 'HEAD']); return output.trim() || null; } catch { return null; } } /** * Get the merge base with a target branch */ getMergeBase(targetBranch = 'main') { if (!this.isGitRepository()) { return null; } try { // Try with the specified branch, fall back to alternatives const branches = [targetBranch, 'main', 'master']; for (const branch of branches) { try { const output = this.execGit(['merge-base', 'HEAD', branch]); const sha = output.trim(); if (sha) return sha; } catch { continue; } } return null; } catch { return null; } } /** * Get untracked CLAUDE.md files */ getUntrackedClaudeMdFiles() { if (!this.isGitRepository()) { return []; } try { const output = this.execGit([ 'ls-files', '--others', '--exclude-standard', ]); const files = output .split('\n') .map(f => f.trim()) .filter(f => f.length > 0); return files.filter(f => f.endsWith('CLAUDE.md')); } catch { return []; } } /** * Execute git with an explicit argument array (never a shell string), so a * ref, branch, or file path can never be interpreted by a shell. This is the * security boundary: --diff-ref and staged file paths flow in as data. */ execGit(args) { return execFileSync('git', args, { cwd: this.rootDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], }); } } //# sourceMappingURL=GitDiffProvider.js.map