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

View File

@@ -0,0 +1,84 @@
import type { Plugin } from "@opencode-ai/plugin";
const { buildReminderContext } = require("./lib/context-builder.cjs");
const { detectProject, getCodingLevelGuidelines } = require("./lib/project-detector.cjs");
const { loadConfig } = require("./lib/ck-config-utils.cjs");
// Track first message per session to inject context once
const injectedSessions = new Set<string>();
/**
* Context Injector Plugin - Inject session context into first message
*
* Combines functionality of dev-rules-reminder.cjs and session-init.cjs.
* Injects rules, session info, project detection into first user message only.
*/
export const ContextInjectorPlugin: Plugin = async ({ directory }) => {
// Load config once at plugin initialization
let config: any;
let detections: any;
try {
config = loadConfig();
detections = detectProject();
} catch (e) {
// Fallback to defaults if config loading fails
config = { codingLevel: -1 };
detections = {};
}
return {
"chat.message": async ({}: any, { message }: any) => {
// Get or generate session ID
const sessionId = process.env.OPENCODE_SESSION_ID ||
`opencode-${Date.now()}`;
// Only inject on first message per session
if (injectedSessions.has(sessionId)) {
return;
}
injectedSessions.add(sessionId);
try {
// Build context
const { content } = buildReminderContext({
sessionId,
config,
staticEnv: {
nodeVersion: process.version,
osPlatform: process.platform,
gitBranch: detections.gitBranch,
gitRoot: detections.gitRoot,
user: process.env.USER || process.env.USERNAME,
locale: process.env.LANG || '',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
},
configDirName: '.opencode'
});
// Inject coding level guidelines if configured
const codingLevel = config.codingLevel ?? -1;
const guidelines = getCodingLevelGuidelines(codingLevel, `${directory}/.opencode`);
// Prepend context to first user message
const contextBlock = [
'<system-context>',
content,
guidelines ? `\n${guidelines}` : '',
'</system-context>',
''
].filter(Boolean).join('\n');
// Modify message content (prepend context)
if (message && typeof message.content === 'string') {
message.content = contextBlock + message.content;
}
} catch (e) {
// Silently fail - don't break the chat if context injection fails
console.error('[ContextInjector] Failed to inject context:', e);
}
}
};
};
export default ContextInjectorPlugin;

View File

