1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt::Write as _;
3
4use statum_graph::{
5 CodebaseDoc, CodebaseMachine, CodebaseRelation, CodebaseRelationBasis, CodebaseRelationCount,
6};
7
8use crate::heuristics::{HeuristicOverlay, HeuristicRelationCount};
9
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub enum CompositionSuggestionSeverity {
12 Warning,
13 Suggestion,
14}
15
16impl CompositionSuggestionSeverity {
17 pub const fn display_label(self) -> &'static str {
18 match self {
19 Self::Warning => "warning",
20 Self::Suggestion => "suggestion",
21 }
22 }
23}
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub enum CompositionSuggestionKind {
27 MissingCompositionRole,
28 HeuristicCompositionCandidate,
29}
30
31impl CompositionSuggestionKind {
32 pub const fn display_label(self) -> &'static str {
33 match self {
34 Self::MissingCompositionRole => "missing composition role",
35 Self::HeuristicCompositionCandidate => "heuristic composition candidate",
36 }
37 }
38}
39
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct CompositionSuggestion {
42 pub index: usize,
43 pub severity: CompositionSuggestionSeverity,
44 pub kind: CompositionSuggestionKind,
45 pub source_machine: usize,
46 pub target_machine: usize,
47 pub exact_relation_indices: Vec<usize>,
48 pub heuristic_relation_indices: Vec<usize>,
49 pub exact_counts: Vec<CodebaseRelationCount>,
50 pub heuristic_counts: Vec<HeuristicRelationCount>,
51}
52
53impl CompositionSuggestion {
54 pub fn source_machine<'a>(&self, doc: &'a CodebaseDoc) -> Option<&'a CodebaseMachine> {
55 doc.machine(self.source_machine)
56 }
57
58 pub fn target_machine<'a>(&self, doc: &'a CodebaseDoc) -> Option<&'a CodebaseMachine> {
59 doc.machine(self.target_machine)
60 }
61
62 pub fn summary_label(&self, doc: &CodebaseDoc) -> String {
63 let source = self
64 .source_machine(doc)
65 .map(render_machine_label)
66 .unwrap_or_else(|| "<missing machine>".to_owned());
67 let target = self
68 .target_machine(doc)
69 .map(render_machine_label)
70 .unwrap_or_else(|| "<missing machine>".to_owned());
71 format!("{source} -> {target}")
72 }
73
74 pub fn counts_label(&self) -> String {
75 match self.severity {
76 CompositionSuggestionSeverity::Warning => self
77 .exact_counts
78 .iter()
79 .map(CodebaseRelationCount::display_label)
80 .collect::<Vec<_>>()
81 .join(", "),
82 CompositionSuggestionSeverity::Suggestion => self
83 .heuristic_counts
84 .iter()
85 .map(HeuristicRelationCount::display_label)
86 .collect::<Vec<_>>()
87 .join(", "),
88 }
89 }
90
91 pub const fn help_text(&self) -> &'static str {
92 match self.kind {
93 CompositionSuggestionKind::MissingCompositionRole => {
94 "consider `#[machine(role = composition)]` on the source machine"
95 }
96 CompositionSuggestionKind::HeuristicCompositionCandidate => {
97 "if this coupling is real workflow orchestration, model it in typed composition state/transition surfaces or promote a detached handoff into the exact lane"
98 }
99 }
100 }
101
102 pub const fn why_text(&self) -> &'static str {
103 match self.kind {
104 CompositionSuggestionKind::MissingCompositionRole => {
105 "protocol machine already exposes typed cross-machine orchestration"
106 }
107 CompositionSuggestionKind::HeuristicCompositionCandidate => {
108 "cross-machine coupling is still only visible through the heuristic lane"
109 }
110 }
111 }
112}
113
114#[derive(Clone, Debug, Default, Eq, PartialEq)]
115pub struct CompositionSuggestionOverlay {
116 suggestions: Vec<CompositionSuggestion>,
117}
118
119impl CompositionSuggestionOverlay {
120 pub fn suggestions(&self) -> &[CompositionSuggestion] {
121 &self.suggestions
122 }
123
124 pub fn is_empty(&self) -> bool {
125 self.suggestions.is_empty()
126 }
127
128 pub fn machine_suggestions(
129 &self,
130 machine_index: usize,
131 ) -> impl Iterator<Item = &CompositionSuggestion> + '_ {
132 self.suggestions
133 .iter()
134 .filter(move |suggestion| suggestion.source_machine == machine_index)
135 }
136
137 pub fn warning_count(&self) -> usize {
138 self.suggestions
139 .iter()
140 .filter(|suggestion| suggestion.severity == CompositionSuggestionSeverity::Warning)
141 .count()
142 }
143
144 pub fn suggestion_count(&self) -> usize {
145 self.suggestions
146 .iter()
147 .filter(|suggestion| suggestion.severity == CompositionSuggestionSeverity::Suggestion)
148 .count()
149 }
150
151 #[cfg(test)]
152 pub(crate) fn from_suggestions(suggestions: Vec<CompositionSuggestion>) -> Self {
153 Self { suggestions }
154 }
155}
156
157pub fn collect_composition_suggestions(
158 doc: &CodebaseDoc,
159 heuristic: &HeuristicOverlay,
160) -> CompositionSuggestionOverlay {
161 let mut suggestions = Vec::new();
162 let mut exact_pairs = BTreeSet::new();
163
164 for group in doc.machine_relation_groups() {
165 if group.from_machine == group.to_machine {
166 continue;
167 }
168 let Some(source_machine) = doc.machine(group.from_machine) else {
169 continue;
170 };
171 if source_machine.role.is_composition() {
172 continue;
173 }
174
175 let mut candidate_relations = Vec::new();
176 let mut counts =
177 BTreeMap::<(statum_graph::CodebaseRelationKind, CodebaseRelationBasis), usize>::new();
178 for relation_index in &group.relation_indices {
179 let Some(relation) = doc.relation(*relation_index) else {
180 continue;
181 };
182 if !is_high_confidence_typed_orchestration(relation) {
183 continue;
184 }
185 candidate_relations.push(*relation_index);
186 *counts.entry((relation.kind, relation.basis)).or_default() += 1;
187 }
188
189 if candidate_relations.is_empty() {
190 continue;
191 }
192
193 exact_pairs.insert((group.from_machine, group.to_machine));
194 suggestions.push(CompositionSuggestion {
195 index: suggestions.len(),
196 severity: CompositionSuggestionSeverity::Warning,
197 kind: CompositionSuggestionKind::MissingCompositionRole,
198 source_machine: group.from_machine,
199 target_machine: group.to_machine,
200 exact_relation_indices: candidate_relations,
201 heuristic_relation_indices: Vec::new(),
202 exact_counts: counts
203 .into_iter()
204 .map(|((kind, basis), count)| CodebaseRelationCount { kind, basis, count })
205 .collect(),
206 heuristic_counts: Vec::new(),
207 });
208 }
209
210 let mut legacy_link_counts = BTreeMap::<(usize, usize), usize>::new();
211 for link in doc.links() {
212 *legacy_link_counts
213 .entry((link.from_machine, link.to_machine))
214 .or_default() += 1;
215 }
216
217 for ((from_machine, to_machine), count) in legacy_link_counts {
218 if from_machine == to_machine || exact_pairs.contains(&(from_machine, to_machine)) {
219 continue;
220 }
221 let Some(source_machine) = doc.machine(from_machine) else {
222 continue;
223 };
224 if source_machine.role.is_composition() {
225 continue;
226 }
227
228 exact_pairs.insert((from_machine, to_machine));
229 suggestions.push(CompositionSuggestion {
230 index: suggestions.len(),
231 severity: CompositionSuggestionSeverity::Warning,
232 kind: CompositionSuggestionKind::MissingCompositionRole,
233 source_machine: from_machine,
234 target_machine: to_machine,
235 exact_relation_indices: Vec::new(),
236 heuristic_relation_indices: Vec::new(),
237 exact_counts: vec![CodebaseRelationCount {
238 kind: statum_graph::CodebaseRelationKind::StatePayload,
239 basis: CodebaseRelationBasis::DirectTypeSyntax,
240 count,
241 }],
242 heuristic_counts: Vec::new(),
243 });
244 }
245
246 for group in heuristic.machine_relation_groups() {
247 if group.from_machine == group.to_machine {
248 continue;
249 }
250 if exact_pairs.contains(&(group.from_machine, group.to_machine)) {
251 continue;
252 }
253 let Some(source_machine) = doc.machine(group.from_machine) else {
254 continue;
255 };
256 if source_machine.role.is_composition() {
257 continue;
258 }
259 if doc.machine_relation_groups().iter().any(|exact| {
260 exact.from_machine == group.from_machine && exact.to_machine == group.to_machine
261 }) {
262 continue;
263 }
264
265 suggestions.push(CompositionSuggestion {
266 index: suggestions.len(),
267 severity: CompositionSuggestionSeverity::Suggestion,
268 kind: CompositionSuggestionKind::HeuristicCompositionCandidate,
269 source_machine: group.from_machine,
270 target_machine: group.to_machine,
271 exact_relation_indices: Vec::new(),
272 heuristic_relation_indices: group.relation_indices.clone(),
273 exact_counts: Vec::new(),
274 heuristic_counts: group.counts.clone(),
275 });
276 }
277
278 CompositionSuggestionOverlay { suggestions }
279}
280
281pub fn render_composition_suggestions(doc: &CodebaseDoc, heuristic: &HeuristicOverlay) -> String {
282 let overlay = collect_composition_suggestions(doc, heuristic);
283 let mut output = String::new();
284 let _ = writeln!(
285 output,
286 "composition diagnostics: {} warning, {} suggestion",
287 overlay.warning_count(),
288 overlay.suggestion_count()
289 );
290 let _ = writeln!(
291 output,
292 "heuristics: {} ({})",
293 heuristic.status().display_label(),
294 heuristic.diagnostics().len()
295 );
296 for diagnostic in heuristic.diagnostics().iter().take(3) {
297 let _ = writeln!(
298 output,
299 "heuristic diagnostic: {}",
300 diagnostic.display_label()
301 );
302 }
303
304 if overlay.is_empty() {
305 let _ = writeln!(output, "no composition diagnostics");
306 return output;
307 }
308
309 for suggestion in overlay.suggestions() {
310 let _ = writeln!(output);
311 let _ = writeln!(
312 output,
313 "{}: {}",
314 suggestion.severity.display_label(),
315 suggestion.summary_label(doc)
316 );
317 let _ = writeln!(output, "kind: {}", suggestion.kind.display_label());
318 let _ = writeln!(output, "why: {}", suggestion.why_text());
319 let _ = writeln!(output, "evidence: {}", suggestion.counts_label());
320 let _ = writeln!(output, "help: {}", suggestion.help_text());
321 }
322
323 output
324}
325
326fn is_high_confidence_typed_orchestration(relation: &CodebaseRelation) -> bool {
327 matches!(
328 relation.basis,
329 CodebaseRelationBasis::DirectTypeSyntax
330 | CodebaseRelationBasis::AttestedTypeSyntax
331 | CodebaseRelationBasis::ViaDeclaration
332 )
333}
334
335fn render_machine_label(machine: &CodebaseMachine) -> String {
336 machine.label.unwrap_or(machine.rust_type_path).to_owned()
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use crate::heuristics::{
343 HeuristicEvidenceKind, HeuristicRelation, HeuristicRelationSource, HeuristicStatusKind,
344 };
345
346 mod suggestion_task {
347 use statum::{machine, state};
348
349 #[state]
350 pub enum State {
351 Running,
352 }
353
354 #[machine]
355 pub struct Machine<State> {}
356 }
357
358 mod suggestion_workflow {
359 use super::suggestion_task as task;
360 use statum::{machine, state, transition};
361
362 #[state]
363 pub enum State {
364 Draft,
365 InProgress(task::Machine<task::Running>),
366 }
367
368 #[machine]
369 pub struct Machine<State> {}
370
371 #[allow(dead_code)]
372 #[transition]
373 impl Machine<Draft> {
374 fn start(self, task: task::Machine<task::Running>) -> Machine<InProgress> {
375 self.transition_with(task)
376 }
377 }
378 }
379
380 fn fixture_doc() -> CodebaseDoc {
381 CodebaseDoc::linked().expect("linked doc")
382 }
383
384 #[test]
385 fn exact_protocol_machine_with_typed_child_machine_gets_warning() {
386 let doc = fixture_doc();
387 let overlay = collect_composition_suggestions(
388 &doc,
389 &HeuristicOverlay::from_parts(HeuristicStatusKind::Available, Vec::new(), Vec::new()),
390 );
391
392 assert!(overlay.warning_count() >= 1);
393 let suggestion = overlay
394 .suggestions()
395 .iter()
396 .find(|suggestion| {
397 suggestion.severity == CompositionSuggestionSeverity::Warning
398 && suggestion.kind == CompositionSuggestionKind::MissingCompositionRole
399 })
400 .expect("composition warning");
401 assert_eq!(
402 suggestion.kind,
403 CompositionSuggestionKind::MissingCompositionRole
404 );
405 assert!(!suggestion.exact_counts.is_empty());
406 }
407
408 #[test]
409 fn heuristic_only_protocol_machine_gets_suggestion() {
410 let doc = fixture_doc();
411 let task = doc
412 .machines()
413 .iter()
414 .find(|machine| machine.rust_type_path.ends_with("suggestion_task::Machine"))
415 .expect("task");
416 let workflow = doc
417 .machines()
418 .iter()
419 .find(|machine| {
420 machine
421 .rust_type_path
422 .ends_with("suggestion_workflow::Machine")
423 })
424 .expect("workflow");
425
426 let overlay = collect_composition_suggestions(
427 &doc,
428 &HeuristicOverlay::from_parts(
429 HeuristicStatusKind::Available,
430 Vec::new(),
431 vec![HeuristicRelation {
432 index: 0,
433 source: HeuristicRelationSource::Transition {
434 machine: task.index,
435 transition: 0,
436 },
437 target_machine: workflow.index,
438 evidence_kind: HeuristicEvidenceKind::Signature,
439 matched_path_text:
440 "suggestion_workflow::Machine<suggestion_workflow::InProgress>".to_owned(),
441 file_path: "/tmp/task.rs".into(),
442 line_number: 10,
443 snippet: None,
444 }],
445 ),
446 );
447
448 assert!(overlay.warning_count() >= 1);
449 assert!(overlay.suggestion_count() >= 1);
450 let suggestion = overlay
451 .suggestions()
452 .iter()
453 .find(|suggestion| suggestion.severity == CompositionSuggestionSeverity::Suggestion)
454 .expect("heuristic suggestion");
455 assert_eq!(
456 suggestion.kind,
457 CompositionSuggestionKind::HeuristicCompositionCandidate
458 );
459 }
460
461 #[test]
462 fn rendered_report_includes_heuristic_status() {
463 let doc = fixture_doc();
464 let report = render_composition_suggestions(
465 &doc,
466 &HeuristicOverlay::from_parts(
467 HeuristicStatusKind::Partial,
468 vec![crate::heuristics::HeuristicDiagnostic {
469 context: "package fixture".to_owned(),
470 message: "failed to parse one module".to_owned(),
471 }],
472 Vec::new(),
473 ),
474 );
475
476 assert!(report.contains("heuristics: partial (1)"));
477 assert!(report.contains("heuristic diagnostic:"));
478 }
479}