fallow-output 3.0.0

Output contract types for fallow reports
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
//! Shared, render-surface-agnostic helpers for the review walkthrough (W2).
//!
//! Both the human terminal renderer (`fallow-cli`) and the markdown renderer
//! (`fallow-api`) project the SAME [`StandardWalkthroughGuide`] into a staged
//! tour. The per-file "why" fact line, the staged/cleared membership split, and
//! the file-accounting math were independently re-derived in each surface and
//! drifted (double-counted files, a path printed twice, mid-word truncation,
//! escaped backticks). This module centralizes that shared logic as pure
//! functions so the two surfaces stay consistent by construction and the wire
//! contracts (`--walkthrough-guide` JSON, audit/brief) are never touched.

use crate::audit_walkthrough::{DirectionUnit, StandardWalkthroughGuide};

/// Max contract members named inline in a coordination fact before collapsing
/// the rest into a "+N more" suffix. Keeps the most load-bearing line readable
/// in a terminal without discarding the trailing guidance.
pub const MAX_CONTRACT_MEMBERS: usize = 6;

/// Honest file accounting for the walkthrough header + status, reconciled so the
/// numbers add up: `staged + cleared + excluded == changed`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WalkthroughAccounting {
    /// Total changed files in the diff (the engine's `triage.files`, the real set).
    pub changed: usize,
    /// Source units shown in a stage (in `direction.order`, not collapsed).
    pub staged: usize,
    /// Source units collapsed into the Cleared panel (de-prioritized + viewed).
    pub cleared: usize,
    /// Non-source files in the diff that carry no contract to review (migrations,
    /// lockfiles, config, docs): counted, never silently dropped.
    pub excluded: usize,
}

impl WalkthroughAccounting {
    /// Reconcile the change into `staged + cleared + excluded`.
    ///
    /// `staged` is the count of direction units that REMAIN in a stage after the
    /// de-prioritized and viewed files collapse out. `cleared` is the
    /// de-prioritized escape hatch plus any staged-then-viewed files. `excluded`
    /// is the remainder of the real changed set that is not a reviewable source
    /// unit. The header renders `staged + cleared + excluded` (which equals
    /// `changed` whenever the engine's changed count is the real set), so the
    /// header, the status line, and reality agree, and no file is counted twice.
    #[must_use]
    pub fn compute(guide: &StandardWalkthroughGuide, viewed: &[String]) -> Self {
        // Each direction unit lands in exactly one rendered bucket: collapsed into
        // Cleared (de-prioritized OR viewed) or visible in a stage.
        let mut staged_visible = 0usize;
        let mut collapsed = 0usize;
        for file in &guide.direction.order {
            if is_deprioritized(guide, file) || is_collapsed_into_cleared(file, viewed) {
                collapsed += 1;
            } else {
                staged_visible += 1;
            }
        }
        // De-prioritized NON-source files (if any ever appear) plus de-prioritized
        // source files all live under Cleared; the loop above already counted the
        // source ones (they are in `direction.order`). Add de-prioritized files NOT
        // in the order so the escape hatch stays fully accounted.
        let deprioritized_off_spine = guide
            .digest
            .focus
            .deprioritized
            .iter()
            .filter(|u| !guide.direction.order.iter().any(|f| f == &u.file))
            .count();
        let cleared = collapsed + deprioritized_off_spine;
        // Source units the engine analyzed (review_here + deprioritized). The diff
        // may also touch non-source files (counted in `changed` but never in the
        // focus map); surface them as the excluded bucket instead of dropping them.
        let source_units = guide.digest.focus.total_units();
        let changed = guide.digest.triage.files;
        let excluded = changed.saturating_sub(source_units);
        WalkthroughAccounting {
            changed,
            staged: staged_visible,
            cleared,
            excluded,
        }
    }

    /// The honest "files in this change" total the header should display:
    /// `staged + cleared + excluded`. Equal to `changed` on a normal change; the
    /// `max` guards the rare case where the engine's count lags the parts.
    #[must_use]
    pub fn header_total(&self) -> usize {
        (self.staged + self.cleared + self.excluded).max(self.changed)
    }
}

/// The clean, surface-agnostic fact text for a decision question, for the tour.
///
/// The raw wire `question` leads with `` `<anchor_file>` `` (which every render
/// surface ALREADY shows as the row's leading path), inlines the full, unbounded
/// contract-member list, and ends with the decision's open question. For a guided
/// tour this: strips the redundant leading path, caps the member list to
/// `max_members` names + "+N more", and DROPS the trailing question (the section
/// header frames the action once, and the question is still carried in the
/// decisions brief and the JSON, where each decision stands alone). The result is
/// plain prose (no backticks), so a markdown surface needs no escaping and a human
/// surface needs no truncation.
#[must_use]
pub fn clean_decision_fact(question: &str, anchor_file: &str, max_members: usize) -> String {
    let stripped = strip_leading_path(question, anchor_file);
    let capped = cap_member_list(&stripped, max_members);
    drop_trailing_question(&capped)
}

