Skip to main content

fallow_output/
issue_contract.rs

1use std::collections::BTreeMap;
2
3use fallow_types::envelope::{Meta, 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://docs.fallow.tools";
10
11/// Docs URL for the dead-code/check command.
12pub const CHECK_DOCS: &str = "https://docs.fallow.tools/cli/dead-code";
13
14/// `_meta` description for the per-finding `actions[]` array shared across
15/// JSON output.
16pub 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.";
17
18/// `_meta` description for the per-action `auto_fixable` bool.
19pub 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`.";
20
21/// Output-facing contract metadata for a serialized dead-code result row.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct IssueOutputContract {
24    /// Canonical issue code that owns this result array.
25    pub code: &'static str,
26    /// Serialized `AnalysisResults` array key that carries this issue row.
27    pub result_key: &'static str,
28    /// Whether `result_key` contributes to `AnalysisResults::total_issues()`.
29    pub counts_in_total: bool,
30    /// Label used by CI summary tables.
31    pub summary_label: &'static str,
32    /// Documentation anchor used by CI summary tables.
33    pub summary_docs_anchor: &'static str,
34    /// Human-readable name emitted in dead-code `_meta.rules`.
35    pub meta_name: &'static str,
36    /// Explanation emitted in dead-code `_meta.rules`.
37    pub meta_description: &'static str,
38    /// Documentation path emitted in dead-code `_meta.rules`.
39    pub meta_docs_path: &'static str,
40    /// SARIF rule ids used by the CLI SARIF formatter for this result row.
41    pub sarif_rule_ids: Vec<String>,
42    /// CodeClimate check names used by the CodeClimate formatter.
43    pub codeclimate_check_names: Vec<String>,
44    /// Published TypeScript alias policy for backwards-compatible bare names.
45    pub ts_alias: Option<TsAliasMeta>,
46}
47
48impl IssueOutputContract {
49    #[must_use]
50    fn from_result_meta(meta: &IssueResultMeta) -> Self {
51        let issue = issue_meta_by_code(meta.code).unwrap_or_else(|| {
52            panic!(
53                "output contract must reference IssueKindMeta row: {}",
54                meta.code
55            )
56        });
57        Self {
58            code: meta.code,
59            result_key: meta.result_key,
60            counts_in_total: meta.counts_in_total,
61            summary_label: meta.summary_label,
62            summary_docs_anchor: meta.docs_anchor,
63            meta_name: meta.meta_name,
64            meta_description: meta.meta_description,
65            meta_docs_path: meta.meta_docs_path,
66            sarif_rule_ids: issue.sarif_rule_ids(),
67            codeclimate_check_names: issue.codeclimate_check_names(),
68            ts_alias: issue.ts_alias(),
69        }
70    }
71}
72
73/// Build the `_meta` object for `fallow dead-code --format json --explain`.
74#[must_use]
75pub fn check_meta() -> Meta {
76    let mut rules = BTreeMap::new();
77    for contract in issue_output_contracts() {
78        rules.insert(
79            contract.code.to_string(),
80            MetaRule {
81                name: Some(contract.meta_name.to_string()),
82                description: Some(contract.meta_description.to_string()),
83                docs: Some(rule_docs_url(contract.meta_docs_path)),
84            },
85        );
86    }
87    rules.insert(
88        "missing-suppression-reason".to_string(),
89        MetaRule {
90            name: Some("Missing Suppression Reason".to_string()),
91            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()),
92            docs: Some(rule_docs_url("explanations/dead-code#stale-suppressions")),
93        },
94    );
95
96    Meta {
97        docs: Some(CHECK_DOCS.to_string()),
98        field_definitions: BTreeMap::from([
99            (
100                "actions[]".to_string(),
101                ACTIONS_FIELD_DEFINITION.to_string(),
102            ),
103            (
104                "actions[].auto_fixable".to_string(),
105                ACTIONS_AUTO_FIXABLE_FIELD_DEFINITION.to_string(),
106            ),
107        ]),
108        rules,
109        ..Meta::default()
110    }
111}
112
113#[must_use]
114pub fn dead_code_docs_url(anchor: &str) -> String {
115    format!("{DOCS_BASE}/explanations/dead-code#{anchor}")
116}
117
118#[must_use]
119pub fn rule_docs_url(docs_path: &str) -> String {
120    format!("{DOCS_BASE}/{docs_path}")
121}
122
123/// Output-facing dead-code result contracts in stable registry order.
124pub fn issue_output_contracts() -> impl Iterator<Item = IssueOutputContract> {
125    result_issue_metas().map(IssueOutputContract::from_result_meta)
126}
127
128/// Output-facing dead-code result contract by issue code.
129#[must_use]
130pub fn issue_output_contract_by_code(code: &str) -> Option<IssueOutputContract> {
131    issue_result_meta_by_code(code).map(IssueOutputContract::from_result_meta)
132}
133
134#[cfg(test)]
135mod tests {
136    use std::collections::BTreeSet;
137
138    use super::*;
139
140    #[test]
141    fn every_result_row_has_output_contract() {
142        let result_codes: BTreeSet<&str> = result_issue_metas().map(|meta| meta.code).collect();
143        let output_codes: BTreeSet<&str> = issue_output_contracts()
144            .map(|contract| contract.code)
145            .collect();
146        assert_eq!(result_codes, output_codes);
147    }
148
149    #[test]
150    fn summary_contracts_are_present() {
151        for contract in issue_output_contracts() {
152            assert!(!contract.summary_label.is_empty());
153            assert!(!contract.summary_docs_anchor.is_empty());
154            assert!(!contract.meta_name.is_empty());
155            assert!(!contract.meta_description.is_empty());
156            assert!(!contract.meta_docs_path.is_empty());
157        }
158    }
159
160    #[test]
161    fn check_meta_uses_output_contracts() {
162        let meta = check_meta();
163        assert_eq!(meta.docs.as_deref(), Some(CHECK_DOCS));
164        assert!(
165            meta.field_definitions["actions[].auto_fixable"].contains("PER FINDING"),
166            "auto_fixable definition should preserve per-finding guidance"
167        );
168        assert!(meta.rules.contains_key("unused-export"));
169        assert!(meta.rules.contains_key("missing-suppression-reason"));
170        assert_eq!(
171            meta.rules["unused-dev-dependency"].docs.as_deref(),
172            Some("https://docs.fallow.tools/explanations/dead-code#unused-devdependencies")
173        );
174    }
175
176    #[test]
177    fn ci_format_contracts_are_present() {
178        for contract in issue_output_contracts() {
179            assert!(
180                contract
181                    .sarif_rule_ids
182                    .contains(&format!("fallow/{}", contract.code)),
183                "result metadata code {} has wrong SARIF rule id",
184                contract.code
185            );
186            for rule_id in contract.sarif_rule_ids {
187                assert!(
188                    rule_id.starts_with("fallow/"),
189                    "result metadata code {} has unprefixed SARIF rule id {rule_id}",
190                    contract.code
191                );
192            }
193            for check_name in contract.codeclimate_check_names {
194                assert!(
195                    check_name.starts_with("fallow/"),
196                    "result metadata code {} has unprefixed CodeClimate check name {check_name}",
197                    contract.code
198                );
199            }
200        }
201    }
202
203    #[test]
204    fn codeclimate_result_exclusions_are_explicit() {
205        let expected = BTreeSet::from(["duplicate-prop-shape", "prop-drilling", "thin-wrapper"]);
206        let from_contracts: BTreeSet<&str> = issue_output_contracts()
207            .filter(|contract| contract.codeclimate_check_names.is_empty())
208            .map(|contract| contract.code)
209            .collect();
210        assert_eq!(expected, from_contracts);
211    }
212
213    #[test]
214    fn codeclimate_result_codes_match_result_metadata() {
215        let result_codes: BTreeSet<&str> = result_issue_metas().map(|meta| meta.code).collect();
216        let codeclimate_codes: BTreeSet<&str> = CODECLIMATE_RESULT_CODES.iter().copied().collect();
217        assert!(codeclimate_codes.is_subset(&result_codes));
218    }
219
220    #[test]
221    fn ts_alias_policy_is_explicit() {
222        let aliases: BTreeSet<(&str, &str)> = issue_output_contracts()
223            .filter_map(|contract| contract.ts_alias.map(|alias| (alias.name, alias.parent)))
224            .collect();
225
226        assert_eq!(
227            BTreeSet::from([
228                ("BoundaryViolation", "BoundaryViolationFinding"),
229                ("CircularDependency", "CircularDependencyFinding"),
230                ("DuplicateExport", "DuplicateExportFinding"),
231                ("EmptyCatalogGroup", "EmptyCatalogGroupFinding"),
232                (
233                    "MisconfiguredDependencyOverride",
234                    "MisconfiguredDependencyOverrideFinding",
235                ),
236                ("PrivateTypeLeak", "PrivateTypeLeakFinding"),
237                ("ReExportCycle", "ReExportCycleFinding"),
238                ("TestOnlyDependency", "TestOnlyDependencyFinding"),
239                ("TypeOnlyDependency", "TypeOnlyDependencyFinding"),
240                ("UnlistedDependency", "UnlistedDependencyFinding"),
241                (
242                    "UnresolvedCatalogReference",
243                    "UnresolvedCatalogReferenceFinding",
244                ),
245                ("UnresolvedImport", "UnresolvedImportFinding"),
246                ("UnusedCatalogEntry", "UnusedCatalogEntryFinding"),
247                ("UnusedDependency", "UnusedDependencyFinding"),
248                ("UnusedDependency", "UnusedDevDependencyFinding"),
249                ("UnusedDependency", "UnusedOptionalDependencyFinding"),
250                (
251                    "UnusedDependencyOverride",
252                    "UnusedDependencyOverrideFinding",
253                ),
254                ("UnusedExport", "UnusedExportFinding"),
255                ("UnusedFile", "UnusedFileFinding"),
256                ("UnusedMember", "UnusedClassMemberFinding"),
257                ("UnusedMember", "UnusedEnumMemberFinding"),
258                ("UnusedMember", "UnusedStoreMemberFinding"),
259            ]),
260            aliases
261        );
262    }
263}