This commit is contained in:
2026-04-12 01:06:31 +07:00
commit 10d660cbcb
1066 changed files with 228596 additions and 0 deletions

View File

@@ -0,0 +1,264 @@
#!/usr/bin/env node
/**
* broad-pattern-detector.cjs - Detect overly broad glob patterns
*
* Prevents LLMs from filling context by using patterns like "all files"
* at project root, which returns ALL files of a type.
*
* Detection Strategy:
* 1. Pattern breadth: recursive glob at start = recursive everywhere
* 2. Path depth: Root or shallow paths are high-risk
* 3. Combined: Broad pattern + high-level path = BLOCK
*/
const path = require('path');
// Patterns that recursively match everywhere when at root
// These are dangerous because they return ALL matching files
const BROAD_PATTERN_REGEXES = [
// ** - all files everywhere (no filter at all)
/^\*\*$/,
// * - all files in root
/^\*$/,
// **/* - all files everywhere
/^\*\*\/\*$/,
// **/. - all dotfiles everywhere
/^\*\*\/\.\*$/,
// *.ext at root (matches all in root, but combined with deep search)
/^\*\.\w+$/,
// *.{ext,ext2} at root
/^\*\.\{[^}]+\}$/,
// **/*.ext - all files of type everywhere (e.g., **/*.ts, **/*.js)
/^\*\*\/\*\.\w+$/,
// **/*.{ext,ext2} - all files of multiple types everywhere
/^\*\*\/\*\.\{[^}]+\}$/,
];
// Common source directories that indicate a more specific search
const SPECIFIC_DIRS = [
'src', 'lib', 'app', 'apps', 'packages', 'components', 'pages',
'api', 'server', 'client', 'web', 'mobile', 'shared', 'common',
'utils', 'helpers', 'services', 'hooks', 'store', 'routes',
'models', 'controllers', 'views', 'tests', '__tests__', 'spec'
];
// High-risk paths (project/worktree roots)
const HIGH_RISK_INDICATORS = [
// Worktree paths
/\/worktrees\/[^/]+\/?$/,
// Project roots (contain package.json, etc.)
/^\.?\/?$/,
// Shallow paths (just one directory deep)
/^[^/]+\/?$/
];
/**
* Check if a glob pattern is overly broad
*
* @param {string} pattern - The glob pattern to check
* @returns {boolean}
*/
function isBroadPattern(pattern) {
if (!pattern || typeof pattern !== 'string') return false;
const normalized = pattern.trim();
// Check against known broad patterns
for (const regex of BROAD_PATTERN_REGEXES) {
if (regex.test(normalized)) {
return true;
}
}
return false;
}
/**
* Check if pattern contains a specific subdirectory.
* Scoped patterns like "src/..." are OK because they target specific dirs.
*
* @param {string} pattern - The glob pattern
* @returns {boolean}
*/
function hasSpecificDirectory(pattern) {
if (!pattern) return false;
// Check if pattern starts with a specific directory
for (const dir of SPECIFIC_DIRS) {
if (pattern.startsWith(`${dir}/`) || pattern.startsWith(`./${dir}/`)) {
return true;
}
}
// Check for any non-glob directory prefix
// e.g., "mydir/..." has a specific directory
const firstSegment = pattern.split('/')[0];
if (firstSegment && !firstSegment.includes('*') && firstSegment !== '.') {
return true;
}
return false;
}
/**
* Check if the base path is at a high-level (risky) location
*
* @param {string} basePath - The path where glob will run
* @param {string} cwd - Current working directory
* @returns {boolean}
*/
function isHighLevelPath(basePath, cwd) {
// No path specified = uses CWD (often project root)
if (!basePath) return true;
const normalized = basePath.replace(/\\/g, '/');
// Check high-risk indicators
for (const regex of HIGH_RISK_INDICATORS) {
if (regex.test(normalized)) {
return true;
}
}
// Check path depth - shallow paths are higher risk
const segments = normalized.split('/').filter(s => s && s !== '.');
if (segments.length <= 1) {
return true;
}
// If path doesn't contain a specific directory, it's high-level
const hasSpecific = SPECIFIC_DIRS.some(dir =>
normalized.includes(`/${dir}/`) || normalized.includes(`/${dir}`) ||
normalized.startsWith(`${dir}/`) || normalized === dir
);
return !hasSpecific;
}
/**
* Generate suggestions for more specific patterns
*
* @param {string} pattern - The broad pattern
* @returns {string[]}
*/
function suggestSpecificPatterns(pattern) {
const suggestions = [];
// Extract the extension/file part from the pattern
let ext = '';
const extMatch = pattern.match(/\*\.(\{[^}]+\}|\w+)$/);
if (extMatch) {
ext = extMatch[1];
}
// Suggest common directories
const commonDirs = ['src', 'lib', 'app', 'components'];
for (const dir of commonDirs) {
if (ext) {
suggestions.push(`${dir}/**/*.${ext}`);
} else {
suggestions.push(`${dir}/**/*`);
}
}
// If it's a TypeScript pattern, add specific suggestions
if (pattern.includes('.ts') || pattern.includes('{ts')) {
suggestions.unshift('src/**/*.ts', 'src/**/*.tsx');
}
// If it's a JavaScript pattern
if (pattern.includes('.js') || pattern.includes('{js')) {
suggestions.unshift('src/**/*.js', 'lib/**/*.js');
}
return suggestions.slice(0, 4); // Return top 4 suggestions
}
/**
* Main detection function - check if a Glob tool call is problematic
*
* @param {Object} toolInput - The tool_input from hook JSON
* @param {string} toolInput.pattern - The glob pattern
* @param {string} [toolInput.path] - Optional base path
* @returns {Object} { blocked: boolean, reason?: string, suggestions?: string[] }
*/
function detectBroadPatternIssue(toolInput) {
if (!toolInput || typeof toolInput !== 'object') {
return { blocked: false };
}
const { pattern, path: basePath } = toolInput;
// No pattern = nothing to check
if (!pattern) {
return { blocked: false };
}
// Pattern has a specific directory = OK
if (hasSpecificDirectory(pattern)) {
return { blocked: false };
}
// Check if pattern is broad
if (!isBroadPattern(pattern)) {
return { blocked: false };
}
// Check if path is high-level
if (!isHighLevelPath(basePath)) {
return { blocked: false };
}
// Broad pattern at high-level path = BLOCK
return {
blocked: true,
reason: `Pattern '${pattern}' is too broad for ${basePath || 'project root'}`,
pattern: pattern,
suggestions: suggestSpecificPatterns(pattern)
};
}
/**
* Format error message for broad pattern detection
*
* @param {Object} result - Result from detectBroadPatternIssue
* @param {string} claudeDir - Path to .claude directory
* @returns {string}
*/
function formatBroadPatternError(result, claudeDir) {
const { reason, pattern, suggestions } = result;
const lines = [
'',
'\x1b[36mNOTE:\x1b[0m This is not an error - this block is intentional to optimize context.',
'',
'\x1b[31mBLOCKED\x1b[0m: Overly broad glob pattern detected',
'',
` \x1b[33mPattern:\x1b[0m ${pattern}`,
` \x1b[33mReason:\x1b[0m Would return ALL matching files, filling context`,
'',
' \x1b[34mUse more specific patterns:\x1b[0m',
];
for (const suggestion of suggestions || []) {
lines.push(`${suggestion}`);
}
lines.push('');
lines.push(' \x1b[2mTip: Target specific directories to avoid context overflow\x1b[0m');
lines.push('');
return lines.join('\n');
}
module.exports = {
isBroadPattern,
hasSpecificDirectory,
isHighLevelPath,
suggestSpecificPatterns,
detectBroadPatternIssue,
formatBroadPatternError,
BROAD_PATTERN_REGEXES,
SPECIFIC_DIRS,
HIGH_RISK_INDICATORS
};

View File

