@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
JavaScript
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