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        ("summary.thin_wrappers".to_owned(), component_error_metric("Thin wrappers")),
122        ("summary.duplicate_prop_shapes".to_owned(), component_error_metric("Duplicate prop shapes")),
123        ]),
124        ..Meta::default()
125    }
126}
127
128fn component_error_metric(name: &str) -> MetaMetric {
129    MetaMetric {
130        name: Some(format!("Enforced {name}")),
131        description: Some("Number of findings with saved effective_severity set to error. Warnings are excluded; the counter is omitted when zero.".to_owned()),
132        range: Some("Non-negative integer".to_owned()),
133        interpretation: Some("Each counted finding contributes to total_issues and CI error gates. The finding array can also contain advisory warnings.".to_owned()),
134    }
135}
136
137/// The `AnalysisResults` arrays that `fallow architecture` reports: import
138/// cycles, boundary violations and rule-pack policy violations.
139///
140/// One list for the `_meta.rules` filter, the human report and the
141/// programmatic selection, so the three cannot disagree.
142pub const ARCHITECTURE_RESULT_KEYS: &[&str] = &[
143    "circular_dependencies",
144    "re_export_cycles",
145    "package_cycles",
146    "boundary_violations",
147    "boundary_coverage_violations",
148    "boundary_call_violations",
149    "policy_violations",
150];
151
152/// Build the `_meta` object for `fallow architecture --format json --explain`.
153///
154/// It has the dead-code field definitions, the architecture docs URL, and
155/// only the rules of the [`ARCHITECTURE_RESULT_KEYS`] arrays.
156#[must_use]
157pub fn architecture_meta() -> Meta {
158    let mut meta = check_meta();
159    let architecture_codes: Vec<&'static str> = issue_output_contracts()
160        .filter(|contract| ARCHITECTURE_RESULT_KEYS.contains(&contract.result_key))
161        .map(|contract| contract.code)
162        .collect();
163    meta.rules
164        .retain(|code, _| architecture_codes.contains(&code.as_str()));
165    for key in fallow_types::results::ERROR_ONLY_RESULT_KEYS {
166        meta.metrics.remove(&format!("summary.{key}"));
167    }
168    meta.docs = Some(ARCHITECTURE_DOCS.to_string());
169    meta
170}
171
172/// Public docs URL for a rule's `docs_path` relative to the docs site root.
173///
174/// A page URL ends with a slash, before any `#anchor`, because that is the
175/// final address of the page: `cli/flags#x` becomes `.../cli/flags/#x`.
176#[must_use]
177pub fn rule_docs_url(docs_path: &str) -> String {
178    match docs_path.split_once('#') {
179        Some((page, anchor)) => format!("{DOCS_BASE}/{page}/#{anchor}"),
180        None => format!("{DOCS_BASE}/{docs_path}/"),
181    }
182}
183
184/// Output-facing dead-code result contracts in stable registry order.
185pub fn issue_output_contracts() -> impl Iterator<Item = IssueOutputContract> {
186    result_issue_metas().map(IssueOutputContract::from_result_meta)
187}
188
189/// Output-facing dead-code result contract by issue code.
190#[must_use]
191pub fn issue_output_contract_by_code(code: &str) -> Option<IssueOutputContract> {
192    issue_result_meta_by_code(code).map(IssueOutputContract::from_result_meta)
193}
194
195#[cfg(test)]
196mod tests {
197    use std::collections::BTreeSet;
198
199    use super::*;
200
201    #[test]
202    fn architecture_meta_lists_only_architecture_rules() {
203        let meta = architecture_meta();
204        let expected: BTreeSet<&str> = issue_output_contracts()
205            .filter(|contract| ARCHITECTURE_RESULT_KEYS.contains(&contract.result_key))
206            .map(|contract| contract.code)
207            .collect();
208        let actual: BTreeSet<&str> = meta.rules.keys().map(String::as_str).collect();
209
210        assert_eq!(actual, expected);
211        assert_eq!(meta.docs.as_deref(), Some(ARCHITECTURE_DOCS));
212        assert_eq!(meta.field_definitions, check_meta().field_definitions);
213        assert!(!meta.metrics.contains_key("summary.prop_drilling_chains"));
214    }
215
216    #[test]
217    fn every_architecture_result_key_names_a_result_row() {
218        let keys: BTreeSet<&str> = issue_output_contracts()
219            .map(|contract| contract.result_key)
220            .collect();
221        for key in ARCHITECTURE_RESULT_KEYS {
222            assert!(keys.contains(key), "`{key}` is not a result array key");
223        }
224        let architecture_rows = issue_output_contracts()
225            .filter(|contract| ARCHITECTURE_RESULT_KEYS.contains(&contract.result_key))
226            .count();
227        assert_eq!(architecture_rows, ARCHITECTURE_RESULT_KEYS.len());
228    }
229
230    #[test]
231    fn every_result_row_has_output_contract() {
232        let result_codes: BTreeSet<&str> = result_issue_metas().map(|meta| meta.code).collect();
233        let output_codes: BTreeSet<&str> = issue_output_contracts()
234            .map(|contract| contract.code)
235            .collect();
236        assert_eq!(result_codes, output_codes);
237    }
238
239    #[test]
240    fn summary_contracts_are_present() {
241        for contract in issue_output_contracts() {
242            assert!(!contract.summary_label.is_empty());
243            assert!(!contract.summary_docs_anchor.is_empty());
244            assert!(!contract.meta_name.is_empty());
245            assert!(!contract.meta_description.is_empty());
246            assert!(!contract.meta_docs_path.is_empty());
247        }
248    }
249
250    #[test]
251    fn check_meta_uses_output_contracts() {
252        let meta = check_meta();
253        assert_eq!(meta.docs.as_deref(), Some(CHECK_DOCS));
254        assert!(
255            meta.field_definitions["actions[].auto_fixable"].contains("PER FINDING"),
256            "auto_fixable definition should preserve per-finding guidance"
257        );
258        assert!(meta.rules.contains_key("unused-export"));
259        assert!(meta.rules.contains_key("missing-suppression-reason"));
260        assert_eq!(
261            meta.rules["unused-dev-dependency"].docs.as_deref(),
262            Some("https://fallow.tools/docs/explanations/dead-code/#unused-devdependencies")
263        );
264    }
265
266    #[test]
267    fn ci_format_contracts_are_present() {
268        for contract in issue_output_contracts() {
269            assert!(
270                contract
271                    .sarif_rule_ids
272                    .contains(&format!("fallow/{}", contract.code)),
273                "result metadata code {} has wrong SARIF rule id",
274                contract.code
275            );
276            for rule_id in contract.sarif_rule_ids {
277                assert!(
278                    rule_id.starts_with("fallow/"),
279                    "result metadata code {} has unprefixed SARIF rule id {rule_id}",
280                    contract.code
281                );
282            }
283            for check_name in &contract.codeclimate_check_names {
284                assert!(
285                    check_name.starts_with("fallow/"),
286                    "result metadata code {} has unprefixed CodeClimate check name {check_name}",
287                    contract.code
288                );
289            }
290            assert!(
291                !contract.codeclimate_check_names.is_empty(),
292                "result metadata code {} has no CodeClimate check names",
293                contract.code
294            );
295        }
296    }
297
298    #[test]
299    fn component_health_codeclimate_contracts_are_present() {
300        for code in ["duplicate-prop-shape", "thin-wrapper"] {
301            let contract = issue_output_contracts()
302                .find(|contract| contract.code == code)
303                .expect("component-health contract must be present");
304            let expected = format!("fallow/{code}");
305            assert_eq!(contract.codeclimate_check_names, vec![expected]);
306        }
307    }
308
309    #[test]
310    fn codeclimate_result_codes_match_result_metadata() {
311        let result_codes: BTreeSet<&str> = result_issue_metas().map(|meta| meta.code).collect();
312        let codeclimate_codes: BTreeSet<&str> = CODECLIMATE_RESULT_CODES.iter().copied().collect();
313        assert!(codeclimate_codes.is_subset(&result_codes));
314    }
315
316    #[test]
317    fn ts_alias_policy_is_explicit() {
318        let aliases: BTreeSet<(&str, &str)> = issue_output_contracts()
319            .filter_map(|contract| contract.ts_alias.map(|alias| (alias.name, alias.parent)))
320            .collect();
321
322        assert_eq!(
323            BTreeSet::from([
324                ("BoundaryViolation", "BoundaryViolationFinding"),
325                ("CircularDependency", "CircularDependencyFinding"),
326                ("DeprecatedExportInUse", "DeprecatedExportInUseFinding"),
327                (
328                    "DevDependencyInProduction",
329                    "DevDependencyInProductionFinding",
330                ),
331                ("DuplicateExport", "DuplicateExportFinding"),
332                ("EmptyCatalogGroup", "EmptyCatalogGroupFinding"),
333                (
334                    "MisconfiguredDependencyOverride",
335                    "MisconfiguredDependencyOverrideFinding",
336                ),
337                ("PackageCycle", "PackageCycleFinding"),
338                ("PrivateTypeLeak", "PrivateTypeLeakFinding"),
339                ("ReExportCycle", "ReExportCycleFinding"),
340                ("TestOnlyDependency", "TestOnlyDependencyFinding"),
341                ("TypeOnlyDependency", "TypeOnlyDependencyFinding"),
342                ("UnlistedDependency", "UnlistedDependencyFinding"),
343                (
344                    "UnresolvedCatalogReference",
345                    "UnresolvedCatalogReferenceFinding",
346                ),
347                ("UnresolvedImport", "UnresolvedImportFinding"),
348                ("UnusedCatalogEntry", "UnusedCatalogEntryFinding"),
349                ("UnusedDependency", "UnusedDependencyFinding"),
350                ("UnusedDependency", "UnusedDevDependencyFinding"),
351                ("UnusedDependency", "UnusedOptionalDependencyFinding"),
352                (
353                    "UnusedDependencyOverride",
354                    "UnusedDependencyOverrideFinding",
355                ),
356                ("UnusedExport", "UnusedExportFinding"),
357                ("UnusedFile", "UnusedFileFinding"),
358                ("UnusedMember", "UnusedClassMemberFinding"),
359                ("UnusedMember", "UnusedEnumMemberFinding"),
360                ("UnusedMember", "UnusedStoreMemberFinding"),
361            ]),
362            aliases
363        );
364    }
365}