1use serde::Serialize;
42use xxhash_rust::xxh3::xxh3_64;
43
44use crate::audit::routing::RoutingFacts;
45use crate::audit_brief::ReviewDeltas;
46
47pub const DEFAULT_DECISION_CAP: usize = 4;
50pub const MIN_DECISION_CAP: usize = 3;
52pub const MAX_DECISION_CAP: usize = 5;
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
59#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60#[serde(rename_all = "kebab-case")]
61pub enum DecisionCategory {
62 CouplingBoundary,
64 PublicApiContract,
66 Dependency,
75}
76
77pub const ALL_CATEGORIES: [DecisionCategory; 3] = [
82 DecisionCategory::CouplingBoundary,
83 DecisionCategory::PublicApiContract,
84 DecisionCategory::Dependency,
85];
86
87impl DecisionCategory {
88 #[must_use]
92 pub const fn tag(self) -> &'static str {
93 match self {
94 Self::CouplingBoundary => "coupling-boundary",
95 Self::PublicApiContract => "public-api-contract",
96 Self::Dependency => "dependency",
97 }
98 }
99
100 const fn reversibility_weight(self) -> u64 {
105 match self {
106 Self::Dependency => 5,
107 Self::PublicApiContract => 3,
108 Self::CouplingBoundary => 2,
109 }
110 }
111}
112
113#[derive(Debug, Clone, Serialize)]
116#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
117pub struct Decision {
118 pub signal_id: String,
121 pub category: DecisionCategory,
123 pub question: String,
125 pub anchor_file: String,
127 pub anchor_line: u32,
129 pub signal_key: String,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub previous_signal_id: Option<String>,
138 pub blast: u64,
140 pub consequence: u64,
142 pub expert: Vec<String>,
145 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
147 pub bus_factor_one: bool,
148 pub internal_consumer_count: u64,
155 pub tradeoff: String,
160}
161
162#[derive(Debug, Clone, Serialize)]
164#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
165pub struct TruncationNote {
166 pub collapsed: usize,
168 pub reason: String,
170}
171
172#[derive(Debug, Clone, Default, Serialize)]
175#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
176pub struct DecisionSurface {
177 pub decisions: Vec<Decision>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub truncated: Option<TruncationNote>,
182 pub emitted_signal_ids: Vec<String>,
186}
187
188impl DecisionSurface {
189 #[must_use]
192 pub fn accept_signal_id(&self, signal_id: &str) -> bool {
193 self.emitted_signal_ids.iter().any(|id| id == signal_id)
194 }
195}
196
197#[must_use]
202pub fn derive_signal_id(category: DecisionCategory, candidate_key: &str) -> String {
203 let mut bytes = Vec::with_capacity(category.tag().len() + 1 + candidate_key.len());
204 bytes.extend_from_slice(category.tag().as_bytes());
205 bytes.push(0);
206 bytes.extend_from_slice(candidate_key.as_bytes());
207 format!("sig:{:016x}", xxh3_64(&bytes))
208}
209
210#[derive(Debug, Clone)]
214pub struct BoundaryAnchor {
215 pub zone_pair_key: String,
218 pub from_file: String,
220 pub from_zone: String,
222 pub to_zone: String,
224 pub line: u32,
226}
227
228#[derive(Debug, Clone)]
231pub struct CoordinationAnchor {
232 pub changed_file: String,
234 pub consumed_symbols: Vec<String>,
236 pub consumer_count: u64,
238 pub line: u32,
242}
243
244pub struct DecisionInputs<'a> {
246 pub deltas: &'a ReviewDeltas,
248 pub boundary_anchors: &'a [BoundaryAnchor],
250 pub coordination: &'a [CoordinationAnchor],
252 pub public_api_anchor_line: u32,
255 pub affected_not_shown: u64,
258 pub routing: &'a RoutingFacts,
260 pub head_source: &'a dyn Fn(&str) -> Option<String>,
263 pub rename_old_path: &'a dyn Fn(&str) -> Option<String>,
267 pub internal_consumers: &'a dyn Fn(&str) -> u64,
272 pub cap: usize,
274}
275
276fn route_for(routing: &RoutingFacts, anchor_file: &str) -> (Vec<String>, bool) {
278 routing
279 .units
280 .iter()
281 .find(|unit| unit.file == anchor_file)
282 .map_or((Vec::new(), false), |unit| {
283 (unit.expert.clone(), unit.bus_factor_one)
284 })
285}
286
287fn is_decision_suppressed(
292 head_source: Option<&str>,
293 category: DecisionCategory,
294 line: u32,
295) -> bool {
296 let Some(source) = head_source else {
297 return false;
298 };
299 let lines: Vec<&str> = source.lines().collect();
300 let token_matches = |comment: &str| {
301 if !comment.contains("fallow-ignore") {
302 return false;
303 }
304 let after = comment
307 .split_once("fallow-ignore-file")
308 .or_else(|| comment.split_once("fallow-ignore-next-line"))
309 .map(|(_, rest)| rest.trim());
310 match after {
311 None => false,
312 Some("") => true,
313 Some(rest) => {
314 rest.contains("decision-surface")
315 || rest.contains("decision-surfaces")
316 || rest.contains(category.tag())
317 }
318 }
319 };
320
321 if lines
323 .iter()
324 .any(|l| l.contains("fallow-ignore-file") && token_matches(l))
325 {
326 return true;
327 }
328 if line >= 2
330 && let Some(prev) = lines.get((line - 2) as usize)
331 && prev.contains("fallow-ignore-next-line")
332 && token_matches(prev)
333 {
334 return true;
335 }
336 false
337}
338
339fn boundary_question(from_zone: &str, to_zone: &str) -> String {
341 format!(
342 "`{from_zone}` now imports `{to_zone}` for the first time. Intended coupling, or should this edge not exist?"
343 )
344}
345
346fn public_api_question(count: usize) -> String {
348 format!(
349 "This change widens the public API surface by {count} export{}. These become maintained contracts. Intended surface, or should they stay internal?",
350 if count == 1 { "" } else { "s" }
351 )
352}
353
354fn coordination_question(changed_file: &str, symbols: &[String], consumers: u64) -> String {
356 format!(
357 "`{changed_file}` changes a contract ({}) consumed by {consumers} module{} NOT in this diff. Coordinate the change, or is the contract stable?",
358 symbols.join(", "),
359 if consumers == 1 { "" } else { "s" }
360 )
361}
362
363fn modules_word(n: u64) -> &'static str {
365 if n == 1 { "module" } else { "modules" }
366}
367
368fn agrees(verb_plural: &str, n: u64) -> String {
371 if n == 1 {
372 format!("{verb_plural}s")
373 } else {
374 verb_plural.to_string()
375 }
376}
377
378fn boundary_tradeoff(from_zone: &str, to_zone: &str, consumers: u64) -> String {
381 format!(
382 "Couples `{from_zone}` to `{to_zone}`; {consumers} in-repo {} already {} on this anchor.",
383 modules_word(consumers),
384 agrees("depend", consumers)
385 )
386}
387
388fn public_api_tradeoff(count: usize, consumers: u64) -> String {
392 format!(
393 "Adds {count} maintained contract{}; {consumers} in-repo {} already {} this surface, and any external consumers become a contract you cannot remove without a breaking change.",
394 if count == 1 { "" } else { "s" },
395 modules_word(consumers),
396 agrees("consume", consumers)
397 )
398}
399
400fn coordination_tradeoff(consumers: u64) -> String {
402 format!(
403 "{consumers} {} outside the diff {} this contract; changing its shape requires coordinating them.",
404 modules_word(consumers),
405 agrees("consume", consumers)
406 )
407}
408
409struct DecisionSpec {
412 category: DecisionCategory,
413 candidate_key: String,
414 question: String,
415 anchor_file: String,
416 anchor_line: u32,
417 blast: u64,
418 internal_consumer_count: u64,
420 tradeoff: String,
422}
423
424fn build_decision(spec: DecisionSpec, inputs: &DecisionInputs<'_>) -> Decision {
426 let DecisionSpec {
427 category,
428 candidate_key,
429 question,
430 anchor_file,
431 anchor_line,
432 blast,
433 internal_consumer_count,
434 tradeoff,
435 } = spec;
436 let signal_id = derive_signal_id(category, &candidate_key);
437 let previous_signal_id = remap_key_paths(&candidate_key, inputs.rename_old_path)
441 .map(|old_key| derive_signal_id(category, &old_key));
442 let (expert, bus_factor_one) = route_for(inputs.routing, &anchor_file);
443 let consequence = blast.saturating_mul(category.reversibility_weight());
444 Decision {
445 signal_id,
446 category,
447 question,
448 anchor_file,
449 anchor_line,
450 signal_key: candidate_key,
451 previous_signal_id,
452 blast,
453 consequence,
454 expert,
455 bus_factor_one,
456 internal_consumer_count,
457 tradeoff,
458 }
459}
460
461fn remap_key_paths(key: &str, rename_old_path: &dyn Fn(&str) -> Option<String>) -> Option<String> {
466 let mut moved = false;
467 let mut parts: Vec<String> = key
468 .split('|')
469 .map(|segment| {
470 if let Some(path) = segment.strip_prefix("contract:")
471 && let Some(old) = rename_old_path(path)
472 {
473 moved = true;
474 return format!("contract:{old}");
475 } else if let Some((path, name)) = segment.split_once("::")
476 && let Some(old) = rename_old_path(path)
477 {
478 moved = true;
479 return format!("{old}::{name}");
480 }
481 segment.to_string()
482 })
483 .collect();
484 if !moved {
485 return None;
486 }
487 parts.sort();
490 Some(parts.join("|"))
491}
492
493fn classify_candidates(inputs: &DecisionInputs<'_>) -> Vec<Decision> {
495 let mut decisions: Vec<Decision> = Vec::new();
496
497 for key in &inputs.deltas.boundary_introduced {
499 let anchor = inputs
500 .boundary_anchors
501 .iter()
502 .find(|a| &a.zone_pair_key == key);
503 let (anchor_file, anchor_line, from_zone, to_zone) = anchor.map_or_else(
504 || (String::new(), 0, key.clone(), String::new()),
505 |a| {
506 (
507 a.from_file.clone(),
508 a.line,
509 a.from_zone.clone(),
510 a.to_zone.clone(),
511 )
512 },
513 );
514 let internal_consumer_count = (inputs.internal_consumers)(&anchor_file);
515 decisions.push(build_decision(
516 DecisionSpec {
517 category: DecisionCategory::CouplingBoundary,
518 candidate_key: key.clone(),
519 question: boundary_question(&from_zone, &to_zone),
520 tradeoff: boundary_tradeoff(&from_zone, &to_zone, internal_consumer_count),
521 anchor_file,
522 anchor_line,
523 blast: inputs.affected_not_shown,
524 internal_consumer_count,
525 },
526 inputs,
527 ));
528 }
529
530 if !inputs.deltas.public_api_added.is_empty() {
532 let key = inputs.deltas.public_api_added.join("|");
535 let anchor_file = inputs
536 .deltas
537 .public_api_added
538 .first()
539 .and_then(|k| k.split("::").next())
540 .map(str::to_string)
541 .unwrap_or_default();
542 let internal_consumer_count = (inputs.internal_consumers)(&anchor_file);
543 decisions.push(build_decision(
544 DecisionSpec {
545 category: DecisionCategory::PublicApiContract,
546 candidate_key: key,
547 question: public_api_question(inputs.deltas.public_api_added.len()),
548 tradeoff: public_api_tradeoff(
549 inputs.deltas.public_api_added.len(),
550 internal_consumer_count,
551 ),
552 anchor_file,
553 anchor_line: inputs.public_api_anchor_line,
554 blast: inputs.affected_not_shown,
555 internal_consumer_count,
556 },
557 inputs,
558 ));
559 }
560
561 for gap in inputs.coordination {
564 let key = format!("contract:{}", gap.changed_file);
565 decisions.push(build_decision(
566 DecisionSpec {
567 category: DecisionCategory::PublicApiContract,
568 candidate_key: key,
569 question: coordination_question(
570 &gap.changed_file,
571 &gap.consumed_symbols,
572 gap.consumer_count,
573 ),
574 tradeoff: coordination_tradeoff(gap.consumer_count),
575 anchor_file: gap.changed_file.clone(),
576 anchor_line: gap.line,
577 blast: gap.consumer_count,
578 internal_consumer_count: gap.consumer_count,
581 },
582 inputs,
583 ));
584 }
585
586 decisions
587}
588
589#[must_use]
597pub fn extract_decision_surface(inputs: &DecisionInputs<'_>) -> DecisionSurface {
598 let cap = inputs.cap.clamp(MIN_DECISION_CAP, MAX_DECISION_CAP);
599
600 let mut classified = classify_candidates(inputs);
601
602 let emitted_signal_ids: Vec<String> = classified.iter().map(|d| d.signal_id.clone()).collect();
604
605 classified.retain(|d| {
610 let source = (inputs.head_source)(&d.anchor_file);
611 !is_decision_suppressed(source.as_deref(), d.category, d.anchor_line)
612 });
613
614 classified.sort_by(|a, b| {
616 b.consequence
617 .cmp(&a.consequence)
618 .then_with(|| a.signal_id.cmp(&b.signal_id))
619 });
620
621 let total = classified.len();
622 let truncated = if total > cap {
623 let collapsed = total - cap;
624 classified.truncate(cap);
625 Some(TruncationNote {
626 collapsed,
627 reason: format!(
628 "{collapsed} more structural decision{} collapsed below the cap of {cap}",
629 if collapsed == 1 { "" } else { "s" }
630 ),
631 })
632 } else {
633 None
634 };
635
636 DecisionSurface {
637 decisions: classified,
638 truncated,
639 emitted_signal_ids,
640 }
641}
642
643pub const DECISION_SURFACE_SCHEMA_VERSION: u32 = 1;
647
648#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
651#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
652pub struct DecisionSurfaceSchemaVersion(pub u32);
653
654impl Default for DecisionSurfaceSchemaVersion {
655 fn default() -> Self {
656 Self(DECISION_SURFACE_SCHEMA_VERSION)
657 }
658}
659
660#[derive(Debug, Clone, Serialize)]
663#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
664pub struct DecisionAction {
665 #[serde(rename = "type")]
667 pub action_type: DecisionActionType,
668 pub description: String,
670 #[serde(default, skip_serializing_if = "Option::is_none")]
672 pub command: Option<String>,
673 pub auto_fixable: bool,
676}
677
678#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
680#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
681#[serde(rename_all = "kebab-case")]
682pub enum DecisionActionType {
683 AskExpert,
685 Suppress,
687}
688
689#[derive(Debug, Clone, Serialize)]
691#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
692pub struct DecisionWithActions {
693 #[serde(flatten)]
695 pub decision: Decision,
696 pub actions: Vec<DecisionAction>,
698}
699
700#[derive(Debug, Clone, Serialize)]
705#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
706#[cfg_attr(
707 feature = "schema",
708 schemars(title = "fallow decision-surface --format json")
709)]
710pub struct DecisionSurfaceOutput {
711 pub schema_version: DecisionSurfaceSchemaVersion,
713 pub version: String,
715 pub command: String,
717 pub decisions: Vec<DecisionWithActions>,
719 #[serde(default, skip_serializing_if = "Option::is_none")]
721 pub truncated: Option<TruncationNote>,
722 pub signal_count: usize,
724}
725
726#[must_use]
728fn suppress_comment(category: DecisionCategory) -> String {
729 format!(
730 "// fallow-ignore-next-line decision-surface {}",
731 category.tag()
732 )
733}
734
735fn decision_actions(decision: &Decision) -> Vec<DecisionAction> {
737 let mut actions = Vec::new();
738 if !decision.expert.is_empty() {
739 actions.push(DecisionAction {
740 action_type: DecisionActionType::AskExpert,
741 description: format!("Ask {} to make this call", decision.expert.join(", ")),
742 command: None,
743 auto_fixable: false,
744 });
745 }
746 actions.push(DecisionAction {
747 action_type: DecisionActionType::Suppress,
748 description: "Suppress this decision if it is settled".to_string(),
749 command: Some(suppress_comment(decision.category)),
750 auto_fixable: false,
751 });
752 actions
753}
754
755#[must_use]
762pub fn build_decision_surface_output(surface: &DecisionSurface) -> DecisionSurfaceOutput {
763 debug_assert!(
764 surface
765 .decisions
766 .iter()
767 .all(|d| surface.accept_signal_id(&d.signal_id)
768 && ALL_CATEGORIES.contains(&d.category)),
769 "a surfaced decision has an unanchored signal_id or an out-of-SOLID-3 category"
770 );
771 let decisions = surface
772 .decisions
773 .iter()
774 .map(|decision| DecisionWithActions {
775 actions: decision_actions(decision),
776 decision: decision.clone(),
777 })
778 .collect();
779 DecisionSurfaceOutput {
780 schema_version: DecisionSurfaceSchemaVersion::default(),
781 version: env!("CARGO_PKG_VERSION").to_string(),
782 command: "decision-surface".to_string(),
783 decisions,
784 truncated: surface.truncated.clone(),
785 signal_count: surface.emitted_signal_ids.len(),
786 }
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792 use crate::audit::routing::RoutingUnit;
793
794 fn deltas(boundary: &[&str], public_api: &[&str]) -> ReviewDeltas {
795 ReviewDeltas {
796 boundary_introduced: boundary.iter().map(|s| (*s).to_string()).collect(),
797 cycle_introduced: Vec::new(),
798 public_api_added: public_api.iter().map(|s| (*s).to_string()).collect(),
799 }
800 }
801
802 fn no_source(_: &str) -> Option<String> {
803 None
804 }
805
806 fn no_consumers(_: &str) -> u64 {
807 0
808 }
809
810 fn inputs<'a>(
811 deltas: &'a ReviewDeltas,
812 boundary_anchors: &'a [BoundaryAnchor],
813 coordination: &'a [CoordinationAnchor],
814 routing: &'a RoutingFacts,
815 head_source: &'a dyn Fn(&str) -> Option<String>,
816 cap: usize,
817 ) -> DecisionInputs<'a> {
818 DecisionInputs {
819 deltas,
820 boundary_anchors,
821 coordination,
822 public_api_anchor_line: 0,
823 affected_not_shown: 3,
824 routing,
825 head_source,
826 rename_old_path: &no_source,
827 internal_consumers: &no_consumers,
828 cap,
829 }
830 }
831
832 fn empty_routing() -> RoutingFacts {
833 RoutingFacts::default()
834 }
835
836 #[test]
839 fn only_three_categories_exist_no_cut_category_representable() {
840 let all = [
841 DecisionCategory::CouplingBoundary,
842 DecisionCategory::PublicApiContract,
843 DecisionCategory::Dependency,
844 ];
845 assert_eq!(all.len(), 3);
846 for c in all {
848 let tag = c.tag();
849 for cut in ["abstraction", "deletion", "convention", "irreversib"] {
850 assert!(!tag.contains(cut), "cut category {cut} leaked into {tag}");
851 }
852 }
853 }
854
855 #[test]
857 fn every_decision_signal_id_resolves_to_an_emitted_candidate() {
858 let d = deltas(&["ui->-db"], &["src/api.ts::Widget"]);
859 let anchors = vec![BoundaryAnchor {
860 zone_pair_key: "ui->-db".to_string(),
861 from_file: "src/ui/page.ts".to_string(),
862 from_zone: "ui".to_string(),
863 to_zone: "db".to_string(),
864 line: 4,
865 }];
866 let routing = empty_routing();
867 let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
868 assert!(!surface.decisions.is_empty());
869 for decision in &surface.decisions {
870 assert!(
871 surface.accept_signal_id(&decision.signal_id),
872 "decision {} has an unanchored signal_id",
873 decision.question
874 );
875 }
876 }
877
878 #[test]
880 fn injected_unanchored_signal_id_is_rejected() {
881 let d = deltas(&["ui->-db"], &[]);
882 let anchors = vec![BoundaryAnchor {
883 zone_pair_key: "ui->-db".to_string(),
884 from_file: "src/ui/page.ts".to_string(),
885 from_zone: "ui".to_string(),
886 to_zone: "db".to_string(),
887 line: 1,
888 }];
889 let routing = empty_routing();
890 let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
891 assert!(!surface.accept_signal_id("sig:deadbeefdeadbeef"));
893 assert!(!surface.accept_signal_id("sig:0000000000000000"));
894 let real = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
896 assert!(surface.accept_signal_id(&real));
897 }
898
899 #[test]
901 fn over_cap_input_is_capped_with_truncation_reason() {
902 let d = deltas(&["a->-x", "b->-x", "c->-x", "d->-x", "e->-x", "f->-x"], &[]);
904 let routing = empty_routing();
905 let surface = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 4));
906 assert_eq!(surface.decisions.len(), 4, "capped to default 4");
907 let note = surface.truncated.expect("truncation note present");
908 assert_eq!(note.collapsed, 2);
909 assert!(note.reason.contains("collapsed"));
910 assert!(note.reason.contains('2'));
911 }
912
913 #[test]
914 fn cap_is_clamped_to_the_4_plus_minus_1_band() {
915 let d = deltas(
916 &[
917 "a->-x", "b->-x", "c->-x", "d->-x", "e->-x", "f->-x", "g->-x",
918 ],
919 &[],
920 );
921 let routing = empty_routing();
922 let high = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 10));
924 assert_eq!(high.decisions.len(), MAX_DECISION_CAP);
925 let low = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 1));
927 assert_eq!(low.decisions.len(), MIN_DECISION_CAP);
928 }
929
930 #[test]
932 fn fallow_ignore_suppresses_a_flagged_decision() {
933 let d = deltas(&["ui->-db"], &[]);
934 let anchors = vec![BoundaryAnchor {
935 zone_pair_key: "ui->-db".to_string(),
936 from_file: "src/ui/page.ts".to_string(),
937 from_zone: "ui".to_string(),
938 to_zone: "db".to_string(),
939 line: 3,
940 }];
941 let routing = empty_routing();
942
943 let unsuppressed =
945 extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
946 assert_eq!(unsuppressed.decisions.len(), 1);
947
948 let file_src = |f: &str| {
950 (f == "src/ui/page.ts").then(|| {
951 "// fallow-ignore-file decision-surface\nimport db from 'db';\n".to_string()
952 })
953 };
954 let suppressed =
955 extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &file_src, 4));
956 assert!(
957 suppressed.decisions.is_empty(),
958 "file-level ignore hides it"
959 );
960 let id = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
962 assert!(suppressed.accept_signal_id(&id));
963
964 let line_src = |f: &str| {
966 (f == "src/ui/page.ts").then(|| {
967 "line1\n// fallow-ignore-next-line decision-surface\nimport db from 'db';\n"
968 .to_string()
969 })
970 };
971 let line_suppressed =
972 extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &line_src, 4));
973 assert!(
974 line_suppressed.decisions.is_empty(),
975 "line-level ignore hides it"
976 );
977 }
978
979 #[test]
980 fn bare_blanket_ignore_suppresses_without_a_kind() {
981 let d = deltas(&["ui->-db"], &[]);
982 let anchors = vec![BoundaryAnchor {
983 zone_pair_key: "ui->-db".to_string(),
984 from_file: "src/ui/page.ts".to_string(),
985 from_zone: "ui".to_string(),
986 to_zone: "db".to_string(),
987 line: 2,
988 }];
989 let routing = empty_routing();
990 let bare = |f: &str| {
991 (f == "src/ui/page.ts")
992 .then(|| "// fallow-ignore-next-line\nimport db from 'db';\n".to_string())
993 };
994 let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &bare, 4));
995 assert!(surface.decisions.is_empty(), "bare blanket ignore hides it");
996 }
997
998 #[test]
999 fn unrelated_kind_ignore_does_not_suppress() {
1000 let d = deltas(&["ui->-db"], &[]);
1001 let anchors = vec![BoundaryAnchor {
1002 zone_pair_key: "ui->-db".to_string(),
1003 from_file: "src/ui/page.ts".to_string(),
1004 from_zone: "ui".to_string(),
1005 to_zone: "db".to_string(),
1006 line: 2,
1007 }];
1008 let routing = empty_routing();
1009 let other = |f: &str| {
1010 (f == "src/ui/page.ts").then(|| {
1011 "// fallow-ignore-next-line unused-export\nimport db from 'db';\n".to_string()
1012 })
1013 };
1014 let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &other, 4));
1015 assert_eq!(
1016 surface.decisions.len(),
1017 1,
1018 "an ignore naming a different kind must not suppress a decision"
1019 );
1020 }
1021
1022 #[test]
1023 fn routed_expert_is_paired_with_a_decision() {
1024 let d = deltas(&["ui->-db"], &[]);
1025 let anchors = vec![BoundaryAnchor {
1026 zone_pair_key: "ui->-db".to_string(),
1027 from_file: "src/ui/page.ts".to_string(),
1028 from_zone: "ui".to_string(),
1029 to_zone: "db".to_string(),
1030 line: 1,
1031 }];
1032 let routing = RoutingFacts {
1033 units: vec![RoutingUnit {
1034 file: "src/ui/page.ts".to_string(),
1035 expert: vec!["@team/ui".to_string()],
1036 bus_factor_one: true,
1037 }],
1038 };
1039 let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
1040 assert_eq!(surface.decisions.len(), 1);
1041 assert_eq!(surface.decisions[0].expert, vec!["@team/ui".to_string()]);
1042 assert!(surface.decisions[0].bus_factor_one);
1043 }
1044
1045 #[test]
1046 fn public_api_is_batch_consolidated_to_one_decision_r1() {
1047 let keys: Vec<String> = (0..111).map(|i| format!("src/ui/index.ts::C{i}")).collect();
1049 let key_refs: Vec<&str> = keys.iter().map(String::as_str).collect();
1050 let d = deltas(&[], &key_refs);
1051 let routing = empty_routing();
1052 let surface = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 4));
1053 let public_api_count = surface
1054 .decisions
1055 .iter()
1056 .filter(|dec| dec.category == DecisionCategory::PublicApiContract)
1057 .count();
1058 assert_eq!(
1059 public_api_count, 1,
1060 "R1: one public-API decision per change"
1061 );
1062 assert!(surface.decisions[0].question.contains("111"));
1063 }
1064
1065 #[test]
1066 fn public_api_decision_carries_honest_consumer_count_and_tradeoff() {
1067 let d = deltas(&[], &["src/ui/index.ts::Widget"]);
1071 let routing = empty_routing();
1072 let seven = |_: &str| 7u64;
1073 let surface = extract_decision_surface(&DecisionInputs {
1074 deltas: &d,
1075 boundary_anchors: &[],
1076 coordination: &[],
1077 public_api_anchor_line: 0,
1078 affected_not_shown: 99,
1080 routing: &routing,
1081 head_source: &no_source,
1082 rename_old_path: &no_source,
1083 internal_consumers: &seven,
1084 cap: 4,
1085 });
1086 let dec = surface
1087 .decisions
1088 .iter()
1089 .find(|dec| dec.category == DecisionCategory::PublicApiContract)
1090 .expect("a public-API decision");
1091 assert_eq!(dec.internal_consumer_count, 7, "honest per-anchor count");
1092 assert_ne!(
1093 dec.internal_consumer_count, dec.blast,
1094 "display number must stay distinct from the ranking proxy"
1095 );
1096 assert!(
1097 dec.tradeoff.contains("7 in-repo"),
1098 "trade-off clause states the count as a fact: {}",
1099 dec.tradeoff
1100 );
1101 assert!(
1102 dec.question.ends_with('?'),
1103 "the decision stays a question (taste ownership)"
1104 );
1105 }
1106
1107 #[test]
1108 fn coordination_gap_becomes_a_public_api_contract_decision() {
1109 let d = deltas(&[], &[]);
1110 let coordination = vec![CoordinationAnchor {
1111 changed_file: "src/core.ts".to_string(),
1112 consumed_symbols: vec!["compute".to_string()],
1113 consumer_count: 4,
1114 line: 7,
1115 }];
1116 let routing = empty_routing();
1117 let surface =
1118 extract_decision_surface(&inputs(&d, &[], &coordination, &routing, &no_source, 4));
1119 assert_eq!(surface.decisions.len(), 1);
1120 assert_eq!(
1121 surface.decisions[0].category,
1122 DecisionCategory::PublicApiContract
1123 );
1124 assert_eq!(surface.decisions[0].blast, 4);
1125 assert_eq!(surface.decisions[0].anchor_line, 7);
1128 assert!(surface.decisions[0].previous_signal_id.is_none());
1130 }
1131
1132 #[test]
1133 fn renamed_anchor_carries_a_previous_signal_id_for_review_memory() {
1134 let d = deltas(&[], &[]);
1138 let coordination = vec![CoordinationAnchor {
1139 changed_file: "src/new.ts".to_string(),
1140 consumed_symbols: vec!["compute".to_string()],
1141 consumer_count: 2,
1142 line: 0,
1143 }];
1144 let routing = empty_routing();
1145 let rename = |rel: &str| -> Option<String> {
1146 (rel == "src/new.ts").then(|| "src/old.ts".to_string())
1147 };
1148 let surface = extract_decision_surface(&DecisionInputs {
1149 deltas: &d,
1150 boundary_anchors: &[],
1151 coordination: &coordination,
1152 public_api_anchor_line: 0,
1153 affected_not_shown: 2,
1154 routing: &routing,
1155 head_source: &no_source,
1156 rename_old_path: &rename,
1157 internal_consumers: &no_consumers,
1158 cap: 4,
1159 });
1160 assert_eq!(surface.decisions.len(), 1);
1161 let decision = &surface.decisions[0];
1162 assert_eq!(
1163 decision.signal_id,
1164 derive_signal_id(DecisionCategory::PublicApiContract, "contract:src/new.ts")
1165 );
1166 assert_eq!(
1167 decision.previous_signal_id,
1168 Some(derive_signal_id(
1169 DecisionCategory::PublicApiContract,
1170 "contract:src/old.ts"
1171 ))
1172 );
1173 }
1174
1175 #[test]
1176 fn signal_id_is_deterministic_and_namespaced_by_category() {
1177 let a = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
1178 let b = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
1179 assert_eq!(a, b, "deterministic");
1180 let c = derive_signal_id(DecisionCategory::PublicApiContract, "ui->-db");
1181 assert_ne!(a, c, "category namespaces the hash");
1182 assert!(a.starts_with("sig:"));
1183 }
1184
1185 #[test]
1186 fn consequence_ranks_less_reversible_categories_higher() {
1187 let dep = DecisionCategory::Dependency.reversibility_weight();
1189 let api = DecisionCategory::PublicApiContract.reversibility_weight();
1190 let coupling = DecisionCategory::CouplingBoundary.reversibility_weight();
1191 assert!(dep > api && api > coupling);
1192 }
1193}