Files
english/.opencode/plugin/scout-block/pattern-matcher.cjs
2026-04-12 01:06:31 +07:00

205 lines
5.7 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* pattern-matcher.cjs - Gitignore-spec compliant pattern matching
*
* Uses 'ignore' package for .ckignore parsing and path matching.
* Supports negation patterns (!) for allowlisting.
*/
const Ignore = require('./vendor/ignore.cjs');
const fs = require('fs');
const path = require('path');
// Default patterns if .ckignore doesn't exist or is empty
// Only includes directories with HEAVY file counts (1000+ files typical)
const DEFAULT_PATTERNS = [
// JavaScript/TypeScript - package dependencies & build outputs
'node_modules',
'dist',
'build',
'.next',
'.nuxt',
// Python - virtualenvs & cache
'__pycache__',
'.venv',
'venv',
// Go/PHP - vendor dependencies
'vendor',
// Rust/Java - compiled outputs
'target',
// Version control
'.git',
// Test coverage (can be large with reports)
'coverage',
];
function readPatternsFromFile(filePath) {
if (!filePath || !fs.existsSync(filePath)) {
return [];
}
try {
return fs.readFileSync(filePath, 'utf-8')
.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'));
} catch (error) {
console.error('WARN: Failed to read .ckignore:', error.message);
return [];
}
}
/**
* Load patterns from the shipped .ckignore plus an optional project override.
* Falls back to DEFAULT_PATTERNS if the shipped file doesn't exist or is empty.
*
* @param {string} ckignorePath - Path to shipped/global .ckignore file
* @param {string} [projectCkignorePath] - Optional project-local .ckignore path
* @returns {string[]} Array of patterns
*/
function loadPatterns(ckignorePath, projectCkignorePath) {
const shippedPatterns = readPatternsFromFile(ckignorePath);
const projectPatterns = readPatternsFromFile(projectCkignorePath);
const basePatterns = shippedPatterns.length > 0 ? shippedPatterns : DEFAULT_PATTERNS;
return [...basePatterns, ...projectPatterns];
}
/**
* Create a matcher from patterns
* Normalizes patterns to match anywhere in the path tree
*
* @param {string[]} patterns - Array of patterns from .ckignore
* @returns {Object} Matcher object with ig instance and pattern info
*/
function createMatcher(patterns) {
const ig = Ignore();
// Normalize patterns to match anywhere in path tree
// e.g., "node_modules" becomes "**\/node_modules" and "**\/node_modules/**"
const normalizedPatterns = [];
for (const p of patterns) {
if (p.startsWith('!')) {
// Negation pattern - un-ignore
const inner = p.slice(1);
if (inner.includes('/') || inner.includes('*')) {
// Already has path or glob - use as-is
normalizedPatterns.push(p);
} else {
// Simple dir name - match anywhere
normalizedPatterns.push(`!**/${inner}`);
normalizedPatterns.push(`!**/${inner}/**`);
}
} else {
// Block pattern
if (p.includes('/') || p.includes('*')) {
// Already has path or glob - use as-is
normalizedPatterns.push(p);
} else {
// Simple dir name - match the dir and contents anywhere
normalizedPatterns.push(`**/${p}`);
normalizedPatterns.push(`**/${p}/**`);
// Also match at root
normalizedPatterns.push(p);
normalizedPatterns.push(`${p}/**`);
}
}
}
ig.add(normalizedPatterns);
return {
ig,
patterns: normalizedPatterns,
original: patterns
};
}
/**
* Check if a path should be blocked
*
* @param {Object} matcher - Matcher object from createMatcher
* @param {string} testPath - Path to test
* @returns {Object} { blocked: boolean, pattern?: string }
*/
function matchPath(matcher, testPath) {
if (!testPath || typeof testPath !== 'string') {
return { blocked: false };
}
// Normalize path separators (Windows backslash to forward slash)
let normalized = testPath.replace(/\\/g, '/');
// Remove leading ./ if present
if (normalized.startsWith('./')) {
normalized = normalized.slice(2);
}
// Strip leading / for absolute paths (ignore lib requires relative paths)
while (normalized.startsWith('/')) {
normalized = normalized.slice(1);
}
// Strip leading ../ segments (resolve parent references)
while (normalized.startsWith('../')) {
normalized = normalized.slice(3);
}
// Empty after normalization = not a blockable path
if (!normalized) {
return { blocked: false };
}
// Check if path is ignored (blocked)
const blocked = matcher.ig.ignores(normalized);
if (blocked) {
// Find which original pattern matched for error message
const matchedPattern = findMatchingPattern(matcher.original, normalized);
return { blocked: true, pattern: matchedPattern };
}
return { blocked: false };
}
/**
* Find which original pattern matched (for error messages)
*
* @param {string[]} originalPatterns - Original patterns from .ckignore
* @param {string} path - The path that was blocked
* @returns {string} The pattern that matched
*/
function findMatchingPattern(originalPatterns, path) {
for (const p of originalPatterns) {
if (p.startsWith('!')) continue; // Skip negations
// Simple substring check for common cases
const pattern = p.replace(/\*\*/g, '').replace(/\*/g, '');
if (pattern && path.includes(pattern)) {
return p;
}
// For more complex patterns, use ignore to test individually
const tempIg = Ignore();
if (p.includes('/') || p.includes('*')) {
tempIg.add(p);
} else {
tempIg.add([`**/${p}`, `**/${p}/**`, p, `${p}/**`]);
}
if (tempIg.ignores(path)) {
return p;
}
}
return originalPatterns.find(p => !p.startsWith('!')) || 'unknown';
}
module.exports = {
loadPatterns,
createMatcher,
matchPath,
findMatchingPattern,
DEFAULT_PATTERNS
};