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/// Public docs URL for a dead-code explanation `anchor` on the dead-code page.
114#[must_use]
115pub fn dead_code_docs_url(anchor: &str) -> String {
116    format!("{DOCS_BASE}/explanations/dead-code#{anchor}")
117}
118
119/// Public docs URL for a rule's `docs_path` relative to the docs site root.
120#[must_use]
121pub fn rule_docs_url(docs_path: &str) -> String {
122    format!("{DOCS_BASE}/{docs_path}")
123}
124
125/// Output-facing dead-code result contracts in stable registry order.
126pub fn issue_output_contracts() -> impl Iterator<Item = IssueOutputContract> {
127    result_issue_metas().map(IssueOutputContract::from_result_meta)
128}
129
130/// Output-facing dead-code result contract by issue code.
131#[must_use]
132pub fn issue_output_contract_by_code(code: &str) -> Option<IssueOutputContract> {
133    issue_result_meta_by_code(code).map(IssueOutputContract::from_result_meta)
134}
135
136#[cfg(test)]
137mod tests {
138    use std::collections::BTreeSet;
139
140    use super::*;
141
142    #[test]
143    fn every_result_row_has_output_contract() {
144        let result_codes: BTreeSet<&str> = result_issue_metas().map(|meta| meta.code).collect();
145        let output_codes: BTreeSet<&str> = issue_output_contracts()
146            .map(|contract| contract.code)
147            .collect();
148        assert_eq!(result_codes, output_codes);
149    }
150
151    #[test]
152    fn summary_contracts_are_present() {
153        for contract in issue_output_contracts() {
154            assert!(!contract.summary_label.is_empty());
155            assert!(!contract.summary_docs_anchor.is_empty());
156            assert!(!contract.meta_name.is_empty());
157            assert!(!contract.meta_description.is_empty());
158            assert!(!contract.meta_docs_path.is_empty());
159        }
160    }
161
162    #[test]
163    fn check_meta_uses_output_contracts() {
164        let meta = check_meta();
165        assert_eq!(meta.docs.as_deref(), Some(CHECK_DOCS));
166        assert!(
167            meta.field_definitions["actions[].auto_fixable"].contains("PER FINDING"),
168            "auto_fixable definition should preserve per-finding guidance"
169        );
170        assert!(meta.rules.contains_key("unused-export"));
171        assert!(meta.rules.contains_key("missing-suppression-reason"));
172        assert_eq!(
173            meta.rules["unused-dev-dependency"].docs.as_deref(),
174            Some("https://docs.fallow.tools/explanations/dead-code#unused-devdependencies")
175        );
176    }
177
178    #[test]
179    fn ci_format_contracts_are_present() {
180        for contract in issue_output_contracts() {
181            assert!(
182                contract
183                    .sarif_rule_ids
184                    .contains(&format!("fallow/{}", contract.code)),
185                "result metadata code {} has wrong SARIF rule id",
186                contract.code
187            );
188            for rule_id in contract.sarif_rule_ids {
189                assert!(
190                    rule_id.starts_with("fallow/"),
191                    "result metadata code {} has unprefixed SARIF rule id {rule_id}",
192                    contract.code
193                );
194            }
195            for check_name in contract.codeclimate_check_names {
196                assert!(
197                    check_name.starts_with("fallow/"),
198                    "result metadata code {} has unprefixed CodeClimate check name {check_name}",
199                    contract.code
200                );
201            }
202        }
203    }
204
205    #[test]
206    fn codeclimate_result_exclusions_are_explicit() {
207        let expected = BTreeSet::from(["duplicate-prop-shape", "prop-drilling", "thin-wrapper"]);
208        let from_contracts: BTreeSet<&str> = issue_output_contracts()
209            .filter(|contract| contract.codeclimate_check_names.is_empty())
210            .map(|contract| contract.code)
211            .collect();
212        assert_eq!(expected, from_contracts);
213    }
214
215    #[test]
216    fn codeclimate_result_codes_match_result_metadata() {
217        let result_codes: BTreeSet<&str> = result_issue_metas().map(|meta| meta.code).collect();
218        let codeclimate_codes: BTreeSet<&str> = CODECLIMATE_RESULT_CODES.iter().copied().collect();
219        assert!(codeclimate_codes.is_subset(&result_codes));
220    }
221
222    #[test]
223    fn ts_alias_policy_is_explicit() {
224        let aliases: BTreeSet<(&str, &str)> = issue_output_contracts()
225            .filter_map(|contract| contract.ts_alias.map(|alias| (alias.name, alias.parent)))
226            .collect();
227
228        assert_eq!(
229            BTreeSet::from([
230                ("BoundaryViolation", "BoundaryViolationFinding"),
231                ("CircularDependency", "CircularDependencyFinding"),
232                (
233                    "DevDependencyInProduction",
234                    "DevDependencyInProductionFinding",
235                ),
236                ("DuplicateExport", "DuplicateExportFinding"),
237                ("EmptyCatalogGroup", "EmptyCatalogGroupFinding"),
238                (
239                    "MisconfiguredDependencyOverride",
240                    "MisconfiguredDependencyOverrideFinding",
241                ),
242                ("PrivateTypeLeak", "PrivateTypeLeakFinding"),
243                ("ReExportCycle", "ReExportCycleFinding"),
244                ("TestOnlyDependency", "TestOnlyDependencyFinding"),
245                ("TypeOnlyDependency", "TypeOnlyDependencyFinding"),
246                ("UnlistedDependency", "UnlistedDependencyFinding"),
247                (
248                    "UnresolvedCatalogReference",
249                    "UnresolvedCatalogReferenceFinding",
250                ),
251                ("UnresolvedImport", "UnresolvedImportFinding"),
252                ("UnusedCatalogEntry", "UnusedCatalogEntryFinding"),
253                ("UnusedDependency", "UnusedDependencyFinding"),
254                ("UnusedDependency", "UnusedDevDependencyFinding"),
255                ("UnusedDependency", "UnusedOptionalDependencyFinding"),
256                (
257                    "UnusedDependencyOverride",
258                    "UnusedDependencyOverrideFinding",
259                ),
260                ("UnusedExport", "UnusedExportFinding"),
261                ("UnusedFile", "UnusedFileFinding"),
262                ("UnusedMember", "UnusedClassMemberFinding"),
263                ("UnusedMember", "UnusedEnumMemberFinding"),
264                ("UnusedMember", "UnusedStoreMemberFinding"),
265            ]),
266            aliases
267        );
268    }
269}