UNPKG

@probelabs/visor

Version:

AI-powered code review tool for GitHub Pull Requests - CLI and GitHub Action

1,513 lines (1,509 loc) 81.6 kB
import { CheckExecutionEngine } from "./chunk-BVFNRCHT.mjs"; import "./chunk-TUTOLSFV.mjs"; import { init_logger, logger } from "./chunk-B5QBV2QJ.mjs"; import "./chunk-U7X54EMV.mjs"; import { ConfigMerger } from "./chunk-U5D2LY66.mjs"; import { __esm, __export, __require, __toCommonJS } from "./chunk-WMJKH4XE.mjs"; // src/generated/config-schema.ts var config_schema_exports = {}; __export(config_schema_exports, { configSchema: () => configSchema, default: () => config_schema_default }); var configSchema, config_schema_default; var init_config_schema = __esm({ "src/generated/config-schema.ts"() { "use strict"; configSchema = { $schema: "http://json-schema.org/draft-07/schema#", $ref: "#/definitions/VisorConfig", definitions: { VisorConfig: { type: "object", properties: { version: { type: "string", description: "Configuration version" }, extends: { anyOf: [ { type: "string" }, { type: "array", items: { type: "string" } } ], description: 'Extends from other configurations - can be file path, HTTP(S) URL, or "default"' }, steps: { $ref: "#/definitions/Record%3Cstring%2CCheckConfig%3E", description: "Step configurations (recommended)" }, checks: { $ref: "#/definitions/Record%3Cstring%2CCheckConfig%3E", description: "Check configurations (legacy, use 'steps' instead) - always populated after normalization" }, output: { $ref: "#/definitions/OutputConfig", description: "Output configuration" }, http_server: { $ref: "#/definitions/HttpServerConfig", description: "HTTP server configuration for receiving webhooks" }, memory: { $ref: "#/definitions/MemoryConfig", description: "Memory storage configuration" }, env: { $ref: "#/definitions/EnvConfig", description: "Global environment variables" }, ai_model: { type: "string", description: "Global AI model setting" }, ai_provider: { type: "string", description: "Global AI provider setting" }, ai_mcp_servers: { $ref: "#/definitions/Record%3Cstring%2CMcpServerConfig%3E", description: "Global MCP servers configuration for AI checks" }, max_parallelism: { type: "number", description: "Maximum number of checks to run in parallel (default: 3)" }, fail_fast: { type: "boolean", description: "Stop execution when any check fails (default: false)" }, fail_if: { type: "string", description: "Simple global fail condition - fails if expression evaluates to true" }, failure_conditions: { $ref: "#/definitions/FailureConditions", description: "Global failure conditions - optional (deprecated, use fail_if)" }, tag_filter: { $ref: "#/definitions/TagFilter", description: "Tag filter for selective check execution" }, routing: { $ref: "#/definitions/RoutingDefaults", description: "Optional routing defaults for retry/goto/run policies" } }, required: ["version", "output"], additionalProperties: false, description: "Main Visor configuration", patternProperties: { "^x-": {} } }, "Record<string,CheckConfig>": { type: "object", additionalProperties: { $ref: "#/definitions/CheckConfig" } }, CheckConfig: { type: "object", properties: { type: { $ref: "#/definitions/ConfigCheckType", description: "Type of check to perform (defaults to 'ai' if not specified)" }, prompt: { type: "string", description: "AI prompt for the check - can be inline string or file path (auto-detected) - required for AI checks" }, appendPrompt: { type: "string", description: "Additional prompt to append when extending configurations - merged with parent prompt" }, exec: { type: "string", description: "Command execution with Liquid template support - required for command checks" }, stdin: { type: "string", description: "Stdin input for tools with Liquid template support - optional for tool checks" }, url: { type: "string", description: "HTTP URL - required for http output checks" }, body: { type: "string", description: "HTTP body template (Liquid) - required for http output checks" }, method: { type: "string", description: "HTTP method (defaults to POST)" }, headers: { $ref: "#/definitions/Record%3Cstring%2Cstring%3E", description: "HTTP headers" }, endpoint: { type: "string", description: "HTTP endpoint path - required for http_input checks" }, transform: { type: "string", description: "Transform template for http_input data (Liquid) - optional" }, transform_js: { type: "string", description: "Transform using JavaScript expressions (evaluated in secure sandbox) - optional" }, schedule: { type: "string", description: 'Cron schedule expression (e.g., "0 2 * * *") - optional for any check type' }, focus: { type: "string", description: "Focus area for the check (security/performance/style/architecture/all) - optional" }, command: { type: "string", description: 'Command that triggers this check (e.g., "review", "security-scan") - optional' }, on: { type: "array", items: { $ref: "#/definitions/EventTrigger" }, description: "Events that trigger this check (defaults to ['manual'] if not specified)" }, triggers: { type: "array", items: { type: "string" }, description: "File patterns that trigger this check (optional)" }, ai: { $ref: "#/definitions/AIProviderConfig", description: "AI provider configuration (optional)" }, ai_model: { type: "string", description: "AI model to use for this check - overrides global setting" }, ai_provider: { type: "string", description: "AI provider to use for this check - overrides global setting" }, ai_mcp_servers: { $ref: "#/definitions/Record%3Cstring%2CMcpServerConfig%3E", description: "MCP servers for this AI check - overrides global setting" }, claude_code: { $ref: "#/definitions/ClaudeCodeConfig", description: "Claude Code configuration (for claude-code type checks)" }, env: { $ref: "#/definitions/EnvConfig", description: "Environment variables for this check" }, timeout: { type: "number", description: "Timeout in seconds for command execution (default: 60)" }, depends_on: { type: "array", items: { type: "string" }, description: "Check IDs that this check depends on (optional)" }, group: { type: "string", description: 'Group name for comment separation (e.g., "code-review", "pr-overview") - optional' }, schema: { anyOf: [ { type: "string" }, { $ref: "#/definitions/Record%3Cstring%2Cunknown%3E" } ], description: 'Schema type for template rendering (e.g., "code-review", "markdown") or inline JSON schema object - optional' }, template: { $ref: "#/definitions/CustomTemplateConfig", description: "Custom template configuration - optional" }, if: { type: "string", description: "Condition to determine if check should run - runs if expression evaluates to true" }, reuse_ai_session: { type: ["string", "boolean"], description: "Check name to reuse AI session from, or true to use first dependency (only works with depends_on)" }, session_mode: { type: "string", enum: ["clone", "append"], description: "How to reuse AI session: 'clone' (default, copy history) or 'append' (share history)" }, fail_if: { type: "string", description: "Simple fail condition - fails check if expression evaluates to true" }, failure_conditions: { $ref: "#/definitions/FailureConditions", description: "Check-specific failure conditions - optional (deprecated, use fail_if)" }, tags: { type: "array", items: { type: "string" }, description: 'Tags for categorizing and filtering checks (e.g., ["local", "fast", "security"])' }, forEach: { type: "boolean", description: "Process output as array and run dependent checks for each item" }, on_fail: { $ref: "#/definitions/OnFailConfig", description: "Failure routing configuration for this check (retry/goto/run)" }, on_success: { $ref: "#/definitions/OnSuccessConfig", description: "Success routing configuration for this check (post-actions and optional goto)" }, message: { type: "string", description: "Message template for log checks" }, level: { type: "string", enum: ["debug", "info", "warn", "error"], description: "Log level for log checks" }, include_pr_context: { type: "boolean", description: "Include PR context in log output" }, include_dependencies: { type: "boolean", description: "Include dependency summaries in log output" }, include_metadata: { type: "boolean", description: "Include execution metadata in log output" }, operation: { type: "string", enum: ["get", "set", "append", "increment", "delete", "clear", "list", "exec_js"], description: "Memory operation to perform" }, key: { type: "string", description: "Key for memory operation" }, value: { description: "Value for set/append operations" }, value_js: { type: "string", description: "JavaScript expression to compute value dynamically" }, memory_js: { type: "string", description: "JavaScript code for exec_js operation with full memory access" }, namespace: { type: "string", description: "Override namespace for this check" }, op: { type: "string", description: "GitHub operation to perform (e.g., 'labels.add', 'labels.remove', 'comment.create')" }, values: { anyOf: [ { type: "array", items: { type: "string" } }, { type: "string" } ], description: "Values for GitHub operations (can be array or single value)" }, transport: { type: "string", enum: ["stdio", "sse", "http"], description: "Transport type for MCP: stdio (default), sse (legacy), or http (streamable HTTP)" }, methodArgs: { $ref: "#/definitions/Record%3Cstring%2Cunknown%3E", description: "Arguments to pass to the MCP method (supports Liquid templates)" }, argsTransform: { type: "string", description: "Transform template for method arguments (Liquid)" }, sessionId: { type: "string", description: "Session ID for HTTP transport (optional, server may generate one)" }, args: { type: "array", items: { type: "string" }, description: "Command arguments (for stdio transport in MCP checks)" }, workingDirectory: { type: "string", description: "Working directory (for stdio transport in MCP checks)" } }, additionalProperties: false, description: "Configuration for a single check", patternProperties: { "^x-": {} } }, ConfigCheckType: { type: "string", enum: [ "ai", "command", "http", "http_input", "http_client", "noop", "log", "memory", "github", "claude-code", "mcp", "human-input" ], description: "Valid check types in configuration" }, "Record<string,string>": { type: "object", additionalProperties: { type: "string" } }, EventTrigger: { type: "string", enum: [ "pr_opened", "pr_updated", "pr_closed", "issue_opened", "issue_comment", "manual", "schedule", "webhook_received" ], description: "Valid event triggers for checks" }, AIProviderConfig: { type: "object", properties: { provider: { type: "string", enum: ["google", "anthropic", "openai", "bedrock", "mock"], description: "AI provider to use" }, model: { type: "string", description: "Model name to use" }, apiKey: { type: "string", description: "API key (usually from environment variables)" }, timeout: { type: "number", description: "Request timeout in milliseconds" }, debug: { type: "boolean", description: "Enable debug mode" }, mcpServers: { $ref: "#/definitions/Record%3Cstring%2CMcpServerConfig%3E", description: "MCP servers configuration" } }, additionalProperties: false, description: "AI provider configuration", patternProperties: { "^x-": {} } }, "Record<string,McpServerConfig>": { type: "object", additionalProperties: { $ref: "#/definitions/McpServerConfig" } }, McpServerConfig: { type: "object", properties: { command: { type: "string", description: "Command to execute for the MCP server" }, args: { type: "array", items: { type: "string" }, description: "Arguments to pass to the command" }, env: { $ref: "#/definitions/Record%3Cstring%2Cstring%3E", description: "Environment variables for the MCP server" } }, required: ["command"], additionalProperties: false, description: "MCP Server configuration", patternProperties: { "^x-": {} } }, ClaudeCodeConfig: { type: "object", properties: { allowedTools: { type: "array", items: { type: "string" }, description: "List of allowed tools for Claude Code to use" }, maxTurns: { type: "number", description: "Maximum number of turns in conversation" }, systemPrompt: { type: "string", description: "System prompt for Claude Code" }, mcpServers: { $ref: "#/definitions/Record%3Cstring%2CMcpServerConfig%3E", description: "MCP servers configuration" }, subagent: { type: "string", description: "Path to subagent script" }, hooks: { type: "object", properties: { onStart: { type: "string", description: "Called when check starts" }, onEnd: { type: "string", description: "Called when check ends" }, onError: { type: "string", description: "Called when check encounters an error" } }, additionalProperties: false, description: "Event hooks for lifecycle management", patternProperties: { "^x-": {} } } }, additionalProperties: false, description: "Claude Code configuration", patternProperties: { "^x-": {} } }, EnvConfig: { type: "object", additionalProperties: { type: ["string", "number", "boolean"] }, description: "Environment variable reference configuration" }, "Record<string,unknown>": { type: "object", additionalProperties: {} }, CustomTemplateConfig: { type: "object", properties: { file: { type: "string", description: "Path to custom template file (relative to config file or absolute)" }, content: { type: "string", description: "Raw template content as string" } }, additionalProperties: false, description: "Custom template configuration", patternProperties: { "^x-": {} } }, FailureConditions: { type: "object", additionalProperties: { $ref: "#/definitions/FailureCondition" }, description: "Collection of failure conditions" }, FailureCondition: { anyOf: [ { $ref: "#/definitions/SimpleFailureCondition" }, { $ref: "#/definitions/ComplexFailureCondition" } ], description: "Failure condition - can be a simple expression string or complex object" }, SimpleFailureCondition: { type: "string", description: "Simple failure condition - just an expression string" }, ComplexFailureCondition: { type: "object", properties: { condition: { type: "string", description: "Expression to evaluate using Function Constructor" }, message: { type: "string", description: "Human-readable message when condition is met" }, severity: { $ref: "#/definitions/FailureConditionSeverity", description: "Severity level of the failure" }, halt_execution: { type: "boolean", description: "Whether this condition should halt execution" } }, required: ["condition"], additionalProperties: false, description: "Complex failure condition with additional metadata", patternProperties: { "^x-": {} } }, FailureConditionSeverity: { type: "string", enum: ["error", "warning", "info"], description: "Failure condition severity levels" }, OnFailConfig: { type: "object", properties: { retry: { $ref: "#/definitions/RetryPolicy", description: "Retry policy" }, run: { type: "array", items: { type: "string" }, description: "Remediation steps to run before reattempt" }, goto: { type: "string", description: "Jump back to an ancestor step (by id)" }, goto_event: { $ref: "#/definitions/EventTrigger", description: "Simulate a different event when performing goto (e.g., 'pr_updated')" }, goto_js: { type: "string", description: "Dynamic goto: JS expression returning step id or null" }, run_js: { type: "string", description: "Dynamic remediation list: JS expression returning string[]" } }, additionalProperties: false, description: "Failure routing configuration per check", patternProperties: { "^x-": {} } }, RetryPolicy: { type: "object", properties: { max: { type: "number", description: "Maximum retry attempts (excluding the first attempt)" }, backoff: { $ref: "#/definitions/BackoffPolicy", description: "Backoff policy" } }, additionalProperties: false, description: "Retry policy for a step", patternProperties: { "^x-": {} } }, BackoffPolicy: { type: "object", properties: { mode: { type: "string", enum: ["fixed", "exponential"], description: "Backoff mode" }, delay_ms: { type: "number", description: "Initial delay in milliseconds" } }, additionalProperties: false, description: "Backoff policy for retries", patternProperties: { "^x-": {} } }, OnSuccessConfig: { type: "object", properties: { run: { type: "array", items: { type: "string" }, description: "Post-success steps to run" }, goto: { type: "string", description: "Optional jump back to ancestor step (by id)" }, goto_event: { $ref: "#/definitions/EventTrigger", description: "Simulate a different event when performing goto (e.g., 'pr_updated')" }, goto_js: { type: "string", description: "Dynamic goto: JS expression returning step id or null" }, run_js: { type: "string", description: "Dynamic post-success steps: JS expression returning string[]" } }, additionalProperties: false, description: "Success routing configuration per check", patternProperties: { "^x-": {} } }, OutputConfig: { type: "object", properties: { pr_comment: { $ref: "#/definitions/PrCommentOutput", description: "PR comment configuration" }, file_comment: { $ref: "#/definitions/FileCommentOutput", description: "File comment configuration (optional)" }, github_checks: { $ref: "#/definitions/GitHubCheckOutput", description: "GitHub check runs configuration (optional)" }, suppressionEnabled: { type: "boolean", description: "Whether to enable issue suppression via visor-disable comments (default: true)" } }, required: ["pr_comment"], additionalProperties: false, description: "Output configuration", patternProperties: { "^x-": {} } }, PrCommentOutput: { type: "object", properties: { format: { $ref: "#/definitions/ConfigOutputFormat", description: "Format of the output" }, group_by: { $ref: "#/definitions/GroupByOption", description: "How to group the results" }, collapse: { type: "boolean", description: "Whether to collapse sections by default" }, debug: { $ref: "#/definitions/DebugConfig", description: "Debug mode configuration (optional)" } }, required: ["format", "group_by", "collapse"], additionalProperties: false, description: "PR comment output configuration", patternProperties: { "^x-": {} } }, ConfigOutputFormat: { type: "string", enum: ["table", "json", "markdown", "sarif"], description: "Valid output formats" }, GroupByOption: { type: "string", enum: ["check", "file", "severity", "group"], description: "Valid grouping options" }, DebugConfig: { type: "object", properties: { enabled: { type: "boolean", description: "Enable debug mode" }, includePrompts: { type: "boolean", description: "Include AI prompts in debug output" }, includeRawResponses: { type: "boolean", description: "Include raw AI responses in debug output" }, includeTiming: { type: "boolean", description: "Include timing information" }, includeProviderInfo: { type: "boolean", description: "Include provider information" } }, required: [ "enabled", "includePrompts", "includeRawResponses", "includeTiming", "includeProviderInfo" ], additionalProperties: false, description: "Debug mode configuration", patternProperties: { "^x-": {} } }, FileCommentOutput: { type: "object", properties: { enabled: { type: "boolean", description: "Whether file comments are enabled" }, inline: { type: "boolean", description: "Whether to show inline comments" } }, required: ["enabled", "inline"], additionalProperties: false, description: "File comment output configuration", patternProperties: { "^x-": {} } }, GitHubCheckOutput: { type: "object", properties: { enabled: { type: "boolean", description: "Whether GitHub check runs are enabled" }, per_check: { type: "boolean", description: "Whether to create individual check runs per configured check" }, name_prefix: { type: "string", description: "Custom name prefix for check runs" } }, required: ["enabled", "per_check"], additionalProperties: false, description: "GitHub Check Runs output configuration", patternProperties: { "^x-": {} } }, HttpServerConfig: { type: "object", properties: { enabled: { type: "boolean", description: "Whether HTTP server is enabled" }, port: { type: "number", description: "Port to listen on" }, host: { type: "string", description: "Host/IP to bind to (defaults to 0.0.0.0)" }, tls: { $ref: "#/definitions/TlsConfig", description: "TLS/SSL configuration for HTTPS" }, auth: { $ref: "#/definitions/HttpAuthConfig", description: "Authentication configuration" }, endpoints: { type: "array", items: { $ref: "#/definitions/HttpEndpointConfig" }, description: "HTTP endpoints configuration" } }, required: ["enabled", "port"], additionalProperties: false, description: "HTTP server configuration for receiving webhooks", patternProperties: { "^x-": {} } }, TlsConfig: { type: "object", properties: { enabled: { type: "boolean", description: "Enable TLS/HTTPS" }, cert: { type: "string", description: "Path to TLS certificate file or certificate content" }, key: { type: "string", description: "Path to TLS key file or key content" }, ca: { type: "string", description: "Path to CA certificate file or CA content (optional)" }, rejectUnauthorized: { type: "boolean", description: "Reject unauthorized connections (default: true)" } }, required: ["enabled"], additionalProperties: false, description: "TLS/SSL configuration for HTTPS server", patternProperties: { "^x-": {} } }, HttpAuthConfig: { type: "object", properties: { type: { type: "string", enum: ["bearer_token", "hmac", "basic", "none"], description: "Authentication type" }, secret: { type: "string", description: "Secret or token for authentication" }, username: { type: "string", description: "Username for basic auth" }, password: { type: "string", description: "Password for basic auth" } }, required: ["type"], additionalProperties: false, description: "HTTP server authentication configuration", patternProperties: { "^x-": {} } }, HttpEndpointConfig: { type: "object", properties: { path: { type: "string", description: "Path for the webhook endpoint" }, transform: { type: "string", description: "Optional transform template (Liquid) for the received data" }, name: { type: "string", description: "Optional name/ID for this endpoint" } }, required: ["path"], additionalProperties: false, description: "HTTP server endpoint configuration", patternProperties: { "^x-": {} } }, MemoryConfig: { type: "object", properties: { storage: { type: "string", enum: ["memory", "file"], description: 'Storage mode: "memory" (in-memory, default) or "file" (persistent)' }, format: { type: "string", enum: ["json", "csv"], description: "Storage format (only for file storage, default: json)" }, file: { type: "string", description: "File path (required if storage: file)" }, namespace: { type: "string", description: 'Default namespace (default: "default")' }, auto_load: { type: "boolean", description: "Auto-load on startup (default: true if storage: file)" }, auto_save: { type: "boolean", description: "Auto-save after operations (default: true if storage: file)" } }, additionalProperties: false, description: "Memory storage configuration", patternProperties: { "^x-": {} } }, TagFilter: { type: "object", properties: { include: { type: "array", items: { type: "string" }, description: "Tags that checks must have to be included (ANY match)" }, exclude: { type: "array", items: { type: "string" }, description: "Tags that will exclude checks if present (ANY match)" } }, additionalProperties: false, description: "Tag filter configuration for selective check execution", patternProperties: { "^x-": {} } }, RoutingDefaults: { type: "object", properties: { max_loops: { type: "number", description: "Per-scope cap on routing transitions (success + failure)" }, defaults: { type: "object", properties: { on_fail: { $ref: "#/definitions/OnFailConfig" } }, additionalProperties: false, description: "Default policies applied to checks (step-level overrides take precedence)", patternProperties: { "^x-": {} } } }, additionalProperties: false, description: "Global routing defaults", patternProperties: { "^x-": {} } } } }; config_schema_default = configSchema; } }); // src/config.ts init_logger(); import * as yaml2 from "js-yaml"; import * as fs2 from "fs"; import * as path2 from "path"; import simpleGit from "simple-git"; // src/utils/config-loader.ts import * as fs from "fs"; import * as path from "path"; import * as yaml from "js-yaml"; var ConfigLoader = class { constructor(options = {}) { this.options = options; this.options = { allowRemote: true, cacheTTL: 5 * 60 * 1e3, // 5 minutes timeout: 30 * 1e3, // 30 seconds maxDepth: 10, allowedRemotePatterns: [], // Empty by default for security projectRoot: this.findProjectRoot(), ...options }; } cache = /* @__PURE__ */ new Map(); loadedConfigs = /* @__PURE__ */ new Set(); /** * Determine the source type from a string */ getSourceType(source) { if (source === "default") { return "default" /* DEFAULT */; } if (source.startsWith("http://") || source.startsWith("https://")) { return "remote" /* REMOTE */; } return "local" /* LOCAL */; } /** * Fetch configuration from any source */ async fetchConfig(source, currentDepth = 0) { if (currentDepth >= (this.options.maxDepth || 10)) { throw new Error( `Maximum extends depth (${this.options.maxDepth}) exceeded. Check for circular dependencies.` ); } const normalizedSource = this.normalizeSource(source); if (this.loadedConfigs.has(normalizedSource)) { throw new Error( `Circular dependency detected: ${normalizedSource} is already in the extends chain` ); } const sourceType = this.getSourceType(source); try { this.loadedConfigs.add(normalizedSource); switch (sourceType) { case "default" /* DEFAULT */: return await this.fetchDefaultConfig(); case "remote" /* REMOTE */: if (!this.options.allowRemote) { throw new Error( "Remote extends are disabled. Enable with --allow-remote-extends or remove VISOR_NO_REMOTE_EXTENDS environment variable." ); } return await this.fetchRemoteConfig(source); case "local" /* LOCAL */: return await this.fetchLocalConfig(source); default: throw new Error(`Unknown configuration source: ${source}`); } } finally { this.loadedConfigs.delete(normalizedSource); } } /** * Normalize source path/URL for comparison */ normalizeSource(source) { const sourceType = this.getSourceType(source); switch (sourceType) { case "default" /* DEFAULT */: return "default"; case "remote" /* REMOTE */: return source.toLowerCase(); case "local" /* LOCAL */: const basePath = this.options.baseDir || process.cwd(); return path.resolve(basePath, source); default: return source; } } /** * Load configuration from local file system */ async fetchLocalConfig(filePath) { const basePath = this.options.baseDir || process.cwd(); const resolvedPath = path.resolve(basePath, filePath); this.validateLocalPath(resolvedPath); if (!fs.existsSync(resolvedPath)) { throw new Error(`Configuration file not found: ${resolvedPath}`); } try { const content = fs.readFileSync(resolvedPath, "utf8"); const config = yaml.load(content); if (!config || typeof config !== "object") { throw new Error(`Invalid YAML in configuration file: ${resolvedPath}`); } const previousBaseDir = this.options.baseDir; this.options.baseDir = path.dirname(resolvedPath); try { if (config.extends) { const processedConfig = await this.processExtends(config); return processedConfig; } return config; } finally { this.options.baseDir = previousBaseDir; } } catch (error) { if (error instanceof Error) { throw new Error(`Failed to load configuration from ${resolvedPath}: ${error.message}`); } throw error; } } /** * Fetch configuration from remote URL */ async fetchRemoteConfig(url) { if (!url.startsWith("http://") && !url.startsWith("https://")) { throw new Error(`Invalid URL: ${url}. Only HTTP and HTTPS protocols are supported.`); } this.validateRemoteURL(url); const cacheEntry = this.cache.get(url); if (cacheEntry && Date.now() - cacheEntry.timestamp < cacheEntry.ttl) { const outputFormat2 = process.env.VISOR_OUTPUT_FORMAT; const logFn2 = outputFormat2 === "json" || outputFormat2 === "sarif" ? console.error : console.log; logFn2(`\u{1F4E6} Using cached configuration from: ${url}`); return cacheEntry.config; } const outputFormat = process.env.VISOR_OUTPUT_FORMAT; const logFn = outputFormat === "json" || outputFormat === "sarif" ? console.error : console.log; logFn(`\u2B07\uFE0F Fetching remote configuration from: ${url}`); const controller = new AbortController(); const timeoutMs = this.options.timeout ?? 3e4; const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { signal: controller.signal, headers: { "User-Agent": "Visor/1.0" } }); if (!response.ok) { throw new Error(`Failed to fetch config: ${response.status} ${response.statusText}`); } const content = await response.text(); const config = yaml.load(content); if (!config || typeof config !== "object") { throw new Error(`Invalid YAML in remote configuration: ${url}`); } this.cache.set(url, { config, timestamp: Date.now(), ttl: this.options.cacheTTL || 5 * 60 * 1e3 }); if (config.extends) { return await this.processExtends(config); } return config; } catch (error) { if (error instanceof Error) { if (error.name === "AbortError") { throw new Error(`Timeout fetching configuration from ${url} (${timeoutMs}ms)`); } throw new Error(`Failed to fetch remote configuration from ${url}: ${error.message}`); } throw error; } finally { clearTimeout(timeoutId); } } /** * Load bundled default configuration */ async fetchDefaultConfig() { const possiblePaths = [ // When running as GitHub Action (bundled in dist/) path.join(__dirname, "defaults", ".visor.yaml"), // When running from source path.join(__dirname, "..", "..", "defaults", ".visor.yaml"), // Try via package root this.findPackageRoot() ? path.join(this.findPackageRoot(), "defaults", ".visor.yaml") : "", // GitHub Action environment variable process.env.GITHUB_ACTION_PATH ? path.join(process.env.GITHUB_ACTION_PATH, "defaults", ".visor.yaml") : "", process.env.GITHUB_ACTION_PATH ? path.join(process.env.GITHUB_ACTION_PATH, "dist", "defaults", ".visor.yaml") : "" ].filter((p) => p); let defaultConfigPath; for (const possiblePath of possiblePaths) { if (fs.existsSync(possiblePath)) { defaultConfigPath = possiblePath; break; } } if (defaultConfigPath && fs.existsSync(defaultConfigPath)) { console.error(`\u{1F4E6} Loading bundled default configuration from ${defaultConfigPath}`); const content = fs.readFileSync(defaultConfigPath, "utf8"); let config = yaml.load(content); if (!config || typeof config !== "object") { throw new Error("Invalid default configuration"); } config = this.normalizeStepsAndChecks(config); if (config.extends) { return await this.processExtends(config); } return config; } console.warn("\u26A0\uFE0F Bundled default configuration not found, using minimal defaults"); return { version: "1.0", checks: {}, output: { pr_comment: { format: "markdown", group_by: "check", collapse: true } } }; } /** * Process extends directive in a configuration */ async processExtends(config) { if (!config.extends) { return config; } const extends_ = Array.isArray(config.extends) ? config.extends : [config.extends]; const { extends: _extendsField, ...configWithoutExtends } = config; const parentConfigs = []; for (const source of extends_) { const parentConfig = await this.fetchConfig(source, this.loadedConfigs.size); parentConfigs.push(parentConfig); } const { ConfigMerger: ConfigMerger2 } = await import("./config-merger-TWUBWFC2.mjs"); const merger = new ConfigMerger2(); let mergedParents = {}; for (const parentConfig of parentConfigs) { mergedParents = merger.merge(mergedParents, parentConfig); } return merger.merge(mergedParents, configWithoutExtends); } /** * Find project root directory (for security validation) */ findProjectRoot() { try { const { execSync } = __require("child_process"); const gitRoot = execSync("git rev-parse --show-toplevel", { encoding: "utf8" }).trim(); if (gitRoot) return gitRoot; } catch { } const packageRoot = this.findPackageRoot(); if (packageRoot) return packageRoot; return process.cwd(); } /** * Validate remote URL against allowlist */ validateRemoteURL(url) { const allowedPatterns = this.options.allowedRemotePatterns || []; if (allowedPatterns.length === 0) { return; } const isAllowed = allowedPatterns.some((pattern) => url.startsWith(pattern)); if (!isAllowed) { throw new Error( `Security error: URL ${url} is not in the allowed list. Allowed patterns: ${allowedPatterns.join(", ")}` ); } } /** * Validate local path against traversal attacks */ validateLocalPath(resolvedPath) { const projectRoot = this.options.projectRoot || process.cwd(); const normalizedPath = path.normalize(resolvedPath); const normalizedRoot = path.normalize(projectRoot); if (!normalizedPath.startsWith(normalizedRoot)) { throw new Error( `Security error: Path traversal detected. Cannot access files outside project root: ${projectRoot}` ); } const sensitivePatterns = [ "/etc/passwd", "/etc/shadow", "/.ssh/", "/.aws/", "/.env", "/private/" ]; const lowerPath = normalizedPath.toLowerCase(); for (const pattern of sensitivePatterns) { if (lowerPath.includes(pattern)) { throw new Error(`Security error: Cannot access potentially sensitive file: ${pattern}`); } } } /** * Find package root directory */ findPackageRoot() { let currentDir = __dirname; const root = path.parse(currentDir).root; while (currentDir !== root) { const packageJsonPath = path.join(currentDir, "package.json"); if (fs.existsSync(packageJsonPath)) { try { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); if (packageJson.name === "@probelabs/visor") { return currentDir; } } catch { } } currentDir = path.dirname(currentDir); } return null; } /** * Clear the configuration cache */ clearCache() { this.cache.clear(); } /** * Reset the loaded configs tracking (for testing) */ reset() { this.loadedConfigs.clear(); this.clearCache(); } /** * Normalize 'checks' and 'steps' keys for backward compatibility * Ensures both keys are present and contain the same data */ normalizeStepsAndChecks(config) { if (config.steps && config.checks) { config.checks = config.steps; } else if (config.steps && !config.checks) { config.checks = config.steps; } else if (config.checks && !config.steps) { config.steps = config.checks; } return config; } }; // src/config.ts import Ajv from "ajv"; import addFormats from "ajv-formats"; var VALID_EVENT_TRIGGERS = [ "pr_opened", "pr_updated", "pr_closed", "issue_opened", "issue_comment", "manual", "schedule", "webhook_received" ]; var ConfigManager = class { validCheckTypes = [ "ai", "claude-code", "mcp", "command", "http", "http_input", "http_client", "memory", "noop", "log", "memory", "github", "human-input" ]; validEventTriggers = [...VALID_EVENT_TRIGGERS]; validOutputFormats = ["table", "json", "markdown", "sarif"]; validGroupByOptions = ["check", "file", "severity", "group"]; /** * Load configuration from a file */ async loadConfig(configPath, options = {}) { const { validate = true, mergeDefaults = true, allowedRemotePatterns } = options; const resolvedPath = path2.isAbsolute(configPath) ? configPath : path2.resolve(process.cwd(), configPath); try { if (!fs2.existsSync(resolvedPath)) { throw new Error(`Configuration file not found: ${resolvedPath}`); } const configContent = fs2.readFileSync(resolvedPath, "utf8"); let parsedConfig; try { parsedConfig = yaml2.load(configContent); } catch (yamlError) { const error