← ubon
import { glob } from 'glob';
import { readFileSync, statSync } from 'fs';
import { join } from 'path';
import { Scanner, ScanOptions, ScanResult } from '../types';
import { ResultCache } from '../utils/result-cache';
import { FileSourceCache, DEFAULT_MAX_FILE_SIZE } from '../utils/file-source-cache';
export interface FileContext {
file: string;
content: string;
lines: string[];
contentHash: string;
}
export abstract class BaseScanner implements Scanner {
abstract name: string;
abstract scan(options: ScanOptions): Promise<ScanResult[]>;
protected resultCache: ResultCache | null = null;
protected initCache(options: ScanOptions, signature: string): void {
this.resultCache = options.noResultCache ? null : new ResultCache(options.directory, signature);
}
protected getCached(file: string, contentHash: string): ScanResult[] | null {
return this.resultCache?.get(file, contentHash) ?? null;
}
protected setCached(file: string, contentHash: string, results: ScanResult[]): void {
this.resultCache?.set(file, contentHash, results);
}
protected saveCache(): void {
this.resultCache?.save();
}
protected async *iterateFiles(options: ScanOptions, pattern: string, ignore: string[]): AsyncGenerator<FileContext> {
const files = await glob(pattern, {
cwd: options.directory,
// dot:true so files like .cursor/mcp.json or .env.local are reachable;
// we still ignore noisy hidden dirs (.git, .next, etc.) explicitly.
dot: true,
ignore: [
'.git/**',
'.next/**',
'.svelte-kit/**',
'.turbo/**',
'.cache/**',
'.parcel-cache/**',
'.nuxt/**',
'.output/**',
'.vercel/**',
'.netlify/**',
// Build / coverage artifacts — HTML, .js.map, compiled output. These
// aren't source and produce massive A11Y / noise if scanned.
'coverage/**',
'dist/**',
'build/**',
'out/**',
'.ubon/**',
...ignore,
...(options.exclude || [])
]
});
const maxSize = options.maxFileSize || DEFAULT_MAX_FILE_SIZE;
const cache = FileSourceCache.forDirectory(options.directory);
for (const file of files) {
const absolute = join(options.directory, file);
try {
const stat = statSync(absolute);
if (stat.size > maxSize) {
if (options.verbose) {
console.error(`🪷 ${this.name}: skipping ${file} (size ${stat.size} > ${maxSize} bytes)`);
}
continue;
}
const content = cache.read(absolute) ?? readFileSync(absolute, 'utf-8');
const contentHash = ResultCache.hashContent(content);
yield { file, content, lines: content.split('\n'), contentHash };
} catch (error) {
if (options.verbose) {
console.error(`🪷 ${this.name}: failed to read ${file}:`, error);
}
}
}
}
protected hasFileSuppression(lines: string[]): boolean {
return lines.some((line) => /ubon-disable-file/.test(line));
}
protected isSuppressed(lines: string[], lineIndex: number, ruleId: string): boolean {
const disableNext = /ubon-disable-next-line\s+([A-Z0-9_,\s-]+)/.exec(lines[lineIndex] || '');
const prevDisable = lineIndex > 0 ? /ubon-disable-next-line\s+([A-Z0-9_,\s-]+)/.exec(lines[lineIndex - 1]) : null;
const disabledList = new Set<string>([
...(disableNext && disableNext[1] ? disableNext[1].split(/[,\s]+/).filter(Boolean) : []),
...(prevDisable && prevDisable[1] ? prevDisable[1].split(/[,\s]+/).filter(Boolean) : [])
]);
return disabledList.has(ruleId);
}
protected createResult(partial: Omit<ScanResult, 'range'> & { range?: ScanResult['range'] }, lineText?: string): ScanResult {
const range = partial.range || (partial.line
? {
startLine: partial.line,
startColumn: 1,
endLine: partial.line,
endColumn: Math.max(1, (lineText || '').length)
}
: undefined);
return {
...partial,
range
};
}
}