@@ -0,0 +1,161 @@
#!/usr/bin/env node
/**
* error-formatter.cjs - Rich, actionable error messages for scout-block
*
* Follows CLI UX best practices: Problem + Reason + Solution
* Supports ANSI colors with NO_COLOR env var respect.
*/
const path = require('path');
// ANSI color codes
const COLORS = {
red: '\x1b[31m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
bold: '\x1b[1m',
dim: '\x1b[2m',
reset: '\x1b[0m'
};
/**
* Check if terminal supports colors
* Respects NO_COLOR standard and FORCE_COLOR
*
* @returns {boolean}
*/
function supportsColor() {
// Respect NO_COLOR standard (https://no-color.org/)
if (process.env.NO_COLOR !== undefined) return false;
// Respect FORCE_COLOR
if (process.env.FORCE_COLOR !== undefined) return true;
// Check if stderr is TTY (we output errors to stderr)
return process.stderr.isTTY || false;
}
/**
* Apply color to text if supported
*
* @param {string} text - Text to colorize
* @param {string} color - Color name from COLORS
* @returns {string}
*/
function colorize(text, color) {
if (!supportsColor()) return text;
const colorCode = COLORS[color] || '';
return `${colorCode}${text}${COLORS.reset}`;
}
/**
* Get .ckignore config path
*
* @param {string} claudeDir - Path to .claude directory
* @param {string} [configPath] - Explicit config path to prefer
* @returns {string}
*/
function formatConfigPath(claudeDir, configPath) {
if (configPath) {
return configPath;
}
if (claudeDir) {
return path.join(claudeDir, '.ckignore');
}
return '.claude/.ckignore';
}
/**
* Format a blocked path error with actionable guidance
*
* Pattern: What went wrong → Why → How to fix → Where to configure
*
* @param {Object} details - Error details
* @param {string} details.path - The blocked path
* @param {string} details.pattern - The pattern that matched
* @param {string} details.tool - The tool that was blocked
* @param {string} details.claudeDir - Path to .claude directory
* @param {string} [details.configPath] - Explicit config path to edit
* @returns {string}
*/
function formatBlockedError(details) {
const { path: blockedPath, pattern, tool, claudeDir, configPath } = details;
const resolvedConfigPath = formatConfigPath(claudeDir, configPath);
// Truncate path if too long
const displayPath = blockedPath.length > 60
? '...' + blockedPath.slice(-57)
: blockedPath;
const lines = [
'',
colorize('NOTE:', 'cyan') + ' This is not an error - this block is intentional to optimize context.',
'',
colorize('BLOCKED', 'red') + `: Access to '${displayPath}' denied`,
'',
` ${colorize('Pattern:', 'yellow')} ${pattern}`,
` ${colorize('Tool:', 'yellow')} ${tool || 'unknown'}`,
'',
` ${colorize('To allow, add to', 'blue')} ${resolvedConfigPath}:`,
` !${pattern}`,
'',
` ${colorize('Config:', 'dim')} ${resolvedConfigPath}`,
''
];
return lines.join('\n');
}
/**
* Format a simple error message (one line, for piped output)
*
* @param {string} pattern - The pattern that matched
* @param {string} blockedPath - The path that was blocked
* @returns {string}
*/
function formatSimpleError(pattern, blockedPath) {
return `ERROR: Blocked pattern '${pattern}' matched path: ${blockedPath}`;
}
/**
* Format error for machine-readable output (exit code 2)
* Used when stderr is not a TTY
*
* @param {Object} details - Error details
* @returns {string}
*/
function formatMachineError(details) {
const { path: blockedPath, pattern, tool, claudeDir, configPath } = details;
const resolvedConfigPath = formatConfigPath(claudeDir, configPath);
return JSON.stringify({
error: 'BLOCKED',
path: blockedPath,
pattern: pattern,
tool: tool,
config: resolvedConfigPath,
fix: `Add '!${pattern}' to ${resolvedConfigPath} to allow this path`
});
}
/**
* Format a warning message (non-blocking)
*
* @param {string} message - Warning message
* @returns {string}
*/
function formatWarning(message) {
return colorize('WARN:', 'yellow') + ' ' + message;
}
module.exports = {
formatBlockedError,
formatSimpleError,
formatMachineError,
formatWarning,
formatConfigPath,
supportsColor,
colorize,
COLORS
};

View File

