Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions cli/src/cli_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ pub const DOCTOR_TOP_LEVEL_PURPOSE: &str =
"Inspect SCE operator health and explicit repair readiness";
pub const DOCTOR_SHOW_IN_TOP_LEVEL_HELP: bool = true;

pub const HOOKS_CLAP_ABOUT: &str = "Run attribution-only git hooks (disabled by default)";
pub const HOOKS_TOP_LEVEL_PURPOSE: &str = "Run attribution-only git hooks (disabled by default)";
pub const HOOKS_CLAP_ABOUT: &str =
"Run attribution-only git hooks (enabled by default; opt out with SCE_ATTRIBUTION_HOOKS_DISABLED, SCE_DISABLED, or policies.attribution_hooks.enabled=false)";
pub const HOOKS_TOP_LEVEL_PURPOSE: &str =
"Run attribution-only git hooks (enabled by default; opt out with SCE_ATTRIBUTION_HOOKS_DISABLED, SCE_DISABLED, or policies.attribution_hooks.enabled=false)";
pub const HOOKS_SHOW_IN_TOP_LEVEL_HELP: bool = false;

pub const POLICY_CLAP_ABOUT: &str = "Evaluate SCE policy requests for editor hooks";
Expand Down
1 change: 0 additions & 1 deletion cli/src/generated_migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,3 @@ pub static AUTH_MIGRATIONS: &[(&str, &str)] = &[
("001_create_auth_tokens", include_str!("../migrations/auth/001_create_auth_tokens.sql")),
("002_create_auth_credentials_updated_at_trigger", include_str!("../migrations/auth/002_create_auth_credentials_updated_at_trigger.sql")),
];

17 changes: 17 additions & 0 deletions cli/src/services/agent_trace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,23 @@ pub fn classify_hunk(
}
}

#[allow(dead_code)]
pub(crate) fn patches_have_overlap(
candidate_patch: &ParsedPatch,
target_patch: &ParsedPatch,
) -> bool {
let intersection_patch = intersect_patches(candidate_patch, target_patch);

patch_has_touched_lines(&intersection_patch)
}

pub(crate) fn patch_has_touched_lines(patch: &ParsedPatch) -> bool {
patch
.files
.iter()
.any(|file| file.hunks.iter().any(|hunk| !hunk.lines.is_empty()))
}

