#![forbid(clippy::indexing_slicing)]
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use monochange_core::BumpSeverity;
use monochange_core::MonochangeError;
use monochange_core::MonochangeResult;
use monochange_core::WorkspaceConfiguration;
use monochange_core::lint::LintCategory;
use monochange_core::lint::LintContext;
use monochange_core::lint::LintLocation;
use monochange_core::lint::LintMaturity;
use monochange_core::lint::LintPreset;
use monochange_core::lint::LintResult;
use monochange_core::lint::LintRule;
use monochange_core::lint::LintRuleConfig;
use monochange_core::lint::LintRuleRunner;
use monochange_core::lint::LintSeverity;
use monochange_core::lint::LintSuite;
use monochange_core::lint::LintTarget;
use monochange_core::lint::LintTargetMetadata;
use crate::RawChangeEntry;
use crate::parse_bump_severity;
#[must_use]
pub fn lint_suite() -> ChangesetLintSuite {
ChangesetLintSuite::new()
}
#[derive(Debug, Clone)]
pub struct ChangesetLintFile {
pub(crate) body: String,
pub(crate) changes: Vec<RawChangeEntry>,
}
#[derive(Debug, Clone, Default)]
pub struct ChangesetLintSuite;
impl ChangesetLintSuite {
#[must_use]
pub fn new() -> Self {
Self
}
}
impl LintSuite for ChangesetLintSuite {
fn suite_id(&self) -> &'static str {
"changesets"
}
fn rules(&self) -> Vec<Box<dyn LintRuleRunner>> {
vec![
Box::new(SummaryRule::new()),
Box::new(NoSectionHeadingsRule::new()),
Box::new(BumpScopeRule::new(BumpSeverity::None)),
Box::new(BumpScopeRule::new(BumpSeverity::Patch)),
Box::new(BumpScopeRule::new(BumpSeverity::Minor)),
Box::new(BumpScopeRule::new(BumpSeverity::Major)),
]
}
fn presets(&self) -> Vec<LintPreset> {
vec![
LintPreset::new(
"changesets/recommended",
"Changesets recommended",
"Balanced changeset linting for typical monochange repositories",
LintMaturity::Stable,
)
.with_rules(BTreeMap::from([(
"changesets/summary".to_string(),
LintRuleConfig::Severity(LintSeverity::Error),
)])),
]
}
fn collect_targets(
&self,
workspace_root: &Path,
_configuration: &WorkspaceConfiguration,
) -> MonochangeResult<Vec<LintTarget>> {
let changeset_dir = workspace_root.join(".changeset");
if !changeset_dir.exists() {
return Ok(Vec::new());
}
let mut targets = Vec::new();
for entry in fs::read_dir(&changeset_dir)
.map_err(|error| {
MonochangeError::Io(format!("failed to read changeset directory: {error}"))
})?
.filter_map(Result::ok)
{
let path = entry.path();
let Some(ext) = path.extension() else {
continue;
};
if ext != "md" {
continue;
}
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !Path::new(file_name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
|| file_name == "README.md"
{
continue;
}
let contents = fs::read_to_string(&path).map_err(|error| {
MonochangeError::Io(format!("failed to read changeset file: {error}"))
})?;
let Some((body, changes)) = parse_changeset_for_lint(&contents) else {
continue;
};
let relative_path = path.strip_prefix(workspace_root).unwrap_or(&path);
targets.push(LintTarget::new(
workspace_root.to_path_buf(),
path.clone(),
contents,
LintTargetMetadata {
ecosystem: "changesets".to_string(),
relative_path: relative_path.to_path_buf(),
package_name: None,
package_id: None,
group_id: None,
managed: false,
private: None,
publishable: None,
},
Box::new(ChangesetLintFile { body, changes }),
));
}
Ok(targets)
}
}
fn parse_changeset_for_lint(contents: &str) -> Option<(String, Vec<RawChangeEntry>)> {
let contents = contents.replace("\r\n", "\n").replace('\r', "\n");
let without_opening = contents.strip_prefix("---")?;
let (frontmatter, body_with_separator) = without_opening.split_once("\n---\n")?;
let body = body_with_separator.trim().to_string();
let mapping: serde_yaml_ng::Mapping = serde_yaml_ng::from_str(frontmatter).ok()?;
let mut changes = Vec::new();
for (key, value) in mapping {
let package = key.as_str()?;
let (bump, change_type) = parse_simple_change_value(&value);
changes.push(RawChangeEntry {
package: package.to_string(),
bump,
version: None,
reason: None,
details: None,
change_type,
caused_by: Vec::new(),
});
}
Some((body, changes))
}
fn parse_simple_change_value(
value: &serde_yaml_ng::Value,
) -> (Option<BumpSeverity>, Option<String>) {
if let Some(token) = value.as_str().map(str::trim).filter(|s| !s.is_empty()) {
if let Some(bump) = parse_bump_severity(token) {
return (Some(bump), None);
}
return (None, Some(token.to_string()));
}
if let Some(mapping) = value.as_mapping() {
let bump = mapping
.get(serde_yaml_ng::Value::String("bump".to_string()))
.and_then(serde_yaml_ng::Value::as_str)
.and_then(parse_bump_severity);
let change_type = mapping
.get(serde_yaml_ng::Value::String("type".to_string()))
.and_then(serde_yaml_ng::Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string);
return (bump, change_type);
}
(None, None)
}
fn changeset_file<'a>(ctx: &'a LintContext<'a>) -> Option<&'a ChangesetLintFile> {
ctx.parsed_as::<ChangesetLintFile>()
}
#[derive(Debug)]
struct SummaryRule {
rule: LintRule,
}
impl SummaryRule {
fn new() -> Self {
Self {
rule: LintRule::new(
"changesets/summary",
"Changeset summary heading",
"Requires changeset body to start with a summary heading",
LintCategory::Correctness,
LintMaturity::Stable,
false,
),
}
}
}
impl LintRuleRunner for SummaryRule {
fn rule(&self) -> &LintRule {
&self.rule
}
fn run(&self, ctx: &LintContext<'_>, config: &LintRuleConfig) -> Vec<LintResult> {
let severity = config.severity();
if !severity.is_enabled() {
return Vec::new();
}
let Some(file) = changeset_file(ctx) else {
return Vec::new();
};
let required = config.bool_option("required", false);
let heading_level = config
.option("heading_level")
.and_then(serde_json::Value::as_u64)
.map(|v| v as usize);
let min_length = config
.option("min_length")
.and_then(serde_json::Value::as_u64)
.map(|v| v as usize);
let max_length = config
.option("max_length")
.and_then(serde_json::Value::as_u64)
.map(|v| v as usize);
let forbid_trailing_period = config.bool_option("forbid_trailing_period", false);
let forbid_conventional_commit_prefix =
config.bool_option("forbid_conventional_commit_prefix", false);
use crate::first_non_empty_line;
use crate::has_conventional_commit_prefix;
use crate::markdown_heading_level;
use crate::markdown_heading_text;
let mut results = Vec::new();
let body = &file.body;
let Some(first_line) = first_non_empty_line(body) else {
if required {
results.push(LintResult::new(
self.rule.id.clone(),
LintLocation::new(ctx.manifest_path, 1, 1),
"changeset body must start with a summary heading",
severity,
));
}
return results;
};
let heading = markdown_heading_level(first_line);
if required && heading.is_none() {
results.push(LintResult::new(
self.rule.id.clone(),
LintLocation::new(ctx.manifest_path, 1, 1),
"changeset body must start with a summary heading",
severity,
));
return results;
}
if let (Some(required_level), Some(actual_level)) = (heading_level, heading)
&& actual_level != required_level
{
results.push(LintResult::new(
self.rule.id.clone(),
LintLocation::new(ctx.manifest_path, 1, 1),
format!(
"changeset summary heading must use level {required_level}, found level {actual_level}"
),
severity,
));
return results;
}
let summary =
markdown_heading_text(first_line).unwrap_or_else(|| first_line.trim().to_string());
if let Some(min) = min_length
&& summary.chars().count() < min
{
results.push(LintResult::new(
self.rule.id.clone(),
LintLocation::new(ctx.manifest_path, 1, 1),
format!("changeset summary must be at least {min} characters"),
severity,
));
}
if let Some(max) = max_length
&& summary.chars().count() > max
{
results.push(LintResult::new(
self.rule.id.clone(),
LintLocation::new(ctx.manifest_path, 1, 1),
format!("changeset summary must be at most {max} characters"),
severity,
));
}
if forbid_trailing_period && summary.ends_with('.') {
results.push(LintResult::new(
self.rule.id.clone(),
LintLocation::new(ctx.manifest_path, 1, 1),
"changeset summary must not end with a period",
severity,
));
}
if forbid_conventional_commit_prefix && has_conventional_commit_prefix(&summary) {
results.push(LintResult::new(
self.rule.id.clone(),
LintLocation::new(ctx.manifest_path, 1, 1),
"changeset summary must not use a conventional-commit prefix",
severity,
));
}
results
}
}
#[derive(Debug)]
struct NoSectionHeadingsRule {
rule: LintRule,
}
impl NoSectionHeadingsRule {
fn new() -> Self {
Self {
rule: LintRule::new(
"changesets/no_section_headings",
"Changeset no section headings",
"Requires changeset body to not use change types as headings",
LintCategory::Correctness,
LintMaturity::Stable,
false,
),
}
}
}
impl LintRuleRunner for NoSectionHeadingsRule {
fn rule(&self) -> &LintRule {
&self.rule
}
fn run(&self, ctx: &LintContext<'_>, config: &LintRuleConfig) -> Vec<LintResult> {
let severity = config.severity();
if !severity.is_enabled() {
return Vec::new();
}
let Some(file) = changeset_file(ctx) else {
return Vec::new();
};
use std::collections::BTreeSet;
use crate::markdown_has_heading;
let change_types: BTreeSet<&str> = file
.changes
.iter()
.filter_map(|change| change.change_type.as_deref())
.collect();
let mut results = Vec::new();
for change_type in change_types {
if markdown_has_heading(&file.body, change_type) {
results.push(LintResult::new(
self.rule.id.clone(),
LintLocation::new(ctx.manifest_path, 1, 1),
format!("changeset type `{change_type}` must not also be used as a heading"),
severity,
));
}
}
results
}
}
#[derive(Debug)]
struct BumpScopeRule {
rule: LintRule,
bump: BumpSeverity,
}
impl BumpScopeRule {
fn new(bump: BumpSeverity) -> Self {
Self {
rule: LintRule::new(
format!("changesets/bump/{bump}"),
format!("Changeset {bump} scope"),
format!("Requires changesets with bump `{bump}` to satisfy scope rules"),
LintCategory::Correctness,
LintMaturity::Stable,
false,
),
bump,
}
}
}
impl LintRuleRunner for BumpScopeRule {
fn rule(&self) -> &LintRule {
&self.rule
}
fn run(&self, ctx: &LintContext<'_>, config: &LintRuleConfig) -> Vec<LintResult> {
let severity = config.severity();
if !severity.is_enabled() {
return Vec::new();
}
let Some(file) = changeset_file(ctx) else {
return Vec::new();
};
use crate::markdown_has_code_block;
use crate::markdown_has_heading;
let required_bump = config
.option("required_bump")
.and_then(|v| v.as_str())
.and_then(parse_bump_severity);
let required_sections = config
.string_list_option("required_sections")
.unwrap_or_default();
let forbidden_headings = config
.string_list_option("forbidden_headings")
.unwrap_or_default();
let min_body_chars = config
.option("min_body_chars")
.and_then(serde_json::Value::as_u64)
.map(|v| v as usize);
let max_body_chars = config
.option("max_body_chars")
.and_then(serde_json::Value::as_u64)
.map(|v| v as usize);
let require_code_block = config.bool_option("require_code_block", false);
let mut results = Vec::new();
for change in &file.changes {
if change.bump != Some(self.bump) {
continue;
}
if let Some(required) = required_bump
&& change.bump != Some(required)
{
let actual = change
.bump
.map_or_else(|| "auto".to_string(), |b| b.to_string());
results.push(LintResult::new(
self.rule.id.clone(),
LintLocation::new(ctx.manifest_path, 1, 1),
format!(
"changeset type `{}` requires bump `{required}`, found `{actual}`",
change.change_type.as_deref().unwrap_or("<unknown>")
),
severity,
));
}
for section in &required_sections {
if !markdown_has_heading(&file.body, section) {
results.push(LintResult::new(
self.rule.id.clone(),
LintLocation::new(ctx.manifest_path, 1, 1),
format!("changeset must include a `{section}` section"),
severity,
));
}
}
for heading in &forbidden_headings {
if markdown_has_heading(&file.body, heading) {
results.push(LintResult::new(
self.rule.id.clone(),
LintLocation::new(ctx.manifest_path, 1, 1),
format!("changeset must not use `{heading}` as a heading"),
severity,
));
}
}
if let Some(min_chars) = min_body_chars
&& file.body.trim().chars().count() < min_chars
{
results.push(LintResult::new(
self.rule.id.clone(),
LintLocation::new(ctx.manifest_path, 1, 1),
format!("changeset body must be at least {min_chars} characters"),
severity,
));
}
if let Some(max_chars) = max_body_chars
&& file.body.trim().chars().count() > max_chars
{
results.push(LintResult::new(
self.rule.id.clone(),
LintLocation::new(ctx.manifest_path, 1, 1),
format!("changeset body must be at most {max_chars} characters"),
severity,
));
}
if require_code_block && !markdown_has_code_block(&file.body) {
results.push(LintResult::new(
self.rule.id.clone(),
LintLocation::new(ctx.manifest_path, 1, 1),
"changeset must include a fenced code block",
severity,
));
}
}
results
}
}
#[allow(dead_code)]
trait LintRuleConfigExt {
fn bool_option(&self, key: &str, default: bool) -> bool;
fn string_list_option(&self, key: &str) -> Option<Vec<String>>;
}
impl LintRuleConfigExt for LintRuleConfig {
fn bool_option(&self, key: &str, default: bool) -> bool {
self.option(key)
.and_then(serde_json::Value::as_bool)
.unwrap_or(default)
}
fn string_list_option(&self, key: &str) -> Option<Vec<String>> {
self.option(key)?.as_array().map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(ToString::to_string))
.collect()
})
}
}
#[cfg(test)]
#[path = "__tests__/lints_tests.rs"]
mod tests;