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