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
12pub const CHECK_DOCS: &str = "https://docs.fallow.tools/cli/dead-code";
14
15pub 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
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`.";
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct IssueOutputContract {
25 pub code: &'static str,
27 pub result_key: &'static str,
29 pub counts_in_total: bool,
31 pub summary_label: &'static str,
33 pub summary_docs_anchor: &'static str,
35 pub meta_name: &'static str,
37 pub meta_description: &'static str,
39 pub meta_docs_path: &'static str,
41 pub sarif_rule_ids: Vec<String>,
43 pub codeclimate_check_names: Vec<String>,
45 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#[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
118pub fn issue_output_contracts() -> impl Iterator<Item = IssueOutputContract> {
120 result_issue_metas().map(IssueOutputContract::from_result_meta)
121}
122
123#[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}