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