n7n-plugin-host 0.1.0

wasmtime-бекенд для crate::registry::PluginRegistry (n7n-harness) — друга (wasm-компонентна) реалізація слотів detector/verify/ast_facts/t0, поруч з in-process-бекендом §3.12 пункту 16а
//! Wasm-бекенд поверхні `detector` (`wit/detector.wit`, world
//! `detector-plugin`) — друга реалізація [`harness::pipeline::DetectFn`]
//! поруч з in-process-замиканням, яке `harness::registry::PluginRegistry`
//! уже приймає (пункт 16а). [`WasmDetector::into_detect_fn`] — точка
//! підключення: `PluginRegistry::new`/`replace_detector` беруть її
//! результат так само, як `Arc::new(|| ...)`.

use std::path::{Path, PathBuf};
use std::sync::Arc;

use wasmtime::component::{Component, HasSelf, Linker, ResourceTable};
use wasmtime::{Engine, Store};

use harness::pipeline::{DetectFn, Violation};

use crate::engine::new_engine;
use crate::host_state::HostState;
use crate::wasi_ctx::read_only_repo_ctx;

// `wasmtime::component::bindgen!` — host-сторона генератора WIT-байндінгів
// живе В САМОМУ `wasmtime` під фічею `component-model` (задача, розділ
// "Залежності `plugin-host`"): окремого `wit-bindgen` у `Cargo.toml` не
// треба. `path` — відносно `CARGO_MANIFEST_DIR` цього крейта
// (`crates/plugin-host`), тобто корінь `wit/` репозиторію напряму: жодного
// дубльованого `wit/` усередині `plugin-host` — контракт лишається одним
// джерелом правди (пункт 16а, doc-коментар `wit/types.wit`).
//
// Кожна з чотирьох поверхонь (`detector`/`verify`/`ast_facts`/`t0`) робить
// СВІЙ `bindgen!` у СВОЄМУ приватному `mod bindings` — одна макро-
// інвокація на файл, а не одна спільна на весь world-набір: `bindgen!`
// генерує одну структуру світу на виклик (тут — `DetectorPlugin`), і
// чотири світи цього пакета експортують РІЗНІ інтерфейси (`detector` проти
// `verify` проти...), тож спільний виклик однаково довелось би повторити
// чотири рази з різним `world:`.
mod bindings {
    wasmtime::component::bindgen!({
        path: "../../wit",
        world: "detector-plugin",
    });
}

/// Дзеркало `record violation` (`wit/types.wit`) → [`Violation`] —
/// РЕАЛЬНИЙ тип, який `bindgen!` вивів із `.wit`-файлів (на відміну від
/// ручного дзеркала `crates/harness/src/registry.rs::wit_conformance`,
/// яке лише перевіряє ФОРМУ; тут — фактичний конвертер типу, який
/// `wasmtime` згенерував сам). Якщо `record violation` і
/// `harness::pipeline::Violation` розійдуться полями, це впаде тут
/// компіляцією, не тестом.
impl From<bindings::exports::n7n::fix_deps::detector::Violation> for Violation {
    fn from(v: bindings::exports::n7n::fix_deps::detector::Violation) -> Self {
        Violation {
            file: PathBuf::from(v.file),
            line: v.line.map(|l| l as usize),
            message: v.message,
        }
    }
}

/// Реалізація вужчого `host-fs`-імпорту (`wit/host.wit`) для поверхні
/// `detector` — читання ЗАВЖДИ, запису немає в самій сигнатурі
/// (`host-fs.read-file` не має аналога "write"). Кожен bindgen!-модуль
/// генерує СВІЙ окремий трейт `Host` (чотири номінально різних типи з
/// однаковою формою — bindgen! не дедуплікує їх між макро-інвокаціями),
/// тож ця імплементація повторюється в кожному з чотирьох файлів
/// (`verify.rs`/`ast_facts.rs`/`t0.rs`) з тим самим однорядковим тілом.
impl bindings::n7n::fix_deps::host_fs::Host for HostState {
    fn read_file(&mut self, path: String) -> Result<String, String> {
        std::fs::read_to_string(self.repo_root.join(path)).map_err(|e| e.to_string())
    }
}

/// Wasm-бекенд одного `detector`-компонента: скомпільований
/// [`Component`]/[`Engine`]/[`Linker`] — кешовані (доккоментар crate-level
/// "інстанс на спробу, без кешу" — кешується лише СКОМПІЛЬОВАНИЙ
/// компонент, не `Instance`), плюс корінь репозиторію для read-only
/// preopen-у.
pub struct WasmDetector {
    engine: Engine,
    component: Component,
    linker: Linker<HostState>,
    repo_root: PathBuf,
}

