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://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 ..Meta::default()
113 }
114}
115
116pub const ARCHITECTURE_RESULT_KEYS: &[&str] = &[
122 "circular_dependencies",
123 "re_export_cycles",
124 "package_cycles",
125 "boundary_violations",
126 "boundary_coverage_violations",
127 "boundary_call_violations",
128 "policy_violations",
129];
130
131#[must_use]
136pub fn architecture_meta() -> Meta {
137 let mut meta = check_meta();
138 let architecture_codes: Vec<&'static str> = issue_output_contracts()
139 .filter(|contract| ARCHITECTURE_RESULT_KEYS.contains(&contract.result_key))
140 .map(|contract| contract.code)
141 .collect();
142 meta.rules
143 .retain(|code, _| architecture_codes.contains(&code.as_str()));
144 meta.docs = Some(ARCHITECTURE_DOCS.to_string());
145 meta
146}
147
148#[must_use]
153pub fn rule_docs_url(docs_path: &str) -> String {
154 match docs_path.split_once('#') {
155 Some((page, anchor)) => format!("{DOCS_BASE}/{page}/#{anchor}"),
156 None => format!("{DOCS_BASE}/{docs_path}/"),
157 }
158}
159
160pub fn issue_output_contracts() -> impl Iterator<Item = IssueOutputContract> {
162 result_issue_metas().map(IssueOutputContract::from_result_meta)
163}
164
165#[must_use]
167pub fn issue_output_contract_by_code(code: &str) -> Option<IssueOutputContract> {
168 issue_result_meta_by_code(code).map(IssueOutputContract::from_result_meta)
169}
170
171#[cfg(test)]
172mod tests {
173 use std::collections::BTreeSet;
174
175 use super::*;
176
177 #[test]
178 fn architecture_meta_lists_only_architecture_rules() {
179 let meta = architecture_meta();
180 let expected: BTreeSet<&str> = issue_output_contracts()
181 .filter(|contract| ARCHITECTURE_RESULT_KEYS.contains(&contract.result_key))
182 .map(|contract| contract.code)
183 .collect();
184 let actual: BTreeSet<&str> = meta.rules.keys().map(String::as_str).collect();
185
186 assert_eq!(actual, expected);
187 assert_eq!(meta.docs.as_deref(), Some(ARCHITECTURE_DOCS));
188 assert_eq!(meta.field_definitions, check_meta().field_definitions);
189 }
190
191 #[test]
192 fn every_architecture_result_key_names_a_result_row() {
193 let keys: BTreeSet<&str> = issue_output_contracts()
194 .map(|contract| contract.result_key)
195 .collect();
196 for key in ARCHITECTURE_RESULT_KEYS {
197 assert!(keys.contains(key), "`{key}` is not a result array key");
198 }
199 let architecture_rows = issue_output_contracts()
200 .filter(|contract| ARCHITECTURE_RESULT_KEYS.contains(&contract.result_key))
201 .count();
202 assert_eq!(architecture_rows, ARCHITECTURE_RESULT_KEYS.len());
203 }
204
205 #[test]
206 fn every_result_row_has_output_contract() {
207 let result_codes: BTreeSet<&str> = result_issue_metas().map(|meta| meta.code).collect();
208 let output_codes: BTreeSet<&str> = issue_output_contracts()
209 .map(|contract| contract.code)
210 .collect();
211 assert_eq!(result_codes, output_codes);
212 }
213
214 #[test]
215 fn summary_contracts_are_present() {
216 for contract in issue_output_contracts() {
217 assert!(!contract.summary_label.is_empty());
218 assert!(!contract.summary_docs_anchor.is_empty());
219 assert!(!contract.meta_name.is_empty());
220 assert!(!contract.meta_description.is_empty());
221 assert!(!contract.meta_docs_path.is_empty());
222 }
223 }
224
225 #[test]
226 fn check_meta_uses_output_contracts() {
227 let meta = check_meta();
228 assert_eq!(meta.docs.as_deref(), Some(CHECK_DOCS));
229 assert!(
230 meta.field_definitions["actions[].auto_fixable"].contains("PER FINDING"),
231 "auto_fixable definition should preserve per-finding guidance"
232 );
233 assert!(meta.rules.contains_key("unused-export"));
234 assert!(meta.rules.contains_key("missing-suppression-reason"));
235 assert_eq!(
236 meta.rules["unused-dev-dependency"].docs.as_deref(),
237 Some("https://fallow.tools/docs/explanations/dead-code/#unused-devdependencies")
238 );
239 }
240
241 #[test]
242 fn ci_format_contracts_are_present() {
243 for contract in issue_output_contracts() {
244 assert!(
245 contract
246 .sarif_rule_ids
247 .contains(&format!("fallow/{}", contract.code)),
248 "result metadata code {} has wrong SARIF rule id",
249 contract.code
250 );
251 for rule_id in contract.sarif_rule_ids {
252 assert!(
253 rule_id.starts_with("fallow/"),
254 "result metadata code {} has unprefixed SARIF rule id {rule_id}",
255 contract.code
256 );
257 }
258 for check_name in contract.codeclimate_check_names {
259 assert!(
260 check_name.starts_with("fallow/"),
261 "result metadata code {} has unprefixed CodeClimate check name {check_name}",
262 contract.code
263 );
264 }
265 }
266 }
267
268 #[test]
269 fn codeclimate_result_exclusions_are_explicit() {
270 let expected = BTreeSet::from(["duplicate-prop-shape", "prop-drilling", "thin-wrapper"]);
271 let from_contracts: BTreeSet<&str> = issue_output_contracts()
272 .filter(|contract| contract.codeclimate_check_names.is_empty())
273 .map(|contract| contract.code)
274 .collect();
275 assert_eq!(expected, from_contracts);
276 }
277
278 #[test]
279 fn codeclimate_result_codes_match_result_metadata() {
280 let result_codes: BTreeSet<&str> = result_issue_metas().map(|meta| meta.code).collect();
281 let codeclimate_codes: BTreeSet<&str> = CODECLIMATE_RESULT_CODES.iter().copied().collect();
282 assert!(codeclimate_codes.is_subset(&result_codes));
283 }
284
285 #[test]
286 fn ts_alias_policy_is_explicit() {
287 let aliases: BTreeSet<(&str, &str)> = issue_output_contracts()
288 .filter_map(|contract| contract.ts_alias.map(|alias| (alias.name, alias.parent)))
289 .collect();
290
291 assert_eq!(
292 BTreeSet::from([
293 ("BoundaryViolation", "BoundaryViolationFinding"),
294 ("CircularDependency", "CircularDependencyFinding"),
295 ("DeprecatedExportInUse", "DeprecatedExportInUseFinding"),
296 (
297 "DevDependencyInProduction",
298 "DevDependencyInProductionFinding",
299 ),
300 ("DuplicateExport", "DuplicateExportFinding"),
301 ("EmptyCatalogGroup", "EmptyCatalogGroupFinding"),
302 (
303 "MisconfiguredDependencyOverride",
304 "MisconfiguredDependencyOverrideFinding",
305 ),
306 ("PackageCycle", "PackageCycleFinding"),
307 ("PrivateTypeLeak", "PrivateTypeLeakFinding"),
308 ("ReExportCycle", "ReExportCycleFinding"),
309 ("TestOnlyDependency", "TestOnlyDependencyFinding"),
310 ("TypeOnlyDependency", "TypeOnlyDependencyFinding"),
311 ("UnlistedDependency", "UnlistedDependencyFinding"),
312 (
313 "UnresolvedCatalogReference",
314 "UnresolvedCatalogReferenceFinding",
315 ),
316 ("UnresolvedImport", "UnresolvedImportFinding"),
317 ("UnusedCatalogEntry", "UnusedCatalogEntryFinding"),
318 ("UnusedDependency", "UnusedDependencyFinding"),
319 ("UnusedDependency", "UnusedDevDependencyFinding"),
320 ("UnusedDependency", "UnusedOptionalDependencyFinding"),
321 (
322 "UnusedDependencyOverride",
323 "UnusedDependencyOverrideFinding",
324 ),
325 ("UnusedExport", "UnusedExportFinding"),
326 ("UnusedFile", "UnusedFileFinding"),
327 ("UnusedMember", "UnusedClassMemberFinding"),
328 ("UnusedMember", "UnusedEnumMemberFinding"),
329 ("UnusedMember", "UnusedStoreMemberFinding"),
330 ]),
331 aliases
332 );
333 }
334}