init
This commit is contained in:
84
.opencode/plugin/context-injector.ts
Normal file
84
.opencode/plugin/context-injector.ts
Normal 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;
|
||||
926
.opencode/plugin/lib/ck-config-utils.cjs
Normal file
926
.opencode/plugin/lib/ck-config-utils.cjs
Normal 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
|
||||
};
|
||||
|
||||
180
.opencode/plugin/lib/colors.cjs
Normal file
180
.opencode/plugin/lib/colors.cjs
Normal 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,
|
||||
};
|
||||
842
.opencode/plugin/lib/context-builder.cjs
Normal file
842
.opencode/plugin/lib/context-builder.cjs
Normal 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
|
||||
};
|
||||
297
.opencode/plugin/lib/privacy-checker.cjs
Normal file
297
.opencode/plugin/lib/privacy-checker.cjs
Normal 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
|
||||
};
|
||||
474
.opencode/plugin/lib/project-detector.cjs
Normal file
474
.opencode/plugin/lib/project-detector.cjs
Normal 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
|
||||
};
|
||||
311
.opencode/plugin/lib/scout-checker.cjs
Normal file
311
.opencode/plugin/lib/scout-checker.cjs
Normal 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
|
||||
};
|
||||
32
.opencode/plugin/privacy-block.ts
Normal file
32
.opencode/plugin/privacy-block.ts
Normal 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;
|
||||
46
.opencode/plugin/scout-block.ts
Normal file
46
.opencode/plugin/scout-block.ts
Normal 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;
|
||||
264
.opencode/plugin/scout-block/broad-pattern-detector.cjs
Executable file
264
.opencode/plugin/scout-block/broad-pattern-detector.cjs
Executable file
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* broad-pattern-detector.cjs - Detect overly broad glob patterns
|
||||
*
|
||||
* Prevents LLMs from filling context by using patterns like "all files"
|
||||
* at project root, which returns ALL files of a type.
|
||||
*
|
||||
* Detection Strategy:
|
||||
* 1. Pattern breadth: recursive glob at start = recursive everywhere
|
||||
* 2. Path depth: Root or shallow paths are high-risk
|
||||
* 3. Combined: Broad pattern + high-level path = BLOCK
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
|
||||
// Patterns that recursively match everywhere when at root
|
||||
// These are dangerous because they return ALL matching files
|
||||
const BROAD_PATTERN_REGEXES = [
|
||||
// ** - all files everywhere (no filter at all)
|
||||
/^\*\*$/,
|
||||
// * - all files in root
|
||||
/^\*$/,
|
||||
// **/* - all files everywhere
|
||||
/^\*\*\/\*$/,
|
||||
// **/. - all dotfiles everywhere
|
||||
/^\*\*\/\.\*$/,
|
||||
// *.ext at root (matches all in root, but combined with deep search)
|
||||
/^\*\.\w+$/,
|
||||
// *.{ext,ext2} at root
|
||||
/^\*\.\{[^}]+\}$/,
|
||||
// **/*.ext - all files of type everywhere (e.g., **/*.ts, **/*.js)
|
||||
/^\*\*\/\*\.\w+$/,
|
||||
// **/*.{ext,ext2} - all files of multiple types everywhere
|
||||
/^\*\*\/\*\.\{[^}]+\}$/,
|
||||
];
|
||||
|
||||
// Common source directories that indicate a more specific search
|
||||
const SPECIFIC_DIRS = [
|
||||
'src', 'lib', 'app', 'apps', 'packages', 'components', 'pages',
|
||||
'api', 'server', 'client', 'web', 'mobile', 'shared', 'common',
|
||||
'utils', 'helpers', 'services', 'hooks', 'store', 'routes',
|
||||
'models', 'controllers', 'views', 'tests', '__tests__', 'spec'
|
||||
];
|
||||
|
||||
// High-risk paths (project/worktree roots)
|
||||
const HIGH_RISK_INDICATORS = [
|
||||
// Worktree paths
|
||||
/\/worktrees\/[^/]+\/?$/,
|
||||
// Project roots (contain package.json, etc.)
|
||||
/^\.?\/?$/,
|
||||
// Shallow paths (just one directory deep)
|
||||
/^[^/]+\/?$/
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a glob pattern is overly broad
|
||||
*
|
||||
* @param {string} pattern - The glob pattern to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isBroadPattern(pattern) {
|
||||
if (!pattern || typeof pattern !== 'string') return false;
|
||||
|
||||
const normalized = pattern.trim();
|
||||
|
||||
// Check against known broad patterns
|
||||
for (const regex of BROAD_PATTERN_REGEXES) {
|
||||
if (regex.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if pattern contains a specific subdirectory.
|
||||
* Scoped patterns like "src/..." are OK because they target specific dirs.
|
||||
*
|
||||
* @param {string} pattern - The glob pattern
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasSpecificDirectory(pattern) {
|
||||
if (!pattern) return false;
|
||||
|
||||
// Check if pattern starts with a specific directory
|
||||
for (const dir of SPECIFIC_DIRS) {
|
||||
if (pattern.startsWith(`${dir}/`) || pattern.startsWith(`./${dir}/`)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for any non-glob directory prefix
|
||||
// e.g., "mydir/..." has a specific directory
|
||||
const firstSegment = pattern.split('/')[0];
|
||||
if (firstSegment && !firstSegment.includes('*') && firstSegment !== '.') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the base path is at a high-level (risky) location
|
||||
*
|
||||
* @param {string} basePath - The path where glob will run
|
||||
* @param {string} cwd - Current working directory
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isHighLevelPath(basePath, cwd) {
|
||||
// No path specified = uses CWD (often project root)
|
||||
if (!basePath) return true;
|
||||
|
||||
const normalized = basePath.replace(/\\/g, '/');
|
||||
|
||||
// Check high-risk indicators
|
||||
for (const regex of HIGH_RISK_INDICATORS) {
|
||||
if (regex.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check path depth - shallow paths are higher risk
|
||||
const segments = normalized.split('/').filter(s => s && s !== '.');
|
||||
if (segments.length <= 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If path doesn't contain a specific directory, it's high-level
|
||||
const hasSpecific = SPECIFIC_DIRS.some(dir =>
|
||||
normalized.includes(`/${dir}/`) || normalized.includes(`/${dir}`) ||
|
||||
normalized.startsWith(`${dir}/`) || normalized === dir
|
||||
);
|
||||
|
||||
return !hasSpecific;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate suggestions for more specific patterns
|
||||
*
|
||||
* @param {string} pattern - The broad pattern
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function suggestSpecificPatterns(pattern) {
|
||||
const suggestions = [];
|
||||
|
||||
// Extract the extension/file part from the pattern
|
||||
let ext = '';
|
||||
const extMatch = pattern.match(/\*\.(\{[^}]+\}|\w+)$/);
|
||||
if (extMatch) {
|
||||
ext = extMatch[1];
|
||||
}
|
||||
|
||||
// Suggest common directories
|
||||
const commonDirs = ['src', 'lib', 'app', 'components'];
|
||||
for (const dir of commonDirs) {
|
||||
if (ext) {
|
||||
suggestions.push(`${dir}/**/*.${ext}`);
|
||||
} else {
|
||||
suggestions.push(`${dir}/**/*`);
|
||||
}
|
||||
}
|
||||
|
||||
// If it's a TypeScript pattern, add specific suggestions
|
||||
if (pattern.includes('.ts') || pattern.includes('{ts')) {
|
||||
suggestions.unshift('src/**/*.ts', 'src/**/*.tsx');
|
||||
}
|
||||
|
||||
// If it's a JavaScript pattern
|
||||
if (pattern.includes('.js') || pattern.includes('{js')) {
|
||||
suggestions.unshift('src/**/*.js', 'lib/**/*.js');
|
||||
}
|
||||
|
||||
return suggestions.slice(0, 4); // Return top 4 suggestions
|
||||
}
|
||||
|
||||
/**
|
||||
* Main detection function - check if a Glob tool call is problematic
|
||||
*
|
||||
* @param {Object} toolInput - The tool_input from hook JSON
|
||||
* @param {string} toolInput.pattern - The glob pattern
|
||||
* @param {string} [toolInput.path] - Optional base path
|
||||
* @returns {Object} { blocked: boolean, reason?: string, suggestions?: string[] }
|
||||
*/
|
||||
function detectBroadPatternIssue(toolInput) {
|
||||
if (!toolInput || typeof toolInput !== 'object') {
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
const { pattern, path: basePath } = toolInput;
|
||||
|
||||
// No pattern = nothing to check
|
||||
if (!pattern) {
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
// Pattern has a specific directory = OK
|
||||
if (hasSpecificDirectory(pattern)) {
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
// Check if pattern is broad
|
||||
if (!isBroadPattern(pattern)) {
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
// Check if path is high-level
|
||||
if (!isHighLevelPath(basePath)) {
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
// Broad pattern at high-level path = BLOCK
|
||||
return {
|
||||
blocked: true,
|
||||
reason: `Pattern '${pattern}' is too broad for ${basePath || 'project root'}`,
|
||||
pattern: pattern,
|
||||
suggestions: suggestSpecificPatterns(pattern)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error message for broad pattern detection
|
||||
*
|
||||
* @param {Object} result - Result from detectBroadPatternIssue
|
||||
* @param {string} claudeDir - Path to .claude directory
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatBroadPatternError(result, claudeDir) {
|
||||
const { reason, pattern, suggestions } = result;
|
||||
|
||||
const lines = [
|
||||
'',
|
||||
'\x1b[36mNOTE:\x1b[0m This is not an error - this block is intentional to optimize context.',
|
||||
'',
|
||||
'\x1b[31mBLOCKED\x1b[0m: Overly broad glob pattern detected',
|
||||
'',
|
||||
` \x1b[33mPattern:\x1b[0m ${pattern}`,
|
||||
` \x1b[33mReason:\x1b[0m Would return ALL matching files, filling context`,
|
||||
'',
|
||||
' \x1b[34mUse more specific patterns:\x1b[0m',
|
||||
];
|
||||
|
||||
for (const suggestion of suggestions || []) {
|
||||
lines.push(` • ${suggestion}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(' \x1b[2mTip: Target specific directories to avoid context overflow\x1b[0m');
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isBroadPattern,
|
||||
hasSpecificDirectory,
|
||||
isHighLevelPath,
|
||||
suggestSpecificPatterns,
|
||||
detectBroadPatternIssue,
|
||||
formatBroadPatternError,
|
||||
BROAD_PATTERN_REGEXES,
|
||||
SPECIFIC_DIRS,
|
||||
HIGH_RISK_INDICATORS
|
||||
};
|
||||
161
.opencode/plugin/scout-block/error-formatter.cjs
Executable file
161
.opencode/plugin/scout-block/error-formatter.cjs
Executable file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* error-formatter.cjs - Rich, actionable error messages for scout-block
|
||||
*
|
||||
* Follows CLI UX best practices: Problem + Reason + Solution
|
||||
* Supports ANSI colors with NO_COLOR env var respect.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
|
||||
// ANSI color codes
|
||||
const COLORS = {
|
||||
red: '\x1b[31m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m',
|
||||
bold: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
reset: '\x1b[0m'
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if terminal supports colors
|
||||
* Respects NO_COLOR standard and FORCE_COLOR
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function supportsColor() {
|
||||
// Respect NO_COLOR standard (https://no-color.org/)
|
||||
if (process.env.NO_COLOR !== undefined) return false;
|
||||
|
||||
// Respect FORCE_COLOR
|
||||
if (process.env.FORCE_COLOR !== undefined) return true;
|
||||
|
||||
// Check if stderr is TTY (we output errors to stderr)
|
||||
return process.stderr.isTTY || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply color to text if supported
|
||||
*
|
||||
* @param {string} text - Text to colorize
|
||||
* @param {string} color - Color name from COLORS
|
||||
* @returns {string}
|
||||
*/
|
||||
function colorize(text, color) {
|
||||
if (!supportsColor()) return text;
|
||||
const colorCode = COLORS[color] || '';
|
||||
return `${colorCode}${text}${COLORS.reset}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get .ckignore config path
|
||||
*
|
||||
* @param {string} claudeDir - Path to .claude directory
|
||||
* @param {string} [configPath] - Explicit config path to prefer
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatConfigPath(claudeDir, configPath) {
|
||||
if (configPath) {
|
||||
return configPath;
|
||||
}
|
||||
if (claudeDir) {
|
||||
return path.join(claudeDir, '.ckignore');
|
||||
}
|
||||
return '.claude/.ckignore';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a blocked path error with actionable guidance
|
||||
*
|
||||
* Pattern: What went wrong → Why → How to fix → Where to configure
|
||||
*
|
||||
* @param {Object} details - Error details
|
||||
* @param {string} details.path - The blocked path
|
||||
* @param {string} details.pattern - The pattern that matched
|
||||
* @param {string} details.tool - The tool that was blocked
|
||||
* @param {string} details.claudeDir - Path to .claude directory
|
||||
* @param {string} [details.configPath] - Explicit config path to edit
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatBlockedError(details) {
|
||||
const { path: blockedPath, pattern, tool, claudeDir, configPath } = details;
|
||||
const resolvedConfigPath = formatConfigPath(claudeDir, configPath);
|
||||
|
||||
// Truncate path if too long
|
||||
const displayPath = blockedPath.length > 60
|
||||
? '...' + blockedPath.slice(-57)
|
||||
: blockedPath;
|
||||
|
||||
const lines = [
|
||||
'',
|
||||
colorize('NOTE:', 'cyan') + ' This is not an error - this block is intentional to optimize context.',
|
||||
'',
|
||||
colorize('BLOCKED', 'red') + `: Access to '${displayPath}' denied`,
|
||||
'',
|
||||
` ${colorize('Pattern:', 'yellow')} ${pattern}`,
|
||||
` ${colorize('Tool:', 'yellow')} ${tool || 'unknown'}`,
|
||||
'',
|
||||
` ${colorize('To allow, add to', 'blue')} ${resolvedConfigPath}:`,
|
||||
` !${pattern}`,
|
||||
'',
|
||||
` ${colorize('Config:', 'dim')} ${resolvedConfigPath}`,
|
||||
''
|
||||
];
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a simple error message (one line, for piped output)
|
||||
*
|
||||
* @param {string} pattern - The pattern that matched
|
||||
* @param {string} blockedPath - The path that was blocked
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatSimpleError(pattern, blockedPath) {
|
||||
return `ERROR: Blocked pattern '${pattern}' matched path: ${blockedPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error for machine-readable output (exit code 2)
|
||||
* Used when stderr is not a TTY
|
||||
*
|
||||
* @param {Object} details - Error details
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatMachineError(details) {
|
||||
const { path: blockedPath, pattern, tool, claudeDir, configPath } = details;
|
||||
const resolvedConfigPath = formatConfigPath(claudeDir, configPath);
|
||||
|
||||
return JSON.stringify({
|
||||
error: 'BLOCKED',
|
||||
path: blockedPath,
|
||||
pattern: pattern,
|
||||
tool: tool,
|
||||
config: resolvedConfigPath,
|
||||
fix: `Add '!${pattern}' to ${resolvedConfigPath} to allow this path`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a warning message (non-blocking)
|
||||
*
|
||||
* @param {string} message - Warning message
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatWarning(message) {
|
||||
return colorize('WARN:', 'yellow') + ' ' + message;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatBlockedError,
|
||||
formatSimpleError,
|
||||
formatMachineError,
|
||||
formatWarning,
|
||||
formatConfigPath,
|
||||
supportsColor,
|
||||
colorize,
|
||||
COLORS
|
||||
};
|
||||
327
.opencode/plugin/scout-block/path-extractor.cjs
Executable file
327
.opencode/plugin/scout-block/path-extractor.cjs
Executable file
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* path-extractor.cjs - Extract paths from Claude Code tool inputs
|
||||
*
|
||||
* Extracts file_path, path, pattern params and parses Bash commands
|
||||
* to find all path-like arguments.
|
||||
*/
|
||||
|
||||
// Flags that indicate the following value should NOT be checked as a path
|
||||
// These are "exclude" semantics - the user is explicitly skipping these paths
|
||||
const EXCLUDE_FLAGS = [
|
||||
'--exclude', '--ignore', '--skip', '--prune',
|
||||
'-x', // tar exclude shorthand
|
||||
'-path', // find -path (used with -prune)
|
||||
'--exclude-dir' // grep --exclude-dir
|
||||
];
|
||||
|
||||
// Filesystem commands where bare directory names (build, dist, etc.)
|
||||
// should be extracted as paths. For non-fs commands (grep, echo, sed),
|
||||
// only tokens that look like actual paths (contain / or extension) are extracted.
|
||||
const FILESYSTEM_COMMANDS = [
|
||||
'cd', 'ls', 'cat', 'head', 'tail', 'less', 'more',
|
||||
'rm', 'cp', 'mv', 'find', 'touch', 'mkdir', 'rmdir',
|
||||
'stat', 'file', 'du', 'tree', 'chmod', 'chown', 'ln',
|
||||
'readlink', 'realpath', 'wc', 'tee', 'tar', 'zip', 'unzip',
|
||||
'open', 'code', 'vim', 'nano', 'bat', 'rsync', 'scp', 'diff'
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract all paths from a tool_input object
|
||||
* Handles: file_path, path, pattern params and command strings
|
||||
*
|
||||
* @param {Object} toolInput - The tool_input from hook JSON
|
||||
* @returns {string[]} Array of extracted paths
|
||||
*/
|
||||
function extractFromToolInput(toolInput) {
|
||||
const paths = [];
|
||||
|
||||
if (!toolInput || typeof toolInput !== 'object') {
|
||||
return paths;
|
||||
}
|
||||
|
||||
// Direct path params (Read, Edit, Write, Grep, Glob tools)
|
||||
const directParams = ['file_path', 'path', 'pattern'];
|
||||
for (const param of directParams) {
|
||||
if (toolInput[param] && typeof toolInput[param] === 'string') {
|
||||
const normalized = normalizeExtractedPath(toolInput[param]);
|
||||
if (normalized) paths.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract from Bash command if present
|
||||
if (toolInput.command && typeof toolInput.command === 'string') {
|
||||
const cmdPaths = extractFromCommand(toolInput.command);
|
||||
paths.push(...cmdPaths);
|
||||
}
|
||||
|
||||
return paths.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path-like segments from a Bash command string.
|
||||
*
|
||||
* Uses pipe-segment-aware command context: for filesystem commands (cd, cat, ls, rm, etc.)
|
||||
* bare blocked directory names are extracted with priority. For non-filesystem commands
|
||||
* (grep, echo, sed, etc.) only tokens that structurally look like paths are extracted,
|
||||
* preventing false positives on search terms and string arguments.
|
||||
*
|
||||
* @param {string} command - The command string
|
||||
* @returns {string[]} Array of extracted paths
|
||||
*/
|
||||
function extractFromCommand(command) {
|
||||
if (!command || typeof command !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const paths = [];
|
||||
|
||||
// First, extract quoted strings (preserve spaces in paths)
|
||||
const quotedPattern = /["']([^"']+)["']/g;
|
||||
let match;
|
||||
while ((match = quotedPattern.exec(command)) !== null) {
|
||||
const content = match[1];
|
||||
|
||||
// Skip sed/awk regex expressions (s/pattern/replacement/flags)
|
||||
if (/^s[\/|@#,]/.test(content)) continue;
|
||||
|
||||
if (looksLikePath(content)) {
|
||||
paths.push(normalizeExtractedPath(content));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove quoted strings for unquoted path extraction
|
||||
const withoutQuotes = command.replace(/["'][^"']*["']/g, ' ');
|
||||
|
||||
// Split on whitespace and extract path-like tokens
|
||||
const tokens = withoutQuotes.split(/\s+/).filter(Boolean);
|
||||
|
||||
// Track command context per pipe segment
|
||||
let commandName = null;
|
||||
let isFsCommand = false;
|
||||
let skipNextToken = false;
|
||||
let heredocDelimiter = null;
|
||||
let nextIsHeredocDelimiter = false;
|
||||
|
||||
for (const token of tokens) {
|
||||
// Heredoc delimiter capture (after << or <<-)
|
||||
if (nextIsHeredocDelimiter) {
|
||||
heredocDelimiter = token.replace(/^['"]/, '').replace(/['"]$/, '');
|
||||
nextIsHeredocDelimiter = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip heredoc body content until closing delimiter
|
||||
if (heredocDelimiter) {
|
||||
if (token === heredocDelimiter) {
|
||||
heredocDelimiter = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect heredoc start: <<EOF, <<'EOF', <<"EOF", <<-EOF
|
||||
if (token.startsWith('<<') && token.length > 2) {
|
||||
heredocDelimiter = token.replace(/^<<-?['"]?/, '').replace(/['"]?$/, '');
|
||||
continue;
|
||||
}
|
||||
if (token === '<<' || token === '<<-') {
|
||||
nextIsHeredocDelimiter = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip value after exclude flags (--exclude node_modules format)
|
||||
if (skipNextToken) {
|
||||
skipNextToken = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reset command context at command/pipe boundaries
|
||||
if (token === '&&' || token === ';' || token.startsWith('|')) {
|
||||
commandName = null;
|
||||
isFsCommand = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip flags and shell operators
|
||||
if (isSkippableToken(token)) {
|
||||
if (EXCLUDE_FLAGS.includes(token)) {
|
||||
skipNextToken = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine the command for this pipe segment (first non-flag token)
|
||||
if (commandName === null) {
|
||||
commandName = token.toLowerCase();
|
||||
isFsCommand = FILESYSTEM_COMMANDS.includes(commandName);
|
||||
// Skip the command word itself
|
||||
if (isCommandKeyword(token) || isFsCommand) continue;
|
||||
// Non-keyword command (e.g., ./script.sh) — fall through to path check
|
||||
}
|
||||
|
||||
// For filesystem commands, extract blocked dir names with priority.
|
||||
// "cd build", "ls dist", "cat node_modules/..." — "build"/"dist" are paths here.
|
||||
if (isFsCommand && isBlockedDirName(token)) {
|
||||
paths.push(normalizeExtractedPath(token));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip common non-path command words
|
||||
if (isCommandKeyword(token)) continue;
|
||||
|
||||
// Check if it looks like a path
|
||||
if (looksLikePath(token)) {
|
||||
paths.push(normalizeExtractedPath(token));
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
// Common blocked directory names that should be extracted even if they
|
||||
// match command keywords (e.g., "build" is both a subcommand and a dir name)
|
||||
// Keep in sync with DEFAULT_PATTERNS in pattern-matcher.cjs
|
||||
const BLOCKED_DIR_NAMES = [
|
||||
'node_modules', '__pycache__', '.git', 'dist', 'build',
|
||||
'.next', '.nuxt', '.venv', 'venv', 'vendor', 'target', 'coverage'
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if token is exactly a blocked directory name
|
||||
* This takes priority over command keyword filtering
|
||||
*
|
||||
* @param {string} token - Token to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isBlockedDirName(token) {
|
||||
return BLOCKED_DIR_NAMES.includes(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string looks like a file path
|
||||
*
|
||||
* @param {string} str - String to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function looksLikePath(str) {
|
||||
if (!str || str.length < 2) return false;
|
||||
|
||||
// Contains path separator
|
||||
if (str.includes('/') || str.includes('\\')) return true;
|
||||
|
||||
// Starts with relative path indicator
|
||||
if (str.startsWith('./') || str.startsWith('../')) return true;
|
||||
|
||||
// Has file extension (likely a file)
|
||||
if (/\.\w{1,6}$/.test(str)) return true;
|
||||
|
||||
// Looks like a directory path
|
||||
if (/^[a-zA-Z0-9_-]+\//.test(str)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token should be skipped (flags, operators)
|
||||
*
|
||||
* @param {string} token - Token to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSkippableToken(token) {
|
||||
// Flags
|
||||
if (token.startsWith('-')) return true;
|
||||
|
||||
// Shell operators
|
||||
if (['|', '||', '&&', '>', '>>', '<', '<<', '&', ';'].includes(token)) return true;
|
||||
if (token.startsWith('|') || token.startsWith('>') || token.startsWith('<')) return true;
|
||||
if (token.startsWith('&')) return true;
|
||||
|
||||
// Numeric values
|
||||
if (/^\d+$/.test(token)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is a common command keyword (not a path)
|
||||
*
|
||||
* @param {string} token - Token to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isCommandKeyword(token) {
|
||||
const keywords = [
|
||||
// Shell commands
|
||||
'echo', 'cat', 'ls', 'cd', 'rm', 'cp', 'mv', 'find', 'grep', 'head', 'tail',
|
||||
'wc', 'du', 'tree', 'touch', 'mkdir', 'rmdir', 'pwd', 'which', 'env', 'export',
|
||||
'source', 'bash', 'sh', 'zsh', 'true', 'false', 'test', 'xargs', 'tee', 'sort',
|
||||
'uniq', 'cut', 'tr', 'sed', 'awk', 'diff', 'chmod', 'chown', 'ln', 'file',
|
||||
|
||||
// Package managers and their subcommands
|
||||
'npm', 'pnpm', 'yarn', 'bun', 'npx', 'pnpx', 'bunx', 'node',
|
||||
'run', 'build', 'test', 'lint', 'dev', 'start', 'install', 'ci', 'exec',
|
||||
'add', 'remove', 'update', 'publish', 'pack', 'init', 'create',
|
||||
|
||||
// Build tools
|
||||
'tsc', 'esbuild', 'vite', 'webpack', 'rollup', 'turbo', 'nx',
|
||||
'jest', 'vitest', 'mocha', 'eslint', 'prettier',
|
||||
|
||||
// Git
|
||||
'git', 'commit', 'push', 'pull', 'merge', 'rebase', 'checkout', 'branch',
|
||||
'status', 'log', 'diff', 'add', 'reset', 'stash', 'fetch', 'clone',
|
||||
|
||||
// Docker
|
||||
'docker', 'compose', 'up', 'down', 'ps', 'logs', 'exec', 'container', 'image',
|
||||
|
||||
// Misc
|
||||
'sudo', 'time', 'timeout', 'watch', 'make', 'cargo', 'python', 'python3', 'pip',
|
||||
'ruby', 'gem', 'go', 'rust', 'java', 'javac', 'mvn', 'gradle'
|
||||
];
|
||||
|
||||
return keywords.includes(token.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an extracted path
|
||||
* - Remove surrounding quotes
|
||||
* - Normalize path separators to forward slash
|
||||
*
|
||||
* @param {string} path - Path to normalize
|
||||
* @returns {string} Normalized path
|
||||
*/
|
||||
function normalizeExtractedPath(path) {
|
||||
if (!path) return '';
|
||||
|
||||
let normalized = path.trim();
|
||||
|
||||
// Remove surrounding quotes
|
||||
if ((normalized.startsWith('"') && normalized.endsWith('"')) ||
|
||||
(normalized.startsWith("'") && normalized.endsWith("'"))) {
|
||||
normalized = normalized.slice(1, -1);
|
||||
}
|
||||
|
||||
// Strip shell metacharacters from edges (backticks, parens, braces)
|
||||
normalized = normalized.replace(/^[`({\[]+/, '').replace(/[`)};\]]+$/, '');
|
||||
|
||||
// Normalize path separators to forward slash
|
||||
normalized = normalized.replace(/\\/g, '/');
|
||||
|
||||
// Remove trailing slash for consistency
|
||||
if (normalized.endsWith('/') && normalized.length > 1) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractFromToolInput,
|
||||
extractFromCommand,
|
||||
looksLikePath,
|
||||
isSkippableToken,
|
||||
isCommandKeyword,
|
||||
isBlockedDirName,
|
||||
normalizeExtractedPath,
|
||||
BLOCKED_DIR_NAMES,
|
||||
EXCLUDE_FLAGS,
|
||||
FILESYSTEM_COMMANDS
|
||||
};
|
||||
204
.opencode/plugin/scout-block/pattern-matcher.cjs
Executable file
204
.opencode/plugin/scout-block/pattern-matcher.cjs
Executable file
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* pattern-matcher.cjs - Gitignore-spec compliant pattern matching
|
||||
*
|
||||
* Uses 'ignore' package for .ckignore parsing and path matching.
|
||||
* Supports negation patterns (!) for allowlisting.
|
||||
*/
|
||||
|
||||
const Ignore = require('./vendor/ignore.cjs');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Default patterns if .ckignore doesn't exist or is empty
|
||||
// Only includes directories with HEAVY file counts (1000+ files typical)
|
||||
const DEFAULT_PATTERNS = [
|
||||
// JavaScript/TypeScript - package dependencies & build outputs
|
||||
'node_modules',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
// Python - virtualenvs & cache
|
||||
'__pycache__',
|
||||
'.venv',
|
||||
'venv',
|
||||
// Go/PHP - vendor dependencies
|
||||
'vendor',
|
||||
// Rust/Java - compiled outputs
|
||||
'target',
|
||||
// Version control
|
||||
'.git',
|
||||
// Test coverage (can be large with reports)
|
||||
'coverage',
|
||||
];
|
||||
|
||||
function readPatternsFromFile(filePath) {
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return fs.readFileSync(filePath, 'utf-8')
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#'));
|
||||
} catch (error) {
|
||||
console.error('WARN: Failed to read .ckignore:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load patterns from the shipped .ckignore plus an optional project override.
|
||||
* Falls back to DEFAULT_PATTERNS if the shipped file doesn't exist or is empty.
|
||||
*
|
||||
* @param {string} ckignorePath - Path to shipped/global .ckignore file
|
||||
* @param {string} [projectCkignorePath] - Optional project-local .ckignore path
|
||||
* @returns {string[]} Array of patterns
|
||||
*/
|
||||
function loadPatterns(ckignorePath, projectCkignorePath) {
|
||||
const shippedPatterns = readPatternsFromFile(ckignorePath);
|
||||
const projectPatterns = readPatternsFromFile(projectCkignorePath);
|
||||
const basePatterns = shippedPatterns.length > 0 ? shippedPatterns : DEFAULT_PATTERNS;
|
||||
return [...basePatterns, ...projectPatterns];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a matcher from patterns
|
||||
* Normalizes patterns to match anywhere in the path tree
|
||||
*
|
||||
* @param {string[]} patterns - Array of patterns from .ckignore
|
||||
* @returns {Object} Matcher object with ig instance and pattern info
|
||||
*/
|
||||
function createMatcher(patterns) {
|
||||
const ig = Ignore();
|
||||
|
||||
// Normalize patterns to match anywhere in path tree
|
||||
// e.g., "node_modules" becomes "**\/node_modules" and "**\/node_modules/**"
|
||||
const normalizedPatterns = [];
|
||||
|
||||
for (const p of patterns) {
|
||||
if (p.startsWith('!')) {
|
||||
// Negation pattern - un-ignore
|
||||
const inner = p.slice(1);
|
||||
if (inner.includes('/') || inner.includes('*')) {
|
||||
// Already has path or glob - use as-is
|
||||
normalizedPatterns.push(p);
|
||||
} else {
|
||||
// Simple dir name - match anywhere
|
||||
normalizedPatterns.push(`!**/${inner}`);
|
||||
normalizedPatterns.push(`!**/${inner}/**`);
|
||||
}
|
||||
} else {
|
||||
// Block pattern
|
||||
if (p.includes('/') || p.includes('*')) {
|
||||
// Already has path or glob - use as-is
|
||||
normalizedPatterns.push(p);
|
||||
} else {
|
||||
// Simple dir name - match the dir and contents anywhere
|
||||
normalizedPatterns.push(`**/${p}`);
|
||||
normalizedPatterns.push(`**/${p}/**`);
|
||||
// Also match at root
|
||||
normalizedPatterns.push(p);
|
||||
normalizedPatterns.push(`${p}/**`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ig.add(normalizedPatterns);
|
||||
|
||||
return {
|
||||
ig,
|
||||
patterns: normalizedPatterns,
|
||||
original: patterns
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path should be blocked
|
||||
*
|
||||
* @param {Object} matcher - Matcher object from createMatcher
|
||||
* @param {string} testPath - Path to test
|
||||
* @returns {Object} { blocked: boolean, pattern?: string }
|
||||
*/
|
||||
function matchPath(matcher, testPath) {
|
||||
if (!testPath || typeof testPath !== 'string') {
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
// Normalize path separators (Windows backslash to forward slash)
|
||||
let normalized = testPath.replace(/\\/g, '/');
|
||||
|
||||
// Remove leading ./ if present
|
||||
if (normalized.startsWith('./')) {
|
||||
normalized = normalized.slice(2);
|
||||
}
|
||||
|
||||
// Strip leading / for absolute paths (ignore lib requires relative paths)
|
||||
while (normalized.startsWith('/')) {
|
||||
normalized = normalized.slice(1);
|
||||
}
|
||||
|
||||
// Strip leading ../ segments (resolve parent references)
|
||||
while (normalized.startsWith('../')) {
|
||||
normalized = normalized.slice(3);
|
||||
}
|
||||
|
||||
// Empty after normalization = not a blockable path
|
||||
if (!normalized) {
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
// Check if path is ignored (blocked)
|
||||
const blocked = matcher.ig.ignores(normalized);
|
||||
|
||||
if (blocked) {
|
||||
// Find which original pattern matched for error message
|
||||
const matchedPattern = findMatchingPattern(matcher.original, normalized);
|
||||
return { blocked: true, pattern: matchedPattern };
|
||||
}
|
||||
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find which original pattern matched (for error messages)
|
||||
*
|
||||
* @param {string[]} originalPatterns - Original patterns from .ckignore
|
||||
* @param {string} path - The path that was blocked
|
||||
* @returns {string} The pattern that matched
|
||||
*/
|
||||
function findMatchingPattern(originalPatterns, path) {
|
||||
for (const p of originalPatterns) {
|
||||
if (p.startsWith('!')) continue; // Skip negations
|
||||
|
||||
// Simple substring check for common cases
|
||||
const pattern = p.replace(/\*\*/g, '').replace(/\*/g, '');
|
||||
if (pattern && path.includes(pattern)) {
|
||||
return p;
|
||||
}
|
||||
|
||||
// For more complex patterns, use ignore to test individually
|
||||
const tempIg = Ignore();
|
||||
if (p.includes('/') || p.includes('*')) {
|
||||
tempIg.add(p);
|
||||
} else {
|
||||
tempIg.add([`**/${p}`, `**/${p}/**`, p, `${p}/**`]);
|
||||
}
|
||||
|
||||
if (tempIg.ignores(path)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
return originalPatterns.find(p => !p.startsWith('!')) || 'unknown';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadPatterns,
|
||||
createMatcher,
|
||||
matchPath,
|
||||
findMatchingPattern,
|
||||
DEFAULT_PATTERNS
|
||||
};
|
||||
165
.opencode/plugin/scout-block/tests/test-broad-pattern-detector.cjs
Executable file
165
.opencode/plugin/scout-block/tests/test-broad-pattern-detector.cjs
Executable file
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* test-broad-pattern-detector.cjs - Unit tests for broad pattern detection
|
||||
*
|
||||
* Tests the detection of overly broad glob patterns that would fill context.
|
||||
*/
|
||||
|
||||
const {
|
||||
isBroadPattern,
|
||||
hasSpecificDirectory,
|
||||
isHighLevelPath,
|
||||
detectBroadPatternIssue,
|
||||
suggestSpecificPatterns
|
||||
} = require('../broad-pattern-detector.cjs');
|
||||
|
||||
// === isBroadPattern tests ===
|
||||
const broadPatternTests = [
|
||||
// Should be detected as broad - TypeScript/JavaScript
|
||||
{ pattern: '**/*', expected: true, desc: 'all files everywhere' },
|
||||
{ pattern: '**', expected: true, desc: 'double star alone' },
|
||||
{ pattern: '*', expected: true, desc: 'single star alone' },
|
||||
{ pattern: '**/.*', expected: true, desc: 'all dotfiles' },
|
||||
|
||||
// Should NOT be detected as broad (specific)
|
||||
{ pattern: 'package.json', expected: false, desc: 'specific file' },
|
||||
{ pattern: 'src/index.ts', expected: false, desc: 'specific file path' },
|
||||
{ pattern: null, expected: false, desc: 'null pattern' },
|
||||
{ pattern: '', expected: false, desc: 'empty pattern' },
|
||||
];
|
||||
|
||||
// === isHighLevelPath tests ===
|
||||
const highLevelPathTests = [
|
||||
// High level (risky)
|
||||
{ path: null, expected: true, desc: 'null path (uses CWD)' },
|
||||
{ path: undefined, expected: true, desc: 'undefined path' },
|
||||
{ path: '.', expected: true, desc: 'current directory' },
|
||||
{ path: './', expected: true, desc: 'current directory with slash' },
|
||||
{ path: '', expected: true, desc: 'empty path' },
|
||||
{ path: '/home/user/worktrees/myproject', expected: true, desc: 'worktree root' },
|
||||
{ path: 'myproject', expected: true, desc: 'single directory' },
|
||||
|
||||
// Specific (OK)
|
||||
{ path: 'src/components', expected: false, desc: 'nested in src' },
|
||||
{ path: 'lib/utils', expected: false, desc: 'nested in lib' },
|
||||
{ path: 'packages/web/src', expected: false, desc: 'monorepo src' },
|
||||
{ path: '/home/user/project/src', expected: false, desc: 'absolute with src' },
|
||||
];
|
||||
|
||||
// === detectBroadPatternIssue integration tests ===
|
||||
const integrationTests = [
|
||||
// Should BLOCK
|
||||
{
|
||||
input: { pattern: '**/*.ts' },
|
||||
expected: true,
|
||||
desc: 'broad pattern, no path'
|
||||
},
|
||||
{
|
||||
input: { pattern: '**/*.{ts,tsx}', path: '/home/user/worktrees/myproject' },
|
||||
expected: true,
|
||||
desc: 'broad pattern at worktree'
|
||||
},
|
||||
{
|
||||
input: { pattern: '**/*', path: '.' },
|
||||
expected: true,
|
||||
desc: 'all files at current dir'
|
||||
},
|
||||
{
|
||||
input: { pattern: '**/index.ts', path: 'myproject' },
|
||||
expected: true,
|
||||
desc: 'all index.ts at shallow path'
|
||||
},
|
||||
|
||||
// Should ALLOW
|
||||
{
|
||||
input: { pattern: 'src/**/*.ts' },
|
||||
expected: false,
|
||||
desc: 'scoped to src'
|
||||
},
|
||||
{
|
||||
input: { pattern: '**/*.ts', path: 'src/components' },
|
||||
expected: false,
|
||||
desc: 'broad pattern but specific path'
|
||||
},
|
||||
{
|
||||
input: { pattern: 'package.json' },
|
||||
expected: false,
|
||||
desc: 'specific file'
|
||||
},
|
||||
{
|
||||
input: { pattern: 'lib/**/*.js', path: '/home/user/project' },
|
||||
expected: false,
|
||||
desc: 'scoped pattern'
|
||||
},
|
||||
{
|
||||
input: {},
|
||||
expected: false,
|
||||
desc: 'no pattern'
|
||||
},
|
||||
{
|
||||
input: null,
|
||||
expected: false,
|
||||
desc: 'null input'
|
||||
},
|
||||
];
|
||||
|
||||
// Run tests
|
||||
console.log('Testing broad-pattern-detector module...\n');
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Test isBroadPattern
|
||||
console.log('\x1b[1m--- isBroadPattern ---\x1b[0m');
|
||||
for (const test of broadPatternTests) {
|
||||
const result = isBroadPattern(test.pattern);
|
||||
const success = result === test.expected;
|
||||
if (success) {
|
||||
console.log(`\x1b[32m✓\x1b[0m ${test.desc}: "${test.pattern}" -> ${result ? 'BROAD' : 'OK'}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`\x1b[31m✗\x1b[0m ${test.desc}: expected ${test.expected ? 'BROAD' : 'OK'}, got ${result ? 'BROAD' : 'OK'}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Test isHighLevelPath
|
||||
console.log('\n\x1b[1m--- isHighLevelPath ---\x1b[0m');
|
||||
for (const test of highLevelPathTests) {
|
||||
const result = isHighLevelPath(test.path);
|
||||
const success = result === test.expected;
|
||||
if (success) {
|
||||
console.log(`\x1b[32m✓\x1b[0m ${test.desc}: "${test.path}" -> ${result ? 'HIGH_LEVEL' : 'SPECIFIC'}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`\x1b[31m✗\x1b[0m ${test.desc}: expected ${test.expected ? 'HIGH_LEVEL' : 'SPECIFIC'}, got ${result ? 'HIGH_LEVEL' : 'SPECIFIC'}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Test integration
|
||||
console.log('\n\x1b[1m--- detectBroadPatternIssue (integration) ---\x1b[0m');
|
||||
for (const test of integrationTests) {
|
||||
const result = detectBroadPatternIssue(test.input);
|
||||
const success = result.blocked === test.expected;
|
||||
if (success) {
|
||||
console.log(`\x1b[32m✓\x1b[0m ${test.desc} -> ${result.blocked ? 'BLOCKED' : 'ALLOWED'}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`\x1b[31m✗\x1b[0m ${test.desc}: expected ${test.expected ? 'BLOCKED' : 'ALLOWED'}, got ${result.blocked ? 'BLOCKED' : 'ALLOWED'}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Test suggestions
|
||||
console.log('\n\x1b[1m--- suggestSpecificPatterns ---\x1b[0m');
|
||||
const suggestions = suggestSpecificPatterns('**/*.ts');
|
||||
if (suggestions.length > 0 && suggestions.some(s => s.includes('src/'))) {
|
||||
console.log(`\x1b[32m✓\x1b[0m suggestions for **/*.ts include src-scoped patterns`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`\x1b[31m✗\x1b[0m suggestions should include src-scoped patterns`);
|
||||
failed++;
|
||||
}
|
||||
|
||||
console.log(`\n\x1b[1mResults:\x1b[0m ${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
137
.opencode/plugin/scout-block/tests/test-build-command-allowlist.cjs
Executable file
137
.opencode/plugin/scout-block/tests/test-build-command-allowlist.cjs
Executable file
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* test-build-command-allowlist.cjs - Tests for build command allowlist patterns
|
||||
*
|
||||
* Tests that build commands from various languages/tools are properly recognized
|
||||
* and allowed (bypassing path blocking).
|
||||
*/
|
||||
|
||||
// Replicate the patterns from scout-block.cjs
|
||||
const BUILD_COMMAND_PATTERN = /^(npm|pnpm|yarn|bun)\s+([^\s]+\s+)*(run\s+)?(build|test|lint|dev|start|install|ci|add|remove|update|publish|pack|init|create|exec)/;
|
||||
const TOOL_COMMAND_PATTERN = /^(\.\/)?(npx|pnpx|bunx|tsc|esbuild|vite|webpack|rollup|turbo|nx|jest|vitest|mocha|eslint|prettier|go|cargo|make|mvn|mvnw|gradle|gradlew|dotnet|docker|podman|kubectl|helm|terraform|ansible|bazel|cmake|sbt|flutter|swift|ant|ninja|meson)/;
|
||||
|
||||
function isBuildCommand(command) {
|
||||
if (!command || typeof command !== 'string') return false;
|
||||
const trimmed = command.trim();
|
||||
return BUILD_COMMAND_PATTERN.test(trimmed) || TOOL_COMMAND_PATTERN.test(trimmed);
|
||||
}
|
||||
|
||||
const tests = [
|
||||
// JS/Node package managers - should be allowed
|
||||
{ cmd: 'npm run build', expected: true, desc: 'npm run build' },
|
||||
{ cmd: 'npm build', expected: true, desc: 'npm build' },
|
||||
{ cmd: 'pnpm build', expected: true, desc: 'pnpm build' },
|
||||
{ cmd: 'yarn build', expected: true, desc: 'yarn build' },
|
||||
{ cmd: 'bun build', expected: true, desc: 'bun build' },
|
||||
{ cmd: 'npm install', expected: true, desc: 'npm install' },
|
||||
{ cmd: 'pnpm --filter web run build', expected: true, desc: 'pnpm with filter' },
|
||||
{ cmd: 'yarn workspace app build', expected: true, desc: 'yarn workspace build' },
|
||||
|
||||
// JS tools - should be allowed
|
||||
{ cmd: 'npx tsc', expected: true, desc: 'npx tsc' },
|
||||
{ cmd: 'tsc --build', expected: true, desc: 'tsc --build' },
|
||||
{ cmd: 'esbuild src/index.ts', expected: true, desc: 'esbuild' },
|
||||
{ cmd: 'vite build', expected: true, desc: 'vite build' },
|
||||
{ cmd: 'webpack', expected: true, desc: 'webpack' },
|
||||
{ cmd: 'turbo run build', expected: true, desc: 'turbo run build' },
|
||||
{ cmd: 'nx build app', expected: true, desc: 'nx build' },
|
||||
|
||||
// Go - should be allowed (THE BUG FIX)
|
||||
{ cmd: 'go build ./...', expected: true, desc: 'go build ./...' },
|
||||
{ cmd: 'go build -o app main.go', expected: true, desc: 'go build with flags' },
|
||||
{ cmd: 'go test ./...', expected: true, desc: 'go test' },
|
||||
{ cmd: 'go run main.go', expected: true, desc: 'go run' },
|
||||
{ cmd: 'go mod tidy', expected: true, desc: 'go mod tidy' },
|
||||
{ cmd: 'go install', expected: true, desc: 'go install' },
|
||||
|
||||
// Rust/Cargo - should be allowed
|
||||
{ cmd: 'cargo build', expected: true, desc: 'cargo build' },
|
||||
{ cmd: 'cargo build --release', expected: true, desc: 'cargo build --release' },
|
||||
{ cmd: 'cargo test', expected: true, desc: 'cargo test' },
|
||||
{ cmd: 'cargo run', expected: true, desc: 'cargo run' },
|
||||
|
||||
// Make - should be allowed
|
||||
{ cmd: 'make', expected: true, desc: 'make' },
|
||||
{ cmd: 'make build', expected: true, desc: 'make build' },
|
||||
{ cmd: 'make clean', expected: true, desc: 'make clean' },
|
||||
{ cmd: 'make -j4', expected: true, desc: 'make -j4' },
|
||||
|
||||
// Java/Maven/Gradle - should be allowed
|
||||
{ cmd: 'mvn clean install', expected: true, desc: 'mvn clean install' },
|
||||
{ cmd: 'mvn package', expected: true, desc: 'mvn package' },
|
||||
{ cmd: 'gradle build', expected: true, desc: 'gradle build' },
|
||||
{ cmd: 'gradle test', expected: true, desc: 'gradle test' },
|
||||
|
||||
// Maven/Gradle wrappers - should be allowed (NEW)
|
||||
{ cmd: './gradlew build', expected: true, desc: './gradlew build' },
|
||||
{ cmd: './gradlew clean test', expected: true, desc: './gradlew clean test' },
|
||||
{ cmd: 'gradlew build', expected: true, desc: 'gradlew build (no ./)' },
|
||||
{ cmd: './mvnw clean install', expected: true, desc: './mvnw clean install' },
|
||||
{ cmd: './mvnw package', expected: true, desc: './mvnw package' },
|
||||
{ cmd: 'mvnw clean install', expected: true, desc: 'mvnw clean install (no ./)' },
|
||||
|
||||
// .NET - should be allowed
|
||||
{ cmd: 'dotnet build', expected: true, desc: 'dotnet build' },
|
||||
{ cmd: 'dotnet run', expected: true, desc: 'dotnet run' },
|
||||
{ cmd: 'dotnet test', expected: true, desc: 'dotnet test' },
|
||||
|
||||
// Docker/Container tools - should be allowed
|
||||
{ cmd: 'docker build .', expected: true, desc: 'docker build' },
|
||||
{ cmd: 'docker build -t myapp .', expected: true, desc: 'docker build with tag' },
|
||||
{ cmd: 'docker compose up', expected: true, desc: 'docker compose' },
|
||||
{ cmd: 'podman build .', expected: true, desc: 'podman build' },
|
||||
|
||||
// Kubernetes/Infrastructure - should be allowed
|
||||
{ cmd: 'kubectl apply -f deploy/', expected: true, desc: 'kubectl apply' },
|
||||
{ cmd: 'kubectl get pods', expected: true, desc: 'kubectl get' },
|
||||
{ cmd: 'helm install myapp ./chart', expected: true, desc: 'helm install' },
|
||||
{ cmd: 'terraform apply', expected: true, desc: 'terraform apply' },
|
||||
{ cmd: 'terraform plan', expected: true, desc: 'terraform plan' },
|
||||
{ cmd: 'ansible-playbook site.yml', expected: true, desc: 'ansible playbook' },
|
||||
|
||||
// Additional build systems - should be allowed (NEW)
|
||||
{ cmd: 'bazel build //...', expected: true, desc: 'bazel build' },
|
||||
{ cmd: 'bazel test //...', expected: true, desc: 'bazel test' },
|
||||
{ cmd: 'cmake --build .', expected: true, desc: 'cmake build' },
|
||||
{ cmd: 'cmake -B build', expected: true, desc: 'cmake configure' },
|
||||
{ cmd: 'sbt compile', expected: true, desc: 'sbt compile' },
|
||||
{ cmd: 'sbt test', expected: true, desc: 'sbt test' },
|
||||
{ cmd: 'flutter build apk', expected: true, desc: 'flutter build apk' },
|
||||
{ cmd: 'flutter run', expected: true, desc: 'flutter run' },
|
||||
{ cmd: 'swift build', expected: true, desc: 'swift build' },
|
||||
{ cmd: 'swift test', expected: true, desc: 'swift test' },
|
||||
{ cmd: 'ant build', expected: true, desc: 'ant build' },
|
||||
{ cmd: 'ant clean', expected: true, desc: 'ant clean' },
|
||||
{ cmd: 'ninja', expected: true, desc: 'ninja' },
|
||||
{ cmd: 'ninja -C build', expected: true, desc: 'ninja -C build' },
|
||||
{ cmd: 'meson compile', expected: true, desc: 'meson compile' },
|
||||
{ cmd: 'meson setup build', expected: true, desc: 'meson setup' },
|
||||
|
||||
// Directory access - should be BLOCKED (not recognized as build commands)
|
||||
{ cmd: 'cd build', expected: false, desc: 'cd build (blocked)' },
|
||||
{ cmd: 'ls build', expected: false, desc: 'ls build (blocked)' },
|
||||
{ cmd: 'cat build/output.js', expected: false, desc: 'cat build file (blocked)' },
|
||||
{ cmd: 'cd node_modules', expected: false, desc: 'cd node_modules (blocked)' },
|
||||
{ cmd: 'rm -rf dist', expected: false, desc: 'rm -rf dist (blocked)' },
|
||||
];
|
||||
|
||||
console.log('Testing build command allowlist...\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const test of tests) {
|
||||
const result = isBuildCommand(test.cmd);
|
||||
const success = result === test.expected;
|
||||
|
||||
if (success) {
|
||||
console.log(`\x1b[32m✓\x1b[0m ${test.desc}: ${result}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`\x1b[31m✗\x1b[0m ${test.desc}: expected ${test.expected}, got ${result}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
114
.opencode/plugin/scout-block/tests/test-error-formatter.cjs
Executable file
114
.opencode/plugin/scout-block/tests/test-error-formatter.cjs
Executable file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* test-error-formatter.cjs - Unit tests for error-formatter module
|
||||
*/
|
||||
|
||||
const {
|
||||
formatBlockedError,
|
||||
formatSimpleError,
|
||||
formatMachineError,
|
||||
formatWarning,
|
||||
formatConfigPath,
|
||||
supportsColor,
|
||||
colorize,
|
||||
COLORS
|
||||
} = require('../error-formatter.cjs');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, condition) {
|
||||
if (condition) {
|
||||
console.log(`\x1b[32m✓\x1b[0m ${name}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`\x1b[31m✗\x1b[0m ${name}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Testing error-formatter module...\n');
|
||||
|
||||
// formatConfigPath tests
|
||||
console.log('--- formatConfigPath Tests ---');
|
||||
test('formatConfigPath with claudeDir', formatConfigPath('/home/user/.claude').includes('.ckignore'));
|
||||
test('formatConfigPath prefers explicit configPath', formatConfigPath('/home/user/.claude', '/tmp/project/.ckignore') === '/tmp/project/.ckignore');
|
||||
test('formatConfigPath without claudeDir', formatConfigPath(null) === '.claude/.ckignore');
|
||||
test('formatConfigPath empty string', formatConfigPath('') === '.claude/.ckignore');
|
||||
|
||||
// formatBlockedError tests
|
||||
console.log('\n--- formatBlockedError Tests ---');
|
||||
const blockError = formatBlockedError({
|
||||
path: 'packages/web/node_modules/react',
|
||||
pattern: 'node_modules',
|
||||
tool: 'Bash',
|
||||
claudeDir: '/home/user/project/.claude',
|
||||
configPath: '/home/user/project/.ckignore'
|
||||
});
|
||||
test('formatBlockedError contains BLOCKED', blockError.includes('BLOCKED'));
|
||||
test('formatBlockedError contains path', blockError.includes('packages/web/node_modules/react'));
|
||||
test('formatBlockedError contains pattern', blockError.includes('node_modules'));
|
||||
test('formatBlockedError contains tool', blockError.includes('Bash'));
|
||||
test('formatBlockedError contains fix hint', blockError.includes('!node_modules'));
|
||||
test('formatBlockedError prefers explicit config path', blockError.includes('/home/user/project/.ckignore'));
|
||||
|
||||
// Test long path truncation
|
||||
const longPath = 'a/'.repeat(50) + 'node_modules/package/index.js';
|
||||
const longPathError = formatBlockedError({
|
||||
path: longPath,
|
||||
pattern: 'node_modules',
|
||||
tool: 'Read',
|
||||
claudeDir: '.claude'
|
||||
});
|
||||
test('formatBlockedError truncates long path', longPathError.includes('...'));
|
||||
|
||||
// formatSimpleError tests
|
||||
console.log('\n--- formatSimpleError Tests ---');
|
||||
const simpleError = formatSimpleError('node_modules', 'packages/web/node_modules');
|
||||
test('formatSimpleError contains ERROR', simpleError.includes('ERROR'));
|
||||
test('formatSimpleError contains pattern', simpleError.includes('node_modules'));
|
||||
test('formatSimpleError contains path', simpleError.includes('packages/web/node_modules'));
|
||||
|
||||
// formatMachineError tests
|
||||
console.log('\n--- formatMachineError Tests ---');
|
||||
const machineError = formatMachineError({
|
||||
path: 'dist/bundle.js',
|
||||
pattern: 'dist',
|
||||
tool: 'Read',
|
||||
claudeDir: '.claude',
|
||||
configPath: '/tmp/project/.ckignore'
|
||||
});
|
||||
const parsed = JSON.parse(machineError);
|
||||
test('formatMachineError is valid JSON', typeof parsed === 'object');
|
||||
test('formatMachineError has error field', parsed.error === 'BLOCKED');
|
||||
test('formatMachineError has path field', parsed.path === 'dist/bundle.js');
|
||||
test('formatMachineError has pattern field', parsed.pattern === 'dist');
|
||||
test('formatMachineError has tool field', parsed.tool === 'Read');
|
||||
test('formatMachineError has config field', parsed.config === '/tmp/project/.ckignore');
|
||||
test('formatMachineError has fix field', parsed.fix.includes('!dist'));
|
||||
|
||||
// formatWarning tests
|
||||
console.log('\n--- formatWarning Tests ---');
|
||||
const warning = formatWarning('Test warning message');
|
||||
test('formatWarning contains WARN', warning.includes('WARN'));
|
||||
test('formatWarning contains message', warning.includes('Test warning message'));
|
||||
|
||||
// colorize tests (with forced NO_COLOR)
|
||||
console.log('\n--- colorize Tests ---');
|
||||
const originalNoColor = process.env.NO_COLOR;
|
||||
process.env.NO_COLOR = '1';
|
||||
test('colorize respects NO_COLOR', colorize('test', 'red') === 'test');
|
||||
delete process.env.NO_COLOR;
|
||||
|
||||
// Test COLORS constant exists
|
||||
test('COLORS constant has expected keys',
|
||||
'red' in COLORS && 'yellow' in COLORS && 'blue' in COLORS && 'reset' in COLORS
|
||||
);
|
||||
|
||||
// Restore original NO_COLOR
|
||||
if (originalNoColor !== undefined) {
|
||||
process.env.NO_COLOR = originalNoColor;
|
||||
}
|
||||
|
||||
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
75
.opencode/plugin/scout-block/tests/test-full-flow-edge-cases.cjs
Executable file
75
.opencode/plugin/scout-block/tests/test-full-flow-edge-cases.cjs
Executable file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* test-full-flow-edge-cases.cjs - Edge case validation for full hook flow
|
||||
*/
|
||||
|
||||
const BUILD_COMMAND_PATTERN = /^(npm|pnpm|yarn|bun)\s+([^\s]+\s+)*(run\s+)?(build|test|lint|dev|start|install|ci|add|remove|update|publish|pack|init|create|exec)/;
|
||||
const TOOL_COMMAND_PATTERN = /^(npx|pnpx|bunx|tsc|esbuild|vite|webpack|rollup|turbo|nx|jest|vitest|mocha|eslint|prettier|go|cargo|make|mvn|gradle|dotnet)/;
|
||||
|
||||
function isBuildCommand(command) {
|
||||
if (!command || typeof command !== 'string') return false;
|
||||
const trimmed = command.trim();
|
||||
return BUILD_COMMAND_PATTERN.test(trimmed) || TOOL_COMMAND_PATTERN.test(trimmed);
|
||||
}
|
||||
|
||||
console.log('=== FULL FLOW EDGE CASE VALIDATION ===\n');
|
||||
|
||||
const tests = [
|
||||
// Should be ALLOWED (bypass path extraction)
|
||||
{ cmd: 'go build ./...', expect: true, desc: 'go build basic' },
|
||||
{ cmd: 'cargo build', expect: true, desc: 'cargo build basic' },
|
||||
{ cmd: 'make build', expect: true, desc: 'make build' },
|
||||
{ cmd: 'make -j4', expect: true, desc: 'make with flags' },
|
||||
{ cmd: 'mvn clean install', expect: true, desc: 'maven' },
|
||||
{ cmd: 'gradle build', expect: true, desc: 'gradle' },
|
||||
{ cmd: 'dotnet build', expect: true, desc: 'dotnet' },
|
||||
{ cmd: 'npm run build', expect: true, desc: 'npm run build' },
|
||||
{ cmd: 'go test ./...', expect: true, desc: 'go test' },
|
||||
|
||||
// Should be BLOCKED (goes through path extraction)
|
||||
{ cmd: 'docker build .', expect: false, desc: 'docker build (not in allowlist)' },
|
||||
{ cmd: 'cd proj && go build', expect: false, desc: 'chained with cd first' },
|
||||
{ cmd: 'GOOS=linux go build', expect: false, desc: 'env var prefix' },
|
||||
{ cmd: 'sudo go build', expect: false, desc: 'sudo prefix' },
|
||||
{ cmd: 'time go build', expect: false, desc: 'time prefix' },
|
||||
{ cmd: 'ls build', expect: false, desc: 'ls build dir' },
|
||||
{ cmd: 'cd build', expect: false, desc: 'cd build dir' },
|
||||
];
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const t of tests) {
|
||||
const result = isBuildCommand(t.cmd);
|
||||
const success = result === t.expect;
|
||||
|
||||
if (success) {
|
||||
console.log(`\x1b[32m✓\x1b[0m ${t.desc}: "${t.cmd}" → ${result}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`\x1b[31m✗\x1b[0m ${t.desc}: "${t.cmd}" → ${result} (expected ${t.expect})`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
||||
|
||||
// Additional edge case analysis
|
||||
console.log('\n=== EDGE CASES REQUIRING ATTENTION ===\n');
|
||||
|
||||
const edgeCases = [
|
||||
{ cmd: 'docker build .', issue: 'docker not in TOOL_COMMAND_PATTERN - should it be?' },
|
||||
{ cmd: 'cd proj && go build', issue: 'Chained commands: first segment checked, not individual commands' },
|
||||
{ cmd: 'GOOS=linux go build', issue: 'Env var prefix breaks regex start anchor' },
|
||||
{ cmd: 'php artisan build', issue: 'php/artisan not in patterns' },
|
||||
{ cmd: 'bundle exec build', issue: 'ruby bundler not in patterns' },
|
||||
];
|
||||
|
||||
console.log('Known edge cases that may cause UX issues:\n');
|
||||
for (const ec of edgeCases) {
|
||||
const allowed = isBuildCommand(ec.cmd);
|
||||
console.log(` ${allowed ? '✓' : '⚠'} "${ec.cmd}"`);
|
||||
console.log(` Issue: ${ec.issue}\n`);
|
||||
}
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
225
.opencode/plugin/scout-block/tests/test-monorepo-scenarios.cjs
Executable file
225
.opencode/plugin/scout-block/tests/test-monorepo-scenarios.cjs
Executable file
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* test-monorepo-scenarios.cjs - Integration tests for monorepo patterns
|
||||
*
|
||||
* THIS IS THE CRITICAL TEST FILE FOR THE BUG FIX!
|
||||
* Tests that subfolder blocked directories (node_modules, dist, etc.)
|
||||
* are properly blocked in monorepo structures.
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const hookPath = path.join(__dirname, '..', '..', 'scout-block.cjs');
|
||||
|
||||
const scenarios = [
|
||||
// === THE BUG CASES - These MUST be BLOCKED ===
|
||||
{
|
||||
input: { tool_name: 'Bash', tool_input: { command: 'ls packages/web/node_modules' } },
|
||||
expected: 'BLOCKED',
|
||||
desc: '[BUG FIX] ls subfolder node_modules'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Bash', tool_input: { command: 'cd apps/api/node_modules' } },
|
||||
expected: 'BLOCKED',
|
||||
desc: '[BUG FIX] cd subfolder node_modules'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Bash', tool_input: { command: 'cat packages/shared/node_modules/lodash/index.js' } },
|
||||
expected: 'BLOCKED',
|
||||
desc: '[BUG FIX] cat file in subfolder node_modules'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Read', tool_input: { file_path: 'packages/web/node_modules/react/package.json' } },
|
||||
expected: 'BLOCKED',
|
||||
desc: '[BUG FIX] Read subfolder node_modules'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Grep', tool_input: { pattern: 'export', path: 'packages/web/node_modules' } },
|
||||
expected: 'BLOCKED',
|
||||
desc: '[BUG FIX] Grep in subfolder node_modules'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Glob', tool_input: { pattern: 'packages/web/node_modules/**/*.js' } },
|
||||
expected: 'BLOCKED',
|
||||
desc: '[BUG FIX] Glob subfolder node_modules'
|
||||
},
|
||||
|
||||
// === Deep nesting (also bug cases) ===
|
||||
{
|
||||
input: { tool_name: 'Read', tool_input: { file_path: 'a/b/c/d/node_modules/pkg/index.js' } },
|
||||
expected: 'BLOCKED',
|
||||
desc: '[BUG FIX] Deep nested node_modules'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Bash', tool_input: { command: 'ls packages/web/dist' } },
|
||||
expected: 'BLOCKED',
|
||||
desc: '[BUG FIX] ls subfolder dist'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Bash', tool_input: { command: 'cat apps/api/build/server.js' } },
|
||||
expected: 'BLOCKED',
|
||||
desc: '[BUG FIX] cat subfolder build'
|
||||
},
|
||||
|
||||
// === Root level blocking (should still work) ===
|
||||
{
|
||||
input: { tool_name: 'Bash', tool_input: { command: 'ls node_modules' } },
|
||||
expected: 'BLOCKED',
|
||||
desc: 'ls root node_modules'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Read', tool_input: { file_path: 'node_modules/lodash/index.js' } },
|
||||
expected: 'BLOCKED',
|
||||
desc: 'Read root node_modules'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Bash', tool_input: { command: 'cat .git/config' } },
|
||||
expected: 'BLOCKED',
|
||||
desc: 'cat .git file'
|
||||
},
|
||||
|
||||
// === Build commands - MUST be ALLOWED ===
|
||||
{
|
||||
input: { tool_name: 'Bash', tool_input: { command: 'npm run build' } },
|
||||
expected: 'ALLOWED',
|
||||
desc: 'npm run build'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Bash', tool_input: { command: 'pnpm build' } },
|
||||
expected: 'ALLOWED',
|
||||
desc: 'pnpm build'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Bash', tool_input: { command: 'yarn build' } },
|
||||
expected: 'ALLOWED',
|
||||
desc: 'yarn build'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Bash', tool_input: { command: 'npm test' } },
|
||||
expected: 'ALLOWED',
|
||||
desc: 'npm test'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Bash', tool_input: { command: 'npm install' } },
|
||||
expected: 'ALLOWED',
|
||||
desc: 'npm install'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Bash', tool_input: { command: 'pnpm --filter web run build' } },
|
||||
expected: 'ALLOWED',
|
||||
desc: 'pnpm filter build'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Bash', tool_input: { command: 'npx tsc' } },
|
||||
expected: 'ALLOWED',
|
||||
desc: 'npx tsc'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Bash', tool_input: { command: 'jest --coverage' } },
|
||||
expected: 'ALLOWED',
|
||||
desc: 'jest with flags'
|
||||
},
|
||||
|
||||
// === Safe operations - MUST be ALLOWED ===
|
||||
{
|
||||
input: { tool_name: 'Read', tool_input: { file_path: 'packages/web/src/App.tsx' } },
|
||||
expected: 'ALLOWED',
|
||||
desc: 'Read safe path'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Bash', tool_input: { command: 'ls packages/web/src' } },
|
||||
expected: 'ALLOWED',
|
||||
desc: 'ls safe path'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Grep', tool_input: { pattern: 'import', path: 'src' } },
|
||||
expected: 'ALLOWED',
|
||||
desc: 'Grep in src'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Glob', tool_input: { pattern: '**/*.ts' } },
|
||||
expected: 'ALLOWED',
|
||||
desc: 'Glob all .ts files'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Bash', tool_input: { command: 'find packages -name "*.json" | head' } },
|
||||
expected: 'ALLOWED',
|
||||
desc: 'find without blocked dirs'
|
||||
},
|
||||
|
||||
// === Edge cases - names containing blocked words but NOT the dirs ===
|
||||
{
|
||||
input: { tool_name: 'Read', tool_input: { file_path: 'my-node_modules-project/file.js' } },
|
||||
expected: 'ALLOWED',
|
||||
desc: 'node_modules in project name'
|
||||
},
|
||||
{
|
||||
input: { tool_name: 'Bash', tool_input: { command: 'ls build-tools' } },
|
||||
expected: 'ALLOWED',
|
||||
desc: 'build- prefix directory'
|
||||
},
|
||||
];
|
||||
|
||||
console.log('Testing monorepo scenarios (scout-block integration)...\n');
|
||||
console.log('Hook path:', hookPath, '\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
try {
|
||||
execSync(`node "${hookPath}"`, {
|
||||
input: JSON.stringify(scenario.input),
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
// Exit 0 = ALLOWED
|
||||
const actual = 'ALLOWED';
|
||||
const success = actual === scenario.expected;
|
||||
if (success) {
|
||||
console.log(`\x1b[32m✓\x1b[0m ${scenario.desc}: ${actual}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`\x1b[31m✗\x1b[0m ${scenario.desc}: expected ${scenario.expected}, got ${actual}`);
|
||||
failed++;
|
||||
}
|
||||
} catch (error) {
|
||||
// Exit 2 = BLOCKED
|
||||
const actual = error.status === 2 ? 'BLOCKED' : `ERROR(${error.status})`;
|
||||
const success = actual === scenario.expected;
|
||||
if (success) {
|
||||
console.log(`\x1b[32m✓\x1b[0m ${scenario.desc}: ${actual}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`\x1b[31m✗\x1b[0m ${scenario.desc}: expected ${scenario.expected}, got ${actual}`);
|
||||
if (error.stderr) {
|
||||
console.log(` stderr: ${error.stderr.toString().trim().split('\n')[0]}`);
|
||||
}
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
||||
|
||||
// Highlight if any bug fix cases failed
|
||||
const bugFixFailed = scenarios.filter(s => s.desc.includes('[BUG FIX]')).some(s => {
|
||||
try {
|
||||
execSync(`node "${hookPath}"`, {
|
||||
input: JSON.stringify(s.input),
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
return s.expected === 'BLOCKED'; // Should have been blocked but wasn't
|
||||
} catch (error) {
|
||||
return error.status !== 2 && s.expected === 'BLOCKED';
|
||||
}
|
||||
});
|
||||
|
||||
if (bugFixFailed) {
|
||||
console.log('\n\x1b[31mWARNING: Some bug fix test cases failed!\x1b[0m');
|
||||
console.log('The subfolder blocking bug has NOT been fixed properly.');
|
||||
}
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
138
.opencode/plugin/scout-block/tests/test-path-extractor.cjs
Executable file
138
.opencode/plugin/scout-block/tests/test-path-extractor.cjs
Executable file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* test-path-extractor.cjs - Unit tests for path-extractor module
|
||||
*/
|
||||
|
||||
const { extractFromToolInput, extractFromCommand, looksLikePath } = require('../path-extractor.cjs');
|
||||
|
||||
const toolInputTests = [
|
||||
{
|
||||
input: { file_path: 'packages/web/src/index.js' },
|
||||
expected: ['packages/web/src/index.js'],
|
||||
desc: 'file_path extraction'
|
||||
},
|
||||
{
|
||||
input: { path: 'node_modules' },
|
||||
expected: ['node_modules'],
|
||||
desc: 'path extraction'
|
||||
},
|
||||
{
|
||||
input: { pattern: '**/node_modules/**' },
|
||||
expected: ['**/node_modules/**'],
|
||||
desc: 'pattern extraction'
|
||||
},
|
||||
{
|
||||
input: { command: 'ls packages/web/node_modules' },
|
||||
hasPath: 'packages/web/node_modules',
|
||||
desc: 'command path extraction'
|
||||
},
|
||||
{
|
||||
input: { file_path: '/home/user/project/node_modules/pkg/index.js' },
|
||||
expected: ['/home/user/project/node_modules/pkg/index.js'],
|
||||
desc: 'absolute path extraction'
|
||||
},
|
||||
{
|
||||
input: { file_path: 'packages/web/node_modules/react/package.json', path: 'src' },
|
||||
hasPath: 'packages/web/node_modules',
|
||||
desc: 'multiple params extraction'
|
||||
}
|
||||
];
|
||||
|
||||
const commandTests = [
|
||||
{ cmd: 'ls packages/web/node_modules', hasPath: 'packages/web/node_modules', desc: 'ls with subfolder' },
|
||||
{ cmd: 'cat "path with spaces/file.js"', hasPath: 'path with spaces/file.js', desc: 'quoted path' },
|
||||
{ cmd: "cat 'single/quoted/path.js'", hasPath: 'single/quoted/path.js', desc: 'single quoted path' },
|
||||
{ cmd: 'cd apps/api/node_modules && ls', hasPath: 'apps/api/node_modules', desc: 'cd with chained command' },
|
||||
{ cmd: 'rm -rf node_modules', hasPath: 'node_modules', desc: 'rm with flags' },
|
||||
{ cmd: 'cp -r dist/ backup/', hasPath: 'dist', desc: 'cp with flags' },
|
||||
|
||||
// Note: Build commands may extract 'build' as a blocked dir name, but this is handled
|
||||
// at the dispatcher level (build commands bypass path checking entirely).
|
||||
// The path extractor correctly identifies blocked dir names like 'build'.
|
||||
{ cmd: 'npm run build', hasPath: 'build', desc: 'npm run build (extracts build)' },
|
||||
{ cmd: 'pnpm build', hasPath: 'build', desc: 'pnpm build (extracts build)' },
|
||||
{ cmd: 'cd build', hasPath: 'build', desc: 'cd build (extracts build)' },
|
||||
{ cmd: 'yarn test', hasPath: null, desc: 'yarn test (no blocked paths)' },
|
||||
{ cmd: 'npm install', hasPath: null, desc: 'npm install (no blocked paths)' },
|
||||
];
|
||||
|
||||
const looksLikePathTests = [
|
||||
{ str: 'packages/web/src', expected: true, desc: 'relative path with slashes' },
|
||||
{ str: '/home/user/project', expected: true, desc: 'absolute path' },
|
||||
{ str: './src/index.js', expected: true, desc: 'dot-relative path' },
|
||||
{ str: '../parent/file.js', expected: true, desc: 'parent-relative path' },
|
||||
{ str: 'file.txt', expected: true, desc: 'file with extension' },
|
||||
{ str: 'node_modules', expected: true, desc: 'blocked dir name' },
|
||||
{ str: 'ls', expected: false, desc: 'command word' },
|
||||
{ str: 'npm', expected: false, desc: 'package manager' },
|
||||
{ str: '-rf', expected: false, desc: 'flag' },
|
||||
{ str: '123', expected: false, desc: 'number' },
|
||||
];
|
||||
|
||||
console.log('Testing path-extractor module...\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Tool input tests
|
||||
console.log('--- Tool Input Tests ---');
|
||||
for (const test of toolInputTests) {
|
||||
const result = extractFromToolInput(test.input);
|
||||
let success;
|
||||
|
||||
if (test.expected) {
|
||||
success = test.expected.every(e => result.includes(e));
|
||||
} else if (test.hasPath) {
|
||||
success = result.some(p => p.includes(test.hasPath));
|
||||
}
|
||||
|
||||
if (success) {
|
||||
console.log(`\x1b[32m✓\x1b[0m ${test.desc}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`\x1b[31m✗\x1b[0m ${test.desc}: got ${JSON.stringify(result)}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Command tests
|
||||
console.log('\n--- Command Tests ---');
|
||||
for (const test of commandTests) {
|
||||
const result = extractFromCommand(test.cmd);
|
||||
let success;
|
||||
|
||||
if (test.hasPath === null) {
|
||||
// Build commands should extract few/no blocked-related paths
|
||||
success = result.length === 0 || !result.some(p =>
|
||||
p.includes('node_modules') || p.includes('dist') || p.includes('build')
|
||||
);
|
||||
} else {
|
||||
success = result.some(p => p.includes(test.hasPath));
|
||||
}
|
||||
|
||||
if (success) {
|
||||
console.log(`\x1b[32m✓\x1b[0m ${test.desc}: ${JSON.stringify(result)}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`\x1b[31m✗\x1b[0m ${test.desc}: expected path containing '${test.hasPath}', got ${JSON.stringify(result)}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// looksLikePath tests
|
||||
console.log('\n--- looksLikePath Tests ---');
|
||||
for (const test of looksLikePathTests) {
|
||||
const result = looksLikePath(test.str);
|
||||
const success = result === test.expected;
|
||||
|
||||
if (success) {
|
||||
console.log(`\x1b[32m✓\x1b[0m ${test.desc}: '${test.str}' -> ${result}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`\x1b[31m✗\x1b[0m ${test.desc}: expected ${test.expected}, got ${result}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
64
.opencode/plugin/scout-block/tests/test-pattern-matcher.cjs
Executable file
64
.opencode/plugin/scout-block/tests/test-pattern-matcher.cjs
Executable file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* test-pattern-matcher.cjs - Unit tests for pattern-matcher module
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { loadPatterns, createMatcher, matchPath, DEFAULT_PATTERNS } = require('../pattern-matcher.cjs');
|
||||
|
||||
const tests = [
|
||||
// === Basic blocking at root ===
|
||||
{ path: 'node_modules/lodash', expected: true, desc: 'root node_modules with content' },
|
||||
{ path: 'node_modules', expected: true, desc: 'root node_modules bare' },
|
||||
{ path: '.git/objects', expected: true, desc: 'root .git' },
|
||||
{ path: 'dist/bundle.js', expected: true, desc: 'root dist' },
|
||||
{ path: 'build/output', expected: true, desc: 'root build' },
|
||||
{ path: '__pycache__/file.pyc', expected: true, desc: 'root __pycache__' },
|
||||
|
||||
// === Subfolder blocking (THE BUG FIX!) ===
|
||||
{ path: 'packages/web/node_modules/react', expected: true, desc: 'subfolder node_modules (monorepo)' },
|
||||
{ path: 'apps/api/node_modules', expected: true, desc: 'subfolder node_modules bare' },
|
||||
{ path: 'packages/.git/HEAD', expected: true, desc: 'subfolder .git' },
|
||||
{ path: 'packages/web/dist/index.js', expected: true, desc: 'subfolder dist' },
|
||||
{ path: 'apps/backend/build/server.js', expected: true, desc: 'subfolder build' },
|
||||
{ path: 'packages/shared/__pycache__/module.pyc', expected: true, desc: 'subfolder __pycache__' },
|
||||
|
||||
// === Deep nesting ===
|
||||
{ path: 'a/b/c/d/node_modules/e', expected: true, desc: 'deep nested node_modules' },
|
||||
{ path: 'projects/monorepo/packages/web/node_modules/react/index.js', expected: true, desc: 'very deep nested' },
|
||||
|
||||
// === Allowed paths ===
|
||||
{ path: 'src/index.js', expected: false, desc: 'src directory' },
|
||||
{ path: 'packages/web/src/App.tsx', expected: false, desc: 'nested src' },
|
||||
{ path: 'lib/utils.js', expected: false, desc: 'lib directory' },
|
||||
{ path: 'README.md', expected: false, desc: 'root file' },
|
||||
{ path: 'apps/api/server.ts', expected: false, desc: 'nested app file' },
|
||||
|
||||
// === Edge cases (should NOT be blocked) ===
|
||||
{ path: 'my-node_modules-project/file.js', expected: false, desc: 'node_modules in project name' },
|
||||
{ path: 'build-tools/script.sh', expected: false, desc: 'build- prefix in name' },
|
||||
{ path: 'src/dist-utils.js', expected: false, desc: 'dist- prefix in name' },
|
||||
{ path: 'nodemodulesbackup/file.js', expected: false, desc: 'node_modules without separator' },
|
||||
{ path: 'distro/file.js', expected: false, desc: 'dist prefix without separator' },
|
||||
];
|
||||
|
||||
console.log('Testing pattern-matcher module...\n');
|
||||
|
||||
const matcher = createMatcher(DEFAULT_PATTERNS);
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const test of tests) {
|
||||
const result = matchPath(matcher, test.path);
|
||||
const success = result.blocked === test.expected;
|
||||
if (success) {
|
||||
console.log(`\x1b[32m✓\x1b[0m ${test.desc}: ${test.path} -> ${result.blocked ? 'BLOCKED' : 'ALLOWED'}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`\x1b[31m✗\x1b[0m ${test.desc}: expected ${test.expected ? 'BLOCKED' : 'ALLOWED'}, got ${result.blocked ? 'BLOCKED' : 'ALLOWED'}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
627
.opencode/plugin/scout-block/vendor/ignore.cjs
vendored
Normal file
627
.opencode/plugin/scout-block/vendor/ignore.cjs
vendored
Normal file
@@ -0,0 +1,627 @@
|
||||
/**
|
||||
* ignore v5.3.0 - Vendored for scout-block hook
|
||||
* https://github.com/kaelzhang/node-ignore
|
||||
* MIT License - Copyright (c) 2013 Kael Zhang
|
||||
*
|
||||
* Vendored to avoid npm dependency for Claude Code hooks.
|
||||
* Original source: https://unpkg.com/ignore@5.3.0/index.js
|
||||
*/
|
||||
|
||||
// A simple implementation of make-array
|
||||
function makeArray (subject) {
|
||||
return Array.isArray(subject)
|
||||
? subject
|
||||
: [subject]
|
||||
}
|
||||
|
||||
const EMPTY = ''
|
||||
const SPACE = ' '
|
||||
const ESCAPE = '\\'
|
||||
const REGEX_TEST_BLANK_LINE = /^\s+$/
|
||||
const REGEX_INVALID_TRAILING_BACKSLASH = /(?:[^\\]|^)\\$/
|
||||
const REGEX_REPLACE_LEADING_EXCAPED_EXCLAMATION = /^\\!/
|
||||
const REGEX_REPLACE_LEADING_EXCAPED_HASH = /^\\#/
|
||||
const REGEX_SPLITALL_CRLF = /\r?\n/g
|
||||
// /foo,
|
||||
// ./foo,
|
||||
// ../foo,
|
||||
// .
|
||||
// ..
|
||||
const REGEX_TEST_INVALID_PATH = /^\.*\/|^\.+$/
|
||||
|
||||
const SLASH = '/'
|
||||
|
||||
// Do not use ternary expression here, since "istanbul ignore next" is buggy
|
||||
let TMP_KEY_IGNORE = 'node-ignore'
|
||||
/* istanbul ignore else */
|
||||
if (typeof Symbol !== 'undefined') {
|
||||
TMP_KEY_IGNORE = Symbol.for('node-ignore')
|
||||
}
|
||||
const KEY_IGNORE = TMP_KEY_IGNORE
|
||||
|
||||
const define = (object, key, value) =>
|
||||
Object.defineProperty(object, key, {value})
|
||||
|
||||
const REGEX_REGEXP_RANGE = /([0-z])-([0-z])/g
|
||||
|
||||
const RETURN_FALSE = () => false
|
||||
|
||||
// Sanitize the range of a regular expression
|
||||
// The cases are complicated, see test cases for details
|
||||
const sanitizeRange = range => range.replace(
|
||||
REGEX_REGEXP_RANGE,
|
||||
(match, from, to) => from.charCodeAt(0) <= to.charCodeAt(0)
|
||||
? match
|
||||
// Invalid range (out of order) which is ok for gitignore rules but
|
||||
// fatal for JavaScript regular expression, so eliminate it.
|
||||
: EMPTY
|
||||
)
|
||||
|
||||
// See fixtures #59
|
||||
const cleanRangeBackSlash = slashes => {
|
||||
const {length} = slashes
|
||||
return slashes.slice(0, length - length % 2)
|
||||
}
|
||||
|
||||
// > If the pattern ends with a slash,
|
||||
// > it is removed for the purpose of the following description,
|
||||
// > but it would only find a match with a directory.
|
||||
// > In other words, foo/ will match a directory foo and paths underneath it,
|
||||
// > but will not match a regular file or a symbolic link foo
|
||||
// > (this is consistent with the way how pathspec works in general in Git).
|
||||
// '`foo/`' will not match regular file '`foo`' or symbolic link '`foo`'
|
||||
// -> ignore-rules will not deal with it, because it costs extra `fs.stat` call
|
||||
// you could use option `mark: true` with `glob`
|
||||
|
||||
// '`foo/`' should not continue with the '`..`'
|
||||
const REPLACERS = [
|
||||
|
||||
// > Trailing spaces are ignored unless they are quoted with backslash ("\")
|
||||
[
|
||||
// (a\ ) -> (a )
|
||||
// (a ) -> (a)
|
||||
// (a \ ) -> (a )
|
||||
/\\?\s+$/,
|
||||
match => match.indexOf('\\') === 0
|
||||
? SPACE
|
||||
: EMPTY
|
||||
],
|
||||
|
||||
// replace (\ ) with ' '
|
||||
[
|
||||
/\\\s/g,
|
||||
() => SPACE
|
||||
],
|
||||
|
||||
// Escape metacharacters
|
||||
// which is written down by users but means special for regular expressions.
|
||||
|
||||
// > There are 12 characters with special meanings:
|
||||
// > - the backslash \,
|
||||
// > - the caret ^,
|
||||
// > - the dollar sign $,
|
||||
// > - the period or dot .,
|
||||
// > - the vertical bar or pipe symbol |,
|
||||
// > - the question mark ?,
|
||||
// > - the asterisk or star *,
|
||||
// > - the plus sign +,
|
||||
// > - the opening parenthesis (,
|
||||
// > - the closing parenthesis ),
|
||||
// > - and the opening square bracket [,
|
||||
// > - the opening curly brace {,
|
||||
// > These special characters are often called "metacharacters".
|
||||
[
|
||||
/[\\$.|*+(){^]/g,
|
||||
match => `\\${match}`
|
||||
],
|
||||
|
||||
[
|
||||
// > a question mark (?) matches a single character
|
||||
/(?!\\)\?/g,
|
||||
() => '[^/]'
|
||||
],
|
||||
|
||||
// leading slash
|
||||
[
|
||||
|
||||
// > A leading slash matches the beginning of the pathname.
|
||||
// > For example, "/*.c" matches "cat-file.c" but not "mozilla-sha1/sha1.c".
|
||||
// A leading slash matches the beginning of the pathname
|
||||
/^\//,
|
||||
() => '^'
|
||||
],
|
||||
|
||||
// replace special metacharacter slash after the leading slash
|
||||
[
|
||||
/\//g,
|
||||
() => '\\/'
|
||||
],
|
||||
|
||||
[
|
||||
// > A leading "**" followed by a slash means match in all directories.
|
||||
// > For example, "**/foo" matches file or directory "foo" anywhere,
|
||||
// > the same as pattern "foo".
|
||||
// > "**/foo/bar" matches file or directory "bar" anywhere that is directly
|
||||
// > under directory "foo".
|
||||
// Notice that the '*'s have been replaced as '\\*'
|
||||
/^\^*\\\*\\\*\\\//,
|
||||
|
||||
// '**/foo' <-> 'foo'
|
||||
() => '^(?:.*\\/)?'
|
||||
],
|
||||
|
||||
// starting
|
||||
[
|
||||
// there will be no leading '/'
|
||||
// (which has been replaced by section "leading slash")
|
||||
// If starts with '**', adding a '^' to the regular expression also works
|
||||
/^(?=[^^])/,
|
||||
function startingReplacer () {
|
||||
// If has a slash `/` at the beginning or middle
|
||||
return !/\/(?!$)/.test(this)
|
||||
// > Prior to 2.22.1
|
||||
// > If the pattern does not contain a slash /,
|
||||
// > Git treats it as a shell glob pattern
|
||||
// Actually, if there is only a trailing slash,
|
||||
// git also treats it as a shell glob pattern
|
||||
|
||||
// After 2.22.1 (compatible but clearer)
|
||||
// > If there is a separator at the beginning or middle (or both)
|
||||
// > of the pattern, then the pattern is relative to the directory
|
||||
// > level of the particular .gitignore file itself.
|
||||
// > Otherwise the pattern may also match at any level below
|
||||
// > the .gitignore level.
|
||||
? '(?:^|\\/)'
|
||||
|
||||
// > Otherwise, Git treats the pattern as a shell glob suitable for
|
||||
// > consumption by fnmatch(3)
|
||||
: '^'
|
||||
}
|
||||
],
|
||||
|
||||
// two globstars
|
||||
[
|
||||
// Use lookahead assertions so that we could match more than one `'/**'`
|
||||
/\\\/\\\*\\\*(?=\\\/|$)/g,
|
||||
|
||||
// Zero, one or several directories
|
||||
// should not use '*', or it will be replaced by the next replacer
|
||||
|
||||
// Check if it is not the last `'/**'`
|
||||
(_, index, str) => index + 6 < str.length
|
||||
|
||||
// case: /**/
|
||||
// > A slash followed by two consecutive asterisks then a slash matches
|
||||
// > zero or more directories.
|
||||
// > For example, "a/**/b" matches "a/b", "a/x/b", "a/x/y/b" and so on.
|
||||
// '/**/'
|
||||
? '(?:\\/[^\\/]+)*'
|
||||
|
||||
// case: /**
|
||||
// > A trailing `"/**"` matches everything inside.
|
||||
|
||||
// #21: everything inside but it should not include the current folder
|
||||
: '\\/.+'
|
||||
],
|
||||
|
||||
// normal intermediate wildcards
|
||||
[
|
||||
// Never replace escaped '*'
|
||||
// ignore rule '\*' will match the path '*'
|
||||
|
||||
// 'abc.*/' -> go
|
||||
// 'abc.*' -> skip this rule,
|
||||
// coz trailing single wildcard will be handed by [trailing wildcard]
|
||||
/(^|[^\\]+)(\\\*)+(?=.+)/g,
|
||||
|
||||
// '*.js' matches '.js'
|
||||
// '*.js' doesn't match 'abc'
|
||||
(_, p1, p2) => {
|
||||
// 1.
|
||||
// > An asterisk "*" matches anything except a slash.
|
||||
// 2.
|
||||
// > Other consecutive asterisks are considered regular asterisks
|
||||
// > and will match according to the previous rules.
|
||||
const unescaped = p2.replace(/\\\*/g, '[^\\/]*')
|
||||
return p1 + unescaped
|
||||
}
|
||||
],
|
||||
|
||||
[
|
||||
// unescape, revert step 3 except for back slash
|
||||
// For example, if a user escape a '\\*',
|
||||
// after step 3, the result will be '\\\\\\*'
|
||||
/\\\\\\(?=[$.|*+(){^])/g,
|
||||
() => ESCAPE
|
||||
],
|
||||
|
||||
[
|
||||
// '\\\\' -> '\\'
|
||||
/\\\\/g,
|
||||
() => ESCAPE
|
||||
],
|
||||
|
||||
[
|
||||
// > The range notation, e.g. [a-zA-Z],
|
||||
// > can be used to match one of the characters in a range.
|
||||
|
||||
// `\` is escaped by step 3
|
||||
/(\\)?\[([^\]/]*?)(\\*)($|\])/g,
|
||||
(match, leadEscape, range, endEscape, close) => leadEscape === ESCAPE
|
||||
// '\\[bar]' -> '\\\\[bar\\]'
|
||||
? `\\[${range}${cleanRangeBackSlash(endEscape)}${close}`
|
||||
: close === ']'
|
||||
? endEscape.length % 2 === 0
|
||||
// A normal case, and it is a range notation
|
||||
// '[bar]'
|
||||
// '[bar\\\\]'
|
||||
? `[${sanitizeRange(range)}${endEscape}]`
|
||||
// Invalid range notaton
|
||||
// '[bar\\]' -> '[bar\\\\]'
|
||||
: '[]'
|
||||
: '[]'
|
||||
],
|
||||
|
||||
// ending
|
||||
[
|
||||
// 'js' will not match 'js.'
|
||||
// 'ab' will not match 'abc'
|
||||
/(?:[^*])$/,
|
||||
|
||||
// WTF!
|
||||
// https://git-scm.com/docs/gitignore
|
||||
// changes in [2.22.1](https://git-scm.com/docs/gitignore/2.22.1)
|
||||
// which re-fixes #24, #38
|
||||
|
||||
// > If there is a separator at the end of the pattern then the pattern
|
||||
// > will only match directories, otherwise the pattern can match both
|
||||
// > files and directories.
|
||||
|
||||
// 'js*' will not match 'a.js'
|
||||
// 'js/' will not match 'a.js'
|
||||
// 'js' will match 'a.js' and 'a.js/'
|
||||
match => /\/$/.test(match)
|
||||
// foo/ will not match 'foo'
|
||||
? `${match}$`
|
||||
// foo matches 'foo' and 'foo/'
|
||||
: `${match}(?=$|\\/$)`
|
||||
],
|
||||
|
||||
// trailing wildcard
|
||||
[
|
||||
/(\^|\\\/)?\\\*$/,
|
||||
(_, p1) => {
|
||||
const prefix = p1
|
||||
// '\^':
|
||||
// '/*' does not match EMPTY
|
||||
// '/*' does not match everything
|
||||
|
||||
// '\\\/':
|
||||
// 'abc/*' does not match 'abc/'
|
||||
? `${p1}[^/]+`
|
||||
|
||||
// 'a*' matches 'a'
|
||||
// 'a*' matches 'aa'
|
||||
: '[^/]*'
|
||||
|
||||
return `${prefix}(?=$|\\/$)`
|
||||
}
|
||||
],
|
||||
]
|
||||
|
||||
// A simple cache, because an ignore rule only has only one certain meaning
|
||||
const regexCache = Object.create(null)
|
||||
|
||||
// @param {pattern}
|
||||
const makeRegex = (pattern, ignoreCase) => {
|
||||
let source = regexCache[pattern]
|
||||
|
||||
if (!source) {
|
||||
source = REPLACERS.reduce(
|
||||
(prev, current) => prev.replace(current[0], current[1].bind(pattern)),
|
||||
pattern
|
||||
)
|
||||
regexCache[pattern] = source
|
||||
}
|
||||
|
||||
return ignoreCase
|
||||
? new RegExp(source, 'i')
|
||||
: new RegExp(source)
|
||||
}
|
||||
|
||||
const isString = subject => typeof subject === 'string'
|
||||
|
||||
// > A blank line matches no files, so it can serve as a separator for readability.
|
||||
const checkPattern = pattern => pattern
|
||||
&& isString(pattern)
|
||||
&& !REGEX_TEST_BLANK_LINE.test(pattern)
|
||||
&& !REGEX_INVALID_TRAILING_BACKSLASH.test(pattern)
|
||||
|
||||
// > A line starting with # serves as a comment.
|
||||
&& pattern.indexOf('#') !== 0
|
||||
|
||||
const splitPattern = pattern => pattern.split(REGEX_SPLITALL_CRLF)
|
||||
|
||||
class IgnoreRule {
|
||||
constructor (
|
||||
origin,
|
||||
pattern,
|
||||
negative,
|
||||
regex
|
||||
) {
|
||||
this.origin = origin
|
||||
this.pattern = pattern
|
||||
this.negative = negative
|
||||
this.regex = regex
|
||||
}
|
||||
}
|
||||
|
||||
const createRule = (pattern, ignoreCase) => {
|
||||
const origin = pattern
|
||||
let negative = false
|
||||
|
||||
// > An optional prefix "!" which negates the pattern;
|
||||
if (pattern.indexOf('!') === 0) {
|
||||
negative = true
|
||||
pattern = pattern.substr(1)
|
||||
}
|
||||
|
||||
pattern = pattern
|
||||
// > Put a backslash ("\") in front of the first "!" for patterns that
|
||||
// > begin with a literal "!", for example, `"\!important!.txt"`.
|
||||
.replace(REGEX_REPLACE_LEADING_EXCAPED_EXCLAMATION, '!')
|
||||
// > Put a backslash ("\") in front of the first hash for patterns that
|
||||
// > begin with a hash.
|
||||
.replace(REGEX_REPLACE_LEADING_EXCAPED_HASH, '#')
|
||||
|
||||
const regex = makeRegex(pattern, ignoreCase)
|
||||
|
||||
return new IgnoreRule(
|
||||
origin,
|
||||
pattern,
|
||||
negative,
|
||||
regex
|
||||
)
|
||||
}
|
||||
|
||||
const throwError = (message, Ctor) => {
|
||||
throw new Ctor(message)
|
||||
}
|
||||
|
||||
const checkPath = (path, originalPath, doThrow) => {
|
||||
if (!isString(path)) {
|
||||
return doThrow(
|
||||
`path must be a string, but got \`${originalPath}\``,
|
||||
TypeError
|
||||
)
|
||||
}
|
||||
|
||||
// We don't know if we should ignore EMPTY, so throw
|
||||
if (!path) {
|
||||
return doThrow(`path must not be empty`, TypeError)
|
||||
}
|
||||
|
||||
// Check if it is a relative path
|
||||
if (checkPath.isNotRelative(path)) {
|
||||
const r = '`path.relative()`d'
|
||||
return doThrow(
|
||||
`path should be a ${r} string, but got "${originalPath}"`,
|
||||
RangeError
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const isNotRelative = path => REGEX_TEST_INVALID_PATH.test(path)
|
||||
|
||||
checkPath.isNotRelative = isNotRelative
|
||||
checkPath.convert = p => p
|
||||
|
||||
class Ignore {
|
||||
constructor ({
|
||||
ignorecase = true,
|
||||
ignoreCase = ignorecase,
|
||||
allowRelativePaths = false
|
||||
} = {}) {
|
||||
define(this, KEY_IGNORE, true)
|
||||
|
||||
this._rules = []
|
||||
this._ignoreCase = ignoreCase
|
||||
this._allowRelativePaths = allowRelativePaths
|
||||
this._initCache()
|
||||
}
|
||||
|
||||
_initCache () {
|
||||
this._ignoreCache = Object.create(null)
|
||||
this._testCache = Object.create(null)
|
||||
}
|
||||
|
||||
_addPattern (pattern) {
|
||||
// #32
|
||||
if (pattern && pattern[KEY_IGNORE]) {
|
||||
this._rules = this._rules.concat(pattern._rules)
|
||||
this._added = true
|
||||
return
|
||||
}
|
||||
|
||||
if (checkPattern(pattern)) {
|
||||
const rule = createRule(pattern, this._ignoreCase)
|
||||
this._added = true
|
||||
this._rules.push(rule)
|
||||
}
|
||||
}
|
||||
|
||||
// @param {Array<string> | string | Ignore} pattern
|
||||
add (pattern) {
|
||||
this._added = false
|
||||
|
||||
makeArray(
|
||||
isString(pattern)
|
||||
? splitPattern(pattern)
|
||||
: pattern
|
||||
).forEach(this._addPattern, this)
|
||||
|
||||
// Some rules have just added to the ignore,
|
||||
// making the behavior changed.
|
||||
if (this._added) {
|
||||
this._initCache()
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
// legacy
|
||||
addPattern (pattern) {
|
||||
return this.add(pattern)
|
||||
}
|
||||
|
||||
// | ignored : unignored
|
||||
// negative | 0:0 | 0:1 | 1:0 | 1:1
|
||||
// -------- | ------- | ------- | ------- | --------
|
||||
// 0 | TEST | TEST | SKIP | X
|
||||
// 1 | TESTIF | SKIP | TEST | X
|
||||
|
||||
// - SKIP: always skip
|
||||
// - TEST: always test
|
||||
// - TESTIF: only test if checkUnignored
|
||||
// - X: that never happen
|
||||
|
||||
// @param {boolean} whether should check if the path is unignored,
|
||||
// setting `checkUnignored` to `false` could reduce additional
|
||||
// path matching.
|
||||
|
||||
// @returns {TestResult} true if a file is ignored
|
||||
_testOne (path, checkUnignored) {
|
||||
let ignored = false
|
||||
let unignored = false
|
||||
|
||||
this._rules.forEach(rule => {
|
||||
const {negative} = rule
|
||||
if (
|
||||
unignored === negative && ignored !== unignored
|
||||
|| negative && !ignored && !unignored && !checkUnignored
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const matched = rule.regex.test(path)
|
||||
|
||||
if (matched) {
|
||||
ignored = !negative
|
||||
unignored = negative
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
ignored,
|
||||
unignored
|
||||
}
|
||||
}
|
||||
|
||||
// @returns {TestResult}
|
||||
_test (originalPath, cache, checkUnignored, slices) {
|
||||
const path = originalPath
|
||||
// Supports nullable path
|
||||
&& checkPath.convert(originalPath)
|
||||
|
||||
checkPath(
|
||||
path,
|
||||
originalPath,
|
||||
this._allowRelativePaths
|
||||
? RETURN_FALSE
|
||||
: throwError
|
||||
)
|
||||
|
||||
return this._t(path, cache, checkUnignored, slices)
|
||||
}
|
||||
|
||||
_t (path, cache, checkUnignored, slices) {
|
||||
if (path in cache) {
|
||||
return cache[path]
|
||||
}
|
||||
|
||||
if (!slices) {
|
||||
// path/to/a.js
|
||||
// ['path', 'to', 'a.js']
|
||||
slices = path.split(SLASH)
|
||||
}
|
||||
|
||||
slices.pop()
|
||||
|
||||
// If the path has no parent directory, just test it
|
||||
if (!slices.length) {
|
||||
return cache[path] = this._testOne(path, checkUnignored)
|
||||
}
|
||||
|
||||
const parent = this._t(
|
||||
slices.join(SLASH) + SLASH,
|
||||
cache,
|
||||
checkUnignored,
|
||||
slices
|
||||
)
|
||||
|
||||
// If the path contains a parent directory, check the parent first
|
||||
return cache[path] = parent.ignored
|
||||
// > It is not possible to re-include a file if a parent directory of
|
||||
// > that file is excluded.
|
||||
? parent
|
||||
: this._testOne(path, checkUnignored)
|
||||
}
|
||||
|
||||
ignores (path) {
|
||||
return this._test(path, this._ignoreCache, false).ignored
|
||||
}
|
||||
|
||||
createFilter () {
|
||||
return path => !this.ignores(path)
|
||||
}
|
||||
|
||||
filter (paths) {
|
||||
return makeArray(paths).filter(this.createFilter())
|
||||
}
|
||||
|
||||
// @returns {TestResult}
|
||||
test (path) {
|
||||
return this._test(path, this._testCache, true)
|
||||
}
|
||||
}
|
||||
|
||||
const factory = options => new Ignore(options)
|
||||
|
||||
const isPathValid = path =>
|
||||
checkPath(path && checkPath.convert(path), path, RETURN_FALSE)
|
||||
|
||||
factory.isPathValid = isPathValid
|
||||
|
||||
// Fixes typescript
|
||||
factory.default = factory
|
||||
|
||||
module.exports = factory
|
||||
|
||||
// Windows
|
||||
// --------------------------------------------------------------
|
||||
/* istanbul ignore if */
|
||||
if (
|
||||
// Detect `process` so that it can run in browsers.
|
||||
typeof process !== 'undefined'
|
||||
&& (
|
||||
process.env && process.env.IGNORE_TEST_WIN32
|
||||
|| process.platform === 'win32'
|
||||
)
|
||||
) {
|
||||
/* eslint no-control-regex: "off" */
|
||||
const makePosix = str => /^\\\\\?\\/.test(str)
|
||||
|| /["<>|\u0000-\u001F]+/u.test(str)
|
||||
? str
|
||||
: str.replace(/\\/g, '/')
|
||||
|
||||
checkPath.convert = makePosix
|
||||
|
||||
// 'C:\\foo' <- 'C:\\foo' has been converted to 'C:/'
|
||||
// 'd:\\foo'
|
||||
const REGIX_IS_WINDOWS_PATH_ABSOLUTE = /^[a-z]:\//i
|
||||
checkPath.isNotRelative = path =>
|
||||
REGIX_IS_WINDOWS_PATH_ABSOLUTE.test(path)
|
||||
|| isNotRelative(path)
|
||||
}
|
||||
Reference in New Issue
Block a user