use std::path::Path;
use skif_core::{Category, ConfigJudgement, Fixability, Location, PathSeg, RuleMeta, Severity, diagnostic};
use skif_lint::Emitter;
use skif_workspace::PackageId;
use super::JsRule;
use super::support::{module_location, unresolved_imports};
use crate::analysis::JsAnalysis;
use crate::config_hints::{ConfigHint, HintKind};
const UNCONFIGURED_MIN_FILES: usize = 20;
const UNCONFIGURED_MIN_RATIO: f64 = 0.2;
pub static META: RuleMeta = RuleMeta::new(
"stale-js-config",
Category::Js,
Some(Severity::Warning),
Fixability::None,
"A `[js]` setting matched nothing or is redundant, a published entry file is missing, or many files are unused.",
);
const DEPENDENCY_RULES: [&str; 5] = [
"unused-dependencies",
"unused-dev-dependencies",
"unlisted-dependencies",
"unused-optional-peer-dependencies",
"unresolved-imports",
];
const UNRESOLVED_RULES: [&str; 1] = ["unresolved-imports"];
pub struct StaleJsConfig;
struct JudgedList<'a> {
written: String,
shown: String,
package: Option<&'a Path>,
verdicts: Vec<(&'a str, bool)>,
}
impl<'a> JudgedList<'a> {
fn new(key: impl Into<String>, verdicts: Vec<(&'a str, bool)>) -> Self {
let written = key.into();
Self {
shown: written.clone(),
written,
package: None,
verdicts,
}
}
}
impl StaleJsConfig {
fn judge(analysis: &JsAnalysis<'_>, out: &mut Emitter<'_>, list: &JudgedList<'_>) {
let Some(path) = Self::config_path(analysis) else {
return;
};
for &(entry, stale) in &list.verdicts {
let message = format!("`{entry}` in `{}` matched nothing", list.shown);
let judgement = ConfigJudgement::new(path.clone(), list.written.as_str(), entry, stale, message.as_str());
out.report_config_judgement(match list.package {
Some(dir) => judgement.with_package(dir),
None => judgement,
});
if stale {
out.emit(
diagnostic(&META, message, Location::new(path.clone(), None))
.help("remove the entry, or correct it if it was meant to match")
.build(),
);
}
}
}
}
impl StaleJsConfig {
fn report_tags(analysis: &JsAnalysis<'_>, out: &mut Emitter<'_>) {
for module in analysis.graph.modules() {
for hint in analysis.unneeded_tags(module) {
let mut builder = diagnostic(
&META,
format!(
"`{}` is used, so its `@{}` tag exempts nothing",
hint.identifier, hint.tag
),
module_location(module, Some(hint.span)),
)
.help("remove the tag");
if let Some(package) = analysis.workspace.package(module.owner) {
builder = builder.package(package.display_name());
}
out.emit(builder.build());
}
}
}
}
impl StaleJsConfig {
fn config_path(analysis: &JsAnalysis<'_>) -> Option<std::path::PathBuf> {
analysis.config_path.clone().or_else(|| {
analysis
.workspace
.package(PackageId::ROOT)
.map(|package| package.manifest.path.clone())
})
}
fn report_hint(analysis: &JsAnalysis<'_>, out: &mut Emitter<'_>, hint: &ConfigHint) {
let ConfigHint {
kind, key, identifier, ..
} = hint;
let (message, help) = match kind {
HintKind::EntryRedundant => (
format!("`{identifier}` in `{key}` is already an entry of a plugin, `package.json` or a script"),
"remove the redundant pattern",
),
HintKind::ProjectRedundant => (
format!("`{identifier}` in `{key}` is an entry file, which is always part of the project"),
"remove the redundant pattern",
),
HintKind::TopLevel => (
format!("`{key}` = {identifier} applies to the root package only, but the workspace has several"),
"remove it, or move it to the `[js.workspaces]` table of the package it is meant for",
),
HintKind::ExtensionUnregistered => (
format!("`{identifier}` files selected by `{key}` are not analysed"),
"remove the extension: skif reads JS, TS, Vue, Svelte, Astro, MDX, TSRX and stylesheet files",
),
HintKind::ExtensionExcluded => (
format!("`{key}` leaves out the package's `{identifier}` files"),
"add the extension to the `project` patterns",
),
HintKind::PackageEntry => (
format!("`{identifier}` in `{key}` of `package.json` names no file"),
"correct the path, or build the package if it is build output that is not gitignored",
),
};
let package = hint.package.and_then(|id| analysis.workspace.package(id));
let location = if let Some(package) = package {
let span = package.manifest.field_span(&[PathSeg::Key(key.clone())]);
Location::new(package.manifest.path.clone(), span)
} else {
let Some(path) = Self::config_path(analysis) else {
return;
};
Location::new(path, None)
};
let mut builder = diagnostic(&META, message, location).help(help);
if let Some(package) = package {
builder = builder.package(package.display_name());
}
out.emit(builder.build());
}
fn report_unconfigured(analysis: &JsAnalysis<'_>, out: &mut Emitter<'_>) {
let unused = analysis.unused_files.len();
let reached = analysis.graph.modules().len();
let looked_at = reached + unused;
#[allow(clippy::cast_precision_loss, reason = "file counts are far below 2^52")]
let ratio = if reached == 0 {
0.0
} else {
unused as f64 / reached as f64
};
if unused <= UNCONFIGURED_MIN_FILES || ratio <= UNCONFIGURED_MIN_RATIO {
return;
}
let Some(path) = Self::config_path(analysis) else {
return;
};
let analysed: Vec<PackageId> = analysis
.workspace
.iter()
.filter(|(id, _)| analysis.setup.package(*id).is_some())
.map(|(id, _)| id)
.collect();
let help = "add `entry` patterns for the files tools or other code load, or narrow `project`";
if analysed.len() == 1 {
out.emit(
diagnostic(
&META,
format!("{unused} of {looked_at} files are unused, so `[js]` likely misses entry files"),
Location::new(path, None),
)
.help(help)
.build(),
);
return;
}
for (size, dir) in unused_by_workspace(analysis, &analysed) {
out.emit(
diagnostic(
&META,
format!(
"{size} files of workspace `{dir}` are unused, so `[js.workspaces.\"{dir}\"]` likely misses \
entry files"
),
Location::new(path.clone(), None),
)
.help(help)
.build(),
);
}
}
}
fn unused_by_workspace(analysis: &JsAnalysis<'_>, analysed: &[PackageId]) -> Vec<(usize, String)> {
let mut sizes: Vec<(usize, String)> = analysed
.iter()
.filter_map(|id| {
let package = analysis.workspace.package(*id)?;
let size = analysis.unused_files.iter().filter(|file| file.owner == *id).count();
let dir = analysis.setup.workspace_keys.key_dir(package);
(size > 1).then_some((size, dir))
})
.collect();
sizes.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
sizes
}
#[derive(Clone, Copy)]
struct ListsRan {
dependencies: bool,
binaries: bool,
unresolved: bool,
}
fn keyed_ignore_lists<'a>(analysis: &'a JsAnalysis<'_>, ran: &dyn Fn(&[&str]) -> bool) -> Vec<JudgedList<'a>> {
let ran = ListsRan {
dependencies: ran(&DEPENDENCY_RULES),
binaries: ran(&["unlisted-binaries"]),
unresolved: ran(&UNRESOLVED_RULES),
};
let mut keyed = top_level_ignore_lists(analysis, ran);
keyed.extend(package_ignore_lists(analysis, ran));
keyed
}
fn top_level_ignore_lists<'a>(analysis: &'a JsAnalysis<'_>, ran: ListsRan) -> Vec<JudgedList<'a>> {
let setup = &analysis.setup;
let unchecked_dependencies = analysis
.workspace
.iter()
.filter(|(id, _)| !analysis.is_skipped(*id) && analysis.entries_incomplete(*id))
.flat_map(|(_, package)| package.dependencies.iter().map(|entry| &*entry.name))
.collect::<Vec<_>>();
let mut keyed: Vec<JudgedList<'_>> = Vec::new();
if ran.dependencies {
keyed.push(JudgedList::new(
"js.ignore-dependencies",
setup
.global_ignore_dependencies
.verdicts_except(&unchecked_dependencies),
));
}
if ran.binaries {
keyed.push(JudgedList::new(
"js.ignore-binaries",
setup.global_ignore_binaries.verdicts(),
));
}
if ran.unresolved {
keyed.push(JudgedList::new(
"js.ignore-unresolved",
setup.global_ignore_unresolved.verdicts(),
));
}
keyed.push(JudgedList::new(
"js.ignore-workspaces",
setup.ignore_workspaces.verdicts(),
));
keyed
}
fn package_ignore_lists<'a>(analysis: &'a JsAnalysis<'_>, ran: ListsRan) -> Vec<JudgedList<'a>> {
let setup = &analysis.setup;
let mut keyed: Vec<JudgedList<'_>> = Vec::new();
for (id, package) in analysis.workspace.iter() {
let Some(package_setup) = setup.package(id) else {
continue;
};
let dir = setup.workspace_keys.key_dir(package);
let table = package_setup.table_key.as_deref().unwrap_or(dir.as_str());
let package_dir: &'a Path = package.dir.as_path();
let list = |name: &str, verdicts: Vec<(&'a str, bool)>| JudgedList {
written: format!("js.workspaces.\"{table}\".{name}"),
shown: format!("js.workspaces.\"{dir}\".{name}"),
package: Some(package_dir),
verdicts,
};
if ran.dependencies && !analysis.is_skipped(id) && !analysis.entries_incomplete(id) {
keyed.push(list(
"ignore-dependencies",
package_setup.user_ignore_dependencies.verdicts(),
));
}
if ran.binaries {
keyed.push(list("ignore-binaries", package_setup.user_ignore_binaries.verdicts()));
}
if ran.unresolved {
keyed.push(list("ignore-unresolved", package_setup.ignore_unresolved.verdicts()));
}
}
keyed
}
impl JsRule for StaleJsConfig {
fn meta(&self) -> &'static RuleMeta {
&META
}
fn runs_last(&self) -> bool {
true
}
fn check(&self, analysis: &JsAnalysis<'_>, out: &mut Emitter<'_>) {
let ran = |ids: &[&str]| {
analysis
.enabled_rules
.as_ref()
.is_none_or(|enabled| ids.iter().all(|id| enabled.contains(*id)))
};
if !ran(&[super::stale_mocks::META.id]) {
for _mock in unresolved_imports(analysis, true) {}
}
for list in keyed_ignore_lists(analysis, &ran) {
Self::judge(analysis, out, &list);
}
let keys = analysis
.setup
.workspace_key_verdicts
.iter()
.map(|(key, stale)| (key.as_str(), *stale))
.collect();
Self::judge(analysis, out, &JudgedList::new("js.workspaces", keys));
let unused_files_ran = ran(&["unused-files"]);
let ignore_files = analysis.judged_ignore_files.iter().filter(|_| unused_files_ran);
for pattern in analysis.judged_patterns.iter().chain(ignore_files) {
let list = JudgedList::new(pattern.key.as_str(), vec![(pattern.pattern.as_str(), pattern.empty)]);
Self::judge(analysis, out, &list);
}
if ran(&["unused-exports"]) {
Self::report_tags(analysis, out);
}
for hint in &analysis.config_hints {
Self::report_hint(analysis, out, hint);
}
if unused_files_ran {
Self::report_unconfigured(analysis, out);
}
}
}