/// Drop a leading `` `<anchor_file>` `` token (with one trailing space) from the
/// question, so the path is not printed a second time after the row's path.
fn strip_leading_path(question: &str, anchor_file: &str) -> String {
    let prefix = format!("`{anchor_file}` ");
    question
        .strip_prefix(&prefix)
        .map_or_else(|| question.to_string(), str::to_string)
}

/// Cap the FIRST parenthesized comma-list (the contract members) to
/// `max_members` names, replacing the overflow with "+N more". Text outside that
/// first parenthetical (including the trailing question) is preserved verbatim.
fn cap_member_list(text: &str, max_members: usize) -> String {
    let Some(open) = text.find('(') else {
        return text.to_string();
    };
    let Some(rel_close) = text[open..].find(')') else {
        return text.to_string();
    };
    let close = open + rel_close;
    let inner = &text[open + 1..close];
    // Only collapse a genuine member list (comma-separated identifiers), never a
    // prose parenthetical like "(env)" or "(the cache)".
    let members: Vec<&str> = inner.split(", ").collect();
    if members.len() <= max_members {
        return text.to_string();
    }
    let shown = members[..max_members].join(", ");
    let more = members.len() - max_members;
    format!(
        "{}({shown}, +{more} more){}",
        &text[..open],
        &text[close + 1..]
    )
}

/// Drop a trailing decision question (a sentence ending in `?`) so a guided tour
/// shows the plain observation, not a per-file question. The decision's open
/// question is still carried in the decisions brief and the JSON, where each
/// decision stands alone; in the tour the section header frames the action once,
/// so a question repeated on every row reads as a wall of the same sentence.
fn drop_trailing_question(text: &str) -> String {
    let parts: Vec<&str> = text.split(". ").collect();
    let mut end = parts.len();
    while end > 0 && parts[end - 1].trim_end().ends_with('?') {
        end -= 1;
    }
    // Nothing trailing was a question (end unchanged), or the whole text is a
    // question (end hit 0): leave it as-is rather than emit an empty fragment.
    if end == parts.len() || end == 0 {
        return text.to_string();
    }
    let kept = parts[..end].join(". ");
    if kept.ends_with(['.', '!', '?']) {
        kept
    } else {
        format!("{kept}.")
    }
}

/// Cap an arbitrary list of names for inline display: first `max` names, then a
/// "+N more" sentinel. Shared by the out-of-diff consumer fact in both surfaces.
#[must_use]
pub fn cap_names(names: &[String], max: usize) -> (Vec<&str>, usize) {
    let shown: Vec<&str> = names.iter().take(max).map(String::as_str).collect();
    let more = names.len().saturating_sub(shown.len());
    (shown, more)
}

/// Whether a staged file collapses into Cleared instead of showing in its stage.
/// A file is cleared when the local viewed-state marked it seen (the
/// `--mark-viewed` collapse): it must appear ONLY under Cleared, never in both.
#[must_use]
fn is_collapsed_into_cleared(file: &str, viewed: &[String]) -> bool {
    viewed.iter().any(|v| v == file)
}

/// Whether `file` is a de-prioritized focus unit. The `--help` contract is that
/// de-prioritized files collapse INTO the Cleared panel, so they must be removed
/// from their stage row and shown only under Cleared.
#[must_use]
fn is_deprioritized(guide: &StandardWalkthroughGuide, file: &str) -> bool {
    guide
        .digest
        .focus
        .deprioritized
        .iter()
        .any(|u| u.file == file)
}

/// True when a direction unit collapses out of its stage and into Cleared,
/// because it is de-prioritized OR locally viewed. Each file is then in exactly
/// one rendered place (stage XOR cleared).
#[must_use]
fn collapses_into_cleared(guide: &StandardWalkthroughGuide, file: &str, viewed: &[String]) -> bool {
    is_deprioritized(guide, file) || is_collapsed_into_cleared(file, viewed)
}

