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]
115pub fn dead_code_docs_url(anchor: &str) -> String {
116 format!("{DOCS_BASE}/explanations/dead-code#{anchor}")
117}
118
119#[must_use]
121pub fn rule_docs_url(docs_path: &str) -> String {
122 format!("{DOCS_BASE}/{docs_path}")
123}
124
125pub fn issue_output_contracts() -> impl Iterator<Item = IssueOutputContract> {
127 result_issue_metas().map(IssueOutputContract::from_result_meta)
128}
129
130#[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}