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 append_boundary_decisions(&mut decisions, inputs);
361 append_public_api_decision(&mut decisions, inputs);
362 append_coordination_decisions(&mut decisions, inputs);
363 decisions
364}
365
366fn append_boundary_decisions(decisions: &mut Vec<Decision>, inputs: &DecisionInputs<'_>) {
367 for key in &inputs.deltas.boundary_introduced {
368 let anchor = inputs
369 .boundary_anchors
370 .iter()
371 .find(|a| &a.zone_pair_key == key);
372 let (anchor_file, anchor_line, from_zone, to_zone) = anchor.map_or_else(
373 || (String::new(), 0, key.clone(), String::new()),
374 |a| {
375 (
376 a.from_file.clone(),
377 a.line,
378 a.from_zone.clone(),
379 a.to_zone.clone(),
380 )
381 },
382 );
383 let internal_consumer_count = (inputs.internal_consumers)(&anchor_file);
384 decisions.push(build_decision(
385 DecisionSpec {
386 category: DecisionCategory::CouplingBoundary,
387 candidate_key: key.clone(),
388 question: boundary_question(&from_zone, &to_zone),
389 tradeoff: boundary_tradeoff(&from_zone, &to_zone, internal_consumer_count),
390 anchor_file,
391 anchor_line,
392 blast: inputs.affected_not_shown,
393 internal_consumer_count,
394 },
395 inputs,
396 ));
397 }
398}
399
400fn append_public_api_decision(decisions: &mut Vec<Decision>, inputs: &DecisionInputs<'_>) {
401 if !inputs.deltas.public_api_added.is_empty() {
402 let key = inputs.deltas.public_api_added.join("|");
405 let anchor_file = inputs
406 .deltas
407 .public_api_added
408 .first()
409 .and_then(|k| k.split("::").next())
410 .map(str::to_string)
411 .unwrap_or_default();
412 let internal_consumer_count = (inputs.internal_consumers)(&anchor_file);
413 decisions.push(build_decision(
414 DecisionSpec {
415 category: DecisionCategory::PublicApiContract,
416 candidate_key: key,
417 question: public_api_question(inputs.deltas.public_api_added.len()),
418 tradeoff: public_api_tradeoff(
419 inputs.deltas.public_api_added.len(),
420 internal_consumer_count,
421 ),
422 anchor_file,
423 anchor_line: inputs.public_api_anchor_line,
424 blast: inputs.affected_not_shown,
425 internal_consumer_count,
426 },
427 inputs,
428 ));
429 }
430}
431
432fn append_coordination_decisions(decisions: &mut Vec<Decision>, inputs: &DecisionInputs<'_>) {
433 for gap in inputs.coordination {
434 let key = format!("contract:{}", gap.changed_file);
435 decisions.push(build_decision(
436 DecisionSpec {
437 category: DecisionCategory::PublicApiContract,
438 candidate_key: key,
439 question: coordination_question(
440 &gap.changed_file,
441 &gap.consumed_symbols,
442 gap.consumer_count,
443 ),
444 tradeoff: coordination_tradeoff(gap.consumer_count),
445 anchor_file: gap.changed_file.clone(),
446 anchor_line: gap.line,
447 blast: gap.consumer_count,
448 internal_consumer_count: gap.consumer_count,
451 },
452 inputs,
453 ));
454 }
455}
456
457#[must_use]
465pub fn extract_decision_surface(inputs: &DecisionInputs<'_>) -> DecisionSurface {
466 let cap = inputs.cap.clamp(MIN_DECISION_CAP, MAX_DECISION_CAP);
467
468 let mut classified = classify_candidates(inputs);
469
470 let emitted_signal_ids: Vec<String> = classified.iter().map(|d| d.signal_id.clone()).collect();
472
473 classified.retain(|d| {
478 let source = (inputs.head_source)(&d.anchor_file);
479 !is_decision_suppressed(source.as_deref(), d.category, d.anchor_line)
480 });
481
482 classified.sort_by(|a, b| {
484 b.consequence
485 .cmp(&a.consequence)
486 .then_with(|| a.signal_id.cmp(&b.signal_id))
487 });
488
489 let total = classified.len();
490 let truncated = if total > cap {
491 let collapsed = total - cap;
492 classified.truncate(cap);
493 Some(TruncationNote {
494 collapsed,
495 reason: format!(
496 "{collapsed} more structural decision{} collapsed below the cap of {cap}",
497 if collapsed == 1 { "" } else { "s" }
498 ),
499 })
500 } else {
501 None
502 };
503
504 DecisionSurface {
505 decisions: classified,
506 truncated,
507 emitted_signal_ids,
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use fallow_output::RoutingUnit;
515
516 fn deltas(boundary: &[&str], public_api: &[&str]) -> ReviewDeltas {
517 ReviewDeltas {
518 boundary_introduced: boundary.iter().map(|s| (*s).to_string()).collect(),
519 cycle_introduced: Vec::new(),
520 public_api_added: public_api.iter().map(|s| (*s).to_string()).collect(),
521 }
522 }
523
524 fn no_source(_: &str) -> Option<String> {
525 None
526 }
527
528 fn no_consumers(_: &str) -> u64 {
529 0
530 }
531
532 fn inputs<'a>(
533 deltas: &'a ReviewDeltas,
534 boundary_anchors: &'a [BoundaryAnchor],
535 coordination: &'a [CoordinationAnchor],
536 routing: &'a RoutingFacts,
537 head_source: &'a dyn Fn(&str) -> Option<String>,
538 cap: usize,
539 ) -> DecisionInputs<'a> {
540 DecisionInputs {
541 deltas,
542 boundary_anchors,
543 coordination,
544 public_api_anchor_line: 0,
545 affected_not_shown: 3,
546 routing,
547 head_source,
548 rename_old_path: &no_source,
549 internal_consumers: &no_consumers,
550 cap,
551 }
552 }
553
554 fn empty_routing() -> RoutingFacts {
555 RoutingFacts::default()
556 }
557
558 #[test]
561 fn only_three_categories_exist_no_cut_category_representable() {
562 let all = [
563 DecisionCategory::CouplingBoundary,
564 DecisionCategory::PublicApiContract,
565 DecisionCategory::Dependency,
566 ];
567 assert_eq!(all.len(), 3);
568 for c in all {
570 let tag = c.tag();
571 for cut in ["abstraction", "deletion", "convention", "irreversib"] {
572 assert!(!tag.contains(cut), "cut category {cut} leaked into {tag}");
573 }
574 }
575 }
576
577 #[test]
579 fn every_decision_signal_id_resolves_to_an_emitted_candidate() {
580 let d = deltas(&["ui->-db"], &["src/api.ts::Widget"]);
581 let anchors = vec![BoundaryAnchor {
582 zone_pair_key: "ui->-db".to_string(),
583 from_file: "src/ui/page.ts".to_string(),
584 from_zone: "ui".to_string(),
585 to_zone: "db".to_string(),
586 line: 4,
587 }];
588 let routing = empty_routing();
589 let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
590 assert!(!surface.decisions.is_empty());
591 for decision in &surface.decisions {
592 assert!(
593 surface.accept_signal_id(&decision.signal_id),
594 "decision {} has an unanchored signal_id",
595 decision.question
596 );
597 }
598 }
599
600 #[test]
602 fn injected_unanchored_signal_id_is_rejected() {
603 let d = deltas(&["ui->-db"], &[]);
604 let anchors = vec![BoundaryAnchor {
605 zone_pair_key: "ui->-db".to_string(),
606 from_file: "src/ui/page.ts".to_string(),
607 from_zone: "ui".to_string(),
608 to_zone: "db".to_string(),
609 line: 1,
610 }];
611 let routing = empty_routing();
612 let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
613 assert!(!surface.accept_signal_id("sig:deadbeefdeadbeef"));
615 assert!(!surface.accept_signal_id("sig:0000000000000000"));
616 let real = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
618 assert!(surface.accept_signal_id(&real));
619 }
620
621 #[test]
623 fn over_cap_input_is_capped_with_truncation_reason() {
624 let d = deltas(&["a->-x", "b->-x", "c->-x", "d->-x", "e->-x", "f->-x"], &[]);
626 let routing = empty_routing();
627 let surface = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 4));
628 assert_eq!(surface.decisions.len(), 4, "capped to default 4");
629 let note = surface.truncated.expect("truncation note present");
630 assert_eq!(note.collapsed, 2);
631 assert!(note.reason.contains("collapsed"));
632 assert!(note.reason.contains('2'));
633 }
634
635 #[test]
636 fn cap_is_clamped_to_the_4_plus_minus_1_band() {
637 let d = deltas(
638 &[
639 "a->-x", "b->-x", "c->-x", "d->-x", "e->-x", "f->-x", "g->-x",
640 ],
641 &[],
642 );
643 let routing = empty_routing();
644 let high = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 10));
646 assert_eq!(high.decisions.len(), MAX_DECISION_CAP);
647 let low = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 1));
649 assert_eq!(low.decisions.len(), MIN_DECISION_CAP);
650 }
651
652 #[test]
654 fn fallow_ignore_suppresses_a_flagged_decision() {
655 let d = deltas(&["ui->-db"], &[]);
656 let anchors = vec![BoundaryAnchor {
657 zone_pair_key: "ui->-db".to_string(),
658 from_file: "src/ui/page.ts".to_string(),
659 from_zone: "ui".to_string(),
660 to_zone: "db".to_string(),
661 line: 3,
662 }];
663 let routing = empty_routing();
664
665 let unsuppressed =
667 extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
668 assert_eq!(unsuppressed.decisions.len(), 1);
669
670 let file_src = |f: &str| {
672 (f == "src/ui/page.ts").then(|| {
673 "// fallow-ignore-file decision-surface\nimport db from 'db';\n".to_string()
674 })
675 };
676 let suppressed =
677 extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &file_src, 4));
678 assert!(
679 suppressed.decisions.is_empty(),
680 "file-level ignore hides it"
681 );
682 let id = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
684 assert!(suppressed.accept_signal_id(&id));
685
686 let line_src = |f: &str| {
688 (f == "src/ui/page.ts").then(|| {
689 "line1\n// fallow-ignore-next-line decision-surface\nimport db from 'db';\n"
690 .to_string()
691 })
692 };
693 let line_suppressed =
694 extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &line_src, 4));
695 assert!(
696 line_suppressed.decisions.is_empty(),
697 "line-level ignore hides it"
698 );
699 }
700
701 #[test]
702 fn bare_blanket_ignore_suppresses_without_a_kind() {
703 let d = deltas(&["ui->-db"], &[]);
704 let anchors = vec![BoundaryAnchor {
705 zone_pair_key: "ui->-db".to_string(),
706 from_file: "src/ui/page.ts".to_string(),
707 from_zone: "ui".to_string(),
708 to_zone: "db".to_string(),
709 line: 2,
710 }];
711 let routing = empty_routing();
712 let bare = |f: &str| {
713 (f == "src/ui/page.ts")
714 .then(|| "// fallow-ignore-next-line\nimport db from 'db';\n".to_string())
715 };
716 let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &bare, 4));
717 assert!(surface.decisions.is_empty(), "bare blanket ignore hides it");
718 }
719
720 #[test]
721 fn unrelated_kind_ignore_does_not_suppress() {
722 let d = deltas(&["ui->-db"], &[]);
723 let anchors = vec![BoundaryAnchor {
724 zone_pair_key: "ui->-db".to_string(),
725 from_file: "src/ui/page.ts".to_string(),
726 from_zone: "ui".to_string(),
727 to_zone: "db".to_string(),
728 line: 2,
729 }];
730 let routing = empty_routing();
731 let other = |f: &str| {
732 (f == "src/ui/page.ts").then(|| {
733 "// fallow-ignore-next-line unused-export\nimport db from 'db';\n".to_string()
734 })
735 };
736 let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &other, 4));
737 assert_eq!(
738 surface.decisions.len(),
739 1,
740 "an ignore naming a different kind must not suppress a decision"
741 );
742 }
743
744 #[test]
745 fn routed_expert_is_paired_with_a_decision() {
746 let d = deltas(&["ui->-db"], &[]);
747 let anchors = vec![BoundaryAnchor {
748 zone_pair_key: "ui->-db".to_string(),
749 from_file: "src/ui/page.ts".to_string(),
750 from_zone: "ui".to_string(),
751 to_zone: "db".to_string(),
752 line: 1,
753 }];
754 let routing = RoutingFacts {
755 units: vec![RoutingUnit {
756 file: "src/ui/page.ts".to_string(),
757 expert: vec!["@team/ui".to_string()],
758 bus_factor_one: true,
759 }],
760 };
761 let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
762 assert_eq!(surface.decisions.len(), 1);
763 assert_eq!(surface.decisions[0].expert, vec!["@team/ui".to_string()]);
764 assert!(surface.decisions[0].bus_factor_one);
765 }
766
767 #[test]
768 fn public_api_is_batch_consolidated_to_one_decision_r1() {
769 let keys: Vec<String> = (0..111).map(|i| format!("src/ui/index.ts::C{i}")).collect();
771 let key_refs: Vec<&str> = keys.iter().map(String::as_str).collect();
772 let d = deltas(&[], &key_refs);
773 let routing = empty_routing();
774 let surface = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 4));
775 let public_api_count = surface
776 .decisions
777 .iter()
778 .filter(|dec| dec.category == DecisionCategory::PublicApiContract)
779 .count();
780 assert_eq!(
781 public_api_count, 1,
782 "R1: one public-API decision per change"
783 );
784 assert!(surface.decisions[0].question.contains("111"));
785 }
786
787 #[test]
788 fn public_api_decision_carries_honest_consumer_count_and_tradeoff() {
789 let d = deltas(&[], &["src/ui/index.ts::Widget"]);
793 let routing = empty_routing();
794 let seven = |_: &str| 7u64;
795 let surface = extract_decision_surface(&DecisionInputs {
796 deltas: &d,
797 boundary_anchors: &[],
798 coordination: &[],
799 public_api_anchor_line: 0,
800 affected_not_shown: 99,
802 routing: &routing,
803 head_source: &no_source,
804 rename_old_path: &no_source,
805 internal_consumers: &seven,
806 cap: 4,
807 });
808 let dec = surface
809 .decisions
810 .iter()
811 .find(|dec| dec.category == DecisionCategory::PublicApiContract)
812 .expect("a public-API decision");
813 assert_eq!(dec.internal_consumer_count, 7, "honest per-anchor count");
814 assert_ne!(
815 dec.internal_consumer_count, dec.blast,
816 "display number must stay distinct from the ranking proxy"
817 );
818 assert!(
819 dec.tradeoff.contains("7 in-repo"),
820 "trade-off clause states the count as a fact: {}",
821 dec.tradeoff
822 );
823 assert!(
824 dec.question.ends_with('?'),
825 "the decision stays a question (taste ownership)"
826 );
827 }
828
829 #[test]
830 fn coordination_gap_becomes_a_public_api_contract_decision() {
831 let d = deltas(&[], &[]);
832 let coordination = vec![CoordinationAnchor {
833 changed_file: "src/core.ts".to_string(),
834 consumed_symbols: vec!["compute".to_string()],
835 consumer_count: 4,
836 line: 7,
837 }];
838 let routing = empty_routing();
839 let surface =
840 extract_decision_surface(&inputs(&d, &[], &coordination, &routing, &no_source, 4));
841 assert_eq!(surface.decisions.len(), 1);
842 assert_eq!(
843 surface.decisions[0].category,
844 DecisionCategory::PublicApiContract
845 );
846 assert_eq!(surface.decisions[0].blast, 4);
847 assert_eq!(surface.decisions[0].anchor_line, 7);
850 assert!(surface.decisions[0].previous_signal_id.is_none());
852 }
853
854 #[test]
855 fn renamed_anchor_carries_a_previous_signal_id_for_review_memory() {
856 let d = deltas(&[], &[]);
860 let coordination = vec![CoordinationAnchor {
861 changed_file: "src/new.ts".to_string(),
862 consumed_symbols: vec!["compute".to_string()],
863 consumer_count: 2,
864 line: 0,
865 }];
866 let routing = empty_routing();
867 let rename = |rel: &str| -> Option<String> {
868 (rel == "src/new.ts").then(|| "src/old.ts".to_string())
869 };
870 let surface = extract_decision_surface(&DecisionInputs {
871 deltas: &d,
872 boundary_anchors: &[],
873 coordination: &coordination,
874 public_api_anchor_line: 0,
875 affected_not_shown: 2,
876 routing: &routing,
877 head_source: &no_source,
878 rename_old_path: &rename,
879 internal_consumers: &no_consumers,
880 cap: 4,
881 });
882 assert_eq!(surface.decisions.len(), 1);
883 let decision = &surface.decisions[0];
884 assert_eq!(
885 decision.signal_id,
886 derive_signal_id(DecisionCategory::PublicApiContract, "contract:src/new.ts")
887 );
888 assert_eq!(
889 decision.previous_signal_id,
890 Some(derive_signal_id(
891 DecisionCategory::PublicApiContract,
892 "contract:src/old.ts"
893 ))
894 );
895 }
896
897 #[test]
898 fn signal_id_is_deterministic_and_namespaced_by_category() {
899 let a = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
900 let b = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
901 assert_eq!(a, b, "deterministic");
902 let c = derive_signal_id(DecisionCategory::PublicApiContract, "ui->-db");
903 assert_ne!(a, c, "category namespaces the hash");
904 assert!(a.starts_with("sig:"));
905 }
906
907 #[test]
908 fn consequence_ranks_less_reversible_categories_higher() {
909 let dep = DecisionCategory::Dependency.reversibility_weight();
911 let api = DecisionCategory::PublicApiContract.reversibility_weight();
912 let coupling = DecisionCategory::CouplingBoundary.reversibility_weight();
913 assert!(dep > api && api > coupling);
914 }
915}