Skip to main content

clawft_kernel/assessment/
mod.rs

1//! Project assessment service for WeftOS kernel.
2//!
3//! Provides automated codebase analysis: file scanning, complexity
4//! detection, >500-line warnings, TODO tracking, and optional
5//! tree-sitter symbol extraction. Supports peer linking for
6//! cross-project comparison and a pluggable analyzer registry
7//! (ADR-023) with 5 built-in analyzers.
8
9pub mod analyzer;
10pub mod analyzers;
11pub mod mesh;
12
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex};
15use std::sync::atomic::{AtomicBool, Ordering};
16
17use async_trait::async_trait;
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use tracing::debug;
21
22use crate::health::HealthStatus;
23use crate::service::{ServiceType, SystemService};
24
25pub use analyzer::{AnalysisContext, Analyzer, AnalyzerRegistry, AssessmentDiff};
26
27// ── Trigger configuration ──────────────────────────────────────
28
29/// Top-level configuration loaded from `.weftos/weave.toml`.
30#[derive(Debug, Clone, Serialize, Deserialize, Default)]
31pub struct AssessmentConfig {
32    /// Project metadata for multi-company namespace isolation.
33    #[serde(default)]
34    pub project: Option<ProjectConfig>,
35    /// Assessment-specific settings.
36    #[serde(default)]
37    pub assessment: Option<AssessmentSettings>,
38}
39
40/// Project-level metadata used for namespace isolation and peer matching.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ProjectConfig {
43    /// Human-readable project name.
44    pub name: String,
45    /// Organisation slug — only projects sharing the same org can link.
46    #[serde(default)]
47    pub org: Option<String>,
48    /// Deployment environment label (development, staging, production).
49    #[serde(default)]
50    pub environment: Option<String>,
51}
52
53/// Assessment section of weave.toml.
54#[derive(Debug, Clone, Serialize, Deserialize, Default)]
55pub struct AssessmentSettings {
56    /// Schema version.
57    #[serde(default)]
58    pub version: Option<u32>,
59    /// Source file configuration.
60    #[serde(default)]
61    pub sources: Option<SourcesConfig>,
62    /// Trigger configuration.
63    #[serde(default)]
64    pub triggers: Option<TriggersConfig>,
65    /// Reporting options.
66    #[serde(default)]
67    pub reporting: Option<ReportingConfig>,
68}
69
70/// Source file patterns.
71#[derive(Debug, Clone, Serialize, Deserialize, Default)]
72pub struct SourcesConfig {
73    #[serde(default)]
74    pub files: Option<FilePatterns>,
75}
76
77/// Glob patterns for file inclusion/exclusion.
78#[derive(Debug, Clone, Serialize, Deserialize, Default)]
79pub struct FilePatterns {
80    #[serde(default)]
81    pub patterns: Vec<String>,
82    #[serde(default)]
83    pub exclude: Vec<String>,
84}
85
86/// Trigger configuration block.
87#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88pub struct TriggersConfig {
89    /// Filesystem-watch trigger.
90    #[serde(default)]
91    pub filesystem: Option<FilesystemTrigger>,
92    /// Cron-based scheduled trigger.
93    #[serde(default)]
94    pub scheduled: Option<ScheduledTrigger>,
95}
96
97/// Filesystem watcher trigger settings.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct FilesystemTrigger {
100    pub enabled: bool,
101    #[serde(default = "default_debounce_ms")]
102    pub debounce_ms: u64,
103    #[serde(default)]
104    pub patterns: Vec<String>,
105    #[serde(default)]
106    pub exclude: Vec<String>,
107}
108
109fn default_debounce_ms() -> u64 {
110    2000
111}
112
113/// Scheduled (cron) trigger settings.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ScheduledTrigger {
116    pub enabled: bool,
117    #[serde(default = "default_cron")]
118    pub cron: String,
119    #[serde(default = "default_scope")]
120    pub scope: String,
121}
122
123fn default_cron() -> String {
124    "0 2 * * *".to_string()
125}
126
127fn default_scope() -> String {
128    "full".to_string()
129}
130
131/// Reporting configuration.
132#[derive(Debug, Clone, Serialize, Deserialize, Default)]
133pub struct ReportingConfig {
134    #[serde(default)]
135    pub default_format: Option<String>,
136    #[serde(default)]
137    pub save_artifacts: Option<bool>,
138}
139
140// ── Report types ────────────────────────────────────────────────
141
142/// Full assessment report produced by a scan.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct AssessmentReport {
145    /// When the assessment was run.
146    pub timestamp: DateTime<Utc>,
147    /// Scope that was used (full, commit, ci, dependency).
148    pub scope: String,
149    /// Root directory that was scanned.
150    pub project: String,
151    /// Number of files scanned.
152    pub files_scanned: usize,
153    /// Aggregate summary metrics.
154    pub summary: AssessmentSummary,
155    /// Individual findings (warnings, issues).
156    pub findings: Vec<Finding>,
157    /// Which analyzers were executed.
158    pub analyzers_run: Vec<String>,
159}
160
161/// Aggregate metrics from an assessment.
162#[derive(Debug, Clone, Default, Serialize, Deserialize)]
163pub struct AssessmentSummary {
164    pub total_files: usize,
165    pub lines_of_code: usize,
166    pub rust_files: usize,
167    pub typescript_files: usize,
168    pub config_files: usize,
169    pub doc_files: usize,
170    pub dependency_files: usize,
171    pub complexity_warnings: usize,
172    pub coherence_score: f64,
173    pub symbols_extracted: usize,
174    pub avg_complexity: f64,
175    /// Number of progressive discovery rounds executed (0 for non-progressive runs).
176    #[serde(default)]
177    pub discovery_rounds: usize,
178    /// Total files discovered across all progressive rounds beyond the initial scan.
179    #[serde(default)]
180    pub files_discovered: usize,
181}
182
183/// A single finding from the assessment.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct Finding {
186    pub severity: String,
187    pub category: String,
188    pub file: String,
189    pub line: Option<usize>,
190    pub message: String,
191}
192
193/// A linked peer project for cross-project comparison.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct PeerInfo {
196    pub name: String,
197    pub location: String,
198    pub linked_at: DateTime<Utc>,
199    pub last_assessment: Option<AssessmentReport>,
200}
201
202/// Comparison between local and remote peer assessment.
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct ComparisonReport {
205    pub local: AssessmentReport,
206    pub remote_name: String,
207    pub remote: AssessmentReport,
208    pub shared_deps: Vec<String>,
209}
210
211// ── Service ─────────────────────────────────────────────────────
212
213/// Project assessment service.
214///
215/// Scans a project directory for code quality signals, complexity
216/// warnings, and structural issues. Uses a pluggable `AnalyzerRegistry`
217/// so that external crates can register custom analyzers. Optionally
218/// uses tree-sitter for Rust symbol extraction and complexity analysis.
219pub struct AssessmentService {
220    started: AtomicBool,
221    latest: Mutex<Option<AssessmentReport>>,
222    peers: Mutex<Vec<PeerInfo>>,
223    /// Path to the previous report JSON for diff computation.
224    previous_report_path: Mutex<Option<PathBuf>>,
225    /// Mesh coordinator for cross-project assessment exchange.
226    /// Present when constructed via [`with_mesh`] / [`with_mesh_coordinator`]
227    /// (kernel boot enables this when `[mesh] enabled = true`).
228    mesh_coordinator: Option<Arc<mesh::MeshCoordinator>>,
229}
230
231impl AssessmentService {
232    pub fn new() -> Self {
233        Self {
234            started: AtomicBool::new(false),
235            latest: Mutex::new(None),
236            peers: Mutex::new(Vec::new()),
237            previous_report_path: Mutex::new(None),
238            mesh_coordinator: None,
239        }
240    }
241
242    /// Create a new service with mesh coordination enabled.
243    pub fn with_mesh(node_id: String, project_name: String) -> Self {
244        Self {
245            started: AtomicBool::new(false),
246            latest: Mutex::new(None),
247            peers: Mutex::new(Vec::new()),
248            previous_report_path: Mutex::new(None),
249            mesh_coordinator: Some(Arc::new(mesh::MeshCoordinator::new(
250                node_id,
251                project_name,
252            ))),
253        }
254    }
255
256    /// Create a service that shares an existing mesh coordinator (WEFT-117).
257    ///
258    /// Used by kernel boot so [`AssessmentTransport`] and the service
259    /// hold the same [`mesh::MeshCoordinator`].
260    pub fn with_mesh_coordinator(coordinator: Arc<mesh::MeshCoordinator>) -> Self {
261        Self {
262            started: AtomicBool::new(false),
263            latest: Mutex::new(None),
264            peers: Mutex::new(Vec::new()),
265            previous_report_path: Mutex::new(None),
266            mesh_coordinator: Some(coordinator),
267        }
268    }
269
270    /// Returns a reference to the mesh coordinator, if enabled.
271    pub fn mesh_coordinator(&self) -> Option<&mesh::MeshCoordinator> {
272        self.mesh_coordinator.as_deref()
273    }
274
275    /// Shared `Arc` to the mesh coordinator (for wiring AssessmentTransport).
276    pub fn mesh_coordinator_arc(&self) -> Option<Arc<mesh::MeshCoordinator>> {
277        self.mesh_coordinator.clone()
278    }
279
280    /// Queue mesh propagation for a completed report.
281    ///
282    /// Prefer **FindingDiff** (changed findings only) when a previous
283    /// report is available; otherwise queue a lightweight gossip message.
284    pub fn queue_mesh_propagation(&self, report: &AssessmentReport) {
285        let Some(ref mc) = self.mesh_coordinator else {
286            return;
287        };
288        if let Some(previous) = self.load_previous_report() {
289            let diff = analyzer::diff_reports(report, &previous);
290            // Only push a diff when something actually changed.
291            if !diff.findings_new.is_empty()
292                || !diff.findings_resolved.is_empty()
293                || diff.complexity_delta != 0
294                || (diff.coherence_delta.abs() > f64::EPSILON)
295            {
296                let msg = mc.build_finding_diff(report, &diff);
297                mc.set_pending_broadcast(msg);
298                return;
299            }
300        }
301        let gossip = mc.build_gossip(report);
302        mc.set_pending_broadcast(gossip);
303    }
304
305    /// Set the path to a previous assessment report JSON file.
306    ///
307    /// When set, `run_assessment` will load it and pass it to analyzers
308    /// via `AnalysisContext`, and `diff_latest` will use it to compute
309    /// an `AssessmentDiff`.
310    pub fn set_previous_report_path(&self, path: PathBuf) {
311        *self.previous_report_path.lock().unwrap() = Some(path);
312    }
313
314    /// Load the previous report from the configured path, if any.
315    fn load_previous_report(&self) -> Option<AssessmentReport> {
316        let guard = self.previous_report_path.lock().unwrap();
317        let path = guard.as_ref()?;
318        let content = std::fs::read_to_string(path).ok()?;
319        serde_json::from_str(&content).ok()
320    }
321
322    /// Compute a diff between the latest report and the previous one.
323    pub fn diff_latest(&self) -> Option<AssessmentDiff> {
324        let current = self.get_latest()?;
325        let previous = self.load_previous_report()?;
326        Some(analyzer::diff_reports(&current, &previous))
327    }
328
329    /// Run the full assessment pipeline on `project_dir`.
330    ///
331    /// `scope` selects what to scan:
332    /// - `"full"` — all files under project_dir
333    /// - `"commit"` — only files changed in the last git commit
334    /// - `"ci"` — CI config files only
335    /// - `"dependency"` — dependency manifests only
336    ///
337    /// `format` is reserved for future output formatting (currently ignored).
338    pub fn run_assessment(
339        &self,
340        project_dir: &Path,
341        scope: &str,
342        _format: &str,
343    ) -> Result<AssessmentReport, String> {
344        self.run_assessment_with_registry(
345            project_dir,
346            scope,
347            _format,
348            AnalyzerRegistry::with_defaults(),
349        )
350    }
351
352    /// Run assessment with a custom analyzer registry.
353    pub fn run_assessment_with_registry(
354        &self,
355        project_dir: &Path,
356        scope: &str,
357        _format: &str,
358        registry: AnalyzerRegistry,
359    ) -> Result<AssessmentReport, String> {
360        let files = match scope {
361            "commit" => collect_git_changed_files(project_dir)?,
362            "ci" => collect_files_filtered(project_dir, is_ci_file),
363            "dependency" => collect_files_filtered(project_dir, is_dependency_file),
364            _ => collect_all_files(project_dir),
365        };
366
367        let mut summary = AssessmentSummary::default();
368        #[allow(unused_mut)]
369        let mut total_complexity_sum: f64 = 0.0;
370        #[allow(unused_mut)]
371        let mut complexity_count: usize = 0;
372
373        // Classify files and compute line counts + tree-sitter metrics
374        let mut ts_findings = Vec::new();
375        for path in &files {
376            let rel = path.strip_prefix(project_dir).unwrap_or(path);
377            let _rel_str = rel.display().to_string();
378
379            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
380            match ext {
381                "rs" => summary.rust_files += 1,
382                "ts" | "tsx" => summary.typescript_files += 1,
383                "toml" | "yaml" | "yml" | "json" if is_config_file(path) => {
384                    summary.config_files += 1;
385                }
386                "md" | "txt" | "adoc" => summary.doc_files += 1,
387                _ => {}
388            }
389            if is_dependency_file(path) {
390                summary.dependency_files += 1;
391            }
392
393            let content = match std::fs::read_to_string(path) {
394                Ok(c) => c,
395                Err(_) => continue,
396            };
397
398            let line_count = content.lines().count();
399            summary.lines_of_code += line_count;
400
401            // Tree-sitter analysis for Rust files
402            #[cfg(feature = "treesitter")]
403            if ext == "rs" {
404                if let Ok(tree) = clawft_plugin_treesitter::analysis::parse_source(
405                    &content,
406                    clawft_plugin_treesitter::types::Language::Rust,
407                ) {
408                    let symbols = clawft_plugin_treesitter::analysis::extract_symbols(
409                        &tree,
410                        &content,
411                        clawft_plugin_treesitter::types::Language::Rust,
412                    );
413                    summary.symbols_extracted += symbols.len();
414
415                    let metrics = clawft_plugin_treesitter::analysis::calculate_complexity(
416                        &tree,
417                        &content,
418                        clawft_plugin_treesitter::types::Language::Rust,
419                    );
420                    for func in &metrics.functions {
421                        total_complexity_sum += func.complexity as f64;
422                        complexity_count += 1;
423                        if func.complexity > 10 {
424                            summary.complexity_warnings += 1;
425                            ts_findings.push(Finding {
426                                severity: "warning".into(),
427                                category: "complexity".into(),
428                                file: _rel_str.clone(),
429                                line: Some(func.start_line),
430                                message: format!(
431                                    "Function '{}' has cyclomatic complexity {}",
432                                    func.name, func.complexity
433                                ),
434                            });
435                        }
436                    }
437                }
438            }
439        }
440
441        // Run pluggable analyzers
442        let previous_report = self.load_previous_report();
443        let context = AnalysisContext {
444            scope: scope.to_string(),
445            previous_report,
446        };
447        let analyzer_ids = registry.analyzer_ids();
448        let mut findings = registry.run_all(project_dir, &files, &context);
449
450        // Append tree-sitter findings
451        findings.append(&mut ts_findings);
452
453        // Update summary with analyzer-produced complexity warnings
454        let warning_count = findings.iter().filter(|f| f.severity == "warning").count();
455        // Add complexity warnings from the size category produced by ComplexityAnalyzer
456        summary.complexity_warnings += findings
457            .iter()
458            .filter(|f| f.category == "size" && f.severity == "warning")
459            .count();
460
461        summary.total_files = files.len();
462        summary.avg_complexity = if complexity_count > 0 {
463            total_complexity_sum / complexity_count as f64
464        } else {
465            0.0
466        };
467        // Simple coherence score: ratio of files without warnings
468        summary.coherence_score = if summary.total_files > 0 {
469            1.0 - (warning_count as f64 / summary.total_files as f64).min(1.0)
470        } else {
471            1.0
472        };
473
474        let mut report = AssessmentReport {
475            timestamp: Utc::now(),
476            scope: scope.to_string(),
477            project: project_dir.display().to_string(),
478            files_scanned: files.len(),
479            summary,
480            findings,
481            analyzers_run: analyzer_ids,
482        };
483
484        // Generate heuristic insights and append them
485        if let Some(mut insights) = self.generate_insights(&report) {
486            report.findings.append(&mut insights);
487        }
488
489        *self.latest.lock().unwrap() = Some(report.clone());
490
491        // If mesh is enabled, queue diff (preferred) or gossip for the
492        // daemon mesh event loop / AssessmentTransport (WEFT-117).
493        self.queue_mesh_propagation(&report);
494
495        debug!(
496            scope = scope,
497            files = report.files_scanned,
498            "assessment complete"
499        );
500        Ok(report)
501    }
502
503    /// Load and parse `.weftos/weave.toml` from the given project directory.
504    ///
505    /// Returns `None` if the file does not exist or cannot be parsed.
506    pub fn load_config(&self, project: &Path) -> Option<AssessmentConfig> {
507        let config_path = project.join(".weftos/weave.toml");
508        let content = std::fs::read_to_string(&config_path).ok()?;
509        match toml::from_str::<AssessmentConfig>(&content) {
510            Ok(cfg) => Some(cfg),
511            Err(e) => {
512                tracing::warn!(
513                    path = %config_path.display(),
514                    error = %e,
515                    "failed to parse assessment config"
516                );
517                None
518            }
519        }
520    }
521
522    /// Returns the latest assessment report, if any.
523    pub fn get_latest(&self) -> Option<AssessmentReport> {
524        self.latest.lock().unwrap().clone()
525    }
526
527    /// Returns all linked peers.
528    pub fn list_peers(&self) -> Vec<PeerInfo> {
529        self.peers.lock().unwrap().clone()
530    }
531
532    /// Link a peer project for comparison.
533    pub fn link_peer(&self, name: String, location: String) -> Result<(), String> {
534        let mut peers = self.peers.lock().unwrap();
535        if peers.iter().any(|p| p.name == name) {
536            return Err(format!("peer '{name}' already linked"));
537        }
538        peers.push(PeerInfo {
539            name,
540            location,
541            linked_at: Utc::now(),
542            last_assessment: None,
543        });
544        Ok(())
545    }
546
547    /// Run assessment on a peer and compare with the latest local assessment.
548    pub fn compare_with_peer(&self, peer_name: &str) -> Result<ComparisonReport, String> {
549        let local = self
550            .get_latest()
551            .ok_or_else(|| "no local assessment available; run an assessment first".to_string())?;
552
553        let peers = self.peers.lock().unwrap();
554        let peer = peers
555            .iter()
556            .find(|p| p.name == peer_name)
557            .ok_or_else(|| format!("peer '{peer_name}' not found"))?
558            .clone();
559        drop(peers);
560
561        let peer_dir = PathBuf::from(&peer.location);
562        if !peer_dir.exists() {
563            return Err(format!("peer directory '{}' does not exist", peer.location));
564        }
565
566        let remote = self.run_assessment(&peer_dir, &local.scope, "json")?;
567
568        // Update peer's last_assessment
569        {
570            let mut peers = self.peers.lock().unwrap();
571            if let Some(p) = peers.iter_mut().find(|p| p.name == peer_name) {
572                p.last_assessment = Some(remote.clone());
573            }
574        }
575
576        // Find shared dependency files by name
577        let local_deps: std::collections::HashSet<String> = local
578            .findings
579            .iter()
580            .filter(|f| f.category == "dependency")
581            .map(|f| f.file.clone())
582            .collect();
583        let remote_deps: std::collections::HashSet<String> = remote
584            .findings
585            .iter()
586            .filter(|f| f.category == "dependency")
587            .map(|f| f.file.clone())
588            .collect();
589        let shared_deps: Vec<String> = local_deps.intersection(&remote_deps).cloned().collect();
590
591        Ok(ComparisonReport {
592            local,
593            remote_name: peer_name.to_string(),
594            remote,
595            shared_deps,
596        })
597    }
598
599    // ── Progressive Discovery ─────────────────────────────────────
600
601    /// Run the assessment pipeline in progressive rounds.
602    ///
603    /// Each round runs all analyzers on scoped files. After each round,
604    /// new file/directory references discovered in findings (topology,
605    /// network, data_source) are collected. If they point to files not
606    /// yet scanned, a follow-up round is executed on those files.
607    /// Repeats until no new files are discovered or `max_rounds` is reached.
608    pub fn run_progressive(
609        &self,
610        project: &Path,
611        scope: &str,
612        max_rounds: usize,
613    ) -> AssessmentReport {
614        let registry = AnalyzerRegistry::with_defaults();
615        let analyzer_ids = registry.analyzer_ids();
616
617        let initial_files = match scope {
618            "commit" => collect_git_changed_files(project).unwrap_or_default(),
619            "ci" => collect_files_filtered(project, is_ci_file),
620            "dependency" => collect_files_filtered(project, is_dependency_file),
621            _ => collect_all_files(project),
622        };
623
624        let previous_report = self.load_previous_report();
625        let context = AnalysisContext {
626            scope: scope.to_string(),
627            previous_report,
628        };
629
630        let mut all_scanned: std::collections::HashSet<PathBuf> =
631            initial_files.iter().cloned().collect();
632        let mut all_findings: Vec<Finding> = Vec::new();
633        let mut round = 0;
634        let mut total_discovered: usize = 0;
635        let mut files_to_scan = initial_files;
636
637        while round < max_rounds && !files_to_scan.is_empty() {
638            let findings = registry.run_all(project, &files_to_scan, &context);
639            all_findings.extend(findings.clone());
640            round += 1;
641
642            if round >= max_rounds {
643                break;
644            }
645
646            // Extract discovery hints: file paths mentioned in findings
647            let mut new_files = Vec::new();
648            for finding in &findings {
649                if !matches!(
650                    finding.category.as_str(),
651                    "topology" | "network" | "data_source"
652                ) {
653                    continue;
654                }
655                // Look for file paths in messages
656                let hints = extract_path_hints(&finding.message, &finding.file, project);
657                for hint in hints {
658                    if hint.exists() && hint.is_file() && !all_scanned.contains(&hint) {
659                        all_scanned.insert(hint.clone());
660                        new_files.push(hint);
661                    }
662                }
663            }
664
665            // Also check if any findings reference directories we haven't walked
666            for finding in &findings {
667                let path = project.join(&finding.file);
668                if let Some(parent) = path.parent()
669                    && parent.is_dir()
670                {
671                    let mut extra = Vec::new();
672                    walk_dir(parent, &mut extra, &|_| true);
673                    for f in extra {
674                        if !all_scanned.contains(&f) {
675                            all_scanned.insert(f.clone());
676                            new_files.push(f);
677                        }
678                    }
679                }
680            }
681
682            total_discovered += new_files.len();
683            files_to_scan = new_files;
684        }
685
686        // Build summary from all scanned files
687        let all_files: Vec<PathBuf> = all_scanned.into_iter().collect();
688        let mut summary = AssessmentSummary {
689            total_files: all_files.len(),
690            discovery_rounds: round,
691            files_discovered: total_discovered,
692            ..Default::default()
693        };
694
695        for path in &all_files {
696            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
697            match ext {
698                "rs" => summary.rust_files += 1,
699                "ts" | "tsx" => summary.typescript_files += 1,
700                "toml" | "yaml" | "yml" | "json" if is_config_file(path) => {
701                    summary.config_files += 1;
702                }
703                "md" | "txt" | "adoc" => summary.doc_files += 1,
704                _ => {}
705            }
706            if is_dependency_file(path) {
707                summary.dependency_files += 1;
708            }
709            if let Ok(content) = std::fs::read_to_string(path) {
710                summary.lines_of_code += content.lines().count();
711            }
712        }
713
714        let warning_count = all_findings
715            .iter()
716            .filter(|f| f.severity == "warning")
717            .count();
718        summary.complexity_warnings = all_findings
719            .iter()
720            .filter(|f| f.category == "size" && f.severity == "warning")
721            .count();
722        summary.coherence_score = if summary.total_files > 0 {
723            1.0 - (warning_count as f64 / summary.total_files as f64).min(1.0)
724        } else {
725            1.0
726        };
727
728        let mut report = AssessmentReport {
729            timestamp: Utc::now(),
730            scope: scope.to_string(),
731            project: project.display().to_string(),
732            files_scanned: all_files.len(),
733            summary,
734            findings: all_findings,
735            analyzers_run: analyzer_ids,
736        };
737
738        // Generate heuristic insights and append them
739        if let Some(mut insights) = self.generate_insights(&report) {
740            report.findings.append(&mut insights);
741        }
742
743        *self.latest.lock().unwrap() = Some(report.clone());
744
745        // Mesh propagation (FindingDiff when prior report exists).
746        self.queue_mesh_propagation(&report);
747
748        debug!(
749            scope = scope,
750            rounds = round,
751            discovered = total_discovered,
752            files = report.files_scanned,
753            "progressive assessment complete"
754        );
755        report
756    }
757
758    // ── LLM Assessor Agent Stub ───────────────────────────────────
759
760    /// Generate LLM-powered insights from assessment findings.
761    ///
762    /// When a kernel daemon is running with an LLM provider configured,
763    /// this method spawns a worker agent via the supervisor that analyzes
764    /// the findings and produces higher-order insights (architectural
765    /// patterns, risk assessment, recommendations).
766    ///
767    /// Returns None if no LLM is available (local-only mode).
768    pub fn generate_insights(&self, report: &AssessmentReport) -> Option<Vec<Finding>> {
769        let mut insights = Vec::new();
770        let summary = &report.summary;
771
772        // Heuristic 1: If coherence_score < 20%, suggest more documentation
773        if summary.coherence_score < 0.2 {
774            insights.push(Finding {
775                severity: "warning".into(),
776                category: "insight".into(),
777                file: String::new(),
778                line: None,
779                message: format!(
780                    "Low coherence score ({:.0}%): consider adding documentation and reducing warnings to improve project health",
781                    summary.coherence_score * 100.0
782                ),
783            });
784        }
785
786        // Heuristic 2: If complexity_warnings > 10% of files, suggest refactoring
787        if summary.total_files > 0
788            && summary.complexity_warnings as f64 / summary.total_files as f64 > 0.1
789        {
790            insights.push(Finding {
791                severity: "warning".into(),
792                category: "insight".into(),
793                file: String::new(),
794                line: None,
795                message: format!(
796                    "High complexity ratio: {} of {} files have complexity warnings ({:.0}%) \
797                     — consider refactoring large or deeply-nested modules",
798                    summary.complexity_warnings,
799                    summary.total_files,
800                    (summary.complexity_warnings as f64 / summary.total_files as f64) * 100.0
801                ),
802            });
803        }
804
805        // Heuristic 3: If security findings > 0, flag for review
806        let security_count = report
807            .findings
808            .iter()
809            .filter(|f| f.category == "security")
810            .count();
811        if security_count > 0 {
812            let error_count = report
813                .findings
814                .iter()
815                .filter(|f| f.category == "security" && f.severity == "error")
816                .count();
817            insights.push(Finding {
818                severity: if error_count > 0 { "error" } else { "warning" }.into(),
819                category: "insight".into(),
820                file: String::new(),
821                line: None,
822                message: format!(
823                    "Security review needed: {security_count} security finding(s) detected \
824                     ({error_count} critical) — prioritize remediation before deployment"
825                ),
826            });
827        }
828
829        // Heuristic 4: If topology shows >5 services, suggest architecture review
830        let service_findings = report
831            .findings
832            .iter()
833            .filter(|f| f.category == "topology" && f.message.starts_with("Service:"))
834            .count();
835        if service_findings > 5 {
836            insights.push(Finding {
837                severity: "info".into(),
838                category: "insight".into(),
839                file: String::new(),
840                line: None,
841                message: format!(
842                    "Complex topology: {service_findings} services detected \
843                     — consider an architecture review to verify service boundaries \
844                     and communication patterns"
845                ),
846            });
847        }
848
849        // Heuristic 5: Cross-reference dependency + security findings
850        let dep_files: std::collections::HashSet<&str> = report
851            .findings
852            .iter()
853            .filter(|f| f.category == "dependency")
854            .map(|f| f.file.as_str())
855            .collect();
856        let security_files: std::collections::HashSet<&str> = report
857            .findings
858            .iter()
859            .filter(|f| f.category == "security")
860            .map(|f| f.file.as_str())
861            .collect();
862        let overlap: Vec<&&str> = dep_files.intersection(&security_files).collect();
863        if !overlap.is_empty() {
864            let file_list: Vec<String> = overlap.iter().map(|f| f.to_string()).collect();
865            insights.push(Finding {
866                severity: "warning".into(),
867                category: "insight".into(),
868                file: String::new(),
869                line: None,
870                message: format!(
871                    "Dependency files with security findings: {} \
872                     — audit these manifests for vulnerable or compromised packages",
873                    file_list.join(", ")
874                ),
875            });
876        }
877
878        Some(insights)
879    }
880}
881
882/// Extract potential file-path hints from a finding message.
883///
884/// Looks for quoted strings and path-like tokens that could reference
885/// files relative to the project root.
886fn extract_path_hints(message: &str, _finding_file: &str, project: &Path) -> Vec<PathBuf> {
887    let mut paths = Vec::new();
888
889    // Look for quoted strings that could be paths
890    for delim in ['"', '\''] {
891        let mut rest = message;
892        while let Some(start) = rest.find(delim) {
893            rest = &rest[start + 1..];
894            if let Some(end) = rest.find(delim) {
895                let candidate = &rest[..end];
896                // Looks like a relative path if it contains / or a known extension
897                if (candidate.contains('/') || candidate.contains('.'))
898                    && !candidate.contains(' ')
899                    && !candidate.starts_with("http")
900                {
901                    let full = project.join(candidate);
902                    paths.push(full);
903                }
904                rest = &rest[end + 1..];
905            } else {
906                break;
907            }
908        }
909    }
910
911    paths
912}
913
914impl Default for AssessmentService {
915    fn default() -> Self {
916        Self::new()
917    }
918}
919
920#[async_trait]
921impl SystemService for AssessmentService {
922    fn name(&self) -> &str {
923        "assessment"
924    }
925
926    fn service_type(&self) -> ServiceType {
927        ServiceType::Custom("assessment".into())
928    }
929
930    async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
931        self.started.store(true, Ordering::Relaxed);
932        tracing::info!("assessment service started");
933        Ok(())
934    }
935
936    async fn stop(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
937        self.started.store(false, Ordering::Relaxed);
938        tracing::info!("assessment service stopped");
939        Ok(())
940    }
941
942    async fn health_check(&self) -> HealthStatus {
943        if self.started.load(Ordering::Relaxed) {
944            HealthStatus::Healthy
945        } else {
946            HealthStatus::Degraded("not started".into())
947        }
948    }
949}
950
951// ── Helpers ─────────────────────────────────────────────────────
952
953fn collect_all_files(dir: &Path) -> Vec<PathBuf> {
954    let mut files = Vec::new();
955    walk_dir(dir, &mut files, &|_| true);
956    files
957}
958
959fn collect_files_filtered<F: Fn(&Path) -> bool>(dir: &Path, predicate: F) -> Vec<PathBuf> {
960    let mut files = Vec::new();
961    walk_dir(dir, &mut files, &predicate);
962    files
963}
964
965fn walk_dir<F: Fn(&Path) -> bool>(dir: &Path, out: &mut Vec<PathBuf>, predicate: &F) {
966    let entries = match std::fs::read_dir(dir) {
967        Ok(e) => e,
968        Err(_) => return,
969    };
970    for entry in entries.flatten() {
971        let path = entry.path();
972        // Skip hidden dirs and common noise
973        if let Some(name) = path.file_name().and_then(|n| n.to_str())
974            && (name.starts_with('.') || name == "target" || name == "node_modules")
975        {
976            continue;
977        }
978        if path.is_dir() {
979            walk_dir(&path, out, predicate);
980        } else if predicate(&path) {
981            out.push(path);
982        }
983    }
984}
985
986fn collect_git_changed_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
987    let output = std::process::Command::new("git")
988        .args(["diff", "--name-only", "HEAD~1"])
989        .current_dir(dir)
990        .output()
991        .map_err(|e| format!("failed to run git diff: {e}"))?;
992
993    if !output.status.success() {
994        return Err(format!(
995            "git diff failed: {}",
996            String::from_utf8_lossy(&output.stderr)
997        ));
998    }
999
1000    let files = String::from_utf8_lossy(&output.stdout)
1001        .lines()
1002        .map(|l| dir.join(l))
1003        .filter(|p| p.exists())
1004        .collect();
1005    Ok(files)
1006}
1007
1008fn is_ci_file(path: &Path) -> bool {
1009    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1010    let path_str = path.display().to_string();
1011    name.contains("ci")
1012        || name.contains("CI")
1013        || path_str.contains(".github/workflows")
1014        || name == "Jenkinsfile"
1015        || name == ".gitlab-ci.yml"
1016}
1017
1018fn is_dependency_file(path: &Path) -> bool {
1019    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1020    matches!(
1021        name,
1022        "Cargo.toml"
1023            | "Cargo.lock"
1024            | "package.json"
1025            | "package-lock.json"
1026            | "yarn.lock"
1027            | "pnpm-lock.yaml"
1028            | "go.mod"
1029            | "go.sum"
1030            | "requirements.txt"
1031            | "Pipfile"
1032            | "Pipfile.lock"
1033    )
1034}
1035
1036fn is_config_file(path: &Path) -> bool {
1037    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1038    // Dependency files are not config files
1039    if is_dependency_file(path) {
1040        return false;
1041    }
1042    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1043    matches!(ext, "toml" | "yaml" | "yml" | "json")
1044        || name == ".editorconfig"
1045        || name == ".rustfmt.toml"
1046        || name == "clippy.toml"
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051    use super::*;
1052    use std::fs;
1053
1054    fn setup_test_dir() -> tempfile::TempDir {
1055        let dir = tempfile::tempdir().unwrap();
1056        // Create a Rust file (>500 lines)
1057        let mut big = String::new();
1058        for i in 0..510 {
1059            big.push_str(&format!("// line {i}\n"));
1060        }
1061        fs::write(dir.path().join("big.rs"), &big).unwrap();
1062
1063        // Create a small Rust file with a TODO
1064        fs::write(
1065            dir.path().join("small.rs"),
1066            "fn main() {\n    // TODO: implement\n}\n",
1067        )
1068        .unwrap();
1069
1070        // Create a config file
1071        fs::write(dir.path().join("config.toml"), "[section]\nkey = 1\n").unwrap();
1072
1073        // Create a markdown doc
1074        fs::write(dir.path().join("README.md"), "# Readme\n").unwrap();
1075
1076        // Create a dep file
1077        fs::write(
1078            dir.path().join("Cargo.toml"),
1079            "[package]\nname = \"test\"\n[dependencies]\nserde = \"1.0\"\n",
1080        )
1081        .unwrap();
1082
1083        dir
1084    }
1085
1086    #[test]
1087    fn full_assessment_scans_files() {
1088        let dir = setup_test_dir();
1089        let svc = AssessmentService::new();
1090        let report = svc.run_assessment(dir.path(), "full", "json").unwrap();
1091
1092        assert_eq!(report.scope, "full");
1093        assert!(report.files_scanned >= 4);
1094        assert!(report.summary.rust_files >= 2);
1095        assert!(report.summary.doc_files >= 1);
1096        assert!(report.summary.dependency_files >= 1);
1097    }
1098
1099    #[test]
1100    fn detects_large_files() {
1101        let dir = setup_test_dir();
1102        let svc = AssessmentService::new();
1103        let report = svc.run_assessment(dir.path(), "full", "json").unwrap();
1104
1105        let size_warnings: Vec<_> = report
1106            .findings
1107            .iter()
1108            .filter(|f| f.category == "size")
1109            .collect();
1110        assert!(!size_warnings.is_empty(), "should detect >500 line file");
1111    }
1112
1113    #[test]
1114    fn detects_todos() {
1115        let dir = setup_test_dir();
1116        let svc = AssessmentService::new();
1117        let report = svc.run_assessment(dir.path(), "full", "json").unwrap();
1118
1119        let todos: Vec<_> = report
1120            .findings
1121            .iter()
1122            .filter(|f| f.category == "todo")
1123            .collect();
1124        assert!(!todos.is_empty(), "should detect TODO comments");
1125    }
1126
1127    #[test]
1128    fn get_latest_returns_last() {
1129        let dir = setup_test_dir();
1130        let svc = AssessmentService::new();
1131        assert!(svc.get_latest().is_none());
1132
1133        svc.run_assessment(dir.path(), "full", "json").unwrap();
1134        assert!(svc.get_latest().is_some());
1135    }
1136
1137    #[test]
1138    fn load_config_parses_weave_toml() {
1139        let dir = tempfile::tempdir().unwrap();
1140        let weftos = dir.path().join(".weftos");
1141        fs::create_dir_all(&weftos).unwrap();
1142        fs::write(
1143            weftos.join("weave.toml"),
1144            r#"
1145[project]
1146name = "acme-app"
1147org = "acme"
1148environment = "development"
1149
1150[assessment]
1151version = 1
1152
1153[assessment.triggers.filesystem]
1154enabled = true
1155debounce_ms = 3000
1156patterns = ["**/*.rs"]
1157exclude = ["target/**"]
1158
1159[assessment.triggers.scheduled]
1160enabled = false
1161cron = "0 4 * * *"
1162scope = "full"
1163"#,
1164        )
1165        .unwrap();
1166
1167        let svc = AssessmentService::new();
1168        let cfg = svc.load_config(dir.path()).expect("should parse config");
1169
1170        let project = cfg.project.expect("project section");
1171        assert_eq!(project.name, "acme-app");
1172        assert_eq!(project.org.as_deref(), Some("acme"));
1173        assert_eq!(project.environment.as_deref(), Some("development"));
1174
1175        let triggers = cfg.assessment.unwrap().triggers.unwrap();
1176        let fs_trigger = triggers.filesystem.unwrap();
1177        assert!(fs_trigger.enabled);
1178        assert_eq!(fs_trigger.debounce_ms, 3000);
1179        assert_eq!(fs_trigger.patterns, vec!["**/*.rs"]);
1180
1181        let sched = triggers.scheduled.unwrap();
1182        assert!(!sched.enabled);
1183        assert_eq!(sched.cron, "0 4 * * *");
1184    }
1185
1186    #[test]
1187    fn load_config_returns_none_when_missing() {
1188        let dir = tempfile::tempdir().unwrap();
1189        let svc = AssessmentService::new();
1190        assert!(svc.load_config(dir.path()).is_none());
1191    }
1192
1193    #[test]
1194    fn peer_link_and_list() {
1195        let svc = AssessmentService::new();
1196        assert!(svc.list_peers().is_empty());
1197
1198        svc.link_peer("other".into(), "/tmp/other".into()).unwrap();
1199        assert_eq!(svc.list_peers().len(), 1);
1200        assert_eq!(svc.list_peers()[0].name, "other");
1201
1202        // Duplicate link fails
1203        assert!(svc.link_peer("other".into(), "/tmp/other2".into()).is_err());
1204    }
1205
1206    #[test]
1207    fn dependency_scope_filters() {
1208        let dir = setup_test_dir();
1209        let svc = AssessmentService::new();
1210        let report = svc
1211            .run_assessment(dir.path(), "dependency", "json")
1212            .unwrap();
1213
1214        // Should only scan dependency files
1215        assert_eq!(report.files_scanned, 1); // Cargo.toml
1216    }
1217
1218    #[test]
1219    fn coherence_score_is_bounded() {
1220        let dir = setup_test_dir();
1221        let svc = AssessmentService::new();
1222        let report = svc.run_assessment(dir.path(), "full", "json").unwrap();
1223
1224        assert!(report.summary.coherence_score >= 0.0);
1225        assert!(report.summary.coherence_score <= 1.0);
1226    }
1227
1228    #[test]
1229    fn report_includes_analyzers_run() {
1230        let dir = setup_test_dir();
1231        let svc = AssessmentService::new();
1232        let report = svc.run_assessment(dir.path(), "full", "json").unwrap();
1233
1234        assert!(!report.analyzers_run.is_empty());
1235        assert!(report.analyzers_run.contains(&"complexity".to_string()));
1236        assert!(report.analyzers_run.contains(&"dependency".to_string()));
1237        assert!(report.analyzers_run.contains(&"security".to_string()));
1238        assert!(report.analyzers_run.contains(&"topology".to_string()));
1239        assert!(report.analyzers_run.contains(&"data_source".to_string()));
1240    }
1241
1242    #[test]
1243    fn dependency_analyzer_finds_deps() {
1244        let dir = setup_test_dir();
1245        let svc = AssessmentService::new();
1246        let report = svc.run_assessment(dir.path(), "full", "json").unwrap();
1247
1248        let dep_findings: Vec<_> = report
1249            .findings
1250            .iter()
1251            .filter(|f| f.category == "dependency")
1252            .collect();
1253        assert!(
1254            !dep_findings.is_empty(),
1255            "should detect dependencies in Cargo.toml"
1256        );
1257    }
1258
1259    #[test]
1260    fn diff_reports_computes_deltas() {
1261        let prev = AssessmentReport {
1262            timestamp: Utc::now(),
1263            scope: "full".into(),
1264            project: "/test".into(),
1265            files_scanned: 2,
1266            summary: AssessmentSummary {
1267                complexity_warnings: 1,
1268                coherence_score: 0.8,
1269                ..Default::default()
1270            },
1271            findings: vec![Finding {
1272                severity: "warning".into(),
1273                category: "size".into(),
1274                file: "old.rs".into(),
1275                line: None,
1276                message: "File has 600 lines (>500 limit)".into(),
1277            }],
1278            analyzers_run: vec!["complexity".into()],
1279        };
1280
1281        let curr = AssessmentReport {
1282            timestamp: Utc::now(),
1283            scope: "full".into(),
1284            project: "/test".into(),
1285            files_scanned: 3,
1286            summary: AssessmentSummary {
1287                complexity_warnings: 2,
1288                coherence_score: 0.7,
1289                ..Default::default()
1290            },
1291            findings: vec![Finding {
1292                severity: "warning".into(),
1293                category: "size".into(),
1294                file: "new.rs".into(),
1295                line: None,
1296                message: "File has 700 lines (>500 limit)".into(),
1297            }],
1298            analyzers_run: vec!["complexity".into()],
1299        };
1300
1301        let diff = analyzer::diff_reports(&curr, &prev);
1302        assert!(diff.files_added.contains(&"new.rs".to_string()));
1303        assert!(diff.files_removed.contains(&"old.rs".to_string()));
1304        assert_eq!(diff.findings_new.len(), 1);
1305        assert_eq!(diff.findings_resolved.len(), 1);
1306        assert_eq!(diff.complexity_delta, 1);
1307        assert!((diff.coherence_delta - (-0.1)).abs() < 0.001);
1308    }
1309
1310    #[test]
1311    fn custom_registry_runs_only_registered() {
1312        let dir = setup_test_dir();
1313        let svc = AssessmentService::new();
1314
1315        // Empty registry — no analyzer findings
1316        let registry = AnalyzerRegistry::new();
1317        let report = svc
1318            .run_assessment_with_registry(dir.path(), "full", "json", registry)
1319            .unwrap();
1320
1321        assert!(report.analyzers_run.is_empty());
1322        assert!(report.findings.is_empty());
1323    }
1324}