1pub use fallow_output::{
42 Decision, DecisionCategory, DecisionSurface, TruncationNote, build_decision_surface_output,
43};
44use xxhash_rust::xxh3::xxh3_64;
45
46use fallow_output::{ReviewDeltas, RoutingFacts};
47
48pub const DEFAULT_DECISION_CAP: usize = 4;
51pub const MIN_DECISION_CAP: usize = 3;
53pub const MAX_DECISION_CAP: usize = 5;
55
56#[must_use]
61pub fn derive_signal_id(category: DecisionCategory, candidate_key: &str) -> String {
62 let mut bytes = Vec::with_capacity(category.tag().len() + 1 + candidate_key.len());
63 bytes.extend_from_slice(category.tag().as_bytes());
64 bytes.push(0);
65 bytes.extend_from_slice(candidate_key.as_bytes());
66 format!("sig:{:016x}", xxh3_64(&bytes))
67}
68
69#[derive(Debug, Clone)]
73pub struct BoundaryAnchor {
74 pub zone_pair_key: String,
77 pub from_file: String,
79 pub from_zone: String,
81 pub to_zone: String,
83 pub line: u32,
85}
86
87#[derive(Debug, Clone)]
90pub struct CoordinationAnchor {
91 pub changed_file: String,
93 pub consumed_symbols: Vec<String>,
95 pub consumer_count: u64,
97 pub line: u32,
101}
102
103pub struct DecisionInputs<'a> {
105 pub deltas: &'a ReviewDeltas,
107 pub boundary_anchors: &'a [BoundaryAnchor],
109 pub coordination: &'a [CoordinationAnchor],
111 pub public_api_anchor_line: u32,
114 pub affected_not_shown: u64,
117 pub routing: &'a RoutingFacts,
119 pub head_source: &'a dyn Fn(&str) -> Option<String>,
122 pub rename_old_path: &'a dyn Fn(&str) -> Option<String>,
126 pub internal_consumers: &'a dyn Fn(&str) -> u64,
131 pub cap: usize,
133}
134
135fn route_for(routing: &RoutingFacts, anchor_file: &str) -> (Vec<String>, bool) {
137 routing
138 .units
139 .iter()
140 .find(|unit| unit.file == anchor_file)
141 .map_or((Vec::new(), false), |unit| {
142 (unit.expert.clone(), unit.bus_factor_one)
143 })
144}
145
146fn is_decision_suppressed(
151 head_source: Option<&str>,
152 category: DecisionCategory,
153 line: u32,
154) -> bool {
155 let Some(source) = head_source else {
156 return false;
157 };
158 let lines: Vec<&str> = source.lines().collect();
159 let token_matches = |comment: &str| {
160 if !comment.contains("fallow-ignore") {
161 return false;
162 }
163 let after = comment
166 .split_once("fallow-ignore-file")
167 .or_else(|| comment.split_once("fallow-ignore-next-line"))
168 .map(|(_, rest)| rest.trim());
169 match after {
170 None => false,
171 Some("") => true,
172 Some(rest) => {
173 rest.contains("decision-surface")
174 || rest.contains("decision-surfaces")
175 || rest.contains(category.tag())
176 }
177 }
178 };
179
180 if lines
182 .iter()
183 .any(|l| l.contains("fallow-ignore-file") && token_matches(l))
184 {
185 return true;
186 }
187 if line >= 2
189 && let Some(prev) = lines.get((line - 2) as usize)
190 && prev.contains("fallow-ignore-next-line")
191 && token_matches(prev)
192 {
193 return true;
194 }
195 false
196}
197
198fn boundary_question(from_zone: &str, to_zone: &str) -> String {
200 format!(
201 "`{from_zone}` now imports `{to_zone}` for the first time. Intended coupling, or should this edge not exist?"
202 )
203}
204
205fn public_api_question(count: usize) -> String {
207 format!(
208 "This change adds {count} export{} to the public API surface. Intended as maintained contracts, or should they stay internal?",
209 if count == 1 { "" } else { "s" }
210 )
211}
212
213fn coordination_question(changed_file: &str, symbols: &[String], consumers: u64) -> String {
215 format!(
216 "`{changed_file}` changes {} ({}) imported by {consumers} {} outside this PR. Does this change break or alter what those callers expect?",
217 if symbols.len() == 1 {
218 "export"
219 } else {
220 "exports"
221 },
222 symbols.join(", "),
223 if consumers == 1 { "file" } else { "files" }
224 )
225}
226
227fn modules_word(n: u64) -> &'static str {
229 if n == 1 { "module" } else { "modules" }
230}
231
232fn agrees(verb_plural: &str, n: u64) -> String {
235 if n == 1 {
236 format!("{verb_plural}s")
237 } else {
238 verb_plural.to_string()
239 }
240}
241
242fn boundary_tradeoff(from_zone: &str, to_zone: &str, consumers: u64) -> String {
245 format!(
246 "Couples `{from_zone}` to `{to_zone}`; {consumers} in-repo {} already {} on this anchor.",
247 modules_word(consumers),
248 agrees("depend", consumers)
249 )
250}
251
252fn public_api_tradeoff(count: usize, consumers: u64) -> String {
256 format!(
257 "Adds {count} maintained contract{}; {consumers} in-repo {} already {} this surface, and any external consumers become a contract you cannot remove without a breaking change.",
258 if count == 1 { "" } else { "s" },
259 modules_word(consumers),
260 agrees("consume", consumers)
261 )
262}
263
264fn coordination_tradeoff(consumers: u64) -> String {
266 format!(
267 "{consumers} {} outside the diff {} this contract; changing its shape requires coordinating them.",
268 modules_word(consumers),
269 agrees("consume", consumers)
270 )
271}
272
273struct DecisionSpec {
276 category: DecisionCategory,
277 candidate_key: String,
278 question: String,
279 anchor_file: String,
280 anchor_line: u32,
281 blast: u64,
282 internal_consumer_count: u64,
284 tradeoff: String,
286}
287
288fn build_decision(spec: DecisionSpec, inputs: &DecisionInputs<'_>) -> Decision {
290 let DecisionSpec {
291 category,
292 candidate_key,
293 question,
294 anchor_file,
295 anchor_line,
296 blast,
297 internal_consumer_count,
298 tradeoff,
299 } = spec;
300 let signal_id = derive_signal_id(category, &candidate_key);
301 let previous_signal_id = remap_key_paths(&candidate_key, inputs.rename_old_path)
305 .map(|old_key| derive_signal_id(category, &old_key));
306 let (expert, bus_factor_one) = route_for(inputs.routing, &anchor_file);
307 let consequence = blast.saturating_mul(category.reversibility_weight());
308 Decision {
309 signal_id,
310 category,
311 question,
312 anchor_file,
313 anchor_line,
314 signal_key: candidate_key,
315 previous_signal_id,
316 blast,
317 consequence,
318 expert,
319 bus_factor_one,
320 internal_consumer_count,
321 tradeoff,
322 }
323}
324
325fn remap_key_paths(key: &str, rename_old_path: &dyn Fn(&str) -> Option<String>) -> Option<String> {
330 let mut moved = false;
331 let mut parts: Vec<String> = key
332 .split('|')
333 .map(|segment| {
334 if let Some(path) = segment.strip_prefix("contract:")
335 && let Some(old) = rename_old_path(path)
336 {
337 moved = true;
338 return format!("contract:{old}");
339 } else if let Some((path, name)) = segment.split_once("::")
340 && let Some(old) = rename_old_path(path)
341 {
342 moved = true;
343 return format!("{old}::{name}");
344 }
345 segment.to_string()
346 })
347 .collect();
348 if !moved {
349 return None;
350 }
351 parts.sort();
354 Some(parts.join("|"))
355}
356
357fn classify_candidates(inputs: &DecisionInputs<'_>) -> Vec<Decision> {
359 let mut decisions: Vec<Decision> = Vec::new();
360
361 for key in &inputs.deltas.boundary_introduced {
363 let anchor = inputs
364 .boundary_anchors
365 .iter()
366 .find(|a| &a.zone_pair_key == key);
367 let (anchor_file, anchor_line, from_zone, to_zone) = anchor.map_or_else(
368 || (String::new(), 0, key.clone(), String::new()),
369 |a| {
370 (
371 a.from_file.clone(),
372 a.line,
373 a.from_zone.clone(),
374 a.to_zone.clone(),
375 )
376 },
377 );
378 let internal_consumer_count = (inputs.internal_consumers)(&anchor_file);
379 decisions.push(build_decision(
380 DecisionSpec {
381 category: DecisionCategory::CouplingBoundary,
382 candidate_key: key.clone(),
383 question: boundary_question(&from_zone, &to_zone),
384 tradeoff: boundary_tradeoff(&from_zone, &to_zone, internal_consumer_count),
385 anchor_file,
386 anchor_line,
387 blast: inputs.affected_not_shown,
388 internal_consumer_count,
389 },
390 inputs,
391 ));
392 }
393
394 if !inputs.deltas.public_api_added.is_empty() {
396 let key = inputs.deltas.public_api_added.join("|");
399 let anchor_file = inputs
400 .deltas
401 .public_api_added
402 .first()
403 .and_then(|k| k.split("::").next())
404 .map(str::to_string)
405 .unwrap_or_default();
406 let internal_consumer_count = (inputs.internal_consumers)(&anchor_file);
407 decisions.push(build_decision(
408 DecisionSpec {
409 category: DecisionCategory::PublicApiContract,
410 candidate_key: key,
411 question: public_api_question(inputs.deltas.public_api_added.len()),
412 tradeoff: public_api_tradeoff(
413 inputs.deltas.public_api_added.len(),
414 internal_consumer_count,
415 ),
416 anchor_file,
417 anchor_line: inputs.public_api_anchor_line,
418 blast: inputs.affected_not_shown,
419 internal_consumer_count,
420 },
421 inputs,
422 ));
423 }
424
425 for gap in inputs.coordination {
428 let key = format!("contract:{}", gap.changed_file);
429 decisions.push(build_decision(
430 DecisionSpec {
431 category: DecisionCategory::PublicApiContract,
432 candidate_key: key,
433 question: coordination_question(
434 &gap.changed_file,
435 &gap.consumed_symbols,
436 gap.consumer_count,
437 ),
438 tradeoff: coordination_tradeoff(gap.consumer_count),
439 anchor_file: gap.changed_file.clone(),
440 anchor_line: gap.line,
441 blast: gap.consumer_count,
442 internal_consumer_count: gap.consumer_count,
445 },
446 inputs,
447 ));
448 }
449
450 decisions
451}
452
453#[must_use]
461pub fn extract_decision_surface(inputs: &DecisionInputs<'_>) -> DecisionSurface {
462 let cap = inputs.cap.clamp(MIN_DECISION_CAP, MAX_DECISION_CAP);
463
464 let mut classified = classify_candidates(inputs);
465
466 let emitted_signal_ids: Vec<String> = classified.iter().map(|d| d.signal_id.clone()).collect();
468
469 classified.retain(|d| {
474 let source = (inputs.head_source)(&d.anchor_file);
475 !is_decision_suppressed(source.as_deref(), d.category, d.anchor_line)
476 });
477
478 classified.sort_by(|a, b| {
480 b.consequence
481 .cmp(&a.consequence)
482 .then_with(|| a.signal_id.cmp(&b.signal_id))
483 });
484
485 let total = classified.len();
486 let truncated = if total > cap {
487 let collapsed = total - cap;
488 classified.truncate(cap);
489 Some(TruncationNote {
490 collapsed,
491 reason: format!(
492 "{collapsed} more structural decision{} collapsed below the cap of {cap}",
493 if collapsed == 1 { "" } else { "s" }
494 ),
495 })
496 } else {
497 None
498 };
499
500 DecisionSurface {
501 decisions: classified,
502 truncated,
503 emitted_signal_ids,
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use fallow_output::RoutingUnit;
511
512 fn deltas(boundary: &[&str], public_api: &[&str]) -> ReviewDeltas {
513 ReviewDeltas {
514 boundary_introduced: boundary.iter().map(|s| (*s).to_string()).collect(),
515 cycle_introduced: Vec::new(),
516 public_api_added: public_api.iter().map(|s| (*s).to_string()).collect(),
517 }
518 }
519
520 fn no_source(_: &str) -> Option<String> {
521 None
522 }
523
524 fn no_consumers(_: &str) -> u64 {
525 0
526 }
527
528 fn inputs<'a>(
529 deltas: &'a ReviewDeltas,
530 boundary_anchors: &'a [BoundaryAnchor],
531 coordination: &'a [CoordinationAnchor],
532 routing: &'a RoutingFacts,
533 head_source: &'a dyn Fn(&str) -> Option<String>,
534 cap: usize,
535 ) -> DecisionInputs<'a> {
536 DecisionInputs {
537 deltas,
538 boundary_anchors,
539 coordination,
540 public_api_anchor_line: 0,
541 affected_not_shown: 3,
542 routing,
543 head_source,
544 rename_old_path: &no_source,
545 internal_consumers: &no_consumers,
546 cap,
547 }
548 }
549
550 fn empty_routing() -> RoutingFacts {
551 RoutingFacts::default()
552 }
553
554 #[test]
557 fn only_three_categories_exist_no_cut_category_representable() {
558 let all = [
559 DecisionCategory::CouplingBoundary,
560 DecisionCategory::PublicApiContract,
561 DecisionCategory::Dependency,
562 ];
563 assert_eq!(all.len(), 3);
564 for c in all {
566 let tag = c.tag();
567 for cut in ["abstraction", "deletion", "convention", "irreversib"] {
568 assert!(!tag.contains(cut), "cut category {cut} leaked into {tag}");
569 }
570 }
571 }
572
573 #[test]
575 fn every_decision_signal_id_resolves_to_an_emitted_candidate() {
576 let d = deltas(&["ui->-db"], &["src/api.ts::Widget"]);
577 let anchors = vec![BoundaryAnchor {
578 zone_pair_key: "ui->-db".to_string(),
579 from_file: "src/ui/page.ts".to_string(),
580 from_zone: "ui".to_string(),
581 to_zone: "db".to_string(),
582 line: 4,
583 }];
584 let routing = empty_routing();
585 let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
586 assert!(!surface.decisions.is_empty());
587 for decision in &surface.decisions {
588 assert!(
589 surface.accept_signal_id(&decision.signal_id),
590 "decision {} has an unanchored signal_id",
591 decision.question
592 );
593 }
594 }
595
596 #[test]
598 fn injected_unanchored_signal_id_is_rejected() {
599 let d = deltas(&["ui->-db"], &[]);
600 let anchors = vec![BoundaryAnchor {
601 zone_pair_key: "ui->-db".to_string(),
602 from_file: "src/ui/page.ts".to_string(),
603 from_zone: "ui".to_string(),
604 to_zone: "db".to_string(),
605 line: 1,
606 }];
607 let routing = empty_routing();
608 let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
609 assert!(!surface.accept_signal_id("sig:deadbeefdeadbeef"));
611 assert!(!surface.accept_signal_id("sig:0000000000000000"));
612 let real = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
614 assert!(surface.accept_signal_id(&real));
615 }
616
617 #[test]
619 fn over_cap_input_is_capped_with_truncation_reason() {
620 let d = deltas(&["a->-x", "b->-x", "c->-x", "d->-x", "e->-x", "f->-x"], &[]);
622 let routing = empty_routing();
623 let surface = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 4));
624 assert_eq!(surface.decisions.len(), 4, "capped to default 4");
625 let note = surface.truncated.expect("truncation note present");
626 assert_eq!(note.collapsed, 2);
627 assert!(note.reason.contains("collapsed"));
628 assert!(note.reason.contains('2'));
629 }
630
631 #[test]
632 fn cap_is_clamped_to_the_4_plus_minus_1_band() {
633 let d = deltas(
634 &[
635 "a->-x", "b->-x", "c->-x", "d->-x", "e->-x", "f->-x", "g->-x",
636 ],
637 &[],
638 );
639 let routing = empty_routing();
640 let high = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 10));
642 assert_eq!(high.decisions.len(), MAX_DECISION_CAP);
643 let low = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 1));
645 assert_eq!(low.decisions.len(), MIN_DECISION_CAP);
646 }
647
648 #[test]
650 fn fallow_ignore_suppresses_a_flagged_decision() {
651 let d = deltas(&["ui->-db"], &[]);
652 let anchors = vec![BoundaryAnchor {
653 zone_pair_key: "ui->-db".to_string(),
654 from_file: "src/ui/page.ts".to_string(),
655 from_zone: "ui".to_string(),
656 to_zone: "db".to_string(),
657 line: 3,
658 }];
659 let routing = empty_routing();
660
661 let unsuppressed =
663 extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
664 assert_eq!(unsuppressed.decisions.len(), 1);
665
666 let file_src = |f: &str| {
668 (f == "src/ui/page.ts").then(|| {
669 "// fallow-ignore-file decision-surface\nimport db from 'db';\n".to_string()
670 })
671 };
672 let suppressed =
673 extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &file_src, 4));
674 assert!(
675 suppressed.decisions.is_empty(),
676 "file-level ignore hides it"
677 );
678 let id = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
680 assert!(suppressed.accept_signal_id(&id));
681
682 let line_src = |f: &str| {
684 (f == "src/ui/page.ts").then(|| {
685 "line1\n// fallow-ignore-next-line decision-surface\nimport db from 'db';\n"
686 .to_string()
687 })
688 };
689 let line_suppressed =
690 extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &line_src, 4));
691 assert!(
692 line_suppressed.decisions.is_empty(),
693 "line-level ignore hides it"
694 );
695 }
696
697 #[test]
698 fn bare_blanket_ignore_suppresses_without_a_kind() {
699 let d = deltas(&["ui->-db"], &[]);
700 let anchors = vec![BoundaryAnchor {
701 zone_pair_key: "ui->-db".to_string(),
702 from_file: "src/ui/page.ts".to_string(),
703 from_zone: "ui".to_string(),
704 to_zone: "db".to_string(),
705 line: 2,
706 }];
707 let routing = empty_routing();
708 let bare = |f: &str| {
709 (f == "src/ui/page.ts")
710 .then(|| "// fallow-ignore-next-line\nimport db from 'db';\n".to_string())
711 };
712 let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &bare, 4));
713 assert!(surface.decisions.is_empty(), "bare blanket ignore hides it");
714 }
715
716 #[test]
717 fn unrelated_kind_ignore_does_not_suppress() {
718 let d = deltas(&["ui->-db"], &[]);
719 let anchors = vec![BoundaryAnchor {
720 zone_pair_key: "ui->-db".to_string(),
721 from_file: "src/ui/page.ts".to_string(),
722 from_zone: "ui".to_string(),
723 to_zone: "db".to_string(),
724 line: 2,
725 }];
726 let routing = empty_routing();
727 let other = |f: &str| {
728 (f == "src/ui/page.ts").then(|| {
729 "// fallow-ignore-next-line unused-export\nimport db from 'db';\n".to_string()
730 })
731 };
732 let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &other, 4));
733 assert_eq!(
734 surface.decisions.len(),
735 1,
736 "an ignore naming a different kind must not suppress a decision"
737 );
738 }
739
740 #[test]
741 fn routed_expert_is_paired_with_a_decision() {
742 let d = deltas(&["ui->-db"], &[]);
743 let anchors = vec![BoundaryAnchor {
744 zone_pair_key: "ui->-db".to_string(),
745 from_file: "src/ui/page.ts".to_string(),
746 from_zone: "ui".to_string(),
747 to_zone: "db".to_string(),
748 line: 1,
749 }];
750 let routing = RoutingFacts {
751 units: vec![RoutingUnit {
752 file: "src/ui/page.ts".to_string(),
753 expert: vec!["@team/ui".to_string()],
754 bus_factor_one: true,
755 }],
756 };
757 let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
758 assert_eq!(surface.decisions.len(), 1);
759 assert_eq!(surface.decisions[0].expert, vec!["@team/ui".to_string()]);
760 assert!(surface.decisions[0].bus_factor_one);
761 }
762
763 #[test]
764 fn public_api_is_batch_consolidated_to_one_decision_r1() {
765 let keys: Vec<String> = (0..111).map(|i| format!("src/ui/index.ts::C{i}")).collect();
767 let key_refs: Vec<&str> = keys.iter().map(String::as_str).collect();
768 let d = deltas(&[], &key_refs);
769 let routing = empty_routing();
770 let surface = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 4));
771 let public_api_count = surface
772 .decisions
773 .iter()
774 .filter(|dec| dec.category == DecisionCategory::PublicApiContract)
775 .count();
776 assert_eq!(
777 public_api_count, 1,
778 "R1: one public-API decision per change"
779 );
780 assert!(surface.decisions[0].question.contains("111"));
781 }
782
783 #[test]
784 fn public_api_decision_carries_honest_consumer_count_and_tradeoff() {
785 let d = deltas(&[], &["src/ui/index.ts::Widget"]);
789 let routing = empty_routing();
790 let seven = |_: &str| 7u64;
791 let surface = extract_decision_surface(&DecisionInputs {
792 deltas: &d,
793 boundary_anchors: &[],
794 coordination: &[],
795 public_api_anchor_line: 0,
796 affected_not_shown: 99,
798 routing: &routing,
799 head_source: &no_source,
800 rename_old_path: &no_source,
801 internal_consumers: &seven,
802 cap: 4,
803 });
804 let dec = surface
805 .decisions
806 .iter()
807 .find(|dec| dec.category == DecisionCategory::PublicApiContract)
808 .expect("a public-API decision");
809 assert_eq!(dec.internal_consumer_count, 7, "honest per-anchor count");
810 assert_ne!(
811 dec.internal_consumer_count, dec.blast,
812 "display number must stay distinct from the ranking proxy"
813 );
814 assert!(
815 dec.tradeoff.contains("7 in-repo"),
816 "trade-off clause states the count as a fact: {}",
817 dec.tradeoff
818 );
819 assert!(
820 dec.question.ends_with('?'),
821 "the decision stays a question (taste ownership)"
822 );
823 }
824
825 #[test]
826 fn coordination_gap_becomes_a_public_api_contract_decision() {
827 let d = deltas(&[], &[]);
828 let coordination = vec![CoordinationAnchor {
829 changed_file: "src/core.ts".to_string(),
830 consumed_symbols: vec!["compute".to_string()],
831 consumer_count: 4,
832 line: 7,
833 }];
834 let routing = empty_routing();
835 let surface =
836 extract_decision_surface(&inputs(&d, &[], &coordination, &routing, &no_source, 4));
837 assert_eq!(surface.decisions.len(), 1);
838 assert_eq!(
839 surface.decisions[0].category,
840 DecisionCategory::PublicApiContract
841 );
842 assert_eq!(surface.decisions[0].blast, 4);
843 assert_eq!(surface.decisions[0].anchor_line, 7);
846 assert!(surface.decisions[0].previous_signal_id.is_none());
848 }
849
850 #[test]
851 fn renamed_anchor_carries_a_previous_signal_id_for_review_memory() {
852 let d = deltas(&[], &[]);
856 let coordination = vec![CoordinationAnchor {
857 changed_file: "src/new.ts".to_string(),
858 consumed_symbols: vec!["compute".to_string()],
859 consumer_count: 2,
860 line: 0,
861 }];
862 let routing = empty_routing();
863 let rename = |rel: &str| -> Option<String> {
864 (rel == "src/new.ts").then(|| "src/old.ts".to_string())
865 };
866 let surface = extract_decision_surface(&DecisionInputs {
867 deltas: &d,
868 boundary_anchors: &[],
869 coordination: &coordination,
870 public_api_anchor_line: 0,
871 affected_not_shown: 2,
872 routing: &routing,
873 head_source: &no_source,
874 rename_old_path: &rename,
875 internal_consumers: &no_consumers,
876 cap: 4,
877 });
878 assert_eq!(surface.decisions.len(), 1);
879 let decision = &surface.decisions[0];
880 assert_eq!(
881 decision.signal_id,
882 derive_signal_id(DecisionCategory::PublicApiContract, "contract:src/new.ts")
883 );
884 assert_eq!(
885 decision.previous_signal_id,
886 Some(derive_signal_id(
887 DecisionCategory::PublicApiContract,
888 "contract:src/old.ts"
889 ))
890 );
891 }
892
893 #[test]
894 fn signal_id_is_deterministic_and_namespaced_by_category() {
895 let a = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
896 let b = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
897 assert_eq!(a, b, "deterministic");
898 let c = derive_signal_id(DecisionCategory::PublicApiContract, "ui->-db");
899 assert_ne!(a, c, "category namespaces the hash");
900 assert!(a.starts_with("sig:"));
901 }
902
903 #[test]
904 fn consequence_ranks_less_reversible_categories_higher() {
905 let dep = DecisionCategory::Dependency.reversibility_weight();
907 let api = DecisionCategory::PublicApiContract.reversibility_weight();
908 let coupling = DecisionCategory::CouplingBoundary.reversibility_weight();
909 assert!(dep > api && api > coupling);
910 }
911}