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