weavatrix-refactor-plan 0.1.1

Evidence metadata, validation profiles, and canonical fingerprints for Weavatrix refactor plans
Documentation
use crate::{EditPlan, PlanAnnotations, PlanError, PlanErrorCode, RefactorOperation, RefactorPlan};
use blazingly_json::Value;

const LEGACY_TOP_LEVEL_FIELDS: [&str; 12] = [
    "schemaVersion",
    "operation",
    "files",
    "completeness",
    "createdAt",
    "graphRevision",
    "completenessProof",
    "uncertainReferences",
    "notModified",
    "warnings",
    "followUp",
    "syntaxCheck",
];

pub(crate) fn from_text_edit_plan(plan: EditPlan) -> Result<RefactorPlan, PlanError> {
    let evidence = extract_annotations(&plan)?;
    Ok(RefactorPlan {
        schema_version: crate::REFACTOR_PLAN_SCHEMA.to_owned(),
        operation: plan.operation,
        operations: plan
            .files
            .into_iter()
            .map(RefactorOperation::Modify)
            .collect(),
        completeness: plan.completeness,
        evidence,
    })
}

pub(crate) fn into_text_edit_plan(plan: RefactorPlan) -> Result<EditPlan, PlanError> {
    let mut files = Vec::with_capacity(plan.operations.len());
    for (index, operation) in plan.operations.into_iter().enumerate() {
        let RefactorOperation::Modify(file) = operation else {
            return Err(PlanError::new(
                PlanErrorCode::NotTextOnly,
                "legacy edit-plan conversion requires only modify operations",
            )
            .at_operation(index));
        };
        files.push(file);
    }
    let mut edit = EditPlan::new(plan.operation, files);
    edit.completeness = plan.completeness;
    attach_annotations(edit, &plan.evidence)
}

/// Reads typed evidence from a legacy edit plan without changing it.
///
/// Reads `plan.extensions`, which is where the whole v1 annotation set travels.
/// A plan decoded through [`crate::weavatrix_edit::DeclaredEditPlan`] carries an
/// empty map, and this returns `Ok(PlanAnnotations::default())` — indistinguishable
/// from a producer that genuinely sent no annotations.
pub fn extract_annotations(plan: &EditPlan) -> Result<PlanAnnotations, PlanError> {
    let object = plan.extensions.clone().into();
    blazingly_json::from_value(Value::Object(object)).map_err(|error| {
        PlanError::new(
            PlanErrorCode::EvidenceMalformed,
            format!("plan evidence is malformed: {error}"),
        )
    })
}

/// Attaches typed evidence to a legacy edit plan without overwriting fields.
///
/// The [`PlanErrorCode::ExtensionConflict`] guard below compares against the
/// plan's existing `extensions`. On a plan decoded through
/// [`crate::weavatrix_edit::DeclaredEditPlan`] that map is empty, so the guard
/// cannot fire and a genuinely colliding wire document is accepted with its
/// original members already gone.
pub fn attach_annotations(
    mut plan: EditPlan,
    annotations: &PlanAnnotations,
) -> Result<EditPlan, PlanError> {
    validate_evidence_extension_keys(annotations)?;
    let Value::Object(object) = encode_evidence(annotations)? else {
        return Err(json_shape_error());
    };
    for (key, value) in object {
        if plan.extensions.insert(key.clone(), value).is_some() {
            return Err(PlanError::new(
                PlanErrorCode::ExtensionConflict,
                format!("plan extension {key:?} already exists"),
            )
            .at_field(key));
        }
    }
    Ok(plan)
}

/// Removes all legacy top-level extensions and decodes them as evidence.
///
/// Reads `plan.extensions`; see [`extract_annotations`] for what a plan decoded
/// through [`crate::weavatrix_edit::DeclaredEditPlan`] yields.
pub fn detach_annotations(mut plan: EditPlan) -> Result<(EditPlan, PlanAnnotations), PlanError> {
    let annotations = extract_annotations(&plan)?;
    plan.extensions.clear();
    Ok((plan, annotations))
}

/// Replaces legacy edit-plan evidence while preserving frozen edit fields.
///
/// Reads `plan.extensions` through [`detach_annotations`], so the same
/// declared-only decode caveat applies.
pub fn replace_annotations(
    plan: EditPlan,
    annotations: &PlanAnnotations,
) -> Result<EditPlan, PlanError> {
    let (base, _) = detach_annotations(plan)?;
    attach_annotations(base, annotations)
}

fn encode_evidence(annotations: &PlanAnnotations) -> Result<Value, PlanError> {
    blazingly_json::to_value(annotations).map_err(|error| {
        PlanError::new(
            PlanErrorCode::JsonEncoding,
            format!("could not encode plan evidence: {error}"),
        )
    })
}

fn validate_evidence_extension_keys(annotations: &PlanAnnotations) -> Result<(), PlanError> {
    check_keys(
        &annotations.extensions,
        &LEGACY_TOP_LEVEL_FIELDS,
        "evidence",
    )?;
    if let Some(proof) = &annotations.completeness_proof {
        check_keys(
            &proof.extensions,
            &["scope", "planner"],
            "completenessProof",
        )?;
        check_keys(
            &proof.scope.extensions,
            &["kind", "value", "roots", "languages"],
            "completenessProof.scope",
        )?;
        check_keys(
            &proof.planner.extensions,
            &["name", "version", "backend", "backendVersion"],
            "completenessProof.planner",
        )?;
    }
    for (index, reference) in annotations
        .uncertain_references
        .iter()
        .flatten()
        .enumerate()
    {
        check_keys(
            &reference.extensions,
            &[
                "path", "file", "line", "subject", "kind", "reason", "excerpt",
            ],
            &format!("uncertainReferences[{index}]"),
        )?;
        if let Some(subject) = &reference.subject {
            check_keys(
                &subject.extensions,
                &["kind", "value"],
                &format!("uncertainReferences[{index}].subject"),
            )?;
        }
    }
    for (index, entry) in annotations.not_modified.iter().flatten().enumerate() {
        check_keys(
            &entry.extensions,
            &["path", "file", "subject", "operationIndex", "reason"],
            &format!("notModified[{index}]"),
        )?;
        if let Some(subject) = &entry.subject {
            check_keys(
                &subject.extensions,
                &["kind", "value"],
                &format!("notModified[{index}].subject"),
            )?;
        }
    }
    Ok(())
}

fn check_keys(
    extensions: &std::collections::BTreeMap<String, Value>,
    reserved: &[&str],
    field: &str,
) -> Result<(), PlanError> {
    if let Some(key) = extensions
        .keys()
        .find(|key| reserved.contains(&key.as_str()))
    {
        return Err(PlanError::new(
            PlanErrorCode::ExtensionConflict,
            format!("extension {key:?} collides with a reserved field"),
        )
        .at_field(format!("{field}.{key}")));
    }
    Ok(())
}

fn json_shape_error() -> PlanError {
    PlanError::new(
        PlanErrorCode::JsonEncoding,
        "plan evidence did not encode as an object",
    )
}