@@ -0,0 +1,327 @@
#!/usr/bin/env node
/**
* path-extractor.cjs - Extract paths from Claude Code tool inputs
*
* Extracts file_path, path, pattern params and parses Bash commands
* to find all path-like arguments.
*/
// Flags that indicate the following value should NOT be checked as a path
// These are "exclude" semantics - the user is explicitly skipping these paths
const EXCLUDE_FLAGS = [
'--exclude', '--ignore', '--skip', '--prune',
'-x', // tar exclude shorthand
'-path', // find -path (used with -prune)
'--exclude-dir' // grep --exclude-dir
];
// Filesystem commands where bare directory names (build, dist, etc.)
// should be extracted as paths. For non-fs commands (grep, echo, sed),
// only tokens that look like actual paths (contain / or extension) are extracted.
const FILESYSTEM_COMMANDS = [
'cd', 'ls', 'cat', 'head', 'tail', 'less', 'more',
'rm', 'cp', 'mv', 'find', 'touch', 'mkdir', 'rmdir',
'stat', 'file', 'du', 'tree', 'chmod', 'chown', 'ln',
'readlink', 'realpath', 'wc', 'tee', 'tar', 'zip', 'unzip',
'open', 'code', 'vim', 'nano', 'bat', 'rsync', 'scp', 'diff'
];
/**
* Extract all paths from a tool_input object
* Handles: file_path, path, pattern params and command strings
*
* @param {Object} toolInput - The tool_input from hook JSON
* @returns {string[]} Array of extracted paths
*/
function extractFromToolInput(toolInput) {
const paths = [];
if (!toolInput || typeof toolInput !== 'object') {
return paths;
}
// Direct path params (Read, Edit, Write, Grep, Glob tools)
const directParams = ['file_path', 'path', 'pattern'];
for (const param of directParams) {
if (toolInput[param] && typeof toolInput[param] === 'string') {
const normalized = normalizeExtractedPath(toolInput[param]);
if (normalized) paths.push(normalized);
}
}
// Extract from Bash command if present
if (toolInput.command && typeof toolInput.command === 'string') {
const cmdPaths = extractFromCommand(toolInput.command);
paths.push(...cmdPaths);
}
return paths.filter(Boolean);
}
/**
* Extract path-like segments from a Bash command string.
*
* Uses pipe-segment-aware command context: for filesystem commands (cd, cat, ls, rm, etc.)
* bare blocked directory names are extracted with priority. For non-filesystem commands
* (grep, echo, sed, etc.) only tokens that structurally look like paths are extracted,
* preventing false positives on search terms and string arguments.
*
* @param {string} command - The command string
* @returns {string[]} Array of extracted paths
*/
function extractFromCommand(command) {
if (!command || typeof command !== 'string') {
return [];
}
const paths = [];
// First, extract quoted strings (preserve spaces in paths)
const quotedPattern = /["']([^"']+)["']/g;
let match;
while ((match = quotedPattern.exec(command)) !== null) {
const content = match[1];
// Skip sed/awk regex expressions (s/pattern/replacement/flags)
if (/^s[\/|@#,]/.test(content)) continue;
if (looksLikePath(content)) {
paths.push(normalizeExtractedPath(content));
}
}
// Remove quoted strings for unquoted path extraction
const withoutQuotes = command.replace(/["'][^"']*["']/g, ' ');
// Split on whitespace and extract path-like tokens
const tokens = withoutQuotes.split(/\s+/).filter(Boolean);
// Track command context per pipe segment
let commandName = null;
let isFsCommand = false;
let skipNextToken = false;
let heredocDelimiter = null;
let nextIsHeredocDelimiter = false;
for (const token of tokens) {
// Heredoc delimiter capture (after << or <<-)
if (nextIsHeredocDelimiter) {
heredocDelimiter = token.replace(/^['"]/, '').replace(/['"]$/, '');
nextIsHeredocDelimiter = false;
continue;
}
// Skip heredoc body content until closing delimiter
if (heredocDelimiter) {
if (token === heredocDelimiter) {
heredocDelimiter = null;
}
continue;
}
// Detect heredoc start: <<EOF, <<'EOF', <<"EOF", <<-EOF
if (token.startsWith('<<') && token.length > 2) {
heredocDelimiter = token.replace(/^<<-?['"]?/, '').replace(/['"]?$/, '');
continue;
}
if (token === '<<' || token === '<<-') {
nextIsHeredocDelimiter = true;
continue;
}
// Skip value after exclude flags (--exclude node_modules format)
if (skipNextToken) {
skipNextToken = false;
continue;
}
// Reset command context at command/pipe boundaries
if (token === '&&' || token === ';' || token.startsWith('|')) {
commandName = null;
isFsCommand = false;
continue;
}
// Skip flags and shell operators
if (isSkippableToken(token)) {
if (EXCLUDE_FLAGS.includes(token)) {
skipNextToken = true;
}
continue;
}
// Determine the command for this pipe segment (first non-flag token)
if (commandName === null) {
commandName = token.toLowerCase();
isFsCommand = FILESYSTEM_COMMANDS.includes(commandName);
// Skip the command word itself
if (isCommandKeyword(token) || isFsCommand) continue;
// Non-keyword command (e.g., ./script.sh) — fall through to path check
}
// For filesystem commands, extract blocked dir names with priority.
// "cd build", "ls dist", "cat node_modules/..." — "build"/"dist" are paths here.
if (isFsCommand && isBlockedDirName(token)) {
paths.push(normalizeExtractedPath(token));
continue;
}
// Skip common non-path command words
if (isCommandKeyword(token)) continue;
// Check if it looks like a path
if (looksLikePath(token)) {
paths.push(normalizeExtractedPath(token));
}
}
return paths;
}
// Common blocked directory names that should be extracted even if they
// match command keywords (e.g., "build" is both a subcommand and a dir name)
// Keep in sync with DEFAULT_PATTERNS in pattern-matcher.cjs
const BLOCKED_DIR_NAMES = [
'node_modules', '__pycache__', '.git', 'dist', 'build',
'.next', '.nuxt', '.venv', 'venv', 'vendor', 'target', 'coverage'
];
/**
* Check if token is exactly a blocked directory name
* This takes priority over command keyword filtering
*
* @param {string} token - Token to check
* @returns {boolean}
*/
function isBlockedDirName(token) {
return BLOCKED_DIR_NAMES.includes(token);
}
/**
* Check if a string looks like a file path
*
* @param {string} str - String to check
* @returns {boolean}
*/
function looksLikePath(str) {
if (!str || str.length < 2) return false;
// Contains path separator
if (str.includes('/') || str.includes('\\')) return true;
// Starts with relative path indicator
if (str.startsWith('./') || str.startsWith('../')) return true;
// Has file extension (likely a file)
if (/\.\w{1,6}$/.test(str)) return true;
// Looks like a directory path
if (/^[a-zA-Z0-9_-]+\//.test(str)) return true;
return false;
}
/**
* Check if token should be skipped (flags, operators)
*
* @param {string} token - Token to check
* @returns {boolean}
*/
function isSkippableToken(token) {
// Flags
if (token.startsWith('-')) return true;
// Shell operators
if (['|', '||', '&&', '>', '>>', '<', '<<', '&', ';'].includes(token)) return true;
if (token.startsWith('|') || token.startsWith('>') || token.startsWith('<')) return true;
if (token.startsWith('&')) return true;
// Numeric values
if (/^\d+$/.test(token)) return true;
return false;
}
/**
* Check if token is a common command keyword (not a path)
*
* @param {string} token - Token to check
* @returns {boolean}
*/
function isCommandKeyword(token) {
const keywords = [
// Shell commands
'echo', 'cat', 'ls', 'cd', 'rm', 'cp', 'mv', 'find', 'grep', 'head', 'tail',
'wc', 'du', 'tree', 'touch', 'mkdir', 'rmdir', 'pwd', 'which', 'env', 'export',
'source', 'bash', 'sh', 'zsh', 'true', 'false', 'test', 'xargs', 'tee', 'sort',
'uniq', 'cut', 'tr', 'sed', 'awk', 'diff', 'chmod', 'chown', 'ln', 'file',
// Package managers and their subcommands
'npm', 'pnpm', 'yarn', 'bun', 'npx', 'pnpx', 'bunx', 'node',
'run', 'build', 'test', 'lint', 'dev', 'start', 'install', 'ci', 'exec',
'add', 'remove', 'update', 'publish', 'pack', 'init', 'create',
// Build tools
'tsc', 'esbuild', 'vite', 'webpack', 'rollup', 'turbo', 'nx',
'jest', 'vitest', 'mocha', 'eslint', 'prettier',
// Git
'git', 'commit', 'push', 'pull', 'merge', 'rebase', 'checkout', 'branch',
'status', 'log', 'diff', 'add', 'reset', 'stash', 'fetch', 'clone',
// Docker
'docker', 'compose', 'up', 'down', 'ps', 'logs', 'exec', 'container', 'image',
// Misc
'sudo', 'time', 'timeout', 'watch', 'make', 'cargo', 'python', 'python3', 'pip',
'ruby', 'gem', 'go', 'rust', 'java', 'javac', 'mvn', 'gradle'
];
return keywords.includes(token.toLowerCase());
}
/**
* Normalize an extracted path
* - Remove surrounding quotes
* - Normalize path separators to forward slash
*
* @param {string} path - Path to normalize
* @returns {string} Normalized path
*/
function normalizeExtractedPath(path) {
if (!path) return '';
let normalized = path.trim();
// Remove surrounding quotes
if ((normalized.startsWith('"') && normalized.endsWith('"')) ||
(normalized.startsWith("'") && normalized.endsWith("'"))) {
normalized = normalized.slice(1, -1);
}
// Strip shell metacharacters from edges (backticks, parens, braces)
normalized = normalized.replace(/^[`({\[]+/, '').replace(/[`)};\]]+$/, '');
// Normalize path separators to forward slash
normalized = normalized.replace(/\\/g, '/');
// Remove trailing slash for consistency
if (normalized.endsWith('/') && normalized.length > 1) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
module.exports = {
extractFromToolInput,
extractFromCommand,
looksLikePath,
isSkippableToken,
isCommandKeyword,
isBlockedDirName,
normalizeExtractedPath,
BLOCKED_DIR_NAMES,
EXCLUDE_FLAGS,
FILESYSTEM_COMMANDS
};

View File

@@ -0,0 +1,204 @@
#!/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
};

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env node
/**
* test-broad-pattern-detector.cjs - Unit tests for broad pattern detection
*
* Tests the detection of overly broad glob patterns that would fill context.
*/
const {
isBroadPattern,
hasSpecificDirectory,
isHighLevelPath,
detectBroadPatternIssue,
suggestSpecificPatterns
} = require('../broad-pattern-detector.cjs');
// === isBroadPattern tests ===
const broadPatternTests = [
// Should be detected as broad - TypeScript/JavaScript
{ pattern: '**/*', expected: true, desc: 'all files everywhere' },
{ pattern: '**', expected: true, desc: 'double star alone' },
{ pattern: '*', expected: true, desc: 'single star alone' },
{ pattern: '**/.*', expected: true, desc: 'all dotfiles' },
// Should NOT be detected as broad (specific)
{ pattern: 'package.json', expected: false, desc: 'specific file' },
{ pattern: 'src/index.ts', expected: false, desc: 'specific file path' },
{ pattern: null, expected: false, desc: 'null pattern' },
{ pattern: '', expected: false, desc: 'empty pattern' },
];
// === isHighLevelPath tests ===
const highLevelPathTests = [
// High level (risky)
{ path: null, expected: true, desc: 'null path (uses CWD)' },
{ path: undefined, expected: true, desc: 'undefined path' },
{ path: '.', expected: true, desc: 'current directory' },
{ path: './', expected: true, desc: 'current directory with slash' },
{ path: '', expected: true, desc: 'empty path' },
{ path: '/home/user/worktrees/myproject', expected: true, desc: 'worktree root' },
{ path: 'myproject', expected: true, desc: 'single directory' },
// Specific (OK)
{ path: 'src/components', expected: false, desc: 'nested in src' },
{ path: 'lib/utils', expected: false, desc: 'nested in lib' },
{ path: 'packages/web/src', expected: false, desc: 'monorepo src' },
{ path: '/home/user/project/src', expected: false, desc: 'absolute with src' },
];
// === detectBroadPatternIssue integration tests ===
const integrationTests = [
// Should BLOCK
{
input: { pattern: '**/*.ts' },
expected: true,
desc: 'broad pattern, no path'
},
{
input: { pattern: '**/*.{ts,tsx}', path: '/home/user/worktrees/myproject' },
expected: true,
desc: 'broad pattern at worktree'
},
{
input: { pattern: '**/*', path: '.' },
expected: true,
desc: 'all files at current dir'
},
{
input: { pattern: '**/index.ts', path: 'myproject' },
expected: true,
desc: 'all index.ts at shallow path'
},
// Should ALLOW
{
input: { pattern: 'src/**/*.ts' },
expected: false,
desc: 'scoped to src'
},
{
input: { pattern: '**/*.ts', path: 'src/components' },
expected: false,
desc: 'broad pattern but specific path'
},
{
input: { pattern: 'package.json' },
expected: false,
desc: 'specific file'
},
{
input: { pattern: 'lib/**/*.js', path: '/home/user/project' },
expected: false,
desc: 'scoped pattern'
},
{
input: {},
expected: false,
desc: 'no pattern'
},
{
input: null,
expected: false,
desc: 'null input'
},
];
// Run tests
console.log('Testing broad-pattern-detector module...\n');
let passed = 0;
let failed = 0;
// Test isBroadPattern
console.log('\x1b[1m--- isBroadPattern ---\x1b[0m');
for (const test of broadPatternTests) {
const result = isBroadPattern(test.pattern);
const success = result === test.expected;
if (success) {
console.log(`\x1b[32m✓\x1b[0m ${test.desc}: "${test.pattern}" -> ${result ? 'BROAD' : 'OK'}`);
passed++;
} else {
console.log(`\x1b[31m✗\x1b[0m ${test.desc}: expected ${test.expected ? 'BROAD' : 'OK'}, got ${result ? 'BROAD' : 'OK'}`);
failed++;
}
}
// Test isHighLevelPath
console.log('\n\x1b[1m--- isHighLevelPath ---\x1b[0m');
for (const test of highLevelPathTests) {
const result = isHighLevelPath(test.path);
const success = result === test.expected;
if (success) {
console.log(`\x1b[32m✓\x1b[0m ${test.desc}: "${test.path}" -> ${result ? 'HIGH_LEVEL' : 'SPECIFIC'}`);
passed++;
} else {
console.log(`\x1b[31m✗\x1b[0m ${test.desc}: expected ${test.expected ? 'HIGH_LEVEL' : 'SPECIFIC'}, got ${result ? 'HIGH_LEVEL' : 'SPECIFIC'}`);
failed++;
}
}
// Test integration
console.log('\n\x1b[1m--- detectBroadPatternIssue (integration) ---\x1b[0m');
for (const test of integrationTests) {
const result = detectBroadPatternIssue(test.input);
const success = result.blocked === test.expected;
if (success) {
console.log(`\x1b[32m✓\x1b[0m ${test.desc} -> ${result.blocked ? 'BLOCKED' : 'ALLOWED'}`);
passed++;
} else {
console.log(`\x1b[31m✗\x1b[0m ${test.desc}: expected ${test.expected ? 'BLOCKED' : 'ALLOWED'}, got ${result.blocked ? 'BLOCKED' : 'ALLOWED'}`);
failed++;
}
}
// Test suggestions
console.log('\n\x1b[1m--- suggestSpecificPatterns ---\x1b[0m');
const suggestions = suggestSpecificPatterns('**/*.ts');
if (suggestions.length > 0 && suggestions.some(s => s.includes('src/'))) {
console.log(`\x1b[32m✓\x1b[0m suggestions for **/*.ts include src-scoped patterns`);
passed++;
} else {
console.log(`\x1b[31m✗\x1b[0m suggestions should include src-scoped patterns`);
failed++;
}
console.log(`\n\x1b[1mResults:\x1b[0m ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);

View File

@@ -0,0 +1,137 @@
#!/usr/bin/env node
/**
* test-build-command-allowlist.cjs - Tests for build command allowlist patterns
*
* Tests that build commands from various languages/tools are properly recognized
* and allowed (bypassing path blocking).
*/
// Replicate the patterns from scout-block.cjs
const BUILD_COMMAND_PATTERN = /^(npm|pnpm|yarn|bun)\s+([^\s]+\s+)*(run\s+)?(build|test|lint|dev|start|install|ci|add|remove|update|publish|pack|init|create|exec)/;
const TOOL_COMMAND_PATTERN = /^(\.\/)?(npx|pnpx|bunx|tsc|esbuild|vite|webpack|rollup|turbo|nx|jest|vitest|mocha|eslint|prettier|go|cargo|make|mvn|mvnw|gradle|gradlew|dotnet|docker|podman|kubectl|helm|terraform|ansible|bazel|cmake|sbt|flutter|swift|ant|ninja|meson)/;
function isBuildCommand(command) {
if (!command || typeof command !== 'string') return false;
const trimmed = command.trim();
return BUILD_COMMAND_PATTERN.test(trimmed) || TOOL_COMMAND_PATTERN.test(trimmed);
}
const tests = [
// JS/Node package managers - should be allowed
{ cmd: 'npm run build', expected: true, desc: 'npm run build' },
{ cmd: 'npm build', expected: true, desc: 'npm build' },
{ cmd: 'pnpm build', expected: true, desc: 'pnpm build' },
{ cmd: 'yarn build', expected: true, desc: 'yarn build' },
{ cmd: 'bun build', expected: true, desc: 'bun build' },
{ cmd: 'npm install', expected: true, desc: 'npm install' },
{ cmd: 'pnpm --filter web run build', expected: true, desc: 'pnpm with filter' },
{ cmd: 'yarn workspace app build', expected: true, desc: 'yarn workspace build' },
// JS tools - should be allowed
{ cmd: 'npx tsc', expected: true, desc: 'npx tsc' },
{ cmd: 'tsc --build', expected: true, desc: 'tsc --build' },
{ cmd: 'esbuild src/index.ts', expected: true, desc: 'esbuild' },
{ cmd: 'vite build', expected: true, desc: 'vite build' },
{ cmd: 'webpack', expected: true, desc: 'webpack' },
{ cmd: 'turbo run build', expected: true, desc: 'turbo run build' },
{ cmd: 'nx build app', expected: true, desc: 'nx build' },
// Go - should be allowed (THE BUG FIX)
{ cmd: 'go build ./...', expected: true, desc: 'go build ./...' },
{ cmd: 'go build -o app main.go', expected: true, desc: 'go build with flags' },
{ cmd: 'go test ./...', expected: true, desc: 'go test' },
{ cmd: 'go run main.go', expected: true, desc: 'go run' },
{ cmd: 'go mod tidy', expected: true, desc: 'go mod tidy' },
{ cmd: 'go install', expected: true, desc: 'go install' },
// Rust/Cargo - should be allowed
{ cmd: 'cargo build', expected: true, desc: 'cargo build' },
{ cmd: 'cargo build --release', expected: true, desc: 'cargo build --release' },
{ cmd: 'cargo test', expected: true, desc: 'cargo test' },
{ cmd: 'cargo run', expected: true, desc: 'cargo run' },
// Make - should be allowed
{ cmd: 'make', expected: true, desc: 'make' },
{ cmd: 'make build', expected: true, desc: 'make build' },
{ cmd: 'make clean', expected: true, desc: 'make clean' },
{ cmd: 'make -j4', expected: true, desc: 'make -j4' },
// Java/Maven/Gradle - should be allowed
{ cmd: 'mvn clean install', expected: true, desc: 'mvn clean install' },
{ cmd: 'mvn package', expected: true, desc: 'mvn package' },
{ cmd: 'gradle build', expected: true, desc: 'gradle build' },
{ cmd: 'gradle test', expected: true, desc: 'gradle test' },
// Maven/Gradle wrappers - should be allowed (NEW)
{ cmd: './gradlew build', expected: true, desc: './gradlew build' },
{ cmd: './gradlew clean test', expected: true, desc: './gradlew clean test' },
{ cmd: 'gradlew build', expected: true, desc: 'gradlew build (no ./)' },
{ cmd: './mvnw clean install', expected: true, desc: './mvnw clean install' },
{ cmd: './mvnw package', expected: true, desc: './mvnw package' },
{ cmd: 'mvnw clean install', expected: true, desc: 'mvnw clean install (no ./)' },
// .NET - should be allowed
{ cmd: 'dotnet build', expected: true, desc: 'dotnet build' },
{ cmd: 'dotnet run', expected: true, desc: 'dotnet run' },
{ cmd: 'dotnet test', expected: true, desc: 'dotnet test' },
// Docker/Container tools - should be allowed
{ cmd: 'docker build .', expected: true, desc: 'docker build' },
{ cmd: 'docker build -t myapp .', expected: true, desc: 'docker build with tag' },
{ cmd: 'docker compose up', expected: true, desc: 'docker compose' },
{ cmd: 'podman build .', expected: true, desc: 'podman build' },
// Kubernetes/Infrastructure - should be allowed
{ cmd: 'kubectl apply -f deploy/', expected: true, desc: 'kubectl apply' },
{ cmd: 'kubectl get pods', expected: true, desc: 'kubectl get' },
{ cmd: 'helm install myapp ./chart', expected: true, desc: 'helm install' },
{ cmd: 'terraform apply', expected: true, desc: 'terraform apply' },
{ cmd: 'terraform plan', expected: true, desc: 'terraform plan' },
{ cmd: 'ansible-playbook site.yml', expected: true, desc: 'ansible playbook' },
// Additional build systems - should be allowed (NEW)
{ cmd: 'bazel build //...', expected: true, desc: 'bazel build' },
{ cmd: 'bazel test //...', expected: true, desc: 'bazel test' },
{ cmd: 'cmake --build .', expected: true, desc: 'cmake build' },
{ cmd: 'cmake -B build', expected: true, desc: 'cmake configure' },
{ cmd: 'sbt compile', expected: true, desc: 'sbt compile' },
{ cmd: 'sbt test', expected: true, desc: 'sbt test' },
{ cmd: 'flutter build apk', expected: true, desc: 'flutter build apk' },
{ cmd: 'flutter run', expected: true, desc: 'flutter run' },
{ cmd: 'swift build', expected: true, desc: 'swift build' },
{ cmd: 'swift test', expected: true, desc: 'swift test' },
{ cmd: 'ant build', expected: true, desc: 'ant build' },
{ cmd: 'ant clean', expected: true, desc: 'ant clean' },
{ cmd: 'ninja', expected: true, desc: 'ninja' },
{ cmd: 'ninja -C build', expected: true, desc: 'ninja -C build' },
{ cmd: 'meson compile', expected: true, desc: 'meson compile' },
{ cmd: 'meson setup build', expected: true, desc: 'meson setup' },
// Directory access - should be BLOCKED (not recognized as build commands)
{ cmd: 'cd build', expected: false, desc: 'cd build (blocked)' },
{ cmd: 'ls build', expected: false, desc: 'ls build (blocked)' },
{ cmd: 'cat build/output.js', expected: false, desc: 'cat build file (blocked)' },
{ cmd: 'cd node_modules', expected: false, desc: 'cd node_modules (blocked)' },
{ cmd: 'rm -rf dist', expected: false, desc: 'rm -rf dist (blocked)' },
];
console.log('Testing build command allowlist...\n');
let passed = 0;
let failed = 0;
for (const test of tests) {
const result = isBuildCommand(test.cmd);
const success = result === test.expected;
if (success) {
console.log(`\x1b[32m✓\x1b[0m ${test.desc}: ${result}`);
passed++;
} else {
console.log(`\x1b[31m✗\x1b[0m ${test.desc}: expected ${test.expected}, got ${result}`);
failed++;
}
}
console.log(`\nResults: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env node
/**
* test-error-formatter.cjs - Unit tests for error-formatter module
*/
const {
formatBlockedError,
formatSimpleError,
formatMachineError,
formatWarning,
formatConfigPath,
supportsColor,
colorize,
COLORS
} = require('../error-formatter.cjs');
let passed = 0;
let failed = 0;
function test(name, condition) {
if (condition) {
console.log(`\x1b[32m✓\x1b[0m ${name}`);
passed++;
} else {
console.log(`\x1b[31m✗\x1b[0m ${name}`);
failed++;
}
}
console.log('Testing error-formatter module...\n');
// formatConfigPath tests
console.log('--- formatConfigPath Tests ---');
test('formatConfigPath with claudeDir', formatConfigPath('/home/user/.claude').includes('.ckignore'));
test('formatConfigPath prefers explicit configPath', formatConfigPath('/home/user/.claude', '/tmp/project/.ckignore') === '/tmp/project/.ckignore');
test('formatConfigPath without claudeDir', formatConfigPath(null) === '.claude/.ckignore');
test('formatConfigPath empty string', formatConfigPath('') === '.claude/.ckignore');
// formatBlockedError tests
console.log('\n--- formatBlockedError Tests ---');
const blockError = formatBlockedError({
path: 'packages/web/node_modules/react',
pattern: 'node_modules',
tool: 'Bash',
claudeDir: '/home/user/project/.claude',
configPath: '/home/user/project/.ckignore'
});
test('formatBlockedError contains BLOCKED', blockError.includes('BLOCKED'));
test('formatBlockedError contains path', blockError.includes('packages/web/node_modules/react'));
test('formatBlockedError contains pattern', blockError.includes('node_modules'));
test('formatBlockedError contains tool', blockError.includes('Bash'));
test('formatBlockedError contains fix hint', blockError.includes('!node_modules'));
test('formatBlockedError prefers explicit config path', blockError.includes('/home/user/project/.ckignore'));
// Test long path truncation
const longPath = 'a/'.repeat(50) + 'node_modules/package/index.js';
const longPathError = formatBlockedError({
path: longPath,
pattern: 'node_modules',
tool: 'Read',
claudeDir: '.claude'
});
test('formatBlockedError truncates long path', longPathError.includes('...'));
// formatSimpleError tests
console.log('\n--- formatSimpleError Tests ---');
const simpleError = formatSimpleError('node_modules', 'packages/web/node_modules');
test('formatSimpleError contains ERROR', simpleError.includes('ERROR'));
test('formatSimpleError contains pattern', simpleError.includes('node_modules'));
test('formatSimpleError contains path', simpleError.includes('packages/web/node_modules'));
// formatMachineError tests
console.log('\n--- formatMachineError Tests ---');
const machineError = formatMachineError({
path: 'dist/bundle.js',
pattern: 'dist',
tool: 'Read',
claudeDir: '.claude',
configPath: '/tmp/project/.ckignore'
});
const parsed = JSON.parse(machineError);
test('formatMachineError is valid JSON', typeof parsed === 'object');
test('formatMachineError has error field', parsed.error === 'BLOCKED');
test('formatMachineError has path field', parsed.path === 'dist/bundle.js');
test('formatMachineError has pattern field', parsed.pattern === 'dist');
test('formatMachineError has tool field', parsed.tool === 'Read');
test('formatMachineError has config field', parsed.config === '/tmp/project/.ckignore');
test('formatMachineError has fix field', parsed.fix.includes('!dist'));
// formatWarning tests
console.log('\n--- formatWarning Tests ---');
const warning = formatWarning('Test warning message');
test('formatWarning contains WARN', warning.includes('WARN'));
test('formatWarning contains message', warning.includes('Test warning message'));
// colorize tests (with forced NO_COLOR)
console.log('\n--- colorize Tests ---');
const originalNoColor = process.env.NO_COLOR;
process.env.NO_COLOR = '1';
test('colorize respects NO_COLOR', colorize('test', 'red') === 'test');
delete process.env.NO_COLOR;
// Test COLORS constant exists
test('COLORS constant has expected keys',
'red' in COLORS && 'yellow' in COLORS && 'blue' in COLORS && 'reset' in COLORS
);
// Restore original NO_COLOR
if (originalNoColor !== undefined) {
process.env.NO_COLOR = originalNoColor;
}
console.log(`\nResults: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env node
/**
* test-full-flow-edge-cases.cjs - Edge case validation for full hook flow
*/
const BUILD_COMMAND_PATTERN = /^(npm|pnpm|yarn|bun)\s+([^\s]+\s+)*(run\s+)?(build|test|lint|dev|start|install|ci|add|remove|update|publish|pack|init|create|exec)/;
const TOOL_COMMAND_PATTERN = /^(npx|pnpx|bunx|tsc|esbuild|vite|webpack|rollup|turbo|nx|jest|vitest|mocha|eslint|prettier|go|cargo|make|mvn|gradle|dotnet)/;
function isBuildCommand(command) {
if (!command || typeof command !== 'string') return false;
const trimmed = command.trim();
return BUILD_COMMAND_PATTERN.test(trimmed) || TOOL_COMMAND_PATTERN.test(trimmed);
}
console.log('=== FULL FLOW EDGE CASE VALIDATION ===\n');
const tests = [
// Should be ALLOWED (bypass path extraction)
{ cmd: 'go build ./...', expect: true, desc: 'go build basic' },
{ cmd: 'cargo build', expect: true, desc: 'cargo build basic' },
{ cmd: 'make build', expect: true, desc: 'make build' },
{ cmd: 'make -j4', expect: true, desc: 'make with flags' },
{ cmd: 'mvn clean install', expect: true, desc: 'maven' },
{ cmd: 'gradle build', expect: true, desc: 'gradle' },
{ cmd: 'dotnet build', expect: true, desc: 'dotnet' },
{ cmd: 'npm run build', expect: true, desc: 'npm run build' },
{ cmd: 'go test ./...', expect: true, desc: 'go test' },
// Should be BLOCKED (goes through path extraction)
{ cmd: 'docker build .', expect: false, desc: 'docker build (not in allowlist)' },
{ cmd: 'cd proj && go build', expect: false, desc: 'chained with cd first' },
{ cmd: 'GOOS=linux go build', expect: false, desc: 'env var prefix' },
{ cmd: 'sudo go build', expect: false, desc: 'sudo prefix' },
{ cmd: 'time go build', expect: false, desc: 'time prefix' },
{ cmd: 'ls build', expect: false, desc: 'ls build dir' },
{ cmd: 'cd build', expect: false, desc: 'cd build dir' },
];
let passed = 0;
let failed = 0;
for (const t of tests) {
const result = isBuildCommand(t.cmd);
const success = result === t.expect;
if (success) {
console.log(`\x1b[32m✓\x1b[0m ${t.desc}: "${t.cmd}" → ${result}`);
passed++;
} else {
console.log(`\x1b[31m✗\x1b[0m ${t.desc}: "${t.cmd}" → ${result} (expected ${t.expect})`);
failed++;
}
}
console.log(`\nResults: ${passed} passed, ${failed} failed`);
// Additional edge case analysis
console.log('\n=== EDGE CASES REQUIRING ATTENTION ===\n');
const edgeCases = [
{ cmd: 'docker build .', issue: 'docker not in TOOL_COMMAND_PATTERN - should it be?' },
{ cmd: 'cd proj && go build', issue: 'Chained commands: first segment checked, not individual commands' },
{ cmd: 'GOOS=linux go build', issue: 'Env var prefix breaks regex start anchor' },
{ cmd: 'php artisan build', issue: 'php/artisan not in patterns' },
{ cmd: 'bundle exec build', issue: 'ruby bundler not in patterns' },
];
console.log('Known edge cases that may cause UX issues:\n');
for (const ec of edgeCases) {
const allowed = isBuildCommand(ec.cmd);
console.log(` ${allowed ? '✓' : '⚠'} "${ec.cmd}"`);
console.log(` Issue: ${ec.issue}\n`);
}
process.exit(failed > 0 ? 1 : 0);

View File

@@ -0,0 +1,225 @@
#!/usr/bin/env node
/**
* test-monorepo-scenarios.cjs - Integration tests for monorepo patterns
*
* THIS IS THE CRITICAL TEST FILE FOR THE BUG FIX!
* Tests that subfolder blocked directories (node_modules, dist, etc.)
* are properly blocked in monorepo structures.
*/
const { execSync } = require('child_process');
const path = require('path');
const hookPath = path.join(__dirname, '..', '..', 'scout-block.cjs');
const scenarios = [
// === THE BUG CASES - These MUST be BLOCKED ===
{
input: { tool_name: 'Bash', tool_input: { command: 'ls packages/web/node_modules' } },
expected: 'BLOCKED',
desc: '[BUG FIX] ls subfolder node_modules'
},
{
input: { tool_name: 'Bash', tool_input: { command: 'cd apps/api/node_modules' } },
expected: 'BLOCKED',
desc: '[BUG FIX] cd subfolder node_modules'
},
{
input: { tool_name: 'Bash', tool_input: { command: 'cat packages/shared/node_modules/lodash/index.js' } },
expected: 'BLOCKED',
desc: '[BUG FIX] cat file in subfolder node_modules'
},
{
input: { tool_name: 'Read', tool_input: { file_path: 'packages/web/node_modules/react/package.json' } },
expected: 'BLOCKED',
desc: '[BUG FIX] Read subfolder node_modules'
},
{
input: { tool_name: 'Grep', tool_input: { pattern: 'export', path: 'packages/web/node_modules' } },
expected: 'BLOCKED',
desc: '[BUG FIX] Grep in subfolder node_modules'
},
{
input: { tool_name: 'Glob', tool_input: { pattern: 'packages/web/node_modules/**/*.js' } },
expected: 'BLOCKED',
desc: '[BUG FIX] Glob subfolder node_modules'
},
// === Deep nesting (also bug cases) ===
{
input: { tool_name: 'Read', tool_input: { file_path: 'a/b/c/d/node_modules/pkg/index.js' } },
expected: 'BLOCKED',
desc: '[BUG FIX] Deep nested node_modules'
},
{
input: { tool_name: 'Bash', tool_input: { command: 'ls packages/web/dist' } },
expected: 'BLOCKED',
desc: '[BUG FIX] ls subfolder dist'
},
{
input: { tool_name: 'Bash', tool_input: { command: 'cat apps/api/build/server.js' } },
expected: 'BLOCKED',
desc: '[BUG FIX] cat subfolder build'
},
// === Root level blocking (should still work) ===
{
input: { tool_name: 'Bash', tool_input: { command: 'ls node_modules' } },
expected: 'BLOCKED',
desc: 'ls root node_modules'
},
{
input: { tool_name: 'Read', tool_input: { file_path: 'node_modules/lodash/index.js' } },
expected: 'BLOCKED',
desc: 'Read root node_modules'
},
{
input: { tool_name: 'Bash', tool_input: { command: 'cat .git/config' } },
expected: 'BLOCKED',
desc: 'cat .git file'
},
// === Build commands - MUST be ALLOWED ===
{
input: { tool_name: 'Bash', tool_input: { command: 'npm run build' } },
expected: 'ALLOWED',
desc: 'npm run build'
},
{
input: { tool_name: 'Bash', tool_input: { command: 'pnpm build' } },
expected: 'ALLOWED',
desc: 'pnpm build'
},
{
input: { tool_name: 'Bash', tool_input: { command: 'yarn build' } },
expected: 'ALLOWED',
desc: 'yarn build'
},
{
input: { tool_name: 'Bash', tool_input: { command: 'npm test' } },
expected: 'ALLOWED',
desc: 'npm test'
},
{
input: { tool_name: 'Bash', tool_input: { command: 'npm install' } },
expected: 'ALLOWED',
desc: 'npm install'
},
{
input: { tool_name: 'Bash', tool_input: { command: 'pnpm --filter web run build' } },
expected: 'ALLOWED',
desc: 'pnpm filter build'
},
{
input: { tool_name: 'Bash', tool_input: { command: 'npx tsc' } },
expected: 'ALLOWED',
desc: 'npx tsc'
},
{
input: { tool_name: 'Bash', tool_input: { command: 'jest --coverage' } },
expected: 'ALLOWED',
desc: 'jest with flags'
},
// === Safe operations - MUST be ALLOWED ===
{
input: { tool_name: 'Read', tool_input: { file_path: 'packages/web/src/App.tsx' } },
expected: 'ALLOWED',
desc: 'Read safe path'
},
{
input: { tool_name: 'Bash', tool_input: { command: 'ls packages/web/src' } },
expected: 'ALLOWED',
desc: 'ls safe path'
},
{
input: { tool_name: 'Grep', tool_input: { pattern: 'import', path: 'src' } },
expected: 'ALLOWED',
desc: 'Grep in src'
},
{
input: { tool_name: 'Glob', tool_input: { pattern: '**/*.ts' } },
expected: 'ALLOWED',
desc: 'Glob all .ts files'
},
{
input: { tool_name: 'Bash', tool_input: { command: 'find packages -name "*.json" | head' } },
expected: 'ALLOWED',
desc: 'find without blocked dirs'
},
// === Edge cases - names containing blocked words but NOT the dirs ===
{
input: { tool_name: 'Read', tool_input: { file_path: 'my-node_modules-project/file.js' } },
expected: 'ALLOWED',
desc: 'node_modules in project name'
},
{
input: { tool_name: 'Bash', tool_input: { command: 'ls build-tools' } },
expected: 'ALLOWED',
desc: 'build- prefix directory'
},
];
console.log('Testing monorepo scenarios (scout-block integration)...\n');
console.log('Hook path:', hookPath, '\n');
let passed = 0;
let failed = 0;
for (const scenario of scenarios) {
try {
execSync(`node "${hookPath}"`, {
input: JSON.stringify(scenario.input),
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
});
// Exit 0 = ALLOWED
const actual = 'ALLOWED';
const success = actual === scenario.expected;
if (success) {
console.log(`\x1b[32m✓\x1b[0m ${scenario.desc}: ${actual}`);
passed++;
} else {
console.log(`\x1b[31m✗\x1b[0m ${scenario.desc}: expected ${scenario.expected}, got ${actual}`);
failed++;
}
} catch (error) {
// Exit 2 = BLOCKED
const actual = error.status === 2 ? 'BLOCKED' : `ERROR(${error.status})`;
const success = actual === scenario.expected;
if (success) {
console.log(`\x1b[32m✓\x1b[0m ${scenario.desc}: ${actual}`);
passed++;
} else {
console.log(`\x1b[31m✗\x1b[0m ${scenario.desc}: expected ${scenario.expected}, got ${actual}`);
if (error.stderr) {
console.log(` stderr: ${error.stderr.toString().trim().split('\n')[0]}`);
}
failed++;
}
}
}
console.log(`\nResults: ${passed} passed, ${failed} failed`);
// Highlight if any bug fix cases failed
const bugFixFailed = scenarios.filter(s => s.desc.includes('[BUG FIX]')).some(s => {
try {
execSync(`node "${hookPath}"`, {
input: JSON.stringify(s.input),
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
});
return s.expected === 'BLOCKED'; // Should have been blocked but wasn't
} catch (error) {
return error.status !== 2 && s.expected === 'BLOCKED';
}
});
if (bugFixFailed) {
console.log('\n\x1b[31mWARNING: Some bug fix test cases failed!\x1b[0m');
console.log('The subfolder blocking bug has NOT been fixed properly.');
}
process.exit(failed > 0 ? 1 : 0);

View File

@@ -0,0 +1,138 @@
#!/usr/bin/env node
/**
* test-path-extractor.cjs - Unit tests for path-extractor module
*/
const { extractFromToolInput, extractFromCommand, looksLikePath } = require('../path-extractor.cjs');
const toolInputTests = [
{
input: { file_path: 'packages/web/src/index.js' },
expected: ['packages/web/src/index.js'],
desc: 'file_path extraction'
},
{
input: { path: 'node_modules' },
expected: ['node_modules'],
desc: 'path extraction'
},
{
input: { pattern: '**/node_modules/**' },
expected: ['**/node_modules/**'],
desc: 'pattern extraction'
},
{
input: { command: 'ls packages/web/node_modules' },
hasPath: 'packages/web/node_modules',
desc: 'command path extraction'
},
{
input: { file_path: '/home/user/project/node_modules/pkg/index.js' },
expected: ['/home/user/project/node_modules/pkg/index.js'],
desc: 'absolute path extraction'
},
{
input: { file_path: 'packages/web/node_modules/react/package.json', path: 'src' },
hasPath: 'packages/web/node_modules',
desc: 'multiple params extraction'
}
];
const commandTests = [
{ cmd: 'ls packages/web/node_modules', hasPath: 'packages/web/node_modules', desc: 'ls with subfolder' },
{ cmd: 'cat "path with spaces/file.js"', hasPath: 'path with spaces/file.js', desc: 'quoted path' },
{ cmd: "cat 'single/quoted/path.js'", hasPath: 'single/quoted/path.js', desc: 'single quoted path' },
{ cmd: 'cd apps/api/node_modules && ls', hasPath: 'apps/api/node_modules', desc: 'cd with chained command' },
{ cmd: 'rm -rf node_modules', hasPath: 'node_modules', desc: 'rm with flags' },
{ cmd: 'cp -r dist/ backup/', hasPath: 'dist', desc: 'cp with flags' },
// Note: Build commands may extract 'build' as a blocked dir name, but this is handled
// at the dispatcher level (build commands bypass path checking entirely).
// The path extractor correctly identifies blocked dir names like 'build'.
{ cmd: 'npm run build', hasPath: 'build', desc: 'npm run build (extracts build)' },
{ cmd: 'pnpm build', hasPath: 'build', desc: 'pnpm build (extracts build)' },
{ cmd: 'cd build', hasPath: 'build', desc: 'cd build (extracts build)' },
{ cmd: 'yarn test', hasPath: null, desc: 'yarn test (no blocked paths)' },
{ cmd: 'npm install', hasPath: null, desc: 'npm install (no blocked paths)' },
];
const looksLikePathTests = [
{ str: 'packages/web/src', expected: true, desc: 'relative path with slashes' },
{ str: '/home/user/project', expected: true, desc: 'absolute path' },
{ str: './src/index.js', expected: true, desc: 'dot-relative path' },
{ str: '../parent/file.js', expected: true, desc: 'parent-relative path' },
{ str: 'file.txt', expected: true, desc: 'file with extension' },
{ str: 'node_modules', expected: true, desc: 'blocked dir name' },
{ str: 'ls', expected: false, desc: 'command word' },
{ str: 'npm', expected: false, desc: 'package manager' },
{ str: '-rf', expected: false, desc: 'flag' },
{ str: '123', expected: false, desc: 'number' },
];
console.log('Testing path-extractor module...\n');
let passed = 0;
let failed = 0;
// Tool input tests
console.log('--- Tool Input Tests ---');
for (const test of toolInputTests) {
const result = extractFromToolInput(test.input);
let success;
if (test.expected) {
success = test.expected.every(e => result.includes(e));
} else if (test.hasPath) {
success = result.some(p => p.includes(test.hasPath));
}
if (success) {
console.log(`\x1b[32m✓\x1b[0m ${test.desc}`);
passed++;
} else {
console.log(`\x1b[31m✗\x1b[0m ${test.desc}: got ${JSON.stringify(result)}`);
failed++;
}
}
// Command tests
console.log('\n--- Command Tests ---');
for (const test of commandTests) {
const result = extractFromCommand(test.cmd);
let success;
if (test.hasPath === null) {
// Build commands should extract few/no blocked-related paths
success = result.length === 0 || !result.some(p =>
p.includes('node_modules') || p.includes('dist') || p.includes('build')
);
} else {
success = result.some(p => p.includes(test.hasPath));
}
if (success) {
console.log(`\x1b[32m✓\x1b[0m ${test.desc}: ${JSON.stringify(result)}`);
passed++;
} else {
console.log(`\x1b[31m✗\x1b[0m ${test.desc}: expected path containing '${test.hasPath}', got ${JSON.stringify(result)}`);
failed++;
}
}
// looksLikePath tests
console.log('\n--- looksLikePath Tests ---');
for (const test of looksLikePathTests) {
const result = looksLikePath(test.str);
const success = result === test.expected;
if (success) {
console.log(`\x1b[32m✓\x1b[0m ${test.desc}: '${test.str}' -> ${result}`);
passed++;
} else {
console.log(`\x1b[31m✗\x1b[0m ${test.desc}: expected ${test.expected}, got ${result}`);
failed++;
}
}
console.log(`\nResults: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* test-pattern-matcher.cjs - Unit tests for pattern-matcher module
*/
const path = require('path');
const { loadPatterns, createMatcher, matchPath, DEFAULT_PATTERNS } = require('../pattern-matcher.cjs');
const tests = [
// === Basic blocking at root ===
{ path: 'node_modules/lodash', expected: true, desc: 'root node_modules with content' },
{ path: 'node_modules', expected: true, desc: 'root node_modules bare' },
{ path: '.git/objects', expected: true, desc: 'root .git' },
{ path: 'dist/bundle.js', expected: true, desc: 'root dist' },
{ path: 'build/output', expected: true, desc: 'root build' },
{ path: '__pycache__/file.pyc', expected: true, desc: 'root __pycache__' },
// === Subfolder blocking (THE BUG FIX!) ===
{ path: 'packages/web/node_modules/react', expected: true, desc: 'subfolder node_modules (monorepo)' },
{ path: 'apps/api/node_modules', expected: true, desc: 'subfolder node_modules bare' },
{ path: 'packages/.git/HEAD', expected: true, desc: 'subfolder .git' },
{ path: 'packages/web/dist/index.js', expected: true, desc: 'subfolder dist' },
{ path: 'apps/backend/build/server.js', expected: true, desc: 'subfolder build' },
{ path: 'packages/shared/__pycache__/module.pyc', expected: true, desc: 'subfolder __pycache__' },
// === Deep nesting ===
{ path: 'a/b/c/d/node_modules/e', expected: true, desc: 'deep nested node_modules' },
{ path: 'projects/monorepo/packages/web/node_modules/react/index.js', expected: true, desc: 'very deep nested' },
// === Allowed paths ===
{ path: 'src/index.js', expected: false, desc: 'src directory' },
{ path: 'packages/web/src/App.tsx', expected: false, desc: 'nested src' },
{ path: 'lib/utils.js', expected: false, desc: 'lib directory' },
{ path: 'README.md', expected: false, desc: 'root file' },
{ path: 'apps/api/server.ts', expected: false, desc: 'nested app file' },
// === Edge cases (should NOT be blocked) ===
{ path: 'my-node_modules-project/file.js', expected: false, desc: 'node_modules in project name' },
{ path: 'build-tools/script.sh', expected: false, desc: 'build- prefix in name' },
{ path: 'src/dist-utils.js', expected: false, desc: 'dist- prefix in name' },
{ path: 'nodemodulesbackup/file.js', expected: false, desc: 'node_modules without separator' },
{ path: 'distro/file.js', expected: false, desc: 'dist prefix without separator' },
];
console.log('Testing pattern-matcher module...\n');
const matcher = createMatcher(DEFAULT_PATTERNS);
let passed = 0;
let failed = 0;
for (const test of tests) {
const result = matchPath(matcher, test.path);
const success = result.blocked === test.expected;
if (success) {
console.log(`\x1b[32m✓\x1b[0m ${test.desc}: ${test.path} -> ${result.blocked ? 'BLOCKED' : 'ALLOWED'}`);
passed++;
} else {
console.log(`\x1b[31m✗\x1b[0m ${test.desc}: expected ${test.expected ? 'BLOCKED' : 'ALLOWED'}, got ${result.blocked ? 'BLOCKED' : 'ALLOWED'}`);
failed++;
}
}
console.log(`\nResults: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);

View File

@@ -0,0 +1,627 @@
/**
* ignore v5.3.0 - Vendored for scout-block hook
* https://github.com/kaelzhang/node-ignore
* MIT License - Copyright (c) 2013 Kael Zhang
*
* Vendored to avoid npm dependency for Claude Code hooks.
* Original source: https://unpkg.com/ignore@5.3.0/index.js
*/
// A simple implementation of make-array
function makeArray (subject) {
return Array.isArray(subject)
? subject
: [subject]
}
const EMPTY = ''
const SPACE = ' '
const ESCAPE = '\\'
const REGEX_TEST_BLANK_LINE = /^\s+$/
const REGEX_INVALID_TRAILING_BACKSLASH = /(?:[^\\]|^)\\$/
const REGEX_REPLACE_LEADING_EXCAPED_EXCLAMATION = /^\\!/
const REGEX_REPLACE_LEADING_EXCAPED_HASH = /^\\#/
const REGEX_SPLITALL_CRLF = /\r?\n/g
// /foo,
// ./foo,
// ../foo,
// .
// ..
const REGEX_TEST_INVALID_PATH = /^\.*\/|^\.+$/
const SLASH = '/'
// Do not use ternary expression here, since "istanbul ignore next" is buggy
let TMP_KEY_IGNORE = 'node-ignore'
/* istanbul ignore else */
if (typeof Symbol !== 'undefined') {
TMP_KEY_IGNORE = Symbol.for('node-ignore')
}
const KEY_IGNORE = TMP_KEY_IGNORE
const define = (object, key, value) =>
Object.defineProperty(object, key, {value})
const REGEX_REGEXP_RANGE = /([0-z])-([0-z])/g
const RETURN_FALSE = () => false
// Sanitize the range of a regular expression
// The cases are complicated, see test cases for details
const sanitizeRange = range => range.replace(
REGEX_REGEXP_RANGE,
(match, from, to) => from.charCodeAt(0) <= to.charCodeAt(0)
? match
// Invalid range (out of order) which is ok for gitignore rules but
// fatal for JavaScript regular expression, so eliminate it.
: EMPTY
)
// See fixtures #59
const cleanRangeBackSlash = slashes => {
const {length} = slashes
return slashes.slice(0, length - length % 2)
}
// > If the pattern ends with a slash,
// > it is removed for the purpose of the following description,
// > but it would only find a match with a directory.
// > In other words, foo/ will match a directory foo and paths underneath it,
// > but will not match a regular file or a symbolic link foo
// > (this is consistent with the way how pathspec works in general in Git).
// '`foo/`' will not match regular file '`foo`' or symbolic link '`foo`'
// -> ignore-rules will not deal with it, because it costs extra `fs.stat` call
// you could use option `mark: true` with `glob`
// '`foo/`' should not continue with the '`..`'
const REPLACERS = [
// > Trailing spaces are ignored unless they are quoted with backslash ("\")
[
// (a\ ) -> (a )
// (a ) -> (a)
// (a \ ) -> (a )
/\\?\s+$/,
match => match.indexOf('\\') === 0
? SPACE
: EMPTY
],
// replace (\ ) with ' '
[
/\\\s/g,
() => SPACE
],
// Escape metacharacters
// which is written down by users but means special for regular expressions.
// > There are 12 characters with special meanings:
// > - the backslash \,
// > - the caret ^,
// > - the dollar sign $,
// > - the period or dot .,
// > - the vertical bar or pipe symbol |,
// > - the question mark ?,
// > - the asterisk or star *,
// > - the plus sign +,
// > - the opening parenthesis (,
// > - the closing parenthesis ),
// > - and the opening square bracket [,
// > - the opening curly brace {,
// > These special characters are often called "metacharacters".
[
/[\\$.|*+(){^]/g,
match => `\\${match}`
],
[
// > a question mark (?) matches a single character
/(?!\\)\?/g,
() => '[^/]'
],
// leading slash
[
// > A leading slash matches the beginning of the pathname.
// > For example, "/*.c" matches "cat-file.c" but not "mozilla-sha1/sha1.c".
// A leading slash matches the beginning of the pathname
/^\//,
() => '^'
],
// replace special metacharacter slash after the leading slash
[
/\//g,
() => '\\/'
],
[
// > A leading "**" followed by a slash means match in all directories.
// > For example, "**/foo" matches file or directory "foo" anywhere,
// > the same as pattern "foo".
// > "**/foo/bar" matches file or directory "bar" anywhere that is directly
// > under directory "foo".
// Notice that the '*'s have been replaced as '\\*'
/^\^*\\\*\\\*\\\//,
// '**/foo' <-> 'foo'
() => '^(?:.*\\/)?'
],
// starting
[
// there will be no leading '/'
// (which has been replaced by section "leading slash")
// If starts with '**', adding a '^' to the regular expression also works
/^(?=[^^])/,
function startingReplacer () {
// If has a slash `/` at the beginning or middle
return !/\/(?!$)/.test(this)
// > Prior to 2.22.1
// > If the pattern does not contain a slash /,
// > Git treats it as a shell glob pattern
// Actually, if there is only a trailing slash,
// git also treats it as a shell glob pattern
// After 2.22.1 (compatible but clearer)
// > If there is a separator at the beginning or middle (or both)
// > of the pattern, then the pattern is relative to the directory
// > level of the particular .gitignore file itself.
// > Otherwise the pattern may also match at any level below
// > the .gitignore level.
? '(?:^|\\/)'
// > Otherwise, Git treats the pattern as a shell glob suitable for
// > consumption by fnmatch(3)
: '^'
}
],
// two globstars
[
// Use lookahead assertions so that we could match more than one `'/**'`
/\\\/\\\*\\\*(?=\\\/|$)/g,
// Zero, one or several directories
// should not use '*', or it will be replaced by the next replacer
// Check if it is not the last `'/**'`
(_, index, str) => index + 6 < str.length
// case: /**/
// > A slash followed by two consecutive asterisks then a slash matches
// > zero or more directories.
// > For example, "a/**/b" matches "a/b", "a/x/b", "a/x/y/b" and so on.
// '/**/'
? '(?:\\/[^\\/]+)*'
// case: /**
// > A trailing `"/**"` matches everything inside.
// #21: everything inside but it should not include the current folder
: '\\/.+'
],
// normal intermediate wildcards
[
// Never replace escaped '*'
// ignore rule '\*' will match the path '*'
// 'abc.*/' -> go
// 'abc.*' -> skip this rule,
// coz trailing single wildcard will be handed by [trailing wildcard]
/(^|[^\\]+)(\\\*)+(?=.+)/g,
// '*.js' matches '.js'
// '*.js' doesn't match 'abc'
(_, p1, p2) => {
// 1.
// > An asterisk "*" matches anything except a slash.
// 2.
// > Other consecutive asterisks are considered regular asterisks
// > and will match according to the previous rules.
const unescaped = p2.replace(/\\\*/g, '[^\\/]*')
return p1 + unescaped
}
],
[
// unescape, revert step 3 except for back slash
// For example, if a user escape a '\\*',
// after step 3, the result will be '\\\\\\*'
/\\\\\\(?=[$.|*+(){^])/g,
() => ESCAPE
],
[
// '\\\\' -> '\\'
/\\\\/g,
() => ESCAPE
],
[
// > The range notation, e.g. [a-zA-Z],
// > can be used to match one of the characters in a range.
// `\` is escaped by step 3
/(\\)?\[([^\]/]*?)(\\*)($|\])/g,
(match, leadEscape, range, endEscape, close) => leadEscape === ESCAPE
// '\\[bar]' -> '\\\\[bar\\]'
? `\\[${range}${cleanRangeBackSlash(endEscape)}${close}`
: close === ']'
? endEscape.length % 2 === 0
// A normal case, and it is a range notation
// '[bar]'
// '[bar\\\\]'
? `[${sanitizeRange(range)}${endEscape}]`
// Invalid range notaton
// '[bar\\]' -> '[bar\\\\]'
: '[]'
: '[]'
],
// ending
[
// 'js' will not match 'js.'
// 'ab' will not match 'abc'
/(?:[^*])$/,
// WTF!
// https://git-scm.com/docs/gitignore
// changes in [2.22.1](https://git-scm.com/docs/gitignore/2.22.1)
// which re-fixes #24, #38
// > If there is a separator at the end of the pattern then the pattern
// > will only match directories, otherwise the pattern can match both
// > files and directories.
// 'js*' will not match 'a.js'
// 'js/' will not match 'a.js'
// 'js' will match 'a.js' and 'a.js/'
match => /\/$/.test(match)
// foo/ will not match 'foo'
? `${match}$`
// foo matches 'foo' and 'foo/'
: `${match}(?=$|\\/$)`
],
// trailing wildcard
[
/(\^|\\\/)?\\\*$/,
(_, p1) => {
const prefix = p1
// '\^':
// '/*' does not match EMPTY
// '/*' does not match everything
// '\\\/':
// 'abc/*' does not match 'abc/'
? `${p1}[^/]+`
// 'a*' matches 'a'
// 'a*' matches 'aa'
: '[^/]*'
return `${prefix}(?=$|\\/$)`
}
],
]
// A simple cache, because an ignore rule only has only one certain meaning
const regexCache = Object.create(null)
// @param {pattern}
const makeRegex = (pattern, ignoreCase) => {
let source = regexCache[pattern]
if (!source) {
source = REPLACERS.reduce(
(prev, current) => prev.replace(current[0], current[1].bind(pattern)),
pattern
)
regexCache[pattern] = source
}
return ignoreCase
? new RegExp(source, 'i')
: new RegExp(source)
}
const isString = subject => typeof subject === 'string'
// > A blank line matches no files, so it can serve as a separator for readability.
const checkPattern = pattern => pattern
&& isString(pattern)
&& !REGEX_TEST_BLANK_LINE.test(pattern)
&& !REGEX_INVALID_TRAILING_BACKSLASH.test(pattern)
// > A line starting with # serves as a comment.
&& pattern.indexOf('#') !== 0
const splitPattern = pattern => pattern.split(REGEX_SPLITALL_CRLF)
class IgnoreRule {
constructor (
origin,
pattern,
negative,
regex
) {
this.origin = origin
this.pattern = pattern
this.negative = negative
this.regex = regex
}
}
const createRule = (pattern, ignoreCase) => {
const origin = pattern
let negative = false
// > An optional prefix "!" which negates the pattern;
if (pattern.indexOf('!') === 0) {
negative = true
pattern = pattern.substr(1)
}
pattern = pattern
// > Put a backslash ("\") in front of the first "!" for patterns that
// > begin with a literal "!", for example, `"\!important!.txt"`.
.replace(REGEX_REPLACE_LEADING_EXCAPED_EXCLAMATION, '!')
// > Put a backslash ("\") in front of the first hash for patterns that
// > begin with a hash.
.replace(REGEX_REPLACE_LEADING_EXCAPED_HASH, '#')
const regex = makeRegex(pattern, ignoreCase)
return new IgnoreRule(
origin,
pattern,
negative,
regex
)
}
const throwError = (message, Ctor) => {
throw new Ctor(message)
}
const checkPath = (path, originalPath, doThrow) => {
if (!isString(path)) {
return doThrow(
`path must be a string, but got \`${originalPath}\``,
TypeError
)
}
// We don't know if we should ignore EMPTY, so throw
if (!path) {
return doThrow(`path must not be empty`, TypeError)
}
// Check if it is a relative path
if (checkPath.isNotRelative(path)) {
const r = '`path.relative()`d'
return doThrow(
`path should be a ${r} string, but got "${originalPath}"`,
RangeError
)
}
return true
}
const isNotRelative = path => REGEX_TEST_INVALID_PATH.test(path)
checkPath.isNotRelative = isNotRelative
checkPath.convert = p => p
class Ignore {
constructor ({
ignorecase = true,
ignoreCase = ignorecase,
allowRelativePaths = false
} = {}) {
define(this, KEY_IGNORE, true)
this._rules = []
this._ignoreCase = ignoreCase
this._allowRelativePaths = allowRelativePaths
this._initCache()
}
_initCache () {
this._ignoreCache = Object.create(null)
this._testCache = Object.create(null)
}
_addPattern (pattern) {
// #32
if (pattern && pattern[KEY_IGNORE]) {
this._rules = this._rules.concat(pattern._rules)
this._added = true
return
}
if (checkPattern(pattern)) {
const rule = createRule(pattern, this._ignoreCase)
this._added = true
this._rules.push(rule)
}
}
// @param {Array<string> | string | Ignore} pattern
add (pattern) {
this._added = false
makeArray(
isString(pattern)
? splitPattern(pattern)
: pattern
).forEach(this._addPattern, this)
// Some rules have just added to the ignore,
// making the behavior changed.
if (this._added) {
this._initCache()
}
return this
}
// legacy
addPattern (pattern) {
return this.add(pattern)
}
// | ignored : unignored
// negative | 0:0 | 0:1 | 1:0 | 1:1
// -------- | ------- | ------- | ------- | --------
// 0 | TEST | TEST | SKIP | X
// 1 | TESTIF | SKIP | TEST | X
// - SKIP: always skip
// - TEST: always test
// - TESTIF: only test if checkUnignored
// - X: that never happen
// @param {boolean} whether should check if the path is unignored,
// setting `checkUnignored` to `false` could reduce additional
// path matching.
// @returns {TestResult} true if a file is ignored
_testOne (path, checkUnignored) {
let ignored = false
let unignored = false
this._rules.forEach(rule => {
const {negative} = rule
if (
unignored === negative && ignored !== unignored
|| negative && !ignored && !unignored && !checkUnignored
) {
return
}
const matched = rule.regex.test(path)
if (matched) {
ignored = !negative
unignored = negative
}
})
return {
ignored,
unignored
}
}
// @returns {TestResult}
_test (originalPath, cache, checkUnignored, slices) {
const path = originalPath
// Supports nullable path
&& checkPath.convert(originalPath)
checkPath(
path,
originalPath,
this._allowRelativePaths
? RETURN_FALSE
: throwError
)
return this._t(path, cache, checkUnignored, slices)
}
_t (path, cache, checkUnignored, slices) {
if (path in cache) {
return cache[path]
}
if (!slices) {
// path/to/a.js
// ['path', 'to', 'a.js']
slices = path.split(SLASH)
}
slices.pop()
// If the path has no parent directory, just test it
if (!slices.length) {
return cache[path] = this._testOne(path, checkUnignored)
}
const parent = this._t(
slices.join(SLASH) + SLASH,
cache,
checkUnignored,
slices
)
// If the path contains a parent directory, check the parent first
return cache[path] = parent.ignored
// > It is not possible to re-include a file if a parent directory of
// > that file is excluded.
? parent
: this._testOne(path, checkUnignored)
}
ignores (path) {
return this._test(path, this._ignoreCache, false).ignored
}
createFilter () {
return path => !this.ignores(path)
}
filter (paths) {
return makeArray(paths).filter(this.createFilter())
}
// @returns {TestResult}
test (path) {
return this._test(path, this._testCache, true)
}
}
const factory = options => new Ignore(options)
const isPathValid = path =>
checkPath(path && checkPath.convert(path), path, RETURN_FALSE)
factory.isPathValid = isPathValid
// Fixes typescript
factory.default = factory
module.exports = factory
// Windows
// --------------------------------------------------------------
/* istanbul ignore if */
if (
// Detect `process` so that it can run in browsers.
typeof process !== 'undefined'
&& (
process.env && process.env.IGNORE_TEST_WIN32
|| process.platform === 'win32'
)
) {
/* eslint no-control-regex: "off" */
const makePosix = str => /^\\\\\?\\/.test(str)
|| /["<>|\u0000-\u001F]+/u.test(str)
? str
: str.replace(/\\/g, '/')
checkPath.convert = makePosix
// 'C:\\foo' <- 'C:\\foo' has been converted to 'C:/'
// 'd:\\foo'
const REGIX_IS_WINDOWS_PATH_ABSOLUTE = /^[a-z]:\//i
checkPath.isNotRelative = path =>
REGIX_IS_WINDOWS_PATH_ABSOLUTE.test(path)
|| isNotRelative(path)
}