impl WasmDetector {
    /// Завантажує й компілює `.wasm`-компонент, лінкує WASI p2 (§3.9: guest
    /// на `wasm32-wasip2` фактично потребує `wasi:cli`/`wasi:io`/
    /// `wasi:random`/`wasi:clocks` навіть якщо власний `wit/detector.wit`
    /// їх не імпортує напряму — рантайм `std` для цієї цілі лінкує їх
    /// безумовно) і власний вужчий `host-fs`.
    pub fn from_file(
        repo_root: impl Into<PathBuf>,
        wasm_path: impl AsRef<Path>,
    ) -> wasmtime::Result<Self> {
        let engine = new_engine()?;
        let component = Component::from_file(&engine, wasm_path.as_ref())?;
        let mut linker = Linker::<HostState>::new(&engine);
        wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?;
        bindings::n7n::fix_deps::host_fs::add_to_linker::<_, HasSelf<HostState>>(
            &mut linker,
            |s| s,
        )?;
        Ok(Self {
            engine,
            component,
            linker,
            repo_root: repo_root.into(),
        })
    }

    /// Один прогін: свіжий `Store`+`WasiCtx`+`Instance` — жодного поля
    /// цього типу не переживає виклик, тож наступний виклик фізично не
    /// може успадкувати стан попереднього (доккоментар crate-level).
    fn call(&self) -> wasmtime::Result<Result<Vec<Violation>, String>> {
        let wasi = read_only_repo_ctx(&self.repo_root)?;
        let mut store = Store::new(
            &self.engine,
            HostState {
                wasi,
                table: ResourceTable::new(),
                repo_root: self.repo_root.clone(),
            },
        );
        let instance =
            bindings::DetectorPlugin::instantiate(&mut store, &self.component, &self.linker)?;
        let result = instance.n7n_fix_deps_detector().call_detect(&mut store)?;
        Ok(result.map(|violations| violations.into_iter().map(Violation::from).collect()))
    }

    /// Перетворює на [`DetectFn`] — та сама інʼєкція, яку
    /// `PluginRegistry::new`/`replace_detector` приймають від in-process
    /// бекенда сьогодні (`crates/harness/src/registry.rs`).
    ///
    /// Помилка САМОГО виклику (інстанціювання впало, трап тощо) мапиться в
    /// `Err(String)` — той самий канал, яким `detector.wit` уже описує
    /// "інфраструктурний збій самого виклику" (доккоментар
    /// `wit/detector.wit`), а не мовчазний "чисто".
    #[must_use]
    pub fn into_detect_fn(self) -> DetectFn {
        let this = Arc::new(self);
        Arc::new(move || {
            let this = Arc::clone(&this);
            Box::pin(async move {
                this.call()
                    .unwrap_or_else(|err| Err(format!("wasm-компонент detector: {err}")))
            })
        })
    }
}

#[cfg(test)]
mod tests {
    //! Компіляційний доказ, що `From<bindings::...::Violation> for
    //! Violation` — РЕАЛЬНИЙ конвертер `bindgen!`-типу (доккоментар
    //! `From` вище) — узгоджений із `wit/detector.wit` без потреби в
    //! `.wasm`-компоненті (доккоментар crate-level "Тестовий guest",
    //! сценарій 2: "тести обмеж тим, що можливо без `.wasm`"). Наскрізний
    //! доказ (реальний guest, реальний виклик) — `tests/wasmtime_backend.rs`.
    use super::*;

    #[test]
    fn bindgen_violation_converts_to_harness_violation_field_by_field() {
        let wit_violation = bindings::exports::n7n::fix_deps::detector::Violation {
            file: "src/a.rs".to_string(),
            line: Some(12),
            message: "порушення".to_string(),
        };

        let violation = Violation::from(wit_violation);

        assert_eq!(violation.file, PathBuf::from("src/a.rs"));
        assert_eq!(violation.line, Some(12));
        assert_eq!(violation.message, "порушення");
    }

    #[test]
    fn bindgen_violation_without_a_line_converts_to_none() {
        let wit_violation = bindings::exports::n7n::fix_deps::detector::Violation {
            file: "src/b.rs".to_string(),
            line: None,
            message: "без рядка".to_string(),
        };

        assert_eq!(Violation::from(wit_violation).line, None);
    }
}