Skip to main content

fallow_output/
issue_contract.rs

1use std::collections::BTreeMap;
2
3use fallow_types::envelope::{Meta, MetaMetric, MetaRule};
4pub use fallow_types::issue_meta::{CODECLIMATE_RESULT_CODES, TsAliasMeta};
5use fallow_types::issue_meta::{
6    IssueResultMeta, issue_meta_by_code, issue_result_meta_by_code, result_issue_metas,
7};
8
9const DOCS_BASE: &str = "https://fallow.tools/docs";
10
11/// Docs URL for the dead-code/check command.
12pub const CHECK_DOCS: &str = "https://fallow.tools/docs/cli/dead-code/";
13
14/// Docs URL for the architecture command.
15pub const ARCHITECTURE_DOCS: &str = "https://fallow.tools/docs/cli/architecture/";
16
17/// `_meta` description for the per-finding `actions[]` array shared across
18/// JSON output.
19pub const ACTIONS_FIELD_DEFINITION: &str = "Per-finding fix and suppression suggestions. Each entry carries a `type` discriminant (kebab-case) plus a per-action `auto_fixable` bool. Consumers dispatch on `type` to choose the remediation and filter on `auto_fixable` of each individual entry.";
20
21/// `_meta` description for the per-action `auto_fixable` bool.
22pub const ACTIONS_AUTO_FIXABLE_FIELD_DEFINITION: &str = "Evaluated PER FINDING, not per action type. The same `type` may carry `auto_fixable: true` on one finding and `auto_fixable: false` on another when per-instance guards in the `fallow fix` applier discriminate. Filter on this bool of each individual action, not on `type` alone. Current per-instance flips: (1) `remove-catalog-entry` is `true` only when the finding's `hardcoded_consumers` array is empty (else fallow fix skips the entry to avoid breaking `pnpm install`); (2) the primary dependency action flips between `remove-dependency` (`auto_fixable: true`) and `move-dependency` (`auto_fixable: false`) based on `used_in_workspaces`; (3) `add-to-config` for `ignoreExports` is `true` when fallow fix can safely apply the action, which means EITHER a fallow config file already exists OR no config exists and the working directory is NOT inside a monorepo subpackage (the applier then creates `.fallowrc.json` using `fallow init`'s framework-aware scaffolding and layers the new rules on top); `false` inside a monorepo subpackage with no workspace-root config because the applier refuses to fragment per-package configs; (4) `update-catalog-reference` is always `false` today (catalog-switching applier not yet wired). All `suppress-line` and `suppress-file` actions are uniformly `false`.";
23
24/// Output-facing contract metadata for a serialized dead-code result row.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct IssueOutputContract {
27    /// Canonical issue code that owns this result array.
28    pub code: &'static str,
29    /// Serialized `AnalysisResults` array key that carries this issue row.
30    pub result_key: &'static str,
31    /// Whether `result_key` contributes to `AnalysisResults::total_issues()`.
32    pub counts_in_total: bool,
33    /// Label used by CI summary tables.
34    pub summary_label: &'static str,
35    /// Documentation anchor used by CI summary tables.
36    pub summary_docs_anchor: &'static str,
37    /// Human-readable name emitted in dead-code `_meta.rules`.
38    pub meta_name: &'static str,
39    /// Explanation emitted in dead-code `_meta.rules`.
40    pub meta_description: &'static str,
41    /// Documentation path emitted in dead-code `_meta.rules`.
42    pub meta_docs_path: &'static str,
43    /// SARIF rule ids used by the CLI SARIF formatter for this result row.
44    pub sarif_rule_ids: Vec<String>,
45    /// CodeClimate check names used by the CodeClimate formatter.
46    pub codeclimate_check_names: Vec<String>,
47    /// Published TypeScript alias policy for backwards-compatible bare names.
48    pub ts_alias: Option<TsAliasMeta>,
49}
50
51impl IssueOutputContract {
52    #[must_use]
53    fn from_result_meta(meta: &IssueResultMeta) -> Self {
54        let issue = issue_meta_by_code(meta.code).unwrap_or_else(|| {
55            panic!(
56                "output contract must reference IssueKindMeta row: {}",
57                meta.code
58            )
59        });
60        Self {
61            code: meta.code,
62            result_key: meta.result_key,
63            counts_in_total: meta.counts_in_total,
64            summary_label: meta.summary_label,
65            summary_docs_anchor: meta.docs_anchor,
66            meta_name: meta.meta_name,
67            meta_description: meta.meta_description,
68            meta_docs_path: meta.meta_docs_path,
69            sarif_rule_ids: issue.sarif_rule_ids(),
70            codeclimate_check_names: issue.codeclimate_check_names(),
71            ts_alias: issue.ts_alias(),
72        }
73    }
74}
75
76/// Build the `_meta` object for `fallow dead-code --format json --explain`.
77#[must_use]
78pub fn check_meta() -> Meta {
79    let mut rules = BTreeMap::new();
80    for contract in issue_output_contracts() {
81        rules.insert(
82            contract.code.to_string(),
83            MetaRule {
84                name: Some(contract.meta_name.to_string()),
85                description: Some(contract.meta_description.to_string()),
86                docs: Some(rule_docs_url(contract.meta_docs_path)),
87            },
88        );
89    }
90    rules.insert(
91        "missing-suppression-reason".to_string(),
92        MetaRule {
93            name: Some("Missing Suppression Reason".to_string()),
94            description: Some("A fallow-ignore-next-line or fallow-ignore-file suppression omits the explanatory reason required by the requireSuppressionReason rule. Add a short reason after the suppression token, or remove the suppression if the issue is no longer intentional.".to_string()),
95            docs: Some(rule_docs_url("explanations/dead-code#stale-suppressions")),
96        },
97    );
98
99    Meta {
100        docs: Some(CHECK_DOCS.to_string()),
101        field_definitions: BTreeMap::from([
102            (
103                "actions[]".to_string(),
104                ACTIONS_FIELD_DEFINITION.to_string(),
105            ),
106            (
107                "actions[].auto_fixable".to_string(),
108                ACTIONS_AUTO_FIXABLE_FIELD_DEFINITION.to_string(),
109            ),
110        ]),
111        rules,
112        metrics: BTreeMap::from([(
113            "summary.prop_drilling_chains".to_string(),
114            MetaMetric {
115                name: Some("Enforced prop-drilling chains".to_string()),
116                description: Some("Number of prop-drilling chains with saved effective_severity set to error. Warning chains are excluded; the counter is omitted when zero.".to_string()),
117                range: Some("Non-negative integer".to_string()),
118                interpretation: Some("Each counted chain contributes to total_issues and CI error gates. The finding array can also contain advisory chains excluded from this counter.".to_string()),
119            },
120        )]),
121        ..Meta::default()
122    }
123}
124
125/// The `AnalysisResults` arrays that `fallow architecture` reports: import
126/// cycles, boundary violations and rule-pack policy violations.
127///
128/// One list for the `_meta.rules` filter, the human report and the
129/// programmatic selection, so the three cannot disagree.
130pub const ARCHITECTURE_RESULT_KEYS: &[&str] = &[
131    "circular_dependencies",
132    "re_export_cycles",
133    "package_cycles",
134    "boundary_violations",
135    "boundary_coverage_violations",
136    "boundary_call_violations",
137    "policy_violations",
138];
139
140/// Build the `_meta` object for `fallow architecture --format json --explain`.
141///
142/// It has the dead-code field definitions, the architecture docs URL, and
143/// only the rules of the [`ARCHITECTURE_RESULT_KEYS`] arrays.
144#[must_use]
145pub fn architecture_meta() -> Meta {
146    let mut meta = check_meta();
147    let architecture_codes: Vec<&'static str> = issue_output_contracts()
148        .filter(|contract| ARCHITECTURE_RESULT_KEYS.contains(&contract.result_key))
149        .map(|contract| contract.code)
150        .collect();
151    meta.rules
152        .retain(|code, _| architecture_codes.contains(&code.as_str()));
153    meta.metrics.remove("summary.prop_drilling_chains");
154    meta.docs = Some(ARCHITECTURE_DOCS.to_string());
155    meta
156}
157
158/// Public docs URL for a rule's `docs_path` relative to the docs site root.
159///
160/// A page URL ends with a slash, before any `#anchor`, because that is the
161/// final address of the page: `cli/flags#x` becomes `.../cli/flags/#x`.
162#[must_use]
163pub fn rule_docs_url(docs_path: &str) -> String {
164    match docs_path.split_once('#') {
165        Some((page, anchor)) => format!("{DOCS_BASE}/{page}/#{anchor}"),
166        None => format!("{DOCS_BASE}/{docs_path}/"),
167    }
168}
169
170/// Output-facing dead-code result contracts in stable registry order.
171pub fn issue_output_contracts() -> impl Iterator<Item = IssueOutputContract> {
172    result_issue_metas().map(IssueOutputContract::from_result_meta)
173}
174
175/// Output-facing dead-code result contract by issue code.
176#[must_use]
177pub fn issue_output_contract_by_code(code: &str) -> Option<IssueOutputContract> {
178    issue_result_meta_by_code(code).map(IssueOutputContract::from_result_meta)
179}
180
181#[cfg(test)]
182mod tests {
183    use std::collections::BTreeSet;
184
185    use super::*;
186
187    #[test]
188    fn architecture_meta_lists_only_architecture_rules() {
189        let meta = architecture_meta();
190        let expected: BTreeSet<&str> = issue_output_contracts()
191            .filter(|contract| ARCHITECTURE_RESULT_KEYS.contains(&contract.result_key))
192            .map(|contract| contract.code)
193            .collect();
194        let actual: BTreeSet<&str> = meta.rules.keys().map(String::as_str).collect();
195
196        assert_eq!(actual, expected);
197        assert_eq!(meta.docs.as_deref(), Some(ARCHITECTURE_DOCS));
198        assert_eq!(meta.field_definitions, check_meta().field_definitions);
199        assert!(!meta.metrics.contains_key("summary.prop_drilling_chains"));
200    }
201
202    #[test]
203    fn every_architecture_result_key_names_a_result_row() {
204        let keys: BTreeSet<&str> = issue_output_contracts()
205            .map(|contract| contract.result_key)
206            .collect();
207        for key in ARCHITECTURE_RESULT_KEYS {
208            assert!(keys.contains(key), "`{key}` is not a result array key");
209        }
210        let architecture_rows = issue_output_contracts()
211            .filter(|contract| ARCHITECTURE_RESULT_KEYS.contains(&contract.result_key))
212            .count();
213        assert_eq!(architecture_rows, ARCHITECTURE_RESULT_KEYS.len());
214    }
215
216    #[test]
217    fn every_result_row_has_output_contract() {
218        let result_codes: BTreeSet<&str> = result_issue_metas().map(|meta| meta.code).collect();
219        let output_codes: BTreeSet<&str> = issue_output_contracts()
220            .map(|contract| contract.code)
221            .collect();
222        assert_eq!(result_codes, output_codes);
223    }
224
225    #[test]
226    fn summary_contracts_are_present() {
227        for contract in issue_output_contracts() {
228            assert!(!contract.summary_label.is_empty());
229            assert!(!contract.summary_docs_anchor.is_empty());
230            assert!(!contract.meta_name.is_empty());
231            assert!(!contract.meta_description.is_empty());
232            assert!(!contract.meta_docs_path.is_empty());
233        }
234    }
235
236    #[test]
237    fn check_meta_uses_output_contracts() {
238        let meta = check_meta();
239        assert_eq!(meta.docs.as_deref(), Some(CHECK_DOCS));
240        assert!(
241            meta.field_definitions["actions[].auto_fixable"].contains("PER FINDING"),
242            "auto_fixable definition should preserve per-finding guidance"
243        );
244        assert!(meta.rules.contains_key("unused-export"));
245        assert!(meta.rules.contains_key("missing-suppression-reason"));
246        assert_eq!(
247            meta.rules["unused-dev-dependency"].docs.as_deref(),
248            Some("https://fallow.tools/docs/explanations/dead-code/#unused-devdependencies")
249        );
250    }
251
252    #[test]
253    fn ci_format_contracts_are_present() {
254        for contract in issue_output_contracts() {
255            assert!(
256                contract
257                    .sarif_rule_ids
258                    .contains(&format!("fallow/{}", contract.code)),
259                "result metadata code {} has wrong SARIF rule id",
260                contract.code
261            );
262            for rule_id in contract.sarif_rule_ids {
263                assert!(
264                    rule_id.starts_with("fallow/"),
265                    "result metadata code {} has unprefixed SARIF rule id {rule_id}",
266                    contract.code
267                );
268            }
269            for check_name in contract.codeclimate_check_names {
270                assert!(
271                    check_name.starts_with("fallow/"),
272                    "result metadata code {} has unprefixed CodeClimate check name {check_name}",
273                    contract.code
274                );
275            }
276        }
277    }
278
279    #[test]
280    fn codeclimate_result_exclusions_are_explicit() {
281        let expected = BTreeSet::from(["duplicate-prop-shape", "thin-wrapper"]);
282        let from_contracts: BTreeSet<&str> = issue_output_contracts()
283            .filter(|contract| contract.codeclimate_check_names.is_empty())
284            .map(|contract| contract.code)
285            .collect();
286        assert_eq!(expected, from_contracts);
287    }
288
289    #[test]
290    fn codeclimate_result_codes_match_result_metadata() {
291        let result_codes: BTreeSet<&str> = result_issue_metas().map(|meta| meta.code).collect();
292        let codeclimate_codes: BTreeSet<&str> = CODECLIMATE_RESULT_CODES.iter().copied().collect();
293        assert!(codeclimate_codes.is_subset(&result_codes));
294    }
295
296    #[test]
297    fn ts_alias_policy_is_explicit() {
298        let aliases: BTreeSet<(&str, &str)> = issue_output_contracts()
299            .filter_map(|contract| contract.ts_alias.map(|alias| (alias.name, alias.parent)))
300            .collect();
301
302        assert_eq!(
303            BTreeSet::from([
304                ("BoundaryViolation", "BoundaryViolationFinding"),
305                ("CircularDependency", "CircularDependencyFinding"),
306                ("DeprecatedExportInUse", "DeprecatedExportInUseFinding"),
307                (
308                    "DevDependencyInProduction",
309                    "DevDependencyInProductionFinding",
310                ),
311                ("DuplicateExport", "DuplicateExportFinding"),
312                ("EmptyCatalogGroup", "EmptyCatalogGroupFinding"),
313                (
314                    "MisconfiguredDependencyOverride",
315                    "MisconfiguredDependencyOverrideFinding",
316                ),
317                ("PackageCycle", "PackageCycleFinding"),
318                ("PrivateTypeLeak", "PrivateTypeLeakFinding"),
319                ("ReExportCycle", "ReExportCycleFinding"),
320                ("TestOnlyDependency", "TestOnlyDependencyFinding"),
321                ("TypeOnlyDependency", "TypeOnlyDependencyFinding"),
322                ("UnlistedDependency", "UnlistedDependencyFinding"),
323                (
324                    "UnresolvedCatalogReference",
325                    "UnresolvedCatalogReferenceFinding",
326                ),
327                ("UnresolvedImport", "UnresolvedImportFinding"),
328                ("UnusedCatalogEntry", "UnusedCatalogEntryFinding"),
329                ("UnusedDependency", "UnusedDependencyFinding"),
330                ("UnusedDependency", "UnusedDevDependencyFinding"),
331                ("UnusedDependency", "UnusedOptionalDependencyFinding"),
332                (
333                    "UnusedDependencyOverride",
334                    "UnusedDependencyOverrideFinding",
335                ),
336                ("UnusedExport", "UnusedExportFinding"),
337                ("UnusedFile", "UnusedFileFinding"),
338                ("UnusedMember", "UnusedClassMemberFinding"),
339                ("UnusedMember", "UnusedEnumMemberFinding"),
340                ("UnusedMember", "UnusedStoreMemberFinding"),
341            ]),
342            aliases
343        );
344    }
345}