/// Check whether two hunks have identical touched lines in the same order.
fn hunks_match_exactly(left: &PatchHunk, right: &PatchHunk) -> bool {
if left.lines.len() != right.lines.len() {
Expand Down
69 changes: 67 additions & 2 deletions cli/src/services/agent_trace/tests.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
use super::{
build_agent_trace, validate_agent_trace_value, AgentTraceMetadataInput, AgentTraceVcsType,
LineRange, AGENT_TRACE_VERSION,
build_agent_trace, patches_have_overlap, validate_agent_trace_value, AgentTraceMetadataInput,
AgentTraceVcsType, LineRange, AGENT_TRACE_VERSION,
};
use crate::services::{
agent_trace::agent_trace_conversation_url,
patch::{combine_patches, parse_patch, ParsedPatch},
structured_patch::{derive_claude_structured_patch, ClaudeStructuredPatchDerivationResult},
};
use serde_json::{json, Value};

Expand All @@ -25,6 +26,10 @@ fn parse_fixtures(fixtures: &[&str]) -> Vec<ParsedPatch> {
.collect()
}

fn parse_fixture(fixture: &str) -> ParsedPatch {
parse_patch(fixture, None).expect("fixture patch should parse")
}

const TEXT_FILE_LIFECYCLE_RECONSTRUCTION_INCREMENTALS: &[&str] = &[
include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_01.patch"),
include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_02.patch"),
Expand Down Expand Up @@ -109,6 +114,66 @@ fn assert_builds_expected_agent_trace(scenario: AgentTraceScenario) {
assert_eq!(actual_json["files"], expected_files);
}

#[test]
fn patch_overlap_predicate_detects_matching_touched_lines() {
let candidate_patch = parse_fixture(include_str!(
"fixtures/hello_world_reconstruction/incremental_01.patch"
));
let target_patch = parse_fixture(include_str!(
"fixtures/hello_world_reconstruction/post_commit.patch"
));

assert!(patches_have_overlap(&candidate_patch, &target_patch));
}

#[test]
fn patch_overlap_predicate_rejects_unrelated_touched_lines() {
let candidate_patch = parse_fixture(include_str!(
"fixtures/hello_world_reconstruction/incremental_01.patch"
));
let target_patch = parse_fixture(include_str!(
"fixtures/poem_write_reconstruction/post_commit.patch"
));

assert!(!patches_have_overlap(&candidate_patch, &target_patch));
}

#[test]
fn patch_overlap_predicate_rejects_empty_or_untouched_patches() {
let candidate_patch = parse_fixture(include_str!(
"fixtures/hello_world_reconstruction/incremental_01.patch"
));
let untouched_patch = parse_fixture(include_str!(
"../structured_patch/fixtures/write_create_empty/expected.patch"
));
let empty_patch = parse_fixture("");

assert!(!patches_have_overlap(&candidate_patch, &untouched_patch));
assert!(!patches_have_overlap(&untouched_patch, &candidate_patch));
assert!(!patches_have_overlap(&empty_patch, &candidate_patch));
assert!(!patches_have_overlap(&candidate_patch, &empty_patch));
}

#[test]
fn patch_overlap_predicate_accepts_claude_structured_patch_derivation() {
let payload: Value = serde_json::from_str(include_str!(
"../structured_patch/fixtures/edit_single_hunk/claude-post-tool-use.json"
))
.expect("Claude structured fixture should parse");
let expected_patch = parse_fixture(include_str!(
"../structured_patch/fixtures/edit_single_hunk/expected.patch"
));
let derived_patch = match derive_claude_structured_patch("PostToolUse", &payload, 1, None) {
ClaudeStructuredPatchDerivationResult::Derived(derived) => derived.patch,
ClaudeStructuredPatchDerivationResult::Skipped(reason) => {
panic!("Claude structured fixture should derive a patch, got {reason}")
}
};

assert_eq!(derived_patch, expected_patch);
assert!(patches_have_overlap(&derived_patch, &expected_patch));
}

#[test]
fn average_age_reconstruction_matches_golden_agent_trace() {
assert_builds_expected_agent_trace(AgentTraceScenario {
Expand Down
122 changes: 115 additions & 7 deletions cli/src/services/config/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use super::types::{
parse_bool_value_from, ConfigPathSource, ConfigRequest, DatabaseRetryConfig, LoadedConfigPath,
LogFileMode, LogFormat, LogLevel, ReportFormat, ResolvedAuthRuntimeConfig,
ResolvedHookRuntimeConfig, ResolvedObservabilityRuntimeConfig, ResolvedOptionalValue,
ResolvedValue, ValueSource, ENV_ATTRIBUTION_HOOKS_ENABLED, ENV_LOG_FILE, ENV_LOG_FILE_MODE,
ResolvedValue, ValueSource, ENV_ATTRIBUTION_HOOKS_DISABLED, ENV_LOG_FILE, ENV_LOG_FILE_MODE,
ENV_LOG_FORMAT, ENV_LOG_LEVEL,
};

Expand Down Expand Up @@ -426,7 +426,7 @@ where
}

let mut resolved_attribution_hooks_enabled = ResolvedValue {
value: false,
value: true,
source: ValueSource::Default,
};
if let Some(value) = file_config.attribution_hooks_enabled {
Expand All @@ -435,17 +435,16 @@ where
source: ValueSource::ConfigFile(value.source),
};
}
if let Some(raw) = env_lookup(ENV_ATTRIBUTION_HOOKS_ENABLED) {
if let Some(raw) = env_lookup(ENV_ATTRIBUTION_HOOKS_DISABLED) {
resolved_attribution_hooks_enabled = ResolvedValue {
value: parse_bool_value_from(
ENV_ATTRIBUTION_HOOKS_ENABLED,
value: !parse_bool_value_from(
ENV_ATTRIBUTION_HOOKS_DISABLED,
&raw,
ENV_ATTRIBUTION_HOOKS_ENABLED,
ENV_ATTRIBUTION_HOOKS_DISABLED,
)?,
source: ValueSource::Env,
};
}

let resolved_workos_client_id = resolve_optional_auth_config_value(
WORKOS_CLIENT_ID_KEY,
file_config.workos_client_id,
Expand Down Expand Up @@ -622,3 +621,112 @@ pub(crate) fn init_database_retry_config_from_environment(cwd: &Path) {
}
}
}

#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};

use super::*;

fn path_exists(path: &Path) -> bool {
path == Path::new("/tmp/sce-config.json")
}

fn missing_path(_: &Path) -> bool {
false
}

fn empty_request() -> ConfigRequest {
ConfigRequest {
report_format: ReportFormat::Text,
config_path: None,
log_level: None,
timeout_ms: None,
}
}

fn explicit_config_request() -> ConfigRequest {
ConfigRequest {
config_path: Some(PathBuf::from("/tmp/sce-config.json")),
..empty_request()
}
}

fn resolve_hooks_with_env_and_config(
env: Option<(&'static str, &'static str)>,
config: Option<&'static str>,
) -> Result<ResolvedHookRuntimeConfig> {
let request = if config.is_some() {
explicit_config_request()
} else {
empty_request()
};
let path_exists_fn = if config.is_some() {
path_exists
} else {
missing_path
};

let runtime = resolve_runtime_config_with(
&request,
Path::new("/tmp/repo"),
|key| env.and_then(|(env_key, value)| (key == env_key).then_some(value.to_string())),
|_| Ok(config.unwrap_or("{}").to_string()),
path_exists_fn,
|| Ok(PathBuf::from("/tmp/missing-global-sce-config.json")),
)?;

Ok(ResolvedHookRuntimeConfig {
attribution_hooks_enabled: runtime.attribution_hooks_enabled.value,
})
}

#[test]
fn attribution_hooks_are_enabled_by_default() {
let resolved = resolve_hooks_with_env_and_config(None, None).unwrap();

assert!(resolved.attribution_hooks_enabled);
}

#[test]
fn attribution_hooks_disabled_env_truthy_opts_out() {
let resolved =
resolve_hooks_with_env_and_config(Some((ENV_ATTRIBUTION_HOOKS_DISABLED, "1")), None)
.unwrap();

assert!(!resolved.attribution_hooks_enabled);
}

#[test]
fn explicit_config_false_opts_out() {
let resolved = resolve_hooks_with_env_and_config(
None,
Some(r#"{"policies":{"attribution_hooks":{"enabled":false}}}"#),
)
.unwrap();

assert!(!resolved.attribution_hooks_enabled);
}

#[test]
fn disabled_env_false_overrides_config_false() {
let resolved = resolve_hooks_with_env_and_config(
Some((ENV_ATTRIBUTION_HOOKS_DISABLED, "0")),
Some(r#"{"policies":{"attribution_hooks":{"enabled":false}}}"#),
)
.unwrap();

assert!(resolved.attribution_hooks_enabled);
}

#[test]
fn explicit_config_false_preserves_legacy_default_off_opt_out() {
let resolved = resolve_hooks_with_env_and_config(
None,
Some(r#"{"policies":{"attribution_hooks":{"enabled":false}}}"#),
)
.unwrap();

assert!(!resolved.attribution_hooks_enabled);
}
}
2 changes: 1 addition & 1 deletion cli/src/services/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pub(crate) const ENV_LOG_LEVEL: &str = "SCE_LOG_LEVEL";
pub(crate) const ENV_LOG_FORMAT: &str = "SCE_LOG_FORMAT";
pub(crate) const ENV_LOG_FILE: &str = "SCE_LOG_FILE";
pub(crate) const ENV_LOG_FILE_MODE: &str = "SCE_LOG_FILE_MODE";
pub(crate) const ENV_ATTRIBUTION_HOOKS_ENABLED: &str = "SCE_ATTRIBUTION_HOOKS_ENABLED";
pub(crate) const ENV_ATTRIBUTION_HOOKS_DISABLED: &str = "SCE_ATTRIBUTION_HOOKS_DISABLED";

pub type ReportFormat = OutputFormat;

Expand Down
Loading