use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::Path;
use crate::cli::FixArgs;
use crate::commands;
use crate::commands::check::collect_preflight_diagnostics_with_module_graph as preflight_diagnostics;
use crate::package::{self, CheckConfig, PreflightSeverity};
use harn_lexer::{FixEdit, Span};
use harn_lint::LintSeverity;
use harn_parser::analysis::{AnalysisDatabase, AnalysisError};
use harn_parser::{
visit, DiagnosticCode as Code, DiagnosticDetails, DiagnosticSeverity, Node, Repair,
RepairSafety, SNode, TypeExpr,
};
#[path = "fix/capability_arguments.rs"]
mod capability_arguments;
#[path = "fix/capability_migrations.rs"]
mod capability_migrations;
#[path = "fix/lint_context.rs"]
mod lint_context;
#[path = "fix/reporting.rs"]
mod reporting;
#[path = "fix/retired_testing.rs"]
mod retired_testing;
#[path = "fix/signature_threading.rs"]
mod signature_threading;
#[path = "fix/whole_program_capabilities.rs"]
mod whole_program_capabilities;
#[path = "fix/wire.rs"]
mod wire;
use capability_arguments::{
capability_argument_for_span, insert_call_argument_before_span, root_harness_argument_for_span,
};
use capability_migrations::{ambient_call_rewrite, ambient_capability_handle, ambient_replacement};
use lint_context::FixLintContext;
#[path = "fix/apply.rs"]
mod apply;
use apply::apply_repairs_with_options;
#[cfg(test)]
use apply::{
apply_capability_file_edits, apply_file_edits, apply_repairs, finish_with_rollback,
render_capability_migration_pass, repair_path,
};
use reporting::{print_apply_result, print_human_plan, skipped_files_error};
use signature_threading::{
add_call_argument_edit, add_harness_param_edit, build_reverse_callers, collect_callable_infos,
harness_param_name_for_insert, propagate_harness_requirements,
repair_for_ambient_capability_plan,
};
use wire::*;
pub(crate) const FIX_PLAN_SCHEMA_VERSION: u32 = 2;
pub(crate) const FIX_APPLY_SCHEMA_VERSION: u32 = 2;
const CAPABILITY_MIGRATION_MAX_PASSES: usize = 64;
#[derive(Debug, Clone)]
struct RepairCandidate {
file: String,
source: &'static str,
severity: &'static str,
code: Code,
message: String,
unresolved_name: Option<String>,
expected_type: Option<TypeExpr>,
span: Option<Span>,
repair: Repair,
impact: RepairImpactWire,
edits: Vec<FixEdit>,
}
#[derive(Debug, Clone)]
struct CallableInfo {
name: String,
span: Span,
is_exported: bool,
insert_offset: usize,
has_params: bool,
bound_names: BTreeSet<String>,
harness_binding: Option<String>,
can_add_harness_param: bool,
calls: Vec<CallSite>,
ambient_capability_calls: Vec<AmbientCapabilityCall>,
}
#[derive(Debug, Clone)]
struct CallSite {
callee: String,
span: Span,
args: Vec<Span>,
}
#[derive(Debug, Clone)]
struct AmbientCapabilityCall {
name: String,
code: Code,
span: Span,
args: Vec<Span>,
}
#[derive(Debug, Clone, Copy, Default)]
struct FixOptions {
capability_migrations_only: bool,
}
struct AmbientRepairContext {
cross_module_importer_count: usize,
}
impl RepairImpactWire {
fn generic() -> Self {
Self {
classification: "generic-repair".to_string(),
strategy: None,
signature_changes: Vec::new(),
requires_cross_module_caller_updates: false,
notes: Vec::new(),
}
}
fn local_ambient(strategy: &'static str) -> Self {
Self {
classification: "local-ambient-rewrite".to_string(),
strategy: Some(strategy.to_string()),
signature_changes: Vec::new(),
requires_cross_module_caller_updates: false,
notes: Vec::new(),
}
}
fn signature_threading(
signature_changes: Vec<SignatureChangeWire>,
cross_module_importer_count: usize,
) -> Self {
let has_exported_change = signature_changes.iter().any(|change| change.is_exported);
let has_public_change = signature_changes
.iter()
.any(|change| change.is_exported || change.is_entrypoint);
let requires_cross_module_caller_updates =
has_exported_change && cross_module_importer_count > 0;
let mut notes = Vec::new();
if requires_cross_module_caller_updates {
notes.push(format!(
"changes exported signatures in a file imported by {cross_module_importer_count} module(s); cross-module callers must be updated"
));
} else if has_public_change {
notes.push(
"changes an exported or entrypoint signature; external callers may need updates"
.to_string(),
);
}
Self {
classification: if has_public_change {
"public-signature-change"
} else {
"local-signature-threading"
}
.to_string(),
strategy: Some("thread-params".to_string()),
signature_changes,
requires_cross_module_caller_updates,
notes,
}
}
}
pub(crate) fn run(args: &FixArgs) -> Result<(), FixRunError> {
if args.apply {
let safety = args.safety.ok_or_else(|| {
"`harn fix --apply` requires `--safety <format-only|behavior-preserving|scope-local|surface-changing|capability-changing>`"
.to_string()
})?;
if safety == RepairSafety::NeedsHuman {
return Err(
"`harn fix --apply --safety needs-human` is not allowed; use `harn fix --plan --json` to inspect propose-only repairs"
.to_string()
.into(),
);
}
let result = apply_repairs_with_options(
&args.path,
safety,
args.dry_run,
FixOptions {
capability_migrations_only: args.capability_migrations_only,
},
)?;
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&result)
.map_err(|error| format!("failed to serialize apply result: {error}"))?
);
} else {
print_apply_result(&result);
}
if !result.skipped_files.is_empty() {
return Err(FixRunError::PartialFailure(skipped_files_error(
result.skipped_files.len(),
)));
}
return Ok(());
}
if !args.plan {
return Err("`harn fix` requires `--plan` or `--apply`"
.to_string()
.into());
}
let plan = build_plan_with_options(
&args.path,
args.safety,
FixOptions {
capability_migrations_only: args.capability_migrations_only,
},
)?;
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&plan)
.map_err(|error| format!("failed to serialize repair plan: {error}"))?
);
} else {
print_human_plan(&plan);
}
if !plan.skipped_files.is_empty() {
return Err(FixRunError::PartialFailure(skipped_files_error(
plan.skipped_files.len(),
)));
}
Ok(())
}
#[cfg(test)]
pub(crate) fn build_plan(
target: &Path,
safety_ceiling: Option<RepairSafety>,
) -> Result<RepairPlan, String> {
build_plan_with_options(target, safety_ceiling, FixOptions::default())
}
fn build_plan_with_options(
target: &Path,
safety_ceiling: Option<RepairSafety>,
options: FixOptions,
) -> Result<RepairPlan, String> {
if let Err(error) = package::validate_runtime_manifest_extensions(target) {
return Err(format!("manifest extension validation failed: {error}"));
}
let target_string = target.to_string_lossy().into_owned();
let target_refs = [target_string.as_str()];
let files = commands::check::collect_harn_targets(&target_refs);
if files.is_empty() {
return Err("no .harn files found under the given target".to_string());
}
let module_graph = commands::check::build_module_graph(&files);
let cross_file_imports = commands::check::collect_cross_file_imports(&module_graph);
let mut analysis = AnalysisDatabase::new();
let mut candidates = Vec::new();
let mut skipped_files = Vec::new();
for file in &files {
if options.capability_migrations_only {
if let Some(candidate) = retired_testing::repair(file) {
candidates.push(candidate);
}
}
if let Err(skipped) = collect_file_candidates(
&mut analysis,
file,
safety_ceiling,
&cross_file_imports,
&module_graph,
options,
&mut candidates,
) {
skipped_files.push(skipped);
}
}
let skipped_paths = skipped_files
.iter()
.map(|skipped| {
std::fs::canonicalize(&skipped.path)
.unwrap_or_else(|_| Path::new(&skipped.path).to_path_buf())
})
.collect::<BTreeSet<_>>();
let valid_files = files
.iter()
.filter(|file| {
!skipped_paths
.contains(&std::fs::canonicalize(file).unwrap_or_else(|_| (*file).clone()))
})
.cloned()
.collect::<Vec<_>>();
let whole_program_repairs =
whole_program_capabilities::plan(&valid_files, &module_graph, &candidates)?;
if !whole_program_repairs.is_empty() {
let whole_program_files = whole_program_repairs
.iter()
.map(|repair| {
std::fs::canonicalize(&repair.file)
.unwrap_or_else(|_| Path::new(&repair.file).to_path_buf())
})
.collect::<BTreeSet<_>>();
candidates.retain(|candidate| {
if is_whole_program_superseded_repair(&candidate.repair) {
return false;
}
candidate.repair.id.as_str() != "bindings/rename-unused"
|| !whole_program_files.contains(
&std::fs::canonicalize(&candidate.file)
.unwrap_or_else(|_| Path::new(&candidate.file).to_path_buf()),
)
});
candidates.extend(whole_program_repairs);
}
if options.capability_migrations_only {
candidates.retain(|candidate| !candidate.edits.is_empty());
}
let conflicts = detect_conflicts(&candidates);
let diagnostics = candidates
.iter()
.enumerate()
.map(|(index, candidate)| DiagnosticWire {
index,
file: candidate.file.clone(),
source: candidate.source,
severity: candidate.severity,
code: candidate.code.to_string(),
message: candidate.message.clone(),
span: candidate.span.map(SpanWire::from),
repair: RepairMetadataWire::from(&candidate.repair),
})
.collect::<Vec<_>>();
let repairs = candidates
.iter()
.enumerate()
.map(|(index, candidate)| {
let conflicts_with = conflicts[index].clone();
RepairWire {
diagnostic_index: index,
diagnostic_code: candidate.code.to_string(),
repair: RepairMetadataWire::from(&candidate.repair),
impact: candidate.impact.clone(),
edits: candidate
.edits
.iter()
.map(FixEditWire::from)
.collect::<Vec<_>>(),
applies_cleanly: conflicts_with.is_empty(),
conflicts_with,
}
})
.collect::<Vec<_>>();
let present_safety = candidates
.iter()
.map(|candidate| candidate.repair.safety)
.collect::<BTreeSet<_>>();
let safety_levels = RepairSafety::ALL
.iter()
.copied()
.filter(|safety| present_safety.contains(safety))
.map(|safety| safety.as_str().to_string())
.collect::<Vec<_>>();
Ok(RepairPlan {
schema_version: FIX_PLAN_SCHEMA_VERSION,
path: target_string,
diagnostics,
repairs,
skipped_files,
safety_levels,
})
}
fn collect_file_candidates(
analysis: &mut AnalysisDatabase,
file: &Path,
safety_ceiling: Option<RepairSafety>,
cross_file_imports: &HashSet<String>,
module_graph: &harn_modules::ModuleGraph,
options: FixOptions,
out: &mut Vec<RepairCandidate>,
) -> Result<(), SkippedFileWire> {
let path_str = file.to_string_lossy().into_owned();
let mut config = package::load_check_config(Some(file));
commands::check::apply_harn_lint_config(file, &mut config);
let output = commands::check::analyze_file(analysis, file, &config, module_graph)
.map_err(|error| skipped_file_from_analysis_error(path_str.clone(), error))?;
let source = output.source;
let program = output.program;
let exported_names = module_graph
.exports_for_module(file)
.into_iter()
.collect::<BTreeSet<_>>();
let ambient_context = AmbientRepairContext {
cross_module_importer_count: module_graph.importers_of(file).len(),
};
let deferred_capability_mismatches = if options.capability_migrations_only {
deferred_capability_mismatch_spans(&output.diagnostics, &program, &source, &exported_names)
} else {
BTreeSet::new()
};
for diag in &output.diagnostics {
if harn_lint::type_diagnostic_lint_disabled(diag, &config.disable_rules) {
continue;
}
let unresolved_name = match diag.details.as_ref() {
Some(DiagnosticDetails::UnresolvedName { name }) => Some(name.clone()),
_ => None,
};
let (mut expected_type, actual_type) = match diag.details.as_ref() {
Some(DiagnosticDetails::TypeMismatch { expected, actual }) => {
(Some(expected.clone()), Some(actual.clone()))
}
_ => (None, None),
};
if let Some(DiagnosticDetails::CallArity {
parameter_types,
actual: 0,
..
}) = diag.details.as_ref()
{
expected_type = parameter_types.first().cloned().flatten().filter(|expected| {
matches!(expected, TypeExpr::Named(name) if name == "Harness" || harn_builtin_meta::CapabilityId::from_type_name(name).is_some())
});
}
if diag
.span
.is_some_and(|span| deferred_capability_mismatches.contains(&(span.start, span.end)))
{
continue;
}
let synthesized = (diag.code == Code::UndefinedVariable
&& unresolved_name.as_deref() == Some("harness"))
.then(|| {
synthesize_missing_harness_repair(
diag.span?,
&source,
&program,
&exported_names,
&ambient_context,
)
})
.flatten();
let synthesized = synthesized.or_else(|| {
if diag.code == Code::OrchestrationArity {
return synthesize_missing_zero_arg_capability_repair(
diag.span?,
expected_type.as_ref()?,
&source,
&program,
);
}
if diag.code != Code::ArgumentTypeMismatch {
return None;
}
if matches!(expected_type.as_ref(), Some(TypeExpr::Named(expected)) if expected == "Harness") {
return synthesize_missing_root_argument_repair(
diag.span?,
&source,
&program,
&exported_names,
&ambient_context,
);
}
synthesize_missing_capability_argument_repair(
diag.span?,
expected_type.as_ref()?,
actual_type.as_ref()?,
&source,
&program,
)
.or_else(|| {
let TypeExpr::Named(expected) = expected_type.as_ref()? else {
return None;
};
harn_builtin_meta::CapabilityId::from_type_name(expected)?;
synthesize_missing_root_argument_repair(
diag.span?,
&source,
&program,
&exported_names,
&ambient_context,
)
})
});
let (repair, edits, impact) = if let Some(repair) = synthesized {
repair
} else {
let Some(repair) = diag.repair.clone() else {
continue;
};
(
repair,
diag.fix.clone().unwrap_or_default(),
RepairImpactWire::generic(),
)
};
if !repair_allowed(&repair, safety_ceiling) {
continue;
}
if options.capability_migrations_only && !is_capability_migration_repair(&repair) {
continue;
}
out.push(RepairCandidate {
file: path_str.clone(),
source: "typecheck",
severity: severity_label(diag.severity),
code: diag.code,
message: diag.message.clone(),
unresolved_name,
expected_type,
span: diag.span,
repair,
impact,
edits,
});
}
let lint_context = FixLintContext::load(file);
let lint_options = lint_context.options(file);
let lint_diagnostics = harn_lint::lint_with_module_graph(
&program,
&config.disable_rules,
Some(&source),
cross_file_imports,
module_graph,
file,
&lint_options,
);
for diag in &lint_diagnostics {
let Some((repair, edits, impact)) =
lint_candidate_repair(diag, file, &source, &program, module_graph)
else {
continue;
};
if !repair_allowed(&repair, safety_ceiling) {
continue;
}
if options.capability_migrations_only && !is_capability_migration_repair(&repair) {
continue;
}
out.push(RepairCandidate {
file: path_str.clone(),
source: "lint",
severity: lint_severity_label(diag.severity),
code: diag.code,
message: diag.message.clone(),
unresolved_name: None,
expected_type: None,
span: Some(diag.span),
repair,
impact,
edits,
});
}
if !options.capability_migrations_only {
collect_preflight(
file,
&source,
&program,
&config,
module_graph,
safety_ceiling,
out,
);
}
Ok(())
}
fn deferred_capability_mismatch_spans(
diagnostics: &[harn_parser::TypeDiagnostic],
program: &[SNode],
source: &str,
exported_names: &BTreeSet<String>,
) -> BTreeSet<(usize, usize)> {
let calls = collect_callable_infos(program, source, exported_names)
.into_iter()
.flat_map(|callable| callable.calls)
.collect::<Vec<_>>();
let mut by_call = BTreeMap::<(usize, usize), Vec<(usize, Span)>>::new();
for diagnostic in diagnostics {
if diagnostic.code != Code::ArgumentTypeMismatch {
continue;
}
let Some(DiagnosticDetails::TypeMismatch {
expected: TypeExpr::Named(expected),
..
}) = diagnostic.details.as_ref()
else {
continue;
};
if expected != "Harness"
&& harn_builtin_meta::CapabilityId::from_type_name(expected.as_str()).is_none()
{
continue;
}
let Some(span) = diagnostic.span else {
continue;
};
let Some((call, argument_index)) = calls.iter().find_map(|call| {
call.args
.iter()
.position(|argument| argument.start == span.start && argument.end == span.end)
.map(|index| (call, index))
}) else {
continue;
};
by_call
.entry((call.span.start, call.span.end))
.or_default()
.push((argument_index, span));
}
let mut deferred = BTreeSet::new();
for mismatches in by_call.values_mut() {
mismatches.sort_by_key(|(argument_index, span)| (*argument_index, span.start, span.end));
deferred.extend(
mismatches
.iter()
.skip(1)
.map(|(_, span)| (span.start, span.end)),
);
}
deferred
}
fn skipped_file_from_analysis_error(
path: String,
error: commands::check::FileAnalysisError,
) -> SkippedFileWire {
let (reason, diagnostic) = match error {
commands::check::FileAnalysisError::Read(error) => (
"read_error",
SkippedFileDiagnosticWire {
source: "io",
severity: "error",
code: None,
message: format!("failed to read {path}: {error}"),
span: None,
help: None,
},
),
commands::check::FileAnalysisError::Analysis(AnalysisError::MissingSource(id)) => (
"analysis_error",
SkippedFileDiagnosticWire {
source: "analysis",
severity: "error",
code: None,
message: format!("missing analysis source {}", id.as_str()),
span: None,
help: None,
},
),
commands::check::FileAnalysisError::Analysis(AnalysisError::Lex { error, .. }) => (
"lex_error",
SkippedFileDiagnosticWire {
source: "lexer",
severity: "error",
code: Some(harn_parser::diagnostic::lexer_error_code(&error).to_string()),
message: error.to_string(),
span: Some(SpanWire::from(commands::check::span_from_lexer_error(
&error,
))),
help: None,
},
),
commands::check::FileAnalysisError::Analysis(AnalysisError::Parse { errors, .. }) => {
let error = errors
.first()
.expect("analysis parse errors should include at least one error");
(
"parse_error",
SkippedFileDiagnosticWire {
source: "parser",
severity: "error",
code: Some(harn_parser::diagnostic::parser_error_code(error).to_string()),
message: harn_parser::diagnostic::parser_error_message(error),
span: Some(SpanWire::from(commands::check::span_from_parser_error(
error,
))),
help: harn_parser::diagnostic::parser_error_help(error).map(str::to_string),
},
)
}
};
SkippedFileWire {
path,
reason,
diagnostics: vec![diagnostic],
}
}
fn is_capability_migration_repair(repair: &Repair) -> bool {
is_capability_migration_repair_id(repair.id.as_str())
}
fn is_capability_migration_repair_id(id: &str) -> bool {
id.starts_with("bindings/thread-harness")
|| matches!(
id,
"bindings/thread-missing-harness"
| "bindings/thread-root-argument"
| "bindings/prepend-capability-argument"
| "bindings/attenuate-harness"
| "bindings/attenuate-capability-argument"
| "bindings/attenuate-capability-bundle-argument"
| "imports/remove-retired-testing-helper"
)
}
fn is_whole_program_superseded_repair(repair: &Repair) -> bool {
let id = repair.id.as_str();
id.starts_with("bindings/thread-harness")
|| matches!(
id,
"bindings/thread-missing-harness"
| "bindings/thread-root-argument"
| "bindings/prepend-capability-argument"
)
}
fn lint_candidate_repair(
diag: &harn_lint::LintDiagnostic,
file: &Path,
source: &str,
program: &[SNode],
module_graph: &harn_modules::ModuleGraph,
) -> Option<(Repair, Vec<FixEdit>, RepairImpactWire)> {
if ambient_capability_handle(diag.code).is_some() {
let exported_names = module_graph
.exports_for_module(file)
.into_iter()
.collect::<BTreeSet<_>>();
let context = AmbientRepairContext {
cross_module_importer_count: module_graph.importers_of(file).len(),
};
return synthesize_ambient_capability_repair(
diag,
source,
program,
&exported_names,
&context,
);
}
let repair = diag.repair()?;
Some((
repair,
diag.fix.clone().unwrap_or_default(),
RepairImpactWire::generic(),
))
}
fn synthesize_ambient_capability_repair(
diag: &harn_lint::LintDiagnostic,
source: &str,
program: &[SNode],
exported_names: &BTreeSet<String>,
context: &AmbientRepairContext,
) -> Option<(Repair, Vec<FixEdit>, RepairImpactWire)> {
ambient_capability_handle(diag.code)?;
let infos = collect_callable_infos(program, source, exported_names);
let owner_idx = infos.iter().position(|info| {
info.ambient_capability_calls.iter().any(|call| {
call.code == diag.code
&& call.span.start == diag.span.start
&& call.span.end == diag.span.end
})
})?;
let reverse_callers = build_reverse_callers(&infos);
let owner = &infos[owner_idx];
let ambient = owner.ambient_capability_calls.iter().find(|call| {
call.code == diag.code
&& call.span.start == diag.span.start
&& call.span.end == diag.span.end
})?;
let replacement_binding = owner
.harness_binding
.clone()
.or_else(|| harness_param_name_for_insert(owner).map(str::to_string));
let replacement =
ambient_replacement(diag.code, &ambient.name, replacement_binding.as_deref())?;
let mut edits = ambient_call_rewrite(source, ambient, &replacement)?;
if owner.harness_binding.is_some() {
return Some((
Repair::from_template(diag.code.repair_template()?),
edits,
RepairImpactWire::local_ambient("existing-harness-binding"),
));
}
let needed = propagate_harness_requirements(&infos, &reverse_callers, owner_idx);
let primary_call_start = owner
.ambient_capability_calls
.iter()
.filter(|call| call.code == diag.code)
.map(|call| call.span.start)
.min()
.unwrap_or(diag.span.start);
if diag.span.start != primary_call_start {
return Some((
repair_for_ambient_capability_plan(diag.code, &infos, &reverse_callers, &needed)?,
edits,
repair_impact_for_signature_threading(
&infos,
&needed,
context.cross_module_importer_count,
),
));
}
for &idx in &needed {
let info = &infos[idx];
edits.push(add_harness_param_edit(source, info)?);
}
for (callee_idx, callers) in reverse_callers.iter().enumerate() {
if !needed.contains(&callee_idx) {
continue;
}
for &(caller_idx, call_idx) in callers {
let caller = &infos[caller_idx];
let arg_name = match caller.harness_binding.as_deref() {
Some(binding) => binding,
None if needed.contains(&caller_idx) => harness_param_name_for_insert(caller)?,
None => continue,
};
edits.push(add_call_argument_edit(
source,
&caller.calls[call_idx].span,
arg_name,
)?);
}
}
Some((
repair_for_ambient_capability_plan(diag.code, &infos, &reverse_callers, &needed)?,
dedupe_edits(edits),
repair_impact_for_signature_threading(&infos, &needed, context.cross_module_importer_count),
))
}
fn synthesize_missing_capability_argument_repair(
span: Span,
expected: &TypeExpr,
actual: &TypeExpr,
source: &str,
program: &[SNode],
) -> Option<(Repair, Vec<FixEdit>, RepairImpactWire)> {
let expected_name = match expected {
TypeExpr::Named(name) => Some(name.as_str()),
_ => None,
};
let capability = expected_name.and_then(harn_builtin_meta::CapabilityId::from_type_name);
let mut matched_argument = None;
visit::walk_program(program, &mut |node| {
let Node::FunctionCall { args, .. } = &node.node else {
return;
};
for candidate in args {
if candidate.span.start == span.start && candidate.span.end == span.end {
if let Node::Identifier(binding) = &candidate.node {
matched_argument = Some((candidate.span, binding.clone()));
}
}
}
});
if matches!(actual, TypeExpr::Named(name) if name == "Harness") {
let (argument_span, binding) = matched_argument?;
if let Some(replacement) = capability_bundle_literal(expected, &binding) {
return Some((
Repair {
id: harn_parser::RepairId::from_owned(
"bindings/attenuate-capability-bundle-argument".to_string(),
),
summary:
"Pass the closed capability bundle required by the attenuated callable"
.to_string(),
safety: RepairSafety::SurfaceChanging,
},
vec![FixEdit {
span: argument_span,
replacement,
}],
RepairImpactWire::local_ambient("attenuate-capability-bundle-argument"),
));
}
let capability = capability?;
let expected_name = expected_name?;
return Some((
Repair {
id: harn_parser::RepairId::from_owned(
"bindings/attenuate-capability-argument".to_string(),
),
summary: format!(
"Pass the `{expected_name}` sub-grant required by the attenuated callable"
),
safety: RepairSafety::SurfaceChanging,
},
vec![FixEdit {
span: argument_span,
replacement: format!("{binding}.{}", capability.field_name()),
}],
RepairImpactWire::local_ambient("attenuate-capability-argument"),
));
}
let _capability = capability?;
let expected_name = expected_name?;
let argument = capability_argument_for_span(program, span, expected_name)?;
let edit = insert_call_argument_before_span(source, program, span, &argument)?;
Some((
Repair {
id: harn_parser::RepairId::from_owned(
"bindings/prepend-capability-argument".to_string(),
),
summary: format!(
"Pass the explicit `{expected_name}` capability required by the migrated callable"
),
safety: RepairSafety::SurfaceChanging,
},
vec![edit],
RepairImpactWire::local_ambient("prepend-capability-argument"),
))
}
fn synthesize_missing_zero_arg_capability_repair(
call_span: Span,
expected: &TypeExpr,
source: &str,
program: &[SNode],
) -> Option<(Repair, Vec<FixEdit>, RepairImpactWire)> {
let TypeExpr::Named(expected_name) = expected else {
return None;
};
harn_builtin_meta::CapabilityId::from_type_name(expected_name)?;
let argument = capability_argument_for_span(program, call_span, expected_name)?;
let edit = add_call_argument_edit(source, &call_span, &argument)?;
Some((
Repair {
id: harn_parser::RepairId::from_owned(
"bindings/prepend-capability-argument".to_string(),
),
summary: format!(
"Pass the explicit `{expected_name}` capability required by the migrated callable"
),
safety: RepairSafety::SurfaceChanging,
},
vec![edit],
RepairImpactWire::local_ambient("prepend-capability-argument"),
))
}
fn capability_bundle_literal(expected: &TypeExpr, binding: &str) -> Option<String> {
let TypeExpr::Shape(fields) = expected else {
return None;
};
let fields = fields
.iter()
.map(|field| {
if field.optional {
return None;
}
let TypeExpr::Named(type_name) = &field.type_expr else {
return None;
};
let capability = harn_builtin_meta::CapabilityId::from_type_name(type_name)?;
(capability.field_name() == field.name)
.then(|| format!("{}: {binding}.{}", field.name, field.name))
})
.collect::<Option<Vec<_>>>()?;
(!fields.is_empty()).then(|| format!("{{{}}}", fields.join(", ")))
}
fn synthesize_missing_harness_repair(
span: Span,
source: &str,
program: &[SNode],
exported_names: &BTreeSet<String>,
context: &AmbientRepairContext,
) -> Option<(Repair, Vec<FixEdit>, RepairImpactWire)> {
let infos = collect_callable_infos(program, source, exported_names);
let owner_idx = infos
.iter()
.enumerate()
.filter(|(_, info)| info.span.start <= span.start && info.span.end >= span.end)
.min_by_key(|(_, info)| info.span.end.saturating_sub(info.span.start))
.map(|(index, _)| index)?;
if infos[owner_idx].harness_binding.is_some() {
return None;
}
let reverse_callers = build_reverse_callers(&infos);
let needed = propagate_harness_requirements(&infos, &reverse_callers, owner_idx);
let mut edits = Vec::new();
for &idx in &needed {
edits.push(add_harness_param_edit(source, &infos[idx])?);
}
for (callee_idx, callers) in reverse_callers.iter().enumerate() {
if !needed.contains(&callee_idx) {
continue;
}
for &(caller_idx, call_idx) in callers {
let caller = &infos[caller_idx];
let arg_name = match caller.harness_binding.as_deref() {
Some(binding) => binding,
None if needed.contains(&caller_idx) => harness_param_name_for_insert(caller)?,
None => continue,
};
edits.push(add_call_argument_edit(
source,
&caller.calls[call_idx].span,
arg_name,
)?);
}
}
Some((
Repair {
id: harn_parser::RepairId::from_owned("bindings/thread-missing-harness".to_string()),
summary:
"Thread the explicit Harness grant through this callable and its local callers"
.to_string(),
safety: RepairSafety::SurfaceChanging,
},
dedupe_edits(edits),
repair_impact_for_signature_threading(&infos, &needed, context.cross_module_importer_count),
))
}
fn synthesize_missing_root_argument_repair(
span: Span,
source: &str,
program: &[SNode],
exported_names: &BTreeSet<String>,
context: &AmbientRepairContext,
) -> Option<(Repair, Vec<FixEdit>, RepairImpactWire)> {
let infos = collect_callable_infos(program, source, exported_names);
let owner_idx = infos
.iter()
.enumerate()
.filter(|(_, info)| info.span.start <= span.start && info.span.end >= span.end)
.min_by_key(|(_, info)| info.span.end.saturating_sub(info.span.start))
.map(|(index, _)| index)?;
if let Some(owner_binding) = infos[owner_idx].harness_binding.as_deref() {
let edit = insert_call_argument_before_span(source, program, span, owner_binding)?;
return Some((
Repair {
id: harn_parser::RepairId::from_owned("bindings/thread-root-argument".to_string()),
summary: "Pass the root Harness required by the migrated callable".to_string(),
safety: RepairSafety::SurfaceChanging,
},
vec![edit],
RepairImpactWire::local_ambient("existing-root-harness-binding"),
));
}
let reverse_callers = build_reverse_callers(&infos);
let needed = propagate_harness_requirements(&infos, &reverse_callers, owner_idx);
let owner_binding = harness_param_name_for_insert(&infos[owner_idx])?;
let mut edits = vec![insert_call_argument_before_span(
source,
program,
span,
owner_binding,
)?];
for &idx in &needed {
if infos[idx].harness_binding.is_none() {
edits.push(add_harness_param_edit(source, &infos[idx])?);
}
}
for (callee_idx, callers) in reverse_callers.iter().enumerate() {
if !needed.contains(&callee_idx) {
continue;
}
for &(caller_idx, call_idx) in callers {
let caller = &infos[caller_idx];
let argument = match caller.harness_binding.as_deref() {
Some(binding) => binding,
None if needed.contains(&caller_idx) => harness_param_name_for_insert(caller)?,
None => continue,
};
edits.push(add_call_argument_edit(
source,
&caller.calls[call_idx].span,
argument,
)?);
}
}
Some((
Repair {
id: harn_parser::RepairId::from_owned("bindings/thread-root-argument".to_string()),
summary: "Thread the root Harness required by the migrated callable".to_string(),
safety: RepairSafety::SurfaceChanging,
},
dedupe_edits(edits),
repair_impact_for_signature_threading(&infos, &needed, context.cross_module_importer_count),
))
}
fn repair_impact_for_signature_threading(
infos: &[CallableInfo],
needed: &BTreeSet<usize>,
cross_module_importer_count: usize,
) -> RepairImpactWire {
let signature_changes = needed
.iter()
.map(|&idx| {
let info = &infos[idx];
SignatureChangeWire {
callable: info.name.clone(),
is_exported: info.is_exported,
is_entrypoint: info.name == "main",
}
})
.collect::<Vec<_>>();
RepairImpactWire::signature_threading(signature_changes, cross_module_importer_count)
}
fn dedupe_edits(edits: Vec<FixEdit>) -> Vec<FixEdit> {
let mut seen = BTreeSet::new();
let mut out = Vec::new();
for edit in edits {
let key = (edit.span.start, edit.span.end, edit.replacement.clone());
if seen.insert(key) {
out.push(edit);
}
}
out
}
fn dedupe_wire_edits(edits: &[FixEditWire]) -> Vec<FixEditWire> {
let mut seen = BTreeSet::new();
let mut out = Vec::new();
for edit in edits {
let key = (edit.span.start, edit.span.end, edit.replacement.clone());
if seen.insert(key) {
out.push(edit.clone());
}
}
out
}
fn replace_identifier_within_span_fix(
source: &str,
span: Span,
old: &str,
new: &str,
) -> Option<Vec<FixEdit>> {
let region = source.get(span.start..span.end)?;
let offset = region.match_indices(old).find_map(|(offset, _)| {
let before_ok = offset == 0
|| !region
.as_bytes()
.get(offset.wrapping_sub(1))
.is_some_and(|byte| is_ident_byte(*byte));
let end = offset + old.len();
let after_ok = region
.as_bytes()
.get(end)
.is_none_or(|byte| !is_ident_byte(*byte));
(before_ok && after_ok).then_some(offset)
})?;
let start = span.start + offset;
Some(vec![FixEdit {
span: Span::with_offsets(start, start + old.len(), span.line, span.column + offset),
replacement: new.to_string(),
}])
}
fn is_ident_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
fn collect_preflight(
file: &Path,
source: &str,
program: &[SNode],
config: &CheckConfig,
module_graph: &harn_modules::ModuleGraph,
safety_ceiling: Option<RepairSafety>,
out: &mut Vec<RepairCandidate>,
) {
let preflight_severity = PreflightSeverity::from_opt(config.preflight_severity.as_deref());
if preflight_severity == PreflightSeverity::Off {
return;
}
for diag in preflight_diagnostics(file, source, program, config, module_graph) {
if commands::check::is_preflight_allowed(&diag.tags, &config.preflight_allow) {
continue;
}
let Some(template) = diag.code.repair_template() else {
continue;
};
let repair = Repair::from_template(template);
if !repair_allowed(&repair, safety_ceiling) {
continue;
}
out.push(RepairCandidate {
file: diag.path,
source: "preflight",
severity: match preflight_severity {
PreflightSeverity::Warning => "warning",
PreflightSeverity::Error => "error",
PreflightSeverity::Off => unreachable!(),
},
code: diag.code,
message: diag.message,
unresolved_name: None,
expected_type: None,
span: Some(diag.span),
repair,
impact: RepairImpactWire::generic(),
edits: Vec::new(),
});
}
}
fn repair_allowed(repair: &Repair, safety_ceiling: Option<RepairSafety>) -> bool {
safety_ceiling
.map(|ceiling| repair.safety.is_at_most(ceiling))
.unwrap_or(true)
}
fn detect_conflicts(candidates: &[RepairCandidate]) -> Vec<Vec<usize>> {
let mut conflicts = vec![Vec::new(); candidates.len()];
for left in 0..candidates.len() {
for right in (left + 1)..candidates.len() {
if candidates_overlap(&candidates[left], &candidates[right]) {
conflicts[left].push(right);
conflicts[right].push(left);
}
}
}
conflicts
}
fn candidates_overlap(left: &RepairCandidate, right: &RepairCandidate) -> bool {
if left.file != right.file {
return false;
}
left.edits.iter().any(|left_edit| {
right
.edits
.iter()
.any(|right_edit| edits_conflict(left_edit, right_edit))
})
}
fn edits_conflict(left: &FixEdit, right: &FixEdit) -> bool {
if left.span == right.span && left.replacement == right.replacement {
return false;
}
let same_zero_width = left.span.start == left.span.end
&& right.span.start == right.span.end
&& left.span.start == right.span.start;
if same_zero_width {
return left.replacement != right.replacement;
}
left.span.start < right.span.end && left.span.end > right.span.start
}
fn severity_label(severity: DiagnosticSeverity) -> &'static str {
match severity {
DiagnosticSeverity::Error => "error",
DiagnosticSeverity::Warning => "warning",
}
}
fn lint_severity_label(severity: LintSeverity) -> &'static str {
match severity {
LintSeverity::Info => "info",
LintSeverity::Warning => "warning",
LintSeverity::Error => "error",
}
}
impl From<Span> for SpanWire {
fn from(span: Span) -> Self {
SpanWire {
start: span.start,
end: span.end,
line: span.line,
column: span.column,
end_line: span.end_line,
}
}
}
impl From<&FixEdit> for FixEditWire {
fn from(edit: &FixEdit) -> Self {
FixEditWire {
span: SpanWire::from(edit.span),
replacement: edit.replacement.clone(),
}
}
}
impl From<&Repair> for RepairMetadataWire {
fn from(repair: &Repair) -> Self {
RepairMetadataWire {
id: repair.id.as_str().to_string(),
summary: repair.summary.clone(),
safety: repair.safety.as_str().to_string(),
}
}
}
#[cfg(test)]
#[path = "fix/capability_apply_tests.rs"]
mod capability_apply_tests;
#[cfg(test)]
mod tests;