1use std::collections::BTreeMap;
2
3use fallow_types::envelope::{Meta, MetaMetric, 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
11pub const CHECK_DOCS: &str = "https://fallow.tools/docs/cli/dead-code/";
13
14pub const ARCHITECTURE_DOCS: &str = "https://fallow.tools/docs/cli/architecture/";
16
17pub 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
21pub 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#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct IssueOutputContract {
27 pub code: &'static str,
29 pub result_key: &'static str,
31 pub counts_in_total: bool,
33 pub summary_label: &'static str,
35 pub summary_docs_anchor: &'static str,
37 pub meta_name: &'static str,
39 pub meta_description: &'static str,
41 pub meta_docs_path: &'static str,
43 pub sarif_rule_ids: Vec<String>,
45 pub codeclimate_check_names: Vec<String>,
47 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#[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 metrics: BTreeMap::from([(
113 "summary.prop_drilling_chains".to_string(),
114 MetaMetric {
115 name: Some("Enforced prop-drilling chains".to_string()),
116 description: Some("Number of prop-drilling chains with saved effective_severity set to error. Warning chains are excluded; the counter is omitted when zero.".to_string()),
117 range: Some("Non-negative integer".to_string()),
118 interpretation: Some("Each counted chain contributes to total_issues and CI error gates. The finding array can also contain advisory chains excluded from this counter.".to_string()),
119 },
120 )]),
121 ..Meta::default()
122 }
123}
124
125pub const ARCHITECTURE_RESULT_KEYS: &[&str] = &[
131 "circular_dependencies",
132 "re_export_cycles",
133 "package_cycles",
134 "boundary_violations",
135 "boundary_coverage_violations",
136 "boundary_call_violations",
137 "policy_violations",
138];
139
140#[must_use]
145pub fn architecture_meta() -> Meta {
146 let mut meta = check_meta();
147 let architecture_codes: Vec<&'static str> = issue_output_contracts()
148 .filter(|contract| ARCHITECTURE_RESULT_KEYS.contains(&contract.result_key))
149 .map(|contract| contract.code)
150 .collect();
151 meta.rules
152 .retain(|code, _| architecture_codes.contains(&code.as_str()));
153 meta.metrics.remove("summary.prop_drilling_chains");
154 meta.docs = Some(ARCHITECTURE_DOCS.to_string());
155 meta
156}
157
158#[must_use]
163pub fn rule_docs_url(docs_path: &str) -> String {
164 match docs_path.split_once('#') {
165 Some((page, anchor)) => format!("{DOCS_BASE}/{page}/#{anchor}"),
166 None => format!("{DOCS_BASE}/{docs_path}/"),
167 }
168}
169
170pub fn issue_output_contracts() -> impl Iterator<Item = IssueOutputContract> {
172 result_issue_metas().map(IssueOutputContract::from_result_meta)
173}
174
175#[must_use]
177pub fn issue_output_contract_by_code(code: &str) -> Option<IssueOutputContract> {
178 issue_result_meta_by_code(code).map(IssueOutputContract::from_result_meta)
179}
180
181#[cfg(test)]
182mod tests {
183 use std::collections::BTreeSet;
184
185 use super::*;
186
187 #[test]
188 fn architecture_meta_lists_only_architecture_rules() {
189 let meta = architecture_meta();
190 let expected: BTreeSet<&str> = issue_output_contracts()
191 .filter(|contract| ARCHITECTURE_RESULT_KEYS.contains(&contract.result_key))
192 .map(|contract| contract.code)
193 .collect();
194 let actual: BTreeSet<&str> = meta.rules.keys().map(String::as_str).collect();
195
196 assert_eq!(actual, expected);
197 assert_eq!(meta.docs.as_deref(), Some(ARCHITECTURE_DOCS));
198 assert_eq!(meta.field_definitions, check_meta().field_definitions);
199 assert!(!meta.metrics.contains_key("summary.prop_drilling_chains"));
200 }
201
202 #[test]
203 fn every_architecture_result_key_names_a_result_row() {
204 let keys: BTreeSet<&str> = issue_output_contracts()
205 .map(|contract| contract.result_key)
206 .collect();
207 for key in ARCHITECTURE_RESULT_KEYS {
208 assert!(keys.contains(key), "`{key}` is not a result array key");
209 }
210 let architecture_rows = issue_output_contracts()
211 .filter(|contract| ARCHITECTURE_RESULT_KEYS.contains(&contract.result_key))
212 .count();
213 assert_eq!(architecture_rows, ARCHITECTURE_RESULT_KEYS.len());
214 }
215
216 #[test]
217 fn every_result_row_has_output_contract() {
218 let result_codes: BTreeSet<&str> = result_issue_metas().map(|meta| meta.code).collect();
219 let output_codes: BTreeSet<&str> = issue_output_contracts()
220 .map(|contract| contract.code)
221 .collect();
222 assert_eq!(result_codes, output_codes);
223 }
224
225 #[test]
226 fn summary_contracts_are_present() {
227 for contract in issue_output_contracts() {
228 assert!(!contract.summary_label.is_empty());
229 assert!(!contract.summary_docs_anchor.is_empty());
230 assert!(!contract.meta_name.is_empty());
231 assert!(!contract.meta_description.is_empty());
232 assert!(!contract.meta_docs_path.is_empty());
233 }
234 }
235
236 #[test]
237 fn check_meta_uses_output_contracts() {
238 let meta = check_meta();
239 assert_eq!(meta.docs.as_deref(), Some(CHECK_DOCS));
240 assert!(
241 meta.field_definitions["actions[].auto_fixable"].contains("PER FINDING"),
242 "auto_fixable definition should preserve per-finding guidance"
243 );
244 assert!(meta.rules.contains_key("unused-export"));
245 assert!(meta.rules.contains_key("missing-suppression-reason"));
246 assert_eq!(
247 meta.rules["unused-dev-dependency"].docs.as_deref(),
248 Some("https://fallow.tools/docs/explanations/dead-code/#unused-devdependencies")
249 );
250 }
251
252 #[test]
253 fn ci_format_contracts_are_present() {
254 for contract in issue_output_contracts() {
255 assert!(
256 contract
257 .sarif_rule_ids
258 .contains(&format!("fallow/{}", contract.code)),
259 "result metadata code {} has wrong SARIF rule id",
260 contract.code
261 );
262 for rule_id in contract.sarif_rule_ids {
263 assert!(
264 rule_id.starts_with("fallow/"),
265 "result metadata code {} has unprefixed SARIF rule id {rule_id}",
266 contract.code
267 );
268 }
269 for check_name in contract.codeclimate_check_names {
270 assert!(
271 check_name.starts_with("fallow/"),
272 "result metadata code {} has unprefixed CodeClimate check name {check_name}",
273 contract.code
274 );
275 }
276 }
277 }
278
279 #[test]
280 fn codeclimate_result_exclusions_are_explicit() {
281 let expected = BTreeSet::from(["duplicate-prop-shape", "thin-wrapper"]);
282 let from_contracts: BTreeSet<&str> = issue_output_contracts()
283 .filter(|contract| contract.codeclimate_check_names.is_empty())
284 .map(|contract| contract.code)
285 .collect();
286 assert_eq!(expected, from_contracts);
287 }
288
289 #[test]
290 fn codeclimate_result_codes_match_result_metadata() {
291 let result_codes: BTreeSet<&str> = result_issue_metas().map(|meta| meta.code).collect();
292 let codeclimate_codes: BTreeSet<&str> = CODECLIMATE_RESULT_CODES.iter().copied().collect();
293 assert!(codeclimate_codes.is_subset(&result_codes));
294 }
295
296 #[test]
297 fn ts_alias_policy_is_explicit() {
298 let aliases: BTreeSet<(&str, &str)> = issue_output_contracts()
299 .filter_map(|contract| contract.ts_alias.map(|alias| (alias.name, alias.parent)))
300 .collect();
301
302 assert_eq!(
303 BTreeSet::from([
304 ("BoundaryViolation", "BoundaryViolationFinding"),
305 ("CircularDependency", "CircularDependencyFinding"),
306 ("DeprecatedExportInUse", "DeprecatedExportInUseFinding"),
307 (
308 "DevDependencyInProduction",
309 "DevDependencyInProductionFinding",
310 ),
311 ("DuplicateExport", "DuplicateExportFinding"),
312 ("EmptyCatalogGroup", "EmptyCatalogGroupFinding"),
313 (
314 "MisconfiguredDependencyOverride",
315 "MisconfiguredDependencyOverrideFinding",
316 ),
317 ("PackageCycle", "PackageCycleFinding"),
318 ("PrivateTypeLeak", "PrivateTypeLeakFinding"),
319 ("ReExportCycle", "ReExportCycleFinding"),
320 ("TestOnlyDependency", "TestOnlyDependencyFinding"),
321 ("TypeOnlyDependency", "TypeOnlyDependencyFinding"),
322 ("UnlistedDependency", "UnlistedDependencyFinding"),
323 (
324 "UnresolvedCatalogReference",
325 "UnresolvedCatalogReferenceFinding",
326 ),
327 ("UnresolvedImport", "UnresolvedImportFinding"),
328 ("UnusedCatalogEntry", "UnusedCatalogEntryFinding"),
329 ("UnusedDependency", "UnusedDependencyFinding"),
330 ("UnusedDependency", "UnusedDevDependencyFinding"),
331 ("UnusedDependency", "UnusedOptionalDependencyFinding"),
332 (
333 "UnusedDependencyOverride",
334 "UnusedDependencyOverrideFinding",
335 ),
336 ("UnusedExport", "UnusedExportFinding"),
337 ("UnusedFile", "UnusedFileFinding"),
338 ("UnusedMember", "UnusedClassMemberFinding"),
339 ("UnusedMember", "UnusedEnumMemberFinding"),
340 ("UnusedMember", "UnusedStoreMemberFinding"),
341 ]),
342 aliases
343 );
344 }
345}