@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
JavaScript
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