fallow-output 3.0.0

Output contract types for fallow reports
Documentation
use std::collections::BTreeMap;

use fallow_types::envelope::{Meta, MetaRule};
pub use fallow_types::issue_meta::{CODECLIMATE_RESULT_CODES, TsAliasMeta};
use fallow_types::issue_meta::{
    IssueResultMeta, issue_meta_by_code, issue_result_meta_by_code, result_issue_metas,
};

const DOCS_BASE: &str = "https://docs.fallow.tools";

/// Docs URL for the dead-code/check command.
pub const CHECK_DOCS: &str = "https://docs.fallow.tools/cli/dead-code";

/// `_meta` description for the per-finding `actions[]` array shared across
/// JSON output.
pub 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.";

/// `_meta` description for the per-action `auto_fixable` bool.
pub 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`.";

/// Output-facing contract metadata for a serialized dead-code result row.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssueOutputContract {
    /// Canonical issue code that owns this result array.
    pub code: &'static str,
    /// Serialized `AnalysisResults` array key that carries this issue row.
    pub result_key: &'static str,
    /// Whether `result_key` contributes to `AnalysisResults::total_issues()`.
    pub counts_in_total: bool,
    /// Label used by CI summary tables.
    pub summary_label: &'static str,
    /// Documentation anchor used by CI summary tables.
    pub summary_docs_anchor: &'static str,
    /// Human-readable name emitted in dead-code `_meta.rules`.
    pub meta_name: &'static str,
    /// Explanation emitted in dead-code `_meta.rules`.
    pub meta_description: &'static str,
    /// Documentation path emitted in dead-code `_meta.rules`.
    pub meta_docs_path: &'static str,
    /// SARIF rule ids used by the CLI SARIF formatter for this result row.
    pub sarif_rule_ids: Vec<String>,
    /// CodeClimate check names used by the CodeClimate formatter.
    pub codeclimate_check_names: Vec<String>,
    /// Published TypeScript alias policy for backwards-compatible bare names.
    pub ts_alias: Option<TsAliasMeta>,
}

impl IssueOutputContract {
    #[must_use]
    fn from_result_meta(meta: &IssueResultMeta) -> Self {
        let issue = issue_meta_by_code(meta.code).unwrap_or_else(|| {
            panic!(
                "output contract must reference IssueKindMeta row: {}",
                meta.code
            )
        });
        Self {
            code: meta.code,
            result_key: meta.result_key,
            counts_in_total: meta.counts_in_total,
            summary_label: meta.summary_label,
            summary_docs_anchor: meta.docs_anchor,
            meta_name: meta.meta_name,
            meta_description: meta.meta_description,
            meta_docs_path: meta.meta_docs_path,
            sarif_rule_ids: issue.sarif_rule_ids(),
            codeclimate_check_names: issue.codeclimate_check_names(),
            ts_alias: issue.ts_alias(),
        }
    }
}

/// Build the `_meta` object for `fallow dead-code --format json --explain`.
#[must_use]
pub fn check_meta() -> Meta {
    let mut rules = BTreeMap::new();
    for contract in issue_output_contracts() {
        rules.insert(
            contract.code.to_string(),
            MetaRule {
                name: Some(contract.meta_name.to_string()),
                description: Some(contract.meta_description.to_string()),
                docs: Some(rule_docs_url(contract.meta_docs_path)),
            },
        );
    }
    rules.insert(
        "missing-suppression-reason".to_string(),
        MetaRule {
            name: Some("Missing Suppression Reason".to_string()),
            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()),
            docs: Some(rule_docs_url("explanations/dead-code#stale-suppressions")),
        },
    );

    Meta {
        docs: Some(CHECK_DOCS.to_string()),
        field_definitions: BTreeMap::from([
            (
                "actions[]".to_string(),
                ACTIONS_FIELD_DEFINITION.to_string(),
            ),
            (
                "actions[].auto_fixable".to_string(),
                ACTIONS_AUTO_FIXABLE_FIELD_DEFINITION.to_string(),
            ),
        ]),
        rules,
        ..Meta::default()
    }
}

#[must_use]
pub fn dead_code_docs_url(anchor: &str) -> String {
    format!("{DOCS_BASE}/explanations/dead-code#{anchor}")
}

#[must_use]
pub fn rule_docs_url(docs_path: &str) -> String {
    format!("{DOCS_BASE}/{docs_path}")
}

/// Output-facing dead-code result contracts in stable registry order.
pub fn issue_output_contracts() -> impl Iterator<Item = IssueOutputContract> {
    result_issue_metas().map(IssueOutputContract::from_result_meta)
}

/// Output-facing dead-code result contract by issue code.
#[must_use]
pub fn issue_output_contract_by_code(code: &str) -> Option<IssueOutputContract> {
    issue_result_meta_by_code(code).map(IssueOutputContract::from_result_meta)
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeSet;

    use super::*;

    #[test]
    fn every_result_row_has_output_contract() {
        let result_codes: BTreeSet<&str> = result_issue_metas().map(|meta| meta.code).collect();
        let output_codes: BTreeSet<&str> = issue_output_contracts()
            .map(|contract| contract.code)
            .collect();
        assert_eq!(result_codes, output_codes);
    }

    #[test]
    fn summary_contracts_are_present() {
        for contract in issue_output_contracts() {
            assert!(!contract.summary_label.is_empty());
            assert!(!contract.summary_docs_anchor.is_empty());
            assert!(!contract.meta_name.is_empty());
            assert!(!contract.meta_description.is_empty());
            assert!(!contract.meta_docs_path.is_empty());
        }
    }

    #[test]
    fn check_meta_uses_output_contracts() {
        let meta = check_meta();
        assert_eq!(meta.docs.as_deref(), Some(CHECK_DOCS));
        assert!(
            meta.field_definitions["actions[].auto_fixable"].contains("PER FINDING"),
            "auto_fixable definition should preserve per-finding guidance"
        );
        assert!(meta.rules.contains_key("unused-export"));
        assert!(meta.rules.contains_key("missing-suppression-reason"));
        assert_eq!(
            meta.rules["unused-dev-dependency"].docs.as_deref(),
            Some("https://docs.fallow.tools/explanations/dead-code#unused-devdependencies")
        );
    }

    #[test]
    fn ci_format_contracts_are_present() {
        for contract in issue_output_contracts() {
            assert!(
                contract
                    .sarif_rule_ids
                    .contains(&format!("fallow/{}", contract.code)),
                "result metadata code {} has wrong SARIF rule id",
                contract.code
            );
            for rule_id in contract.sarif_rule_ids {
                assert!(
                    rule_id.starts_with("fallow/"),
                    "result metadata code {} has unprefixed SARIF rule id {rule_id}",
                    contract.code
                );
            }
            for check_name in contract.codeclimate_check_names {
                assert!(
                    check_name.starts_with("fallow/"),
                    "result metadata code {} has unprefixed CodeClimate check name {check_name}",
                    contract.code
                );
            }
        }
    }

    #[test]
    fn codeclimate_result_exclusions_are_explicit() {
        let expected = BTreeSet::from(["duplicate-prop-shape", "prop-drilling", "thin-wrapper"]);
        let from_contracts: BTreeSet<&str> = issue_output_contracts()
            .filter(|contract| contract.codeclimate_check_names.is_empty())
            .map(|contract| contract.code)
            .collect();
        assert_eq!(expected, from_contracts);
    }

    #[test]
    fn codeclimate_result_codes_match_result_metadata() {
        let result_codes: BTreeSet<&str> = result_issue_metas().map(|meta| meta.code).collect();
        let codeclimate_codes: BTreeSet<&str> = CODECLIMATE_RESULT_CODES.iter().copied().collect();
        assert!(codeclimate_codes.is_subset(&result_codes));
    }

    #[test]
    fn ts_alias_policy_is_explicit() {
        let aliases: BTreeSet<(&str, &str)> = issue_output_contracts()
            .filter_map(|contract| contract.ts_alias.map(|alias| (alias.name, alias.parent)))
            .collect();

        assert_eq!(
            BTreeSet::from([
                ("BoundaryViolation", "BoundaryViolationFinding"),
                ("CircularDependency", "CircularDependencyFinding"),
                ("DuplicateExport", "DuplicateExportFinding"),
                ("EmptyCatalogGroup", "EmptyCatalogGroupFinding"),
                (
                    "MisconfiguredDependencyOverride",
                    "MisconfiguredDependencyOverrideFinding",
                ),
                ("PrivateTypeLeak", "PrivateTypeLeakFinding"),
                ("ReExportCycle", "ReExportCycleFinding"),
                ("TestOnlyDependency", "TestOnlyDependencyFinding"),
                ("TypeOnlyDependency", "TypeOnlyDependencyFinding"),
                ("UnlistedDependency", "UnlistedDependencyFinding"),
                (
                    "UnresolvedCatalogReference",
                    "UnresolvedCatalogReferenceFinding",
                ),
                ("UnresolvedImport", "UnresolvedImportFinding"),
                ("UnusedCatalogEntry", "UnusedCatalogEntryFinding"),
                ("UnusedDependency", "UnusedDependencyFinding"),
                ("UnusedDependency", "UnusedDevDependencyFinding"),
                ("UnusedDependency", "UnusedOptionalDependencyFinding"),
                (
                    "UnusedDependencyOverride",
                    "UnusedDependencyOverrideFinding",
                ),
                ("UnusedExport", "UnusedExportFinding"),
                ("UnusedFile", "UnusedFileFinding"),
                ("UnusedMember", "UnusedClassMemberFinding"),
                ("UnusedMember", "UnusedEnumMemberFinding"),
                ("UnusedMember", "UnusedStoreMemberFinding"),
            ]),
            aliases
        );
    }
}