From 1d738bd7e555456ebd8f199e30064a80ced8e0cd Mon Sep 17 00:00:00 2001 From: Thomas Dickson Date: Tue, 11 Aug 2026 10:44:03 +0100 Subject: [PATCH] fix(checks): stop ImportFanOutOutlier flagging shared utilities (CD-333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any successfully shared module is a fan-in outlier by construction, so the fan-in branch flagged leaf utilities for being reused. Measured across five local repos: 23 fan-in findings, every one a false positive — a button, a config module, a utils module, two type modules, a seeded PRNG, an API client, an error adapter, test helpers, a layout partial — and not one genuine god object. The check's stated target is a module with high fan-in AND high fan-out. The fan-in branch now requires fan-out to be an outlier too, rather than merely above the mean: these means sit near 1.7 to 2.2, so an above-the-mean bar means importing three things and suppresses barely a quarter of the false positives. Fan-in findings become rare by design, and the docs say so. A synthetic god module — both metrics outliers — is pinned by a test so the branch is not silently dead. The fan-out branch, which the field report found accurate, is untouched and its findings are unchanged on all five repos. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019afugU2bTYCtXn6Vy9dn1v --- CHANGELOG.md | 1 + .../docs/Design.ImportFanOutOutlier.md | 9 +- .../src/design/import_fan_out_outlier.rs | 118 +++++++++++++++++- crates/cofferdam-checks/src/design/mod.rs | 41 ++++-- docs/checks/Design.ImportFanOutOutlier.md | 9 +- docs/public/checks.json | 2 +- 6 files changed, 156 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83818d52..9a016a01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A check declares a set of languages rather than one, and `Consistency.SpellingDialect` now reads Markdown as well as TypeScript (CD-316). The check shipped against the codebase but not against the corpus that motivated it: a docs tree split between "analyz*" and "analys*", which it could not reach because `Check::language()` returned a single `Language` and the engine dispatched on equality. `Check::languages()` returns `&'static [Language]`, defaulting to `&[Language::TypeScript]`, so every check that does not care compiles unchanged; the four that do — three Rust checks and one HTML — name their own. Registering a second check id for Markdown was the cheaper option and was rejected: two entries in the catalogue for one convention is a worse surface than the gap. In Markdown the whole document is prose, so what gets excluded is the code a page quotes — fenced blocks, inline code spans, link destinations and YAML frontmatter — but not indented blocks, since four spaces is also a nested list continuation. Markdown discovery remains opt-in behind `[engine] extra_extensions`, so no project sees new findings without asking. ### Fixed +- `Design.ImportFanOutOutlier`'s fan-in branch no longer fires on fan-in alone (CD-333). Any successfully shared module is a fan-in outlier by construction, so the check was flagging leaf utilities for being reused: a manual audit across five local repos found 23 fan-in findings, every one a false positive (buttons, config, utils, type modules, an API client), and not one genuine over-centralised god object. The fan-in branch now also requires the file's fan-out to be a statistical outlier, matching the check's stated target of a hub with high fan-in *and* high fan-out. Fan-in findings are rare by design as a result; the fan-out branch is unchanged. - `Design.LayerViolation`'s catalogue page no longer recommends `.cofferdamignore` for excluding tests. That is the exact configuration that makes `Design.OrphanExport` claim a symbol is "never imported in the project" when its only importers are the ignored tests — `.cofferdamignore` prunes files before discovery, so they contribute no import edges either (CD-325). The page now points at an `[[overrides]]` block with `disabled = true`, which turns off the one check without removing the files from the graph. The underlying behaviour is unchanged and tracked separately. - `Refactor.PurityHeuristic` can be switched on (CD-324). Its one option is called `enabled`, and the config loader stripped that key out of every check's option bag before validation as a forward-compatible placeholder — so the check's own catalogue page told the user to write `enabled = true`, and the check never saw it. A registered check no user could reach is worse than an unrecognised key, so `enabled` now flows through to the option bag like any other option, and is dropped again only for the checks that do not declare it. Configs carrying a stray `enabled` therefore keep loading rather than failing on upgrade, which is why the fix is silent rather than a warning: making the inert case loud needs a warning sink `options_for_raw` does not have, and is not worth a breaking change to reach. - `scripts/version.mjs set X.Y.Z --regen` could stamp the previous version into `docs/public/checks.json` and `docs/public/llms.txt` (CD-317). A stale cargo fingerprint after a `Cargo.toml` revert-then-rebump left `cargo build --workspace` reporting `Finished` without recompiling `cofferdam-cli`, so the subsequent `gen-docs` ran the old binary and wrote the old version, and `node scripts/version.mjs check` then failed on locations that regen was meant to fix. `--regen` now runs `cargo clean -p cofferdam-cli` before the build to force that crate's recompile, checks the built binary's own `--version` against the target before trusting it to run `gen-docs`, and re-reads `checks.json`/`llms.txt` afterwards to confirm they landed on the target version — failing loudly and naming both versions if not. diff --git a/crates/cofferdam-checks/docs/Design.ImportFanOutOutlier.md b/crates/cofferdam-checks/docs/Design.ImportFanOutOutlier.md index 4d0df88b..d9b7afaa 100644 --- a/crates/cofferdam-checks/docs/Design.ImportFanOutOutlier.md +++ b/crates/cofferdam-checks/docs/Design.ImportFanOutOutlier.md @@ -6,7 +6,7 @@ default_severity: Medium options: [] --- -A file's import fan-in (files that import it) or fan-out (files it imports) is a statistical outlier versus the rest of the project — a likely "god module" (doing too much) or over-centralized dependency (too many things depend on one module), found without a hardcoded threshold. +A file's import fan-out (files it imports) is a statistical outlier versus the rest of the project — a likely "god module" doing too much, found without a hardcoded threshold. A file whose fan-in (files that import it) and fan-out are both outliers is flagged as a genuine hub — over-centralised, pulling in half the project while everything else pulls it in too. ```ts // god.ts — imports a dozen unrelated modules; flagged for fan-out @@ -16,10 +16,7 @@ import { c } from "./c"; // ...ten more... ``` -```ts -// utils.ts — imported by nearly every other file; flagged for fan-in -export function formatDate() { /* ... */ } -``` +Fan-in alone is never flagged: a shared utility imported by nearly every other file is doing exactly what a shared utility should. Only a file that is both heavily imported and itself imports heavily — high fan-in and high fan-out together — earns the fan-in finding. Not flagged — `index.ts` and `types.ts` barrels are excluded entirely, since a real aggregator's high fan-in/fan-out is by design, not a smell: @@ -30,7 +27,7 @@ export * from "./b"; export * from "./c"; ``` -Statistics: computed only over in-project (resolved) import edges — an external package import (`react`, `lodash`) doesn't count toward either metric, including a bare specifier the resolver traced into `node_modules` (a vendor package's own internal import graph never counts toward fan-in/fan-out, and vendor files never enter the population). `index.*`/`types.*` basenames are excluded from the statistical population entirely (not just from being flagged), since including their legitimately extreme counts would inflate the mean/stddev for every other file. Below 8 non-excluded files in the project, or when a metric's standard deviation is 0 (every file has the same count), nothing is flagged for that metric — there isn't enough variation to call anything an outlier. A file's fan-in (or fan-out) must exceed the project mean plus 3 standard deviations to be flagged; a file can be flagged for both metrics independently. +Statistics: computed only over in-project (resolved) import edges — an external package import (`react`, `lodash`) doesn't count toward either metric, including a bare specifier the resolver traced into `node_modules` (a vendor package's own internal import graph never counts toward fan-in/fan-out, and vendor files never enter the population). `index.*`/`types.*` basenames are excluded from the statistical population entirely (not just from being flagged), since including their legitimately extreme counts would inflate the mean/stddev for every other file. Below 8 non-excluded files in the project, or when a metric's standard deviation is 0 (every file has the same count), nothing is flagged for that metric — there isn't enough variation to call anything an outlier. A file's fan-out must exceed the project mean plus three standard deviations to be flagged on its own. Fan-in requires both: the file's fan-in must clear its own three-standard-deviation bar and its fan-out must clear the fan-out bar too. Fan-in alone can never distinguish a healthy leaf module — reused everywhere, importing little — from a real god object, so the check no longer treats it as sufficient by itself. This makes fan-in findings rare by design: most true hubs show up on the fan-out branch anyway, and the fan-in branch now exists only to catch a hub that also gets pulled in from everywhere. Suppressing a legitimate hub: findings are pinned to line 1, column 1 of the file (it's a whole-file metric, not tied to a specific import), so the usual next-line `// cofferdam-ignore: Design.ImportFanOutOutlier` comment has no real "line 0" to sit on. Use the file-wide directive instead — `// cofferdam-ignore-file: Design.ImportFanOutOutlier: ` anywhere in the file — which matches regardless of the finding's line number. diff --git a/crates/cofferdam-checks/src/design/import_fan_out_outlier.rs b/crates/cofferdam-checks/src/design/import_fan_out_outlier.rs index 1dce806c..a10915f4 100644 --- a/crates/cofferdam-checks/src/design/import_fan_out_outlier.rs +++ b/crates/cofferdam-checks/src/design/import_fan_out_outlier.rs @@ -224,12 +224,16 @@ fn compute_outliers( let mut sorted_population = population; sorted_population.sort_by(|a, b| a.display.cmp(&b.display)); for stats in sorted_population { - if stddev_in > 0.0 && stats.fan_in as f64 > threshold_in { + if stddev_in > 0.0 + && stats.fan_in as f64 > threshold_in + && stddev_out > 0.0 + && stats.fan_out as f64 > threshold_out + { issues.push(Issue { check_id: META.id.to_string(), message: format!( - "unusually high import fan-in: {} files import this one (project mean {mean_in:.1}, stddev {stddev_in:.1}) — possible over-centralized dependency", - stats.fan_in + "unusually high import fan-in and fan-out: {} files import this one and it imports {} others (project mean fan-in {mean_in:.1}, stddev {stddev_in:.1}) — possible over-centralized hub", + stats.fan_in, stats.fan_out ), file: stats.display.clone(), location: Location::from_span(&stats.display, zero_span), @@ -255,3 +259,111 @@ fn compute_outliers( } issues } + +#[cfg(test)] +mod tests { + use super::*; + use cofferdam_core::graph::{ImportKind, ImportedName}; + use std::path::PathBuf; + + fn edge(from: &Path, to: &Path) -> ImportRecord { + ImportRecord { + from_file: from.to_path_buf(), + source_specifier: "./m".to_string(), + resolved: Some(to.to_path_buf()), + names: vec![ImportedName { + source_name: "x".to_string(), + local_name: "x".to_string(), + kind: ImportKind::Named, + type_only: false, + local_use_count: 1, + }], + type_only: false, + span: Span { + start_byte: 0, + end_byte: 0, + line: 1, + column: 1, + }, + } + } + + fn messages_for<'a>(issues: &'a [Issue], needle: &str) -> Vec<&'a Issue> { + issues + .iter() + .filter(|i| i.message.contains(needle)) + .collect() + } + + /// CD-333 regression: a leaf utility with many importers but no + /// imports of its own (fan-in high, fan-out low) must not be + /// flagged — it is a shared module doing its job, not a hub. + #[test] + fn high_fan_in_alone_is_not_flagged() { + let leaf = PathBuf::from("/proj/leaf.ts"); + let mut all_files = HashSet::new(); + all_files.insert(leaf.clone()); + let mut imports = Vec::new(); + for i in 0..15 { + let importer = PathBuf::from(format!("/proj/importer_{i}.ts")); + all_files.insert(importer.clone()); + imports.push(edge(&importer, &leaf)); + } + + let issues = compute_outliers(&imports, &[], &all_files); + assert!( + issues.is_empty(), + "expected no findings for a high-fan-in-only leaf, got {issues:?}" + ); + } + + /// A genuine god module — high fan-in AND high fan-out — must + /// still fire the fan-in finding. + #[test] + fn high_fan_in_and_fan_out_together_is_flagged() { + let god = PathBuf::from("/proj/god.ts"); + let mut all_files = HashSet::new(); + all_files.insert(god.clone()); + let mut imports = Vec::new(); + for i in 0..15 { + let sibling = PathBuf::from(format!("/proj/sibling_{i}.ts")); + all_files.insert(sibling.clone()); + imports.push(edge(&sibling, &god)); // sibling -> god: god's fan-in + imports.push(edge(&god, &sibling)); // god -> sibling: god's fan-out + } + + let issues = compute_outliers(&imports, &[], &all_files); + let fan_in_hits = messages_for(&issues, "fan-in"); + assert_eq!( + fan_in_hits.len(), + 1, + "expected exactly one fan-in finding, got {issues:?}" + ); + assert_eq!(fan_in_hits[0].file, god); + } + + /// The fan-out branch is untouched: high fan-out with low fan-in + /// still produces exactly the fan-out finding, nothing else for + /// that file. + #[test] + fn high_fan_out_alone_is_still_flagged() { + let hub = PathBuf::from("/proj/hub.ts"); + let mut all_files = HashSet::new(); + all_files.insert(hub.clone()); + let mut imports = Vec::new(); + for i in 0..15 { + let target = PathBuf::from(format!("/proj/target_{i}.ts")); + all_files.insert(target.clone()); + imports.push(edge(&hub, &target)); + } + + let issues = compute_outliers(&imports, &[], &all_files); + let hub_issues: Vec<&Issue> = issues.iter().filter(|i| i.file == hub).collect(); + assert_eq!( + hub_issues.len(), + 1, + "expected exactly one finding for hub, got {issues:?}" + ); + assert!(hub_issues[0].message.contains("fan-out")); + } +} diff --git a/crates/cofferdam-checks/src/design/mod.rs b/crates/cofferdam-checks/src/design/mod.rs index 3c001ae2..d725b332 100644 --- a/crates/cofferdam-checks/src/design/mod.rs +++ b/crates/cofferdam-checks/src/design/mod.rs @@ -1724,10 +1724,12 @@ export function computeTotal(items: number[]): number { } #[test] - fn fan_in_outlier_is_flagged() { - // 14 files that each import a single shared `utils.ts` — utils - // has fan-in 14 against a background of zero, the fan-in mirror - // of `fan_out_outlier_is_flagged`. + fn high_fan_in_alone_is_not_flagged() { + // CD-333: 14 files that each import a single shared `utils.ts` + // — utils has fan-in 14 against a background of zero, but zero + // fan-out of its own. A shared leaf module doing its job must + // not be flagged; fan-in alone can't distinguish it from a + // real hub. let utils = PathBuf::from("/p/utils.ts"); let imports: Vec = (0..14) .map(|i| { @@ -1736,13 +1738,36 @@ export function computeTotal(items: number[]): number { }) .collect(); let issues = run_fan_out_outlier(imports); + assert!( + issues.is_empty(), + "high fan-in alone must not be flagged; got {issues:?}" + ); + } + + #[test] + fn high_fan_in_and_fan_out_together_is_flagged() { + // CD-333: a genuine god module — high fan-in AND high fan-out + // — must still fire the fan-in finding. 14 sibling files each + // import `hub.ts` (hub's fan-in) and `hub.ts` imports each of + // them back (hub's fan-out). + let hub = PathBuf::from("/p/hub.ts"); + let mut imports = Vec::new(); + for i in 0..14 { + let sibling = PathBuf::from(format!("/p/sibling{i}.ts")); + imports.push(internal_import(&sibling, &hub)); + imports.push(internal_import(&hub, &sibling)); + } + let issues = run_fan_out_outlier(imports); + let fan_in_issues: Vec<&CoreIssue> = issues + .iter() + .filter(|i| i.message.contains("fan-in")) + .collect(); assert_eq!( - issues.len(), + fan_in_issues.len(), 1, - "expected one fan-in finding for utils.ts; got {issues:?}" + "expected one fan-in finding for the god module; got {issues:?}" ); - assert_eq!(issues[0].file, utils); - assert!(issues[0].message.contains("fan-in")); + assert_eq!(fan_in_issues[0].file, hub); } #[test] diff --git a/docs/checks/Design.ImportFanOutOutlier.md b/docs/checks/Design.ImportFanOutOutlier.md index a3ee2c06..37eab7e0 100644 --- a/docs/checks/Design.ImportFanOutOutlier.md +++ b/docs/checks/Design.ImportFanOutOutlier.md @@ -11,7 +11,7 @@ autofix: false -A file's import fan-in (files that import it) or fan-out (files it imports) is a statistical outlier versus the rest of the project — a likely "god module" (doing too much) or over-centralized dependency (too many things depend on one module), found without a hardcoded threshold. +A file's import fan-out (files it imports) is a statistical outlier versus the rest of the project — a likely "god module" doing too much, found without a hardcoded threshold. A file whose fan-in (files that import it) and fan-out are both outliers is flagged as a genuine hub — over-centralised, pulling in half the project while everything else pulls it in too. ```ts // god.ts — imports a dozen unrelated modules; flagged for fan-out @@ -21,10 +21,7 @@ import { c } from "./c"; // ...ten more... ``` -```ts -// utils.ts — imported by nearly every other file; flagged for fan-in -export function formatDate() { /* ... */ } -``` +Fan-in alone is never flagged: a shared utility imported by nearly every other file is doing exactly what a shared utility should. Only a file that is both heavily imported and itself imports heavily — high fan-in and high fan-out together — earns the fan-in finding. Not flagged — `index.ts` and `types.ts` barrels are excluded entirely, since a real aggregator's high fan-in/fan-out is by design, not a smell: @@ -35,7 +32,7 @@ export * from "./b"; export * from "./c"; ``` -Statistics: computed only over in-project (resolved) import edges — an external package import (`react`, `lodash`) doesn't count toward either metric, including a bare specifier the resolver traced into `node_modules` (a vendor package's own internal import graph never counts toward fan-in/fan-out, and vendor files never enter the population). `index.*`/`types.*` basenames are excluded from the statistical population entirely (not just from being flagged), since including their legitimately extreme counts would inflate the mean/stddev for every other file. Below 8 non-excluded files in the project, or when a metric's standard deviation is 0 (every file has the same count), nothing is flagged for that metric — there isn't enough variation to call anything an outlier. A file's fan-in (or fan-out) must exceed the project mean plus 3 standard deviations to be flagged; a file can be flagged for both metrics independently. +Statistics: computed only over in-project (resolved) import edges — an external package import (`react`, `lodash`) doesn't count toward either metric, including a bare specifier the resolver traced into `node_modules` (a vendor package's own internal import graph never counts toward fan-in/fan-out, and vendor files never enter the population). `index.*`/`types.*` basenames are excluded from the statistical population entirely (not just from being flagged), since including their legitimately extreme counts would inflate the mean/stddev for every other file. Below 8 non-excluded files in the project, or when a metric's standard deviation is 0 (every file has the same count), nothing is flagged for that metric — there isn't enough variation to call anything an outlier. A file's fan-out must exceed the project mean plus three standard deviations to be flagged on its own. Fan-in requires both: the file's fan-in must clear its own three-standard-deviation bar and its fan-out must clear the fan-out bar too. Fan-in alone can never distinguish a healthy leaf module — reused everywhere, importing little — from a real god object, so the check no longer treats it as sufficient by itself. This makes fan-in findings rare by design: most true hubs show up on the fan-out branch anyway, and the fan-in branch now exists only to catch a hub that also gets pulled in from everywhere. Suppressing a legitimate hub: findings are pinned to line 1, column 1 of the file (it's a whole-file metric, not tied to a specific import), so the usual next-line `// cofferdam-ignore: Design.ImportFanOutOutlier` comment has no real "line 0" to sit on. Use the file-wide directive instead — `// cofferdam-ignore-file: Design.ImportFanOutOutlier: ` anywhere in the file — which matches regardless of the finding's line number. diff --git a/docs/public/checks.json b/docs/public/checks.json index 25f00650..aa2ad797 100644 --- a/docs/public/checks.json +++ b/docs/public/checks.json @@ -222,7 +222,7 @@ "base_priority": 6, "default_severity": "Medium", "explanation": "A file's import fan-in or fan-out is a statistical outlier versus the rest of the project — a likely \"god module\" (doing too much) or over-centralized dependency (too many things depend on one module).", - "body": "---\nid: Design.ImportFanOutOutlier\ncategory: Design\nbase_priority: 6\ndefault_severity: Medium\noptions: []\n---\n\nA file's import fan-in (files that import it) or fan-out (files it imports) is a statistical outlier versus the rest of the project — a likely \"god module\" (doing too much) or over-centralized dependency (too many things depend on one module), found without a hardcoded threshold.\n\n```ts\n// god.ts — imports a dozen unrelated modules; flagged for fan-out\nimport { a } from \"./a\";\nimport { b } from \"./b\";\nimport { c } from \"./c\";\n// ...ten more...\n```\n\n```ts\n// utils.ts — imported by nearly every other file; flagged for fan-in\nexport function formatDate() { /* ... */ }\n```\n\nNot flagged — `index.ts` and `types.ts` barrels are excluded entirely, since a real aggregator's high fan-in/fan-out is by design, not a smell:\n\n```ts\n// index.ts\nexport * from \"./a\";\nexport * from \"./b\";\nexport * from \"./c\";\n```\n\nStatistics: computed only over in-project (resolved) import edges — an external package import (`react`, `lodash`) doesn't count toward either metric, including a bare specifier the resolver traced into `node_modules` (a vendor package's own internal import graph never counts toward fan-in/fan-out, and vendor files never enter the population). `index.*`/`types.*` basenames are excluded from the statistical population entirely (not just from being flagged), since including their legitimately extreme counts would inflate the mean/stddev for every other file. Below 8 non-excluded files in the project, or when a metric's standard deviation is 0 (every file has the same count), nothing is flagged for that metric — there isn't enough variation to call anything an outlier. A file's fan-in (or fan-out) must exceed the project mean plus 3 standard deviations to be flagged; a file can be flagged for both metrics independently.\n\nSuppressing a legitimate hub: findings are pinned to line 1, column 1 of the file (it's a whole-file metric, not tied to a specific import), so the usual next-line `// cofferdam-ignore: Design.ImportFanOutOutlier` comment has no real \"line 0\" to sit on. Use the file-wide directive instead — `// cofferdam-ignore-file: Design.ImportFanOutOutlier: ` anywhere in the file — which matches regardless of the finding's line number.\n\nScope: the hub exclusion is a fixed basename list (`index.ts`/`index.tsx`/`index.js`/`index.jsx`/`index.mjs`/`index.cjs`/`types.ts`/`types.tsx`), plus a file that resolves as the nearest `package.json`'s declared entry point (`main`/`module`/`types`/`typings`/`exports`, the same resolution `Design.BarrelReexportBloat` uses) — a differently-named central hub that's the project's real entry point (e.g. `container.ts` referenced from `package.json`) is covered by the second rule even though it isn't in the basename list. A differently-named internal hub that isn't the package's declared entry point (an internal convention, never referenced from `package.json`) still isn't covered by either rule and may be flagged. A file with no imports and no exports at all (e.g. a side-effect-only script) still enters the population with fan-in/fan-out of 0, tracked separately from the import/export graph during each file's analysis pass.\n", + "body": "---\nid: Design.ImportFanOutOutlier\ncategory: Design\nbase_priority: 6\ndefault_severity: Medium\noptions: []\n---\n\nA file's import fan-out (files it imports) is a statistical outlier versus the rest of the project — a likely \"god module\" doing too much, found without a hardcoded threshold. A file whose fan-in (files that import it) and fan-out are both outliers is flagged as a genuine hub — over-centralised, pulling in half the project while everything else pulls it in too.\n\n```ts\n// god.ts — imports a dozen unrelated modules; flagged for fan-out\nimport { a } from \"./a\";\nimport { b } from \"./b\";\nimport { c } from \"./c\";\n// ...ten more...\n```\n\nFan-in alone is never flagged: a shared utility imported by nearly every other file is doing exactly what a shared utility should. Only a file that is both heavily imported and itself imports heavily — high fan-in and high fan-out together — earns the fan-in finding.\n\nNot flagged — `index.ts` and `types.ts` barrels are excluded entirely, since a real aggregator's high fan-in/fan-out is by design, not a smell:\n\n```ts\n// index.ts\nexport * from \"./a\";\nexport * from \"./b\";\nexport * from \"./c\";\n```\n\nStatistics: computed only over in-project (resolved) import edges — an external package import (`react`, `lodash`) doesn't count toward either metric, including a bare specifier the resolver traced into `node_modules` (a vendor package's own internal import graph never counts toward fan-in/fan-out, and vendor files never enter the population). `index.*`/`types.*` basenames are excluded from the statistical population entirely (not just from being flagged), since including their legitimately extreme counts would inflate the mean/stddev for every other file. Below 8 non-excluded files in the project, or when a metric's standard deviation is 0 (every file has the same count), nothing is flagged for that metric — there isn't enough variation to call anything an outlier. A file's fan-out must exceed the project mean plus three standard deviations to be flagged on its own. Fan-in requires both: the file's fan-in must clear its own three-standard-deviation bar and its fan-out must clear the fan-out bar too. Fan-in alone can never distinguish a healthy leaf module — reused everywhere, importing little — from a real god object, so the check no longer treats it as sufficient by itself. This makes fan-in findings rare by design: most true hubs show up on the fan-out branch anyway, and the fan-in branch now exists only to catch a hub that also gets pulled in from everywhere.\n\nSuppressing a legitimate hub: findings are pinned to line 1, column 1 of the file (it's a whole-file metric, not tied to a specific import), so the usual next-line `// cofferdam-ignore: Design.ImportFanOutOutlier` comment has no real \"line 0\" to sit on. Use the file-wide directive instead — `// cofferdam-ignore-file: Design.ImportFanOutOutlier: ` anywhere in the file — which matches regardless of the finding's line number.\n\nScope: the hub exclusion is a fixed basename list (`index.ts`/`index.tsx`/`index.js`/`index.jsx`/`index.mjs`/`index.cjs`/`types.ts`/`types.tsx`), plus a file that resolves as the nearest `package.json`'s declared entry point (`main`/`module`/`types`/`typings`/`exports`, the same resolution `Design.BarrelReexportBloat` uses) — a differently-named central hub that's the project's real entry point (e.g. `container.ts` referenced from `package.json`) is covered by the second rule even though it isn't in the basename list. A differently-named internal hub that isn't the package's declared entry point (an internal convention, never referenced from `package.json`) still isn't covered by either rule and may be flagged. A file with no imports and no exports at all (e.g. a side-effect-only script) still enters the population with fan-in/fan-out of 0, tracked separately from the import/export graph during each file's analysis pass.\n", "requires_types": false, "consistency": false, "autofix": false,