@@ -0,0 +1,926 @@
/**
* Shared utilities for ClaudeKit hooks
*
* Contains config loading, path sanitization, and common constants
* used by session-init.cjs and dev-rules-reminder.cjs
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
const LOCAL_CONFIG_PATH = '.opencode/.ck.json';
const GLOBAL_CONFIG_PATH = path.join(os.homedir(), '.claude', '.ck.json');
const SESSION_STATE_LOCK_TIMEOUT_MS = 500;
const SESSION_STATE_LOCK_RETRY_MS = 10;
const SESSION_STATE_LOCK_STALE_MS = 5000;
// Legacy export for backward compatibility
const CONFIG_PATH = LOCAL_CONFIG_PATH;
const DEFAULT_CONFIG = {
plan: {
namingFormat: '{date}-{issue}-{slug}',
dateFormat: 'YYMMDD-HHmm',
issuePrefix: null,
reportsDir: 'reports',
resolution: {
// CHANGED: Removed 'mostRecent' - only explicit session state activates plans
// Branch matching now returns 'suggested' not 'active'
order: ['session', 'branch'],
branchPattern: '(?:feat|fix|chore|refactor|docs)/(?:[^/]+/)?(.+)'
},
validation: {
mode: 'prompt', // 'auto' | 'prompt' | 'off'
minQuestions: 3,
maxQuestions: 8,
focusAreas: ['assumptions', 'risks', 'tradeoffs', 'architecture']
}
},
paths: {
docs: 'docs',
plans: 'plans'
},
docs: {
maxLoc: 800 // Maximum lines of code per doc file before warning
},
locale: {
thinkingLanguage: null, // Language for reasoning (e.g., "en" for precision)
responseLanguage: null // Language for user-facing output (e.g., "vi")
},
trust: {
passphrase: null,
enabled: false
},
project: {
type: 'auto',
packageManager: 'auto',
framework: 'auto'
},
skills: {
research: {
useGemini: false // Opt-in: set true only with working Gemini CLI
}
},
assertions: [],
statusline: 'full',
statuslineColors: true,
statuslineQuota: true,
hooks: {
'session-init': true,
'subagent-init': true,
'dev-rules-reminder': true,
'usage-context-awareness': true,
'context-tracking': true,
'scout-block': true,
'privacy-block': true,
'post-edit-simplify-reminder': true,
'task-completed-handler': true,
'teammate-idle-handler': true,
'session-state': true
}
};
/**
* Deep merge objects (source values override target, nested objects merged recursively)
* Arrays are replaced entirely (not concatenated) to avoid duplicate entries
*
* IMPORTANT: Empty objects {} are treated as "inherit from parent", not "replace with empty".
* This allows global config to set hooks.foo: false and have it persist even when
* local config has hooks: {} (empty = inherit, not reset to defaults).
*
* @param {Object} target - Base object
* @param {Object} source - Object to merge (takes precedence)
* @returns {Object} Merged object
*/
function deepMerge(target, source) {
if (!source || typeof source !== 'object') return target;
if (!target || typeof target !== 'object') return source;
const result = { ...target };
for (const key of Object.keys(source)) {
const sourceVal = source[key];
const targetVal = target[key];
// Arrays: replace entirely (don't concatenate)
if (Array.isArray(sourceVal)) {
result[key] = [...sourceVal];
}
// Objects: recurse (but not null)
// SKIP empty objects - treat {} as "inherit from parent"
else if (sourceVal !== null && typeof sourceVal === 'object' && !Array.isArray(sourceVal)) {
// Empty object = inherit (don't override parent values)
if (Object.keys(sourceVal).length === 0) {
// Keep target value unchanged - empty source means "no override"
continue;
}
result[key] = deepMerge(targetVal || {}, sourceVal);
}
// Primitives: source wins
else {
result[key] = sourceVal;
}
}
return result;
}
/**
* Load config from a specific file path
* @param {string} configPath - Path to config file
* @returns {Object|null} Parsed config or null if not found/invalid
*/
function loadConfigFromPath(configPath) {
try {
if (!fs.existsSync(configPath)) return null;
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch (e) {
return null;
}
}
/**
* Get session temp file path
* @param {string} sessionId - Session identifier
* @returns {string} Path to session temp file
*/
function getSessionTempPath(sessionId) {
return path.join(os.tmpdir(), `ck-session-${sessionId}.json`);
}
/**
* Read session state from temp file
* @param {string} sessionId - Session identifier
* @returns {Object|null} Session state or null
*/
function readSessionState(sessionId) {
if (!sessionId) return null;
const tempPath = getSessionTempPath(sessionId);
try {
if (!fs.existsSync(tempPath)) return null;
return JSON.parse(fs.readFileSync(tempPath, 'utf8'));
} catch (e) {
return null;
}
}
/**
* Write session state atomically to temp file
* @param {string} sessionId - Session identifier
* @param {Object} state - State object to persist
* @returns {boolean} Success status
*/
function writeSessionState(sessionId, state) {
if (!sessionId) return false;
const tempPath = getSessionTempPath(sessionId);
const tmpFile = tempPath + '.' + Math.random().toString(36).slice(2);
try {
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2));
fs.renameSync(tmpFile, tempPath);
return true;
} catch (e) {
try { fs.unlinkSync(tmpFile); } catch (_) { /* ignore */ }
return false;
}
}
function sleepSync(ms) {
if (ms <= 0) return;
if (typeof SharedArrayBuffer === 'function' && typeof Atomics === 'object' && typeof Atomics.wait === 'function') {
const signal = new Int32Array(new SharedArrayBuffer(4));
Atomics.wait(signal, 0, 0, ms);
return;
}
const end = Date.now() + ms;
while (Date.now() < end) {
// Busy wait is a last-resort fallback when Atomics.wait is unavailable.
}
}
function getSessionStateLockPath(sessionId) {
return `${getSessionTempPath(sessionId)}.lock`;
}
function removeStaleSessionStateLock(lockPath, now = Date.now()) {
try {
const stats = fs.statSync(lockPath);
if (now - stats.mtimeMs < SESSION_STATE_LOCK_STALE_MS) return false;
fs.unlinkSync(lockPath);
return true;
} catch {
return false;
}
}
function acquireSessionStateLock(sessionId) {
const lockPath = getSessionStateLockPath(sessionId);
const deadline = Date.now() + SESSION_STATE_LOCK_TIMEOUT_MS;
while (Date.now() <= deadline) {
try {
const fd = fs.openSync(lockPath, 'wx');
fs.writeFileSync(fd, String(process.pid));
return { fd, lockPath };
} catch (error) {
if (error?.code !== 'EEXIST') return null;
removeStaleSessionStateLock(lockPath);
sleepSync(SESSION_STATE_LOCK_RETRY_MS);
}
}
return null;
}
function releaseSessionStateLock(lock) {
if (!lock) return;
try { fs.closeSync(lock.fd); } catch (_) { /* ignore */ }
try { fs.unlinkSync(lock.lockPath); } catch (_) { /* ignore */ }
}
/**
* Update session state by merging or transforming the existing value.
* @param {string} sessionId - Session identifier
* @param {Object|Function} updater - Partial state or transform function
* @returns {boolean} Success status
*/
function updateSessionState(sessionId, updater) {
if (!sessionId) return false;
const lock = acquireSessionStateLock(sessionId);
if (!lock) return false;
try {
const current = readSessionState(sessionId) || {};
const next = typeof updater === 'function'
? updater({ ...current })
: { ...current, ...(updater || {}) };
if (!next || typeof next !== 'object') return false;
return writeSessionState(sessionId, next);
} finally {
releaseSessionStateLock(lock);
}
}
/**
* Characters invalid in filenames across Windows, macOS, Linux
* Windows: < > : " / \ | ? *
* macOS/Linux: / and null byte
* Also includes control characters and other problematic chars
*/
const INVALID_FILENAME_CHARS = /[<>:"/\\|?*\x00-\x1f\x7f]/g;
/**
* Sanitize slug for safe filesystem usage
* - Removes invalid filename characters
* - Replaces non-alphanumeric (except hyphen) with hyphen
* - Collapses multiple hyphens
* - Removes leading/trailing hyphens
* - Limits length to prevent filesystem issues
*
* @param {string} slug - Slug to sanitize
* @returns {string} Sanitized slug (empty string if nothing valid remains)
*/
function sanitizeSlug(slug) {
if (!slug || typeof slug !== 'string') return '';
let sanitized = slug
// Remove invalid filename chars first
.replace(INVALID_FILENAME_CHARS, '')
// Replace any non-alphanumeric (except hyphen) with hyphen
.replace(/[^a-z0-9-]/gi, '-')
// Collapse multiple consecutive hyphens
.replace(/-+/g, '-')
// Remove leading/trailing hyphens
.replace(/^-+|-+$/g, '')
// Limit length (most filesystems support 255, but keep reasonable)
.slice(0, 100);
return sanitized;
}
/**
* Extract feature slug from git branch name
* Pattern: (?:feat|fix|chore|refactor|docs)/(?:[^/]+/)?(.+)
* @param {string} branch - Git branch name
* @param {string} pattern - Regex pattern (optional)
* @returns {string|null} Extracted slug or null
*/
function extractSlugFromBranch(branch, pattern) {
if (!branch) return null;
const defaultPattern = /(?:feat|fix|chore|refactor|docs)\/(?:[^\/]+\/)?(.+)/;
const regex = pattern ? new RegExp(pattern) : defaultPattern;
const match = branch.match(regex);
return match ? sanitizeSlug(match[1]) : null;
}
/**
* Find most recent plan folder by timestamp prefix
* @param {string} plansDir - Plans directory path
* @returns {string|null} Most recent plan path or null
*/
function findMostRecentPlan(plansDir) {
try {
if (!fs.existsSync(plansDir)) return null;
const entries = fs.readdirSync(plansDir, { withFileTypes: true });
const planDirs = entries
.filter(e => e.isDirectory() && /^\d{6}/.test(e.name))
.map(e => e.name)
.sort()
.reverse();
return planDirs.length > 0 ? path.join(plansDir, planDirs[0]) : null;
} catch (e) {
return null;
}
}
/**
* Default timeout for git commands (5 seconds)
* Prevents indefinite hangs on network mounts or corrupted repos
*/
const DEFAULT_EXEC_TIMEOUT_MS = 5000;
/**
* Safely execute shell command (internal helper)
* SECURITY: Only accepts whitelisted git read commands
* @param {string} cmd - Command to execute
* @param {Object} options - Execution options
* @param {string} options.cwd - Working directory (optional)
* @param {number} options.timeout - Timeout in ms (default: 5000)
* @returns {string|null} Command output or null
*/
function execSafe(cmd, options = {}) {
const allowedCommands = {
'git branch --show-current': ['git', ['branch', '--show-current']],
'git rev-parse --abbrev-ref HEAD': ['git', ['rev-parse', '--abbrev-ref', 'HEAD']],
'git rev-parse --show-toplevel': ['git', ['rev-parse', '--show-toplevel']]
};
const commandSpec = allowedCommands[cmd];
if (!commandSpec) {
return null;
}
const { cwd = undefined, timeout = DEFAULT_EXEC_TIMEOUT_MS } = options;
const [file, args] = commandSpec;
try {
return execFileSync(file, args, {
encoding: 'utf8',
timeout,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
}).trim();
} catch (e) {
return null;
}
}
/**
* Resolve active plan path using cascading resolution with tracking
*
* Resolution semantics:
* - 'session': Explicitly set via set-active-plan.cjs → ACTIVE (directive)
* - 'branch': Matched from git branch name → SUGGESTED (hint only)
* - 'mostRecent': REMOVED - was causing stale plan pollution
*
* @param {string} sessionId - Session identifier (optional)
* @param {Object} config - ClaudeKit config
* @returns {{ path: string|null, resolvedBy: 'session'|'branch'|null }} Resolution result with tracking
*/
function resolvePlanPath(sessionId, config) {
const plansDir = config?.paths?.plans || 'plans';
const resolution = config?.plan?.resolution || {};
const order = resolution.order || ['session', 'branch'];
const branchPattern = resolution.branchPattern;
for (const method of order) {
switch (method) {
case 'session': {
const state = readSessionState(sessionId);
if (state?.activePlan) {
// Issue #335: Handle both absolute and relative paths
// - Absolute paths (from updated set-active-plan.cjs): use as-is
// - Relative paths (legacy): resolve using sessionOrigin if available
let resolvedPath = state.activePlan;
if (!path.isAbsolute(resolvedPath) && state.sessionOrigin) {
// Resolve relative path using session origin directory
resolvedPath = path.join(state.sessionOrigin, resolvedPath);
}
return { path: resolvedPath, resolvedBy: 'session' };
}
break;
}
case 'branch': {
try {
const branch = execSafe('git branch --show-current');
const slug = extractSlugFromBranch(branch, branchPattern);
if (slug && fs.existsSync(plansDir)) {
const entries = fs.readdirSync(plansDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.includes(slug));
if (entries.length > 0) {
return {
path: path.join(plansDir, entries[entries.length - 1].name),
resolvedBy: 'branch'
};
}
}
} catch (e) {
// Ignore errors reading plans dir
}
break;
}
// NOTE: 'mostRecent' case intentionally removed - was causing stale plan pollution
}
}
return { path: null, resolvedBy: null };
}
/**
* Normalize path value (trim, remove trailing slashes, handle empty)
* @param {string} pathValue - Path to normalize
* @returns {string|null} Normalized path or null if invalid
*/
function normalizePath(pathValue) {
if (!pathValue || typeof pathValue !== 'string') return null;
// Trim whitespace
let normalized = pathValue.trim();
// Empty after trim = invalid
if (!normalized) return null;
// Remove trailing slashes (but keep root "/" or "C:\")
normalized = normalized.replace(/[/\\]+$/, '');
// If it became empty (was just slashes), return null
if (!normalized) return null;
return normalized;
}
/**
* Check if path is absolute
* @param {string} pathValue - Path to check
* @returns {boolean} True if absolute path
*/
function isAbsolutePath(pathValue) {
if (!pathValue) return false;
// Unix absolute: starts with /
// Windows absolute: starts with drive letter (C:\) or UNC (\\)
return path.isAbsolute(pathValue);
}
/**
* Sanitize path values
* - Normalizes path (trim, remove trailing slashes)
* - Allows absolute paths (for consolidated plans use case)
* - Prevents obvious security issues (null bytes, etc.)
*
* @param {string} pathValue - Path to sanitize
* @param {string} projectRoot - Project root for relative path resolution
* @returns {string|null} Sanitized path or null if invalid
*/
function sanitizePath(pathValue, projectRoot) {
// Normalize first
const normalized = normalizePath(pathValue);
if (!normalized) return null;
// Block null bytes and other dangerous chars
if (/[\x00]/.test(normalized)) return null;
// Allow absolute paths (user explicitly wants consolidated plans elsewhere)
if (isAbsolutePath(normalized)) {
return normalized;
}
// For relative paths, resolve and validate
const resolved = path.resolve(projectRoot, normalized);
// Prevent path traversal outside project (../ attacks)
// But allow if user explicitly set absolute path
if (!resolved.startsWith(projectRoot + path.sep) && resolved !== projectRoot) {
// This is a relative path trying to escape - block it
return null;
}
return normalized;
}
/**
* Validate and sanitize config paths
*/
function sanitizeConfig(config, projectRoot) {
const result = { ...config };
if (result.plan) {
result.plan = { ...result.plan };
if (!sanitizePath(result.plan.reportsDir, projectRoot)) {
result.plan.reportsDir = DEFAULT_CONFIG.plan.reportsDir;
}
// Merge resolution defaults
result.plan.resolution = {
...DEFAULT_CONFIG.plan.resolution,
...result.plan.resolution
};
// Merge validation defaults
result.plan.validation = {
...DEFAULT_CONFIG.plan.validation,
...result.plan.validation
};
}
if (result.paths) {
result.paths = { ...result.paths };
if (!sanitizePath(result.paths.docs, projectRoot)) {
result.paths.docs = DEFAULT_CONFIG.paths.docs;
}
if (!sanitizePath(result.paths.plans, projectRoot)) {
result.paths.plans = DEFAULT_CONFIG.paths.plans;
}
}
if (result.locale) {
result.locale = { ...result.locale };
}
return result;
}
/**
* Load config with cascading resolution: DEFAULT → global → local
*
* Resolution order (each layer overrides the previous):
* 1. DEFAULT_CONFIG (hardcoded defaults)
* 2. Global config (~/.opencode/.ck.json) - user preferences
* 3. Local config (./.opencode/.ck.json) - project-specific overrides
*
* @param {Object} options - Options for config loading
* @param {boolean} options.includeProject - Include project section (default: true)
* @param {boolean} options.includeAssertions - Include assertions (default: true)
* @param {boolean} options.includeLocale - Include locale section (default: true)
*/
function loadConfig(options = {}) {
const { includeProject = true, includeAssertions = true, includeLocale = true } = options;
const projectRoot = process.cwd();
// Load configs from both locations
const globalConfig = loadConfigFromPath(GLOBAL_CONFIG_PATH);
const localConfig = loadConfigFromPath(LOCAL_CONFIG_PATH);
// No config files found - use defaults
if (!globalConfig && !localConfig) {
return getDefaultConfig(includeProject, includeAssertions, includeLocale);
}
try {
// Deep merge: DEFAULT → global → local (local wins)
let merged = deepMerge({}, DEFAULT_CONFIG);
if (globalConfig) merged = deepMerge(merged, globalConfig);
if (localConfig) merged = deepMerge(merged, localConfig);
// Build result with optional sections
const result = {
plan: merged.plan || DEFAULT_CONFIG.plan,
paths: merged.paths || DEFAULT_CONFIG.paths,
docs: merged.docs || DEFAULT_CONFIG.docs
};
if (includeLocale) {
result.locale = merged.locale || DEFAULT_CONFIG.locale;
}
// Always include trust config for verification
result.trust = merged.trust || DEFAULT_CONFIG.trust;
if (includeProject) {
result.project = merged.project || DEFAULT_CONFIG.project;
}
if (includeAssertions) {
result.assertions = merged.assertions || [];
}
// Coding level for output style selection (-1 to 5, default: -1 = disabled)
// -1 = disabled (no injection, saves tokens)
// 0-5 = inject corresponding level guidelines
result.codingLevel = merged.codingLevel ?? -1;
// Skills configuration
result.skills = merged.skills || DEFAULT_CONFIG.skills;
// Hooks configuration
result.hooks = merged.hooks || DEFAULT_CONFIG.hooks;
// Statusline mode
result.statusline = merged.statusline || 'full';
result.statuslineColors = merged.statuslineColors ?? true;
result.statuslineQuota = merged.statuslineQuota ?? true;
result.statuslineLayout = merged.statuslineLayout || undefined;
return sanitizeConfig(result, projectRoot);
} catch (e) {
return getDefaultConfig(includeProject, includeAssertions, includeLocale);
}
}
/**
* Get default config with optional sections
*/
function getDefaultConfig(includeProject = true, includeAssertions = true, includeLocale = true) {
const result = {
plan: { ...DEFAULT_CONFIG.plan },
paths: { ...DEFAULT_CONFIG.paths },
docs: { ...DEFAULT_CONFIG.docs },
codingLevel: -1, // Default: disabled (no injection, saves tokens)
skills: { ...DEFAULT_CONFIG.skills },
hooks: { ...DEFAULT_CONFIG.hooks },
statusline: 'full',
statuslineColors: true,
statuslineQuota: true
};
if (includeLocale) {
result.locale = { ...DEFAULT_CONFIG.locale };
}
if (includeProject) {
result.project = { ...DEFAULT_CONFIG.project };
}
if (includeAssertions) {
result.assertions = [];
}
return result;
}
/**
* Escape shell special characters for env file values
* Handles: backslash, double quote, dollar sign, backtick
*/
function escapeShellValue(str) {
if (typeof str !== 'string') return str;
return str
.replace(/\\/g, '\\\\') // Backslash first
.replace(/"/g, '\\"') // Double quotes
.replace(/\$/g, '\\$') // Dollar sign
.replace(/`/g, '\\`'); // Backticks (command substitution)
}
/**
* Write environment variable to CLAUDE_ENV_FILE (with escaping)
*/
function writeEnv(envFile, key, value) {
if (envFile && value !== null && value !== undefined) {
const escaped = escapeShellValue(String(value));
fs.appendFileSync(envFile, `export ${key}="${escaped}"\n`);
}
}
/**
* Get reports path based on plan resolution
* Only uses plan-specific path for 'session' resolved plans (explicitly active)
* Branch-matched (suggested) plans use default path to avoid pollution
*
* @param {string|null} planPath - The plan path
* @param {string|null} resolvedBy - How plan was resolved ('session'|'branch'|null)
* @param {Object} planConfig - Plan configuration
* @param {Object} pathsConfig - Paths configuration
* @param {string|null} baseDir - Optional base directory for absolute path resolution
* @returns {string} Reports path (absolute if baseDir provided, relative otherwise)
*/
function getReportsPath(planPath, resolvedBy, planConfig, pathsConfig, baseDir = null) {
const reportsDir = normalizePath(planConfig?.reportsDir) || 'reports';
const plansDir = normalizePath(pathsConfig?.plans) || 'plans';
let reportPath;
// Only use plan-specific reports path if explicitly active (session state)
// Issue #327: Validate normalized path to prevent whitespace-only paths creating invalid directories
const normalizedPlanPath = planPath && resolvedBy === 'session' ? normalizePath(planPath) : null;
if (normalizedPlanPath) {
reportPath = `${normalizedPlanPath}/${reportsDir}`;
} else {
// Default path for no plan or suggested (branch-matched) plans
reportPath = `${plansDir}/${reportsDir}`;
}
// Return absolute path if baseDir provided
// Guard: if reportPath is already absolute (Issue #335 made planPath absolute),
// don't double-join with baseDir — path.join concatenates, not resolves
if (baseDir) {
return path.isAbsolute(reportPath) ? reportPath : path.join(baseDir, reportPath);
}
return reportPath + '/';
}
/**
* Format issue ID with prefix
*/
function formatIssueId(issueId, planConfig) {
if (!issueId) return null;
return planConfig.issuePrefix ? `${planConfig.issuePrefix}${issueId}` : `#${issueId}`;
}
/**
* Extract issue ID from branch name
*/
function extractIssueFromBranch(branch) {
if (!branch) return null;
const patterns = [
/(?:issue|gh|fix|feat|bug)[/-]?(\d+)/i,
/[/-](\d+)[/-]/,
/#(\d+)/
];
for (const pattern of patterns) {
const match = branch.match(pattern);
if (match) return match[1];
}
return null;
}
/**
* Format date according to dateFormat config
* Supports: YYMMDD, YYMMDD-HHmm, YYYYMMDD, etc.
* @param {string} format - Date format string
* @returns {string} Formatted date
*/
function formatDate(format) {
const now = new Date();
const pad = (n, len = 2) => String(n).padStart(len, '0');
const tokens = {
'YYYY': now.getFullYear(),
'YY': String(now.getFullYear()).slice(-2),
'MM': pad(now.getMonth() + 1),
'DD': pad(now.getDate()),
'HH': pad(now.getHours()),
'mm': pad(now.getMinutes()),
'ss': pad(now.getSeconds())
};
let result = format;
for (const [token, value] of Object.entries(tokens)) {
result = result.replace(token, value);
}
return result;
}
/**
* Validate naming pattern result
* Ensures pattern resolves to a usable directory name
*
* @param {string} pattern - Resolved naming pattern
* @returns {{ valid: boolean, error?: string }} Validation result
*/
function validateNamingPattern(pattern) {
if (!pattern || typeof pattern !== 'string') {
return { valid: false, error: 'Pattern is empty or not a string' };
}
// After removing {slug} placeholder, should still have content
const withoutSlug = pattern.replace(/\{slug\}/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
if (!withoutSlug) {
return { valid: false, error: 'Pattern resolves to empty after removing {slug}' };
}
// Check for remaining unresolved placeholders (besides {slug})
const unresolvedMatch = withoutSlug.match(/\{[^}]+\}/);
if (unresolvedMatch) {
return { valid: false, error: `Unresolved placeholder: ${unresolvedMatch[0]}` };
}
// Pattern must contain {slug} for agents to substitute
if (!pattern.includes('{slug}')) {
return { valid: false, error: 'Pattern must contain {slug} placeholder' };
}
return { valid: true };
}
/**
* Resolve naming pattern with date and optional issue prefix
* Keeps {slug} as placeholder for agents to substitute
*
* Example: namingFormat="{date}-{issue}-{slug}", dateFormat="YYMMDD-HHmm", issue="GH-88"
* Returns: "251212-1830-GH-88-{slug}" (if issue exists)
* Returns: "251212-1830-{slug}" (if no issue)
*
* @param {Object} planConfig - Plan configuration
* @param {string|null} gitBranch - Current git branch (for issue extraction)
* @returns {string} Resolved naming pattern with {slug} placeholder
*/
function resolveNamingPattern(planConfig, gitBranch) {
const { namingFormat, dateFormat, issuePrefix } = planConfig;
const formattedDate = formatDate(dateFormat);
// Try to extract issue ID from branch name
const issueId = extractIssueFromBranch(gitBranch);
const fullIssue = issueId && issuePrefix ? `${issuePrefix}${issueId}` : null;
// Build pattern by substituting {date} and {issue}, keep {slug}
let pattern = namingFormat;
pattern = pattern.replace('{date}', formattedDate);
if (fullIssue) {
pattern = pattern.replace('{issue}', fullIssue);
} else {
// Remove {issue} and any trailing/leading dash
pattern = pattern.replace(/-?\{issue\}-?/, '-').replace(/--+/g, '-');
}
// Clean up the result:
// - Remove leading/trailing hyphens
// - Collapse multiple hyphens (except around {slug})
pattern = pattern
.replace(/^-+/, '') // Remove leading hyphens
.replace(/-+$/, '') // Remove trailing hyphens
.replace(/-+(\{slug\})/g, '-$1') // Single hyphen before {slug}
.replace(/(\{slug\})-+/g, '$1-') // Single hyphen after {slug}
.replace(/--+/g, '-'); // Collapse other multiple hyphens
// Validate the resulting pattern
const validation = validateNamingPattern(pattern);
if (!validation.valid) {
// Log warning but return pattern anyway (fail-safe)
if (process.env.CK_DEBUG) {
console.error(`[ck-config] Warning: ${validation.error}`);
}
}
return pattern;
}
/**
* Get current git branch (safe execution)
* @param {string|null} cwd - Working directory to run git command from (optional)
* @returns {string|null} Current branch name or null
*/
function getGitBranch(cwd = null) {
return execSafe('git branch --show-current', { cwd: cwd || undefined });
}
/**
* Get git repository root directory
* @param {string|null} cwd - Working directory to run git command from (optional)
* @returns {string|null} Git root absolute path or null if not in git repo
*/
function getGitRoot(cwd = null) {
return execSafe('git rev-parse --show-toplevel', { cwd: cwd || undefined });
}
/**
* Extract task list ID from plan resolution for Claude Code Tasks coordination
* Only returns ID for session-resolved plans (explicitly active, not branch-suggested)
*
* Cross-platform: path.basename() handles both Unix/Windows separators
*
* @param {{ path: string|null, resolvedBy: 'session'|'branch'|null }} resolved - Plan resolution result
* @returns {string|null} Task list ID (plan directory name) or null
*/
function extractTaskListId(resolved) {
if (!resolved || resolved.resolvedBy !== 'session' || !resolved.path) {
return null;
}
return path.basename(resolved.path);
}
/**
* Check if a hook is enabled in config
* Returns true if hook is not defined (default enabled)
*
* @param {string} hookName - Hook name (script basename without .cjs)
* @returns {boolean} Whether hook is enabled
*/
function isHookEnabled(hookName) {
const config = loadConfig({ includeProject: false, includeAssertions: false, includeLocale: false });
const hooks = config.hooks || {};
// Return true if undefined (default enabled), otherwise return the boolean value
return hooks[hookName] !== false;
}
module.exports = {
CONFIG_PATH,
LOCAL_CONFIG_PATH,
GLOBAL_CONFIG_PATH,
DEFAULT_CONFIG,
INVALID_FILENAME_CHARS,
deepMerge,
loadConfigFromPath,
loadConfig,
normalizePath,
isAbsolutePath,
sanitizePath,
sanitizeSlug,
sanitizeConfig,
escapeShellValue,
writeEnv,
getSessionTempPath,
readSessionState,
writeSessionState,
updateSessionState,
resolvePlanPath,
extractSlugFromBranch,
findMostRecentPlan,
getReportsPath,
formatIssueId,
extractIssueFromBranch,
formatDate,
validateNamingPattern,
resolveNamingPattern,
getGitBranch,
getGitRoot,
extractTaskListId,
isHookEnabled
};

View File

@@ -0,0 +1,180 @@
#!/usr/bin/env node
'use strict';
/**
* ANSI Terminal Colors - Cross-platform color support for statusline
* Supports NO_COLOR, FORCE_COLOR, COLORTERM auto-detection
* @module colors
*/
// ANSI escape codes (standard + bright palette)
const RESET = '\x1b[0m';
const DIM = '\x1b[2m';
const CLEAR_INTENSITY = '\x1b[22m';
const CLEAR_FOREGROUND = '\x1b[39m';
const RED = '\x1b[31m';
const GREEN = '\x1b[32m';
const YELLOW = '\x1b[33m';
const BLUE = '\x1b[34m';
const MAGENTA = '\x1b[35m';
const CYAN = '\x1b[36m';
const BRIGHT_RED = '\x1b[91m';
const BRIGHT_GREEN = '\x1b[92m';
const BRIGHT_YELLOW = '\x1b[93m';
const BRIGHT_BLUE = '\x1b[94m';
const BRIGHT_MAGENTA = '\x1b[95m';
const BRIGHT_CYAN = '\x1b[96m';
const BRIGHT_WHITE = '\x1b[97m';
const STABLE_PREFIX = `${CLEAR_INTENSITY}${CLEAR_FOREGROUND}`;
const STABLE_SUFFIX = `${RESET}${CLEAR_INTENSITY}${CLEAR_FOREGROUND}`;
const COLOR_CODES = {
green: GREEN,
yellow: YELLOW,
red: RED,
blue: BLUE,
cyan: CYAN,
magenta: MAGENTA,
dim: DIM,
brightRed: BRIGHT_RED,
brightGreen: BRIGHT_GREEN,
brightYellow: BRIGHT_YELLOW,
brightBlue: BRIGHT_BLUE,
brightMagenta: BRIGHT_MAGENTA,
brightCyan: BRIGHT_CYAN,
brightWhite: BRIGHT_WHITE,
};
// Detect color support at module load (cached)
// Claude Code statusline runs via pipe but output displays in TTY - default to true
const shouldUseColor = (() => {
if (process.env.NO_COLOR) return false;
if (process.env.FORCE_COLOR) return true;
// Default true for statusline context (Claude Code handles TTY display)
return true;
})();
// Mutable override (set by statusline.cjs from config)
// null = use env detection, true/false = explicit override
let _colorOverride = null;
/**
* Set explicit color enable/disable override (from config)
* Pass null to revert to env-var detection
* @param {boolean} enabled
*/
function setColorEnabled(enabled) {
_colorOverride = enabled;
}
/**
* Determine if colors should be rendered, respecting env vars and config override
* NO_COLOR env var always takes precedence over config override
* @returns {boolean}
*/
function isColorEnabled() {
// NO_COLOR env var is a hard override that always wins
if (process.env.NO_COLOR) return false;
if (_colorOverride !== null) return _colorOverride;
return shouldUseColor;
}
// Detect 256-color support via COLORTERM
const has256Color = (() => {
const ct = process.env.COLORTERM;
return ct === 'truecolor' || ct === '24bit' || ct === '256color';
})();
/**
* Wrap text with ANSI color code
* @param {string} text - Text to colorize
* @param {string} code - ANSI escape code
* @returns {string} Colorized text or plain text if colors disabled
*/
function colorize(text, code) {
if (!isColorEnabled() || !code) return String(text);
return `${STABLE_PREFIX}${code}${text}${STABLE_SUFFIX}`;
}
function green(text) { return colorize(text, GREEN); }
function yellow(text) { return colorize(text, YELLOW); }
function red(text) { return colorize(text, RED); }
function blue(text) { return colorize(text, BLUE); }
function cyan(text) { return colorize(text, CYAN); }
function magenta(text) { return colorize(text, MAGENTA); }
function dim(text) { return colorize(text, DIM); }
function brightRed(text) { return colorize(text, BRIGHT_RED); }
function brightGreen(text) { return colorize(text, BRIGHT_GREEN); }
function brightYellow(text) { return colorize(text, BRIGHT_YELLOW); }
function brightBlue(text) { return colorize(text, BRIGHT_BLUE); }
function brightMagenta(text) { return colorize(text, BRIGHT_MAGENTA); }
function brightCyan(text) { return colorize(text, BRIGHT_CYAN); }
function brightWhite(text) { return colorize(text, BRIGHT_WHITE); }
/**
* Get color code based on context percentage threshold
* @param {number} percent - Context usage percentage (0-100)
* @returns {string} ANSI color code
*/
function resolveColorCode(colorName) {
if (colorName === 'white' || colorName === 'none' || colorName === 'default') return '';
return COLOR_CODES[colorName] || '';
}
function getContextColor(percent, palette = {}) {
const high = resolveColorCode(palette.high || 'red') || RED;
const mid = resolveColorCode(palette.mid || 'yellow') || YELLOW;
const low = resolveColorCode(palette.low || 'green') || GREEN;
if (percent >= 85) return high;
if (percent >= 70) return mid;
return low;
}
/**
* Generate colored progress bar for context window
* Uses ▰▱ characters (smooth horizontal rectangles) for consistent rendering
* @param {number} percent - Usage percentage (0-100)
* @param {number} width - Bar width in characters (default 12)
* @returns {string} Unicode progress bar with threshold-based colors
*/
function coloredBar(percent, width = 12, palette = {}) {
const clamped = Math.max(0, Math.min(100, percent));
const filled = Math.round((clamped / 100) * width);
const empty = width - filled;
if (!isColorEnabled()) {
return '▰'.repeat(filled) + '▱'.repeat(empty);
}
const color = getContextColor(percent, palette);
return `${STABLE_PREFIX}${color}${'▰'.repeat(filled)}${STABLE_PREFIX}${DIM}${'▱'.repeat(empty)}${STABLE_SUFFIX}`;
}
/**
* Resolve a color name from theme config to its color function.
* Used by section renderers to apply theme-configurable colors.
* Falls back to identity function (no color) for unknown names.
* @param {string} colorName - Color name (e.g. "green", "yellow", "dim")
* @returns {Function} Color function (string) => string
*/
function resolveColor(colorName) {
const code = resolveColorCode(colorName);
return code ? (s) => colorize(s, code) : (s) => String(s);
}
module.exports = {
RESET,
green,
yellow,
red,
cyan,
magenta,
dim,
getContextColor,
coloredBar,
shouldUseColor,
has256Color,
setColorEnabled,
isColorEnabled,
resolveColorCode,
resolveColor,
};

View File

@@ -0,0 +1,842 @@
#!/usr/bin/env node
/**
* context-builder.cjs - Context/reminder building for session injection
*
* Extracted from dev-rules-reminder.cjs for reuse in both Claude hooks and OpenCode plugins.
* Builds session context, rules, paths, and plan information.
*
* @module context-builder
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execSync } = require('child_process');
// Usage cache file path (written by usage-context-awareness.cjs hook)
const USAGE_CACHE_FILE = path.join(os.tmpdir(), 'ck-usage-limits-cache.json');
const RECENT_INJECTION_TTL_MS = 5 * 60 * 1000;
const PENDING_INJECTION_TTL_MS = 30 * 1000;
const WARN_THRESHOLD = 70;
const CRITICAL_THRESHOLD = 90;
const {
loadConfig,
resolvePlanPath,
getReportsPath,
resolveNamingPattern,
normalizePath,
getGitBranch,
readSessionState,
updateSessionState
} = require('./ck-config-utils.cjs');
function execSafe(cmd) {
try {
return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
} catch {
return null;
}
}
/**
* Resolve rules file path (local or global) with backward compat
* @param {string} filename - Rules filename
* @param {string} [configDirName='.claude'] - Config directory name
* @returns {string|null} Resolved path or null
*/
function resolveRulesPath(filename, configDirName = '.claude') {
// Try rules/ first (new location)
const localRulesPath = path.join(process.cwd(), configDirName, 'rules', filename);
const globalRulesPath = path.join(os.homedir(), '.claude', 'rules', filename);
if (fs.existsSync(localRulesPath)) return `${configDirName}/rules/${filename}`;
if (fs.existsSync(globalRulesPath)) return `~/.opencode/rules/${filename}`;
// Backward compat: try workflows/ (legacy location)
const localWorkflowsPath = path.join(process.cwd(), configDirName, 'workflows', filename);
const globalWorkflowsPath = path.join(os.homedir(), '.claude', 'workflows', filename);
if (fs.existsSync(localWorkflowsPath)) return `${configDirName}/workflows/${filename}`;
if (fs.existsSync(globalWorkflowsPath)) return `~/.opencode/workflows/${filename}`;
return null;
}
/**
* Resolve script file path (local or global)
* @param {string} filename - Script filename
* @param {string} [configDirName='.claude'] - Config directory name
* @returns {string|null} Resolved path or null
*/
function resolveScriptPath(filename, configDirName = '.claude') {
const localPath = path.join(process.cwd(), configDirName, 'scripts', filename);
const globalPath = path.join(os.homedir(), '.claude', 'scripts', filename);
if (fs.existsSync(localPath)) return `${configDirName}/scripts/${filename}`;
if (fs.existsSync(globalPath)) return `~/.opencode/scripts/${filename}`;
return null;
}
/**
* Resolve skills venv Python path (local or global)
* @param {string} [configDirName='.claude'] - Config directory name
* @returns {string|null} Resolved venv Python path or null
*/
function resolveSkillsVenv(configDirName = '.claude') {
const isWindows = process.platform === 'win32';
const venvBin = isWindows ? 'Scripts' : 'bin';
const pythonExe = isWindows ? 'python.exe' : 'python3';
const localVenv = path.join(process.cwd(), configDirName, 'skills', '.venv', venvBin, pythonExe);
const globalVenv = path.join(os.homedir(), '.claude', 'skills', '.venv', venvBin, pythonExe);
if (fs.existsSync(localVenv)) {
return isWindows
? `${configDirName}\\skills\\.venv\\Scripts\\python.exe`
: `${configDirName}/skills/.venv/bin/python3`;
}
if (fs.existsSync(globalVenv)) {
return isWindows
? '~\\.claude\\skills\\.venv\\Scripts\\python.exe'
: '~/.opencode/skills/.venv/bin/python3';
}
return null;
}
/**
* Build plan context from config and git info
* @param {string|null} sessionId - Session ID
* @param {Object} config - Loaded config
* @returns {Object} Plan context object
*/
function buildPlanContext(sessionId, config) {
const { plan, paths } = config;
const gitBranch = getGitBranch();
const resolved = resolvePlanPath(sessionId, config);
const reportsPath = getReportsPath(resolved.path, resolved.resolvedBy, plan, paths);
// Compute naming pattern directly for reliable injection
const namePattern = resolveNamingPattern(plan, gitBranch);
const planLine = resolved.resolvedBy === 'session'
? `- Plan: ${resolved.path}`
: resolved.resolvedBy === 'branch'
? `- Plan: none | Suggested: ${resolved.path}`
: `- Plan: none`;
// Validation config (injected so LLM can reference it)
const validation = plan.validation || {};
const validationMode = validation.mode || 'prompt';
const validationMin = validation.minQuestions || 3;
const validationMax = validation.maxQuestions || 8;
return { reportsPath, gitBranch, planLine, namePattern, validationMode, validationMin, validationMax };
}
/**
* Build a scope key for reminder dedup so cwd-sensitive output can re-inject when needed.
* @param {Object} params
* @param {string} [params.baseDir] - Working directory for the hook invocation
* @returns {string} Stable scope key
*/
function buildInjectionScopeKey({ baseDir } = {}) {
const cwdKey = normalizePath(path.resolve(baseDir || process.cwd())) || process.cwd();
return cwdKey;
}
function parseTimestamp(value) {
if (typeof value === 'number') return value;
if (typeof value === 'string') return Date.parse(value);
return NaN;
}
function getReminderScopeState(reminderState, scopeKey) {
const scopes = reminderState?.scopes;
if (!scopes || typeof scopes !== 'object') return null;
const scopeState = scopes[scopeKey];
return scopeState && typeof scopeState === 'object' ? scopeState : null;
}
function hasRecentInjection(scopeState, now = Date.now()) {
const injectedTs = parseTimestamp(scopeState?.lastInjectedAt);
return Number.isFinite(injectedTs) && now - injectedTs < RECENT_INJECTION_TTL_MS;
}
function hasPendingInjection(scopeState, now = Date.now()) {
const pendingTs = parseTimestamp(scopeState?.pendingAt);
return Number.isFinite(pendingTs) && now - pendingTs < PENDING_INJECTION_TTL_MS;
}
function pruneReminderScopes(scopes, now = Date.now()) {
const nextScopes = {};
for (const [scopeKey, scopeState] of Object.entries(scopes || {})) {
if (!scopeState || typeof scopeState !== 'object') continue;
if (hasRecentInjection(scopeState, now) || hasPendingInjection(scopeState, now)) {
nextScopes[scopeKey] = scopeState;
}
}
return nextScopes;
}
function wasTranscriptRecentlyInjected(transcriptPath, scopeKey = null) {
try {
if (!transcriptPath || !fs.existsSync(transcriptPath)) return false;
const tail = fs.readFileSync(transcriptPath, 'utf-8').split('\n').slice(-150);
const hasReminderMarker = tail.some(line => line.includes('[IMPORTANT] Consider Modularization'));
if (!hasReminderMarker) return false;
if (!scopeKey) return true;
// The reminder output is cwd-sensitive; only treat transcript fallback as a match
// when the same cwd-specific session lines were already injected recently.
return tail.some(line => line === `- CWD: ${scopeKey}` || line === `- Working directory: ${scopeKey}`);
} catch {
return false;
}
}
/**
* Check if context was recently injected (prevent duplicate injection).
* Uses session-scoped markers when a session ID is available, otherwise falls back to transcript scan.
* @param {string} transcriptPath - Path to transcript file
* @param {string|null} [sessionId] - Session identifier for temp-state dedup
* @param {string|null} [scopeKey='session'] - Scope key for cwd/transcript-aware dedup
* @returns {boolean} true if recently injected
*/
function wasRecentlyInjected(transcriptPath, sessionId = null, scopeKey = 'session') {
try {
if (sessionId) {
const reminderState = readSessionState(sessionId)?.devRulesReminder;
if (hasRecentInjection(getReminderScopeState(reminderState, scopeKey))) {
return true;
}
}
return wasTranscriptRecentlyInjected(transcriptPath, scopeKey);
} catch {
return false;
}
}
/**
* Reserve an injection slot atomically so concurrent hooks do not double-inject.
* @param {string|null} sessionId - Session identifier
* @param {string|null} [scopeKey='session'] - Scope key for cwd/transcript-aware dedup
* @param {string|null} [transcriptPath] - Transcript path for legacy fallback when no session ID exists
* @returns {{ shouldInject: boolean, reserved: boolean }} Whether to inject and whether a pending reservation was written
*/
function reserveInjectionScope(sessionId, scopeKey = 'session', transcriptPath = null) {
const transcriptAlreadyInjected = wasTranscriptRecentlyInjected(transcriptPath, scopeKey);
if (!sessionId) {
return {
shouldInject: !transcriptAlreadyInjected,
reserved: false
};
}
try {
let shouldInject = false;
const now = Date.now();
const updated = updateSessionState(sessionId, (state) => {
const reminderState = state.devRulesReminder && typeof state.devRulesReminder === 'object'
? state.devRulesReminder
: {};
const scopes = pruneReminderScopes(reminderState.scopes, now);
const scopeState = getReminderScopeState({ scopes }, scopeKey) || {};
if (hasRecentInjection(scopeState, now) || hasPendingInjection(scopeState, now)) {
return state;
}
if (transcriptAlreadyInjected) {
scopes[scopeKey] = {
...scopeState,
lastInjectedAt: new Date(now).toISOString()
};
return {
...state,
devRulesReminder: {
...reminderState,
scopes
}
};
}
shouldInject = true;
scopes[scopeKey] = {
...scopeState,
pendingAt: new Date(now).toISOString()
};
return {
...state,
devRulesReminder: {
...reminderState,
scopes
}
};
});
if (!updated) {
return {
shouldInject: !transcriptAlreadyInjected,
reserved: false
};
}
return { shouldInject, reserved: shouldInject };
} catch {
return {
shouldInject: !transcriptAlreadyInjected,
reserved: false
};
}
}
/**
* Persist a recent injection marker for the current session and clear the pending reservation.
* @param {string|null} sessionId - Session identifier
* @param {string|null} [scopeKey='session'] - Scope key for cwd/transcript-aware dedup
* @returns {boolean} true when the marker is written
*/
function markRecentlyInjected(sessionId, scopeKey = 'session') {
if (!sessionId) return false;
try {
return updateSessionState(sessionId, (state) => {
const reminderState = state.devRulesReminder && typeof state.devRulesReminder === 'object'
? state.devRulesReminder
: {};
const scopes = pruneReminderScopes(reminderState.scopes);
const scopeState = getReminderScopeState({ scopes }, scopeKey) || {};
scopes[scopeKey] = {
...scopeState,
lastInjectedAt: new Date().toISOString()
};
delete scopes[scopeKey].pendingAt;
return {
...state,
devRulesReminder: {
...reminderState,
scopes
}
};
});
} catch {
return false;
}
}
/**
* Clear a pending reservation when the hook fails after reserving a slot.
* @param {string|null} sessionId - Session identifier
* @param {string|null} [scopeKey='session'] - Scope key for cwd/transcript-aware dedup
* @returns {boolean} true when cleanup succeeds
*/
function clearPendingInjection(sessionId, scopeKey = 'session') {
if (!sessionId) return false;
try {
return updateSessionState(sessionId, (state) => {
const reminderState = state.devRulesReminder && typeof state.devRulesReminder === 'object'
? state.devRulesReminder
: {};
const scopes = pruneReminderScopes(reminderState.scopes);
const scopeState = getReminderScopeState({ scopes }, scopeKey);
if (!scopeState || !scopeState.pendingAt) {
return state;
}
const nextScopeState = { ...scopeState };
delete nextScopeState.pendingAt;
if (Object.keys(nextScopeState).length === 0) {
delete scopes[scopeKey];
} else {
scopes[scopeKey] = nextScopeState;
}
return {
...state,
devRulesReminder: {
...reminderState,
scopes
}
};
});
} catch {
return false;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// SECTION BUILDERS
// ═══════════════════════════════════════════════════════════════════════════
/**
* Build language section
* @param {Object} params
* @param {string} [params.thinkingLanguage] - Language for thinking
* @param {string} [params.responseLanguage] - Language for response
* @returns {string[]} Lines for language section
*/
function buildLanguageSection({ thinkingLanguage, responseLanguage }) {
// Auto-default thinkingLanguage to 'en' when only responseLanguage is set
const effectiveThinking = thinkingLanguage || (responseLanguage ? 'en' : null);
const hasThinking = effectiveThinking && effectiveThinking !== responseLanguage;
const hasResponse = responseLanguage;
const lines = [];
if (hasThinking || hasResponse) {
lines.push(`## Language`);
if (hasThinking) {
lines.push(`- Thinking: Use ${effectiveThinking} for reasoning (logic, precision).`);
}
if (hasResponse) {
lines.push(`- Response: Respond in ${responseLanguage} (natural, fluent).`);
}
lines.push(``);
}
return lines;
}
/**
* Build session section
* @param {Object} [staticEnv] - Pre-computed static environment info
* @returns {string[]} Lines for session section
*/
function buildSessionSection(staticEnv = {}) {
const memUsed = Math.round(process.memoryUsage().heapUsed / 1024 / 1024);
const memTotal = Math.round(os.totalmem() / 1024 / 1024);
const memPercent = Math.round((memUsed / memTotal) * 100);
const cpuUsage = Math.round((process.cpuUsage().user / 1000000) * 100);
const cpuSystem = Math.round((process.cpuUsage().system / 1000000) * 100);
return [
`## Session`,
`- DateTime: ${new Date().toLocaleString()}`,
`- CWD: ${staticEnv.cwd || process.cwd()}`,
`- Timezone: ${staticEnv.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone}`,
`- Working directory: ${staticEnv.cwd || process.cwd()}`,
`- OS: ${staticEnv.osPlatform || process.platform}`,
`- User: ${staticEnv.user || process.env.USERNAME || process.env.USER}`,
`- Locale: ${staticEnv.locale || process.env.LANG || ''}`,
`- Memory usage: ${memUsed}MB/${memTotal}MB (${memPercent}%)`,
`- CPU usage: ${cpuUsage}% user / ${cpuSystem}% system`,
`- Spawning multiple subagents can cause performance issues, spawn and delegate tasks intelligently based on the available system resources.`,
`- Remember that each subagent only has 200K tokens in context window, spawn and delegate tasks intelligently to make sure their context windows don't get bloated.`,
`- IMPORTANT: Include these environment information when prompting subagents to perform tasks.`,
``
];
}
/**
* Read usage limits from cache file (written by usage-context-awareness.cjs)
* @returns {Object|null} Usage data or null if unavailable
*/
function readUsageCache() {
try {
if (fs.existsSync(USAGE_CACHE_FILE)) {
const cache = JSON.parse(fs.readFileSync(USAGE_CACHE_FILE, 'utf-8'));
// Cache is valid for 5 minutes for injection purposes
if (Date.now() - cache.timestamp < 300000 && cache.data) {
return cache.data;
}
}
} catch { }
return null;
}
/**
* Format time until reset
* @param {string} resetAt - ISO timestamp
* @returns {string|null} Formatted time or null
*/
function formatTimeUntilReset(resetAt) {
if (!resetAt) return null;
const resetTime = new Date(resetAt);
const remaining = Math.floor(resetTime.getTime() / 1000) - Math.floor(Date.now() / 1000);
if (remaining <= 0 || remaining > 18000) return null; // Only show if < 5 hours
const hours = Math.floor(remaining / 3600);
const mins = Math.floor((remaining % 3600) / 60);
return `${hours}h ${mins}m`;
}
/**
* Format percentage with warning level
* @param {number} value - Percentage value
* @param {string} label - Label prefix
* @returns {string} Formatted string with warning if applicable
*/
function formatUsagePercent(value, label) {
const pct = Math.round(value);
if (pct >= CRITICAL_THRESHOLD) return `${label}: ${pct}% [CRITICAL]`;
if (pct >= WARN_THRESHOLD) return `${label}: ${pct}% [WARNING]`;
return `${label}: ${pct}%`;
}
/**
* Build context window section from statusline cache
* @param {string} sessionId - Session ID
* @returns {string[]} Lines for context section
*/
function buildContextSection(sessionId) {
// TEMPORARILY DISABLED
return [];
if (!sessionId) return [];
// RE-ENABLED IF NEEDED IN THE FUTURE
try {
const contextPath = path.join(os.tmpdir(), `ck-context-${sessionId}.json`);
if (!fs.existsSync(contextPath)) return [];
const data = JSON.parse(fs.readFileSync(contextPath, 'utf-8'));
// Only use fresh data (< 5 min old - statusline updates every 300ms when active)
if (Date.now() - data.timestamp > 300000) return [];
const lines = [`## Current Session's Context`];
// Format: 48% used (96K/200K tokens)
const usedK = Math.round(data.tokens / 1000);
const sizeK = Math.round(data.size / 1000);
lines.push(`- Context: ${data.percent}% used (${usedK}K/${sizeK}K tokens)`);
lines.push(`- **NOTE:** Optimize the workflow for token efficiency`);
// Warning if high usage
if (data.percent >= CRITICAL_THRESHOLD) {
lines.push(`- **CRITICAL:** Context nearly full. Before compaction hits:`);
lines.push(` 1. Update TodoWrite with current progress (completed + remaining)`);
lines.push(` 2. Be extremely concise — no verbose explanations`);
lines.push(` 3. Session state will auto-restore after compaction`);
} else if (data.percent >= WARN_THRESHOLD) {
lines.push(`- **WARNING:** Context usage moderate - be concise, optimize token efficiency, keep tool outputs short.`);
}
lines.push(``);
return lines;
} catch {
return [];
}
}
/**
* Build usage section from cache
* @returns {string[]} Lines for usage section
*/
function buildUsageSection() {
// TEMPORARILY DISABLED
return [];
// RE-ENABLED IF NEEDED IN THE FUTURE
const usage = readUsageCache();
if (!usage) return [];
const lines = [];
const parts = [];
// 5-hour limit
if (usage.five_hour) {
const util = usage.five_hour.utilization;
if (typeof util === 'number') {
parts.push(formatUsagePercent(util, '5h'));
}
const timeLeft = formatTimeUntilReset(usage.five_hour.resets_at);
if (timeLeft) {
parts.push(`resets in ${timeLeft}`);
}
}
// 7-day limit
if (usage.seven_day?.utilization != null) {
parts.push(formatUsagePercent(usage.seven_day.utilization, '7d'));
}
if (parts.length > 0) {
lines.push(`## Usage Limits`);
lines.push(`- ${parts.join(' | ')}`);
lines.push(``);
}
return lines;
}
/**
* Build rules section
* @param {Object} params
* @param {string} [params.devRulesPath] - Path to dev rules
* @param {string} [params.skillsVenv] - Path to skills venv
* @param {string} [params.plansPath] - Absolute plans path (Issue #476: prevents wrong subdirectory creation)
* @param {string} [params.docsPath] - Absolute docs path
* @returns {string[]} Lines for rules section
*/
function buildRulesSection({ devRulesPath, skillsVenv, plansPath, docsPath }) {
const lines = [`## Rules`];
if (devRulesPath) {
lines.push(`- Read and follow development rules: "${devRulesPath}"`);
}
// Issue #476: Use absolute paths to prevent LLM confusion in multi-CLAUDE.md projects
const plansRef = plansPath || 'plans';
const docsRef = docsPath || 'docs';
lines.push(`- Markdown files are organized in: Plans → "${plansRef}" directory, Docs → "${docsRef}" directory`);
lines.push(`- **IMPORTANT:** DO NOT create markdown files outside of "${plansRef}" or "${docsRef}" UNLESS the user explicitly requests it.`);
if (skillsVenv) {
lines.push(`- Python scripts in .opencode/skills/: Use \`${skillsVenv}\``);
}
lines.push(`- When skills' scripts are failed to execute, always fix them and run again, repeat until success.`);
lines.push(`- Follow **YAGNI (You Aren't Gonna Need It) - KISS (Keep It Simple, Stupid) - DRY (Don't Repeat Yourself)** principles`);
lines.push(`- Sacrifice grammar for the sake of concision when writing reports.`);
lines.push(`- In reports, list any unresolved questions at the end, if any.`);
lines.push(`- IMPORTANT: Ensure token consumption efficiency while maintaining high quality.`);
lines.push(``);
return lines;
}
/**
* Build modularization section
* @returns {string[]} Lines for modularization section
*/
function buildModularizationSection() {
return [
`## **[IMPORTANT] Consider Modularization:**`,
`- Check existing modules before creating new`,
`- Analyze logical separation boundaries (functions, classes, concerns)`,
`- Prefer kebab-case for JS/TS/Python/shell; respect language conventions (C#/Java use PascalCase, Go/Rust use snake_case)`,
`- Write descriptive code comments`,
`- After modularization, continue with main task`,
`- When not to modularize: Markdown files, plain text files, bash scripts, configuration files, environment variables files, etc.`,
``
];
}
/**
* Build paths section
* @param {Object} params
* @param {string} params.reportsPath - Reports path
* @param {string} params.plansPath - Plans path
* @param {string} params.docsPath - Docs path
* @param {number} [params.docsMaxLoc=800] - Max lines of code for docs
* @returns {string[]} Lines for paths section
*/
function buildPathsSection({ reportsPath, plansPath, docsPath, docsMaxLoc = 800 }) {
return [
`## Paths`,
`Reports: ${reportsPath} | Plans: ${plansPath}/ | Docs: ${docsPath}/ | docs.maxLoc: ${docsMaxLoc}`,
``
];
}
/**
* Build plan context section
* @param {Object} params
* @param {string} params.planLine - Plan status line
* @param {string} params.reportsPath - Reports path
* @param {string} [params.gitBranch] - Git branch
* @param {string} params.validationMode - Validation mode
* @param {number} params.validationMin - Min questions
* @param {number} params.validationMax - Max questions
* @returns {string[]} Lines for plan context section
*/
function buildPlanContextSection({ planLine, reportsPath, gitBranch, validationMode, validationMin, validationMax }) {
const lines = [
`## Plan Context`,
planLine,
`- Reports: ${reportsPath}`
];
if (gitBranch) {
lines.push(`- Branch: ${gitBranch}`);
}
lines.push(`- Validation: mode=${validationMode}, questions=${validationMin}-${validationMax}`);
lines.push(``);
return lines;
}
/**
* Build naming section
* @param {Object} params
* @param {string} params.reportsPath - Reports path
* @param {string} params.plansPath - Plans path
* @param {string} params.namePattern - Naming pattern
* @returns {string[]} Lines for naming section
*/
function buildNamingSection({ reportsPath, plansPath, namePattern }) {
return [
`## Naming`,
`- Report: \`${reportsPath}{type}-${namePattern}.md\``,
`- Plan dir: \`${plansPath}/${namePattern}/\``,
`- Replace \`{type}\` with: agent name, report type, or context`,
`- Replace \`{slug}\` in pattern with: descriptive-kebab-slug`
];
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN ENTRY POINTS
// ═══════════════════════════════════════════════════════════════════════════
/**
* Build full reminder content from all sections
* @param {Object} params - All parameters for building reminder
* @returns {string[]} Array of lines
*/
function buildReminder(params) {
const {
sessionId,
thinkingLanguage,
responseLanguage,
devRulesPath,
skillsVenv,
reportsPath,
plansPath,
docsPath,
docsMaxLoc,
planLine,
gitBranch,
namePattern,
validationMode,
validationMin,
validationMax,
staticEnv,
hooks
} = params;
// Respect hooks config — skip sections when their corresponding hook is disabled
const hooksConfig = hooks || {};
const contextEnabled = hooksConfig['context-tracking'] !== false;
const usageEnabled = hooksConfig['usage-context-awareness'] !== false;
return [
...buildLanguageSection({ thinkingLanguage, responseLanguage }),
...buildSessionSection(staticEnv),
...(contextEnabled ? buildContextSection(sessionId) : []),
...(usageEnabled ? buildUsageSection() : []),
...buildRulesSection({ devRulesPath, skillsVenv, plansPath, docsPath }),
...buildModularizationSection(),
...buildPathsSection({ reportsPath, plansPath, docsPath, docsMaxLoc }),
...buildPlanContextSection({ planLine, reportsPath, gitBranch, validationMode, validationMin, validationMax }),
...buildNamingSection({ reportsPath, plansPath, namePattern })
];
}
/**
* Build complete reminder context (unified entry point for plugins)
*
* @param {Object} [params]
* @param {string} [params.sessionId] - Session ID
* @param {Object} [params.config] - CK config (auto-loaded if not provided)
* @param {Object} [params.staticEnv] - Pre-computed static environment info
* @param {string} [params.configDirName='.claude'] - Config directory name
* @param {string} [params.baseDir] - Base directory for absolute path resolution (Issue #327)
* @returns {{
* content: string,
* lines: string[],
* sections: Object
* }}
*/
function buildReminderContext({ sessionId, config, staticEnv, configDirName = '.claude', baseDir } = {}) {
// Load config if not provided
const cfg = config || loadConfig({ includeProject: false, includeAssertions: false });
// Resolve paths
const devRulesPath = resolveRulesPath('development-rules.md', configDirName);
const skillsVenv = resolveSkillsVenv(configDirName);
// Build plan context
const planCtx = buildPlanContext(sessionId, cfg);
// Issue #327: Use baseDir for absolute path resolution (subdirectory workflow support)
// If baseDir provided, resolve paths as absolute; otherwise use relative paths
const effectiveBaseDir = baseDir || null;
const plansPathRel = normalizePath(cfg.paths?.plans) || 'plans';
const docsPathRel = normalizePath(cfg.paths?.docs) || 'docs';
// Build all parameters with absolute paths if baseDir provided
const params = {
sessionId,
thinkingLanguage: cfg.locale?.thinkingLanguage,
responseLanguage: cfg.locale?.responseLanguage,
devRulesPath,
skillsVenv,
reportsPath: effectiveBaseDir ? path.join(effectiveBaseDir, planCtx.reportsPath) : planCtx.reportsPath,
plansPath: effectiveBaseDir ? path.join(effectiveBaseDir, plansPathRel) : plansPathRel,
docsPath: effectiveBaseDir ? path.join(effectiveBaseDir, docsPathRel) : docsPathRel,
docsMaxLoc: Math.max(1, parseInt(cfg.docs?.maxLoc, 10) || 800),
planLine: planCtx.planLine,
gitBranch: planCtx.gitBranch,
namePattern: planCtx.namePattern,
validationMode: planCtx.validationMode,
validationMin: planCtx.validationMin,
validationMax: planCtx.validationMax,
staticEnv,
hooks: cfg.hooks
};
const lines = buildReminder(params);
// Respect hooks config for sections object too
const hooksConfig = cfg.hooks || {};
const contextEnabled = hooksConfig['context-tracking'] !== false;
const usageEnabled = hooksConfig['usage-context-awareness'] !== false;
return {
content: lines.join('\n'),
lines,
sections: {
language: buildLanguageSection({ thinkingLanguage: params.thinkingLanguage, responseLanguage: params.responseLanguage }),
session: buildSessionSection(staticEnv),
context: contextEnabled ? buildContextSection(sessionId) : [],
usage: usageEnabled ? buildUsageSection() : [],
rules: buildRulesSection({ devRulesPath, skillsVenv, plansPath: params.plansPath, docsPath: params.docsPath }),
modularization: buildModularizationSection(),
paths: buildPathsSection({ reportsPath: params.reportsPath, plansPath: params.plansPath, docsPath: params.docsPath, docsMaxLoc: params.docsMaxLoc }),
planContext: buildPlanContextSection(planCtx),
naming: buildNamingSection({ reportsPath: params.reportsPath, plansPath: params.plansPath, namePattern: params.namePattern })
}
};
}
// ═══════════════════════════════════════════════════════════════════════════
// EXPORTS
// ═══════════════════════════════════════════════════════════════════════════
module.exports = {
// Main entry points
buildReminderContext,
buildReminder,
// Section builders
buildLanguageSection,
buildSessionSection,
buildContextSection,
buildUsageSection,
buildRulesSection,
buildModularizationSection,
buildPathsSection,
buildPlanContextSection,
buildNamingSection,
// Helpers
execSafe,
resolveRulesPath,
resolveScriptPath,
resolveSkillsVenv,
buildPlanContext,
buildInjectionScopeKey,
wasRecentlyInjected,
reserveInjectionScope,
markRecentlyInjected,
clearPendingInjection,
// Backward compat alias
resolveWorkflowPath: resolveRulesPath
};

View File

@@ -0,0 +1,297 @@
#!/usr/bin/env node
/**
* privacy-checker.cjs - Privacy pattern matching logic for sensitive file detection
*
* Extracted from privacy-block.cjs for reuse in both Claude hooks and OpenCode plugins.
* Pure logic module - no stdin/stdout, no exit codes.
*
* @module privacy-checker
*/
const path = require('path');
const fs = require('fs');
// ═══════════════════════════════════════════════════════════════════════════
// CONSTANTS
// ═══════════════════════════════════════════════════════════════════════════
const APPROVED_PREFIX = 'APPROVED:';
// Safe file patterns - exempt from privacy checks (documentation/template files)
const SAFE_PATTERNS = [
/\.example$/i, // .env.example, config.example
/\.sample$/i, // .env.sample
/\.template$/i, // .env.template
];
// Privacy-sensitive patterns
const PRIVACY_PATTERNS = [
/^\.env$/, // .env
/^\.env\./, // .env.local, .env.production, etc.
/\.env$/, // path/to/.env
/\/\.env\./, // path/to/.env.local
/credentials/i, // credentials.json, etc.
/secrets?\.ya?ml$/i, // secrets.yaml, secret.yml
/\.pem$/, // Private keys
/\.key$/, // Private keys
/id_rsa/, // SSH keys
/id_ed25519/, // SSH keys
];
// ═══════════════════════════════════════════════════════════════════════════
// HELPER FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════════
/**
* Check if path is a safe file (example/sample/template)
* @param {string} testPath - Path to check
* @returns {boolean} true if file matches safe patterns
*/
function isSafeFile(testPath) {
if (!testPath) return false;
const basename = path.basename(testPath);
return SAFE_PATTERNS.some(p => p.test(basename));
}
/**
* Check if path has APPROVED: prefix
* @param {string} testPath - Path to check
* @returns {boolean} true if path starts with APPROVED:
*/
function hasApprovalPrefix(testPath) {
return testPath && testPath.startsWith(APPROVED_PREFIX);
}
/**
* Strip APPROVED: prefix from path
* @param {string} testPath - Path to process
* @returns {string} Path without APPROVED: prefix
*/
function stripApprovalPrefix(testPath) {
if (hasApprovalPrefix(testPath)) {
return testPath.slice(APPROVED_PREFIX.length);
}
return testPath;
}
/**
* Check if stripped path is suspicious (path traversal or absolute)
* @param {string} strippedPath - Path after stripping APPROVED: prefix
* @returns {boolean} true if path looks suspicious
*/
function isSuspiciousPath(strippedPath) {
return strippedPath.includes('..') || path.isAbsolute(strippedPath);
}
/**
* Check if path matches privacy patterns
* @param {string} testPath - Path to check
* @returns {boolean} true if path matches privacy-sensitive patterns
*/
function isPrivacySensitive(testPath) {
if (!testPath) return false;
// Strip prefix for pattern matching
const cleanPath = stripApprovalPrefix(testPath);
let normalized = cleanPath.replace(/\\/g, '/');
// Decode URI components to catch obfuscated paths (%2e = '.')
try {
normalized = decodeURIComponent(normalized);
} catch (e) {
// Invalid encoding, use as-is
}
// Check safe patterns first - exempt example/sample/template files
if (isSafeFile(normalized)) {
return false;
}
const basename = path.basename(normalized);
for (const pattern of PRIVACY_PATTERNS) {
if (pattern.test(basename) || pattern.test(normalized)) {
return true;
}
}
return false;
}
/**
* Extract paths from tool input
* @param {Object} toolInput - Tool input object with file_path, path, pattern, or command
* @returns {Array<{value: string, field: string}>} Array of extracted paths with field names
*/
function extractPaths(toolInput) {
const paths = [];
if (!toolInput) return paths;
if (toolInput.file_path) paths.push({ value: toolInput.file_path, field: 'file_path' });
if (toolInput.path) paths.push({ value: toolInput.path, field: 'path' });
if (toolInput.pattern) paths.push({ value: toolInput.pattern, field: 'pattern' });
// Check bash commands for file paths
if (toolInput.command) {
// Look for APPROVED:.env or .env patterns
const approvedMatch = toolInput.command.match(/APPROVED:[^\s]+/g) || [];
approvedMatch.forEach(p => paths.push({ value: p, field: 'command' }));
// Only look for .env if no APPROVED: version found
if (approvedMatch.length === 0) {
const envMatch = toolInput.command.match(/\.env[^\s]*/g) || [];
envMatch.forEach(p => paths.push({ value: p, field: 'command' }));
// Also check bash variable assignments (FILE=.env, ENV_FILE=.env.local)
const varAssignments = toolInput.command.match(/\w+=[^\s]*\.env[^\s]*/g) || [];
varAssignments.forEach(a => {
const value = a.split('=')[1];
if (value) paths.push({ value, field: 'command' });
});
// Check command substitution containing sensitive patterns - extract .env from inside
const cmdSubst = toolInput.command.match(/\$\([^)]*?(\.env[^\s)]*)[^)]*\)/g) || [];
for (const subst of cmdSubst) {
const inner = subst.match(/\.env[^\s)]*/);
if (inner) paths.push({ value: inner[0], field: 'command' });
}
}
}
return paths.filter(p => p.value);
}
/**
* Load .ck.json config to check if privacy block is disabled
* @param {string} [configDir] - Directory containing .ck.json (defaults to .claude in cwd)
* @returns {boolean} true if privacy block should be skipped
*/
function isPrivacyBlockDisabled(configDir) {
try {
const configPath = configDir
? path.join(configDir, '.ck.json')
: path.join(process.cwd(), '.claude', '.ck.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
return config.privacyBlock === false;
} catch {
return false; // Default to enabled on error (file not found or invalid JSON)
}
}
/**
* Build prompt data for AskUserQuestion tool
* @param {string} filePath - Blocked file path
* @returns {Object} Prompt data object
*/
function buildPromptData(filePath) {
const basename = path.basename(filePath);
return {
type: 'PRIVACY_PROMPT',
file: filePath,
basename: basename,
question: {
header: 'File Access',
text: `I need to read "${basename}" which may contain sensitive data (API keys, passwords, tokens). Do you approve?`,
options: [
{ label: 'Yes, approve access', description: `Allow reading ${basename} this time` },
{ label: 'No, skip this file', description: 'Continue without accessing this file' }
]
}
};
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN ENTRY POINT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Check if a tool call accesses privacy-sensitive files
*
* @param {Object} params
* @param {string} params.toolName - Name of tool (Read, Write, Bash, etc.)
* @param {Object} params.toolInput - Tool input with file_path, path, command, etc.
* @param {Object} [params.options]
* @param {boolean} [params.options.disabled] - Skip checks if true
* @param {string} [params.options.configDir] - Directory for .ck.json config
* @param {boolean} [params.options.allowBash] - Allow Bash tool without blocking (default: true)
* @returns {{
* blocked: boolean,
* filePath?: string,
* reason?: string,
* approved?: boolean,
* isBash?: boolean,
* suspicious?: boolean,
* promptData?: Object
* }}
*/
function checkPrivacy({ toolName, toolInput, options = {} }) {
const { disabled, configDir, allowBash = true } = options;
// Check if disabled via options or config
if (disabled || isPrivacyBlockDisabled(configDir)) {
return { blocked: false };
}
const isBashTool = toolName === 'Bash';
const paths = extractPaths(toolInput);
// Check each path
for (const { value: testPath } of paths) {
if (!isPrivacySensitive(testPath)) continue;
// Check for approval prefix
if (hasApprovalPrefix(testPath)) {
const strippedPath = stripApprovalPrefix(testPath);
return {
blocked: false,
approved: true,
filePath: strippedPath,
suspicious: isSuspiciousPath(strippedPath)
};
}
// For Bash: warn but don't block (allows "Yes → bash cat" flow)
if (isBashTool && allowBash) {
return {
blocked: false,
isBash: true,
filePath: testPath,
reason: `Bash command accesses sensitive file: ${testPath}`
};
}
// Block - sensitive file without approval
return {
blocked: true,
filePath: testPath,
reason: `Sensitive file access requires user approval`,
promptData: buildPromptData(testPath)
};
}
// No sensitive paths found
return { blocked: false };
}
// ═══════════════════════════════════════════════════════════════════════════
// EXPORTS
// ═══════════════════════════════════════════════════════════════════════════
module.exports = {
// Main entry point
checkPrivacy,
// Helper functions (for testing and direct use)
isSafeFile,
isPrivacySensitive,
hasApprovalPrefix,
stripApprovalPrefix,
isSuspiciousPath,
extractPaths,
isPrivacyBlockDisabled,
buildPromptData,
// Constants
APPROVED_PREFIX,
SAFE_PATTERNS,
PRIVACY_PATTERNS
};

View File

@@ -0,0 +1,474 @@
#!/usr/bin/env node
/**
* project-detector.cjs - Project and environment detection logic
*
* Extracted from session-init.cjs for reuse in both Claude hooks and OpenCode plugins.
* Detects project type, package manager, framework, and runtime versions.
*
* @module project-detector
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync, execFileSync } = require('child_process');
// ═══════════════════════════════════════════════════════════════════════════
// SAFE EXECUTION HELPERS
// ═══════════════════════════════════════════════════════════════════════════
/**
* Safely execute shell command with optional timeout
* @param {string} cmd - Command to execute
* @param {number} [timeoutMs=5000] - Timeout in milliseconds
* @returns {string|null} Output or null on error
*/
function execSafe(cmd, timeoutMs = 5000) {
try {
return execSync(cmd, {
encoding: 'utf8',
timeout: timeoutMs,
stdio: ['pipe', 'pipe', 'pipe']
}).trim();
} catch (e) {
return null;
}
}
/**
* Safely execute a binary with arguments (no shell interpolation)
* @param {string} binary - Path to the executable
* @param {string[]} args - Arguments array
* @param {number} [timeoutMs=2000] - Timeout in milliseconds
* @returns {string|null} Output or null on error
*/
function execFileSafe(binary, args, timeoutMs = 2000) {
try {
return execFileSync(binary, args, {
encoding: 'utf8',
timeout: timeoutMs,
stdio: ['pipe', 'pipe', 'pipe']
}).trim();
} catch (e) {
return null;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// PYTHON DETECTION
// ═══════════════════════════════════════════════════════════════════════════
/**
* Validate that a path is a file and doesn't contain shell metacharacters
* @param {string} p - Path to validate
* @returns {boolean}
*/
function isValidPythonPath(p) {
if (!p || typeof p !== 'string') return false;
if (/[;&|`$(){}[\]<>!#*?]/.test(p)) return false;
try {
const stat = fs.statSync(p);
return stat.isFile();
} catch (e) {
return false;
}
}
/**
* Build platform-specific Python paths for fast filesystem check
* @returns {string[]} Array of potential Python paths
*/
function getPythonPaths() {
const paths = [];
if (process.env.PYTHON_PATH) {
paths.push(process.env.PYTHON_PATH);
}
if (process.platform === 'win32') {
const localAppData = process.env.LOCALAPPDATA;
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
if (localAppData) {
paths.push(path.join(localAppData, 'Microsoft', 'WindowsApps', 'python.exe'));
paths.push(path.join(localAppData, 'Microsoft', 'WindowsApps', 'python3.exe'));
for (const ver of ['313', '312', '311', '310', '39']) {
paths.push(path.join(localAppData, 'Programs', 'Python', `Python${ver}`, 'python.exe'));
}
}
for (const ver of ['313', '312', '311', '310', '39']) {
paths.push(path.join(programFiles, `Python${ver}`, 'python.exe'));
paths.push(path.join(programFilesX86, `Python${ver}`, 'python.exe'));
}
paths.push('C:\\Python313\\python.exe');
paths.push('C:\\Python312\\python.exe');
paths.push('C:\\Python311\\python.exe');
paths.push('C:\\Python310\\python.exe');
paths.push('C:\\Python39\\python.exe');
} else {
paths.push('/usr/bin/python3');
paths.push('/usr/local/bin/python3');
paths.push('/opt/homebrew/bin/python3');
paths.push('/opt/homebrew/bin/python');
paths.push('/usr/bin/python');
paths.push('/usr/local/bin/python');
}
return paths;
}
/**
* Find Python binary using fast `which` lookup first, then filesystem check
* @returns {string|null} Python binary path or null
*/
function findPythonBinary() {
// Fast path: try `which` command first (10ms vs 2000ms per path)
if (process.platform !== 'win32') {
const whichPython3 = execSafe('which python3', 500);
if (whichPython3 && isValidPythonPath(whichPython3)) return whichPython3;
const whichPython = execSafe('which python', 500);
if (whichPython && isValidPythonPath(whichPython)) return whichPython;
} else {
// Windows: try `where` command
const wherePython = execSafe('where python', 500);
if (wherePython) {
const firstPath = wherePython.split('\n')[0].trim();
if (isValidPythonPath(firstPath)) return firstPath;
}
}
// Fallback: check known paths
const paths = getPythonPaths();
for (const p of paths) {
if (isValidPythonPath(p)) return p;
}
return null;
}
/**
* Get Python version with optimized detection
* @returns {string|null} Python version string or null
*/
function getPythonVersion() {
const pythonPath = findPythonBinary();
if (pythonPath) {
const result = execFileSafe(pythonPath, ['--version']);
if (result) return result;
}
const commands = ['python3', 'python'];
for (const cmd of commands) {
const result = execFileSafe(cmd, ['--version']);
if (result) return result;
}
return null;
}
// ═══════════════════════════════════════════════════════════════════════════
// GIT DETECTION
// ═══════════════════════════════════════════════════════════════════════════
/**
* Check if current directory is inside a git repository (fast check)
* Uses filesystem traversal instead of git command to avoid command failures
* @param {string} [startDir] - Directory to check from (defaults to cwd)
* @returns {boolean}
*/
function isGitRepo(startDir) {
let dir;
try {
dir = startDir || process.cwd();
} catch (e) {
// CWD deleted or inaccessible
return false;
}
const root = path.parse(dir).root;
while (dir !== root) {
if (fs.existsSync(path.join(dir, '.git'))) return true;
dir = path.dirname(dir);
}
return fs.existsSync(path.join(root, '.git'));
}
/**
* Get git remote URL
* @returns {string|null}
*/
function getGitRemoteUrl() {
if (!isGitRepo()) return null;
return execFileSafe('git', ['config', '--get', 'remote.origin.url']);
}
/**
* Get current git branch
* @returns {string|null}
*/
function getGitBranch() {
if (!isGitRepo()) return null;
return execFileSafe('git', ['branch', '--show-current']);
}
/**
* Get git repository root
* @returns {string|null}
*/
function getGitRoot() {
if (!isGitRepo()) return null;
return execFileSafe('git', ['rev-parse', '--show-toplevel']);
}
// ═══════════════════════════════════════════════════════════════════════════
// PROJECT DETECTION
// ═══════════════════════════════════════════════════════════════════════════
/**
* Detect project type based on workspace indicators
* @param {string} [configOverride] - Manual override from config
* @returns {'monorepo' | 'library' | 'single-repo'}
*/
function detectProjectType(configOverride) {
if (configOverride && configOverride !== 'auto') return configOverride;
if (fs.existsSync('pnpm-workspace.yaml')) return 'monorepo';
if (fs.existsSync('lerna.json')) return 'monorepo';
if (fs.existsSync('package.json')) {
try {
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
if (pkg.workspaces) return 'monorepo';
if (pkg.main || pkg.exports) return 'library';
} catch (e) { /* ignore */ }
}
return 'single-repo';
}
/**
* Detect package manager from lock files
* @param {string} [configOverride] - Manual override from config
* @returns {'npm' | 'pnpm' | 'yarn' | 'bun' | null}
*/
function detectPackageManager(configOverride) {
if (configOverride && configOverride !== 'auto') return configOverride;
if (fs.existsSync('bun.lockb')) return 'bun';
if (fs.existsSync('pnpm-lock.yaml')) return 'pnpm';
if (fs.existsSync('yarn.lock')) return 'yarn';
if (fs.existsSync('package-lock.json')) return 'npm';
return null;
}
/**
* Detect framework from package.json dependencies
* @param {string} [configOverride] - Manual override from config
* @returns {string|null}
*/
function detectFramework(configOverride) {
if (configOverride && configOverride !== 'auto') return configOverride;
if (!fs.existsSync('package.json')) return null;
try {
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
if (deps['next']) return 'next';
if (deps['nuxt']) return 'nuxt';
if (deps['astro']) return 'astro';
if (deps['@remix-run/node'] || deps['@remix-run/react']) return 'remix';
if (deps['svelte'] || deps['@sveltejs/kit']) return 'svelte';
if (deps['vue']) return 'vue';
if (deps['react']) return 'react';
if (deps['express']) return 'express';
if (deps['fastify']) return 'fastify';
if (deps['hono']) return 'hono';
if (deps['elysia']) return 'elysia';
return null;
} catch (e) {
return null;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// CODING LEVEL
// ═══════════════════════════════════════════════════════════════════════════
/**
* Get coding level style name mapping
* @param {number} level - Coding level (0-5)
* @returns {string} Style name
*/
function getCodingLevelStyleName(level) {
const styleMap = {
0: 'coding-level-0-eli5',
1: 'coding-level-1-junior',
2: 'coding-level-2-mid',
3: 'coding-level-3-senior',
4: 'coding-level-4-lead',
5: 'coding-level-5-god'
};
return styleMap[level] || 'coding-level-5-god';
}
/**
* Get coding level guidelines by reading from output-styles .md files
* @param {number} level - Coding level (-1 to 5)
* @param {string} [configDir] - Config directory path
* @returns {string|null} Guidelines text or null if disabled
*/
function getCodingLevelGuidelines(level, configDir) {
if (level === -1 || level === null || level === undefined) return null;
const styleName = getCodingLevelStyleName(level);
const basePath = configDir || path.join(process.cwd(), '.claude');
const stylePath = path.join(basePath, 'output-styles', `${styleName}.md`);
try {
if (!fs.existsSync(stylePath)) return null;
const content = fs.readFileSync(stylePath, 'utf8');
const withoutFrontmatter = content.replace(/^---[\s\S]*?---\n*/, '').trim();
return withoutFrontmatter;
} catch (e) {
return null;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// CONTEXT OUTPUT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Build context summary for output (compact, single line)
* @param {Object} config - Loaded config
* @param {Object} detections - Project detections
* @param {{ path: string|null, resolvedBy: string|null }} resolved - Plan resolution
* @param {string|null} gitRoot - Git repository root
* @returns {string}
*/
function buildContextOutput(config, detections, resolved, gitRoot) {
const lines = [`Project: ${detections.type || 'unknown'}`];
if (detections.pm) lines.push(`PM: ${detections.pm}`);
lines.push(`Plan naming: ${config.plan.namingFormat}`);
if (gitRoot && gitRoot !== process.cwd()) {
lines.push(`Root: ${gitRoot}`);
}
if (resolved.path) {
if (resolved.resolvedBy === 'session') {
lines.push(`Plan: ${resolved.path}`);
} else {
lines.push(`Suggested: ${resolved.path}`);
}
}
return lines.join(' | ');
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN ENTRY POINT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Detect all project information
*
* @param {Object} [options]
* @param {Object} [options.configOverrides] - Override auto-detection
* @returns {{
* type: 'monorepo' | 'library' | 'single-repo',
* packageManager: 'npm' | 'pnpm' | 'yarn' | 'bun' | null,
* framework: string | null,
* pythonVersion: string | null,
* nodeVersion: string,
* gitBranch: string | null,
* gitRoot: string | null,
* gitUrl: string | null,
* osPlatform: string,
* user: string,
* locale: string,
* timezone: string
* }}
*/
function detectProject(options = {}) {
const { configOverrides = {} } = options;
return {
type: detectProjectType(configOverrides.type),
packageManager: detectPackageManager(configOverrides.packageManager),
framework: detectFramework(configOverrides.framework),
pythonVersion: getPythonVersion(),
nodeVersion: process.version,
gitBranch: getGitBranch(),
gitRoot: getGitRoot(),
gitUrl: getGitRemoteUrl(),
osPlatform: process.platform,
user: process.env.USERNAME || process.env.USER || process.env.LOGNAME || os.userInfo().username,
locale: process.env.LANG || '',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
};
}
/**
* Build static environment info object
* @param {string} [configDir] - Config directory path
* @returns {Object} Static environment info
*/
function buildStaticEnv(configDir) {
return {
nodeVersion: process.version,
pythonVersion: getPythonVersion(),
osPlatform: process.platform,
gitUrl: getGitRemoteUrl(),
gitBranch: getGitBranch(),
gitRoot: getGitRoot(),
user: process.env.USERNAME || process.env.USER || process.env.LOGNAME || os.userInfo().username,
locale: process.env.LANG || '',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
configDir: configDir || path.join(process.cwd(), '.claude')
};
}
// ═══════════════════════════════════════════════════════════════════════════
// EXPORTS
// ═══════════════════════════════════════════════════════════════════════════
module.exports = {
// Main entry points
detectProject,
buildStaticEnv,
// Detection functions
detectProjectType,
detectPackageManager,
detectFramework,
// Python detection
getPythonVersion,
findPythonBinary,
getPythonPaths,
isValidPythonPath,
// Git detection
isGitRepo,
getGitRemoteUrl,
getGitBranch,
getGitRoot,
// Coding level
getCodingLevelStyleName,
getCodingLevelGuidelines,
// Output
buildContextOutput,
// Helpers
execSafe,
execFileSafe
};

View File

@@ -0,0 +1,311 @@
#!/usr/bin/env node
/**
* scout-checker.cjs - Facade for scout-block modules
*
* Provides unified interface to scout-block/* modules for reuse in both
* Claude hooks and OpenCode plugins.
*
* @module scout-checker
*/
const fs = require('fs');
const path = require('path');
// Import scout-block modules
const { loadPatterns, createMatcher, matchPath } = require('../scout-block/pattern-matcher.cjs');
const { extractFromToolInput } = require('../scout-block/path-extractor.cjs');
const { detectBroadPatternIssue } = require('../scout-block/broad-pattern-detector.cjs');
// ═══════════════════════════════════════════════════════════════════════════
// COMMAND PATTERNS
// ═══════════════════════════════════════════════════════════════════════════
// Build command allowlist - these are allowed even if they contain blocked paths
// Handles flags and filters: npm build, pnpm --filter web run build, yarn workspace app build
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)/;
// Tool commands - JS/TS, Go, Rust, Java, .NET, containers, IaC, Python, Ruby, PHP, Deno, Elixir
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|python3?|pip|uv|deno|bundle|rake|gem|php|composer|ruby|mix|elixir)/;
// Allow execution from .venv/bin/ or venv/bin/ (Unix) and .venv/Scripts/ or venv/Scripts/ (Windows)
const VENV_EXECUTABLE_PATTERN = /(^|[\/\\])\.?venv[\/\\](bin|Scripts)[\/\\]/;
// Allow Python venv creation commands (cross-platform):
// - python/python3 -m venv (Unix/macOS/Windows)
// - py -m venv (Windows py launcher, supports -3, -3.11, etc.)
// - uv venv (fast Rust-based Python package manager)
// - virtualenv (legacy but still widely used)
const VENV_CREATION_PATTERN = /^(python3?|py)\s+(-[\w.]+\s+)*-m\s+venv\s+|^uv\s+venv(\s|$)|^virtualenv\s+/;
// ═══════════════════════════════════════════════════════════════════════════
// HELPER FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════════
/**
* Strip leading ENV variable assignments and command wrappers (sudo, env, etc.)
* e.g., "NODE_ENV=production npm run build" → "npm run build"
* @param {string} command - The command to strip
* @returns {string}
*/
function stripCommandPrefix(command) {
if (!command || typeof command !== 'string') return command;
let stripped = command.trim();
// Strip env var assignments (KEY=VALUE KEY2=VALUE2 ...)
stripped = stripped.replace(/^(\w+=\S+\s+)+/, '');
// Strip common command wrappers (one level)
stripped = stripped.replace(/^(sudo|env|nice|nohup|time|timeout)\s+/, '');
// Strip env vars again (sudo env VAR=x cmd)
stripped = stripped.replace(/^(\w+=\S+\s+)+/, '');
return stripped.trim();
}
/**
* Check if a command is a build/tooling command (should be allowed)
* @param {string} command - The command to check
* @returns {boolean}
*/
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);
}
/**
* Split a compound command into sub-commands on &&, ||, and ;.
* Does NOT split on newlines — newlines in command strings are typically
* heredoc bodies or multiline strings, not compound operators.
* Does not handle operators inside quoted strings (extremely rare in practice).
*
* @param {string} command - The compound command string
* @returns {string[]} Array of sub-commands (trimmed, non-empty)
*/
function splitCompoundCommand(command) {
if (!command || typeof command !== 'string') return [];
return command.split(/\s*(?:&&|\|\||;)\s*/).filter(cmd => cmd && cmd.trim().length > 0);
}
/**
* Unwrap shell executor wrappers (bash -c "...", sh -c '...', eval "...").
* Returns the inner command string for re-processing.
* @param {string} command - The command to unwrap
* @returns {string} Inner command, or original if not a shell executor
*/
function unwrapShellExecutor(command) {
if (!command || typeof command !== 'string') return command;
const match = command.trim().match(
/^(?:(?:bash|sh|zsh)\s+-c|eval)\s+["'](.+)["']\s*$/
);
return match ? match[1] : command;
}
/**
* Check if command executes from a .venv bin directory
* @param {string} command - The command to check
* @returns {boolean}
*/
function isVenvExecutable(command) {
if (!command || typeof command !== 'string') return false;
return VENV_EXECUTABLE_PATTERN.test(command);
}
/**
* Check if command creates a Python virtual environment
* @param {string} command - The command to check
* @returns {boolean}
*/
function isVenvCreationCommand(command) {
if (!command || typeof command !== 'string') return false;
return VENV_CREATION_PATTERN.test(command.trim());
}
/**
* Check if command should be allowed (build, venv executable, or venv creation)
* Strips ENV prefixes and command wrappers before checking.
* @param {string} command - The command to check
* @returns {boolean}
*/
function isAllowedCommand(command) {
const stripped = stripCommandPrefix(command);
return isBuildCommand(stripped) || isVenvExecutable(stripped) || isVenvCreationCommand(stripped);
}
function findGitRoot(startDir) {
if (!startDir || typeof startDir !== 'string') return null;
let dir = path.resolve(startDir);
const root = path.parse(dir).root;
while (true) {
if (fs.existsSync(path.join(dir, '.git')) || dir === root) {
return fs.existsSync(path.join(dir, '.git')) ? dir : null;
}
dir = path.dirname(dir);
}
}
/**
* Find an optional project-local .ckignore at the git root config directory.
* This keeps overrides stable regardless of the caller cwd inside the repo.
*
* @param {string} startDir - Directory to start searching from
* @param {string} [configDirName] - Config directory at git root (.claude, .opencode)
* @returns {string|null}
*/
function findProjectCkignore(startDir, configDirName) {
if (!configDirName || typeof configDirName !== 'string') return null;
const gitRoot = findGitRoot(startDir);
if (!gitRoot) return null;
const candidate = path.join(gitRoot, configDirName, '.ckignore');
return fs.existsSync(candidate) ? candidate : null;
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN ENTRY POINT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Check if a tool call accesses blocked directories or uses overly broad patterns
*
* @param {Object} params
* @param {string} params.toolName - Name of tool (Bash, Glob, Read, etc.)
* @param {Object} params.toolInput - Tool input with file_path, path, pattern, command
* @param {Object} [params.options]
* @param {string} [params.options.ckignorePath] - Path to .ckignore file
* @param {string} [params.options.projectCkignorePath] - Explicit project-local .ckignore path
* @param {string} [params.options.claudeDir] - Path to .claude or .opencode directory
* @param {string} [params.options.cwd] - Working directory used to discover a project .ckignore
* @param {string} [params.options.projectConfigDirName] - Git-root config dir for project-local overrides
* @param {boolean} [params.options.checkBroadPatterns] - Check for overly broad glob patterns (default: true)
* @returns {{
* blocked: boolean,
* path?: string,
* pattern?: string,
* reason?: string,
* configPath?: string,
* isBroadPattern?: boolean,
* suggestions?: string[],
* isAllowedCommand?: boolean
* }}
*/
function checkScoutBlock({ toolName, toolInput, options = {} }) {
const {
ckignorePath,
projectCkignorePath,
claudeDir = path.join(process.cwd(), '.claude'),
cwd = process.cwd(),
projectConfigDirName,
checkBroadPatterns = true
} = options;
// Unwrap shell executor wrappers (bash -c "...", eval "...")
// so the inner command gets properly analyzed
if (toolInput.command) {
const unwrapped = unwrapShellExecutor(toolInput.command);
if (unwrapped !== toolInput.command) {
toolInput = { ...toolInput, command: unwrapped };
}
}
// For Bash commands, split compound commands (&&, ||, ;) and check
// each sub-command independently. This prevents "echo msg && npm run build"
// from being blocked due to "build" token in the allowed build sub-command.
// Must split BEFORE isAllowedCommand because BUILD_COMMAND_PATTERN has no end
// anchor and would match the prefix of "npm run build && cat dist/file.js".
if (toolInput.command) {
const subCommands = splitCompoundCommand(toolInput.command);
const nonAllowed = subCommands.filter(cmd => !isAllowedCommand(cmd.trim()));
if (nonAllowed.length === 0) {
return { blocked: false, isAllowedCommand: true };
}
// Only extract paths from non-allowed sub-commands
if (nonAllowed.length < subCommands.length) {
toolInput = { ...toolInput, command: nonAllowed.join(' ; ') };
}
}
// Check for overly broad glob patterns (Glob tool)
if (checkBroadPatterns && (toolName === 'Glob' || toolInput.pattern)) {
const broadResult = detectBroadPatternIssue(toolInput);
if (broadResult.blocked) {
return {
blocked: true,
isBroadPattern: true,
pattern: toolInput.pattern,
reason: broadResult.reason || 'Pattern too broad - may fill context with too many files',
suggestions: broadResult.suggestions || []
};
}
}
// Resolve .ckignore path
const resolvedCkignorePath = ckignorePath || path.join(claudeDir, '.ckignore');
const discoveredProjectCkignorePath = projectCkignorePath || findProjectCkignore(cwd, projectConfigDirName);
const resolvedProjectCkignorePath = discoveredProjectCkignorePath
&& path.resolve(discoveredProjectCkignorePath) !== path.resolve(resolvedCkignorePath)
? discoveredProjectCkignorePath
: null;
const configPath = resolvedProjectCkignorePath || resolvedCkignorePath;
// Load patterns and create matcher
const patterns = loadPatterns(resolvedCkignorePath, resolvedProjectCkignorePath);
const matcher = createMatcher(patterns);
// Extract paths from tool input
const extractedPaths = extractFromToolInput(toolInput);
// If no paths extracted, allow operation
if (extractedPaths.length === 0) {
return { blocked: false };
}
// Check each path against patterns
for (const extractedPath of extractedPaths) {
const result = matchPath(matcher, extractedPath);
if (result.blocked) {
return {
blocked: true,
path: extractedPath,
pattern: result.pattern,
configPath,
reason: `Path matches blocked pattern: ${result.pattern}`
};
}
}
// All paths allowed
return { blocked: false };
}
// ═══════════════════════════════════════════════════════════════════════════
// EXPORTS
// ═══════════════════════════════════════════════════════════════════════════
module.exports = {
// Main entry point
checkScoutBlock,
// Command checkers
isBuildCommand,
isVenvExecutable,
isVenvCreationCommand,
isAllowedCommand,
splitCompoundCommand,
stripCommandPrefix,
unwrapShellExecutor,
findGitRoot,
findProjectCkignore,
// Re-export scout-block modules for direct access
loadPatterns,
createMatcher,
matchPath,
extractFromToolInput,
detectBroadPatternIssue,
// Patterns (for testing)
BUILD_COMMAND_PATTERN,
TOOL_COMMAND_PATTERN,
VENV_EXECUTABLE_PATTERN,
VENV_CREATION_PATTERN
};

View File

@@ -0,0 +1,32 @@
import type { Plugin } from "@opencode-ai/plugin";
// Import shared CJS module
const { checkPrivacy } = require("./lib/privacy-checker.cjs");
/**
* Privacy Block Plugin - Block access to sensitive files
*
* Equivalent to Claude's privacy-block.cjs hook.
* Blocks .env, credentials, keys unless explicitly approved.
*/
export const PrivacyBlockPlugin: Plugin = async ({ directory }) => {
return {
"tool.execute.before": async (input: any, output: any) => {
const result = checkPrivacy({
toolName: input.tool,
toolInput: output.args,
options: { configDir: `${directory}/.opencode` }
});
if (result.blocked && !result.approved) {
throw new Error(
`[Privacy Block] Access to ${result.filePath} requires approval.\n` +
`File may contain sensitive data (API keys, passwords).\n` +
`Reason: ${result.reason}`
);
}
}
};
};
export default PrivacyBlockPlugin;

View File

@@ -0,0 +1,46 @@
import type { Plugin } from "@opencode-ai/plugin";
const { checkScoutBlock } = require("./lib/scout-checker.cjs");
/**
* Scout Block Plugin - Prevent access to heavy directories
*
* Blocks node_modules, dist, .git, etc. to prevent context overflow.
* Equivalent to Claude's scout-block.cjs hook.
*/
export const ScoutBlockPlugin: Plugin = async ({ directory }) => {
const ckignorePath = `${directory}/.opencode/.ckignore`;
const claudeDir = `${directory}/.opencode`;
return {
"tool.execute.before": async (input: any, output: any) => {
const result = checkScoutBlock({
toolName: input.tool,
toolInput: output.args,
options: {
ckignorePath,
claudeDir,
cwd: directory,
projectConfigDirName: `.opencode`
}
});
if (result.blocked) {
const configPath = result.configPath || `.opencode/.ckignore`;
let errorMsg = `[Scout Block] Access to '${result.path}' blocked.\n`;
errorMsg += `Pattern: ${result.pattern}\n`;
if (result.isBroadPattern && result.suggestions?.length) {
errorMsg += `\nSuggested alternatives:\n`;
result.suggestions.forEach((s: string) => errorMsg += ` - ${s}\n`);
}
errorMsg += `\nTo allow, add '!${result.pattern}' to ${configPath}`;
throw new Error(errorMsg);
}
}
};
};
export default ScoutBlockPlugin;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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