Skip to main content

skif_config/
model.rs

1use std::path::{Path, PathBuf};
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5use skif_core::{FileSystem, OsFs};
6use skif_specifier::CONFIG_IDENTIFIERS;
7
8use crate::dependency_types::{DependencyType, DependencyTypes};
9use crate::error::ConfigError;
10use crate::overrides::ConfigOverrides;
11use crate::rules::{Level, RuleConfig, rules_map};
12use crate::selector::{Selector, anchor_patterns, unanchored};
13
14#[cfg(feature = "schema")]
15use crate::schema::RuleConfigSchema;
16
17/// The complete `skif.toml` model.
18#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
19#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
20#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
21#[non_exhaustive]
22pub struct Config {
23    /// Stops the upward search for configuration files: no file above this one contributes. Only valid in a file
24    /// found by discovery, not in one reached through `extends`.
25    #[serde(skip_serializing_if = "is_false")]
26    pub root: bool,
27    /// Configuration files to inherit, relative to this file. They apply before this file does, in the order listed.
28    #[serde(skip_serializing_if = "Vec::is_empty")]
29    pub extends: Vec<String>,
30    /// Inherited lists to discard before this file applies.
31    #[serde(skip_serializing_if = "Vec::is_empty")]
32    pub reset: Vec<ResetKey>,
33    /// `[workspace]`: package-manager detection and which packages are skipped.
34    pub workspace: Workspace,
35    /// `[lint]`: how diagnostics are reported.
36    pub lint: Lint,
37    /// `[rules]`: the level and options of every configured rule, in file order.
38    #[serde(with = "rules_map")]
39    #[cfg_attr(feature = "schema", schemars(with = "IndexMap<String, RuleConfigSchema>"))]
40    pub rules: IndexMap<String, RuleConfig>,
41    /// `[[ignore]]`: suppressions for diagnostics.
42    pub ignore: Vec<IgnoreEntry>,
43    /// `[policy]`: defaults for the version policies.
44    pub policy: Policy,
45    /// `[[version-groups]]`: how dependency versions must relate, first match wins.
46    pub version_groups: Vec<VersionGroup>,
47    /// `[[semver-groups]]`: the range type dependencies must use, first match wins.
48    pub semver_groups: Vec<SemverGroup>,
49    /// `[[custom-types]]`: manifest fields that hold versions and take part in the version policies.
50    #[serde(skip_serializing_if = "Vec::is_empty")]
51    pub custom_types: Vec<CustomType>,
52    /// `[catalog]`: when catalog entries are suggested and reported.
53    pub catalog: Catalog,
54    /// `[workspace-protocol]`: how dependencies on workspace packages are written.
55    pub workspace_protocol: WorkspaceProtocol,
56    /// `[[dependency-families]]`: packages that must share a version.
57    pub dependency_families: Vec<DependencyFamily>,
58    /// `[update]`: how `skif update` reads the package registry. Linting never reads it.
59    pub update: Update,
60    /// `[[update-groups]]`: how far `skif update` may move the dependencies it matches, first match wins.
61    #[serde(skip_serializing_if = "Vec::is_empty")]
62    pub update_groups: Vec<UpdateGroup>,
63    /// `[js]`: the JavaScript and TypeScript analysis.
64    pub js: Js,
65    /// `[format]`: how `package-json-format` and `skif format` order and lay out `package.json` files.
66    pub format: Format,
67    /// Scoped rules for packages and files; only package-scoped keys are allowed in them.
68    #[serde(skip_serializing_if = "Vec::is_empty")]
69    pub overrides: Vec<Override>,
70    /// Rule levels set on the command line. They also sit in `rules`; this copy tells the engine to let them beat
71    /// the levels of overrides. Set by [`apply`](Self::apply), never read from a file.
72    #[serde(skip)]
73    #[cfg_attr(feature = "schema", schemars(skip))]
74    pub cli_levels: IndexMap<String, Level>,
75    /// The instances the version-group policies see, narrowed on the command line (`--dependency`,
76    /// `--dependency-types`, `--specifier-types`, `--source`). Never read from a file.
77    #[serde(skip)]
78    #[cfg_attr(feature = "schema", schemars(skip))]
79    pub filter: InstanceFilter,
80}
81
82/// Narrows the dependency instances the version-group policies see, as syncpack's command-line filters do: an
83/// instance that does not pass is left out before it is assigned to a group, so it is neither reported nor aligned
84/// with. Empty selects everything.
85#[derive(Debug, Clone, PartialEq, Eq, Default)]
86#[non_exhaustive]
87pub struct InstanceFilter {
88    /// Dependency names or globs.
89    pub dependencies: Selector,
90    /// Dependency types; a non-empty list also replaces `[policy] dependency-types` as the scope of the default
91    /// group, so `["peer"]` governs peer dependencies.
92    pub dependency_types: DependencyTypes,
93    /// Specifier types (`exact`, `range`, ...), optionally negated with `!`.
94    pub specifier_types: Vec<String>,
95    /// Globs of the files the instances are declared in, relative to the workspace root (`packages/*/package.json`).
96    pub sources: Selector,
97}
98
99impl InstanceFilter {
100    /// Builds a filter from command-line values.
101    ///
102    /// # Errors
103    /// [`ConfigError::Invalid`] for a glob that does not compile, an empty dependency type or an unknown specifier
104    /// type.
105    pub fn new(
106        dependencies: &[String],
107        dependency_types: &[String],
108        specifier_types: &[String],
109        sources: &[String],
110    ) -> Result<Self, ConfigError> {
111        if let Some(message) = unknown_specifier_type(specifier_types) {
112            return Err(ConfigError::invalid(None, format!("--specifier-types: {message}")));
113        }
114        Ok(Self {
115            dependencies: Selector::new(dependencies.iter().cloned())?,
116            dependency_types: DependencyTypes::new(dependency_types)?,
117            specifier_types: specifier_types.to_vec(),
118            sources: Selector::new(sources.iter().cloned())?,
119        })
120    }
121
122    /// Whether the filter selects everything.
123    #[must_use]
124    pub fn is_empty(&self) -> bool {
125        self.dependencies.is_empty()
126            && self.dependency_types.is_empty()
127            && self.specifier_types.is_empty()
128            && self.sources.is_empty()
129    }
130
131    /// The first custom type the filter names that `config` does not define.
132    #[must_use]
133    pub fn unknown_custom_type<'a>(&'a self, config: &Config) -> Option<&'a str> {
134        self.dependency_types
135            .custom_names()
136            .find(|name| config.custom_types.iter().all(|custom| custom.name != *name))
137    }
138}
139
140#[allow(clippy::trivially_copy_pass_by_ref)]
141const fn is_false(value: &bool) -> bool {
142    !*value
143}
144
145/// The inherited lists that `reset` can discard.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
147#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
148#[serde(rename_all = "kebab-case")]
149#[non_exhaustive]
150pub enum ResetKey {
151    /// Discard the inherited `version-groups`.
152    VersionGroups,
153    /// Discard the inherited `semver-groups`.
154    SemverGroups,
155    /// Discard the inherited `ignore` entries.
156    Ignore,
157    /// Discard the inherited `dependency-families`.
158    DependencyFamilies,
159    /// Discard the inherited `custom-types`.
160    CustomTypes,
161    /// Discard the inherited `overrides`.
162    Overrides,
163    /// Discard the inherited `update-groups`.
164    UpdateGroups,
165}
166
167impl ResetKey {
168    /// The key as written in `skif.toml`.
169    #[must_use]
170    pub const fn name(self) -> &'static str {
171        match self {
172            Self::VersionGroups => "version-groups",
173            Self::SemverGroups => "semver-groups",
174            Self::Ignore => "ignore",
175            Self::DependencyFamilies => "dependency-families",
176            Self::CustomTypes => "custom-types",
177            Self::Overrides => "overrides",
178            Self::UpdateGroups => "update-groups",
179        }
180    }
181}
182
183/// Why `patterns` are not a valid selector, when they are not.
184fn selector_problem(patterns: &[String]) -> Option<String> {
185    match Selector::new(patterns.iter().cloned()) {
186        Err(ConfigError::Invalid { message, .. }) => Some(message),
187        _ => None,
188    }
189}
190
191impl Config {
192    /// Parses `skif.toml` contents.
193    ///
194    /// # Errors
195    /// [`ConfigError::Parse`] for malformed TOML, unknown keys or wrong types, and
196    /// [`ConfigError::Invalid`] for inconsistent group definitions.
197    pub fn from_toml_str(source: &str) -> Result<Self, ConfigError> {
198        Self::parse(source, None)
199    }
200
201    /// Reads and parses a configuration file; errors carry `path`.
202    ///
203    /// # Errors
204    /// [`ConfigError::Io`] when the file cannot be read, otherwise as [`Config::from_toml_str`].
205    pub fn from_toml_file(path: &Path) -> Result<Self, ConfigError> {
206        Self::from_toml_file_with_fs(path, &OsFs)
207    }
208
209    /// Reads and parses a configuration file through `fs`; errors carry `path`.
210    ///
211    /// # Errors
212    /// [`ConfigError::Io`] when the file cannot be read, otherwise as [`Config::from_toml_str`].
213    pub fn from_toml_file_with_fs(path: &Path, fs: &dyn FileSystem) -> Result<Self, ConfigError> {
214        let source = fs.read_to_string(path).map_err(|source| ConfigError::Io {
215            path: path.to_path_buf(),
216            source,
217        })?;
218        Self::parse(&source, Some(path))
219    }
220
221    /// Parses and validates the typed form of an already parsed document, leaving `extend-*` keys as written.
222    ///
223    /// `resolve_references` is whether a `dependency-types` list may only name the custom types this document
224    /// defines. A layer of a chain leaves that to the merged configuration, where the types of every layer are known.
225    pub(crate) fn from_document(
226        document: toml::Spanned<toml::de::DeTable<'_>>,
227        source: &str,
228        path: Option<&Path>,
229        resolve_references: bool,
230    ) -> Result<Self, ConfigError> {
231        let config = Self::deserialize(toml::de::Deserializer::from(document))
232            .map_err(|err| ConfigError::from_toml(&err, source, path))?;
233        config.validate_with(path, resolve_references)?;
234        Ok(config)
235    }
236
237    /// Parses a standalone file: the document is parsed once, its `extend-*` keys are folded into the lists they
238    /// extend.
239    fn parse(source: &str, path: Option<&Path>) -> Result<Self, ConfigError> {
240        let from_toml = |err: &toml::de::Error| ConfigError::from_toml(err, source, path);
241        let document = toml::de::DeTable::parse(source).map_err(|err| from_toml(&err))?;
242        let config = Self::from_document(document.clone(), source, path, true)?;
243        let table = toml::Table::deserialize(toml::de::Deserializer::from(document)).map_err(|err| from_toml(&err))?;
244        if !crate::merge::has_extend_keys(&table) {
245            return Ok(config);
246        }
247        let mut merger = crate::merge::Merger::default();
248        merger.apply(0, &table, &skif_core::HashMap::default());
249        let folded = merger.into_table();
250        toml::Value::Table(folded)
251            .try_into()
252            .map_err(|err: toml::de::Error| ConfigError::invalid(path, err.message()))
253    }
254
255    /// The first suppression without a `reason`, named by its key: an `[[ignore]]` entry (`ignore[0]`, also one of an
256    /// override: `overrides[1].ignore[0]`), an entry of a `[js]` ignore list, with its name
257    /// (``js.workspaces."apps/web".ignore-dependencies[2] `left-pad` ``), or an entry of a rule's `allow` option
258    /// (``rules.stray-lockfile.allow[0] `tools/legacy/package-lock.json` ``). `None` when every one has a reason, which
259    /// is what `--require-ignore-reasons` and `[lint] require-ignore-reasons` require.
260    ///
261    /// The check reads this configuration as it is, so on a merged one the key and position are those of the merged
262    /// list: an entry a file adds with `extend-ignore-dependencies`, or inherits through `extends`, is named by the
263    /// list it was merged into and its place there, and the name of the entry is what finds it in the files.
264    #[must_use]
265    pub fn missing_ignore_reason(&self) -> Option<String> {
266        unreasoned_entry("ignore", &self.ignore)
267            .or_else(|| {
268                self.js_ignore_lists().into_iter().find_map(|(key, items)| {
269                    items
270                        .iter()
271                        .enumerate()
272                        .find(|(_, item)| item.reason.is_none())
273                        .map(|(index, item)| format!("{key}[{index}] `{}`", item.name))
274                })
275            })
276            .or_else(|| {
277                self.overrides
278                    .iter()
279                    .enumerate()
280                    .find_map(|(index, scoped)| unreasoned_entry(&format!("overrides[{index}].ignore"), &scoped.ignore))
281            })
282            .or_else(|| self.unreasoned_allow_entry())
283    }
284
285    /// The first entry without a `reason` of a rule's `allow` option (``rules.stray-lockfile.allow[0] `a/yarn.lock` ``):
286    /// an `allow` list keeps findings quiet as an ignore list does. An entry that is neither a string nor a table is
287    /// left to the rule's own validation.
288    fn unreasoned_allow_entry(&self) -> Option<String> {
289        self.rules.iter().find_map(|(id, rule)| {
290            let items = rule.options.get("allow")?.as_array()?;
291            items.iter().enumerate().find_map(|(index, item)| {
292                let name = match item {
293                    toml::Value::String(name) => name.as_str(),
294                    toml::Value::Table(table) if !table.contains_key("reason") => {
295                        table.get("name").and_then(toml::Value::as_str).unwrap_or_default()
296                    }
297                    _ => return None,
298                };
299                Some(format!("rules.{id}.allow[{index}] `{name}`"))
300            })
301        })
302    }
303
304    /// Every `[js]` ignore list under its full key: those of the top level, of each `[js.workspaces]` table and of
305    /// the `js` of each override.
306    fn js_ignore_lists(&self) -> Vec<(String, &[IgnoreItem])> {
307        let mut lists = keyed("js", &self.js.ignore_lists());
308        for (dir, workspace) in &self.js.workspaces {
309            lists.extend(keyed(&format!("js.workspaces.\"{dir}\""), &workspace.ignore_lists()));
310        }
311        for (index, scoped) in self.overrides.iter().enumerate() {
312            lists.extend(keyed(&format!("overrides[{index}].js"), &scoped.js.ignore_lists()));
313        }
314        lists
315    }
316
317    /// Serializes back to TOML.
318    ///
319    /// # Errors
320    /// Fails only when a rule option holds a value TOML cannot represent.
321    pub fn to_toml_string(&self) -> Result<String, toml::ser::Error> {
322        toml::to_string(self)
323    }
324
325    /// Checks cross-field consistency of groups, catalog and families.
326    ///
327    /// # Errors
328    /// [`ConfigError::Invalid`] naming the offending entry.
329    pub fn validate(&self) -> Result<(), ConfigError> {
330        self.validate_with(None, true)
331    }
332
333    fn validate_with(&self, path: Option<&Path>, resolve_references: bool) -> Result<(), ConfigError> {
334        let invalid = |message: String| Err(ConfigError::invalid(path, message));
335        for (index, group) in self.version_groups.iter().enumerate() {
336            if let Some(message) = group.inconsistency() {
337                return invalid(format!("version-groups[{index}]: {message}"));
338            }
339            if let Some(message) = unknown_specifier_type(&group.specifier_types) {
340                return invalid(format!("version-groups[{index}]: {message}"));
341            }
342        }
343        for (index, group) in self.semver_groups.iter().enumerate() {
344            if let Some(message) = group.inconsistency() {
345                return invalid(format!("semver-groups[{index}]: {message}"));
346            }
347            if let Some(message) = unknown_specifier_type(&group.specifier_types) {
348                return invalid(format!("semver-groups[{index}]: {message}"));
349            }
350        }
351        for (index, group) in self.update_groups.iter().enumerate() {
352            if let Some(message) = group.inconsistency() {
353                return invalid(format!("update-groups[{index}]: {message}"));
354            }
355            if let Some(message) = unknown_specifier_type(&group.specifier_types) {
356                return invalid(format!("update-groups[{index}]: {message}"));
357            }
358        }
359        if self.update.max_concurrent_requests == 0 {
360            return invalid("update.max-concurrent-requests must be at least 1".to_string());
361        }
362        if let Some(message) = self.custom_type_problem(resolve_references) {
363            return invalid(message);
364        }
365        for (index, entry) in self.ignore.iter().enumerate() {
366            if entry.rules.is_empty()
367                && entry.packages.is_empty()
368                && entry.dependencies.is_empty()
369                && entry.files.is_empty()
370            {
371                return invalid(format!(
372                    "ignore[{index}]: set at least one of `rules`, `packages`, `dependencies` or `files`; an entry without filters would suppress every diagnostic"
373                ));
374            }
375            if entry.reason.as_deref().is_some_and(|reason| reason.trim().is_empty()) {
376                return invalid(format!("ignore[{index}]: `reason` is empty; say why or remove it"));
377            }
378        }
379        for (key, items) in self.js_ignore_lists() {
380            let blank = items
381                .iter()
382                .position(|item| item.reason.as_deref().is_some_and(|reason| reason.trim().is_empty()));
383            if let Some(index) = blank {
384                return invalid(format!("{key}[{index}]: `reason` is empty; say why or remove it"));
385            }
386        }
387        if let Some(message) = self.js.compilers.iter().find_map(|(extension, compiler)| {
388            compiler.problem(&crate::provenance::join_key("js.compilers", extension), extension)
389        }) {
390            return invalid(message);
391        }
392        if let Some(message) = self.format.inconsistency() {
393            return invalid(format!("format.{message}"));
394        }
395        for (index, scoped) in self.overrides.iter().enumerate() {
396            if let Some(message) = scoped.inconsistency() {
397                return invalid(format!("overrides[{index}]: {message}"));
398            }
399        }
400        if self.catalog.min_packages == 0 {
401            return invalid("catalog.min-packages must be at least 1".to_string());
402        }
403        if let Some(message) = selector_problem(&self.catalog.exclude) {
404            return invalid(format!("catalog.exclude: {message}"));
405        }
406        for (index, pattern) in self.workspace.discover_ignore.iter().enumerate() {
407            // The gitignore parser reads an unclosed class such as `[bad` literally; a typo like that is reported.
408            let glob = pattern.strip_prefix('!').unwrap_or(pattern).trim_start_matches('/');
409            let mut builder = ignore::gitignore::GitignoreBuilder::new("");
410            let checked = builder
411                .add_line(None, pattern)
412                .map(|_| ())
413                .map_err(|err| err.to_string())
414                .and_then(|()| globset::Glob::new(glob).map(|_| ()).map_err(|err| err.to_string()));
415            if let Err(err) = checked {
416                return invalid(format!("workspace.discover-ignore[{index}]: {err}"));
417            }
418        }
419        for (index, family) in self.dependency_families.iter().enumerate() {
420            if family.name.is_empty() {
421                return invalid(format!("dependency-families[{index}]: `name` must not be empty"));
422            }
423            if family.enabled && family.packages.is_empty() {
424                return invalid(format!("dependency-families[{index}]: `packages` must not be empty"));
425            }
426            if let Some(message) = selector_problem(&family.packages) {
427                return invalid(format!("dependency-families[{index}]: {message}"));
428            }
429            match family.anchor.as_deref() {
430                Some(_) if family.scope.is_manifests() => {
431                    return invalid(format!(
432                        "dependency-families[{index}]: `anchor` is only valid with `scope = \"importer\"` or `scope = \"lockfile\"`"
433                    ));
434                }
435                Some(anchor) if anchor.trim().is_empty() => {
436                    return invalid(format!("dependency-families[{index}]: `anchor` must not be empty"));
437                }
438                _ => {}
439            }
440        }
441        Ok(())
442    }
443
444    /// The first problem of `[[custom-types]]`, or, with `resolve_references`, of a `dependency-types` list naming a
445    /// custom type that is not defined.
446    fn custom_type_problem(&self, resolve_references: bool) -> Option<String> {
447        for (index, custom) in self.custom_types.iter().enumerate() {
448            if let Some(message) = custom.inconsistency() {
449                return Some(format!("custom-types[{index}]: {message}"));
450            }
451            if self.custom_types[..index]
452                .iter()
453                .any(|earlier| earlier.name == custom.name)
454            {
455                return Some(format!(
456                    "custom-types[{index}]: the name \"{}\" is defined twice",
457                    custom.name
458                ));
459            }
460        }
461        if !resolve_references {
462            return None;
463        }
464        let lists = std::iter::once(("policy.dependency-types".to_owned(), &self.policy.dependency_types))
465            .chain(self.version_groups.iter().enumerate().map(|(index, group)| {
466                (
467                    format!("version-groups[{index}].dependency-types"),
468                    &group.dependency_types,
469                )
470            }))
471            .chain(self.semver_groups.iter().enumerate().map(|(index, group)| {
472                (
473                    format!("semver-groups[{index}].dependency-types"),
474                    &group.dependency_types,
475                )
476            }))
477            .chain(self.update_groups.iter().enumerate().map(|(index, group)| {
478                (
479                    format!("update-groups[{index}].dependency-types"),
480                    &group.dependency_types,
481                )
482            }));
483        for (place, types) in lists {
484            if let Some(name) = types
485                .custom_names()
486                .find(|name| self.custom_types.iter().all(|custom| custom.name != *name))
487            {
488                let hint = closest(
489                    name,
490                    &self
491                        .custom_types
492                        .iter()
493                        .map(|custom| custom.name.as_str())
494                        .chain(BUILTIN_TYPE_TOKENS)
495                        .collect::<Vec<_>>(),
496                )
497                .map_or_else(String::new, |s| format!("; did you mean \"{s}\"?"));
498                return Some(format!(
499                    "{place}: unknown dependency type \"{name}\"{hint} (expected {}, or the name of a `[[custom-types]]` entry)",
500                    BUILTIN_TYPE_TOKENS.join(", ")
501                ));
502            }
503        }
504        None
505    }
506
507    /// Fails on the first rule ID (in `[rules]`, `[[ignore]]` or an override) that is not in `known`, suggesting the closest ID.
508    ///
509    /// # Errors
510    /// [`ConfigError::UnknownRule`].
511    pub fn validate_rules(&self, known: &[&str]) -> Result<(), ConfigError> {
512        let configured = self.rules.keys();
513        let ignored = self.ignore.iter().flat_map(|entry| entry.rules.iter());
514        let scoped = self.overrides.iter().flat_map(|scoped| {
515            scoped
516                .rules
517                .keys()
518                .chain(scoped.ignore.iter().flat_map(|entry| entry.rules.iter()))
519        });
520        for id in configured.chain(ignored).chain(scoped) {
521            if !known.contains(&id.as_str()) {
522                return Err(ConfigError::UnknownRule {
523                    rule: id.clone(),
524                    suggestion: closest(id, known),
525                });
526            }
527        }
528        Ok(())
529    }
530
531    /// Fails on the first plugin id (in `[js.plugins]`, a `[js.workspaces]` table or an override) that is not in `known`,
532    /// suggesting the closest id.
533    ///
534    /// # Errors
535    /// [`ConfigError::Invalid`] naming the key.
536    pub fn validate_plugins(&self, known: &[&str]) -> Result<(), ConfigError> {
537        let workspaces = self
538            .js
539            .workspaces
540            .iter()
541            .map(|(dir, workspace)| (format!("js.workspaces.\"{dir}\".plugins"), &workspace.plugins));
542        let overrides = self
543            .overrides
544            .iter()
545            .enumerate()
546            .map(|(index, scoped)| (format!("overrides[{index}].js.plugins"), &scoped.js.plugins));
547        let tables = std::iter::once(("js.plugins".to_owned(), &self.js.plugins))
548            .chain(workspaces)
549            .chain(overrides);
550        for (key, table) in tables {
551            if let Some(id) = table.keys().find(|id| !known.contains(&id.as_str())) {
552                let hint = closest(id, known).map_or_else(String::new, |close| format!("; did you mean `{close}`?"));
553                return Err(ConfigError::invalid(
554                    None,
555                    format!("{key}.{id}: unknown plugin `{id}`{hint}"),
556                ));
557            }
558        }
559        Ok(())
560    }
561
562    /// The configuration of rule `id`, if it appears in `[rules]`.
563    #[must_use]
564    pub fn rule(&self, id: &str) -> Option<&RuleConfig> {
565        self.rules.get(id)
566    }
567
568    /// Layers CLI overrides over this configuration (defaults < file < CLI).
569    ///
570    /// # Errors
571    /// [`ConfigError::Invalid`] when an ignore pattern in `overrides` is not a valid selector; the configuration is
572    /// left unchanged.
573    pub fn apply(&mut self, overrides: &ConfigOverrides) -> Result<(), ConfigError> {
574        let ignore_packages = Selector::new(overrides.ignore_packages.iter().cloned())?;
575        let ignore_dependencies = Selector::new(overrides.ignore_dependencies.iter().cloned())?;
576        for (id, level) in &overrides.rules {
577            self.rules.entry(id.clone()).or_default().severity = Some(*level);
578            self.cli_levels.insert(id.clone(), *level);
579        }
580        for id in &overrides.ignore_rules {
581            self.rules.entry(id.clone()).or_default().severity = Some(Level::Off);
582            self.cli_levels.insert(id.clone(), Level::Off);
583        }
584        if !ignore_packages.is_empty() {
585            self.workspace.ignore_packages = ignore_packages;
586        }
587        if !ignore_dependencies.is_empty() {
588            self.ignore.push(IgnoreEntry {
589                dependencies: ignore_dependencies,
590                reason: Some("--ignore-dependency".to_owned()),
591                ..IgnoreEntry::default()
592            });
593        }
594        if let Some(value) = overrides.fail_on_warnings {
595            self.lint.fail_on_warnings = value;
596        }
597        if let Some(value) = overrides.production {
598            self.js.production = value;
599        }
600        if overrides.no_js {
601            self.js.enabled = false;
602        }
603        Ok(())
604    }
605}
606
607/// The built-in dependency types, as `dependency-types` names them.
608const BUILTIN_TYPE_TOKENS: [&str; 8] = [
609    "prod",
610    "dev",
611    "peer",
612    "optional",
613    "local",
614    "resolutions",
615    "overrides",
616    "pnpm-overrides",
617];
618
619/// Describes the first entry of `specifier_types` (optionally negated with `!`) that is not a specifier type.
620fn unknown_specifier_type(specifier_types: &[String]) -> Option<String> {
621    specifier_types.iter().find_map(|raw| {
622        let name = raw.strip_prefix('!').unwrap_or(raw);
623        if CONFIG_IDENTIFIERS.contains(&name) {
624            return None;
625        }
626        let hint = closest(name, &CONFIG_IDENTIFIERS).map_or_else(String::new, |s| format!("; did you mean \"{s}\"?"));
627        Some(format!(
628            "unknown specifier type \"{name}\"{hint} (expected one of: {})",
629            CONFIG_IDENTIFIERS.join(", ")
630        ))
631    })
632}
633
634fn closest(id: &str, known: &[&str]) -> Option<String> {
635    let limit = (id.chars().count() / 3).clamp(2, 4);
636    known
637        .iter()
638        .map(|candidate| (strsim::levenshtein(id, candidate), candidate))
639        .filter(|(distance, _)| *distance <= limit)
640        .min_by_key(|(distance, _)| *distance)
641        .map(|(_, candidate)| (*candidate).to_string())
642}
643
644/// Package managers that can be forced with `[workspace] package-manager`.
645#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
646#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
647#[serde(rename_all = "kebab-case")]
648#[non_exhaustive]
649pub enum PackageManager {
650    /// npm.
651    Npm,
652    /// pnpm.
653    Pnpm,
654    /// Yarn.
655    Yarn,
656    /// Bun.
657    Bun,
658}
659
660/// Names of the directories that hold test fixtures. Recursive root discovery skips them by default (they lead
661/// [`DEFAULT_DISCOVER_IGNORE`]), and the JavaScript analysis reports nothing about their files that would only hold
662/// for production code: their imports are not unresolved or unlisted, and, unless `[js] include-fixtures` is set,
663/// their files and exports are not unused.
664pub const FIXTURE_DIRECTORIES: [&str; 2] = ["__fixtures__", "fixtures"];
665
666/// Directory names that recursive root discovery skips by default: they hold fixtures and test doubles, not projects.
667/// The first entries are [`FIXTURE_DIRECTORIES`].
668pub const DEFAULT_DISCOVER_IGNORE: [&str; 4] = ["__fixtures__", "fixtures", "__mocks__", "__snapshots__"];
669
670fn default_discover_ignore() -> Vec<String> {
671    DEFAULT_DISCOVER_IGNORE.iter().map(|name| (*name).to_owned()).collect()
672}
673
674/// `[workspace]`
675#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
676#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
677#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
678#[non_exhaustive]
679pub struct Workspace {
680    /// Package names or globs excluded from every check.
681    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
682    pub ignore_packages: Selector,
683    /// Overrides package-manager detection.
684    #[serde(skip_serializing_if = "Option::is_none")]
685    pub package_manager: Option<PackageManager>,
686    /// Gitignore-style patterns, relative to the directory a recursive run starts in, of directories that root
687    /// discovery (`-r`) does not enter, so no project below them is found. A bare name matches at any depth; a
688    /// pattern with a slash is anchored at the start directory. Replaces the default list
689    /// (`__fixtures__`, `fixtures`, `__mocks__`, `__snapshots__`); `[]` skips nothing. Read from the configuration
690    /// file that governs the start directory.
691    pub discover_ignore: Vec<String>,
692    /// Appended to `ignore-packages` instead of replacing it. Folded away when the configuration is resolved.
693    #[serde(skip_serializing_if = "Selector::is_empty")]
694    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
695    pub extend_ignore_packages: Selector,
696}
697
698impl Default for Workspace {
699    fn default() -> Self {
700        Self {
701            ignore_packages: Selector::default(),
702            extend_ignore_packages: Selector::default(),
703            package_manager: None,
704            discover_ignore: default_discover_ignore(),
705        }
706    }
707}
708
709impl Workspace {
710    /// Sets `ignore-packages`.
711    #[must_use]
712    pub fn with_ignore_packages(mut self, ignore_packages: Selector) -> Self {
713        self.ignore_packages = ignore_packages;
714        self
715    }
716
717    /// Forces the package manager instead of detecting it.
718    #[must_use]
719    pub const fn with_package_manager(mut self, package_manager: PackageManager) -> Self {
720        self.package_manager = Some(package_manager);
721        self
722    }
723
724    /// Sets `discover-ignore`, replacing the default list.
725    #[must_use]
726    pub fn with_discover_ignore(mut self, discover_ignore: Vec<String>) -> Self {
727        self.discover_ignore = discover_ignore;
728        self
729    }
730}
731
732/// The ignore lists `lists` under their keys prefixed with `prefix`.
733fn keyed<'c>(prefix: &str, lists: &[(&str, &'c [IgnoreItem])]) -> Vec<(String, &'c [IgnoreItem])> {
734    lists
735        .iter()
736        .map(|(key, items)| (format!("{prefix}.{key}"), *items))
737        .collect()
738}
739
740/// `key[index]` of the first entry of `entries` without a reason.
741fn unreasoned_entry(key: &str, entries: &[IgnoreEntry]) -> Option<String> {
742    entries
743        .iter()
744        .position(|entry| entry.reason.is_none())
745        .map(|index| format!("{key}[{index}]"))
746}
747
748/// `[lint]`
749#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
750#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
751#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
752#[non_exhaustive]
753pub struct Lint {
754    /// Exit non-zero when only warnings were reported.
755    pub fail_on_warnings: bool,
756    /// Caps the severity of every diagnostic: a rule configured above this level reports at it.
757    #[serde(skip_serializing_if = "Option::is_none")]
758    pub max_level: Option<Level>,
759    /// Fail the run when a suppression has no `reason`: an `[[ignore]]` entry, also one of an override, an entry of a
760    /// `[js]` ignore list, or an entry of a rule's `allow` option. The same check as `--require-ignore-reasons`; either
761    /// one turns it on.
762    #[serde(skip_serializing_if = "is_false")]
763    pub require_ignore_reasons: bool,
764}
765
766impl Lint {
767    /// Sets `fail-on-warnings`.
768    #[must_use]
769    pub const fn with_fail_on_warnings(mut self, fail_on_warnings: bool) -> Self {
770        self.fail_on_warnings = fail_on_warnings;
771        self
772    }
773
774    /// Caps the severity of every diagnostic at `max_level`.
775    #[must_use]
776    pub const fn with_max_level(mut self, max_level: Level) -> Self {
777        self.max_level = Some(max_level);
778        self
779    }
780
781    /// Sets `require-ignore-reasons`.
782    #[must_use]
783    pub const fn with_require_ignore_reasons(mut self, require: bool) -> Self {
784        self.require_ignore_reasons = require;
785        self
786    }
787}
788
789/// One `[[ignore]]` suppression. An empty list does not narrow that dimension, but at least one list must be set:
790/// an entry without any filter is rejected as it would suppress everything.
791#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
792#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
793#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
794#[non_exhaustive]
795pub struct IgnoreEntry {
796    /// Rule IDs to suppress; an empty list does not narrow the entry by rule.
797    pub rules: Vec<String>,
798    /// Package names or directories; an empty selector matches every package.
799    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
800    pub packages: Selector,
801    /// Dependency names; an empty selector matches every dependency.
802    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
803    pub dependencies: Selector,
804    /// Paths of the files a finding is located in, relative to the workspace root (`src/legacy.ts`, `src/old/**`); an
805    /// empty selector matches every file. The paths are matched as written in every configuration file, not anchored
806    /// at the file that declares the entry.
807    #[serde(skip_serializing_if = "Selector::is_empty")]
808    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
809    pub files: Selector,
810    /// Why the suppression exists. `--require-ignore-reasons` and `[lint] require-ignore-reasons` fail a run on an
811    /// entry without one.
812    #[serde(skip_serializing_if = "Option::is_none")]
813    pub reason: Option<String>,
814}
815
816impl IgnoreEntry {
817    /// Limits the entry to the rule IDs `rules`.
818    #[must_use]
819    pub fn with_rules<I, S>(mut self, rules: I) -> Self
820    where
821        I: IntoIterator<Item = S>,
822        S: Into<String>,
823    {
824        self.rules = rules.into_iter().map(Into::into).collect();
825        self
826    }
827
828    /// Limits the entry to the packages `packages` selects.
829    #[must_use]
830    pub fn with_packages(mut self, packages: Selector) -> Self {
831        self.packages = packages;
832        self
833    }
834
835    /// Limits the entry to the dependencies `dependencies` selects.
836    #[must_use]
837    pub fn with_dependencies(mut self, dependencies: Selector) -> Self {
838        self.dependencies = dependencies;
839        self
840    }
841
842    /// Limits the entry to the findings located in the files `files` selects.
843    #[must_use]
844    pub fn with_files(mut self, files: Selector) -> Self {
845        self.files = files;
846        self
847    }
848
849    /// Records why the suppression exists.
850    #[must_use]
851    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
852        self.reason = Some(reason.into());
853        self
854    }
855}
856
857/// One entry of a `[js]` ignore list (`ignore-dependencies`, `ignore-files`, ...): a bare string, or an inline table
858/// `{ name = "...", reason = "..." }` that records why the entry exists.
859///
860/// `name` holds whatever the list takes as a bare string: a name, a glob or a slash-delimited regular expression,
861/// with the same `!` production marker. Without a `reason` the entry is written back as a bare string. Compared with a
862/// string, an entry compares its `name`.
863#[derive(Debug, Clone, PartialEq, Eq, Hash)]
864#[non_exhaustive]
865pub struct IgnoreItem {
866    /// The name, glob or regular expression the list matches.
867    pub name: String,
868    /// Why the entry exists. `--require-ignore-reasons` and `[lint] require-ignore-reasons` fail a run on an entry
869    /// without one.
870    pub reason: Option<String>,
871}
872
873impl IgnoreItem {
874    /// An entry for `name` without a reason.
875    #[must_use]
876    pub fn new(name: impl Into<String>) -> Self {
877        Self {
878            name: name.into(),
879            reason: None,
880        }
881    }
882
883    /// Records why the entry exists.
884    #[must_use]
885    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
886        self.reason = Some(reason.into());
887        self
888    }
889}
890
891impl From<&str> for IgnoreItem {
892    fn from(name: &str) -> Self {
893        Self::new(name)
894    }
895}
896
897impl From<String> for IgnoreItem {
898    fn from(name: String) -> Self {
899        Self::new(name)
900    }
901}
902
903impl AsRef<str> for IgnoreItem {
904    fn as_ref(&self) -> &str {
905        &self.name
906    }
907}
908
909impl PartialEq<str> for IgnoreItem {
910    fn eq(&self, other: &str) -> bool {
911        self.name == other
912    }
913}
914
915impl PartialEq<&str> for IgnoreItem {
916    fn eq(&self, other: &&str) -> bool {
917        self.name == *other
918    }
919}
920
921impl PartialEq<String> for IgnoreItem {
922    fn eq(&self, other: &String) -> bool {
923        &self.name == other
924    }
925}
926
927impl Serialize for IgnoreItem {
928    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
929        use serde::ser::SerializeMap as _;
930
931        match &self.reason {
932            None => serializer.serialize_str(&self.name),
933            Some(reason) => {
934                let mut map = serializer.serialize_map(Some(2))?;
935                map.serialize_entry("name", &self.name)?;
936                map.serialize_entry("reason", reason)?;
937                map.end()
938            }
939        }
940    }
941}
942
943/// The table form of an [`IgnoreItem`].
944#[derive(Deserialize)]
945#[serde(deny_unknown_fields)]
946struct IgnoreItemTable {
947    name: String,
948    #[serde(default)]
949    reason: Option<String>,
950}
951
952/// Read by hand rather than as an untagged enum, so that a mistyped key of the table form is reported by name
953/// (`unknown field "reson"`) instead of serde's "data did not match any variant".
954impl<'de> Deserialize<'de> for IgnoreItem {
955    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
956        struct Visitor;
957
958        impl<'de> serde::de::Visitor<'de> for Visitor {
959            type Value = IgnoreItem;
960
961            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
962                f.write_str("a string, or a table of `name` and `reason`")
963            }
964
965            fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
966                Ok(IgnoreItem::new(value))
967            }
968
969            fn visit_string<E: serde::de::Error>(self, value: String) -> Result<Self::Value, E> {
970                Ok(IgnoreItem::new(value))
971            }
972
973            fn visit_map<A: serde::de::MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
974                IgnoreItemTable::deserialize(serde::de::value::MapAccessDeserializer::new(map)).map(|table| {
975                    IgnoreItem {
976                        name: table.name,
977                        reason: table.reason,
978                    }
979                })
980            }
981        }
982
983        deserializer.deserialize_any(Visitor)
984    }
985}
986
987/// Which version `preferred-semver` favours.
988#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
989#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
990#[serde(rename_all = "kebab-case")]
991#[non_exhaustive]
992pub enum Prefer {
993    /// Prefer the highest version in use.
994    #[default]
995    Highest,
996    /// Prefer the lowest version in use.
997    Lowest,
998}
999
1000/// How the override sections (`resolutions`, `overrides`, `pnpm-overrides`) are read as dependency instances.
1001#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1002#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1003#[serde(rename_all = "kebab-case")]
1004#[non_exhaustive]
1005pub enum OverrideReading {
1006    /// The dependency of an entry is the package its selector replaces (`parent>child` and `child@<2` are `child`),
1007    /// npm's nested objects are read, and `pnpm.overrides` of every `package.json` is read.
1008    #[default]
1009    Skif,
1010    /// As syncpack 15 reads them: the key of an entry is the dependency name, so `parent>child` and `child@<2` are
1011    /// dependencies of their own; only string values count (nested objects are skipped); and `pnpm-overrides` is the
1012    /// `overrides` of `pnpm-workspace.yaml` only.
1013    Syncpack,
1014}
1015
1016/// `[policy]`
1017#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
1018#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1019#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
1020#[non_exhaustive]
1021pub struct Policy {
1022    /// Which version `preferred-semver` favours.
1023    pub prefer: Prefer,
1024    /// Dependency types the policy rules apply to. Empty means production and development dependencies (`dependencies` and `devDependencies`) and a package's own version; name `peer` or `optional` to include them. A list that does not select `local` leaves the own versions out, so no dependent is compared with the version of the workspace package it names. A list of negations only (`["!local"]`) starts from every dependency section, `peerDependencies` and `optionalDependencies` included, as in syncpack; to keep the default sections without the own versions, write `["prod", "dev"]`.
1025    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
1026    pub dependency_types: DependencyTypes,
1027    /// How `resolutions`, `overrides` and `pnpm-overrides` entries become dependency instances: `skif` (default) names
1028    /// the package a selector replaces, `syncpack` reads them as syncpack 15 does.
1029    pub override_reading: OverrideReading,
1030}
1031
1032impl Policy {
1033    /// Sets which version `preferred-semver` favours.
1034    #[must_use]
1035    pub const fn with_prefer(mut self, prefer: Prefer) -> Self {
1036        self.prefer = prefer;
1037        self
1038    }
1039
1040    /// Sets the dependency types the policy rules apply to.
1041    #[must_use]
1042    pub fn with_dependency_types(mut self, dependency_types: DependencyTypes) -> Self {
1043        self.dependency_types = dependency_types;
1044        self
1045    }
1046
1047    /// Sets how the override sections are read.
1048    #[must_use]
1049    pub const fn with_override_reading(mut self, override_reading: OverrideReading) -> Self {
1050        self.override_reading = override_reading;
1051        self
1052    }
1053}
1054
1055/// The policy of a version group.
1056#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1057#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1058#[serde(rename_all = "kebab-case")]
1059#[non_exhaustive]
1060pub enum VersionPolicy {
1061    /// Every matched dependency must use the `pin` version.
1062    Pinned,
1063    /// The matched dependencies must not be used.
1064    Banned,
1065    /// The matched dependencies are not checked: versions may differ and no semver group applies to them
1066    /// (`semver-range-only` keeps the semver groups).
1067    Ignored,
1068    /// Every range must overlap every other range.
1069    SameRange,
1070    /// Every matched dependency must share a major and minor version.
1071    SameMinor,
1072    /// Versions must match those declared by the `snap-to` packages.
1073    SnappedTo,
1074    /// Versions must match the highest or lowest one in use.
1075    #[default]
1076    PreferredSemver,
1077    /// Versions may differ; only the range the semver groups require is checked (syncpack's `semverRangeOnly`).
1078    SemverRangeOnly,
1079}
1080
1081/// What a version group judges.
1082#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1083#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1084#[serde(rename_all = "kebab-case")]
1085#[non_exhaustive]
1086pub enum GroupScope {
1087    /// The dependency instances the manifests declare.
1088    #[default]
1089    Manifests,
1090    /// Every package `pnpm-lock.yaml` resolves, transitive ones included; only with policy `banned`. The group claims
1091    /// no manifest instance, so those fall through to the groups after it.
1092    Lockfile,
1093}
1094
1095impl GroupScope {
1096    /// Whether this is the default, [`Manifests`](Self::Manifests).
1097    #[must_use]
1098    pub const fn is_manifests(&self) -> bool {
1099        matches!(self, Self::Manifests)
1100    }
1101}
1102
1103/// One `[[version-groups]]` entry; first match wins. Selectors accept `!` negation and `$LOCAL`.
1104#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
1105#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1106#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
1107#[non_exhaustive]
1108pub struct VersionGroup {
1109    /// A name shown in diagnostics.
1110    #[serde(skip_serializing_if = "Option::is_none")]
1111    pub label: Option<String>,
1112    /// Dependency names the group covers; empty covers every dependency.
1113    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
1114    pub dependencies: Selector,
1115    /// Package names the group covers; empty covers every package.
1116    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
1117    pub packages: Selector,
1118    /// Dependency types the group covers; empty covers the types `[policy] dependency-types` governs (production and development dependencies and a package's own version by default). Peer and optional dependencies, the override sections and custom types are covered only when named.
1119    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
1120    pub dependency_types: DependencyTypes,
1121    /// Specifier types the group covers (`exact`, `range`, `tag`, ...), optionally negated with `!`; empty covers every type.
1122    pub specifier_types: Vec<String>,
1123    /// The policy the group enforces.
1124    pub policy: VersionPolicy,
1125    /// Required by `pinned`.
1126    #[serde(skip_serializing_if = "Option::is_none")]
1127    pub pin: Option<String>,
1128    /// Required by `snapped-to`: packages whose version the group follows.
1129    pub snap_to: Vec<String>,
1130    /// Allowed with `preferred-semver`; overrides `[policy] prefer`.
1131    #[serde(skip_serializing_if = "Option::is_none")]
1132    pub prefer: Option<Prefer>,
1133    /// `manifests` (default) judges the declared dependencies; `lockfile` judges what `pnpm-lock.yaml` resolves.
1134    #[serde(skip_serializing_if = "GroupScope::is_manifests")]
1135    pub scope: GroupScope,
1136}
1137
1138impl VersionGroup {
1139    /// Sets what the group judges: the manifests or the lockfile.
1140    #[must_use]
1141    pub const fn with_scope(mut self, scope: GroupScope) -> Self {
1142        self.scope = scope;
1143        self
1144    }
1145
1146    /// Sets the label shown in diagnostics.
1147    #[must_use]
1148    pub fn with_label(mut self, label: impl Into<String>) -> Self {
1149        self.label = Some(label.into());
1150        self
1151    }
1152
1153    /// Selects the dependencies the group covers.
1154    #[must_use]
1155    pub fn with_dependencies(mut self, dependencies: Selector) -> Self {
1156        self.dependencies = dependencies;
1157        self
1158    }
1159
1160    /// Selects the packages the group covers.
1161    #[must_use]
1162    pub fn with_packages(mut self, packages: Selector) -> Self {
1163        self.packages = packages;
1164        self
1165    }
1166
1167    /// Selects the dependency types the group covers.
1168    #[must_use]
1169    pub fn with_dependency_types(mut self, dependency_types: DependencyTypes) -> Self {
1170        self.dependency_types = dependency_types;
1171        self
1172    }
1173
1174    /// Selects the specifier types the group covers.
1175    #[must_use]
1176    pub fn with_specifier_types<I, S>(mut self, specifier_types: I) -> Self
1177    where
1178        I: IntoIterator<Item = S>,
1179        S: Into<String>,
1180    {
1181        self.specifier_types = specifier_types.into_iter().map(Into::into).collect();
1182        self
1183    }
1184
1185    /// Sets the policy.
1186    #[must_use]
1187    pub const fn with_policy(mut self, policy: VersionPolicy) -> Self {
1188        self.policy = policy;
1189        self
1190    }
1191
1192    /// Sets the version `pinned` requires.
1193    #[must_use]
1194    pub fn with_pin(mut self, pin: impl Into<String>) -> Self {
1195        self.pin = Some(pin.into());
1196        self
1197    }
1198
1199    /// Sets the packages `snapped-to` follows.
1200    #[must_use]
1201    pub fn with_snap_to<I, S>(mut self, snap_to: I) -> Self
1202    where
1203        I: IntoIterator<Item = S>,
1204        S: Into<String>,
1205    {
1206        self.snap_to = snap_to.into_iter().map(Into::into).collect();
1207        self
1208    }
1209
1210    /// Overrides `[policy] prefer` for this group.
1211    #[must_use]
1212    pub const fn with_prefer(mut self, prefer: Prefer) -> Self {
1213        self.prefer = Some(prefer);
1214        self
1215    }
1216
1217    fn inconsistency(&self) -> Option<&'static str> {
1218        match self.policy {
1219            VersionPolicy::Pinned if self.pin.is_none() => return Some("policy `pinned` requires `pin`"),
1220            VersionPolicy::SnappedTo if self.snap_to.is_empty() => {
1221                return Some("policy `snapped-to` requires `snap-to`");
1222            }
1223            _ => {}
1224        }
1225        if self.pin.is_some() && self.policy != VersionPolicy::Pinned {
1226            return Some("`pin` is only valid with policy `pinned`");
1227        }
1228        if !self.snap_to.is_empty() && self.policy != VersionPolicy::SnappedTo {
1229            return Some("`snap-to` is only valid with policy `snapped-to`");
1230        }
1231        if self.prefer.is_some() && self.policy != VersionPolicy::PreferredSemver {
1232            return Some("`prefer` is only valid with policy `preferred-semver`");
1233        }
1234        if self.scope == GroupScope::Lockfile {
1235            if self.policy != VersionPolicy::Banned {
1236                return Some("`scope = \"lockfile\"` is only valid with policy `banned`");
1237            }
1238            if !self.dependency_types.is_empty() || !self.specifier_types.is_empty() {
1239                return Some(
1240                    "`dependency-types` and `specifier-types` select manifest entries and are not valid with `scope = \"lockfile\"`",
1241                );
1242            }
1243        }
1244        None
1245    }
1246}
1247
1248/// The range type a semver group enforces.
1249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1250#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1251#[non_exhaustive]
1252pub enum SemverRange {
1253    /// No range operator: an exact version.
1254    #[serde(rename = "")]
1255    Exact,
1256    /// Any version (`*`).
1257    #[serde(rename = "*")]
1258    Any,
1259    /// Compatible versions (`^`).
1260    #[serde(rename = "^")]
1261    Caret,
1262    /// Patch-level changes (`~`).
1263    #[serde(rename = "~")]
1264    Tilde,
1265    /// Greater than (`>`).
1266    #[serde(rename = ">")]
1267    Gt,
1268    /// Greater than or equal (`>=`).
1269    #[serde(rename = ">=")]
1270    Gte,
1271    /// Less than (`<`).
1272    #[serde(rename = "<")]
1273    Lt,
1274    /// Less than or equal (`<=`).
1275    #[serde(rename = "<=")]
1276    Lte,
1277}
1278
1279/// One `[[semver-groups]]` entry; first match wins. Set `range`, or `ignored = true` to leave the matched dependencies
1280/// without a range requirement (syncpack's `isIgnored` and `isDisabled`).
1281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1282#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1283#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1284#[non_exhaustive]
1285pub struct SemverGroup {
1286    /// A name shown in diagnostics.
1287    #[serde(default, skip_serializing_if = "Option::is_none")]
1288    pub label: Option<String>,
1289    /// Dependency names the group covers; empty covers every dependency.
1290    #[serde(default)]
1291    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
1292    pub dependencies: Selector,
1293    /// Package names the group covers; empty covers every package.
1294    #[serde(default)]
1295    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
1296    pub packages: Selector,
1297    /// Dependency types the group covers; empty covers the types `[policy] dependency-types` governs (production and development dependencies and a package's own version by default). Peer and optional dependencies, the override sections and custom types are covered only when named.
1298    #[serde(default)]
1299    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
1300    pub dependency_types: DependencyTypes,
1301    /// Specifier types the group covers, optionally negated with `!`; empty covers every type.
1302    #[serde(default)]
1303    pub specifier_types: Vec<String>,
1304    /// The range type the matched dependencies must use. Required unless `ignored` is set.
1305    #[serde(default, skip_serializing_if = "Option::is_none")]
1306    pub range: Option<SemverRange>,
1307    /// The matched dependencies have no range requirement and no later semver group applies to them. Exclusive with
1308    /// `range`.
1309    #[serde(default, skip_serializing_if = "is_false")]
1310    pub ignored: bool,
1311}
1312
1313impl SemverGroup {
1314    /// A group enforcing `range` on every dependency.
1315    #[must_use]
1316    pub fn new(range: SemverRange) -> Self {
1317        Self {
1318            label: None,
1319            dependencies: Selector::default(),
1320            packages: Selector::default(),
1321            dependency_types: DependencyTypes::default(),
1322            specifier_types: Vec::new(),
1323            range: Some(range),
1324            ignored: false,
1325        }
1326    }
1327
1328    /// A group that exempts the dependencies it covers from range enforcement.
1329    #[must_use]
1330    pub fn ignored() -> Self {
1331        Self {
1332            range: None,
1333            ignored: true,
1334            ..Self::new(SemverRange::Exact)
1335        }
1336    }
1337
1338    fn inconsistency(&self) -> Option<&'static str> {
1339        match (self.range, self.ignored) {
1340            (None, false) => Some("set `range`, or `ignored = true` to exempt the dependencies from a range"),
1341            (Some(_), true) => Some("`range` and `ignored = true` are mutually exclusive"),
1342            _ => None,
1343        }
1344    }
1345
1346    /// Sets the label shown in diagnostics.
1347    #[must_use]
1348    pub fn with_label(mut self, label: impl Into<String>) -> Self {
1349        self.label = Some(label.into());
1350        self
1351    }
1352
1353    /// Selects the dependencies the group covers.
1354    #[must_use]
1355    pub fn with_dependencies(mut self, dependencies: Selector) -> Self {
1356        self.dependencies = dependencies;
1357        self
1358    }
1359
1360    /// Selects the packages the group covers.
1361    #[must_use]
1362    pub fn with_packages(mut self, packages: Selector) -> Self {
1363        self.packages = packages;
1364        self
1365    }
1366
1367    /// Selects the dependency types the group covers.
1368    #[must_use]
1369    pub fn with_dependency_types(mut self, dependency_types: DependencyTypes) -> Self {
1370        self.dependency_types = dependency_types;
1371        self
1372    }
1373
1374    /// Selects the specifier types the group covers.
1375    #[must_use]
1376    pub fn with_specifier_types<I, S>(mut self, specifier_types: I) -> Self
1377    where
1378        I: IntoIterator<Item = S>,
1379        S: Into<String>,
1380    {
1381        self.specifier_types = specifier_types.into_iter().map(Into::into).collect();
1382        self
1383    }
1384}
1385
1386/// How far `skif update` may move a version: syncpack's `--target` and an update group's `target`.
1387#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1388#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1389#[serde(rename_all = "kebab-case")]
1390#[non_exhaustive]
1391pub enum UpdateTarget {
1392    /// The highest published version, across majors.
1393    #[default]
1394    Latest,
1395    /// The highest version with the same major.
1396    Minor,
1397    /// The highest version with the same major and minor.
1398    Patch,
1399}
1400
1401impl UpdateTarget {
1402    /// The stricter of two targets: `patch` over `minor` over `latest`. The command line's `--target` and an update
1403    /// group's `target` combine this way, so a group can only narrow what the run allows.
1404    #[must_use]
1405    pub const fn stricter(self, other: Self) -> Self {
1406        match (self, other) {
1407            (Self::Patch, _) | (_, Self::Patch) => Self::Patch,
1408            (Self::Minor, _) | (_, Self::Minor) => Self::Minor,
1409            _ => Self::Latest,
1410        }
1411    }
1412
1413    /// The name as `--target` and `target` write it.
1414    #[must_use]
1415    pub const fn name(self) -> &'static str {
1416        match self {
1417            Self::Latest => "latest",
1418            Self::Minor => "minor",
1419            Self::Patch => "patch",
1420        }
1421    }
1422}
1423
1424/// The default of `[update] max-concurrent-requests`, syncpack's `maxConcurrentRequests`.
1425pub const DEFAULT_MAX_CONCURRENT_REQUESTS: usize = 12;
1426
1427/// `[update]`: how `skif update` reads the package registry (ADR 0026). Linting never reads it.
1428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1429#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1430#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
1431#[non_exhaustive]
1432pub struct Update {
1433    /// Minutes a version must have been published before `skif update` proposes it; `0` turns the delay off. Unset,
1434    /// the `minimumReleaseAge` of `pnpm-workspace.yaml` applies, else one day (1440), as in syncpack.
1435    #[serde(skip_serializing_if = "Option::is_none")]
1436    pub minimum_release_age: Option<u64>,
1437    /// How many registry requests run at once.
1438    pub max_concurrent_requests: usize,
1439}
1440
1441impl Default for Update {
1442    fn default() -> Self {
1443        Self {
1444            minimum_release_age: None,
1445            max_concurrent_requests: DEFAULT_MAX_CONCURRENT_REQUESTS,
1446        }
1447    }
1448}
1449
1450impl Update {
1451    /// Sets the minimum release age in minutes.
1452    #[must_use]
1453    pub const fn with_minimum_release_age(mut self, minutes: Option<u64>) -> Self {
1454        self.minimum_release_age = minutes;
1455        self
1456    }
1457
1458    /// Sets how many registry requests run at once.
1459    #[must_use]
1460    pub const fn with_max_concurrent_requests(mut self, requests: usize) -> Self {
1461        self.max_concurrent_requests = requests;
1462        self
1463    }
1464}
1465
1466/// One `[[update-groups]]` entry; first match wins. Set `target` to cap how far `skif update` moves the matched
1467/// dependencies, or `ignored = true` to leave them alone (syncpack's `updateGroups`).
1468#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1469#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1470#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1471#[non_exhaustive]
1472pub struct UpdateGroup {
1473    /// A name for the group.
1474    #[serde(default, skip_serializing_if = "Option::is_none")]
1475    pub label: Option<String>,
1476    /// Dependency names the group covers; empty covers every dependency.
1477    #[serde(default)]
1478    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
1479    pub dependencies: Selector,
1480    /// Package names the group covers; empty covers every package. A group that names packages does not cover catalog
1481    /// entries, which belong to no package.
1482    #[serde(default)]
1483    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
1484    pub packages: Selector,
1485    /// Dependency types the group covers; empty covers the types `[policy] dependency-types` governs. A group that
1486    /// names types does not cover catalog entries.
1487    #[serde(default)]
1488    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
1489    pub dependency_types: DependencyTypes,
1490    /// Specifier types the group covers, optionally negated with `!`; empty covers every type.
1491    #[serde(default)]
1492    pub specifier_types: Vec<String>,
1493    /// The furthest the matched dependencies may move; combined with `--target`, the stricter wins. Required unless
1494    /// `ignored` is set.
1495    #[serde(default, skip_serializing_if = "Option::is_none")]
1496    pub target: Option<UpdateTarget>,
1497    /// `skif update` never changes the matched dependencies. Exclusive with `target`.
1498    #[serde(default, skip_serializing_if = "is_false")]
1499    pub ignored: bool,
1500}
1501
1502impl UpdateGroup {
1503    /// A group capping every dependency at `target`.
1504    #[must_use]
1505    pub fn new(target: UpdateTarget) -> Self {
1506        Self {
1507            label: None,
1508            dependencies: Selector::default(),
1509            packages: Selector::default(),
1510            dependency_types: DependencyTypes::default(),
1511            specifier_types: Vec::new(),
1512            target: Some(target),
1513            ignored: false,
1514        }
1515    }
1516
1517    /// A group whose dependencies `skif update` leaves alone.
1518    #[must_use]
1519    pub fn ignored() -> Self {
1520        Self {
1521            target: None,
1522            ignored: true,
1523            ..Self::new(UpdateTarget::Latest)
1524        }
1525    }
1526
1527    fn inconsistency(&self) -> Option<&'static str> {
1528        match (self.target, self.ignored) {
1529            (None, false) => Some("set `target`, or `ignored = true` to leave the dependencies alone"),
1530            (Some(_), true) => Some("`target` and `ignored = true` are mutually exclusive"),
1531            _ => None,
1532        }
1533    }
1534
1535    /// Sets the label.
1536    #[must_use]
1537    pub fn with_label(mut self, label: impl Into<String>) -> Self {
1538        self.label = Some(label.into());
1539        self
1540    }
1541
1542    /// Selects the dependencies the group covers.
1543    #[must_use]
1544    pub fn with_dependencies(mut self, dependencies: Selector) -> Self {
1545        self.dependencies = dependencies;
1546        self
1547    }
1548
1549    /// Selects the packages the group covers.
1550    #[must_use]
1551    pub fn with_packages(mut self, packages: Selector) -> Self {
1552        self.packages = packages;
1553        self
1554    }
1555
1556    /// Selects the dependency types the group covers.
1557    #[must_use]
1558    pub fn with_dependency_types(mut self, dependency_types: DependencyTypes) -> Self {
1559        self.dependency_types = dependency_types;
1560        self
1561    }
1562
1563    /// Selects the specifier types the group covers.
1564    #[must_use]
1565    pub fn with_specifier_types<I, S>(mut self, specifier_types: I) -> Self
1566    where
1567        I: IntoIterator<Item = S>,
1568        S: Into<String>,
1569    {
1570        self.specifier_types = specifier_types.into_iter().map(Into::into).collect();
1571        self
1572    }
1573}
1574
1575/// How a [`CustomType`] reads versions from its `path`.
1576#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1577#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1578#[non_exhaustive]
1579pub enum CustomStrategy {
1580    /// The value is a string `name@version` (`"packageManager": "pnpm@9.0.0"`): the dependency is the name.
1581    #[serde(rename = "name@version")]
1582    NameAtVersion,
1583    /// The value is a version string (`engines.node`): the dependency is named after the custom type.
1584    #[serde(rename = "version")]
1585    Version,
1586    /// The value is an object of `name: version` (`pnpm.peerDependencyRules.allowedVersions`).
1587    #[serde(rename = "versions-by-name", alias = "versionsByName")]
1588    VersionsByName,
1589    /// The value is a version string and the dependency name is the string at `name-path` (how syncpack reads a
1590    /// package's own `name` and `version`).
1591    #[serde(rename = "name~version")]
1592    NameTildeVersion,
1593}
1594
1595/// One `[[custom-types]]` entry: a field of `package.json` that holds versions. Name it in a `dependency-types` list to
1596/// select its instances, like a dependency section (syncpack's `customTypes`).
1597#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1598#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1599#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1600#[non_exhaustive]
1601pub struct CustomType {
1602    /// The token that selects this type in `dependency-types`. It must not be the name of a built-in type
1603    /// (`prod`, `dev`, `peer`, `optional`, `local`, `resolutions`, `overrides`, `pnpm-overrides`). With the `version`
1604    /// strategy it is also the dependency name.
1605    pub name: String,
1606    /// Dotted path of the field in `package.json`, e.g. `engines.node` or `packageManager`.
1607    pub path: String,
1608    /// How the value at `path` holds versions.
1609    pub strategy: CustomStrategy,
1610    /// With the `name~version` strategy: dotted path of the field that holds the dependency name.
1611    #[serde(default, skip_serializing_if = "Option::is_none")]
1612    pub name_path: Option<String>,
1613}
1614
1615impl CustomType {
1616    /// A custom type reading `strategy` from `path`.
1617    #[must_use]
1618    pub fn new(name: impl Into<String>, path: impl Into<String>, strategy: CustomStrategy) -> Self {
1619        Self {
1620            name: name.into(),
1621            path: path.into(),
1622            strategy,
1623            name_path: None,
1624        }
1625    }
1626
1627    /// Sets the field that holds the dependency name, for the `name~version` strategy.
1628    #[must_use]
1629    pub fn with_name_path(mut self, name_path: impl Into<String>) -> Self {
1630        self.name_path = Some(name_path.into());
1631        self
1632    }
1633
1634    /// The `name-path` split into its keys; empty when there is none.
1635    pub fn name_segments(&self) -> impl Iterator<Item = &str> {
1636        self.name_path.iter().flat_map(|path| path.split('.'))
1637    }
1638
1639    /// The `path` split into its keys.
1640    pub fn segments(&self) -> impl Iterator<Item = &str> {
1641        self.path.split('.')
1642    }
1643
1644    fn inconsistency(&self) -> Option<String> {
1645        if self.name.is_empty() || self.name.starts_with('!') {
1646            return Some("`name` must not be empty or start with `!`".to_owned());
1647        }
1648        if DependencyType::is_builtin_token(&self.name) {
1649            return Some(format!(
1650                "`name` \"{}\" is the name of a built-in dependency type",
1651                self.name
1652            ));
1653        }
1654        if self.segments().any(str::is_empty) {
1655            return Some(format!(
1656                "`path` \"{}\" must be a dotted path of non-empty keys",
1657                self.path
1658            ));
1659        }
1660        let takes_name_path = self.strategy == CustomStrategy::NameTildeVersion;
1661        match (takes_name_path, self.name_path.is_some()) {
1662            (true, false) => return Some("strategy `name~version` requires `name-path`".to_owned()),
1663            (false, true) => return Some("`name-path` is only valid with strategy `name~version`".to_owned()),
1664            _ => {}
1665        }
1666        if self.name_segments().any(str::is_empty) {
1667            return Some("`name-path` must be a dotted path of non-empty keys".to_owned());
1668        }
1669        None
1670    }
1671}
1672
1673/// `[catalog] mode`
1674#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1675#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1676#[serde(rename_all = "kebab-case")]
1677#[non_exhaustive]
1678pub enum CatalogMode {
1679    /// Catalog rules suggest nothing.
1680    #[default]
1681    Off,
1682    /// Suggest moving shared dependencies into a catalog.
1683    Prefer,
1684    /// Require shared dependencies to come from a catalog.
1685    Enforce,
1686}
1687
1688/// `[catalog]`
1689#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1690#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1691#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
1692#[non_exhaustive]
1693pub struct Catalog {
1694    /// How strongly catalogs are preferred.
1695    pub mode: CatalogMode,
1696    /// Minimum number of packages using a dependency before a catalog entry is suggested.
1697    pub min_packages: usize,
1698    /// Dependencies never suggested for a catalog.
1699    pub exclude: Vec<String>,
1700    /// Report catalog entries no package references.
1701    pub report_unused_entries: bool,
1702    /// Appended to `exclude` instead of replacing it. Folded away when the configuration is resolved.
1703    #[serde(skip_serializing_if = "Vec::is_empty")]
1704    pub extend_exclude: Vec<String>,
1705}
1706
1707impl Default for Catalog {
1708    fn default() -> Self {
1709        Self {
1710            mode: CatalogMode::Off,
1711            min_packages: 2,
1712            exclude: Vec::new(),
1713            report_unused_entries: false,
1714            extend_exclude: Vec::new(),
1715        }
1716    }
1717}
1718
1719impl Catalog {
1720    /// Sets the catalog mode.
1721    #[must_use]
1722    pub const fn with_mode(mut self, mode: CatalogMode) -> Self {
1723        self.mode = mode;
1724        self
1725    }
1726
1727    /// Sets the minimum number of packages using a dependency before an entry is suggested.
1728    #[must_use]
1729    pub const fn with_min_packages(mut self, min_packages: usize) -> Self {
1730        self.min_packages = min_packages;
1731        self
1732    }
1733
1734    /// Sets the dependencies excluded from catalog suggestions.
1735    #[must_use]
1736    pub fn with_exclude<I, S>(mut self, exclude: I) -> Self
1737    where
1738        I: IntoIterator<Item = S>,
1739        S: Into<String>,
1740    {
1741        self.exclude = exclude.into_iter().map(Into::into).collect();
1742        self
1743    }
1744
1745    /// Sets whether unused catalog entries are reported.
1746    #[must_use]
1747    pub const fn with_report_unused_entries(mut self, report_unused_entries: bool) -> Self {
1748        self.report_unused_entries = report_unused_entries;
1749        self
1750    }
1751}
1752
1753/// `[workspace-protocol]`
1754#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
1755#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1756#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
1757#[non_exhaustive]
1758pub struct WorkspaceProtocol {
1759    /// Require `workspace:` for dependencies on other workspace packages.
1760    pub internal: bool,
1761    /// Accept `file:` / `link:` paths for internal dependencies.
1762    pub allow_local_paths: bool,
1763}
1764
1765impl WorkspaceProtocol {
1766    /// Sets whether `workspace:` is required for internal dependencies.
1767    #[must_use]
1768    pub const fn with_internal(mut self, internal: bool) -> Self {
1769        self.internal = internal;
1770        self
1771    }
1772
1773    /// Sets whether `file:` / `link:` paths are accepted for internal dependencies.
1774    #[must_use]
1775    pub const fn with_allow_local_paths(mut self, allow_local_paths: bool) -> Self {
1776        self.allow_local_paths = allow_local_paths;
1777        self
1778    }
1779}
1780
1781/// `[format]`: what the `package-json-format` rule and `skif format` check, with syncpack's defaults (ADR 0009).
1782#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1783#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1784#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
1785#[non_exhaustive]
1786pub struct Format {
1787    /// Top-level keys that come first, in this order.
1788    pub sort_first: Vec<String>,
1789    /// Fields sorted alphabetically one level deep: an object by key, an array of strings by value. `a/b` names a
1790    /// nested field. For fields whose order means nothing: an array with a `!`-prefixed entry keeps its order.
1791    pub sort_az: Vec<String>,
1792    /// Keys that come first, in this order, in every object of `exports`; `default` is always last. The default moves
1793    /// only `types` and `default`; a key that reorders other conditions makes the fix unsafe.
1794    pub sort_exports: Vec<String>,
1795    /// Sort the top-level keys `sort-first` does not name.
1796    pub sort_packages: bool,
1797    /// Write a `bugs` object that holds only `url` as that string.
1798    pub format_bugs: bool,
1799    /// Write a `repository` object that holds only `url` and a `type` of `git` (or no `type`) as its shorthand.
1800    pub format_repository: bool,
1801    /// Check the layout against `JSON.stringify(manifest, null, indent)`: spaces or tabs, for example `"  "` or
1802    /// `"\t"`. Unset keeps each file's layout.
1803    #[serde(skip_serializing_if = "Option::is_none")]
1804    pub indent: Option<String>,
1805}
1806
1807fn strings(items: &[&str]) -> Vec<String> {
1808    items.iter().map(|item| (*item).to_owned()).collect()
1809}
1810
1811impl Default for Format {
1812    fn default() -> Self {
1813        Self {
1814            sort_first: strings(&["name", "description", "version", "author"]),
1815            sort_az: strings(&[
1816                "bin",
1817                "contributors",
1818                "dependencies",
1819                "devDependencies",
1820                "keywords",
1821                "peerDependencies",
1822                "resolutions",
1823                "scripts",
1824            ]),
1825            sort_exports: strings(&["types", "default"]),
1826            sort_packages: true,
1827            format_bugs: false,
1828            format_repository: false,
1829            indent: None,
1830        }
1831    }
1832}
1833
1834impl Format {
1835    /// Sets the keys that come first.
1836    #[must_use]
1837    pub fn with_sort_first<I: IntoIterator<Item = S>, S: Into<String>>(mut self, keys: I) -> Self {
1838        self.sort_first = keys.into_iter().map(Into::into).collect();
1839        self
1840    }
1841
1842    /// Sets the fields sorted alphabetically.
1843    #[must_use]
1844    pub fn with_sort_az<I: IntoIterator<Item = S>, S: Into<String>>(mut self, keys: I) -> Self {
1845        self.sort_az = keys.into_iter().map(Into::into).collect();
1846        self
1847    }
1848
1849    /// Sets the order of the `exports` conditions.
1850    #[must_use]
1851    pub fn with_sort_exports<I: IntoIterator<Item = S>, S: Into<String>>(mut self, keys: I) -> Self {
1852        self.sort_exports = keys.into_iter().map(Into::into).collect();
1853        self
1854    }
1855
1856    /// Sets whether the remaining top-level keys are sorted.
1857    #[must_use]
1858    pub const fn with_sort_packages(mut self, sort: bool) -> Self {
1859        self.sort_packages = sort;
1860        self
1861    }
1862
1863    /// Sets whether `bugs` is collapsed to its URL.
1864    #[must_use]
1865    pub const fn with_format_bugs(mut self, format: bool) -> Self {
1866        self.format_bugs = format;
1867        self
1868    }
1869
1870    /// Sets whether `repository` is collapsed to its shorthand.
1871    #[must_use]
1872    pub const fn with_format_repository(mut self, format: bool) -> Self {
1873        self.format_repository = format;
1874        self
1875    }
1876
1877    /// Sets the indent the layout is checked against.
1878    #[must_use]
1879    pub fn with_indent(mut self, indent: impl Into<String>) -> Self {
1880        self.indent = Some(indent.into());
1881        self
1882    }
1883
1884    /// The indent with a written `\t` read as a tab, as syncpack reads it.
1885    #[must_use]
1886    pub fn indent_text(&self) -> Option<String> {
1887        self.indent.as_ref().map(|indent| indent.replace("\\t", "\t"))
1888    }
1889
1890    fn inconsistency(&self) -> Option<String> {
1891        let indent = self.indent_text()?;
1892        (indent.is_empty() || indent.chars().any(|c| c != ' ' && c != '\t')).then(|| {
1893            format!(
1894                "indent: {:?} is not a run of spaces or tabs",
1895                self.indent.as_deref().unwrap_or_default()
1896            )
1897        })
1898    }
1899}
1900
1901/// Where the members of a dependency family must share a version.
1902#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1903#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1904#[serde(rename_all = "kebab-case")]
1905#[non_exhaustive]
1906pub enum FamilyScope {
1907    /// Within each package, as its manifest declares them.
1908    #[default]
1909    Manifests,
1910    /// Within what each importer of `pnpm-lock.yaml` resolves, transitive packages included.
1911    Importer,
1912    /// Across everything `pnpm-lock.yaml` resolves.
1913    Lockfile,
1914}
1915
1916impl FamilyScope {
1917    /// Whether this is the default, [`Manifests`](Self::Manifests).
1918    #[must_use]
1919    pub const fn is_manifests(&self) -> bool {
1920        matches!(self, Self::Manifests)
1921    }
1922}
1923
1924/// One `[[dependency-families]]` entry: packages that must share a version.
1925#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1926#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1927#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1928#[non_exhaustive]
1929pub struct DependencyFamily {
1930    /// The family name; a built-in family of the same name is replaced.
1931    pub name: String,
1932    /// Package names or globs that must share a version.
1933    #[serde(default)]
1934    pub packages: Vec<String>,
1935    /// `false` disables a built-in family of the same name.
1936    #[serde(default = "default_true")]
1937    pub enabled: bool,
1938    /// `manifests` (default) compares the versions each package declares; `importer` and `lockfile` compare what
1939    /// `pnpm-lock.yaml` resolves, per importer or across the lockfile.
1940    #[serde(default, skip_serializing_if = "FamilyScope::is_manifests")]
1941    pub scope: FamilyScope,
1942    /// With a lockfile scope: a package whose resolved majors each allow one version of every member, so `@types/react`
1943    /// may resolve once per major of `react`.
1944    #[serde(default, skip_serializing_if = "Option::is_none")]
1945    pub anchor: Option<String>,
1946}
1947
1948impl DependencyFamily {
1949    /// An enabled family called `name` whose members are `packages`.
1950    #[must_use]
1951    pub fn new<I, S>(name: impl Into<String>, packages: I) -> Self
1952    where
1953        I: IntoIterator<Item = S>,
1954        S: Into<String>,
1955    {
1956        Self {
1957            name: name.into(),
1958            packages: packages.into_iter().map(Into::into).collect(),
1959            enabled: true,
1960            scope: FamilyScope::Manifests,
1961            anchor: None,
1962        }
1963    }
1964
1965    /// Sets where the members must share a version.
1966    #[must_use]
1967    pub const fn with_scope(mut self, scope: FamilyScope) -> Self {
1968        self.scope = scope;
1969        self
1970    }
1971
1972    /// Sets the anchor package: with a lockfile scope, every member may resolve once per major of it.
1973    #[must_use]
1974    pub fn with_anchor(mut self, anchor: impl Into<String>) -> Self {
1975        self.anchor = Some(anchor.into());
1976        self
1977    }
1978
1979    /// Enables or disables the family; `false` disables a built-in family of the same name.
1980    #[must_use]
1981    pub const fn with_enabled(mut self, enabled: bool) -> Self {
1982        self.enabled = enabled;
1983        self
1984    }
1985}
1986
1987const fn default_true() -> bool {
1988    true
1989}
1990
1991/// `[js] discovery`
1992#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1993#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1994#[serde(rename_all = "lowercase")]
1995#[non_exhaustive]
1996pub enum JsDiscovery {
1997    /// The directory walk. The git index cannot tell whether an unchanged directory holds an untracked file created
1998    /// before the index was last written, so the default does not use it.
1999    #[default]
2000    Auto,
2001    /// The git index, checked against the file system: a directory modified since the index was written is listed
2002    /// from disk, the others from the index. Faster than the walk, but blind to an untracked file created before the
2003    /// last write of the index (a `git add` or `git commit` of another file); `unused-files` then offers no fix that
2004    /// deletes a file. Suits a fresh checkout. The directory walk when there is no usable index.
2005    Git,
2006    /// Always walk the directories.
2007    Walk,
2008}
2009
2010/// `[js.cycles]`: knip's `cycles` options of the `circular-imports` rule.
2011#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
2012#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2013#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
2014#[non_exhaustive]
2015pub struct JsCycles {
2016    /// Cycles that are not reported, each the list of its files relative to the workspace root. A cycle is allowed
2017    /// when its files are exactly the files of one entry, in any order.
2018    pub allow: Vec<Vec<String>>,
2019    /// Count dynamic `import()` as an edge of a cycle. Off by default: a dynamic import runs after the module graph
2020    /// is loaded, so it cannot cause an initialisation-order problem.
2021    pub dynamic_imports: bool,
2022}
2023
2024/// `[js]`
2025#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2026#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2027#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
2028#[non_exhaustive]
2029#[allow(
2030    clippy::struct_excessive_bools,
2031    reason = "each bool is an independent switch of the analysis"
2032)]
2033pub struct Js {
2034    /// Run the JavaScript analysis.
2035    pub enabled: bool,
2036    /// Entry-point globs, added to the ones plugins and `package.json` provide.
2037    pub entry: Vec<String>,
2038    /// Globs of the files that belong to the project.
2039    pub project: Vec<String>,
2040    /// Globs of files that are not analysed.
2041    pub ignore: Vec<String>,
2042    /// Globs of files, relative to the workspace root, never reported by `unused-files`; other rules still analyse
2043    /// them (knip's `ignoreFiles`).
2044    pub ignore_files: Vec<IgnoreItem>,
2045    /// Dependencies never reported as unused or unlisted.
2046    pub ignore_dependencies: Vec<IgnoreItem>,
2047    /// Binaries never reported as unlisted.
2048    pub ignore_binaries: Vec<IgnoreItem>,
2049    /// Never report the system and shell tools scripts run (`rm`, `curl`, `git`, `docker`) as unlisted binaries,
2050    /// since they are installed globally rather than from npm (knip's `ignoreGlobalBinaries`). On by default; turn it
2051    /// off to have `unlisted-binaries` report them unless a dependency provides them.
2052    pub ignore_global_binaries: bool,
2053    /// Import specifiers never reported as unresolved. Entries are exact specifiers, globs or regular expressions
2054    /// (`/^@org\/.+$/`), see the `[js]` documentation.
2055    pub ignore_unresolved: Vec<IgnoreItem>,
2056    /// Workspace directories (globs) the JavaScript analysis skips. Manifest and policy rules still check them.
2057    pub ignore_workspaces: Vec<IgnoreItem>,
2058    /// Exports that other code in the same file uses are not reported: `true` for every kind, or a table of kinds.
2059    pub ignore_exports_used_in_file: IgnoreExportsUsedInFile,
2060    /// Enum and namespace member names (globs or regular expressions) never reported as unused.
2061    pub ignore_members: Vec<IgnoreItem>,
2062    /// Analyse production code only.
2063    pub production: bool,
2064    /// Workspace isolation: each workspace package uses only the dependencies its own manifest lists. An import, a
2065    /// binary or a package a tool config names is not satisfied by a containing package or the root, a private
2066    /// package's unlisted import of a workspace member is reported, and `use-public-hoisting` is ignored. Independent
2067    /// of `production`: outside production mode tests, test setup and tool configs are checked against their own
2068    /// package's manifest, development dependencies included. `skif --isolate-workspaces` turns it on; `strict`
2069    /// implies it.
2070    pub isolate_workspaces: bool,
2071    /// knip's strict mode (`skif --strict`): `production` and `isolate-workspaces`, and only direct dependencies
2072    /// count. Production code must import from `dependencies` or `peerDependencies`, a peer dependency of a listed
2073    /// package does not stand in for a listing, type-only imports use no package, and required peer dependencies are
2074    /// checked like `dependencies`.
2075    pub strict: bool,
2076    /// Find source files that `.gitignore` files hide (the git index is not used then). Set by
2077    /// `skif --no-gitignore`; not a configuration key.
2078    #[serde(skip)]
2079    #[cfg_attr(feature = "schema", schemars(skip))]
2080    pub no_gitignore: bool,
2081    /// Report unused exports of entry files too.
2082    pub include_entry_exports: bool,
2083    /// The TypeScript configuration of the root package, relative to the workspace root and inside it, used instead
2084    /// of `tsconfig.json` for its path aliases and compiler options (knip's `--tsConfig`). Other packages keep their
2085    /// own `tsconfig.json`.
2086    #[serde(skip_serializing_if = "Option::is_none")]
2087    pub tsconfig: Option<String>,
2088    /// The project files of a package with a TypeScript configuration are the files its `files` names and those its
2089    /// `include` selects minus its `exclude`, instead of the `project` globs (knip's `--use-tsconfig-files`).
2090    pub use_tsconfig_files: bool,
2091    /// Report files in `fixtures` and `__fixtures__` directories as unused, and their unused exports, types and
2092    /// members, like any other file. Off by default: those directories hold test data that tests load by path.
2093    pub include_fixtures: bool,
2094    /// Count a package that the install hoists publicly into a `node_modules` above a package as available to it,
2095    /// so importing it without listing it is not an unlisted dependency: pnpm's `public-hoist-pattern` and
2096    /// `shamefully-hoist` (from `pnpm-workspace.yaml`, or the root `.npmrc` before pnpm 11), and `rules_js`'s
2097    /// `public_hoist_packages`. Off by default, as in knip; strict mode ignores it.
2098    pub use_public_hoisting: bool,
2099    /// How source files are found: `auto` (the default) and `walk` walk the directories; `git` lists them from the
2100    /// git index and re-lists from disk every directory modified since the index was written (faster, blind to an
2101    /// untracked file older than the last write of the index). `git` falls back to the walk when the workspace is not
2102    /// in a git work tree with a usable SHA-1 index. The toolchain scan and the Bazel `BUILD` file search of
2103    /// `stray-lockfile` list directories the same way.
2104    pub discovery: JsDiscovery,
2105    /// What `circular-imports` reports.
2106    pub cycles: JsCycles,
2107    /// Per-workspace overrides keyed by directory. The key `"."` is the root workspace.
2108    pub workspaces: IndexMap<String, JsWorkspace>,
2109    /// Plugin switches keyed by plugin name.
2110    pub plugins: IndexMap<String, PluginConfig>,
2111    /// How to read the files of extensions skif has no reader for, keyed by the extension without its dot (`html`):
2112    /// the static counterpart of knip's `compilers`, which never runs project code. Such files join the default
2113    /// project, and the imports read from them count as usage.
2114    #[serde(skip_serializing_if = "IndexMap::is_empty")]
2115    pub compilers: IndexMap<String, JsCompiler>,
2116    /// Appended to `ignore` instead of replacing it. Folded away when the configuration is resolved.
2117    #[serde(skip_serializing_if = "Vec::is_empty")]
2118    pub extend_ignore: Vec<String>,
2119    /// Appended to `ignore-dependencies` instead of replacing it. Folded away when the configuration is resolved.
2120    #[serde(skip_serializing_if = "Vec::is_empty")]
2121    pub extend_ignore_dependencies: Vec<IgnoreItem>,
2122    /// Appended to `ignore-binaries` instead of replacing it. Folded away when the configuration is resolved.
2123    #[serde(skip_serializing_if = "Vec::is_empty")]
2124    pub extend_ignore_binaries: Vec<IgnoreItem>,
2125    /// Appended to `ignore-unresolved` instead of replacing it. Folded away when the configuration is resolved.
2126    #[serde(skip_serializing_if = "Vec::is_empty")]
2127    pub extend_ignore_unresolved: Vec<IgnoreItem>,
2128}
2129
2130impl Js {
2131    /// The ignore lists under their keys, in the order the keys are documented.
2132    pub(crate) fn ignore_lists(&self) -> [(&'static str, &[IgnoreItem]); 9] {
2133        [
2134            ("ignore-files", self.ignore_files.as_slice()),
2135            ("ignore-dependencies", self.ignore_dependencies.as_slice()),
2136            ("ignore-binaries", self.ignore_binaries.as_slice()),
2137            ("ignore-unresolved", self.ignore_unresolved.as_slice()),
2138            ("ignore-workspaces", self.ignore_workspaces.as_slice()),
2139            ("ignore-members", self.ignore_members.as_slice()),
2140            ("extend-ignore-dependencies", self.extend_ignore_dependencies.as_slice()),
2141            ("extend-ignore-binaries", self.extend_ignore_binaries.as_slice()),
2142            ("extend-ignore-unresolved", self.extend_ignore_unresolved.as_slice()),
2143        ]
2144    }
2145
2146    /// Sets the entry globs.
2147    #[must_use]
2148    pub fn with_entry<I, S>(mut self, entry: I) -> Self
2149    where
2150        I: IntoIterator<Item = S>,
2151        S: Into<String>,
2152    {
2153        self.entry = entry.into_iter().map(Into::into).collect();
2154        self
2155    }
2156
2157    /// Sets the project globs.
2158    #[must_use]
2159    pub fn with_project<I, S>(mut self, project: I) -> Self
2160    where
2161        I: IntoIterator<Item = S>,
2162        S: Into<String>,
2163    {
2164        self.project = project.into_iter().map(Into::into).collect();
2165        self
2166    }
2167
2168    /// Sets `ignore-dependencies`, from names or [`IgnoreItem`]s with a reason.
2169    #[must_use]
2170    pub fn with_ignore_dependencies<I, S>(mut self, ignore_dependencies: I) -> Self
2171    where
2172        I: IntoIterator<Item = S>,
2173        S: Into<IgnoreItem>,
2174    {
2175        self.ignore_dependencies = ignore_dependencies.into_iter().map(Into::into).collect();
2176        self
2177    }
2178
2179    /// Enables or disables the JavaScript analysis.
2180    #[must_use]
2181    pub const fn with_enabled(mut self, enabled: bool) -> Self {
2182        self.enabled = enabled;
2183        self
2184    }
2185
2186    /// Restricts the analysis to production code.
2187    #[must_use]
2188    pub const fn with_production(mut self, production: bool) -> Self {
2189        self.production = production;
2190        self
2191    }
2192
2193    /// Makes each workspace package use only the dependencies its own manifest lists (`isolate-workspaces`).
2194    #[must_use]
2195    pub const fn with_isolate_workspaces(mut self, isolate: bool) -> Self {
2196        self.isolate_workspaces = isolate;
2197        self
2198    }
2199
2200    /// Turns on knip's strict mode (`strict`), which implies production mode and workspace isolation.
2201    #[must_use]
2202    pub const fn with_strict(mut self, strict: bool) -> Self {
2203        self.strict = strict;
2204        self
2205    }
2206
2207    /// Whether production code only is analysed: `production`, or `strict`, which implies it.
2208    #[must_use]
2209    pub const fn production_mode(&self) -> bool {
2210        self.production || self.strict
2211    }
2212
2213    /// Whether each workspace package uses only its own manifest: `isolate-workspaces`, or `strict`, which implies it.
2214    #[must_use]
2215    pub const fn isolated(&self) -> bool {
2216        self.isolate_workspaces || self.strict
2217    }
2218
2219    /// Uses `path`, relative to the workspace root, as the TypeScript configuration of the root package.
2220    #[must_use]
2221    pub fn with_tsconfig(mut self, path: impl Into<String>) -> Self {
2222        self.tsconfig = Some(path.into());
2223        self
2224    }
2225
2226    /// Takes the project files of each package with a TypeScript configuration from its `files`, `include` and
2227    /// `exclude`.
2228    #[must_use]
2229    pub const fn with_use_tsconfig_files(mut self, enabled: bool) -> Self {
2230        self.use_tsconfig_files = enabled;
2231        self
2232    }
2233
2234    /// Counts packages the install hoists publicly as available to every package below the hoist
2235    /// (`use-public-hoisting`).
2236    #[must_use]
2237    pub const fn with_use_public_hoisting(mut self, enabled: bool) -> Self {
2238        self.use_public_hoisting = enabled;
2239        self
2240    }
2241
2242    /// Switches a plugin on or off, or configures it.
2243    #[must_use]
2244    pub fn with_plugin(mut self, name: impl Into<String>, plugin: PluginConfig) -> Self {
2245        self.plugins.insert(name.into(), plugin);
2246        self
2247    }
2248}
2249
2250impl Default for Js {
2251    fn default() -> Self {
2252        Self {
2253            enabled: true,
2254            entry: Vec::new(),
2255            project: Vec::new(),
2256            ignore: Vec::new(),
2257            ignore_files: Vec::new(),
2258            ignore_dependencies: Vec::new(),
2259            ignore_binaries: Vec::new(),
2260            ignore_global_binaries: true,
2261            ignore_unresolved: Vec::new(),
2262            ignore_workspaces: Vec::new(),
2263            ignore_exports_used_in_file: IgnoreExportsUsedInFile::default(),
2264            ignore_members: Vec::new(),
2265            production: false,
2266            isolate_workspaces: false,
2267            strict: false,
2268            no_gitignore: false,
2269            include_entry_exports: false,
2270            tsconfig: None,
2271            use_tsconfig_files: false,
2272            include_fixtures: false,
2273            use_public_hoisting: false,
2274            discovery: JsDiscovery::Auto,
2275            cycles: JsCycles::default(),
2276            workspaces: IndexMap::new(),
2277            plugins: IndexMap::new(),
2278            compilers: IndexMap::new(),
2279            extend_ignore: Vec::new(),
2280            extend_ignore_dependencies: Vec::new(),
2281            extend_ignore_binaries: Vec::new(),
2282            extend_ignore_unresolved: Vec::new(),
2283        }
2284    }
2285}
2286
2287/// One `[js.compilers]` entry: how the files of one extension are read. Exactly one of `as` and `imports` is set.
2288#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
2289#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2290#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
2291#[non_exhaustive]
2292pub struct JsCompiler {
2293    /// Read the files as if they had this extension, with the reader skif has for it (`as = "mdx"`): one of
2294    /// [`JsCompiler::READERS`].
2295    #[serde(rename = "as", skip_serializing_if = "Option::is_none")]
2296    pub reader: Option<String>,
2297    /// Regular expressions with exactly one capture group, matched against the whole text of a file: every match
2298    /// captures an import specifier, resolved as an import written in that file.
2299    #[serde(skip_serializing_if = "Option::is_none")]
2300    pub imports: Option<Vec<String>>,
2301}
2302
2303impl JsCompiler {
2304    /// The extensions skif reads itself, sorted: the possible values of `as`, and the extensions a compiler cannot be
2305    /// declared for.
2306    pub const READERS: &'static [&'static str] = &[
2307        "astro", "cjs", "css", "cts", "js", "jsx", "less", "marko", "mdx", "mjs", "mts", "pcss", "sass", "scss",
2308        "styl", "stylus", "svelte", "ts", "tsrx", "tsx", "vue",
2309    ];
2310
2311    /// Reads the files with the reader of `extension` (`as`).
2312    #[must_use]
2313    pub fn reading(extension: impl Into<String>) -> Self {
2314        Self {
2315            reader: Some(extension.into()),
2316            imports: None,
2317        }
2318    }
2319
2320    /// Takes the imports of the files from the captures of `patterns` (`imports`).
2321    #[must_use]
2322    pub fn matching<I, S>(patterns: I) -> Self
2323    where
2324        I: IntoIterator<Item = S>,
2325        S: Into<String>,
2326    {
2327        Self {
2328            reader: None,
2329            imports: Some(patterns.into_iter().map(Into::into).collect()),
2330        }
2331    }
2332
2333    /// The first problem of the entry `key` declares for `extension`, as a message naming its key.
2334    fn problem(&self, key: &str, extension: &str) -> Option<String> {
2335        let written = !extension.is_empty()
2336            && extension
2337                .bytes()
2338                .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' || byte == b'_');
2339        if !written {
2340            return Some(format!(
2341                "{key}: write the extension in lowercase without the leading dot (`html`, not `.html`)"
2342            ));
2343        }
2344        if Self::READERS.contains(&extension) {
2345            return Some(format!(
2346                "{key}: skif reads `.{extension}` files itself; remove the entry"
2347            ));
2348        }
2349        match (self.reader.as_deref(), self.imports.as_deref()) {
2350            (Some(reader), None) => (!Self::READERS.contains(&reader)).then(|| {
2351                let hint =
2352                    closest(reader, Self::READERS).map_or_else(String::new, |s| format!("; did you mean \"{s}\"?"));
2353                format!(
2354                    "{key}: unknown reader `{reader}`{hint} (expected one of {})",
2355                    Self::READERS.join(", ")
2356                )
2357            }),
2358            (None, Some([])) => Some(format!("{key}.imports: list at least one regular expression")),
2359            (None, Some(patterns)) => patterns.iter().enumerate().find_map(|(index, pattern)| {
2360                pattern_problem(pattern).map(|message| format!("{key}.imports[{index}]: {message}"))
2361            }),
2362            _ => Some(format!("{key}: set exactly one of `as` and `imports`")),
2363        }
2364    }
2365}
2366
2367/// Why `pattern` cannot be an `imports` pattern: it does not compile, or it has not exactly one capture group.
2368fn pattern_problem(pattern: &str) -> Option<String> {
2369    match regex::Regex::new(pattern) {
2370        Err(error) => Some(format!("not a valid regular expression: {error}")),
2371        Ok(regex) => {
2372            let groups = regex.captures_len().saturating_sub(1);
2373            (groups != 1)
2374                .then(|| format!("has {groups} capture groups; it needs exactly one, which captures the specifier"))
2375        }
2376    }
2377}
2378
2379/// `ignore-exports-used-in-file`: `true`, or a table naming the kinds of export it applies to.
2380///
2381/// It is read by hand rather than as an untagged enum, so that a mistyped kind is reported by name (`unknown field
2382/// "fucntion"`) instead of serde's "data did not match any variant".
2383#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2384#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2385#[serde(untagged)]
2386#[non_exhaustive]
2387pub enum IgnoreExportsUsedInFile {
2388    /// `true` ignores every kind of export, `false` none.
2389    All(bool),
2390    /// Ignores the kinds set to `true`.
2391    Kinds(ExportKinds),
2392}
2393
2394impl<'de> Deserialize<'de> for IgnoreExportsUsedInFile {
2395    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2396        struct Visitor;
2397
2398        impl<'de> serde::de::Visitor<'de> for Visitor {
2399            type Value = IgnoreExportsUsedInFile;
2400
2401            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2402                f.write_str("`true`, `false` or a table of export kinds")
2403            }
2404
2405            fn visit_bool<E: serde::de::Error>(self, value: bool) -> Result<Self::Value, E> {
2406                Ok(IgnoreExportsUsedInFile::All(value))
2407            }
2408
2409            fn visit_map<A: serde::de::MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
2410                ExportKinds::deserialize(serde::de::value::MapAccessDeserializer::new(map))
2411                    .map(IgnoreExportsUsedInFile::Kinds)
2412            }
2413        }
2414
2415        deserializer.deserialize_any(Visitor)
2416    }
2417}
2418
2419impl Default for IgnoreExportsUsedInFile {
2420    fn default() -> Self {
2421        Self::All(false)
2422    }
2423}
2424
2425/// The kinds of export `ignore-exports-used-in-file` can name, as in knip.
2426#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
2427#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2428#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
2429#[non_exhaustive]
2430#[allow(clippy::struct_excessive_bools)]
2431pub struct ExportKinds {
2432    /// `const`, `let` and `var` exports.
2433    pub variable: bool,
2434    /// `function` exports.
2435    pub function: bool,
2436    /// `class` exports.
2437    pub class: bool,
2438    /// `enum` exports.
2439    #[serde(rename = "enum")]
2440    pub enumeration: bool,
2441    /// TypeScript `namespace` exports.
2442    pub namespace: bool,
2443    /// Type alias exports. skif does not tell type aliases from interfaces, so this and `interface` are the same.
2444    #[serde(rename = "type")]
2445    pub type_alias: bool,
2446    /// Interface exports, see `type`.
2447    pub interface: bool,
2448    /// Enum and namespace members. skif always counts a member read in its own file as used, so this key is
2449    /// accepted for compatibility and has no further effect.
2450    pub member: bool,
2451}
2452
2453/// `[js.workspaces."<dir>"]`
2454#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
2455#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2456#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
2457#[non_exhaustive]
2458pub struct JsWorkspace {
2459    /// Entry-point globs relative to the workspace directory.
2460    pub entry: Vec<String>,
2461    /// Project file globs relative to the workspace directory.
2462    pub project: Vec<String>,
2463    /// Globs of files that are not analysed.
2464    pub ignore: Vec<String>,
2465    /// Globs of files, relative to the workspace directory, never reported by `unused-files`.
2466    pub ignore_files: Vec<IgnoreItem>,
2467    /// Dependencies never reported as unused or unlisted.
2468    pub ignore_dependencies: Vec<IgnoreItem>,
2469    /// Binaries never reported as unlisted.
2470    pub ignore_binaries: Vec<IgnoreItem>,
2471    /// Import specifiers never reported as unresolved in this workspace.
2472    pub ignore_unresolved: Vec<IgnoreItem>,
2473    /// Report unused exports of entry files of this workspace; unset inherits `[js] include-entry-exports`.
2474    #[serde(skip_serializing_if = "Option::is_none")]
2475    pub include_entry_exports: Option<bool>,
2476    /// Exports used in their own file that are not reported in this workspace; unset inherits `[js]`.
2477    #[serde(skip_serializing_if = "Option::is_none")]
2478    pub ignore_exports_used_in_file: Option<IgnoreExportsUsedInFile>,
2479    /// Enum and namespace member names never reported as unused in this workspace.
2480    pub ignore_members: Vec<IgnoreItem>,
2481    /// Module aliases as in a `tsconfig` `paths` map: `{ "alias/*" = ["./target/*"] }`, targets relative to the workspace.
2482    pub paths: IndexMap<String, Vec<String>>,
2483    /// Plugin switches and options for this workspace, applied over `[js.plugins]`.
2484    pub plugins: IndexMap<String, PluginConfig>,
2485    /// Appended to `ignore` instead of replacing it. Folded away when the configuration is resolved.
2486    #[serde(skip_serializing_if = "Vec::is_empty")]
2487    pub extend_ignore: Vec<String>,
2488    /// Appended to `ignore-dependencies` instead of replacing it. Folded away when the configuration is resolved.
2489    #[serde(skip_serializing_if = "Vec::is_empty")]
2490    pub extend_ignore_dependencies: Vec<IgnoreItem>,
2491    /// Appended to `ignore-binaries` instead of replacing it. Folded away when the configuration is resolved.
2492    #[serde(skip_serializing_if = "Vec::is_empty")]
2493    pub extend_ignore_binaries: Vec<IgnoreItem>,
2494    /// Appended to `ignore-unresolved` instead of replacing it. Folded away when the configuration is resolved.
2495    #[serde(skip_serializing_if = "Vec::is_empty")]
2496    pub extend_ignore_unresolved: Vec<IgnoreItem>,
2497}
2498
2499impl JsWorkspace {
2500    /// The ignore lists under their keys.
2501    pub(crate) fn ignore_lists(&self) -> [(&'static str, &[IgnoreItem]); 8] {
2502        [
2503            ("ignore-files", self.ignore_files.as_slice()),
2504            ("ignore-dependencies", self.ignore_dependencies.as_slice()),
2505            ("ignore-binaries", self.ignore_binaries.as_slice()),
2506            ("ignore-unresolved", self.ignore_unresolved.as_slice()),
2507            ("ignore-members", self.ignore_members.as_slice()),
2508            ("extend-ignore-dependencies", self.extend_ignore_dependencies.as_slice()),
2509            ("extend-ignore-binaries", self.extend_ignore_binaries.as_slice()),
2510            ("extend-ignore-unresolved", self.extend_ignore_unresolved.as_slice()),
2511        ]
2512    }
2513}
2514
2515/// `[js.plugins] <name> = true | { ... }`
2516#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2517#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2518#[serde(untagged)]
2519#[non_exhaustive]
2520pub enum PluginConfig {
2521    /// A bare switch: `true` forces the plugin on, `false` off.
2522    Enabled(bool),
2523    /// The table form with options.
2524    Options(PluginOptions),
2525}
2526
2527/// Table form of a plugin entry.
2528#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
2529#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2530#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
2531#[non_exhaustive]
2532pub struct PluginOptions {
2533    /// Forces the plugin on or off; unset keeps its detection.
2534    #[serde(skip_serializing_if = "Option::is_none")]
2535    pub enabled: Option<bool>,
2536    /// Config-file globs the plugin reads.
2537    pub config: Vec<String>,
2538    /// Entry-point globs the plugin contributes.
2539    pub entry: Vec<String>,
2540    /// Project file globs the plugin contributes.
2541    pub project: Vec<String>,
2542}
2543
2544impl PluginOptions {
2545    /// Options with nothing set.
2546    #[must_use]
2547    pub fn new() -> Self {
2548        Self::default()
2549    }
2550
2551    /// Switches the plugin on or off explicitly.
2552    #[must_use]
2553    pub const fn with_enabled(mut self, enabled: bool) -> Self {
2554        self.enabled = Some(enabled);
2555        self
2556    }
2557
2558    /// Sets the config-file globs.
2559    #[must_use]
2560    pub fn with_config<I, S>(mut self, config: I) -> Self
2561    where
2562        I: IntoIterator<Item = S>,
2563        S: Into<String>,
2564    {
2565        self.config = config.into_iter().map(Into::into).collect();
2566        self
2567    }
2568
2569    /// Sets the entry globs.
2570    #[must_use]
2571    pub fn with_entry<I, S>(mut self, entry: I) -> Self
2572    where
2573        I: IntoIterator<Item = S>,
2574        S: Into<String>,
2575    {
2576        self.entry = entry.into_iter().map(Into::into).collect();
2577        self
2578    }
2579
2580    /// Sets the project globs.
2581    #[must_use]
2582    pub fn with_project<I, S>(mut self, project: I) -> Self
2583    where
2584        I: IntoIterator<Item = S>,
2585        S: Into<String>,
2586    {
2587        self.project = project.into_iter().map(Into::into).collect();
2588        self
2589    }
2590}
2591
2592/// `[js]` keys an override can set for the packages it selects.
2593///
2594/// Globs are relative to each selected package. The `extend-*` keys append to what the package inherits; the others
2595/// replace it, and set to an empty list they clear it.
2596#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
2597#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2598#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
2599#[non_exhaustive]
2600pub struct PackageJs {
2601    /// Replaces the entry globs; an empty list clears what the package inherits.
2602    #[serde(skip_serializing_if = "Option::is_none")]
2603    pub entry: Option<Vec<String>>,
2604    /// Replaces the project globs; an empty list clears what the package inherits.
2605    #[serde(skip_serializing_if = "Option::is_none")]
2606    pub project: Option<Vec<String>>,
2607    /// Replaces the ignore globs; an empty list clears what the package inherits.
2608    #[serde(skip_serializing_if = "Option::is_none")]
2609    pub ignore: Option<Vec<String>>,
2610    /// Replaces the ignored dependencies; an empty list clears what the package inherits.
2611    #[serde(skip_serializing_if = "Option::is_none")]
2612    pub ignore_dependencies: Option<Vec<IgnoreItem>>,
2613    /// Replaces the ignored binaries; an empty list clears what the package inherits.
2614    #[serde(skip_serializing_if = "Option::is_none")]
2615    pub ignore_binaries: Option<Vec<IgnoreItem>>,
2616    /// Appended to the ignore globs.
2617    #[serde(skip_serializing_if = "Vec::is_empty")]
2618    pub extend_ignore: Vec<String>,
2619    /// Appended to the ignored dependencies.
2620    #[serde(skip_serializing_if = "Vec::is_empty")]
2621    pub extend_ignore_dependencies: Vec<IgnoreItem>,
2622    /// Appended to the ignored binaries.
2623    #[serde(skip_serializing_if = "Vec::is_empty")]
2624    pub extend_ignore_binaries: Vec<IgnoreItem>,
2625    /// Replaces the ignored unresolved specifiers; an empty list clears what the package inherits.
2626    #[serde(skip_serializing_if = "Option::is_none")]
2627    pub ignore_unresolved: Option<Vec<IgnoreItem>>,
2628    /// Appended to the ignored unresolved specifiers.
2629    #[serde(skip_serializing_if = "Vec::is_empty")]
2630    pub extend_ignore_unresolved: Vec<IgnoreItem>,
2631    /// Plugin switches and options for the selected packages.
2632    #[serde(skip_serializing_if = "IndexMap::is_empty")]
2633    pub plugins: IndexMap<String, PluginConfig>,
2634}
2635
2636impl PackageJs {
2637    /// The ignore lists under their keys; a key that is not set is an empty list.
2638    pub(crate) fn ignore_lists(&self) -> [(&'static str, &[IgnoreItem]); 6] {
2639        [
2640            (
2641                "ignore-dependencies",
2642                self.ignore_dependencies.as_deref().unwrap_or_default(),
2643            ),
2644            ("ignore-binaries", self.ignore_binaries.as_deref().unwrap_or_default()),
2645            (
2646                "ignore-unresolved",
2647                self.ignore_unresolved.as_deref().unwrap_or_default(),
2648            ),
2649            ("extend-ignore-dependencies", self.extend_ignore_dependencies.as_slice()),
2650            ("extend-ignore-binaries", self.extend_ignore_binaries.as_slice()),
2651            ("extend-ignore-unresolved", self.extend_ignore_unresolved.as_slice()),
2652        ]
2653    }
2654
2655    /// Whether no key is set.
2656    #[must_use]
2657    pub fn is_empty(&self) -> bool {
2658        self == &Self::default()
2659    }
2660}
2661
2662/// One `[[overrides]]` entry: settings for the packages and files it selects.
2663///
2664/// Patterns are anchored at the directory of the file that declares them: a package pattern starting with `./` is a
2665/// directory below it, any other is a package name (or a `$LOCAL`-style token); a file pattern is always a path
2666/// below it. A `$ROOT/` prefix anchors a pattern at the root being linted instead, and a path that would leave the
2667/// anchor never matches.
2668///
2669/// Only package-scoped keys are allowed. An override never changes what a workspace-wide analysis computes; it
2670/// changes how the diagnostics located in the selected packages or files are reported, and the JavaScript inputs of
2671/// the selected packages.
2672#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
2673#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2674#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
2675#[non_exhaustive]
2676pub struct Override {
2677    /// Package names, or directories (`./apps/old/**`). At least one of `packages` and `files` must be set.
2678    #[serde(skip_serializing_if = "Selector::is_empty")]
2679    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
2680    pub packages: Selector,
2681    /// File globs. When both lists are set, a diagnostic must match both.
2682    #[serde(skip_serializing_if = "Selector::is_empty")]
2683    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
2684    pub files: Selector,
2685    /// Caps the severity of every matched diagnostic.
2686    #[serde(skip_serializing_if = "Option::is_none")]
2687    pub max_level: Option<Level>,
2688    /// Rule levels; options are root-scoped and not allowed here.
2689    #[serde(with = "rules_map", skip_serializing_if = "IndexMap::is_empty")]
2690    #[cfg_attr(feature = "schema", schemars(with = "IndexMap<String, RuleConfigSchema>"))]
2691    pub rules: IndexMap<String, RuleConfig>,
2692    /// Suppressions limited to the selected packages and files; an entry may omit every filter.
2693    #[serde(skip_serializing_if = "Vec::is_empty")]
2694    pub ignore: Vec<IgnoreEntry>,
2695    /// JavaScript inputs for the selected packages; needs `packages` and no `files`.
2696    #[serde(skip_serializing_if = "PackageJs::is_empty")]
2697    pub js: PackageJs,
2698    /// Where the override was declared. Set on a resolved configuration only.
2699    #[serde(skip)]
2700    pub scope: Option<OverrideScope>,
2701}
2702
2703impl Override {
2704    pub(crate) fn inconsistency(&self) -> Option<&'static str> {
2705        if self.packages.is_empty() && self.files.is_empty() {
2706            return Some(
2707                "set at least one of `packages` and `files`; an override without a selector would apply everywhere",
2708            );
2709        }
2710        if !self.js.is_empty() && (self.packages.is_empty() || !self.files.is_empty()) {
2711            return Some("`js` needs `packages` and cannot be combined with `files`");
2712        }
2713        if self.rules.values().any(|rule| !rule.options.is_empty()) {
2714            return Some("rule options are root-scoped; an override can only set rule levels");
2715        }
2716        None
2717    }
2718
2719    /// Whether this override covers a diagnostic located in `package` (its name and absolute directory) and/or in
2720    /// `file` (absolute). When both selectors are set both must match; a selector that is set but has nothing to
2721    /// match against (a diagnostic without a package or file) does not match.
2722    ///
2723    /// False for an override without a [`scope`](Self::scope), which only a resolved configuration has.
2724    #[must_use]
2725    pub fn applies_to(&self, package: Option<(&str, &Path)>, file: Option<&Path>) -> bool {
2726        self.scope.as_ref().is_some_and(|scope| scope.applies_to(package, file))
2727    }
2728
2729    /// As [`applies_to`](Self::applies_to) for directories and files already written with `/` separators (see
2730    /// `skif_core::slash`), which a caller checking many overrides converts once instead of once per override.
2731    #[must_use]
2732    pub fn applies_to_slashed(&self, package: Option<(&str, &str)>, file: Option<&str>) -> bool {
2733        self.scope
2734            .as_ref()
2735            .is_some_and(|scope| scope.applies_to_slashed(package, file))
2736    }
2737
2738    /// The names and `/`-separated absolute directories this override covers, when it selects packages only and by
2739    /// literal patterns only: it then [applies to](Self::applies_to) a package exactly when the
2740    /// [`Selector::literal_key`] of its name or directory is one of them. `None` for any other override.
2741    #[must_use]
2742    pub fn literal_packages(&self) -> Option<impl Iterator<Item = &str>> {
2743        self.scope.as_ref().and_then(OverrideScope::literal_packages)
2744    }
2745}
2746
2747/// Where an [`Override`] was declared, with its patterns resolved to absolute ones.
2748#[derive(Debug, Clone, PartialEq)]
2749#[non_exhaustive]
2750pub struct OverrideScope {
2751    /// The directory of the declaring file.
2752    pub declared_in: PathBuf,
2753    /// Index of the declaring file in the resolved configuration's sources.
2754    pub source: usize,
2755    has_packages: bool,
2756    has_files: bool,
2757    packages: Selector,
2758    files: Selector,
2759    unanchored: Vec<String>,
2760}
2761
2762impl OverrideScope {
2763    /// Anchors the patterns of `scoped`, declared by the file `source` in `declared_in`, for the root `root`.
2764    ///
2765    /// # Errors
2766    /// [`ConfigError::Invalid`] when an anchored pattern is not a valid glob.
2767    pub fn new(scoped: &Override, declared_in: &Path, source: usize, root: &Path) -> Result<Self, ConfigError> {
2768        Ok(Self {
2769            declared_in: declared_in.to_path_buf(),
2770            source,
2771            has_packages: !scoped.packages.is_empty(),
2772            has_files: !scoped.files.is_empty(),
2773            packages: anchor_patterns(scoped.packages.patterns(), declared_in, root, false)?,
2774            files: anchor_patterns(scoped.files.patterns(), declared_in, root, true)?,
2775            unanchored: unanchored(scoped.packages.patterns(), false)
2776                .into_iter()
2777                .chain(unanchored(scoped.files.patterns(), true))
2778                .collect(),
2779        })
2780    }
2781
2782    /// The `packages` and `files` patterns, as written, that leave the directory they are anchored at (`../packages/*`)
2783    /// and so match nothing; a host warns about them.
2784    #[must_use]
2785    pub fn unanchored(&self) -> &[String] {
2786        &self.unanchored
2787    }
2788
2789    fn literal_packages(&self) -> Option<impl Iterator<Item = &str>> {
2790        if !self.has_packages || self.has_files {
2791            return None;
2792        }
2793        self.packages.only_literals()
2794    }
2795
2796    fn applies_to(&self, package: Option<(&str, &Path)>, file: Option<&Path>) -> bool {
2797        let package = package.map(|(name, dir)| (name, skif_core::slash(dir)));
2798        let file = file.map(skif_core::slash);
2799        self.applies_to_slashed(
2800            package.as_ref().map(|(name, dir)| (*name, dir.as_str())),
2801            file.as_deref(),
2802        )
2803    }
2804
2805    fn applies_to_slashed(&self, package: Option<(&str, &str)>, file: Option<&str>) -> bool {
2806        let package_ok =
2807            !self.has_packages || package.is_some_and(|(name, dir)| self.packages.matches_any([name, dir], true));
2808        let file_ok = !self.has_files || file.is_some_and(|file| self.files.matches(file, false));
2809        package_ok && file_ok
2810    }
2811}