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