init
This commit is contained in:
264
.opencode/plugin/scout-block/broad-pattern-detector.cjs
Executable file
264
.opencode/plugin/scout-block/broad-pattern-detector.cjs
Executable 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
|
||||
};
|
||||
161
.opencode/plugin/scout-block/error-formatter.cjs
Executable file
161
.opencode/plugin/scout-block/error-formatter.cjs
Executable 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
|
||||
};
|
||||
327
.opencode/plugin/scout-block/path-extractor.cjs
Executable file
327
.opencode/plugin/scout-block/path-extractor.cjs
Executable 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
|
||||
};
|
||||
204
.opencode/plugin/scout-block/pattern-matcher.cjs
Executable file
204
.opencode/plugin/scout-block/pattern-matcher.cjs
Executable 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
|
||||
};
|
||||
165
.opencode/plugin/scout-block/tests/test-broad-pattern-detector.cjs
Executable file
165
.opencode/plugin/scout-block/tests/test-broad-pattern-detector.cjs
Executable 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);
|
||||
137
.opencode/plugin/scout-block/tests/test-build-command-allowlist.cjs
Executable file
137
.opencode/plugin/scout-block/tests/test-build-command-allowlist.cjs
Executable 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);
|
||||
114
.opencode/plugin/scout-block/tests/test-error-formatter.cjs
Executable file
114
.opencode/plugin/scout-block/tests/test-error-formatter.cjs
Executable 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);
|
||||
75
.opencode/plugin/scout-block/tests/test-full-flow-edge-cases.cjs
Executable file
75
.opencode/plugin/scout-block/tests/test-full-flow-edge-cases.cjs
Executable 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);
|
||||
225
.opencode/plugin/scout-block/tests/test-monorepo-scenarios.cjs
Executable file
225
.opencode/plugin/scout-block/tests/test-monorepo-scenarios.cjs
Executable 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);
|
||||
138
.opencode/plugin/scout-block/tests/test-path-extractor.cjs
Executable file
138
.opencode/plugin/scout-block/tests/test-path-extractor.cjs
Executable 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);
|
||||
64
.opencode/plugin/scout-block/tests/test-pattern-matcher.cjs
Executable file
64
.opencode/plugin/scout-block/tests/test-pattern-matcher.cjs
Executable 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);
|
||||
627
.opencode/plugin/scout-block/vendor/ignore.cjs
vendored
Normal file
627
.opencode/plugin/scout-block/vendor/ignore.cjs
vendored
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user