weavatrix-refactor-plan 0.1.1

Evidence metadata, validation profiles, and canonical fingerprints for Weavatrix refactor plans
Documentation
//! Pins why this crate must keep decoding legacy envelopes through `EditPlan`.
//!
//! `weavatrix-edit` offers `DeclaredEditPlan`, a decode that skips undeclared
//! members instead of materializing them. It accepts and rejects exactly the
//! same documents, so it is safe for a terminal consumer of declared data. This
//! crate is not one: the entire v1 annotation set is undeclared in the edit
//! envelope, so a declared-only decode would silently erase the evidence and
//! leave this crate's own extension budget with nothing to weigh.
//!
//! These tests fail if that stops being true, which would mean the conversion
//! path had quietly become safe — or unsafe in a new way — without anyone
//! revisiting the decision recorded in `docs/benchmarks.md`.

use weavatrix_refactor_plan::{
    EDIT_PLAN_SCHEMA, EditPlan, PlanErrorCode, PlanEvidence, RefactorPlan,
    weavatrix_edit::DeclaredEditPlan,
};

/// A legacy edit-plan document whose annotations are all undeclared members of
/// the edit envelope, at the plan level (`createdAt`, `graphRevision`,
/// `vendorBlob`) and at the file level (`language`).
fn annotated_legacy_json(vendor_blob: &str) -> String {
    let sha = "0".repeat(64);
    format!(
        r#"{{
            "schemaVersion": "{EDIT_PLAN_SCHEMA}",
            "operation": "rename_symbol",
            "createdAt": "2026-08-02T12:00:00Z",
            "graphRevision": "revision-1",
            "vendorBlob": {vendor_blob},
            "files": [{{
                "path": "src/a.rs",
                "sha256": "{sha}",
                "language": "rust",
                "edits": [{{
                    "startLine": 1, "startChar": 0, "endLine": 1, "endChar": 1,
                    "before": "a", "after": "b", "provenance": "EXACT_LSP"
                }}]
            }}]
        }}"#
    )
}

fn decode_both(json: &str) -> (EditPlan, EditPlan) {
    let capturing: EditPlan = blazingly_json::from_str(json).expect("capturing decode");
    let declared: EditPlan = blazingly_json::from_str::<DeclaredEditPlan>(json)
        .expect("declared-only decode")
        .into();
    (capturing, declared)
}

#[test]
fn declared_only_decode_keeps_every_declared_member_and_drops_every_other() {
    let (capturing, declared) = decode_both(&annotated_legacy_json(r#""vendor""#));

    // Declared data, and therefore edit-level validation, is identical.
    assert_eq!(capturing.operation, declared.operation);
    assert_eq!(capturing.files.len(), declared.files.len());
    assert_eq!(capturing.files[0].path, declared.files[0].path);
    assert_eq!(capturing.files[0].sha256, declared.files[0].sha256);
    assert_eq!(capturing.files[0].edits, declared.files[0].edits);
    assert_eq!(
        capturing
            .validate()
            .expect("capturing validates")
            .total_edits(),
        declared
            .validate()
            .expect("declared validates")
            .total_edits(),
    );

    // Everything this crate reads back out of extensions is gone.
    assert!(capturing.extensions.contains_key("createdAt"));
    assert!(capturing.files[0].extensions.contains_key("language"));
    assert!(declared.extensions.is_empty());
    assert!(declared.files[0].extensions.is_empty());
}

#[test]
fn declared_only_decode_silently_erases_plan_evidence_on_conversion() {
    let (capturing, declared) = decode_both(&annotated_legacy_json(r#""vendor""#));

    let kept = RefactorPlan::from_text_edit_plan(capturing).expect("capturing conversion");
    assert_eq!(
        kept.evidence.created_at.as_deref(),
        Some("2026-08-02T12:00:00Z")
    );
    assert_eq!(kept.evidence.graph_revision.as_deref(), Some("revision-1"));
    assert!(kept.evidence.extensions.contains_key("vendorBlob"));

    // No error and no warning: the evidence is simply absent afterwards.
    let lost = RefactorPlan::from_text_edit_plan(declared).expect("declared-only conversion");
    assert_eq!(lost.evidence, PlanEvidence::default());

    // Same wire bytes, different published fingerprint.
    assert_ne!(
        kept.fingerprint().expect("kept fingerprint"),
        lost.fingerprint().expect("lost fingerprint"),
    );
}

#[test]
fn declared_only_decode_would_make_the_extension_budget_vacuous() {
    let blob = format!("[{}]", vec!["1"; 5_000].join(","));
    let (capturing, declared) = decode_both(&annotated_legacy_json(&blob));

    let kept = RefactorPlan::from_text_edit_plan(capturing).expect("capturing conversion");
    let error = kept
        .validate()
        .expect_err("the extension node budget must reject the blob");
    assert_eq!(error.code(), PlanErrorCode::EvidenceTooLarge);

    // The identical document passes validation once the payload is discarded at
    // decode, because the budget only ever weighs what was captured.
    let lost = RefactorPlan::from_text_edit_plan(declared).expect("declared-only conversion");
    assert!(lost.validate().is_ok());
}