Skip to main content

fallow_cli/
audit_decision_surface.rs

1//! Decision-surface extractor (stage 6 / 6.G): THE product.
2//!
3//! The apex of the review brief. A change embeds many decisions; almost all are
4//! mechanical and a few are consequential enough to need human taste. This
5//! extractor lifts the consequential STRUCTURAL decisions out of the scattered
6//! diff, frames each as a judgment question, ranks by consequence (blast x
7//! reversibility), caps the surface to a working-memory-sized handful (4 plus or
8//! minus 1), collapses the mechanical remainder, and pairs each decision with the
9//! routed expert ("who to ask").
10//!
11//! ## The SOLID-3 (the ONLY categories that ship)
12//!
13//! Per the verdict (`.plans/agentic-review-e0-verdict.md`) the decision
14//! categories are NOT uniformly reliable on a syntactic engine (ADR-001). Exactly
15//! three are validated and shippable, each backed by a deterministic signal
16//! fallow already emits:
17//!
18//! 1. **coupling/boundary** (`boundary_introduced`): a new cross-zone edge.
19//! 2. **public-API/contract** (`public_api_added` + coordination gaps): a
20//!    new exports-aware public surface, or a changed contract consumed by modules
21//!    outside the diff.
22//! 3. **dependency**: a new `package.json` dependency entry (the arm is present;
23//!    its candidate source is a dependency delta not yet threaded on the brief
24//!    path, so it produces decisions only once that delta lands, never a
25//!    fabricated signal).
26//!
27//! The four CUT categories (abstraction-with-1-implementor, deletion-still-
28//! reachable, convention-divergence, irreversibility/migration) are CONFIRMED
29//! NOISE and MUST NOT ship. `DecisionCategory` has exactly three discriminants,
30//! so a cut category is not even representable: the type system is the guarantee.
31//!
32//! ## The trust mechanism (anti-hallucination)
33//!
34//! Post-validation closes on EXTRACTION, not on framing. Every decision carries a
35//! `signal_id` deterministically derived from the fallow-emitted candidate key it
36//! frames (a delta key or a coordination-gap key). The deterministic layer keeps
37//! the SET of signal_ids it emitted; `DecisionSurface::accept_signal_id` returns
38//! true iff an id is in that set. An agent-proposed decision whose `signal_id` was
39//! never emitted is REJECTED. The agent proposes; the graph disposes.
40
41pub 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
49/// Default decision-surface cap (the working-memory limit). The surface holds at
50/// most this many ranked decisions; the rest collapse into a truncation note.
51pub const DEFAULT_DECISION_CAP: usize = 4;
52/// Lower bound on the configurable cap (4 minus 1).
53pub const MIN_DECISION_CAP: usize = 3;
54/// Upper bound on the configurable cap (4 plus 1).
55pub const MAX_DECISION_CAP: usize = 5;
56
57/// Derive a deterministic, content-addressed `signal_id` from a category tag plus
58/// the fallow-emitted candidate key. The tag namespaces the key so a boundary key
59/// and a public-API key sharing text never collide. Pure: same inputs always
60/// yield the same id (byte-identical across runs).
61#[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/// A representative boundary violation used to anchor a coupling/boundary
71/// decision to a file + line. Decoupled from the `fallow_types` finding type so
72/// the extractor unit-tests without constructing full findings.
73#[derive(Debug, Clone)]
74pub struct BoundaryAnchor {
75    /// The R2 zone-pair key (`"<from_zone>->-<to_zone>"`), matching
76    /// `ReviewDeltas::boundary_introduced`.
77    pub zone_pair_key: String,
78    /// Root-relative path of the importing file (the decision anchor).
79    pub from_file: String,
80    /// The `from_zone` of the edge (for the framed question).
81    pub from_zone: String,
82    /// The `to_zone` of the edge (for the framed question).
83    pub to_zone: String,
84    /// 1-based line of the offending import (the suppression anchor).
85    pub line: u32,
86}
87
88/// A coordination gap projected onto the public-API/contract decision shape: a
89/// changed contract consumed by a module outside the diff.
90#[derive(Debug, Clone)]
91pub struct CoordinationAnchor {
92    /// Root-relative path of the changed file whose contract is consumed elsewhere.
93    pub changed_file: String,
94    /// The consumed symbol names (the contract).
95    pub consumed_symbols: Vec<String>,
96    /// Count of distinct non-diff consumers of this changed file's contract.
97    pub consumer_count: u64,
98    /// 1-based line of the contract symbol's declaration in `changed_file`, so the
99    /// decision deep-links / inline-anchors to the exact export. `0` when the line
100    /// could not be resolved (graph not retained or file unreadable).
101    pub line: u32,
102}
103
104/// All inputs the extractor needs, gathered from the assembled brief data.
105pub struct DecisionInputs<'a> {
106    /// Diff-aware deltas (boundary + public-API). The candidate source.
107    pub deltas: &'a ReviewDeltas,
108    /// Boundary anchors keyed by zone-pair, one representative per introduced edge.
109    pub boundary_anchors: &'a [BoundaryAnchor],
110    /// Coordination gaps projected to the contract decision shape.
111    pub coordination: &'a [CoordinationAnchor],
112    /// 1-based line of the first widened public-API export's declaration, so the
113    /// public-API-surface decision anchors to a real line. `0` when unresolved.
114    pub public_api_anchor_line: u32,
115    /// Project-wide fan-in beyond the diff (impact-closure `affected_not_shown`).
116    /// Used as the blast magnitude for boundary + public-API-surface decisions.
117    pub affected_not_shown: u64,
118    /// Ownership routing (routed expert per file).
119    pub routing: &'a RoutingFacts,
120    /// Per-anchor-file head source, for suppression checks. `None` for a file
121    /// whose head content could not be read (the decision is then not suppressed).
122    pub head_source: &'a dyn Fn(&str) -> Option<String>,
123    /// Resolve a head (post-rename) root-relative path to its pre-rename path, from
124    /// the diff's rename pairs. `None` when the file was not renamed. Lets each
125    /// decision carry a `previous_signal_id` so review memory survives a `git mv`.
126    pub rename_old_path: &'a dyn Fn(&str) -> Option<String>,
127    /// Honest per-anchor in-repo out-of-diff consumer count, precomputed from the
128    /// retained graph's reverse-deps before it was dropped. `0` for an anchor with
129    /// no recorded importers (a genuinely new file). The display number; distinct
130    /// from `affected_not_shown` (the project-wide ranking proxy).
131    pub internal_consumers: &'a dyn Fn(&str) -> u64,
132    /// The decision cap (default 4, clamped to [3, 5] by the caller).
133    pub cap: usize,
134}
135
136/// Resolve the routed expert(s) + bus-factor flag for a decision's anchor file.
137fn 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
147/// Whether the head source of `anchor_file` suppresses a decision of `category`
148/// at (1-based) `line`. Honors a file-level `fallow-ignore-file` and a
149/// line-level `fallow-ignore-next-line` immediately above the anchor line, in
150/// both the category-scoped (`decision-surface` / category tag) and bare forms.
151fn 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        // A bare ignore (no kind) suppresses; a kinded ignore must name the
165        // decision-surface family or this decision's category tag.
166        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    // File-level: any line carrying a file-level ignore.
182    if lines
183        .iter()
184        .any(|l| l.contains("fallow-ignore-file") && token_matches(l))
185    {
186        return true;
187    }
188    // Line-level: the comment sits immediately above the 1-based anchor line.
189    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
199/// Frame a coupling/boundary decision as a judgment question.
200fn 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
206/// Frame the (batch-consolidated, R1) public-API-surface decision.
207fn 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
214/// Frame a coordination-gap (contract consumed outside the diff) decision.
215fn 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
223/// Pluralize "module" against a count.
224fn modules_word(n: u64) -> &'static str {
225    if n == 1 { "module" } else { "modules" }
226}
227
228/// Subject-verb agreement for the per-clause count: a singular subject takes the
229/// "-s" verb form ("1 module depends"), plural drops it ("2 modules depend").
230fn 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
238/// The named structural sacrifice for a coupling/boundary decision, as a FACT.
239/// `consumers` is the honest in-repo out-of-diff count for the anchor.
240fn 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
248/// The named structural sacrifice for the public-API-surface decision, as a FACT.
249/// The internal count is internal-only, so the clause also names the external
250/// contract risk in prose (it cannot count a published library's downstream).
251fn 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
260/// The named structural sacrifice for a coordination-gap decision, as a FACT.
261fn 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
269/// The per-decision fields for [`build_decision`], distinct from the shared
270/// run context carried in [`DecisionInputs`].
271struct DecisionSpec {
272    category: DecisionCategory,
273    candidate_key: String,
274    question: String,
275    anchor_file: String,
276    anchor_line: u32,
277    blast: u64,
278    /// Honest per-decision in-repo out-of-diff consumer count (display number).
279    internal_consumer_count: u64,
280    /// The named-sacrifice clause, stated as a fact.
281    tradeoff: String,
282}
283
284/// Build one decision, resolving its routed expert and suppression state.
285fn 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    // Rename-durable review memory: if any path embedded in the candidate key was
298    // renamed, derive the signal_id this decision WOULD have had under the old
299    // path so the cloud can carry a prior dismissal across the move.
300    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
321/// Rebuild a candidate key with every embedded rel path swapped to its pre-rename
322/// form via `rename_old_path`. The key embeds paths as `contract:<path>` or as
323/// `|`-joined `<path>::<name>` components (boundary zone-pair keys carry no path).
324/// Returns the rebuilt, re-sorted key iff at least one path moved, else `None`.
325fn 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    // The public-API key is the SORTED added-key set joined; re-sort so the rebuilt
348    // key matches what the pre-rename change would have emitted.
349    parts.sort();
350    Some(parts.join("|"))
351}
352
353/// Classify the candidate signals into framed decisions (pre-rank, pre-cap).
354fn classify_candidates(inputs: &DecisionInputs<'_>) -> Vec<Decision> {
355    let mut decisions: Vec<Decision> = Vec::new();
356
357    // (1) Coupling/boundary: one decision per introduced zone-pair edge (R2).
358    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    // (2a) Public-API surface: R1 batch-consolidate to ONE decision per change.
391    if !inputs.deltas.public_api_added.is_empty() {
392        // The candidate key is the full sorted added-key set joined: one stable
393        // id per change, never one-per-symbol (kills the 111-export noise).
394        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    // (2b) Coordination gaps: a changed contract consumed outside the diff. One
422    // decision per (changed file) contract, keyed on the changed file path.
423    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                // The coordination arm already carries the honest per-decision
439                // count; no precomputed-map lookup needed.
440                internal_consumer_count: gap.consumer_count,
441            },
442            inputs,
443        ));
444    }
445
446    decisions
447}
448
449/// Extract the full decision surface from the assembled brief inputs: classify
450/// the SOLID-3 candidates, anchor each `signal_id`, rank by consequence, cap to
451/// the working-memory limit, collapse the rest, and drop suppressed decisions.
452///
453/// The emitted-signal-id allowlist is built over EVERY classified decision
454/// (before the cap and before suppression drops), so `accept_signal_id` still
455/// recognizes a collapsed-or-suppressed decision's anchor as fallow-emitted.
456#[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    // The allowlist: every signal_id the deterministic layer emitted.
463    let emitted_signal_ids: Vec<String> = classified.iter().map(|d| d.signal_id.clone()).collect();
464
465    // Drop suppressed decisions (suppression parity): a `// fallow-ignore` on the
466    // anchor hides the decision. Done BEFORE the cap so a suppressed decision does
467    // not consume a slot. The signal_id stays on the allowlist (anchor is still a
468    // real fallow signal), so an agent re-proposing it is not "hallucinating".
469    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    // Rank by consequence desc; stable, deterministic tiebreak on signal_id.
475    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    // (d) None of the four cut categories can ever appear: the enum has exactly
551    // three discriminants, so this is a compile-time + runtime guarantee.
552    #[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        // Serialized tags never include a cut-category name.
561        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    // (a) Every surfaced decision has a signal_id fallow emitted.
570    #[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    // (b) An injected decision with no signal anchor is REJECTED.
593    #[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        // A fabricated id the deterministic layer never emitted.
606        assert!(!surface.accept_signal_id("sig:deadbeefdeadbeef"));
607        assert!(!surface.accept_signal_id("sig:0000000000000000"));
608        // The real one is accepted.
609        let real = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
610        assert!(surface.accept_signal_id(&real));
611    }
612
613    // (c) A >cap input is capped to 4 plus/minus 1 with a truncation reason.
614    #[test]
615    fn over_cap_input_is_capped_with_truncation_reason() {
616        // 6 boundary edges; default cap 4.
617        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        // cap=10 clamps to MAX (5).
637        let high = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 10));
638        assert_eq!(high.decisions.len(), MAX_DECISION_CAP);
639        // cap=1 clamps to MIN (3).
640        let low = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 1));
641        assert_eq!(low.decisions.len(), MIN_DECISION_CAP);
642    }
643
644    // (e) A `// fallow-ignore` suppresses a flagged decision.
645    #[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        // No suppression: one decision surfaces.
658        let unsuppressed =
659            extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
660        assert_eq!(unsuppressed.decisions.len(), 1);
661
662        // File-level suppression hides it.
663        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        // But the signal id stays on the allowlist (the anchor is still real).
675        let id = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
676        assert!(suppressed.accept_signal_id(&id));
677
678        // Line-level suppression immediately above the anchor line also hides it.
679        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        // 111 added export keys collapse to ONE public-API decision (R1).
762        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        // A public-API delta whose anchor has 7 in-repo out-of-diff consumers must
782        // surface that honest number on the decision AND name it as a fact in the
783        // trade-off clause, distinct from the project-wide ranking proxy (`blast`).
784        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            // The project-wide proxy must NOT become the display number.
793            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        // The contract symbol's declaration line flows onto the decision so a PR
840        // review can anchor an inline comment to the exact export.
841        assert_eq!(surface.decisions[0].anchor_line, 7);
842        // No rename in this change -> no previous_signal_id (the default).
843        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        // A coordination decision on a file renamed src/old.ts -> src/new.ts. The
849        // signal_id keys on the NEW path; previous_signal_id keys on the OLD path,
850        // so a cloud memory layer carries a prior dismissal across the `git mv`.
851        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        // Same blast: dependency > public-api > coupling on reversibility weight.
902        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}