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 ("summary.thin_wrappers".to_owned(), component_error_metric("Thin wrappers")),
122 ("summary.duplicate_prop_shapes".to_owned(), component_error_metric("Duplicate prop shapes")),
123 ]),
124 ..Meta::default()
125 }
126}
127
128fn component_error_metric(name: &str) -> MetaMetric {
129 MetaMetric {
130 name: Some(format!("Enforced {name}")),
131 description: Some("Number of findings with saved effective_severity set to error. Warnings are excluded; the counter is omitted when zero.".to_owned()),
132 range: Some("Non-negative integer".to_owned()),
133 interpretation: Some("Each counted finding contributes to total_issues and CI error gates. The finding array can also contain advisory warnings.".to_owned()),
134 }
135}
136
137pub const ARCHITECTURE_RESULT_KEYS: &[&str] = &[
143 "circular_dependencies",
144 "re_export_cycles",
145 "package_cycles",
146 "boundary_violations",
147 "boundary_coverage_violations",
148 "boundary_call_violations",
149 "policy_violations",
150];
151
152#[must_use]
157pub fn architecture_meta() -> Meta {
158 let mut meta = check_meta();
159 let architecture_codes: Vec<&'static str> = issue_output_contracts()
160 .filter(|contract| ARCHITECTURE_RESULT_KEYS.contains(&contract.result_key))
161 .map(|contract| contract.code)
162 .collect();
163 meta.rules
164 .retain(|code, _| architecture_codes.contains(&code.as_str()));
165 for key in fallow_types::results::ERROR_ONLY_RESULT_KEYS {
166 meta.metrics.remove(&format!("summary.{key}"));
167 }
168 meta.docs = Some(ARCHITECTURE_DOCS.to_string());
169 meta
170}
171
172#[must_use]
177pub fn rule_docs_url(docs_path: &str) -> String {
178 match docs_path.split_once('#') {
179 Some((page, anchor)) => format!("{DOCS_BASE}/{page}/#{anchor}"),
180 None => format!("{DOCS_BASE}/{docs_path}/"),
181 }
182}
183
184pub fn issue_output_contracts() -> impl Iterator<Item = IssueOutputContract> {
186 result_issue_metas().map(IssueOutputContract::from_result_meta)
187}
188
189#[must_use]
191pub fn issue_output_contract_by_code(code: &str) -> Option<IssueOutputContract> {
192 issue_result_meta_by_code(code).map(IssueOutputContract::from_result_meta)
193}
194
195#[cfg(test)]
196mod tests {
197 use std::collections::BTreeSet;
198
199 use super::*;
200
201 #[test]
202 fn architecture_meta_lists_only_architecture_rules() {
203 let meta = architecture_meta();
204 let expected: BTreeSet<&str> = issue_output_contracts()
205 .filter(|contract| ARCHITECTURE_RESULT_KEYS.contains(&contract.result_key))
206 .map(|contract| contract.code)
207 .collect();
208 let actual: BTreeSet<&str> = meta.rules.keys().map(String::as_str).collect();
209
210 assert_eq!(actual, expected);
211 assert_eq!(meta.docs.as_deref(), Some(ARCHITECTURE_DOCS));
212 assert_eq!(meta.field_definitions, check_meta().field_definitions);
213 assert!(!meta.metrics.contains_key("summary.prop_drilling_chains"));
214 }
215
216 #[test]
217 fn every_architecture_result_key_names_a_result_row() {
218 let keys: BTreeSet<&str> = issue_output_contracts()
219 .map(|contract| contract.result_key)
220 .collect();
221 for key in ARCHITECTURE_RESULT_KEYS {
222 assert!(keys.contains(key), "`{key}` is not a result array key");
223 }
224 let architecture_rows = issue_output_contracts()
225 .filter(|contract| ARCHITECTURE_RESULT_KEYS.contains(&contract.result_key))
226 .count();
227 assert_eq!(architecture_rows, ARCHITECTURE_RESULT_KEYS.len());
228 }
229
230 #[test]
231 fn every_result_row_has_output_contract() {
232 let result_codes: BTreeSet<&str> = result_issue_metas().map(|meta| meta.code).collect();
233 let output_codes: BTreeSet<&str> = issue_output_contracts()
234 .map(|contract| contract.code)
235 .collect();
236 assert_eq!(result_codes, output_codes);
237 }
238
239 #[test]
240 fn summary_contracts_are_present() {
241 for contract in issue_output_contracts() {
242 assert!(!contract.summary_label.is_empty());
243 assert!(!contract.summary_docs_anchor.is_empty());
244 assert!(!contract.meta_name.is_empty());
245 assert!(!contract.meta_description.is_empty());
246 assert!(!contract.meta_docs_path.is_empty());
247 }
248 }
249
250 #[test]
251 fn check_meta_uses_output_contracts() {
252 let meta = check_meta();
253 assert_eq!(meta.docs.as_deref(), Some(CHECK_DOCS));
254 assert!(
255 meta.field_definitions["actions[].auto_fixable"].contains("PER FINDING"),
256 "auto_fixable definition should preserve per-finding guidance"
257 );
258 assert!(meta.rules.contains_key("unused-export"));
259 assert!(meta.rules.contains_key("missing-suppression-reason"));
260 assert_eq!(
261 meta.rules["unused-dev-dependency"].docs.as_deref(),
262 Some("https://fallow.tools/docs/explanations/dead-code/#unused-devdependencies")
263 );
264 }
265
266 #[test]
267 fn ci_format_contracts_are_present() {
268 for contract in issue_output_contracts() {
269 assert!(
270 contract
271 .sarif_rule_ids
272 .contains(&format!("fallow/{}", contract.code)),
273 "result metadata code {} has wrong SARIF rule id",
274 contract.code
275 );
276 for rule_id in contract.sarif_rule_ids {
277 assert!(
278 rule_id.starts_with("fallow/"),
279 "result metadata code {} has unprefixed SARIF rule id {rule_id}",
280 contract.code
281 );
282 }
283 for check_name in &contract.codeclimate_check_names {
284 assert!(
285 check_name.starts_with("fallow/"),
286 "result metadata code {} has unprefixed CodeClimate check name {check_name}",
287 contract.code
288 );
289 }
290 assert!(
291 !contract.codeclimate_check_names.is_empty(),
292 "result metadata code {} has no CodeClimate check names",
293 contract.code
294 );
295 }
296 }
297
298 #[test]
299 fn component_health_codeclimate_contracts_are_present() {
300 for code in ["duplicate-prop-shape", "thin-wrapper"] {
301 let contract = issue_output_contracts()
302 .find(|contract| contract.code == code)
303 .expect("component-health contract must be present");
304 let expected = format!("fallow/{code}");
305 assert_eq!(contract.codeclimate_check_names, vec![expected]);
306 }
307 }
308
309 #[test]
310 fn codeclimate_result_codes_match_result_metadata() {
311 let result_codes: BTreeSet<&str> = result_issue_metas().map(|meta| meta.code).collect();
312 let codeclimate_codes: BTreeSet<&str> = CODECLIMATE_RESULT_CODES.iter().copied().collect();
313 assert!(codeclimate_codes.is_subset(&result_codes));
314 }
315
316 #[test]
317 fn ts_alias_policy_is_explicit() {
318 let aliases: BTreeSet<(&str, &str)> = issue_output_contracts()
319 .filter_map(|contract| contract.ts_alias.map(|alias| (alias.name, alias.parent)))
320 .collect();
321
322 assert_eq!(
323 BTreeSet::from([
324 ("BoundaryViolation", "BoundaryViolationFinding"),
325 ("CircularDependency", "CircularDependencyFinding"),
326 ("DeprecatedExportInUse", "DeprecatedExportInUseFinding"),
327 (
328 "DevDependencyInProduction",
329 "DevDependencyInProductionFinding",
330 ),
331 ("DuplicateExport", "DuplicateExportFinding"),
332 ("EmptyCatalogGroup", "EmptyCatalogGroupFinding"),
333 (
334 "MisconfiguredDependencyOverride",
335 "MisconfiguredDependencyOverrideFinding",
336 ),
337 ("PackageCycle", "PackageCycleFinding"),
338 ("PrivateTypeLeak", "PrivateTypeLeakFinding"),
339 ("ReExportCycle", "ReExportCycleFinding"),
340 ("TestOnlyDependency", "TestOnlyDependencyFinding"),
341 ("TypeOnlyDependency", "TypeOnlyDependencyFinding"),
342 ("UnlistedDependency", "UnlistedDependencyFinding"),
343 (
344 "UnresolvedCatalogReference",
345 "UnresolvedCatalogReferenceFinding",
346 ),
347 ("UnresolvedImport", "UnresolvedImportFinding"),
348 ("UnusedCatalogEntry", "UnusedCatalogEntryFinding"),
349 ("UnusedDependency", "UnusedDependencyFinding"),
350 ("UnusedDependency", "UnusedDevDependencyFinding"),
351 ("UnusedDependency", "UnusedOptionalDependencyFinding"),
352 (
353 "UnusedDependencyOverride",
354 "UnusedDependencyOverrideFinding",
355 ),
356 ("UnusedExport", "UnusedExportFinding"),
357 ("UnusedFile", "UnusedFileFinding"),
358 ("UnusedMember", "UnusedClassMemberFinding"),
359 ("UnusedMember", "UnusedEnumMemberFinding"),
360 ("UnusedMember", "UnusedStoreMemberFinding"),
361 ]),
362 aliases
363 );
364 }
365}