/// The visible stage members for a guide: direction units in order, MINUS any
/// file collapsed into Cleared (de-prioritized or viewed). Returned as references
/// into the guide so the caller can render rows without cloning.
#[must_use]
pub fn visible_stage_units<'a>(
    guide: &'a StandardWalkthroughGuide,
    viewed: &[String],
) -> Vec<&'a DirectionUnit> {
    guide
        .direction
        .order
        .iter()
        .filter(|file| !collapses_into_cleared(guide, file, viewed))
        .filter_map(|file| guide.direction.units.iter().find(|u| &u.file == file))
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::audit_brief::{
        DiffTriage, GraphFacts, ImpactClosureFacts, PartitionFacts, ReviewBriefSchemaVersion,
        ReviewDeltas, ReviewEffort, RiskClass, StandardReviewBriefOutput,
    };
    use crate::audit_decision_surface::DecisionSurface;
    use crate::audit_focus::{FocusLabel, FocusMap, FocusScore, FocusUnit};
    use crate::audit_routing::RoutingFacts;
    use crate::audit_walkthrough::{
        AgentSchema, DirectionUnit, INJECTION_NOTE, ReviewDirection, StandardWalkthroughGuide,
    };

    fn focus_unit(file: &str, label: FocusLabel) -> FocusUnit {
        FocusUnit {
            file: file.to_string(),
            score: FocusScore::default(),
            label,
            reason: format!("reason for {file}"),
            confidence: Vec::new(),
        }
    }

    fn dir_unit(file: &str) -> DirectionUnit {
        DirectionUnit {
            file: file.to_string(),
            concern_lens: "orientation".to_string(),
            scoring_budget: 1,
            out_of_diff: Vec::new(),
            expert: Vec::new(),
        }
    }

    /// A guide whose direction is the review_here + deprioritized source units (as
    /// the engine builds it), with `changed` total files that may exceed the source
    /// unit count (the non-source excluded bucket).
    fn guide_for(
        review_here: &[&str],
        deprioritized: &[&str],
        changed_total: usize,
    ) -> StandardWalkthroughGuide {
        let order: Vec<String> = review_here
            .iter()
            .chain(deprioritized.iter())
            .map(|s| (*s).to_string())
            .collect();
        let units: Vec<DirectionUnit> = order.iter().map(|f| dir_unit(f)).collect();
        let digest = StandardReviewBriefOutput {
            schema_version: ReviewBriefSchemaVersion::default(),
            version: "test".to_string(),
            command: "audit-brief".to_string(),
            triage: DiffTriage {
                files: changed_total,
                hunks: None,
                net_lines: None,
                risk_class: RiskClass::Medium,
                review_effort: ReviewEffort::Review,
            },
            graph_facts: GraphFacts {
                exports_added: 0,
                api_width_delta: 0,
                reachable_from: Vec::new(),
                boundaries_touched: Vec::new(),
            },
            partition: PartitionFacts::default(),
            impact_closure: ImpactClosureFacts::default(),
            focus: FocusMap {
                review_here: review_here
                    .iter()
                    .map(|f| focus_unit(f, FocusLabel::ReviewHere))
                    .collect(),
                deprioritized: deprioritized
                    .iter()
                    .map(|f| focus_unit(f, FocusLabel::NotPrioritized))
                    .collect(),
            },
            deltas: ReviewDeltas::default(),
            weakening: Vec::new(),
            routing: RoutingFacts::default(),
            decisions: DecisionSurface::default(),
        };
        StandardWalkthroughGuide {
            schema_version: ReviewBriefSchemaVersion::default(),
            version: "test".to_string(),
            command: "review-walkthrough-guide".to_string(),
            graph_snapshot_hash: "hash1".to_string(),
            digest,
            direction: ReviewDirection { order, units },
            change_anchors: Vec::new(),
            agent_schema: AgentSchema {
                judgment_shape: "",
                echo_field: "graph_snapshot_hash",
                anchoring_rule: "",
            },
            injection_note: INJECTION_NOTE,
        }
    }

    #[test]
    fn accounting_reconciles_staged_cleared_excluded() {
        // 16 changed files: 2 review-here source, 1 de-prioritized source, 13
        // non-source (migrations/config/docs). No viewed.
        let guide = guide_for(&["src/a.ts", "src/b.ts"], &["src/c.ts"], 16);
        let acc = WalkthroughAccounting::compute(&guide, &[]);
        assert_eq!(acc.changed, 16);
        assert_eq!(acc.staged, 2, "review-here source units stay in stages");
        assert_eq!(acc.cleared, 1, "de-prioritized collapses into cleared");
        assert_eq!(
            acc.excluded, 13,
            "non-source files are excluded, not dropped"
        );
        // The header total accounts for the whole changed set.
        assert_eq!(acc.header_total(), 16);
        assert_eq!(acc.staged + acc.cleared + acc.excluded, acc.changed);
    }

    #[test]
    fn viewed_file_moves_from_staged_to_cleared() {
        let guide = guide_for(&["src/a.ts", "src/b.ts"], &[], 2);
        let viewed = vec!["src/a.ts".to_string()];
        let acc = WalkthroughAccounting::compute(&guide, &viewed);
        assert_eq!(acc.staged, 1, "the viewed file left the stage");
        assert_eq!(acc.cleared, 1, "the viewed file is counted in cleared");
        assert_eq!(acc.excluded, 0);
        assert_eq!(acc.staged + acc.cleared + acc.excluded, acc.changed);
    }

    #[test]
    fn deprioritized_and_viewed_appear_in_exactly_one_place() {
        let guide = guide_for(&["src/a.ts", "src/b.ts"], &["src/c.ts"], 3);
        let viewed = vec!["src/a.ts".to_string()];
        // a.ts is viewed -> cleared; c.ts is de-prioritized -> cleared; only b.ts
        // remains visible in a stage.
        let visible = visible_stage_units(&guide, &viewed);
        let files: Vec<&str> = visible.iter().map(|u| u.file.as_str()).collect();
        assert_eq!(files, vec!["src/b.ts"]);
        assert!(collapses_into_cleared(&guide, "src/a.ts", &viewed));
        assert!(collapses_into_cleared(&guide, "src/c.ts", &viewed));
        assert!(!collapses_into_cleared(&guide, "src/b.ts", &viewed));
    }

    #[test]
    fn strips_leading_path_caps_members_and_drops_question() {
        let q = "`src/db/schema.ts` changes exports (a, b, c, d, e, f, g, h) imported by 32 files outside this PR. Does this change break or alter what those callers expect?";
        let out = clean_decision_fact(q, "src/db/schema.ts", 3);
        // The leading path is gone (printed once by the row).
        assert!(
            !out.starts_with("`src/db/schema.ts`"),
            "leading path must be stripped: {out}"
        );
        // The member list is capped with a "+N more".
        assert!(out.contains("(a, b, c, +5 more)"), "got: {out}");
        // The trailing decision question is dropped in the tour (it lives in the brief).
        assert!(
            !out.contains('?'),
            "trailing question must be dropped: {out}"
        );
        assert!(
            out.ends_with("outside this PR."),
            "the observation survives, ending cleanly: {out}"
        );
        // No backticks remain to be escaped.
        assert!(!out.contains('`'), "no backticks remain: {out}");
    }

    #[test]
    fn short_member_list_is_kept_and_question_dropped() {
        let q = "`src/lib/r2.ts` changes exports (getR2, getR2Text) imported by 6 files outside this PR. Does this change break or alter what those callers expect?";
        let out = clean_decision_fact(q, "src/lib/r2.ts", 6);
        assert_eq!(
            out,
            "changes exports (getR2, getR2Text) imported by 6 files outside this PR."
        );
    }

    #[test]
    fn single_member_prose_parenthetical_is_kept_question_dropped() {
        let q = "`src/lib/env.ts` changes export (env) imported by 22 files outside this PR. Does this change break or alter what those callers expect?";
        let out = clean_decision_fact(q, "src/lib/env.ts", 6);
        assert!(out.contains("(env)"), "single member kept: {out}");
        assert!(!out.contains('?'), "trailing question dropped: {out}");
        assert!(out.ends_with("outside this PR."), "observation kept: {out}");
    }

    #[test]
    fn non_anchor_path_is_kept_but_question_dropped() {
        // A boundary question names a DIFFERENT path than the anchor; its leading
        // token is not stripped, but the tour still drops the trailing question.
        let q = "`ui` now imports `db` for the first time. Intended coupling, or should this edge not exist?";
        let out = clean_decision_fact(q, "src/ui/page.ts", 6);
        assert_eq!(out, "`ui` now imports `db` for the first time.");
    }

    #[test]
    fn public_api_surface_question_drops_to_one_sentence() {
        // The consolidated public-API-surface decision has no leading path and no
        // member parenthetical; the tour keeps only the one observation sentence,
        // dropping the trailing "Intended as maintained contracts ...?" question.
        let q = "This change adds 3 exports to the public API surface. Intended as maintained contracts, or should they stay internal?";
        let out = clean_decision_fact(q, "src/lib/id.ts", 6);
        assert_eq!(out, "This change adds 3 exports to the public API surface.");
    }

    #[test]
    fn cap_names_first_k_then_more() {
        let names = vec![
            "a".to_string(),
            "b".to_string(),
            "c".to_string(),
            "d".to_string(),
        ];
        let (shown, more) = cap_names(&names, 2);
        assert_eq!(shown, vec!["a", "b"]);
        assert_eq!(more, 2);
    }
}